dsh-remote-hub 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
28
28
  手机端不通过 npm 分发——npm 包 `dsh-remote-hub` 是电脑侧的 DSH 插件,两者配合使用:
29
29
 
30
30
  ```
31
- 电脑:dsh plugin --profile desktop add dsh-remote-hub@0.4.2 → 起桥接 / 开隧道 / 签 Cookie
31
+ 电脑:dsh plugin --profile desktop add dsh-remote-hub@0.4.3 --registry=https://registry.npmjs.org → 起桥接 / 开隧道 / 签 Cookie
32
32
  手机:装上面这个 APK → 填电脑给出的地址与 Cookie → 连接
33
33
  ```
34
34
 
@@ -184,8 +184,8 @@ node mint.mjs "127.0.0.1:1815" 30 # 签发 30 天 Cookie
184
184
  ```bash
185
185
  # 从 NPM 安装(推荐,会出现在 DSH 内的「插件中心」)
186
186
  # https://www.npmjs.com/package/dsh-remote-hub
187
- dsh plugin --profile desktop add dsh-remote-hub@0.4.2
188
- dsh plugin --profile web add dsh-remote-hub@0.4.2 # web profile 同样可用
187
+ dsh plugin --profile desktop add dsh-remote-hub@0.4.3 --registry=https://registry.npmjs.org
188
+ dsh plugin --profile web add dsh-remote-hub@0.4.3 --registry=https://registry.npmjs.org
189
189
 
190
190
  # 或直接从本仓库源码安装(不经过 NPM)
191
191
  dsh plugin --profile web add github:liaoguogang-hub/dsh-remote-public
@@ -242,7 +242,9 @@ dsh-remote: ready — 手机端扫码:dsh-remote qr(或浏览器打开 http:
242
242
  ### 升级插件(重要:必须完全重启 DSH)
243
243
 
244
244
  ```bash
