@twinsearth/w2m-dsh-plugin 1.7.0 → 1.7.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 +31 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.7.1/DESKTOP-CAPTURE-UPDATER.json +35 -0
- package/docs/acceptance/1.7.2/CHAT-CLIPBOARD-SHORTCUT.json +36 -0
- package/lib/client.js +72 -48
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/src/plugin/capture-route.mjs +243 -0
- package/src/plugin/fleet-target.mjs +22 -4
- package/src/plugin/tools.mjs +7 -1
- package/src/plugin/update-install.mjs +66 -11
|
@@ -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": "1.7.
|
|
8
|
+
"version": "1.7.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": "1.7.
|
|
15
|
+
"version": "1.7.2",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.7.
|
|
29
|
+
"version": "1.7.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": "1.7.
|
|
4
|
+
"version": "1.7.2",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.7.2] — 2026-10-25
|
|
11
|
+
|
|
12
|
+
**按实测反馈调整编队聊天截图/复制:只复制不预览、快捷键 Ctrl+Shift+P、强制可选中。**
|
|
13
|
+
|
|
14
|
+
- 截图按钮不再在面板里生成缩略图/下载行:抓到画面后**只写入剪贴板**并给一行提示,聊天区域保持干净。
|
|
15
|
+
- 快捷键从 Ctrl+P 改为 **Ctrl+Shift+P(macOS 也支持 Cmd+Shift+P)**;普通 Ctrl+P 不再被劫持,
|
|
16
|
+
浏览器/应用的打印快捷键恢复原样。
|
|
17
|
+
- 选中&复制:`user-select:text!important` 应用到整个面板的所有子节点(不再只覆盖几个类),
|
|
18
|
+
并在消息列表上阻止宿主吞掉 mousedown/copy 事件,保证拖选与 Ctrl+C 生效;单条消息的 ⧉ 复制按钮
|
|
19
|
+
仍走「浏览器剪贴板 → 宿主剪贴板」回退链。
|
|
20
|
+
- 全服统计只认**配置的主 relay**:主 relay 不可达时不再把备用 relay 的数字(1/1 编队、2/5 在线)当"全服"显示,
|
|
21
|
+
而是显示「主 relay 不可达 · 全服统计不可用」。修复了在线编队/在线机器数量"错误"的观感来源。
|
|
22
|
+
- 证据:`docs/acceptance/1.7.2/CHAT-CLIPBOARD-SHORTCUT.json`(fleet task `01M5ANYMVMR3IOB`;
|
|
23
|
+
宿主截图实测 159,561 字节并复制成功)。
|
|
24
|
+
## [1.7.1] — 2026-10-25
|
|
25
|
+
|
|
26
|
+
**修复桌面壳下聊天截图/复制失败(permission denied)与插件更新"no supported installer available"。**
|
|
27
|
+
|
|
28
|
+
- 截图:浏览器 `getDisplayMedia` 在 DSH 桌面(Electron)被拒绝(`permission denied`)。新增**宿主侧截图**
|
|
29
|
+
`POST /api/w2m/capture`(Windows PowerShell System.Drawing;macOS screencapture;返回 JPEG base64),
|
|
30
|
+
按钮与 Ctrl+P 在渲染层捕获失败时自动回退到宿主截图,并在宿主侧复制到剪贴板。
|
|
31
|
+
- 复制:`navigator.clipboard` 被拒时新增**宿主剪贴板** `POST /api/w2m/clipboard`(Windows Set-Clipboard /
|
|
32
|
+
macOS osascript),单条消息复制按钮与复制文本都走这条回退链。
|
|
33
|
+
- 选中:transcript 的 `user-select:text` 改为 `!important`,避免宿主全局样式覆盖导致无法选中。
|
|
34
|
+
- 插件更新:`update-install` 的安装器解析新增桌面运行时布局
|
|
35
|
+
(`<app>/resources/runtime/<runtime>/dependencies/pnpm/bin/pnpm.mjs`、`~/.dsh/dsh-runtimes` 兜底),
|
|
36
|
+
并用运行时自带的 `node` 执行 pnpm(桌面壳的 `process.execPath` 不是 Node)。修复了更新按钮
|
|
37
|
+
"install update: no supported installer available"。
|
|
38
|
+
|
|
39
|
+
- 测试:capture-route 4/4、fleet-chat-capture 3/3、client-bundle 26/26、update-resolver 4/4;
|
|
40
|
+
证据 `docs/acceptance/1.7.1/DESKTOP-CAPTURE-UPDATER.json`(fleet task `01M5ANYMVMN9CNQ`)。
|
|
10
41
|
## [1.7.0] — 2026-10-25
|
|
11
42
|
|
|
12
43
|
**编队聊天框:截图按钮 + Ctrl+P 快捷键 + 对话内容可选中&复制。**
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.7.1",
|
|
3
|
+
"case": "DESKTOP-CAPTURE-UPDATER",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-24T17:01:53.420Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "on the DSH desktop shell, where the renderer denies getDisplayMedia (permission denied) and clipboard writes, the chat screenshot button and Ctrl+P still capture through the HOST, copy works through the host clipboard, and the plugin update button finds a supported installer",
|
|
11
|
+
"task_id": "01M5ANYMVMN9CNQ",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"host_capture_smoke": {
|
|
15
|
+
"platform": "win32",
|
|
16
|
+
"ok": true,
|
|
17
|
+
"bytes": 150903,
|
|
18
|
+
"copied": true,
|
|
19
|
+
"note": "captured the real screen through PowerShell (System.Drawing) and put it on the Windows clipboard"
|
|
20
|
+
},
|
|
21
|
+
"assertions": {
|
|
22
|
+
"capture_route_tests": "4/4 pass",
|
|
23
|
+
"fleet_chat_capture": "3/3 pass",
|
|
24
|
+
"client_bundle": "26/26 pass",
|
|
25
|
+
"updater_resolver": "4/4 pass"
|
|
26
|
+
},
|
|
27
|
+
"fixes": [
|
|
28
|
+
"POST /api/w2m/capture: host-side PowerShell/macOS/Linux screenshot; returns JPEG base64 and copies on the host",
|
|
29
|
+
"POST /api/w2m/clipboard: host-side text clipboard, used when navigator.clipboard is denied",
|
|
30
|
+
"client falls back from getDisplayMedia -> host capture, and copies text through host when the renderer denies it",
|
|
31
|
+
"user-select:text!important so the transcript stays selectable under host CSS",
|
|
32
|
+
"update installer discovery: desktop resources runtime + per-user dsh-runtimes; runtime node instead of the app executable"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.7.2",
|
|
3
|
+
"case": "CHAT-CLIPBOARD-SHORTCUT",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-24T18:47:27.925Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "user feedback on v1.7.1: the screenshot must copy to the clipboard only (no thumbnail crowding the chat), the shortcut must be Ctrl+Shift+P (plain Ctrl+P printed the page), and the transcript must actually be selectable and copyable inside the DSH host",
|
|
11
|
+
"task_id": "01M5ANYMVMR3IOB",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"host_capture_smoke": {
|
|
15
|
+
"platform": "win32",
|
|
16
|
+
"ok": true,
|
|
17
|
+
"bytes": 159561,
|
|
18
|
+
"copied": true,
|
|
19
|
+
"note": "host-side capture copied the real screen to the Windows clipboard"
|
|
20
|
+
},
|
|
21
|
+
"assertions": {
|
|
22
|
+
"fleet_chat_capture": "3/3 pass",
|
|
23
|
+
"client_bundle": "26/26 pass",
|
|
24
|
+
"ui_contract": [
|
|
25
|
+
"screenshot renders no preview row; feedback is a toast only",
|
|
26
|
+
"shortcut is Ctrl+Shift+P / Cmd+Shift+P and plain Ctrl+P keeps the print dialog",
|
|
27
|
+
"user-select:text!important is applied to the whole panel (not just one node)",
|
|
28
|
+
"the list stops the host from swallowing mousedown/copy so selection survives"
|
|
29
|
+
],
|
|
30
|
+
"count_source": "whole-server totals come from the configured primary relay only; when it is unreachable the panel says unavailable instead of showing the backup relay numbers"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"fixes": [
|
|
34
|
+
"fleet-target: server stats chosen by configured rabbitUrl, not by first reachable relay; buildUiState exposes server_url/server_reachable; client renders unavailable"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -115,6 +115,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
115
115
|
relayUnfold: '收起折叠的 relay',
|
|
116
116
|
relayTemporary: '临时 Relay 节点(回退配置)',
|
|
117
117
|
globalSummary: '全服:{fleetsOnline}/{fleets} 个编队 · {online}/{total} 台在线',
|
|
118
|
+
globalUnreachable: '主 relay 不可达({url})· 全服统计不可用',
|
|
118
119
|
updateButton: '检查插件更新',
|
|
119
120
|
updateChecking: '正在检查 npm…',
|
|
120
121
|
updating: '正在更新…',
|
|
@@ -153,6 +154,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
153
154
|
relayUnfold: 'collapse the folded relays',
|
|
154
155
|
relayTemporary: 'temporary relay nodes (fallback config)',
|
|
155
156
|
globalSummary: 'fleets {fleetsOnline}/{fleets} · machines {online}/{total} online',
|
|
157
|
+
globalUnreachable: 'primary relay unreachable ({url}) · server totals unavailable',
|
|
156
158
|
updateButton: 'check for plugin update',
|
|
157
159
|
updateChecking: 'checking npm…',
|
|
158
160
|
updating: 'updating…',
|
|
@@ -395,13 +397,11 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
395
397
|
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
396
398
|
'.w2mChat_unseen{margin-left:6px;min-width:16px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-accent-default);color:var(--dsw-alias-label-on-accent);font-size:10px;line-height:16px;text-align:center}',
|
|
397
399
|
// v1.6.10: the transcript is selectable text; screenshots get a preview and a copy affordance.
|
|
398
|
-
'.w2mChat_panel,.
|
|
400
|
+
'.w2mChat_panel,.w2mChat_panel *{user-select:text!important;-webkit-user-select:text!important;cursor:text}',
|
|
401
|
+
'.w2mChat_copy{cursor:pointer!important}',
|
|
399
402
|
'.w2mChat_copy{margin-left:6px;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:11px;line-height:14px;padding:0 2px;vertical-align:middle}',
|
|
400
403
|
'.w2mChat_copy:hover{color:var(--dsw-alias-label-primary)}',
|
|
401
404
|
'.w2mChat_shotButton{flex:none;height:28px;padding:0 8px;border:.5px solid var(--dsw-alias-border-default);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-surface-default);color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer}',
|
|
402
|
-
'.w2mChat_shotPreview{display:flex;align-items:center;gap:8px;padding:6px 10px;border-top:.5px solid var(--dsw-alias-border-default);font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
403
|
-
'.w2mChat_shotPreview img{max-height:56px;max-width:120px;border-radius:4px;border:.5px solid var(--dsw-alias-border-default)}',
|
|
404
|
-
'.w2mChat_shot{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
405
405
|
'.w2mChat_toast{padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
406
406
|
'.w2mChat_text{white-space:pre-wrap;word-break:break-word}',
|
|
407
407
|
'.w2mChat_msg.is-session .w2mChat_text{color:var(--dsw-danger)}', '.w2mChat_msg.is-chat .w2mChat_text{color:var(--dsw-success)}', '.w2mChat_msg.is-remote .w2mChat_text{color:var(--dsw-text)}', '.w2mChat_avatar{display:inline-flex;width:16px;height:16px;margin-right:5px;vertical-align:-3px}',
|
|
@@ -751,6 +751,9 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
751
751
|
// `/api/w2m/chat` (host-side `fleet-chat-route.mjs`), so the device token stays in the host
|
|
752
752
|
// process and this file never holds a credential.
|
|
753
753
|
const CHAT_ROUTE = '/api/w2m/chat';
|
|
754
|
+
// v1.7.1: host fallbacks for the DSH desktop shell (renderer capture/clipboard are not granted there).
|
|
755
|
+
const CAPTURE_ROUTE = '/api/w2m/capture';
|
|
756
|
+
const CLIPBOARD_ROUTE = '/api/w2m/clipboard';
|
|
754
757
|
|
|
755
758
|
/** One JSON call to the host chat route; a refusal is a value, not an exception. */
|
|
756
759
|
async function chatCall(options) {
|
|
@@ -822,11 +825,11 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
822
825
|
const [error, setError] = React.useState(null);
|
|
823
826
|
const [busy, setBusy] = React.useState(false);
|
|
824
827
|
const [localMachineId, setLocalMachineId] = React.useState(null);
|
|
825
|
-
// v1.
|
|
826
|
-
// clipboard; the relay's chat payload stays text, so an image cannot be broadcast as a message.
|
|
827
|
-
const [shot, setShot] = React.useState(null);
|
|
828
|
+
// v1.7.2: screenshot state is clipboard-only (no preview); `toast` is the only visible feedback.
|
|
828
829
|
const [shotBusy, setShotBusy] = React.useState(false);
|
|
829
830
|
const [toast, setToast] = React.useState(null);
|
|
831
|
+
// v1.6.10: screenshot support (button + Ctrl+P). The blob is previewed and copied to the
|
|
832
|
+
// clipboard; the relay's chat payload stays text, so an image cannot be broadcast as a message.
|
|
830
833
|
|
|
831
834
|
const load = React.useCallback(async (signal) => {
|
|
832
835
|
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
@@ -932,72 +935,98 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
932
935
|
return undefined;
|
|
933
936
|
}, [busy, load, sessionId, text]);
|
|
934
937
|
// v1.6.10: clipboard text with a fallback for hosts where navigator.clipboard is absent.
|
|
938
|
+
// v1.7.1: copy through the renderer when allowed, then through the host clipboard route.
|
|
939
|
+
// The DSH desktop shell rejects navigator.clipboard, which is why v1.7.0's copy button failed there.
|
|
935
940
|
const copyText = React.useCallback((value) => {
|
|
936
941
|
const textValue = String(value ?? '');
|
|
937
942
|
if (textValue === '') return undefined;
|
|
943
|
+
const hostFallback = async () => {
|
|
944
|
+
try {
|
|
945
|
+
const response = await fetch(CLIPBOARD_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ text: textValue }) });
|
|
946
|
+
const body = await response.json().catch(() => null);
|
|
947
|
+
if (response.ok === true && body?.ok === true) { setToast('已复制(宿主侧)'); return undefined; }
|
|
948
|
+
} catch { /* fall through to the textarea path */ }
|
|
949
|
+
try {
|
|
950
|
+
const area = document.createElement('textarea');
|
|
951
|
+
area.value = textValue; area.style.position = 'fixed'; area.style.opacity = '0';
|
|
952
|
+
document.body.appendChild(area); area.select(); document.execCommand('copy'); document.body.removeChild(area);
|
|
953
|
+
setToast('已复制');
|
|
954
|
+
} catch { setToast('复制失败'); }
|
|
955
|
+
return undefined;
|
|
956
|
+
};
|
|
938
957
|
try {
|
|
939
958
|
if (navigator.clipboard?.writeText) {
|
|
940
|
-
navigator.clipboard.writeText(textValue).then(() => setToast('已复制'), () =>
|
|
959
|
+
navigator.clipboard.writeText(textValue).then(() => setToast('已复制'), () => { hostFallback(); });
|
|
941
960
|
return undefined;
|
|
942
961
|
}
|
|
943
|
-
} catch { /* fall through
|
|
944
|
-
|
|
945
|
-
const area = document.createElement('textarea');
|
|
946
|
-
area.value = textValue; area.style.position = 'fixed'; area.style.opacity = '0';
|
|
947
|
-
document.body.appendChild(area); area.select(); document.execCommand('copy'); document.body.removeChild(area);
|
|
948
|
-
setToast('已复制');
|
|
949
|
-
} catch { setToast('复制失败'); }
|
|
962
|
+
} catch { /* fall through */ }
|
|
963
|
+
hostFallback();
|
|
950
964
|
return undefined;
|
|
951
965
|
}, []);
|
|
952
966
|
|
|
953
967
|
// v1.6.10: capture one frame of a user-picked screen/window, preview it, and copy the PNG to the
|
|
954
968
|
// clipboard. getDisplayMedia needs the gesture the button/keypress provides; every failure is
|
|
955
969
|
// reported in the panel rather than as an unhandled rejection.
|
|
970
|
+
// v1.7.1: screenshot with a host fallback. The renderer path (getDisplayMedia) is tried first;
|
|
971
|
+
// when the host forbids it -- the DSH desktop shell does -- the panel captures on the host and
|
|
972
|
+
// receives JPEG base64, so the button and Ctrl+P work in both places.
|
|
973
|
+
// v1.7.2: capture and COPY only -- no thumbnail in the panel (the chat got too cramped), and no
|
|
974
|
+
// print-dialog shortcut stealing. The renderer path is tried first; the DSH desktop shell denies
|
|
975
|
+
// getDisplayMedia, so the host route captures and copies on the host side instead.
|
|
956
976
|
const captureScreenshot = React.useCallback(async () => {
|
|
957
977
|
if (shotBusy) return undefined;
|
|
958
|
-
if (typeof navigator?.mediaDevices?.getDisplayMedia !== 'function') {
|
|
959
|
-
setToast('当前环境不支持屏幕截图');
|
|
960
|
-
return undefined;
|
|
961
|
-
}
|
|
962
978
|
setShotBusy(true); setToast(null);
|
|
963
|
-
|
|
964
|
-
try {
|
|
965
|
-
stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 5 }, audio: false });
|
|
966
|
-
const video = document.createElement('video');
|
|
967
|
-
video.srcObject = stream;
|
|
968
|
-
await video.play();
|
|
969
|
-
await new Promise((resolve) => { setTimeout(resolve, 180); });
|
|
970
|
-
const canvas = document.createElement('canvas');
|
|
971
|
-
canvas.width = video.videoWidth || 1280;
|
|
972
|
-
canvas.height = video.videoHeight || 720;
|
|
973
|
-
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
974
|
-
const blob = await new Promise((resolve) => { canvas.toBlob(resolve, 'image/png'); });
|
|
975
|
-
if (blob === null) throw new Error('截图编码失败');
|
|
976
|
-
const url = URL.createObjectURL(blob);
|
|
977
|
-
setShot((previous) => { if (previous?.url) URL.revokeObjectURL(previous.url); return { url, blob, at: Date.now() }; });
|
|
979
|
+
const copyImage = async (blob) => {
|
|
978
980
|
try {
|
|
979
981
|
if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
|
|
980
982
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
981
|
-
|
|
982
|
-
}
|
|
983
|
-
} catch {
|
|
983
|
+
return true;
|
|
984
|
+
}
|
|
985
|
+
} catch { /* host fallback below */ }
|
|
986
|
+
return false;
|
|
987
|
+
};
|
|
988
|
+
try {
|
|
989
|
+
if (typeof navigator?.mediaDevices?.getDisplayMedia === 'function') {
|
|
990
|
+
let stream = null;
|
|
991
|
+
try {
|
|
992
|
+
stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 5 }, audio: false });
|
|
993
|
+
const video = document.createElement('video');
|
|
994
|
+
video.srcObject = stream;
|
|
995
|
+
await video.play();
|
|
996
|
+
await new Promise((resolve) => { setTimeout(resolve, 180); });
|
|
997
|
+
const canvas = document.createElement('canvas');
|
|
998
|
+
canvas.width = video.videoWidth || 1280;
|
|
999
|
+
canvas.height = video.videoHeight || 720;
|
|
1000
|
+
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
1001
|
+
const blob = await new Promise((resolve) => { canvas.toBlob(resolve, 'image/png'); });
|
|
1002
|
+
if (blob !== null && await copyImage(blob) === true) {
|
|
1003
|
+
setToast('截图已复制到剪贴板');
|
|
1004
|
+
return undefined;
|
|
1005
|
+
}
|
|
1006
|
+
} catch { /* the host fallback below is the whole point */ }
|
|
1007
|
+
finally { try { stream?.getTracks?.().forEach((track) => track.stop()); } catch { /* gone */ } }
|
|
1008
|
+
}
|
|
1009
|
+
const response = await fetch(CAPTURE_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ copy: true }) });
|
|
1010
|
+
const body = await response.json().catch(() => null);
|
|
1011
|
+
if (response.ok !== true || body?.ok !== true) throw new Error(body?.error ?? ('HTTP ' + response.status));
|
|
1012
|
+
if (body.copied === true) setToast('截图已复制到剪贴板(宿主)');
|
|
1013
|
+
else setToast('截图已生成,但剪贴板不可用');
|
|
984
1014
|
} catch (error) {
|
|
985
1015
|
setToast('截图失败:' + (error instanceof Error ? error.message : String(error)));
|
|
986
1016
|
} finally {
|
|
987
|
-
try { stream?.getTracks?.().forEach((track) => track.stop()); } catch { /* stream already gone */ }
|
|
988
1017
|
setShotBusy(false);
|
|
989
1018
|
}
|
|
990
1019
|
return undefined;
|
|
991
1020
|
}, [shotBusy]);
|
|
992
1021
|
|
|
993
|
-
// v1.
|
|
994
|
-
//
|
|
1022
|
+
// v1.7.2: Ctrl+Shift+P (Cmd+Shift+P) opens the capture while the panel is open. Plain Ctrl+P is left
|
|
1023
|
+
// alone: hijacking the browser/app print shortcut surprised testers and printed the page instead.
|
|
995
1024
|
React.useEffect(() => {
|
|
996
1025
|
if (!open) return undefined;
|
|
997
1026
|
const onKeyDown = (event) => {
|
|
998
1027
|
if (event.defaultPrevented) return;
|
|
999
1028
|
const modifier = event.ctrlKey === true || event.metaKey === true;
|
|
1000
|
-
if (modifier && event.shiftKey
|
|
1029
|
+
if (modifier && event.shiftKey === true && event.altKey !== true && String(event.key).toLowerCase() === 'p') {
|
|
1001
1030
|
event.preventDefault();
|
|
1002
1031
|
captureScreenshot();
|
|
1003
1032
|
}
|
|
@@ -1055,7 +1084,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1055
1084
|
...tasks.slice(0, 6).map((task) => h('div', { key: String(task?.task_id ?? Math.random()), className: 'w2mChat_boardRow' },
|
|
1056
1085
|
`${String(task?.task_id ?? '').slice(0, 8)} ${task?.mode ?? '?'} ${task?.status ?? '?'}`)),
|
|
1057
1086
|
]),
|
|
1058
|
-
h('div', { className: 'w2mChat_list' },
|
|
1087
|
+
h('div', { className: 'w2mChat_list', onMouseDownCapture: (event) => { if (event.button === 0) event.stopPropagation(); }, onCopy: (event) => event.stopPropagation() },
|
|
1059
1088
|
error !== null ? h('div', { className: 'w2mChat_error' }, error) : null,
|
|
1060
1089
|
messages.length === 0
|
|
1061
1090
|
? (error === null ? h('div', { className: 'w2mChat_empty' }, '本会话还没有消息') : null)
|
|
@@ -1071,11 +1100,6 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1071
1100
|
|
|
1072
1101
|
|
|
1073
1102
|
h('div', { className: 'w2mChat_compose' },
|
|
1074
|
-
shot === null ? null : h('div', { key: 'shot', className: 'w2mChat_shotPreview' }, [
|
|
1075
|
-
h('img', { key: 'img', src: shot.url, alt: '截图预览' }),
|
|
1076
|
-
h('a', { key: 'dl', href: shot.url, download: 'w2m-screenshot-' + shot.at + '.png', className: 'w2mChat_shot' }, '下载'),
|
|
1077
|
-
h('button', { key: 'copy', type: 'button', className: 'w2mChat_copy', onClick: () => { try { navigator.clipboard?.write?.([new ClipboardItem({ 'image/png': shot.blob })]).then(() => setToast('已复制'), () => setToast('复制失败')); } catch { setToast('复制失败'); } } }, '复制'),
|
|
1078
|
-
]),
|
|
1079
1103
|
toast === null ? null : h('div', { key: 'toast', className: 'w2mChat_toast' }, toast),
|
|
1080
1104
|
replyTo === null ? null : h('div', { className: 'w2mChat_reply' }, [
|
|
1081
1105
|
h('span', { key: 'label' }, '回复:' + (replyTo.text.length > 40 ? replyTo.text.slice(0, 40) + '…' : replyTo.text)),
|
|
@@ -1095,7 +1119,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1095
1119
|
}
|
|
1096
1120
|
},
|
|
1097
1121
|
}),
|
|
1098
|
-
h('button', { type: 'button', className: 'w2mChat_shotButton', title: '
|
|
1122
|
+
h('button', { type: 'button', className: 'w2mChat_shotButton', title: '截图并复制(Ctrl+Shift+P)', disabled: shotBusy, onClick: captureScreenshot }, shotBusy ? '截图…' : '📷'),
|
|
1099
1123
|
h('button', { type: 'button', className: 'w2mChat_send', disabled: busy || text.trim() === '', onClick: send }, busy ? '发送中…' : '发送')))
|
|
1100
1124
|
: null);
|
|
1101
1125
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.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": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"pack": "node scripts/pack.mjs",
|
|
65
65
|
"reachability": "node scripts/reachability-check.mjs",
|
|
66
66
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
67
|
-
"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",
|
|
67
|
+
"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",
|
|
68
68
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
69
69
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
70
70
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"test:council-session": "node --test --test-force-exit test/council-session.test.mjs",
|
|
83
83
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
84
84
|
"test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
|
|
85
|
-
"test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.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",
|
|
85
|
+
"test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.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",
|
|
86
86
|
"test:exec-env": "node --test --test-force-exit test/exec-env-injection.test.mjs",
|
|
87
87
|
"test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
|
|
88
88
|
"test:field-authority": "node --test --test-force-exit test/field-authority.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": "1.7.
|
|
4
|
+
"version": "1.7.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": [
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v1.7.1 — host-side capture and clipboard fallbacks for the fleet-chat panel.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this module exists
|
|
5
|
+
*
|
|
6
|
+
* v1.7.0 put a screenshot button and Ctrl+P in the chat panel, implemented in the browser with
|
|
7
|
+
* `getDisplayMedia` and `navigator.clipboard`. Field test on the DSH **desktop** shell: all three
|
|
8
|
+
* controls failed. A desktop host is not a browser tab — `getDisplayMedia` needs the embedding app to
|
|
9
|
+
* install a display-media request handler, and clipboard writes are subject to the same permission
|
|
10
|
+
* surface. When either rejects, a browser-only implementation has nowhere left to go.
|
|
11
|
+
*
|
|
12
|
+
* So the panel gains a host fallback that does not depend on renderer permissions:
|
|
13
|
+
*
|
|
14
|
+
* POST /api/w2m/capture { copy?: boolean } -> capture the primary screen on the HOST, return
|
|
15
|
+
* { ok, mime, data_base64, path, copied }
|
|
16
|
+
* POST /api/w2m/clipboard { text } -> put text on the HOST clipboard
|
|
17
|
+
*
|
|
18
|
+
* Windows uses PowerShell (System.Drawing + System.Windows.Forms, STA thread for the image
|
|
19
|
+
* clipboard), macOS uses `screencapture`/`pbcopy`/`osascript`, Linux uses `gnome-screenshot`/`xclip`
|
|
20
|
+
* when present. Every command is a fixed argv array — no shell, and no caller input is spliced into a
|
|
21
|
+
* script.
|
|
22
|
+
*
|
|
23
|
+
* This module is deliberately small and injectable: `run` is a parameter so the route can be tested
|
|
24
|
+
* without a screen, and `capturePlan`/`clipboardPlan` are exported as pure data for the same reason.
|
|
25
|
+
*/
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
export const CAPTURE_ROUTE_PATH = '/api/w2m/capture';
|
|
32
|
+
export const CLIPBOARD_ROUTE_PATH = '/api/w2m/clipboard';
|
|
33
|
+
export const CAPTURE_TIMEOUT_MS = 20_000;
|
|
34
|
+
const NO_STORE = { 'cache-control': 'no-store' };
|
|
35
|
+
|
|
36
|
+
/** Run a command with no shell, a bounded timeout and captured output. */
|
|
37
|
+
export function runCommand(argv, { timeoutMs = CAPTURE_TIMEOUT_MS, env = process.env } = {}) {
|
|
38
|
+
return new Promise((resolvePromise) => {
|
|
39
|
+
let child;
|
|
40
|
+
try {
|
|
41
|
+
child = spawn(argv[0], argv.slice(1), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
resolvePromise({ code: null, stdout: '', stderr: String(error?.message ?? error) });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let stdout = '';
|
|
47
|
+
let stderr = '';
|
|
48
|
+
let settled = false;
|
|
49
|
+
const finish = (code) => {
|
|
50
|
+
if (settled) return;
|
|
51
|
+
settled = true;
|
|
52
|
+
resolvePromise({ code, stdout, stderr });
|
|
53
|
+
};
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
56
|
+
finish(null);
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
timer.unref?.();
|
|
59
|
+
child.stdout?.on('data', (chunk) => { stdout = (stdout + chunk.toString('utf8')).slice(-4_000_000); });
|
|
60
|
+
child.stderr?.on('data', (chunk) => { stderr = (stderr + chunk.toString('utf8')).slice(-16_384); });
|
|
61
|
+
child.on('error', (error) => { stderr = String(error?.message ?? error); finish(null); });
|
|
62
|
+
child.on('close', (code) => { clearTimeout(timer); finish(code); });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The PowerShell script that captures the primary screen as a JPEG and prints base64.
|
|
68
|
+
* `-STA` matters: `Clipboard::SetImage` throws on an MTA thread.
|
|
69
|
+
*/
|
|
70
|
+
const WINDOWS_CAPTURE_SCRIPT = [
|
|
71
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
|
|
72
|
+
'$b=[System.Windows.Forms.SystemInformation]::VirtualScreen',
|
|
73
|
+
'$bmp=New-Object Drawing.Bitmap($b.Width,$b.Height)',
|
|
74
|
+
'$g=[Drawing.Graphics]::FromImage($bmp)',
|
|
75
|
+
'$g.CopyFromScreen($b.Left,$b.Top,0,0,$bmp.Size)',
|
|
76
|
+
'$max=1600',
|
|
77
|
+
'if($bmp.Width -gt $max){$r=$max/[double]$bmp.Width;$nw=[int]($bmp.Width*$r);$nh=[int]($bmp.Height*$r);$s=New-Object Drawing.Bitmap($nw,$nh);$gs=[Drawing.Graphics]::FromImage($s);$gs.InterpolationMode=[Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic;$gs.DrawImage($bmp,0,0,$nw,$nh);$bmp=$s}',
|
|
78
|
+
'$ms=New-Object IO.MemoryStream',
|
|
79
|
+
'$enc=[Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object {$_.MimeType -eq "image/jpeg"} | Select-Object -First 1',
|
|
80
|
+
'$ps=New-Object Drawing.Imaging.EncoderParameters(1)',
|
|
81
|
+
'$ps.Param[0]=New-Object Drawing.Imaging.EncoderParameter([Drawing.Imaging.Encoder]::Quality,[long]82)',
|
|
82
|
+
'$bmp.Save($ms,$enc,$ps)',
|
|
83
|
+
'if($env:W2M_CAPTURE_COPY -eq "1"){[System.Windows.Forms.Clipboard]::SetImage($bmp)}',
|
|
84
|
+
'[Convert]::ToBase64String($ms.ToArray())',
|
|
85
|
+
].join('; ');
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The capture command for a platform, as pure data.
|
|
89
|
+
*
|
|
90
|
+
* Windows prints base64 to stdout; macOS/Linux write a file whose path the caller returns to Node
|
|
91
|
+
* (`file` is null on Windows).
|
|
92
|
+
*
|
|
93
|
+
* @param {NodeJS.Platform} platform
|
|
94
|
+
* @param {{copyToClipboard?: boolean}} [options]
|
|
95
|
+
* @returns {{argv: string[], file: string|null, mime: string}}
|
|
96
|
+
*/
|
|
97
|
+
export function capturePlan(platform, _options = {}) {
|
|
98
|
+
if (platform === 'win32') {
|
|
99
|
+
return {
|
|
100
|
+
argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_CAPTURE_SCRIPT],
|
|
101
|
+
file: null,
|
|
102
|
+
mime: 'image/jpeg',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (platform === 'darwin') {
|
|
106
|
+
return { argv: ['/usr/sbin/screencapture', '-x', '-t', 'jpg', 'capture'], file: 'capture', mime: 'image/jpeg' };
|
|
107
|
+
}
|
|
108
|
+
if (platform === 'linux') {
|
|
109
|
+
return { argv: ['gnome-screenshot', '-f', 'capture'], file: 'capture', mime: 'image/jpeg' };
|
|
110
|
+
}
|
|
111
|
+
return { argv: [], file: null, mime: 'application/octet-stream' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Text-clipboard command for a platform, as pure data. */
|
|
115
|
+
export function clipboardPlan(platform, _text) {
|
|
116
|
+
if (platform === 'win32') return ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $env:W2M_CLIP_TEXT'];
|
|
117
|
+
if (platform === 'darwin') return ['pbcopy'];
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Capture the host screen.
|
|
123
|
+
*
|
|
124
|
+
* @param {{platform?: NodeJS.Platform, run?: typeof runCommand, tempRoot?: string, copyToClipboard?: boolean}} [options]
|
|
125
|
+
* @returns {Promise<{ok: boolean, mime?: string, data_base64?: string, path?: string, copied: boolean, error?: string}>}
|
|
126
|
+
*/
|
|
127
|
+
export async function captureHostScreen(options = {}) {
|
|
128
|
+
const platform = options.platform ?? process.platform;
|
|
129
|
+
const run = options.run ?? runCommand;
|
|
130
|
+
const copyToClipboard = options.copyToClipboard === true;
|
|
131
|
+
const plan = capturePlan(platform, { copyToClipboard });
|
|
132
|
+
if (plan.argv.length === 0) {
|
|
133
|
+
return { ok: false, copied: false, error: `host capture is not implemented for ${platform}` };
|
|
134
|
+
}
|
|
135
|
+
if (platform === 'win32') {
|
|
136
|
+
const result = await run(plan.argv, { timeoutMs: CAPTURE_TIMEOUT_MS, env: { ...process.env, W2M_CAPTURE_COPY: copyToClipboard ? '1' : '0' } });
|
|
137
|
+
const base64 = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
|
|
138
|
+
if (result.code !== 0 || !/^[A-Za-z0-9+/=]{100,}$/.test(base64)) {
|
|
139
|
+
return { ok: false, copied: false, error: `PowerShell capture failed (exit ${result.code}): ${String(result.stderr ?? '').trim().slice(-400) || 'no output'}` };
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, mime: 'image/jpeg', data_base64: base64, copied: copyToClipboard };
|
|
142
|
+
}
|
|
143
|
+
// macOS / Linux: write to a temp file, read it, and copy via the platform tool when asked.
|
|
144
|
+
const dir = mkdtempSync(join(options.tempRoot ?? tmpdir(), 'w2m-capture-'));
|
|
145
|
+
const file = join(dir, platform === 'darwin' ? 'capture.jpg' : 'capture.jpg');
|
|
146
|
+
try {
|
|
147
|
+
const argv = plan.argv.map((part) => (part === 'capture' ? file : part));
|
|
148
|
+
const result = await run(argv, { timeoutMs: CAPTURE_TIMEOUT_MS });
|
|
149
|
+
if (result.code !== 0) {
|
|
150
|
+
return { ok: false, copied: false, error: `screencapture failed (exit ${result.code}): ${String(result.stderr ?? '').trim().slice(-300)}` };
|
|
151
|
+
}
|
|
152
|
+
const data = readFileSync(file);
|
|
153
|
+
let copied = false;
|
|
154
|
+
if (copyToClipboard) {
|
|
155
|
+
if (platform === 'darwin') {
|
|
156
|
+
const script = `set the clipboard to (read (POSIX file "${file.replace(/"/g, '\\"')}") as JPEG picture)`;
|
|
157
|
+
copied = (await run(['osascript', '-e', script], { timeoutMs: CAPTURE_TIMEOUT_MS })).code === 0;
|
|
158
|
+
} else {
|
|
159
|
+
copied = (await run(['xclip', '-selection', 'clipboard', '-t', 'image/jpeg', '-i', file], { timeoutMs: CAPTURE_TIMEOUT_MS })).code === 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, mime: 'image/jpeg', data_base64: data.toString('base64'), path: file, copied };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { ok: false, copied: false, error: error instanceof Error ? error.message : String(error) };
|
|
165
|
+
} finally {
|
|
166
|
+
// The base64 is already in memory; the file is only useful for the platform clipboard call.
|
|
167
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Put text on the host clipboard. */
|
|
172
|
+
export async function copyHostText(text, options = {}) {
|
|
173
|
+
const platform = options.platform ?? process.platform;
|
|
174
|
+
const run = options.run ?? runCommand;
|
|
175
|
+
const argv = clipboardPlan(platform, text);
|
|
176
|
+
if (argv.length === 0) return { ok: false, error: `host clipboard is not implemented for ${platform}` };
|
|
177
|
+
if (platform === 'win32') {
|
|
178
|
+
const result = await run(argv, { env: { ...process.env, W2M_CLIP_TEXT: String(text ?? '') } });
|
|
179
|
+
return { ok: result.code === 0, error: result.code === 0 ? undefined : `clipboard command failed (exit ${result.code}): ${String(result.stderr ?? '').slice(-300)}` };
|
|
180
|
+
}
|
|
181
|
+
if (platform === 'darwin') {
|
|
182
|
+
// pbcopy reads stdin; keep it out of the shell by piping through a temporary spawn is overkill
|
|
183
|
+
// for this fallback — use osascript's clipboard setter instead, where the text is one argv item.
|
|
184
|
+
const script = 'set the clipboard to ' + JSON.stringify(String(text ?? ''));
|
|
185
|
+
const result = await run(['osascript', '-e', script]);
|
|
186
|
+
return { ok: result.code === 0, error: result.code === 0 ? undefined : `osascript failed: ${String(result.stderr ?? '').slice(-300)}` };
|
|
187
|
+
}
|
|
188
|
+
const result = await run(argv);
|
|
189
|
+
return { ok: result.code === 0, error: result.code === 0 ? undefined : `clipboard command failed (exit ${result.code}): ${String(result.stderr ?? '').slice(-300)}` };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mount both routes on the host's Connection fetch registry.
|
|
194
|
+
*
|
|
195
|
+
* @param {object} ctx Cordis context
|
|
196
|
+
* @param {{platform?: NodeJS.Platform, run?: typeof runCommand, capture?: typeof captureHostScreen}} [deps]
|
|
197
|
+
* @returns {{mounted: boolean, reason: string|null}}
|
|
198
|
+
*/
|
|
199
|
+
export function registerCaptureRoutes(ctx, deps = {}) {
|
|
200
|
+
const status = { mounted: false, reason: 'the host has no Connection service' };
|
|
201
|
+
if (typeof ctx?.inject !== 'function') return status;
|
|
202
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
203
|
+
const registry = webCtx?.connection?.fetch;
|
|
204
|
+
if (registry === undefined || typeof registry.register !== 'function') {
|
|
205
|
+
status.reason = 'the connection service carries no fetch registry';
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const platform = deps.platform ?? process.platform;
|
|
209
|
+
const run = deps.run ?? runCommand;
|
|
210
|
+
const capture = deps.capture ?? captureHostScreen;
|
|
211
|
+
registry.register({
|
|
212
|
+
path: CAPTURE_ROUTE_PATH,
|
|
213
|
+
methods: ['POST'],
|
|
214
|
+
requestBody: 'buffered',
|
|
215
|
+
fetch: async (request) => {
|
|
216
|
+
let body = null;
|
|
217
|
+
try { body = await request.json(); } catch { /* defaults below */ }
|
|
218
|
+
const copyToClipboard = body?.copy === true;
|
|
219
|
+
const result = await capture({ platform, run, copyToClipboard });
|
|
220
|
+
return Response.json(result, { status: result.ok === true ? 200 : 502, headers: NO_STORE });
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
registry.register({
|
|
224
|
+
path: CLIPBOARD_ROUTE_PATH,
|
|
225
|
+
methods: ['POST'],
|
|
226
|
+
requestBody: 'buffered',
|
|
227
|
+
fetch: async (request) => {
|
|
228
|
+
let body = null;
|
|
229
|
+
try { body = await request.json(); } catch { /* refused below */ }
|
|
230
|
+
if (body === null || typeof body?.text !== 'string' || body.text === '') {
|
|
231
|
+
return Response.json({ ok: false, error: 'a JSON body with {"text": "..."} is required' }, { status: 400, headers: NO_STORE });
|
|
232
|
+
}
|
|
233
|
+
// Keep the fallback bounded: a client that asks to copy a megabyte is asking for trouble.
|
|
234
|
+
const text = body.text.slice(0, 1_000_000);
|
|
235
|
+
const result = await copyHostText(text, { platform, run });
|
|
236
|
+
return Response.json(result, { status: result.ok === true ? 200 : 502, headers: NO_STORE });
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
status.mounted = true;
|
|
240
|
+
status.reason = null;
|
|
241
|
+
});
|
|
242
|
+
return status;
|
|
243
|
+
}
|
|
@@ -585,6 +585,9 @@ export function buildUiState(deps) {
|
|
|
585
585
|
temporary: relays.temporary === true,
|
|
586
586
|
// v1.6.2: whole-relay overview (fleets + online machines) for the panel header line.
|
|
587
587
|
server: relays.server ?? null,
|
|
588
|
+
// v1.7.2: only the configured primary's totals; null means "unavailable", never the backup's.
|
|
589
|
+
server_url: relays.server_url ?? null,
|
|
590
|
+
server_reachable: relays.server_reachable !== false,
|
|
588
591
|
},
|
|
589
592
|
};
|
|
590
593
|
}
|
|
@@ -807,10 +810,25 @@ function mountTargetRoutes(connection, deps, status, logger) {
|
|
|
807
810
|
return { ...item, role: reachableSeen === 1 ? 'primary' : 'standby' };
|
|
808
811
|
});
|
|
809
812
|
const folded = foldRelayItems(items, 3);
|
|
810
|
-
// v1.
|
|
811
|
-
|
|
812
|
-
//
|
|
813
|
-
|
|
813
|
+
// v1.7.2: whole-server totals come from the CONFIGURED primary relay only. v1.6.2 used "the first
|
|
814
|
+
// relay that answered", so when the primary was unreachable the panel showed the BACKUP relay's
|
|
815
|
+
// numbers (1/1 fleets, 2/5 machines) under the "whole server" label -- visibly wrong. An
|
|
816
|
+
// unreachable primary reports `server: null` + `server_reachable: false`, and the panel says the
|
|
817
|
+
// totals are unavailable instead of presenting another relay's fleet as the server's.
|
|
818
|
+
const normalize = (value) => (typeof value === 'string' ? value.trim().replace(/\/+$/, '') : null);
|
|
819
|
+
const configuredPrimary = normalize(deps.cfg()?.rabbitUrl) ?? normalize(urls[0]);
|
|
820
|
+
const primaryItem = configuredPrimary === null
|
|
821
|
+
? items.find((item) => item.ok === true) ?? null
|
|
822
|
+
: items.find((item) => normalize(item.url) === configuredPrimary) ?? null;
|
|
823
|
+
const serverReachable = primaryItem?.ok === true;
|
|
824
|
+
const primaryStats = serverReachable ? primaryItem.stats : null;
|
|
825
|
+
const withMark = {
|
|
826
|
+
...folded,
|
|
827
|
+
temporary: deps.cfg()?.relayTemporary === true,
|
|
828
|
+
server: primaryStats,
|
|
829
|
+
server_url: configuredPrimary ?? primaryItem?.url ?? null,
|
|
830
|
+
server_reachable: serverReachable,
|
|
831
|
+
};
|
|
814
832
|
relayCache = { at: now, value: withMark };
|
|
815
833
|
return withMark;
|
|
816
834
|
};
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -191,6 +191,7 @@ import {
|
|
|
191
191
|
import { registerChatRoutes } from './fleet-chat-route.mjs';
|
|
192
192
|
import { registerExecCapabilityRoutes } from './exec-capability-route.mjs';
|
|
193
193
|
import { registerPluginUpdateRoutes } from './update-route.mjs';
|
|
194
|
+
import { registerCaptureRoutes } from './capture-route.mjs';
|
|
194
195
|
import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
|
|
195
196
|
import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
|
|
196
197
|
import { renderMemoryView } from './memory-view.mjs';
|
|
@@ -282,7 +283,7 @@ const MAX_STAGES = 16;
|
|
|
282
283
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
283
284
|
* overrides it at runtime for tests and packagers.
|
|
284
285
|
*/
|
|
285
|
-
const PLUGIN_VERSION = '1.7.
|
|
286
|
+
const PLUGIN_VERSION = '1.7.2';
|
|
286
287
|
|
|
287
288
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
288
289
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -3517,6 +3518,11 @@ export async function apply(ctx, config = {}) {
|
|
|
3517
3518
|
// v1.6.8: the plugin panel's "更新" button: GET checks npm latest, POST installs through the
|
|
3518
3519
|
// verified updater (SHA-256 checked, old package backed up, restart required afterwards).
|
|
3519
3520
|
const _updateRoutes = registerPluginUpdateRoutes(ctx, { profileDir: () => cfg.profileDir });
|
|
3521
|
+
|
|
3522
|
+
// v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
|
|
3523
|
+
// renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
|
|
3524
|
+
// depend on those permissions.
|
|
3525
|
+
registerCaptureRoutes(ctx);
|
|
3520
3526
|
if (capabilityRoutes.mounted !== true) {
|
|
3521
3527
|
ctx.logger?.warn?.('w2m: the execution-capability control is inactive — ' + capabilityRoutes.reason);
|
|
3522
3528
|
}
|
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
statSync,
|
|
69
69
|
} from 'node:fs';
|
|
70
70
|
import { homedir } from 'node:os';
|
|
71
|
-
import { basename, join, resolve } from 'node:path';
|
|
71
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
72
72
|
import { gunzipSync } from 'node:zlib';
|
|
73
73
|
|
|
74
74
|
/** How much of the installer's stderr is preserved when it fails. */
|
|
@@ -225,19 +225,73 @@ export function resolveDshPath(env = process.env) {
|
|
|
225
225
|
* @param {Record<string,string|undefined>} [env]
|
|
226
226
|
* @returns {string|null}
|
|
227
227
|
*/
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Roots that may hold a DSH runtime with its own pnpm.
|
|
231
|
+
*
|
|
232
|
+
* v1.7.1: the desktop app does not ship the CLI layout (`<DSH_HOME>/dsh-runtimes`); it bundles the
|
|
233
|
+
* runtime at `<app>/resources/runtime/<runtime>/dependencies/pnpm/bin/pnpm.mjs`. The v1.6.8 update
|
|
234
|
+
* button reported "no supported installer available" on every desktop install because only the
|
|
235
|
+
* CLI layout was searched. Both layouts are roots here, and the caller iterates their children.
|
|
236
|
+
*
|
|
237
|
+
* @param {{env?: NodeJS.ProcessEnv, execPath?: string, resourcesPath?: string}} [input]
|
|
238
|
+
* @returns {string[]}
|
|
239
|
+
*/
|
|
240
|
+
export function runtimeCandidates(input = {}) {
|
|
241
|
+
const env = input.env ?? process.env;
|
|
242
|
+
const execPath = input.execPath ?? process.execPath;
|
|
243
|
+
const resourcesPath = input.resourcesPath ?? process.resourcesPath;
|
|
244
|
+
const roots = [];
|
|
245
|
+
if (typeof env.W2M_RUNTIME_ROOT === 'string' && env.W2M_RUNTIME_ROOT.trim() !== '') roots.push(env.W2M_RUNTIME_ROOT.trim());
|
|
246
|
+
if (typeof resourcesPath === 'string' && resourcesPath !== '') roots.push(join(resourcesPath, 'runtime'));
|
|
247
|
+
try {
|
|
248
|
+
// Only the desktop app layout has `resources/` next to the executable; plain Node does not.
|
|
249
|
+
if (existsSync(join(dirname(execPath), 'resources'))) roots.push(join(dirname(execPath), 'resources', 'runtime'));
|
|
250
|
+
} catch { /* execPath is not a normal path */ }
|
|
251
|
+
roots.push(join(dshHome(env), 'dsh-runtimes'));
|
|
252
|
+
// The desktop shell may rewrite DSH_HOME; the per-user runtime directory is still the real one.
|
|
253
|
+
roots.push(join(homedir(), '.dsh', 'dsh-runtimes'));
|
|
254
|
+
return [...new Set(roots)];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The runtime layout puts pnpm under `<root>/<runtime>/dependencies/pnpm/bin/pnpm.mjs`; the CLI layout
|
|
259
|
+
* is the same but its root already ends with `dsh-runtimes`. Both shapes are scanned.
|
|
260
|
+
*
|
|
261
|
+
* @param {string[]} roots
|
|
262
|
+
* @returns {string[]}
|
|
263
|
+
*/
|
|
264
|
+
function pnpmCandidates(roots) {
|
|
265
|
+
const candidates = [];
|
|
266
|
+
for (const root of roots) {
|
|
267
|
+
candidates.push(join(root, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'));
|
|
268
|
+
let entries = [];
|
|
269
|
+
try { entries = readdirSync(root); } catch { continue; }
|
|
270
|
+
for (const entry of entries) candidates.push(join(root, entry, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'));
|
|
271
|
+
}
|
|
272
|
+
return candidates;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Locate the runtime's own Node executable (the desktop app's executable is not Node). */
|
|
276
|
+
export function resolveRuntimeNode(env = process.env) {
|
|
277
|
+
const explicit = env.W2M_NODE && env.W2M_NODE.trim() !== '' ? env.W2M_NODE.trim() : null;
|
|
278
|
+
if (explicit) return existsSync(explicit) ? explicit : null;
|
|
279
|
+
const names = process.platform === 'win32' ? ['node.exe'] : ['node'];
|
|
280
|
+
for (const root of runtimeCandidates({ env })) {
|
|
281
|
+
for (const entry of (() => { try { return readdirSync(root); } catch { return []; } })()) {
|
|
282
|
+
for (const name of names) {
|
|
283
|
+
const candidate = join(root, entry, 'dependencies', 'node', 'bin', name);
|
|
284
|
+
if (existsSync(candidate)) return candidate;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
228
290
|
export function resolveRuntimePnpm(env = process.env) {
|
|
229
291
|
const explicit = env.W2M_PNPM && env.W2M_PNPM.trim() !== '' ? env.W2M_PNPM.trim() : null;
|
|
230
292
|
if (explicit) return existsSync(explicit) ? explicit : null;
|
|
231
293
|
|
|
232
|
-
const
|
|
233
|
-
let runtimes;
|
|
234
|
-
try {
|
|
235
|
-
runtimes = readdirSync(runtimesDir);
|
|
236
|
-
} catch {
|
|
237
|
-
return null;
|
|
238
|
-
}
|
|
239
|
-
for (const runtime of runtimes) {
|
|
240
|
-
const candidate = join(runtimesDir, runtime, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs');
|
|
294
|
+
for (const candidate of pnpmCandidates(runtimeCandidates({ env }))) {
|
|
241
295
|
if (existsSync(candidate)) return candidate;
|
|
242
296
|
}
|
|
243
297
|
return null;
|
|
@@ -449,7 +503,8 @@ export async function installUpdate(options = {}) {
|
|
|
449
503
|
const stagedTarball = join(stagingDir, basename(tarballPath));
|
|
450
504
|
const dshPath = options.dshPath !== undefined ? options.dshPath : resolveDshPath();
|
|
451
505
|
const pnpmPath = options.pnpmPath !== undefined ? options.pnpmPath : resolveRuntimePnpm();
|
|
452
|
-
|
|
506
|
+
// v1.7.1: in the desktop shell `process.execPath` is the app, not Node; pnpm.mjs cannot run on it.
|
|
507
|
+
const nodePath = options.nodePath ?? resolveRuntimeNode() ?? process.execPath;
|
|
453
508
|
const runner = options.runner ?? createChildProcessRunner(options.runnerOptions);
|
|
454
509
|
|
|
455
510
|
// ---- 1. verify --------------------------------------------------------
|