@twinsearth/w2m-dsh-plugin 2.2.1 → 2.2.3
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 +27 -2
- package/README.md +3 -2
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.2.2/RELEASE-2220.json +25 -0
- package/docs/acceptance/2.2.3/RELEASE-2230.json +32 -0
- package/lib/client.js +41 -9
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +19 -6
- package/src/agent/view/capture.mjs +114 -8
- package/src/agent/view/consent.mjs +36 -0
- package/src/agent/view/frame-channel.mjs +52 -17
- package/src/agent/view/relay-target.mjs +86 -42
- package/src/plugin/remote-view-route.mjs +32 -8
- package/src/plugin/tools.mjs +1 -1
|
@@ -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.2.
|
|
8
|
+
"version": "2.2.3"
|
|
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.2.
|
|
15
|
+
"version": "2.2.3",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "2.2.
|
|
29
|
+
"version": "2.2.3"
|
|
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.2.
|
|
4
|
+
"version": "2.2.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -5,8 +5,33 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
7
|
- `w2m_intent`: structured intents mapped onto allow-listed commands; unknown actions, parameters and types
|
|
8
|
-
are refused by name (the fleet does not invent commands).
|
|
9
|
-
|
|
8
|
+
are refused by name (the fleet does not invent commands).
|
|
9
|
+
|
|
10
|
+
## [2.2.3] - 2026-11-05
|
|
11
|
+
|
|
12
|
+
- Remote desktop (view-only) now reaches 1 frame per second in a fleet. A machine opts in by
|
|
13
|
+
enabling 远程桌面 in its own settings; the target agent reads that file before consuming a
|
|
14
|
+
request and before every frame, so enabling or disabling takes effect without an agent restart.
|
|
15
|
+
- The target sends repeated frames instead of one: P2P renews the request from the viewer, and the
|
|
16
|
+
relay target runs a bounded encrypted-frame stream while the viewer renews its request. The viewer
|
|
17
|
+
fetches the next relay frame on every 1 Hz poll and re-derives the key when a renewed stream uses a
|
|
18
|
+
new target ephemeral key.
|
|
19
|
+
- Windows capture uses a once-compiled .NET helper (with the PowerShell path as fallback) so a frame
|
|
20
|
+
costs hundreds of milliseconds rather than a PowerShell startup; the frame scheduler compensates
|
|
21
|
+
for capture time to keep the one-second cadence.
|
|
22
|
+
- Tests: target consent (including the localside parent directory), relay streaming at the configured
|
|
23
|
+
interval, P2P frame streaming and stop, viewer relay frame refresh, and a 1 Hz viewer route probe.
|
|
24
|
+
|
|
25
|
+
## [2.2.2] - 2026-11-05
|
|
26
|
+
|
|
27
|
+
- DSH 0.2.2-rc.1 compatibility: composer popups (the dispatch-target menu and the Node-execution menu)
|
|
28
|
+
now render through a `react-dom` portal under `document.body`. The release candidate puts
|
|
29
|
+
`container-type: inline-size` on the composer row, which makes that row the containing block for
|
|
30
|
+
`position: fixed` descendants; the Node-execution menu was positioned relative to the row and its
|
|
31
|
+
options could not be selected. A portal keeps the viewport coordinates the menus compute.
|
|
32
|
+
- Loader tests: the bundle-fetch helper sends a Fetch `Request` as 0.2.2-rc.1 requires, the real-page
|
|
33
|
+
probe seeds a Workspace, opens the Node-execution menu and selects temporary execution, and the
|
|
34
|
+
host route probe confirms `GET/POST /api/w2m/capability` is mounted.
|
|
10
35
|
## [2.2.1] - 2026-11-01
|
|
11
36
|
|
|
12
37
|
- Fleet identity is now per-account **home**-first. Each machine records the fleet key it actually joined under `fleet-home.json` beside `device.json`; switching back to an account returns to that account’s previous fleet instead of deriving a new one. This is the “whoever leaves returns by themselves” rule: a first appearance of account B may join B’s account fleet, but an existing home for A is never rewritten by B, and no other machine is moved.
|
package/README.md
CHANGED
|
@@ -37,7 +37,8 @@ English | [中文说明](#中文说明)
|
|
|
37
37
|
### 功能 6 · 全局文件夹读写黑白名单:保护系统盘 & 个人/工作文件夹
|
|
38
38
|
DSH 默认的权限模型是“限制 or 全开”,一旦放开就难以精细控制。本插件在官方沙箱之上追加全局文件夹读写黑白名单,为系统盘(`C:\Windows`、`Program Files`、`/etc`、`/usr` 等)和个人/工作文件夹建立明确的读/写边界,避免 AI 或跨机任务误触不该动的路径。这一设计来自实际重装系统后的教训:粗粒度的“全开”代价太高,黑白名单把“绝对不允许”与“经常要放行”的路径变成可配置的硬规则。v2.0.4 起提供两种策略:**小白模式(默认)**只保留黑名单强制拒绝(内置 Windows/Program Files 等默认保护文件夹,路径操作每 3 秒最多自动放行一次),其余全部放行;**高级模式**需手动切换,保留白名单预算、待审批队列与 10 秒无人应答自动放行。
|
|
39
39
|
|
|
40
|
-
### 功能 7
|
|
40
|
+
### 功能 7 · 远程桌面(仅查看,1 帧/秒)
|
|
41
|
+
在设置里打开「远程桌面」后,可在远程桌面页选择在线目标并连接。目标机也必须打开同一开关,表示同意被编队内其他机器查看;只传输画面,不发送鼠标/键盘输入。目标以 1 帧/秒采集并加密:优先 P2P 直连,P2P 不通时走 relay 的加密帧通道(relay 只看到密文,看不到 JPEG)。查看端每秒拉取最新一帧,因此最低可用帧率是 1 帧/秒;网络和采集耗时会决定实际帧率。目标关闭开关后,下一次采集立即停止。
|
|
41
42
|
|
|
42
43
|
## 安装
|
|
43
44
|
|
|
@@ -92,7 +93,7 @@ w2m-mcp --help
|
|
|
92
93
|
# 复制进你自己的 codex/mcp-servers.toml
|
|
93
94
|
[mcp_servers.w2m]
|
|
94
95
|
command = "npx"
|
|
95
|
-
args = ["-y", "@twinsearth/w2m-dsh-plugin@2.2.
|
|
96
|
+
args = ["-y", "@twinsearth/w2m-dsh-plugin@2.2.3", "w2m-mcp"]
|
|
96
97
|
```
|
|
97
98
|
|
|
98
99
|
### 5. 验证过的 release tarball(离线)
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.2.2",
|
|
3
|
+
"case": "RELEASE-2220",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-11-04T20:16:04.717Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-2220-1793823364724",
|
|
11
|
+
"requirement": "v2.2.2: DSH 0.2.2-rc.1 users can open the Node-execution menu and select temporary execution from the composer",
|
|
12
|
+
"commands": [
|
|
13
|
+
["node", "--test", "--test-force-exit", "test/client-bundle.test.mjs"],
|
|
14
|
+
["node", "--test", "--test-force-exit", "test/loader-entry.test.mjs"]
|
|
15
|
+
],
|
|
16
|
+
"result": {
|
|
17
|
+
"outcome": "ok",
|
|
18
|
+
"assertions": {
|
|
19
|
+
"client": "test/client-bundle.test.mjs 36/36: popup menus portal to document.body when react-dom is available and fall back inline without it",
|
|
20
|
+
"loader": "test/loader-entry.test.mjs 10/10 against the packed 2.2.2 tarball in the real DSH 0.2.2-rc.1 Loader and its own Edge page; the route probe finds /api/w2m/capability and W2M_CAPABILITY_PROBE=1 opens the Node-execution menu and selects temporary execution",
|
|
21
|
+
"dsh": "the installed runtime is @deepseek-ai/dsh-desktop-runtime 0.2.2-rc.1 (resources/app.asar), not the 0.2.1-rc.5 profile pins",
|
|
22
|
+
"scope": "local code and packed artifact only; no installed profile was edited by the tests; the release is published separately"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.2.3",
|
|
3
|
+
"case": "RELEASE-2230",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-11-05T02:16:57.551Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-2230-1793845017571",
|
|
11
|
+
"requirement": "v2.2.3: a fleet target that enables remote desktop serves repeated encrypted view frames to an online viewer at a one-second cadence, over P2P when available and the encrypted relay frame channel otherwise",
|
|
12
|
+
"commands": [
|
|
13
|
+
["node", "--test", "--test-force-exit", "test/view-capture.test.mjs", "test/view-frame-channel.test.mjs", "test/view-consent.test.mjs"],
|
|
14
|
+
["node", "--test", "--test-force-exit", "test/relay-target.test.mjs", "test/remote-view-route.test.mjs"],
|
|
15
|
+
["node", "--test", "--test-force-exit", "test/client-bundle.test.mjs"],
|
|
16
|
+
["node", "--test", "--test-force-exit", "test/loader-entry.test.mjs"]
|
|
17
|
+
],
|
|
18
|
+
"result": {
|
|
19
|
+
"outcome": "ok",
|
|
20
|
+
"assertions": {
|
|
21
|
+
"capture": "test/view-capture.test.mjs 4/4: unsupported platform, macOS capture, spawnSync status, and the transport size refusal",
|
|
22
|
+
"p2p": "test/view-frame-channel.test.mjs 4/4: consent denial, handshake, repeated frames at the configured interval, and input-frame refusal",
|
|
23
|
+
"consent": "test/view-consent.test.mjs 4/4: default deny, local enable, localside/ parent fallback, and W2M_VIEW_ALLOW overrides",
|
|
24
|
+
"relay": "test/relay-target.test.mjs 3/3: first sealed frame, repeated frames at the interval, and no request consumption while consent is off",
|
|
25
|
+
"viewer": "test/remote-view-route.test.mjs 3/3: P2P session, P2P refusal, and a fresh relay frame on each 1 Hz poll",
|
|
26
|
+
"client": "test/client-bundle.test.mjs 36/36: the client bundle still loads with the remote-view settings and page copy",
|
|
27
|
+
"loader": "test/loader-entry.test.mjs 10/10 against the packed 2.2.3 tarball in the real DSH 0.2.2-rc.1 Loader and Edge page",
|
|
28
|
+
"capture_timing": "local Windows real-capture relay stream produced 3 frames at 1.067 s, 2.087 s and 3.123 s (1.02-1.06 s cadence); the fast helper itself measured 0.23-0.40 s after compilation",
|
|
29
|
+
"scope": "local code and tests only; no installed profile was edited by the tests; release is published separately"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -66,6 +66,37 @@ window.__ModuleLoader__.load({
|
|
|
66
66
|
|
|
67
67
|
const React = require('react');
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Render a popup under `document.body`.
|
|
71
|
+
*
|
|
72
|
+
* DSH 0.2.2-rc.1 puts `container-type: inline-size` on the composer row. Layout containment makes
|
|
73
|
+
* that row the containing block for `position: fixed` descendants, so a menu rendered inside the
|
|
74
|
+
* composer slot is positioned relative to the row (and can be clipped) instead of the viewport.
|
|
75
|
+
* A portal keeps the viewport coordinates the menus already compute. `react-dom` is a platform
|
|
76
|
+
* seed in the Web shell; the lazy require keeps this bundle loadable where only React is present,
|
|
77
|
+
* and the inline fallback preserves the old behavior if the portal cannot be created.
|
|
78
|
+
*
|
|
79
|
+
* @param {object|null} node - menu element.
|
|
80
|
+
* @returns {object|null} portal element, or the original node.
|
|
81
|
+
*/
|
|
82
|
+
let portalRuntime;
|
|
83
|
+
let portalUnavailable = false;
|
|
84
|
+
function portalNode(node) {
|
|
85
|
+
if (node === null || portalUnavailable) return node;
|
|
86
|
+
if (typeof document === 'undefined' || document.body === null || document.body === undefined) return node;
|
|
87
|
+
try {
|
|
88
|
+
if (portalRuntime === undefined) portalRuntime = require('react-dom');
|
|
89
|
+
if (typeof portalRuntime?.createPortal !== 'function') {
|
|
90
|
+
portalUnavailable = true;
|
|
91
|
+
return node;
|
|
92
|
+
}
|
|
93
|
+
return portalRuntime.createPortal(node, document.body);
|
|
94
|
+
} catch {
|
|
95
|
+
portalUnavailable = true;
|
|
96
|
+
return node;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
69
100
|
// -------------------------------------------------------------------------------------------
|
|
70
101
|
// Wire
|
|
71
102
|
// -------------------------------------------------------------------------------------------
|
|
@@ -2285,7 +2316,7 @@ function UpdateSettingsSection() {
|
|
|
2285
2316
|
};
|
|
2286
2317
|
|
|
2287
2318
|
const menu = open && placement !== null
|
|
2288
|
-
? h('div', {
|
|
2319
|
+
? portalNode(h('div', {
|
|
2289
2320
|
className: cls('menu'),
|
|
2290
2321
|
role: 'listbox',
|
|
2291
2322
|
'aria-label': t('targetLabel', { name: label }),
|
|
@@ -2381,7 +2412,7 @@ function UpdateSettingsSection() {
|
|
|
2381
2412
|
? h('div', { key: 'hint', className: cls('relayHint') }, t('relayFold', { count: relayFold.hidden_count }))
|
|
2382
2413
|
: null,
|
|
2383
2414
|
]),
|
|
2384
|
-
])
|
|
2415
|
+
]))
|
|
2385
2416
|
: null;
|
|
2386
2417
|
|
|
2387
2418
|
return h(React.Fragment, null, [
|
|
@@ -2595,8 +2626,8 @@ function UpdateSettingsSection() {
|
|
|
2595
2626
|
h('span', { key: 'text' }, enabled ? '已开启' : '已关闭'),
|
|
2596
2627
|
]),
|
|
2597
2628
|
h('div', { key: 'hint', style: { fontSize: '12px', opacity: 0.75, lineHeight: 1.5 } },
|
|
2598
|
-
'
|
|
2599
|
-
h('div', { key: 'status', style: { fontSize: '12px', opacity: 0.6 } }, '
|
|
2629
|
+
'开启后可查看其他机器;本机也会允许编队内其他机器查看,仅画面,不发送鼠标/键盘输入。目标帧率为 1 帧/秒;实际帧率取决于目标机性能与网络。媒体优先 P2P,P2P 不通时走加密 relay 帧通道。'),
|
|
2630
|
+
h('div', { key: 'status', style: { fontSize: '12px', opacity: 0.6 } }, '要查看某台机器,那台机器也需要开启此开关。选择目标后点「连接」;远端画面每秒刷新一次。'),
|
|
2600
2631
|
]);
|
|
2601
2632
|
}
|
|
2602
2633
|
|
|
@@ -2662,7 +2693,7 @@ function UpdateSettingsSection() {
|
|
|
2662
2693
|
const ready = enabled && frame?.data_base64;
|
|
2663
2694
|
return h('div', { style: { padding: '16px', display: 'grid', gap: '12px', maxWidth: '1100px' } }, [
|
|
2664
2695
|
h('div', { key: 'title', style: { fontSize: '16px', fontWeight: 600 } }, '远程桌面 · 仅查看'),
|
|
2665
|
-
h('div', { key: 'state', style: { fontSize: '13px', opacity: 0.8 } }, enabled ? (session ? ('
|
|
2696
|
+
h('div', { key: 'state', style: { fontSize: '13px', opacity: 0.8 } }, enabled ? (session ? ('远程会话:' + (session.machine_id ?? '') + (session.encrypted ? '(加密)' : '(未加密)') + ' · ' + (session.channel === 'relay' ? '加密 relay' : 'P2P')) : '未连接目标') : '插件设置中尚未开启远程桌面。'),
|
|
2666
2697
|
h('div', { key: 'controls', style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } }, [
|
|
2667
2698
|
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)))),
|
|
2668
2699
|
h('button', { key: 'connect', type: 'button', disabled: !enabled || target === '' || session !== null, onClick: connect }, '连接'),
|
|
@@ -2671,10 +2702,10 @@ function UpdateSettingsSection() {
|
|
|
2671
2702
|
]),
|
|
2672
2703
|
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
|
|
2673
2704
|
? h('img', { src: 'data:' + (frame.mime ?? 'image/jpeg') + ';base64,' + frame.data_base64, alt: '远程桌面画面', style: { maxWidth: '100%', maxHeight: '70vh', display: 'block' } })
|
|
2674
|
-
: h('span', { style: { opacity: 0.6, fontSize: '13px' } }, !enabled ? '尚未开启' : (error ? ('查看失败:' + error) : (session ? '等待远端帧…' : '
|
|
2705
|
+
: h('span', { style: { opacity: 0.6, fontSize: '13px' } }, !enabled ? '尚未开启' : (error ? ('查看失败:' + error) : (session ? '等待远端帧…' : '选择在线目标后点「连接」')))),
|
|
2675
2706
|
h('div', { key: 'statusline', style: { fontSize: '12px', opacity: 0.7, display: 'flex', gap: '14px', flexWrap: 'wrap' } }, [
|
|
2676
2707
|
h('span', { key: 'fps' }, '1 fps 上限'),
|
|
2677
|
-
h('span', { key: 'channel' }, '通道:' + (session ? (session.channel === 'relay' ? '加密中继' : 'P2P') : '
|
|
2708
|
+
h('span', { key: 'channel' }, '通道:' + (session ? (session.channel === 'relay' ? '加密中继' : 'P2P') : '未连接')),
|
|
2678
2709
|
h('span', { key: 'cap' }, '采集:' + (stats?.capture_ms ?? '—') + 'ms'),
|
|
2679
2710
|
h('span', { key: 'bytes' }, '帧:' + (stats?.bytes ?? '—') + 'B'),
|
|
2680
2711
|
h('span', { key: 'age' }, '最近帧:' + (stats?.at ?? '—')),
|
|
@@ -3106,7 +3137,7 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
|
|
|
3106
3137
|
};
|
|
3107
3138
|
|
|
3108
3139
|
const menu = open && placement !== null
|
|
3109
|
-
? h('div', {
|
|
3140
|
+
? portalNode(h('div', {
|
|
3110
3141
|
className: 'w2mCap_menu',
|
|
3111
3142
|
role: 'menu',
|
|
3112
3143
|
'aria-label': t('capMenuLabel'),
|
|
@@ -3118,7 +3149,7 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
|
|
|
3118
3149
|
h('button', { key: 'temp', type: 'button', className: 'w2mCap_option', 'data-active': capability?.state === 'temporary' ? 'true' : 'false', onClick: () => choose('temporary', 24 * 60 * 60 * 1000) }, t('capTemporary')),
|
|
3119
3150
|
h('button', { key: 'perm', type: 'button', className: 'w2mCap_option', 'data-active': capability?.state === 'permanent' ? 'true' : 'false', onClick: () => choose('permanent') }, t('capPermanent')),
|
|
3120
3151
|
error === null ? null : h('div', { key: 'err', className: 'w2mCap_note', role: 'status' }, error),
|
|
3121
|
-
])
|
|
3152
|
+
]))
|
|
3122
3153
|
: null;
|
|
3123
3154
|
|
|
3124
3155
|
return h(React.Fragment, null, [
|
|
@@ -3449,6 +3480,7 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
|
|
|
3449
3480
|
exports.FleetChat = FleetChat;
|
|
3450
3481
|
exports.injectStyle = injectStyle;
|
|
3451
3482
|
exports.makeTranslator = makeTranslator;
|
|
3483
|
+
exports.portalNode = portalNode;
|
|
3452
3484
|
return module.exports;
|
|
3453
3485
|
},
|
|
3454
3486
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
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": {
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"pack": "node scripts/pack.mjs",
|
|
72
72
|
"reachability": "node scripts/reachability-check.mjs",
|
|
73
73
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
74
|
-
"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/fleet-home.test.mjs test/fleet-config.test.mjs test/fleet-config-route.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 test/view-session-key.test.mjs test/remote-requests.test.mjs test/relay-target.test.mjs",
|
|
74
|
+
"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/fleet-home.test.mjs test/fleet-config.test.mjs test/fleet-config-route.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/view-consent.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs test/view-session-key.test.mjs test/remote-requests.test.mjs test/relay-target.test.mjs",
|
|
75
75
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
76
76
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
77
77
|
"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.2.
|
|
4
|
+
"version": "2.2.3",
|
|
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
|
@@ -73,6 +73,7 @@ import { createXferFrameRouter } from './transfer/router.mjs';
|
|
|
73
73
|
import { captureScreen } from './view/capture.mjs';
|
|
74
74
|
import { createViewTarget } from './view/frame-channel.mjs';
|
|
75
75
|
import { startRelayViewTarget } from './view/relay-target.mjs';
|
|
76
|
+
import { viewAllowed } from './view/consent.mjs';
|
|
76
77
|
import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
77
78
|
import { punchSession, reverseDialSession } from './p2p.mjs';
|
|
78
79
|
import { createSigner } from '../signing.mjs';
|
|
@@ -1149,6 +1150,7 @@ export function createAgent(options) {
|
|
|
1149
1150
|
/** P0-a: xfer.* frames arriving on a P2P channel are routed here, not to task dispatch. */
|
|
1150
1151
|
const xferRouter = createXferFrameRouter();
|
|
1151
1152
|
let viewTargets = null;
|
|
1153
|
+
let viewRelayRunner = null;
|
|
1152
1154
|
/**
|
|
1153
1155
|
* What each task's direct channel actually was, for the result envelope.
|
|
1154
1156
|
*
|
|
@@ -3978,8 +3980,8 @@ let attemptAttached = false;
|
|
|
3978
3980
|
let target = viewTargets.get(key);
|
|
3979
3981
|
if (target === undefined || payload.type === 'view.hello') {
|
|
3980
3982
|
target = createViewTarget({
|
|
3981
|
-
capture: () => captureScreen({}),
|
|
3982
|
-
allow:
|
|
3983
|
+
capture: () => captureScreen({ fast: true }),
|
|
3984
|
+
allow: () => viewAllowed(stateDir),
|
|
3983
3985
|
send: (frame) => { try { event.channel.send(frame); } catch { /* peer gone */ } },
|
|
3984
3986
|
});
|
|
3985
3987
|
viewTargets.set(key, target);
|
|
@@ -4434,6 +4436,10 @@ let attemptAttached = false;
|
|
|
4434
4436
|
if (idleHeartbeatTimer) clearInterval(idleHeartbeatTimer);
|
|
4435
4437
|
idleHeartbeatTimer = null;
|
|
4436
4438
|
heartbeat?.stop();
|
|
4439
|
+
viewRelayRunner?.stop();
|
|
4440
|
+
viewRelayRunner = null;
|
|
4441
|
+
for (const target of viewTargets?.values() ?? []) target?.stop?.();
|
|
4442
|
+
viewTargets = null;
|
|
4437
4443
|
runAbort?.abort();
|
|
4438
4444
|
streamAbort?.abort();
|
|
4439
4445
|
// Final snapshot: a reader must be able to tell "stopped" from "crashed
|
|
@@ -4470,10 +4476,17 @@ let attemptAttached = false;
|
|
|
4470
4476
|
// the first heartbeat: "is an agent running here?" is the first question a
|
|
4471
4477
|
// remote operator asks (task-16).
|
|
4472
4478
|
publishState();
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4479
|
+
// The poller always runs; the consent file is checked before a request is consumed and before
|
|
4480
|
+
// every frame, so enabling remote viewing in the settings page takes effect without an agent
|
|
4481
|
+
// restart. A disabled machine never consumes a viewer request.
|
|
4482
|
+
viewRelayRunner = startRelayViewTarget({
|
|
4483
|
+
rabbitUrl: baseUrl,
|
|
4484
|
+
token: () => identity.device_token,
|
|
4485
|
+
machineId: identity.machine_id,
|
|
4486
|
+
capture: (options) => captureScreen({ ...(options ?? {}), fast: true }),
|
|
4487
|
+
allow: () => viewAllowed(stateDir),
|
|
4488
|
+
log,
|
|
4489
|
+
});
|
|
4477
4490
|
if (statePublishIntervalMs > 0) {
|
|
4478
4491
|
statePublishTimer = setInterval(() => publishState(), statePublishIntervalMs);
|
|
4479
4492
|
// Never keep the process alive just to refresh a diagnostics file.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* without a reason.
|
|
7
7
|
*/
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
|
-
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
9
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
|
|
@@ -59,7 +59,93 @@ function windowsCaptureScript(maxBytes) {
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
62
|
+
* A tiny compiled Windows helper for 1 fps capture.
|
|
63
|
+
*
|
|
64
|
+
* Starting PowerShell costs 1.5-3 s per frame on this host, so a 1 fps stream
|
|
65
|
+
* written as "spawn PowerShell every second" is not 1 fps. The helper is compiled
|
|
66
|
+
* once with the in-box .NET Framework compiler and then invoked like any small
|
|
67
|
+
* executable; the fallback keeps working on machines without that compiler.
|
|
68
|
+
*/
|
|
69
|
+
const WINDOWS_HELPER_SOURCE = `using System;
|
|
70
|
+
using System.Drawing;
|
|
71
|
+
using System.Drawing.Imaging;
|
|
72
|
+
using System.IO;
|
|
73
|
+
using System.Windows.Forms;
|
|
74
|
+
class W2MCapture {
|
|
75
|
+
static void Main(string[] args) {
|
|
76
|
+
int max = int.Parse(args[1]);
|
|
77
|
+
var b = Screen.PrimaryScreen.Bounds;
|
|
78
|
+
using (var src = new Bitmap(b.Width, b.Height)) {
|
|
79
|
+
using (var g = Graphics.FromImage(src)) g.CopyFromScreen(b.Left, b.Top, 0, 0, src.Size);
|
|
80
|
+
int[] widths = new int[] { 960, 800, 640 };
|
|
81
|
+
var codec = Array.Find(ImageCodecInfo.GetImageEncoders(), c => c.MimeType == "image/jpeg");
|
|
82
|
+
foreach (int w in widths) {
|
|
83
|
+
int h = Math.Max(1, (int)Math.Round(b.Height * (double)w / b.Width));
|
|
84
|
+
using (var dst = new Bitmap(w, h)) {
|
|
85
|
+
using (var dg = Graphics.FromImage(dst)) {
|
|
86
|
+
dg.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
|
|
87
|
+
dg.DrawImage(src, 0, 0, w, h);
|
|
88
|
+
}
|
|
89
|
+
foreach (int q in new int[] { 40, 30 }) {
|
|
90
|
+
using (var ms = new MemoryStream()) {
|
|
91
|
+
var ep = new EncoderParameters(1);
|
|
92
|
+
ep.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, (long)q);
|
|
93
|
+
dst.Save(ms, codec, ep);
|
|
94
|
+
var bytes = ms.ToArray();
|
|
95
|
+
if (bytes.Length <= max) {
|
|
96
|
+
File.WriteAllBytes(args[0], bytes);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}`;
|
|
106
|
+
|
|
107
|
+
let windowsHelperState;
|
|
108
|
+
|
|
109
|
+
/** Resolve, compiling once, the fast Windows capture helper. */
|
|
110
|
+
function windowsFastHelper() {
|
|
111
|
+
if (windowsHelperState !== undefined) return windowsHelperState;
|
|
112
|
+
try {
|
|
113
|
+
const dir = join(tmpdir(), 'w2m-view-fast');
|
|
114
|
+
mkdirSync(dir, { recursive: true });
|
|
115
|
+
const exe = join(dir, 'w2m-capture-v1.exe');
|
|
116
|
+
if (existsSync(exe)) { windowsHelperState = exe; return exe; }
|
|
117
|
+
const source = join(dir, 'w2m-capture-v1.cs');
|
|
118
|
+
writeFileSync(source, WINDOWS_HELPER_SOURCE, 'utf8');
|
|
119
|
+
const cscCandidates = [
|
|
120
|
+
join(process.env.WINDIR ?? 'C:\\Windows', 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
|
|
121
|
+
join(process.env.WINDIR ?? 'C:\\Windows', 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe'),
|
|
122
|
+
];
|
|
123
|
+
const csc = cscCandidates.find(candidate => existsSync(candidate));
|
|
124
|
+
if (csc !== undefined) {
|
|
125
|
+
spawnSync(csc, [
|
|
126
|
+
'/nologo', '/target:exe', `/out:${exe}`,
|
|
127
|
+
'/r:System.Drawing.dll', '/r:System.Windows.Forms.dll', source,
|
|
128
|
+
], { timeout: 60_000, windowsHide: true, encoding: 'utf8' });
|
|
129
|
+
}
|
|
130
|
+
if (!existsSync(exe)) {
|
|
131
|
+
// Some hosts deny csc.exe while allowing PowerShell. Add-Type compiles the same source
|
|
132
|
+
// once and writes the executable, after which every frame is a small process spawn.
|
|
133
|
+
const psQuote = value => `'${value.replace(/'/g, "''")}'`;
|
|
134
|
+
const command = `Add-Type -Path ${psQuote(source)} -OutputAssembly ${psQuote(exe)} -OutputType ConsoleApplication -ReferencedAssemblies System.Drawing.dll,System.Windows.Forms.dll`;
|
|
135
|
+
spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], {
|
|
136
|
+
timeout: 60_000, windowsHide: true, encoding: 'utf8',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
windowsHelperState = existsSync(exe) ? exe : null;
|
|
140
|
+
return windowsHelperState;
|
|
141
|
+
} catch {
|
|
142
|
+
windowsHelperState = null;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {{platform?: NodeJS.Platform, run?: Function, tempRoot?: string, maxBytes?: number, fast?: boolean}} [options]
|
|
63
149
|
* @returns {{ok: true, code: string, mime: string, data: Buffer, base64: string, bytes: number, capture_ms: number, width: number|null, height: number|null}
|
|
64
150
|
* | {ok: false, code: string, error: string, capture_ms: number}}
|
|
65
151
|
*/
|
|
@@ -73,12 +159,32 @@ export function captureScreen(options = {}) {
|
|
|
73
159
|
let width = null;
|
|
74
160
|
let height = null;
|
|
75
161
|
if (platform === 'win32') {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
162
|
+
if (options.fast === true) {
|
|
163
|
+
const helper = windowsFastHelper();
|
|
164
|
+
if (helper !== null) {
|
|
165
|
+
const dir = mkdtempSync(join(options.tempRoot ?? tmpdir(), 'w2m-view-fast-'));
|
|
166
|
+
const file = join(dir, 'frame.jpg');
|
|
167
|
+
try {
|
|
168
|
+
const result = run([helper, file, String(maxBytes)], { timeoutMs: 5000, encoding: 'utf8' });
|
|
169
|
+
if (result.status === 0 && existsSync(file)) {
|
|
170
|
+
const data = readFileSync(file);
|
|
171
|
+
if (data.length >= 1000) base64 = data.toString('base64');
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
// Fall through to the PowerShell capture path.
|
|
175
|
+
} finally {
|
|
176
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (base64 === null) {
|
|
181
|
+
const result = run(['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', windowsCaptureScript(maxBytes)], { timeoutMs: 20000 });
|
|
182
|
+
// `spawnSync` reports the exit code as `status`; `code` is the execFile-shaped field
|
|
183
|
+
// and is undefined here, which made every successful capture look like a denial.
|
|
184
|
+
if (result.status !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `PowerShell capture failed (exit ${result.status ?? 'null'}${result.signal ? ', signal ' + result.signal : ''}${result.error?.message ? ', ' + result.error.message : ''})`, capture_ms: Date.now() - started };
|
|
185
|
+
base64 = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
|
|
186
|
+
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 };
|
|
187
|
+
}
|
|
82
188
|
} else if (platform === 'darwin') {
|
|
83
189
|
const dir = mkdtempSync(join(options.tempRoot ?? tmpdir(), 'w2m-view-'));
|
|
84
190
|
const file = join(dir, 'frame.jpg');
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target-side remote-view consent.
|
|
3
|
+
*
|
|
4
|
+
* Turning on "远程桌面" on a machine is an explicit statement that the machine may be viewed by
|
|
5
|
+
* the fleet; `W2M_VIEW_ALLOW=0` remains an operator override that denies even an enabled file.
|
|
6
|
+
* The file is written by the host settings route under the same state directory the agent uses.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** The per-machine settings file shared with `src/plugin/remote-view-route.mjs`. */
|
|
12
|
+
export const REMOTE_VIEW_CONFIG_FILE = 'remote-view.json';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether this machine may serve view frames.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} stateDir - agent state directory.
|
|
18
|
+
* @param {NodeJS.ProcessEnv} [env] - process environment for the operator override.
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function viewAllowed(stateDir, env = process.env) {
|
|
22
|
+
if (env.W2M_VIEW_ALLOW === '0') return false;
|
|
23
|
+
if (env.W2M_VIEW_ALLOW === '1') return true;
|
|
24
|
+
// The agent's `--state` is the `localside/` child of the plugin's state directory, while the
|
|
25
|
+
// settings route writes beside `device.json` one level up. Check the agent directory first (an
|
|
26
|
+
// explicit deployment may keep the file there), then the plugin root.
|
|
27
|
+
for (const file of [join(stateDir, REMOTE_VIEW_CONFIG_FILE), join(dirname(stateDir), REMOTE_VIEW_CONFIG_FILE)]) {
|
|
28
|
+
try {
|
|
29
|
+
const value = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
30
|
+
if (value?.enabled === true || value?.allow === true) return true;
|
|
31
|
+
} catch {
|
|
32
|
+
// Try the next location.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
@@ -48,13 +48,44 @@ export function parseViewFrame(payload) {
|
|
|
48
48
|
return frame;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** @param {{capture:Function, allow?:boolean, send:Function, maxBytes?:number, fps?:number}} deps */
|
|
52
|
-
export function createViewTarget({ capture, allow = false, send, maxBytes = 96 * 1024, fps = 1 } = {}) {
|
|
51
|
+
/** @param {{capture:Function, allow?:boolean|Function, send:Function, maxBytes?:number, fps?:number, intervalMs?:number, maxFrames?:number}} deps */
|
|
52
|
+
export function createViewTarget({ capture, allow = false, send, maxBytes = 96 * 1024, fps = 1, intervalMs, maxFrames = 300 } = {}) {
|
|
53
53
|
if (typeof capture !== 'function' || typeof send !== 'function') return { ok: false, code: VIEW_CODE.BAD_FRAME };
|
|
54
54
|
let seq = 0;
|
|
55
|
+
let frames = 0;
|
|
55
56
|
let stopped = false;
|
|
56
57
|
let vk = null;
|
|
58
|
+
let timer = null;
|
|
59
|
+
let sessionId = null;
|
|
60
|
+
const delay = Number.isInteger(intervalMs) && intervalMs > 0 ? intervalMs : Math.max(100, Math.round(1000 / Math.max(1, fps)));
|
|
61
|
+
const allowed = () => (typeof allow === 'function' ? allow() === true : allow === true);
|
|
57
62
|
const reply = (frame) => { try { send(JSON.stringify(frame)); } catch { /* channel gone */ } };
|
|
63
|
+
const stopTimer = () => { if (timer !== null) clearTimeout(timer); timer = null; };
|
|
64
|
+
const schedule = (waitMs) => {
|
|
65
|
+
if (stopped || vk === null) return;
|
|
66
|
+
timer = setTimeout(() => { timer = null; tick(); }, Math.max(0, waitMs));
|
|
67
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
68
|
+
};
|
|
69
|
+
const fail = (code) => {
|
|
70
|
+
stopped = true;
|
|
71
|
+
stopTimer();
|
|
72
|
+
reply({ type: VIEW_FRAME.ERROR, code, session_id: sessionId });
|
|
73
|
+
};
|
|
74
|
+
const tick = () => {
|
|
75
|
+
if (stopped || vk === null || sessionId === null) { stopTimer(); return; }
|
|
76
|
+
if (!allowed()) { fail(VIEW_CODE.CONSENT_REQUIRED); return; }
|
|
77
|
+
if (maxFrames > 0 && frames >= maxFrames) { stopTimer(); return; }
|
|
78
|
+
const started = Date.now();
|
|
79
|
+
const result = capture({ maxBytes });
|
|
80
|
+
if (result?.ok !== true) { fail(result?.code ?? VIEW_CODE.CAPTURE_FAILED); return; }
|
|
81
|
+
let ct;
|
|
82
|
+
try { ct = sealViewFrame(vk, result.data ?? Buffer.from(result.base64 ?? '', 'base64')); }
|
|
83
|
+
catch { fail(VIEW_CODE.VK_BAD); return; }
|
|
84
|
+
seq += 1;
|
|
85
|
+
frames += 1;
|
|
86
|
+
reply({ type: VIEW_FRAME.FRAME, session_id: sessionId, seq, ts_ms: Date.now(), mime: result.mime ?? 'image/jpeg', bytes: result.bytes, enc: 'aes-256-gcm', ct });
|
|
87
|
+
if (maxFrames <= 0 || frames < maxFrames) schedule(delay - (Date.now() - started));
|
|
88
|
+
};
|
|
58
89
|
return {
|
|
59
90
|
ok: true,
|
|
60
91
|
handle(payload) {
|
|
@@ -65,30 +96,34 @@ export function createViewTarget({ capture, allow = false, send, maxBytes = 96 *
|
|
|
65
96
|
}
|
|
66
97
|
const frame = parseViewFrame(payload);
|
|
67
98
|
if (frame === null) return false;
|
|
68
|
-
if (frame.type === VIEW_FRAME.STOP) { stopped = true; return true; }
|
|
99
|
+
if (frame.type === VIEW_FRAME.STOP) { stopped = true; stopTimer(); return true; }
|
|
69
100
|
if (frame.type === VIEW_FRAME.HELLO) {
|
|
70
|
-
|
|
101
|
+
stopTimer();
|
|
102
|
+
stopped = false;
|
|
103
|
+
frames = 0;
|
|
104
|
+
sessionId = typeof frame.session_id === 'string' ? frame.session_id : null;
|
|
105
|
+
if (typeof frame.public_key !== 'string' || frame.public_key === '') { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.VK_BAD, session_id: sessionId }); return true; }
|
|
71
106
|
try {
|
|
72
107
|
const side = createViewSide();
|
|
73
108
|
vk = side.finish(frame.public_key).key;
|
|
74
|
-
reply({ type: VIEW_FRAME.HELLO_ACK, session_id:
|
|
75
|
-
} catch { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.VK_BAD, session_id:
|
|
109
|
+
reply({ type: VIEW_FRAME.HELLO_ACK, session_id: sessionId, public_key: side.public_key });
|
|
110
|
+
} catch { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.VK_BAD, session_id: sessionId }); }
|
|
76
111
|
return true;
|
|
77
112
|
}
|
|
78
113
|
if (frame.type !== VIEW_FRAME.REQUEST) return true;
|
|
79
|
-
if (
|
|
80
|
-
if (stopped) {
|
|
81
|
-
if (vk === null) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
reply({ type: VIEW_FRAME.FRAME, session_id: frame.session_id ?? null, seq, ts_ms: Date.now(), mime: result.mime ?? 'image/jpeg', bytes: result.bytes, enc: 'aes-256-gcm', ct });
|
|
114
|
+
if (!allowed()) { fail(VIEW_CODE.CONSENT_REQUIRED); return true; }
|
|
115
|
+
if (stopped) { fail(VIEW_CODE.DISABLED); return true; }
|
|
116
|
+
if (vk === null) { fail(VIEW_CODE.VK_REQUIRED); return true; }
|
|
117
|
+
if (typeof frame.session_id === 'string' && frame.session_id !== '') sessionId = frame.session_id;
|
|
118
|
+
// A renewal is a fresh viewer request; its budget starts over so an actively polling viewer
|
|
119
|
+
// keeps the stream alive, while a viewer that disappears leaves the target to expire its budget.
|
|
120
|
+
frames = 0;
|
|
121
|
+
stopTimer();
|
|
122
|
+
tick();
|
|
89
123
|
return true;
|
|
90
124
|
},
|
|
91
|
-
|
|
125
|
+
stop() { stopped = true; stopTimer(); vk = null; sessionId = null; },
|
|
126
|
+
stats() { return { seq, frames, stopped, allow: allowed(), fps, intervalMs: delay, max_bytes: maxBytes, encrypted: vk !== null }; },
|
|
92
127
|
};
|
|
93
128
|
}
|
|
94
129
|
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* it derives VK from the viewer's ephemeral public key, captures a JPEG, seals
|
|
6
6
|
* it, and posts opaque ciphertext at ≤1 fps. Nothing is written to disk and the
|
|
7
7
|
* relay never sees plaintext.
|
|
8
|
+
*
|
|
9
|
+
* The poller is deliberately always running: the operator may enable remote
|
|
10
|
+
* viewing after the agent started, and `allow` is consulted before every request
|
|
11
|
+
* and every frame. A disabled machine never consumes a viewer request, so a
|
|
12
|
+
* later enable does not lose the request to an earlier no-op.
|
|
8
13
|
*/
|
|
9
14
|
import { createViewSide, sealViewFrame } from './session-key.mjs';
|
|
10
15
|
|
|
@@ -13,65 +18,104 @@ export const REMOTE_FRAME_PATH = '/v1/remote/frame';
|
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
20
|
* @param {{rabbitUrl:string, token:()=>string|null, machineId:string, capture:Function,
|
|
16
|
-
* intervalMs?:number, maxBytes?:number,
|
|
21
|
+
* allow?:()=>boolean, intervalMs?:number, maxBytes?:number, maxFrames?:number,
|
|
22
|
+
* fetchImpl?:Function, log?:Function}} deps
|
|
17
23
|
*/
|
|
18
24
|
export function startRelayViewTarget(deps) {
|
|
19
25
|
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
20
|
-
const intervalMs = Number.isInteger(deps.intervalMs) ? deps.intervalMs : 1000;
|
|
21
|
-
|
|
22
|
-
const
|
|
26
|
+
const intervalMs = Number.isInteger(deps.intervalMs) && deps.intervalMs > 0 ? deps.intervalMs : 1000;
|
|
27
|
+
// Stay below the relay broker's 47 KiB ciphertext cap after base64 framing.
|
|
28
|
+
const maxBytes = Number.isInteger(deps.maxBytes) && deps.maxBytes > 0 ? deps.maxBytes : 40 * 1024;
|
|
29
|
+
const maxFrames = Number.isInteger(deps.maxFrames) && deps.maxFrames > 0 ? deps.maxFrames : 60;
|
|
30
|
+
const allow = typeof deps.allow === 'function' ? deps.allow : () => process.env.W2M_VIEW_ALLOW === '1';
|
|
23
31
|
const base = String(deps.rabbitUrl ?? '').replace(/\/+$/, '');
|
|
24
32
|
let stopped = false;
|
|
25
|
-
let
|
|
26
|
-
let
|
|
33
|
+
let pollTimer = null;
|
|
34
|
+
let frameTimer = null;
|
|
35
|
+
let active = null;
|
|
36
|
+
|
|
37
|
+
const endStream = () => {
|
|
38
|
+
if (frameTimer !== null) clearTimeout(frameTimer);
|
|
39
|
+
frameTimer = null;
|
|
40
|
+
active = null;
|
|
41
|
+
};
|
|
42
|
+
const scheduleFrame = (waitMs) => {
|
|
43
|
+
if (stopped || active === null) return;
|
|
44
|
+
frameTimer = setTimeout(() => { frameTimer = null; void tick(); }, Math.max(0, waitMs));
|
|
45
|
+
if (typeof frameTimer.unref === 'function') frameTimer.unref();
|
|
46
|
+
};
|
|
27
47
|
|
|
28
|
-
const
|
|
48
|
+
const tick = async () => {
|
|
49
|
+
if (stopped || active === null || active.inFlight === true) return;
|
|
50
|
+
if (!allow()) { endStream(); return; }
|
|
51
|
+
if (active.frames >= maxFrames) { endStream(); return; }
|
|
52
|
+
active.inFlight = true;
|
|
53
|
+
const current = active;
|
|
54
|
+
const started = Date.now();
|
|
55
|
+
try {
|
|
56
|
+
const captured = deps.capture({ maxBytes });
|
|
57
|
+
if (captured?.ok !== true) { endStream(); return; }
|
|
58
|
+
let ct;
|
|
59
|
+
try { ct = sealViewFrame(current.vk, captured.data ?? Buffer.from(captured.base64 ?? '', 'base64')); }
|
|
60
|
+
catch { endStream(); return; }
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetchImpl(base + REMOTE_FRAME_PATH, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { authorization: 'Bearer ' + deps.token(), 'content-type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ session_id: current.sessionId, target: deps.machineId, ct, public_key: current.side.public_key }),
|
|
66
|
+
});
|
|
67
|
+
if (response.ok !== true) { endStream(); return; }
|
|
68
|
+
current.frames += 1;
|
|
69
|
+
if (current.frames >= maxFrames) endStream();
|
|
70
|
+
else scheduleFrame(intervalMs - (Date.now() - started));
|
|
71
|
+
} catch {
|
|
72
|
+
// The next poll may establish a fresh session; one transient relay failure
|
|
73
|
+
// ends this stream rather than emitting a burst of stale frames.
|
|
74
|
+
endStream();
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
current.inFlight = false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
29
80
|
|
|
30
81
|
const poll = async () => {
|
|
31
|
-
if (stopped ||
|
|
82
|
+
if (stopped || active !== null || !allow()) return;
|
|
32
83
|
const token = deps.token();
|
|
33
84
|
if (typeof token !== 'string' || token === '') return;
|
|
34
|
-
|
|
85
|
+
let response;
|
|
35
86
|
try {
|
|
36
|
-
|
|
87
|
+
response = await fetchImpl(base + REMOTE_REQUEST_PATH + '?target=' + encodeURIComponent(deps.machineId), {
|
|
37
88
|
headers: { authorization: 'Bearer ' + token },
|
|
38
89
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (stopped || frames >= maxFrames) { busy = false; return; }
|
|
49
|
-
frames += 1;
|
|
50
|
-
const captured = deps.capture({ maxBytes });
|
|
51
|
-
if (captured?.ok !== true) { busy = false; return; }
|
|
52
|
-
let ct;
|
|
53
|
-
try { ct = sealViewFrame(vk, captured.data ?? Buffer.from(captured.base64 ?? '', 'base64')); } catch { busy = false; return; }
|
|
54
|
-
try {
|
|
55
|
-
await fetchImpl(base + REMOTE_FRAME_PATH, {
|
|
56
|
-
method: 'POST',
|
|
57
|
-
headers: { authorization: 'Bearer ' + deps.token(), 'content-type': 'application/json' },
|
|
58
|
-
body: JSON.stringify({ session_id: sessionId, target: deps.machineId, ct, public_key: side.public_key }),
|
|
59
|
-
});
|
|
60
|
-
} catch { /* relay transient; next tick retries */ }
|
|
61
|
-
};
|
|
62
|
-
await tick();
|
|
63
|
-
timer = setInterval(() => { void tick(); }, intervalMs);
|
|
64
|
-
if (typeof timer.unref === 'function') timer.unref();
|
|
65
|
-
} finally {
|
|
66
|
-
busy = false;
|
|
67
|
-
}
|
|
90
|
+
} catch { return; }
|
|
91
|
+
if (stopped || !response.ok) return;
|
|
92
|
+
const body = await response.json().catch(() => null);
|
|
93
|
+
if (body?.ok !== true || typeof body.viewer_public_key !== 'string' || typeof body.session_id !== 'string') return;
|
|
94
|
+
const side = createViewSide();
|
|
95
|
+
let vk;
|
|
96
|
+
try { vk = side.finish(body.viewer_public_key).key; } catch { return; }
|
|
97
|
+
active = { sessionId: body.session_id, side, vk, frames: 0, inFlight: false };
|
|
98
|
+
await tick();
|
|
68
99
|
};
|
|
69
100
|
|
|
70
|
-
|
|
101
|
+
pollTimer = setInterval(() => { void poll(); }, intervalMs);
|
|
71
102
|
if (typeof pollTimer.unref === 'function') pollTimer.unref();
|
|
72
103
|
void poll();
|
|
73
104
|
return {
|
|
74
|
-
stop() {
|
|
75
|
-
|
|
105
|
+
stop() {
|
|
106
|
+
stopped = true;
|
|
107
|
+
if (pollTimer !== null) clearInterval(pollTimer);
|
|
108
|
+
pollTimer = null;
|
|
109
|
+
endStream();
|
|
110
|
+
},
|
|
111
|
+
stats() {
|
|
112
|
+
return {
|
|
113
|
+
stopped,
|
|
114
|
+
active: active === null ? null : { session_id: active.sessionId, frames: active.frames },
|
|
115
|
+
interval_ms: intervalMs,
|
|
116
|
+
max_bytes: maxBytes,
|
|
117
|
+
max_frames: maxFrames,
|
|
118
|
+
};
|
|
119
|
+
},
|
|
76
120
|
};
|
|
77
121
|
}
|
|
@@ -134,7 +134,7 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
|
|
|
134
134
|
if (dialled?.ok !== true) p2pError = dialled?.error ?? 'P2P_DIAL_FAILED';
|
|
135
135
|
}
|
|
136
136
|
if (dialled?.ok === true) {
|
|
137
|
-
active = { mode: 'p2p', machineId, sessionId, channel: dialled.channel, side, vk: null, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, startedAt: Date.now() };
|
|
137
|
+
active = { mode: 'p2p', machineId, sessionId, channel: dialled.channel, side, vk: null, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, p2pRenewAt: Date.now() + 10_000, startedAt: Date.now() };
|
|
138
138
|
listener = (event) => {
|
|
139
139
|
const frame = payloadJson(event?.payload);
|
|
140
140
|
if (frame === null || frame?.session_id !== sessionId) return;
|
|
@@ -165,7 +165,7 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
|
|
|
165
165
|
if (relayRequest.ok !== true) {
|
|
166
166
|
return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: p2pError, relay_error: relayRequest.error ?? null }, { status: 502, headers: NO_STORE });
|
|
167
167
|
}
|
|
168
|
-
active = { mode: 'relay', machineId, sessionId, channel: null, side, vk: null, encrypted: true, latest: null, error: null, startedAt: Date.now() };
|
|
168
|
+
active = { mode: 'relay', machineId, sessionId, channel: null, side, vk: null, encrypted: true, latest: null, error: null, lastRelayAt: 0, relayRenewAt: Date.now() + 10_000, startedAt: Date.now() };
|
|
169
169
|
return Response.json({ ok: true, machine_id: machineId, session_id: sessionId, encrypted: true, channel: 'relay', relay_ttl_ms: relayRequest.ttl_ms ?? null }, { headers: NO_STORE });
|
|
170
170
|
},
|
|
171
171
|
});
|
|
@@ -182,17 +182,41 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
|
|
|
182
182
|
fetch: async () => {
|
|
183
183
|
if (readConfig(file).enabled !== true) return Response.json({ ok: false, code: 'VIEW_DISABLED' }, { status: 403, headers: NO_STORE });
|
|
184
184
|
if (active !== null) {
|
|
185
|
-
|
|
185
|
+
// A P2P target stops after its frame budget. The viewer's 1 Hz poll renews the request
|
|
186
|
+
// every ten seconds so an open page keeps the stream alive; closing the page stops the
|
|
187
|
+
// renewals and the target's bounded budget expires on its own.
|
|
188
|
+
if (active.mode === 'p2p' && Date.now() >= active.p2pRenewAt) {
|
|
189
|
+
active.p2pRenewAt = Date.now() + 10_000;
|
|
190
|
+
try {
|
|
191
|
+
void active.channel.send(JSON.stringify({ type: 'view.request', session_id: active.sessionId, machine_id: active.machineId, fps: 1, max_bytes: maxBytes }));
|
|
192
|
+
} catch { /* peer gone */ }
|
|
193
|
+
}
|
|
194
|
+
// The viewer polls at 1 Hz. Relay media is latest-frame-wins, so each poll asks the relay
|
|
195
|
+
// for the next frame instead of serving the first frame forever.
|
|
196
|
+
if (active.mode === 'relay' && (active.latest === null || Date.now() - active.lastRelayAt >= 900)) {
|
|
197
|
+
active.lastRelayAt = Date.now();
|
|
198
|
+
// The target's relay stream is bounded (60 frames). A renewed request sits in the
|
|
199
|
+
// mailbox and is consumed by the next poll after that stream ends, so the viewer keeps
|
|
200
|
+
// receiving frames without reconnect churn.
|
|
201
|
+
if (Date.now() >= active.relayRenewAt) {
|
|
202
|
+
active.relayRenewAt = Date.now() + 10_000;
|
|
203
|
+
void postRelayRequest({ stateDir: deps.stateDir, machineId: active.machineId, sessionId: active.sessionId, publicKey: active.side.public_key });
|
|
204
|
+
}
|
|
186
205
|
const got = await fetchRelayFrame({ stateDir: deps.stateDir, sessionId: active.sessionId, machineId: active.machineId });
|
|
187
206
|
if (got.ok === true) {
|
|
188
207
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
208
|
+
// A renewed relay stream creates a new target ephemeral keypair; re-derive the
|
|
209
|
+
// viewer side whenever the target publishes a different public key.
|
|
210
|
+
if (typeof got.public_key === 'string') active.vk = active.side.finish(got.public_key).key;
|
|
211
|
+
if (active.vk === null) { active.error = 'VIEW_VK_REQUIRED'; return Response.json({ ok: false, code: active.error }, { status: 502, headers: NO_STORE }); }
|
|
193
212
|
const plaintext = openViewFrame(active.vk, got.ct);
|
|
194
|
-
if (plaintext.length <= maxBytes)
|
|
213
|
+
if (plaintext.length <= maxBytes) {
|
|
214
|
+
active.latest = { seq: null, mime: 'image/jpeg', bytes: plaintext.length, data_base64: plaintext.toString('base64'), at: new Date().toISOString(), encrypted: true, channel: 'relay' };
|
|
215
|
+
active.error = null;
|
|
216
|
+
}
|
|
195
217
|
} catch { active.error = 'VIEW_VK_BAD'; }
|
|
218
|
+
} else if (got.waiting !== true) {
|
|
219
|
+
active.error = 'VIEW_RELAY_UNAVAILABLE';
|
|
196
220
|
}
|
|
197
221
|
}
|
|
198
222
|
if (active.latest !== null) return Response.json({ ok: true, ...active.latest }, { headers: NO_STORE });
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -296,7 +296,7 @@ const MAX_STAGES = 16;
|
|
|
296
296
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
297
297
|
* overrides it at runtime for tests and packagers.
|
|
298
298
|
*/
|
|
299
|
-
const PLUGIN_VERSION = '2.2.
|
|
299
|
+
const PLUGIN_VERSION = '2.2.3';
|
|
300
300
|
|
|
301
301
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
302
302
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|