dsh-chrome-bridge 1.13.0 → 1.14.0
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/client.js +6 -4
- package/index.js +25 -13
- package/package.json +1 -1
- package/panel.js +3 -3
- package/setup.js +130 -31
- package/vendor/cb +27 -16
- package/vendor/host/host.js +24 -12
package/client.js
CHANGED
|
@@ -40,7 +40,7 @@ window.__ModuleLoader__.load({
|
|
|
40
40
|
connected: '已连接',
|
|
41
41
|
paused: '已暂停',
|
|
42
42
|
disconnected: '未连接',
|
|
43
|
-
unsupported: '目前只支持 macOS',
|
|
43
|
+
unsupported: '目前只支持 macOS、Windows、Linux',
|
|
44
44
|
summaryOn: '已连接 · 正在控制 %n 个标签页',
|
|
45
45
|
summaryIdle: '已连接,没有在控制的标签页',
|
|
46
46
|
summaryOff: '还没连上 Chrome,点开看怎么装',
|
|
@@ -70,6 +70,7 @@ window.__ModuleLoader__.load({
|
|
|
70
70
|
openInstall: '帮我打开安装页',
|
|
71
71
|
installOpened: '已在 Chrome 里打开扩展页',
|
|
72
72
|
pathCopied: ',文件夹路径也复制好了(文件框里按 ⌘⇧G 再 ⌘V)',
|
|
73
|
+
pathCopiedWin: ',文件夹路径也复制好了(粘进文件框顶上的地址栏、回车)',
|
|
73
74
|
plugin: '插件 v%v',
|
|
74
75
|
guide: '使用说明',
|
|
75
76
|
failed: '操作没成功:%e',
|
|
@@ -81,7 +82,7 @@ window.__ModuleLoader__.load({
|
|
|
81
82
|
connected: 'Connected',
|
|
82
83
|
paused: 'Paused',
|
|
83
84
|
disconnected: 'Not connected',
|
|
84
|
-
unsupported: 'macOS
|
|
85
|
+
unsupported: 'Supports macOS, Windows and Linux only',
|
|
85
86
|
summaryOn: 'Connected · controlling %n tab(s)',
|
|
86
87
|
summaryIdle: 'Connected, no tabs under control',
|
|
87
88
|
summaryOff: 'Not connected to Chrome yet — open for setup steps',
|
|
@@ -111,6 +112,7 @@ window.__ModuleLoader__.load({
|
|
|
111
112
|
openInstall: 'Open the install page for me',
|
|
112
113
|
installOpened: 'Opened the extensions page in Chrome',
|
|
113
114
|
pathCopied: '; the folder path is on the clipboard (⌘⇧G then ⌘V in the file picker)',
|
|
115
|
+
pathCopiedWin: '; the folder path is on the clipboard (paste it into the file picker\'s address bar)',
|
|
114
116
|
plugin: 'Plugin v%v',
|
|
115
117
|
guide: 'Guide',
|
|
116
118
|
failed: 'That did not work: %e',
|
|
@@ -206,7 +208,7 @@ window.__ModuleLoader__.load({
|
|
|
206
208
|
|
|
207
209
|
function phaseOf(s) {
|
|
208
210
|
if (!s) return 'checking'
|
|
209
|
-
if (s.platform &&
|
|
211
|
+
if (s.platform && !['darwin', 'win32', 'linux'].includes(s.platform)) return 'unsupported'
|
|
210
212
|
if (!s.connected) return 'off'
|
|
211
213
|
return s.paused ? 'paused' : 'on'
|
|
212
214
|
}
|
|
@@ -274,7 +276,7 @@ window.__ModuleLoader__.load({
|
|
|
274
276
|
h('li', null, t('setupStep3'))),
|
|
275
277
|
state.store ? null : h('p', { className: 'dcb-hint' }, t('setupStoreAlt'))),
|
|
276
278
|
state.store ? null : h(Btn, { primary: true, disabled: busy, onClick: () => run({ action: 'install', via: 'store' }, t('storeOpened')) }, t('openStore')),
|
|
277
|
-
h(Btn, { primary: !!state.store, disabled: busy, onClick: () => run({ action: 'install' }, (s) => t('installOpened') + (s.assist && s.assist.clipboard ? t('pathCopied') : '')) }, t('openInstall')))) : null
|
|
279
|
+
h(Btn, { primary: !!state.store, disabled: busy, onClick: () => run({ action: 'install' }, (s) => t('installOpened') + (s.assist && s.assist.clipboard ? t(state.platform === 'win32' ? 'pathCopiedWin' : 'pathCopied') : '')) }, t('openInstall')))) : null
|
|
278
280
|
|
|
279
281
|
const switches = state && state.connected ? h('div', { className: 'dcb-card' },
|
|
280
282
|
h('div', { className: 'dcb-row' },
|
package/index.js
CHANGED
|
@@ -35,18 +35,29 @@ import { drag } from './drag.js'
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
// socket / token / 日志都在只有当前用户进得去的 /tmp/dsh-bridge-<uid>/(和宿主、cb 同一个约定):
|
|
38
|
-
// /tmp
|
|
38
|
+
// /tmp 人人可写,固定文件名会被别的用户抢先占位,或者用软链接把写入引到别处。
|
|
39
|
+
// Windows:目录是 %TEMP%\dsh-bridge(本来就只属于当前用户);没有 Unix socket,宿主每次起一个
|
|
40
|
+
// 随机名字的命名管道,名字写在 token 文件第二行
|
|
41
|
+
const WIN = process.platform === 'win32'
|
|
39
42
|
const UID = typeof process.getuid === 'function' ? process.getuid() : null
|
|
40
|
-
const DIR = process.env.DSH_BRIDGE_DIR || `/tmp/dsh-bridge-${UID ?? 'user'}`
|
|
41
|
-
const SOCK = process.env.DSH_BRIDGE_SOCK || path.join(DIR, 'dsh-bridge.sock')
|
|
43
|
+
const DIR = process.env.DSH_BRIDGE_DIR || (WIN ? path.join(os.tmpdir(), 'dsh-bridge') : `/tmp/dsh-bridge-${UID ?? 'user'}`)
|
|
42
44
|
const TOKEN_FILE = process.env.DSH_BRIDGE_TOKEN || path.join(DIR, 'dsh-bridge.token')
|
|
45
|
+
const FIXED_SOCK = process.env.DSH_BRIDGE_SOCK || (WIN ? null : path.join(DIR, 'dsh-bridge.sock'))
|
|
46
|
+
|
|
47
|
+
function tokenLines() {
|
|
48
|
+
try { return fs.readFileSync(TOKEN_FILE, 'utf8').split('\n').map((l) => l.trim()) } catch { return [] }
|
|
49
|
+
}
|
|
50
|
+
/** 宿主在听的地址(Windows 上每次启动都换) */
|
|
51
|
+
function hostSock() { return FIXED_SOCK || tokenLines()[1] || null }
|
|
52
|
+
/** 宿主起没起:socket 文件在不在;Windows 看 token 文件里有没有管道名(宿主退出时会删掉) */
|
|
53
|
+
function hostUp() { const s = hostSock(); return Boolean(s) && (WIN || fs.existsSync(s)) }
|
|
43
54
|
const TRACE = path.join(DIR, 'plugin.log')
|
|
44
55
|
const { O_WRONLY, O_CREAT, O_APPEND, O_NOFOLLOW } = fs.constants
|
|
45
56
|
|
|
46
57
|
/** 目录必须是自己的真目录(不是软链接)、别人进不去:不然连上的可能是别人摆的假宿主 */
|
|
47
58
|
function checkDir(dir) {
|
|
48
59
|
const st = fs.lstatSync(dir)
|
|
49
|
-
if (!st.isDirectory() || (UID !== null && st.uid !== UID) || (st.mode & 0o077)) {
|
|
60
|
+
if (!st.isDirectory() || (!WIN && ((UID !== null && st.uid !== UID) || (st.mode & 0o077)))) {
|
|
50
61
|
throw new Error(`${dir} 不是只属于当前用户的目录,拒绝使用`)
|
|
51
62
|
}
|
|
52
63
|
}
|
|
@@ -180,11 +191,11 @@ function firstAid() {
|
|
|
180
191
|
if (aided) return ''
|
|
181
192
|
aided = true
|
|
182
193
|
try {
|
|
183
|
-
if (
|
|
194
|
+
if (hostUp()) return ''
|
|
184
195
|
const r = assistInstall()
|
|
185
196
|
const notes = []
|
|
186
197
|
if (r.opened) notes.push(`我已经帮你把 chrome://extensions 打开(后台标签页)${r.store ? ',看一眼 DSH Bridge 是不是启用着' : ''}`)
|
|
187
|
-
if (r.clipboard) notes.push('
|
|
198
|
+
if (r.clipboard) notes.push(`扩展文件夹的路径已经复制到剪贴板(${WIN ? '粘进文件框顶上的地址栏、回车' : '文件框里 ⌘⇧G 再 ⌘V 回车'}就到)`)
|
|
188
199
|
return notes.length ? `\n(${notes.join(';')})` : ''
|
|
189
200
|
} catch {
|
|
190
201
|
return ''
|
|
@@ -199,12 +210,12 @@ const HINT = () => installHint() + firstAid()
|
|
|
199
210
|
/** 一次请求一个连接,协议是换行分隔的 JSON(和 cb CLI 完全一致)。 */
|
|
200
211
|
function call(op, args = {}, timeoutMs = 60000) {
|
|
201
212
|
return new Promise((resolve, reject) => {
|
|
202
|
-
|
|
203
|
-
|
|
213
|
+
const SOCK = hostSock()
|
|
214
|
+
if (!hostUp()) {
|
|
215
|
+
return reject(new Error(`浏览器桥没在跑(找不到 ${SOCK || TOKEN_FILE}):Chrome 没开,或扩展还没加载。\n${HINT()}`))
|
|
204
216
|
}
|
|
205
|
-
try { checkDir(path.dirname(SOCK)) } catch (e) { return reject(e) }
|
|
206
|
-
|
|
207
|
-
try { token = fs.readFileSync(TOKEN_FILE, 'utf8').trim() } catch {}
|
|
217
|
+
try { checkDir(WIN ? DIR : path.dirname(SOCK)) } catch (e) { return reject(e) }
|
|
218
|
+
const token = tokenLines()[0] || null
|
|
208
219
|
const id = crypto.randomUUID()
|
|
209
220
|
const sock = net.connect(SOCK)
|
|
210
221
|
let acc = ''
|
|
@@ -293,6 +304,7 @@ async function emulateFocus(tabId) {
|
|
|
293
304
|
* 只发 reload,不发别的。
|
|
294
305
|
*/
|
|
295
306
|
function nudgeLegacyHost() {
|
|
307
|
+
if (WIN) return
|
|
296
308
|
try {
|
|
297
309
|
const legacy = '/tmp/dsh-bridge.sock'
|
|
298
310
|
const tokenFile = '/tmp/dsh-bridge.token'
|
|
@@ -316,7 +328,7 @@ function nudgeLegacyHost() {
|
|
|
316
328
|
* reload 只会白白掐断宿主一下;而商店版本号常常落后本地,不跳过就会每次启动都重载。
|
|
317
329
|
*/
|
|
318
330
|
async function refreshExtension() {
|
|
319
|
-
if (!
|
|
331
|
+
if (!hostUp()) {
|
|
320
332
|
if (!process.env.DSH_BRIDGE_SOCK) nudgeLegacyHost()
|
|
321
333
|
return
|
|
322
334
|
}
|
|
@@ -450,7 +462,7 @@ export function apply(ctx, config) {
|
|
|
450
462
|
sub.effect(() => sub.webServer.register({
|
|
451
463
|
kind: 'exact',
|
|
452
464
|
path: PANEL_ROUTE,
|
|
453
|
-
handler: panelHandler({ call, sock:
|
|
465
|
+
handler: panelHandler({ call, sock: hostUp, origin: () => sub.webServer.getOrigin(), trace }),
|
|
454
466
|
}), 'chrome-bridge: settings panel route')
|
|
455
467
|
trace(`设置页路由已挂上 ${PANEL_ROUTE}`)
|
|
456
468
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-chrome-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "DSH plugin: native browser_* tools that drive the Chrome you are already using — over native messaging + CDP. No WebSocket, no port, no Connect button. The native host and the MV3 extension ship inside this package and are installed automatically on first load; the only manual step left is picking ~/dsh-bridge-extension once at chrome://extensions (Chrome ≥137 has no programmatic extension install). Until then browser_* tools fail loudly with those exact steps.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/panel.js
CHANGED
|
@@ -25,7 +25,7 @@ const LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
|
|
|
25
25
|
/**
|
|
26
26
|
* 当前状态。连不上时也要答得出「差哪一步」,所以安装情况单独给。
|
|
27
27
|
* @param call - socket 调用(index.js 的 call)
|
|
28
|
-
* @param sock - socket
|
|
28
|
+
* @param sock - 宿主在不在的判断函数(或 socket 路径);不在就是宿主没起(= 扩展没在 Chrome 里跑)
|
|
29
29
|
*/
|
|
30
30
|
export async function panelState(call, sock) {
|
|
31
31
|
const inst = installState()
|
|
@@ -36,7 +36,7 @@ export async function panelState(call, sock) {
|
|
|
36
36
|
extDir: paths().extDir,
|
|
37
37
|
connected: false,
|
|
38
38
|
}
|
|
39
|
-
if (!fs.existsSync(sock)) return base
|
|
39
|
+
if (!(typeof sock === 'function' ? sock() : fs.existsSync(sock))) return base
|
|
40
40
|
let ver
|
|
41
41
|
try {
|
|
42
42
|
ver = await call('version', {}, 3000)
|
|
@@ -87,7 +87,7 @@ function readJson(req, limit = 4096) {
|
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
89
|
* @param deps.call - socket 调用
|
|
90
|
-
* @param deps.sock - socket
|
|
90
|
+
* @param deps.sock - 宿主在不在的判断函数(或 socket 路径)
|
|
91
91
|
* @param deps.origin - DSH 网页端自己的 origin(webServer.getOrigin)
|
|
92
92
|
* @param deps.trace - 日志
|
|
93
93
|
*/
|
package/setup.js
CHANGED
|
@@ -22,7 +22,7 @@ import fs from 'node:fs'
|
|
|
22
22
|
import os from 'node:os'
|
|
23
23
|
import path from 'node:path'
|
|
24
24
|
import crypto from 'node:crypto'
|
|
25
|
-
import { execFileSync } from 'node:child_process'
|
|
25
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
26
26
|
import { fileURLToPath } from 'node:url'
|
|
27
27
|
|
|
28
28
|
export const HOST_NAME = 'com.dsh.bridge'
|
|
@@ -44,16 +44,34 @@ export function vendorDir() {
|
|
|
44
44
|
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'vendor')
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
export
|
|
47
|
+
/** 支持的系统。Windows 的原生宿主注册走注册表,其余两家是固定目录里的 JSON。 */
|
|
48
|
+
export const PLATFORMS = ['darwin', 'win32', 'linux']
|
|
49
|
+
|
|
50
|
+
/** Windows 上 Chrome(和 Edge,它也能装 Chrome 应用商店的扩展)找原生宿主的注册表项 */
|
|
51
|
+
export const REG_KEYS = [
|
|
52
|
+
`HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_NAME}`,
|
|
53
|
+
`HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${HOST_NAME}`,
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/** 所有落盘位置。home、platform 可覆盖,方便测试。 */
|
|
57
|
+
export function paths(home = os.homedir(), platform = process.platform) {
|
|
58
|
+
const win = platform === 'win32'
|
|
49
59
|
const bridge = path.join(home, '.dsh/chrome-bridge')
|
|
50
60
|
const extDir = path.join(home, EXT_DIR_NAME)
|
|
51
|
-
const
|
|
52
|
-
const
|
|
61
|
+
const hostDir = path.join(bridge, 'host')
|
|
62
|
+
const chromeDir = win
|
|
63
|
+
? path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'Google', 'Chrome', 'User Data')
|
|
64
|
+
: platform === 'linux'
|
|
65
|
+
? path.join(home, '.config/google-chrome')
|
|
66
|
+
: path.join(home, 'Library/Application Support/Google/Chrome')
|
|
67
|
+
// Windows 的注册文件放哪都行(注册表指过去),就放在宿主旁边
|
|
68
|
+
const nmDir = win ? bridge : path.join(chromeDir, 'NativeMessagingHosts')
|
|
53
69
|
return {
|
|
54
70
|
home,
|
|
55
71
|
bridge,
|
|
56
|
-
hostDir
|
|
72
|
+
hostDir,
|
|
73
|
+
// Chrome 拉起宿主用的脚本:Windows 是装的时候现写的 run.bat,其余是自带的 run.sh
|
|
74
|
+
launcher: path.join(hostDir, win ? 'run.bat' : 'run.sh'),
|
|
57
75
|
extDir,
|
|
58
76
|
chromeDir,
|
|
59
77
|
nmDir,
|
|
@@ -129,6 +147,34 @@ export function storeExtension(home = os.homedir()) {
|
|
|
129
147
|
return null
|
|
130
148
|
}
|
|
131
149
|
|
|
150
|
+
/** Windows 注册表里某一项的默认值;读不到回 null */
|
|
151
|
+
function regValue(key) {
|
|
152
|
+
try {
|
|
153
|
+
const out = execFileSync('reg', ['query', key, '/ve'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })
|
|
154
|
+
const m = /REG_SZ\s+(.*)$/m.exec(out)
|
|
155
|
+
return m ? m[1].trim() : null
|
|
156
|
+
} catch { return null }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Windows 的宿主启动脚本。Chrome 用 cmd 跑它,stdout 就是 native messaging 管道,所以脚本自己一个字都不能往外打。
|
|
161
|
+
* node 用 DSH 自己的(桌面版是 Electron,要 ELECTRON_RUN_AS_NODE),没了再找 PATH 里的;DSH 升级换了路径,
|
|
162
|
+
* 插件下次加载会重写这个文件。路径可能带中文,先把代码页切到 UTF-8 再读后面的行。
|
|
163
|
+
*/
|
|
164
|
+
export function winLauncher(execPath = process.execPath, electron = Boolean(process.versions.electron)) {
|
|
165
|
+
return [
|
|
166
|
+
'@echo off',
|
|
167
|
+
'chcp 65001 >nul',
|
|
168
|
+
'setlocal',
|
|
169
|
+
...(electron ? ['set ELECTRON_RUN_AS_NODE=1'] : []),
|
|
170
|
+
`set "N=${execPath}"`,
|
|
171
|
+
'if defined DSH_BRIDGE_NODE set "N=%DSH_BRIDGE_NODE%"',
|
|
172
|
+
'if not exist "%N%" for %%I in (node.exe) do set "N=%%~$PATH:I"',
|
|
173
|
+
'"%N%" "%~dp0host.js" %*',
|
|
174
|
+
'',
|
|
175
|
+
].join('\r\n')
|
|
176
|
+
}
|
|
177
|
+
|
|
132
178
|
/** 装好了没有(只看落盘结果,不管 Chrome 有没有加载扩展)。 */
|
|
133
179
|
export function installState(home = os.homedir()) {
|
|
134
180
|
const p = paths(home)
|
|
@@ -138,8 +184,8 @@ export function installState(home = os.homedir()) {
|
|
|
138
184
|
...p,
|
|
139
185
|
extId,
|
|
140
186
|
extensionCopied: !!extId,
|
|
141
|
-
hostCopied: fs.existsSync(
|
|
142
|
-
registered: fs.existsSync(p.nmFile),
|
|
187
|
+
hostCopied: fs.existsSync(p.launcher),
|
|
188
|
+
registered: fs.existsSync(p.nmFile) && (process.platform !== 'win32' || regValue(REG_KEYS[0]) !== null),
|
|
143
189
|
vendor: fs.existsSync(vendorDir()),
|
|
144
190
|
store: storeExtension(home),
|
|
145
191
|
}
|
|
@@ -160,9 +206,10 @@ export function ensureInstalled(options = {}) {
|
|
|
160
206
|
const p = paths(home)
|
|
161
207
|
const out = { ok: false, changed: false, home, vendor, ...p }
|
|
162
208
|
|
|
209
|
+
const win = process.platform === 'win32'
|
|
163
210
|
try {
|
|
164
|
-
if (process.platform
|
|
165
|
-
return { ...out, reason:
|
|
211
|
+
if (!PLATFORMS.includes(process.platform)) {
|
|
212
|
+
return { ...out, reason: `浏览器桥暂不支持 ${process.platform}(只支持 macOS、Windows、Linux)` }
|
|
166
213
|
}
|
|
167
214
|
const vExt = path.join(vendor, 'extension')
|
|
168
215
|
const vHost = path.join(vendor, 'host')
|
|
@@ -178,22 +225,32 @@ export function ensureInstalled(options = {}) {
|
|
|
178
225
|
const nmBody = `${JSON.stringify({
|
|
179
226
|
name: HOST_NAME,
|
|
180
227
|
description: 'dsh-chrome-bridge native host (local browser control)',
|
|
181
|
-
path:
|
|
228
|
+
path: p.launcher,
|
|
182
229
|
type: 'stdio',
|
|
183
230
|
allowed_origins: [extId, ...STORE_EXT_IDS].map((id) => `chrome-extension://${id}/`),
|
|
184
231
|
}, null, 2)}\n`
|
|
185
232
|
|
|
186
233
|
let nmPrev = null
|
|
187
234
|
try { nmPrev = fs.readFileSync(p.nmFile, 'utf8') } catch {}
|
|
188
|
-
const
|
|
235
|
+
const launcherBody = win ? winLauncher() : null
|
|
236
|
+
let launcherPrev = null
|
|
237
|
+
if (win) try { launcherPrev = fs.readFileSync(p.launcher, 'utf8') } catch {}
|
|
238
|
+
const winOk = !win || (launcherPrev === launcherBody && REG_KEYS.every((k) => regValue(k) === p.nmFile))
|
|
239
|
+
const upToDate = prev === stamp && nmPrev === nmBody && winOk && allPresent(vExt, p.extDir) && allPresent(vHost, p.hostDir)
|
|
189
240
|
if (!upToDate) {
|
|
190
241
|
fs.mkdirSync(p.extDir, { recursive: true })
|
|
191
242
|
fs.cpSync(vExt, p.extDir, { recursive: true, force: true })
|
|
192
243
|
fs.mkdirSync(p.hostDir, { recursive: true })
|
|
193
244
|
fs.cpSync(vHost, p.hostDir, { recursive: true, force: true })
|
|
194
|
-
fs.
|
|
245
|
+
if (win) fs.writeFileSync(p.launcher, launcherBody)
|
|
246
|
+
else fs.chmodSync(p.launcher, 0o755)
|
|
195
247
|
fs.mkdirSync(p.nmDir, { recursive: true })
|
|
196
248
|
fs.writeFileSync(p.nmFile, nmBody)
|
|
249
|
+
if (win) {
|
|
250
|
+
for (const k of REG_KEYS) {
|
|
251
|
+
execFileSync('reg', ['add', k, '/ve', '/t', 'REG_SZ', '/d', p.nmFile, '/f'], { stdio: 'ignore', windowsHide: true })
|
|
252
|
+
}
|
|
253
|
+
}
|
|
197
254
|
fs.writeFileSync(p.stampFile, `${stamp}\n`)
|
|
198
255
|
out.changed = true
|
|
199
256
|
}
|
|
@@ -201,13 +258,18 @@ export function ensureInstalled(options = {}) {
|
|
|
201
258
|
// ── 可选件:cb CLI、页内光标脚本 —— 失败只是少个便利,不该让安装失败 ──
|
|
202
259
|
// 刻意不动 ~/.dsh/skills:那是全局 agent 行为,装了插件就有原生 browser_* 工具,不需要它。
|
|
203
260
|
try {
|
|
204
|
-
copyFile(path.join(vendor, 'cb'), p.cb, 0o755)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
261
|
+
copyFile(path.join(vendor, 'cb'), p.cb, win ? undefined : 0o755)
|
|
262
|
+
if (win) {
|
|
263
|
+
// Windows 没有 shebang:旁边放个 cb.cmd(用 PATH 里的 node),也不去建 ~/.local/bin 的软链接
|
|
264
|
+
fs.writeFileSync(`${p.cb}.cmd`, '@echo off\r\nnode "%~dp0cb" %*\r\n')
|
|
265
|
+
} else {
|
|
266
|
+
fs.mkdirSync(path.dirname(p.cbLink), { recursive: true })
|
|
267
|
+
// ~/.local/bin/cb 可能是用户自己的另一个 cb:只在还没有时建链接;指向我们的链接本来就对,
|
|
268
|
+
// 别的东西(用户自己的文件 / 指向别处的链接)一律不碰
|
|
269
|
+
let cur = null
|
|
270
|
+
try { cur = fs.lstatSync(p.cbLink) } catch {}
|
|
271
|
+
if (!cur) fs.symlinkSync(p.cb, p.cbLink)
|
|
272
|
+
}
|
|
211
273
|
} catch {}
|
|
212
274
|
try {
|
|
213
275
|
copyFile(path.join(vendor, 'in-page-cursor.js'), p.cursorFile)
|
|
@@ -228,15 +290,15 @@ export function ensureInstalled(options = {}) {
|
|
|
228
290
|
export function installHint(home = os.homedir()) {
|
|
229
291
|
const s = installState(home)
|
|
230
292
|
if (!s.vendor) return '插件包里没有自带宿主:请重新用 release tarball 安装本插件(README 的 Install 一节)。'
|
|
231
|
-
if (process.platform
|
|
232
|
-
return
|
|
293
|
+
if (!PLATFORMS.includes(process.platform)) {
|
|
294
|
+
return `浏览器桥暂不支持 ${process.platform}(只支持 macOS、Windows、Linux)。`
|
|
233
295
|
}
|
|
234
296
|
if (!s.hostCopied || !s.registered) {
|
|
235
297
|
return [
|
|
236
298
|
'宿主或 native messaging 注册没写成功(多半是权限问题):',
|
|
237
299
|
`宿主目录 ${s.hostDir}${s.hostCopied ? ' ✅' : ' ❌'}`,
|
|
238
|
-
|
|
239
|
-
'
|
|
300
|
+
`注册${process.platform === 'win32' ? '(注册表 + 文件)' : '文件'} ${s.nmFile}${s.registered ? ' ✅' : ' ❌'}`,
|
|
301
|
+
'重启一次 DSH 会再试一遍;还不行就在 GitHub 提 issue。',
|
|
240
302
|
].join('\n')
|
|
241
303
|
}
|
|
242
304
|
if (s.store) {
|
|
@@ -255,15 +317,49 @@ export function installHint(home = os.homedir()) {
|
|
|
255
317
|
'· 或者加载已解压的扩展:',
|
|
256
318
|
' 1. Chrome 打开 chrome://extensions',
|
|
257
319
|
' 2. 右上角打开「开发者模式」',
|
|
258
|
-
|
|
320
|
+
` 3. 点「加载已解压的扩展程序」,${PASTE_TIP[process.platform] || PASTE_TIP.darwin},选这个文件夹:`,
|
|
259
321
|
` ${s.extDir}`,
|
|
260
322
|
'装好后回到这里重试;宿主和 native 注册已经自动装好了,不用再跑任何脚本。',
|
|
261
323
|
].join('\n')
|
|
262
324
|
}
|
|
263
325
|
|
|
326
|
+
/** 文件框里怎么直接粘路径 */
|
|
327
|
+
const PASTE_TIP = {
|
|
328
|
+
darwin: '在文件框里按 ⌘⇧G、粘贴、回车',
|
|
329
|
+
win32: '把路径粘进文件框顶上的地址栏、回车',
|
|
330
|
+
linux: '在文件框里按 Ctrl+L、粘贴、回车',
|
|
331
|
+
}
|
|
332
|
+
|
|
264
333
|
/** Chrome 在不在跑(决定要不要顺手把设置页打开)。 */
|
|
265
334
|
function chromeRunning() {
|
|
266
|
-
try {
|
|
335
|
+
try {
|
|
336
|
+
if (process.platform === 'win32') {
|
|
337
|
+
const out = execFileSync('tasklist', ['/FI', 'IMAGENAME eq chrome.exe', '/NH'], { encoding: 'utf8', windowsHide: true })
|
|
338
|
+
return /chrome\.exe/i.test(out)
|
|
339
|
+
}
|
|
340
|
+
execFileSync('/usr/bin/pgrep', ['-x', process.platform === 'linux' ? 'chrome' : 'Google Chrome'], { stdio: 'ignore' })
|
|
341
|
+
return true
|
|
342
|
+
} catch { return false }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Windows / Linux 上 Chrome 的可执行文件(打开网址用);找不到回 null */
|
|
346
|
+
function chromeBin() {
|
|
347
|
+
const c = process.platform === 'win32'
|
|
348
|
+
? [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean)
|
|
349
|
+
.map((d) => path.join(d, 'Google', 'Chrome', 'Application', 'chrome.exe'))
|
|
350
|
+
: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/opt/google/chrome/chrome']
|
|
351
|
+
return c.find((f) => fs.existsSync(f)) || null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 用 Chrome 打开一个网址(macOS 可以不抢前台) */
|
|
355
|
+
function openInChrome(url, { background = false } = {}) {
|
|
356
|
+
if (process.platform === 'darwin') {
|
|
357
|
+
execFileSync('/usr/bin/open', [...(background ? ['-g'] : []), '-a', 'Google Chrome', url])
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
const bin = chromeBin()
|
|
361
|
+
if (!bin) throw new Error('找不到 Chrome')
|
|
362
|
+
spawn(bin, [url], { detached: true, stdio: 'ignore', windowsHide: false }).unref()
|
|
267
363
|
}
|
|
268
364
|
|
|
269
365
|
/**
|
|
@@ -281,20 +377,23 @@ export function assistInstall(home = os.homedir(), { viaStore = false } = {}) {
|
|
|
281
377
|
const out = { clipboard: false, opened: false, chromeRunning: chromeRunning(), store: !!store }
|
|
282
378
|
if (viaStore) {
|
|
283
379
|
try {
|
|
284
|
-
|
|
380
|
+
openInChrome(STORE_URL)
|
|
285
381
|
out.opened = true
|
|
286
382
|
} catch {}
|
|
287
383
|
return out
|
|
288
384
|
}
|
|
289
|
-
|
|
385
|
+
// 剪贴板:macOS 用 pbcopy,Windows 用 clip(Linux 不一定有 xclip,就不塞了)
|
|
386
|
+
if (!store && process.platform !== 'linux') {
|
|
290
387
|
try {
|
|
291
|
-
execFileSync('
|
|
388
|
+
if (process.platform === 'win32') execFileSync('clip', [], { input: p.extDir, windowsHide: true })
|
|
389
|
+
else execFileSync('/usr/bin/pbcopy', [], { input: `${p.extDir}\n` })
|
|
292
390
|
out.clipboard = true
|
|
293
391
|
} catch {}
|
|
294
392
|
}
|
|
295
|
-
|
|
393
|
+
// 只有 macOS 能在后台开标签页;别的系统会把 Chrome 拉到前台,就不主动开了
|
|
394
|
+
if (out.chromeRunning && process.platform === 'darwin') {
|
|
296
395
|
try {
|
|
297
|
-
|
|
396
|
+
openInChrome('chrome://extensions', { background: true })
|
|
298
397
|
out.opened = true
|
|
299
398
|
} catch {}
|
|
300
399
|
}
|
package/vendor/cb
CHANGED
|
@@ -41,16 +41,25 @@ import fs from 'node:fs'
|
|
|
41
41
|
import path from 'node:path'
|
|
42
42
|
import os from 'node:os'
|
|
43
43
|
import crypto from 'node:crypto'
|
|
44
|
+
import { fileURLToPath } from 'node:url'
|
|
44
45
|
|
|
45
|
-
// 和宿主同一个约定:只有当前用户进得去的 /tmp/dsh-bridge-<uid
|
|
46
|
-
const
|
|
47
|
-
const
|
|
46
|
+
// 和宿主同一个约定:只有当前用户进得去的 /tmp/dsh-bridge-<uid>/(Windows:%TEMP%\dsh-bridge)
|
|
47
|
+
const WIN = process.platform === 'win32'
|
|
48
|
+
const DIR = process.env.DSH_BRIDGE_DIR || (WIN ? path.join(os.tmpdir(), 'dsh-bridge') : `/tmp/dsh-bridge-${process.getuid()}`)
|
|
48
49
|
const TOKEN_FILE = process.env.DSH_BRIDGE_TOKEN || path.join(DIR, 'dsh-bridge.token')
|
|
50
|
+
const FIXED_SOCK = process.env.DSH_BRIDGE_SOCK || (WIN ? null : path.join(DIR, 'dsh-bridge.sock'))
|
|
51
|
+
|
|
52
|
+
/** token 文件:第一行 token;Windows 上第二行是宿主这次起的命名管道 */
|
|
53
|
+
function tokenLines() {
|
|
54
|
+
try { return fs.readFileSync(TOKEN_FILE, 'utf8').split('\n').map((l) => l.trim()) } catch { return [] }
|
|
55
|
+
}
|
|
56
|
+
function hostSock() { return FIXED_SOCK || tokenLines()[1] || null }
|
|
57
|
+
function hostUp() { const s = hostSock(); return Boolean(s) && (WIN || fs.existsSync(s)) }
|
|
49
58
|
|
|
50
59
|
/** socket 所在目录必须是自己的、别人进不去:不然连上的可能是别人摆的假宿主(会把要打的字、要跑的 JS 都交出去) */
|
|
51
60
|
function checkDir(dir) {
|
|
52
61
|
const st = fs.lstatSync(dir)
|
|
53
|
-
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077)) {
|
|
62
|
+
if (!st.isDirectory() || (!WIN && (st.uid !== process.getuid() || (st.mode & 0o077)))) {
|
|
54
63
|
throw new Error(`${dir} 不是只属于当前用户的目录,拒绝连接`)
|
|
55
64
|
}
|
|
56
65
|
}
|
|
@@ -76,7 +85,7 @@ function die(msg) {
|
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
function readToken() {
|
|
79
|
-
|
|
88
|
+
return tokenLines()[0] || null
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
// 换目录之前的宿主只在这儿听:升级后 Chrome 没重启,它就还活着
|
|
@@ -89,6 +98,7 @@ const LEGACY_TOKEN = '/tmp/dsh-bridge.token'
|
|
|
89
98
|
*/
|
|
90
99
|
function nudgeLegacyHost() {
|
|
91
100
|
return new Promise((resolve) => {
|
|
101
|
+
if (WIN) return resolve(false)
|
|
92
102
|
try {
|
|
93
103
|
const st = fs.lstatSync(LEGACY_SOCK)
|
|
94
104
|
const tk = fs.lstatSync(LEGACY_TOKEN)
|
|
@@ -108,19 +118,20 @@ function nudgeLegacyHost() {
|
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
async function call(op, args = {}, timeoutMs = 60000) {
|
|
111
|
-
if (!
|
|
121
|
+
if (!hostUp() && !process.env.DSH_BRIDGE_SOCK && await nudgeLegacyHost()) {
|
|
112
122
|
// 扩展重载 + 新宿主起来一般一两秒;最多等 10 秒
|
|
113
|
-
for (let i = 0; i < 40 && !
|
|
114
|
-
if (op === 'reload' &&
|
|
123
|
+
for (let i = 0; i < 40 && !hostUp(); i++) await new Promise((r) => setTimeout(r, 250))
|
|
124
|
+
if (op === 'reload' && hostUp()) return { reloading: true } // 刚才那下就是重载,不用再来一次
|
|
115
125
|
}
|
|
116
126
|
return new Promise((resolve, reject) => {
|
|
117
|
-
|
|
118
|
-
|
|
127
|
+
const SOCK = hostSock()
|
|
128
|
+
if (!hostUp()) {
|
|
129
|
+
if (!WIN && fs.existsSync(LEGACY_SOCK)) {
|
|
119
130
|
return reject(new Error(`宿主没在跑(找不到 ${SOCK})。老版宿主还占着 ${LEGACY_SOCK}、叫不醒:重启一次 Chrome`))
|
|
120
131
|
}
|
|
121
|
-
return reject(new Error(`宿主没在跑(找不到 ${SOCK})。Chrome 开着吗?扩展装了吗?`))
|
|
132
|
+
return reject(new Error(`宿主没在跑(找不到 ${SOCK || TOKEN_FILE})。Chrome 开着吗?扩展装了吗?`))
|
|
122
133
|
}
|
|
123
|
-
try { checkDir(path.dirname(SOCK)) } catch (e) { return reject(e) }
|
|
134
|
+
try { checkDir(WIN ? DIR : path.dirname(SOCK)) } catch (e) { return reject(e) }
|
|
124
135
|
const token = readToken()
|
|
125
136
|
const id = crypto.randomUUID()
|
|
126
137
|
const sock = net.connect(SOCK)
|
|
@@ -156,7 +167,7 @@ function cursorScript() {
|
|
|
156
167
|
const cands = [
|
|
157
168
|
process.env.DSH_CURSOR_JS,
|
|
158
169
|
path.join(os.homedir(), '.dsh/chrome-cursor/in-page-cursor.js'),
|
|
159
|
-
path.join(path.dirname(
|
|
170
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), '../scratch/in-page-cursor.js'),
|
|
160
171
|
].filter(Boolean)
|
|
161
172
|
for (const p of cands) {
|
|
162
173
|
try { return fs.readFileSync(p, 'utf8') } catch {}
|
|
@@ -182,7 +193,7 @@ const USAGE = `cb <命令> [参数] 用 cb help 看完整用法`
|
|
|
182
193
|
|
|
183
194
|
async function main() {
|
|
184
195
|
if (!cmd || cmd === 'help' || flags.help) {
|
|
185
|
-
console.log(fs.readFileSync(
|
|
196
|
+
console.log(fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('*/')[0].replace(/^\/\*\*?/, '').replace(/^ \* ?/gm, '').trim())
|
|
186
197
|
return
|
|
187
198
|
}
|
|
188
199
|
|
|
@@ -313,7 +324,7 @@ async function main() {
|
|
|
313
324
|
case 'record': {
|
|
314
325
|
const tabId = await resolveTab(rest[0])
|
|
315
326
|
const ms = Number(flags.ms || 5000)
|
|
316
|
-
const dir = String(flags.out ||
|
|
327
|
+
const dir = String(flags.out || path.join(WIN ? os.tmpdir() : '/tmp', `dsh-frames-${Date.now()}`))
|
|
317
328
|
fs.mkdirSync(dir, { recursive: true })
|
|
318
329
|
const r = await call('screencast', {
|
|
319
330
|
tabId, durationMs: ms, maxWidth: Number(flags.width || 900), quality: Number(flags.quality || 70),
|
|
@@ -390,7 +401,7 @@ async function main() {
|
|
|
390
401
|
full: !!flags.full,
|
|
391
402
|
selector: flags.sel || undefined,
|
|
392
403
|
}, 90000)
|
|
393
|
-
const file = String(flags.out ||
|
|
404
|
+
const file = String(flags.out || path.join(WIN ? os.tmpdir() : '/tmp', `dsh-shot-${Date.now()}.${format === 'jpeg' ? 'jpg' : 'png'}`))
|
|
394
405
|
fs.writeFileSync(file, Buffer.from(r.data, 'base64'))
|
|
395
406
|
out(file, { file, format, url: r.url, title: r.title, bytes: fs.statSync(file).size, full: r.full, clip: r.clip })
|
|
396
407
|
return
|
package/vendor/host/host.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 两条腿:
|
|
6
6
|
* 1. stdin/stdout ← native messaging 协议(4 字节 LE 长度前缀 + JSON)→ Chrome 扩展
|
|
7
|
-
* 2. /tmp/dsh-bridge-<uid>/dsh-bridge.sock ← 换行分隔 JSON → DSH
|
|
7
|
+
* 2. /tmp/dsh-bridge-<uid>/dsh-bridge.sock ← 换行分隔 JSON → DSH 插件和 `cb` CLI
|
|
8
|
+
* (Windows 没有 Unix socket:每次起一个随机名字的命名管道,名字写在 token 文件第二行)
|
|
8
9
|
*
|
|
9
10
|
* 由 Chrome 拉起(扩展 connectNative 时)。stdin 关闭 = Chrome 没了 = 退出。
|
|
10
11
|
* 调试时可以手动跑:node host.js --standalone
|
|
@@ -12,13 +13,17 @@
|
|
|
12
13
|
|
|
13
14
|
import net from 'node:net'
|
|
14
15
|
import fs from 'node:fs'
|
|
16
|
+
import os from 'node:os'
|
|
15
17
|
import path from 'node:path'
|
|
16
18
|
import crypto from 'node:crypto'
|
|
17
19
|
|
|
18
20
|
// socket / token / 日志都放进只有当前用户进得去的目录(0700):/tmp 人人可写,
|
|
19
21
|
// 固定文件名会被别的用户抢先占位、或者用软链接把我们的写入引到别处。插件和 cb 用同一个约定。
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
+
// Windows 上 %TEMP% 本来就是每个用户自己的目录
|
|
23
|
+
const WIN = process.platform === 'win32'
|
|
24
|
+
const DIR = process.env.DSH_BRIDGE_DIR || (WIN ? path.join(os.tmpdir(), 'dsh-bridge') : `/tmp/dsh-bridge-${process.getuid()}`)
|
|
25
|
+
const SOCK = process.env.DSH_BRIDGE_SOCK ||
|
|
26
|
+
(WIN ? `\\\\.\\pipe\\dsh-bridge-${crypto.randomBytes(12).toString('hex')}` : path.join(DIR, 'dsh-bridge.sock'))
|
|
22
27
|
const TOKEN_FILE = process.env.DSH_BRIDGE_TOKEN || path.join(DIR, 'dsh-bridge.token')
|
|
23
28
|
const LOG_FILE = process.env.DSH_BRIDGE_LOG || path.join(DIR, 'dsh-bridge.log')
|
|
24
29
|
const TIMEOUT_MS = Number(process.env.DSH_BRIDGE_TIMEOUT_MS || 45000)
|
|
@@ -32,12 +37,12 @@ const TOKEN = crypto.randomBytes(24).toString('hex')
|
|
|
32
37
|
function privateDir(dir) {
|
|
33
38
|
try { fs.mkdirSync(dir, { mode: 0o700 }) } catch (e) { if (e.code !== 'EEXIST') throw e }
|
|
34
39
|
const st = fs.lstatSync(dir)
|
|
35
|
-
if (!st.isDirectory() || st.uid !== process.getuid()) throw new Error(`${dir} 不是当前用户自己的目录,拒绝使用`)
|
|
36
|
-
if (st.mode & 0o077) fs.chmodSync(dir, 0o700)
|
|
40
|
+
if (!st.isDirectory() || (!WIN && st.uid !== process.getuid())) throw new Error(`${dir} 不是当前用户自己的目录,拒绝使用`)
|
|
41
|
+
if (!WIN && (st.mode & 0o077)) fs.chmodSync(dir, 0o700)
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
try {
|
|
40
|
-
for (const d of new Set([SOCK, TOKEN_FILE, LOG_FILE].map((f) => path.dirname(f)))) privateDir(d)
|
|
45
|
+
for (const d of new Set([...(WIN ? [] : [SOCK]), TOKEN_FILE, LOG_FILE].map((f) => path.dirname(f)))) privateDir(d)
|
|
41
46
|
} catch (e) {
|
|
42
47
|
process.stderr.write(`dsh-bridge: ${e.message}\n`) // Chrome 会把宿主的 stderr 记进它自己的日志
|
|
43
48
|
process.exit(1)
|
|
@@ -57,7 +62,7 @@ function writeToken() {
|
|
|
57
62
|
const tmp = `${TOKEN_FILE}.${process.pid}.tmp`
|
|
58
63
|
try { fs.unlinkSync(tmp) } catch {}
|
|
59
64
|
const fd = fs.openSync(tmp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600)
|
|
60
|
-
try { fs.writeSync(fd, TOKEN + '\n') } finally { fs.closeSync(fd) }
|
|
65
|
+
try { fs.writeSync(fd, TOKEN + '\n' + (WIN ? SOCK + '\n' : '')) } finally { fs.closeSync(fd) }
|
|
61
66
|
fs.renameSync(tmp, TOKEN_FILE)
|
|
62
67
|
}
|
|
63
68
|
|
|
@@ -186,7 +191,7 @@ function handleLine(sock, line) {
|
|
|
186
191
|
|
|
187
192
|
function startServer() {
|
|
188
193
|
try {
|
|
189
|
-
if (fs.existsSync(SOCK)) {
|
|
194
|
+
if (!WIN && fs.existsSync(SOCK)) {
|
|
190
195
|
// 探测一下是不是活着的旧实例;活着也让路(接管),否则清掉陈旧文件
|
|
191
196
|
try {
|
|
192
197
|
const probe = net.connect(SOCK)
|
|
@@ -215,8 +220,10 @@ function startServer() {
|
|
|
215
220
|
server.on('error', (e) => { log('socket 服务出错:', String(e)); shutdown(1) })
|
|
216
221
|
server.listen(SOCK, () => {
|
|
217
222
|
listening = true
|
|
218
|
-
|
|
219
|
-
|
|
223
|
+
if (!WIN) {
|
|
224
|
+
fs.chmodSync(SOCK, 0o600)
|
|
225
|
+
try { const st = fs.lstatSync(SOCK); ownSock = { dev: st.dev, ino: st.ino } } catch {}
|
|
226
|
+
}
|
|
220
227
|
try { writeToken() } catch (e) { log('写 token 失败:', String(e)); shutdown(1) }
|
|
221
228
|
log(`监听 ${SOCK}(pid ${process.pid}, standalone=${STANDALONE})`)
|
|
222
229
|
})
|
|
@@ -231,8 +238,13 @@ function shutdown(code) {
|
|
|
231
238
|
// 不调 server.close():Node 关 Unix socket 服务时会按路径无条件 unlink(实测),
|
|
232
239
|
// 正好会删掉后来接管的新宿主的 socket。反正马上退出,fd 由系统回收。
|
|
233
240
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
if (WIN) {
|
|
242
|
+
// 命名管道随进程消失;token 文件还指着自己的管道就删掉,免得客户端连一个不存在的管道
|
|
243
|
+
if (fs.readFileSync(TOKEN_FILE, 'utf8').split('\n')[1] === SOCK) fs.unlinkSync(TOKEN_FILE)
|
|
244
|
+
} else {
|
|
245
|
+
const st = fs.lstatSync(SOCK)
|
|
246
|
+
if (ownSock && st.dev === ownSock.dev && st.ino === ownSock.ino) fs.unlinkSync(SOCK)
|
|
247
|
+
}
|
|
236
248
|
} catch {}
|
|
237
249
|
process.exit(code)
|
|
238
250
|
}
|