245
- dsh plugin --profile desktop add dsh-remote-hub@0.4.2 # 或 --profile web
245
+ dsh plugin --profile desktop add dsh-remote-hub@0.4.3 --registry=https://registry.npmjs.org
246
+ # web profile 把 desktop 换成 web
247
+
246
248
  ```
247
249
 
248
250
  **升级后请完全退出 DSH 再重新打开,不要只靠插件热重载。** 原因是旧版本的桥接监听可能不会被热重载回收,
@@ -273,6 +273,18 @@ async function main() {
273
273
  const live = await bridgeHealthy(Number(process.env.DSH_REMOTE_PORT || 6300))
274
274
  const mismatch = live && (live.relay || null) !== (publicUrl || null)
275
275
  if (mismatch && live.pid) {
276
+ // NEVER signal a bridge that lives inside the desktop app: health.pid is then the DSH
277
+ // host process, and killing it takes the whole app down (this actually happened).
278
+ if (live.runtime === 'electron') {
279
+ console.log('')
280
+ console.log(' 桥接由 DSH 桌面端内部托管(PID ' + live.pid + ' 就是 DSH 本体),不能在这里停止。')
281
+ console.log(' 它的公网配置来自 profile 的 cordis.patch.yml:')
282
+ console.log(` 当前 relay = ${live.relay ?? '(未配置)'},本次启动想要 ${publicUrl || '(不用中转)'}`)
283
+ console.log(' 要改配置:编辑 cordis.patch.yml 里的 publicUrl 后重启 DSH;')
284
+ console.log(' 要临时用本启动器的配置:先完全退出 DSH,再双击 connect.cmd。')
285
+ console.log('')
286
+ return
287
+ }
276
288
  console.log(`已有桥接配置不同(relay=${live.relay ?? '无'}),先停止它(PID ${live.pid})再按当前配置启动`)
277
289
  try {
278
290
  process.kill(Number(live.pid))
package/lib/bridge.js CHANGED
@@ -156,6 +156,9 @@ export function startBridge({ listenPort, authority, upstream, pairing = null, r
156
156
  ok: true,
157
157
  service: 'dsh-remote-bridge',
158
158
  pid: process.pid,
159
+ // 'electron' when the bridge lives inside the DSH desktop host (plugin mode). Callers must
160
+ // NEVER signal that pid: it is the app itself, not a disposable node helper.
161
+ runtime: process.versions.electron ? 'electron' : 'node',
159
162
  // Whether this bridge advertises a self-hosted relay; a launcher that expects none must not
160
163
  // adopt a bridge that has one, or the public app would show the self-use relay.
161
164
  relay: relayUrl || null,
package/lib/discover.js CHANGED
@@ -108,6 +108,57 @@ export function probePort(port) {
108
108
  })
109
109
  }
110
110
 
111
+ /** Map local listening port -> owning PID (Windows only). */
112
+ async function portOwners() {
113
+ if (process.platform !== 'win32') return new Map()
114
+ const out = await run('netstat', ['-ano', '-p', 'tcp'])
115
+ const map = new Map()
116
+ for (const line of splitLines(out)) {
117
+ const m = /^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i.exec(line)
118
+ if (m) map.set(Number(m[1]), Number(m[2]))
119
+ }
120
+ return map
121
+ }
122
+
123
+ /** Process name for a PID, or '' when unknown (uses tasklist; cheap enough at discovery time). */
124
+ const NAME_CACHE = new Map()
125
+ async function processName(pid) {
126
+ if (process.platform !== 'win32') return ''
127
+ if (NAME_CACHE.has(pid)) return NAME_CACHE.get(pid)
128
+ const out = await run('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'])
129
+ const line = splitLines(out).find((l) => l.trim().length > 0)
130
+ if (!line) return ''
131
+ const m = /^"([^"]+)"/.exec(line.trim())
132
+ const name = m ? m[1] : ''
133
+ NAME_CACHE.set(pid, name)
134
+ return name
135
+ }
136
+
137
+ /**
138
+ * Pick the best DSH host when several profile hosts are running.
139
+ *
140
+ * A machine can run the desktop app and a CLI host (e.g. a Lark profile) at the same time; both
141
+ * answer the fingerprint. The phone wants the DESKTOP one, so a host whose image is not node.exe
142
+ * (the Electron app) wins over a plain `node` host.
143
+ */
144
+ async function rankHosts(ports) {
145
+ if (process.platform !== 'win32' || ports.length <= 1) return ports
146
+ const owners = await portOwners()
147
+ const ranked = []
148
+ for (const port of ports) {
149
+ const pid = owners.get(port)
150
+ let name = ''
151
+ if (pid) {
152
+ // eslint-disable-next-line no-await-in-loop
153
+ name = await processName(pid)
154
+ }
155
+ const desktop = name && !/^node(\.exe)?$/i.test(name)
156
+ ranked.push({ port, desktop })
157
+ }
158
+ const preferred = ranked.filter((r) => r.desktop)
159
+ return (preferred.length ? preferred : ranked).map((r) => r.port)
160
+ }
161
+
111
162
  /**
112
163
  * A resolver that remembers the last known-good port.
113
164
  *
@@ -128,16 +179,24 @@ export function createDiscovery({ pinned = 0, ttlMs = 30_000, exclude = [] } = {
128
179
  inFlight = (async () => {
129
180
  const skip = new Set(exclude.filter((p) => Number.isInteger(p) && p > 0))
130
181
  const ports = (await listeningPorts()).filter((p) => !skip.has(p))
131
- // Probe the remembered port first: it is usually still right and saves a full sweep.
132
- const ordered = port && ports.includes(port) ? [port, ...ports.filter((p) => p !== port)] : ports
133
- for (const candidate of ordered) {
134
- // eslint-disable-next-line no-await-in-loop
135
- if (await probePort(candidate)) {
136
- port = candidate
137
- resolvedAt = Date.now()
138
- return port
182
+ // Probe in parallel: a full sweep touches dozens of ports, and a serial walk made the first
183
+ // request after startup take 10+ seconds.
184
+ const CONCURRENCY = 12
185
+ let cursor = 0
186
+ const hits = []
187
+ async function worker() {
188
+ while (cursor < ports.length) {
189
+ const candidate = ports[cursor++]
190
+ // eslint-disable-next-line no-await-in-loop
191
+ if (await probePort(candidate)) hits.push(candidate)
139
192
  }
140
193
  }
194
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, ports.length) }, worker))
195
+ if (hits.length === 0) return port
196
+ // Keep the remembered port when it is still a hit; otherwise prefer the desktop host.
197
+ const ranked = hits.includes(port) ? [port, ...hits.filter((p) => p !== port)] : await rankHosts(hits)
198
+ port = ranked[0]
199
+ resolvedAt = Date.now()
141
200
  return port
142
201
  })()
143
202
  try {
package/lib/index.js CHANGED
@@ -16,7 +16,7 @@ import { start } from './supervisor.js'
16
16
 
17
17
  export const name = 'dsh-remote-hub'
18
18
 
19
- export const version = '0.4.2'
19
+ export const version = '0.4.3'
20
20
 
21
21
  /** No host services are required: the plugin only needs Node builtins. */
22
22
  export const inject = []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-hub",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "DSH 插件:把电脑上的 DeepSeek Harness 变成手机可访问的服务——起桥接、自动开隧道(ngrok / cloudflared)、签发 30 天 Cookie,并输出地址与凭据,配合 DSH Remote Android App 使用。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",