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 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 only for now',
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 && s.platform !== 'darwin') return 'unsupported'
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 (fs.existsSync(SOCK)) return ''
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('扩展文件夹的路径已经复制到剪贴板(文件框里 ⌘⇧G 再 ⌘V 回车就到)')
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
- if (!fs.existsSync(SOCK)) {
203
- return reject(new Error(`浏览器桥没在跑(找不到 ${SOCK}):Chrome 没开,或扩展还没加载。\n${HINT()}`))
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
- let token = null
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 (!fs.existsSync(SOCK)) {
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: SOCK, origin: () => sub.webServer.getOrigin(), trace }),
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.13.0",
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 路径;文件都不在就是宿主没起(= 扩展没在 Chrome 里跑)
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
- /** 所有落盘位置。home 可覆盖,方便测试。 */
48
- export function paths(home = os.homedir()) {
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 chromeDir = path.join(home, 'Library/Application Support/Google/Chrome')
52
- const nmDir = path.join(chromeDir, 'NativeMessagingHosts')
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: path.join(bridge, 'host'),
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(path.join(p.hostDir, 'run.sh')),
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 !== 'darwin') {
165
- return { ...out, reason: 'macOS only(native messaging 路径是 macOS 专有)' }
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: path.join(p.hostDir, 'run.sh'),
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 upToDate = prev === stamp && nmPrev === nmBody && allPresent(vExt, p.extDir) && allPresent(vHost, p.hostDir)
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.chmodSync(path.join(p.hostDir, 'run.sh'), 0o755)
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
- fs.mkdirSync(path.dirname(p.cbLink), { recursive: true })
206
- // ~/.local/bin/cb 可能是用户自己的另一个 cb:只在还没有时建链接;指向我们的链接本来就对,
207
- // 别的东西(用户自己的文件 / 指向别处的链接)一律不碰
208
- let cur = null
209
- try { cur = fs.lstatSync(p.cbLink) } catch {}
210
- if (!cur) fs.symlinkSync(p.cb, p.cbLink)
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 !== 'darwin') {
232
- return '浏览器桥只支持 macOS:native messaging 的注册路径是 macOS 专有的,其他系统上装不了宿主(插件本身不会静默失败)。'
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
- `注册文件 ${s.nmFile}${s.registered ? ' ✅' : ' ❌'}`,
239
- '可以手动跑一次:./bridge setup(源码仓库里),或者用源码的 install.sh。',
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
- ' 3. 点「加载已解压的扩展程序」,在文件框里按 ⌘⇧G、粘贴、回车,选这个文件夹:',
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 { execFileSync('/usr/bin/pgrep', ['-x', 'Google Chrome'], { stdio: 'ignore' }); return true } catch { return false }
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
- execFileSync('/usr/bin/open', ['-a', 'Google Chrome', STORE_URL])
380
+ openInChrome(STORE_URL)
285
381
  out.opened = true
286
382
  } catch {}
287
383
  return out
288
384
  }
289
- if (!store) {
385
+ // 剪贴板:macOS 用 pbcopy,Windows 用 clip(Linux 不一定有 xclip,就不塞了)
386
+ if (!store && process.platform !== 'linux') {
290
387
  try {
291
- execFileSync('/usr/bin/pbcopy', [], { input: `${p.extDir}\n` })
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
- if (out.chromeRunning) {
393
+ // 只有 macOS 能在后台开标签页;别的系统会把 Chrome 拉到前台,就不主动开了
394
+ if (out.chromeRunning && process.platform === 'darwin') {
296
395
  try {
297
- execFileSync('/usr/bin/open', ['-g', '-a', 'Google Chrome', 'chrome://extensions'])
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 DIR = process.env.DSH_BRIDGE_DIR || `/tmp/dsh-bridge-${process.getuid()}`
47
- const SOCK = process.env.DSH_BRIDGE_SOCK || path.join(DIR, 'dsh-bridge.sock')
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
- try { return fs.readFileSync(TOKEN_FILE, 'utf8').trim() } catch { return null }
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 (!fs.existsSync(SOCK) && !process.env.DSH_BRIDGE_SOCK && await nudgeLegacyHost()) {
121
+ if (!hostUp() && !process.env.DSH_BRIDGE_SOCK && await nudgeLegacyHost()) {
112
122
  // 扩展重载 + 新宿主起来一般一两秒;最多等 10 秒
113
- for (let i = 0; i < 40 && !fs.existsSync(SOCK); i++) await new Promise((r) => setTimeout(r, 250))
114
- if (op === 'reload' && fs.existsSync(SOCK)) return { reloading: true } // 刚才那下就是重载,不用再来一次
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
- if (!fs.existsSync(SOCK)) {
118
- if (fs.existsSync(LEGACY_SOCK)) {
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(new URL(import.meta.url).pathname), '../scratch/in-page-cursor.js'),
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(new URL(import.meta.url).pathname, 'utf8').split('*/')[0].replace(/^\/\*\*?/, '').replace(/^ \* ?/gm, '').trim())
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 || `/tmp/dsh-frames-${Date.now()}`)
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 || `/tmp/dsh-shot-${Date.now()}.${format === 'jpeg' ? 'jpg' : 'png'}`)
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
@@ -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 的 `cb` CLI
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
- const DIR = process.env.DSH_BRIDGE_DIR || `/tmp/dsh-bridge-${process.getuid()}`
21
- const SOCK = process.env.DSH_BRIDGE_SOCK || path.join(DIR, 'dsh-bridge.sock')
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
- fs.chmodSync(SOCK, 0o600)
219
- try { const st = fs.lstatSync(SOCK); ownSock = { dev: st.dev, ino: st.ino } } catch {}
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
- const st = fs.lstatSync(SOCK)
235
- if (ownSock && st.dev === ownSock.dev && st.ino === ownSock.ino) fs.unlinkSync(SOCK)
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
  }