dsh-pocket 1.0.11 → 1.0.13

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.en.md CHANGED
@@ -28,6 +28,12 @@ That's what DSH Pocket does: **install it, scan a QR code, and your phone shows
28
28
 
29
29
  > One-liner: `dsh web` only allows local access by default; DSH Pocket puts a proxy in front of it that safely bridges your phone (see [How it works](#-how-it-works)).
30
30
 
31
+ What it looks like — the phone shows the exact same UI as your computer, live:
32
+
33
+ <p align="center">
34
+ <img src="docs/interface.jpg" alt="DSH UI on the phone" width="100%">
35
+ </p>
36
+
31
37
  ## ✨ Features
32
38
 
33
39
  | Feature | Description |
@@ -95,6 +101,7 @@ DSH's `/api` browser-trust fence only accepts loopback or `--trusted-host` autho
95
101
  | Version stuck below 1.x | `^0.x` ranges never jump to 1.x: update with `--latest` (`dsh plugin --profile web update dsh-pocket --latest -w`) |
96
102
  | No push on iOS Safari | Safari Web Push requires **"Add to Home Screen"** first, then open from the home-screen icon (Chrome/Android don't need this) |
97
103
  | Public `error 1033` | See "Public tunnel troubleshooting" below — usually a local proxy/VPN (Clash etc. TUN mode) killing the tunnel |
104
+ | After "Restart dsh web", the page says the process is running in the background | The new process from in-page self-restart is a detached background process (not attached to your terminal) — that's the standard way to apply updates in-page; stop it with `lsof -ti :3080 \| xargs kill -9` (logs under `$DSH_HOME` as `dsh-pocket-restart-*.log`) |
98
105
 
99
106
  ## ⚠️ Public tunnel troubleshooting (read first)
100
107
 
package/README.md CHANGED
@@ -28,6 +28,12 @@ DSH Pocket 就是干这个的:**装上它,手机扫个码,就能实时看
28
28
 
29
29
  > 原理一句话:`dsh web` 默认只允许本机访问,DSH Pocket 在它前面加一层代理,把手机访问安全地接入(详见[「原理」](#-原理))。
30
30
 
31
+ 实际效果——手机上的界面就是电脑上的界面,实时同步:
32
+
33
+ <p align="center">
34
+ <img src="docs/interface.jpg" alt="手机上的 DSH 界面" width="100%">
35
+ </p>
36
+
31
37
  ## ✨ 特性
32
38
 
33
39
  | 特性 | 说明 |
@@ -95,6 +101,7 @@ DSH 的 `/api` 浏览器信任栅栏只认 loopback 或 `--trusted-host`(官
95
101
  | 版本停在 0.x 升不上去 | `^0.x` 范围不允许升到 1.x:更新用 `--latest`(`dsh plugin --profile web update dsh-pocket --latest -w`) |
96
102
  | 手机 iOS 收不到推送 | Safari 的 Web Push 要求**先把网页「添加到主屏幕」**,从主屏幕图标打开后才生效(Chrome/Android 无此要求) |
97
103
  | 公网 `error 1033` | 见下方「公网隧道常见问题」——多半是本机代理/VPN(Clash 等 TUN 模式)掐断了隧道 |
104
+ | 点「重启 dsh web」后页面提示进程在后台运行 | 自重启的新进程是 detached 后台进程(不挂终端),是页内更新的标准做法;停止它:`lsof -ti :3080 \| xargs kill -9`(日志在 `$DSH_HOME` 下 `dsh-pocket-restart-*.log`) |
98
105
 
99
106
  ## ⚠️ 公网隧道常见问题(必读)
100
107
 
package/client/client.js CHANGED
@@ -1306,6 +1306,7 @@ function PocketSettingsTab({ rpcCall }) {
1306
1306
  const [pushEnabled, setPushEnabled] = (0, import_react2.useState)(true);
1307
1307
  const [pushState, setPushState] = (0, import_react2.useState)("checking");
1308
1308
  const [tunnelState, setTunnelState] = (0, import_react2.useState)(null);
1309
+ const [restartNotice, setRestartNotice] = (0, import_react2.useState)(false);
1309
1310
  const [updateInfo, setUpdateInfo] = (0, import_react2.useState)(null);
1310
1311
  const call = async (endpoint, payload) => {
1311
1312
  const res = await rpcCall(endpoint, payload);
@@ -1317,6 +1318,7 @@ function PocketSettingsTab({ rpcCall }) {
1317
1318
  const s = await call(POCKET_ENDPOINTS.status, {});
1318
1319
  setStatus(s);
1319
1320
  setTunnelState(s.tunnelState ?? null);
1321
+ if (s.restartNotice) setRestartNotice(true);
1320
1322
  } catch {
1321
1323
  }
1322
1324
  };
@@ -1428,10 +1430,22 @@ function PocketSettingsTab({ rpcCall }) {
1428
1430
  "\u5F00\u53D1\u8005\uFF1A\u7A0B\u5E8F\u5458\u5C11\u5317\u6668"
1429
1431
  )
1430
1432
  ),
1433
+ // 重启后提示(进程在后台运行,停止方法)
1434
+ restartNotice ? (0, import_react2.createElement)(
1435
+ "div",
1436
+ { style: { ...styles.block, border: "1px solid var(--dsw-alias-brand-primary,#4f6ef7)", borderRadius: 8, background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", padding: "10px 12px" } },
1437
+ (0, import_react2.createElement)(
1438
+ "div",
1439
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
1440
+ (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, "\u{1F504} \u5DF2\u91CD\u542F | Restarted"),
1441
+ (0, import_react2.createElement)("button", { style: styles.btn, onClick: () => setRestartNotice(false) }, "\u77E5\u9053\u4E86 | OK")
1442
+ ),
1443
+ (0, import_react2.createElement)("div", { style: styles.muted, marginTop: 4, wordBreak: "break-all" }, "\u8FDB\u7A0B\u5728\u540E\u53F0\u8FD0\u884C\uFF08\u4E0D\u6302\u7EC8\u7AEF\uFF09\u3002\u5982\u9700\u505C\u6B62\uFF1Alsof -ti :3080 | xargs kill -9")
1444
+ ) : null,
1431
1445
  // 更新提示
1432
1446
  updateInfo ? (0, import_react2.createElement)(
1433
1447
  "div",
1434
- { style: { ...styles.block, border: "1px solid var(--dsw-alias-state-warn-primary,#b45309)", borderRadius: 8, background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", padding: "10px 12px" } },
1448
+ { style: { ...styles.block, border: "1px solid var(--dsw-alias-state-warn-primary,#b45309)" }, borderRadius: 8, background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", padding: "10px 12px" },
1435
1449
  (0, import_react2.createElement)(
1436
1450
  "div",
1437
1451
  { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 } },
package/client/index.jsx CHANGED
@@ -61,6 +61,7 @@ function PocketSettingsTab({ rpcCall }) {
61
61
  const [pushEnabled, setPushEnabled] = useState(true); // 宿主开关
62
62
  const [pushState, setPushState] = useState('checking'); // checking|on|unsupported|insecure|off
63
63
  const [tunnelState, setTunnelState] = useState(null); // 隧道进度 {phase, detail, startedAt}
64
+ const [restartNotice, setRestartNotice] = useState(false); // 重启后提示
64
65
  const [updateInfo, setUpdateInfo] = useState(null); // { current, latest, updating, result } | null
65
66
 
66
67
  const call = async (endpoint, payload) => {
@@ -74,6 +75,7 @@ function PocketSettingsTab({ rpcCall }) {
74
75
  const s = await call(POCKET_ENDPOINTS.status, {});
75
76
  setStatus(s);
76
77
  setTunnelState(s.tunnelState ?? null);
78
+ if (s.restartNotice) setRestartNotice(true);
77
79
  } catch { /* 忽略瞬时失败 */ }
78
80
  };
79
81
 
@@ -186,8 +188,17 @@ function PocketSettingsTab({ rpcCall }) {
186
188
  '开发者:程序员少北晨'),
187
189
  ),
188
190
 
191
+ // 重启后提示(进程在后台运行,停止方法)
192
+ restartNotice ? h('div', { style: { ...styles.block, border: '1px solid var(--dsw-alias-brand-primary,#4f6ef7)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', padding: '10px 12px' } },
193
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
194
+ h('div', { style: { fontWeight: 600, fontSize: 13 } }, '🔄 已重启 | Restarted'),
195
+ h('button', { style: styles.btn, onClick: () => setRestartNotice(false) }, '知道了 | OK'),
196
+ ),
197
+ h('div', { style: styles.muted, marginTop: 4, wordBreak: 'break-all' }, '进程在后台运行(不挂终端)。如需停止:lsof -ti :3080 | xargs kill -9'),
198
+ ) : null,
199
+
189
200
  // 更新提示
190
- updateInfo ? h('div', { style: { ...styles.block, border: '1px solid var(--dsw-alias-state-warn-primary,#b45309)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', padding: '10px 12px' } },
201
+ updateInfo ? h('div', { style: { ...styles.block, border: '1px solid var(--dsw-alias-state-warn-primary,#b45309)' }, borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', padding: '10px 12px' },
191
202
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
192
203
  h('div', { style: { fontWeight: 600, fontSize: 13 } }, `📦 新版本 v${updateInfo.latest} | Update available`),
193
204
  updateInfo.result !== 'ok'
package/lib/index.js CHANGED
@@ -7,10 +7,12 @@
7
7
  // - 更新提示:有新版本时显示一键更新按钮(dsh plugin update --latest)
8
8
  // 手机看到的界面 = 电脑上的 dsh web,实时同步(WebSocket 透传)。
9
9
 
10
- import { readFile } from 'node:fs/promises';
11
10
  import { fileURLToPath } from 'node:url';
12
11
  import { spawn } from 'node:child_process';
13
12
  import { createRequire } from 'node:module';
13
+ import { writeFile, readFile, mkdir } from 'node:fs/promises';
14
+ import { join, dirname } from 'node:path';
15
+ import { homedir } from 'node:os';
14
16
 
15
17
  import { createPocketService } from './service.mjs';
16
18
  import { installPocketRpc } from './web-rpc.js';
@@ -30,6 +32,28 @@ function currentVersion() {
30
32
  }
31
33
  }
32
34
 
35
+ const restartNoticeRel = join('dsh-pocket', 'restarted.json');
36
+ function restartNoticePath() {
37
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), restartNoticeRel);
38
+ }
39
+ function readRestartNotice() {
40
+ try {
41
+ const raw = JSON.parse(readFile(restartNoticePath(), 'utf8'));
42
+ if (!raw?.at) return null;
43
+ if (Date.now() - raw.at > 30 * 60 * 1000) return null; // 30 分钟后过期
44
+ return raw;
45
+ } catch { return null; }
46
+ }
47
+ function writeRestartNotice() {
48
+ return mkdir(dirname(restartNoticePath()), { recursive: true })
49
+ .then(() => writeFile(restartNoticePath(), JSON.stringify({ at: Date.now(), pid: process.pid }), 'utf8'));
50
+ }
51
+ /** 自重启(先落 notice,让重启后的页面提示停止方法)。 */
52
+ function pocketRestart() {
53
+ writeRestartNotice().catch(() => {});
54
+ return restartHost({ internals });
55
+ }
56
+
33
57
  /** 执行更新:dsh plugin --profile <p> update dsh-pocket --latest -w(超时保护)。 */
34
58
  function performUpdate(profile, { timeoutMs = 180_000 } = {}) {
35
59
  return new Promise((resolve) => {
@@ -84,7 +108,8 @@ export function apply(ctx, config = {}, internals = {}) {
84
108
  service,
85
109
  push: internals.push ?? { vapidPublicKey: () => '', count: () => 0, subscribe: async () => false, unsubscribe: async () => false, isEnabled: () => true, setEnabled: async () => true },
86
110
  runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate },
87
- restart: internals.restart ?? (() => restartHost({ internals })),
111
+ restart: internals.restart ?? pocketRestart,
112
+ restartNotice: internals.restartNotice ?? readRestartNotice,
88
113
  log: logger,
89
114
  });
90
115
  disposers.push(disposeRpc);
package/lib/restart.js CHANGED
@@ -1,45 +1,76 @@
1
- // dsh-pocket 自重启:重新拉起启动本宿主的确切 dsh 调用(脱离父进程),
2
- // 让更新后的插件代码生效——用户无需离开界面手动重启(借鉴 dshmarket 的
3
- // self-restart 方案,见 https://github.com/dsh-market/dsh-market lib/restart.js)。
1
+ // dsh-pocket 自重启:重新拉起启动本宿主的确切 dsh 调用(detached 交接),
2
+ // 让更新后的插件代码生效——用户无需离开界面手动重启。
4
3
  //
5
- // 原理:本插件运行在 dsh web 进程内,process.argv 即 dsh CLI 的启动参数
6
- // (如 node <bin.js> web [flags])。用相同参数 detached 拉起新进程,
7
- // 短暂等待后旧进程退出,新进程接管端口。
4
+ // 方案借鉴 dshmarket self-restart(lib/restart.js,MIT):不直接拉起新
5
+ // 进程,而是先拉一个 detached node 辅助进程,等旧进程退出、端口释放
6
+ // (1.5s)后再拉起新 dsh,并把新进程输出写入临时日志——避免端口竞争
7
+ // (EADDRINUSE)导致新进程静默崩溃。
8
+ //
9
+ // 注意:新进程 detached,不挂终端——停止方式:lsof -ti :3080 | xargs kill -9。
8
10
 
9
11
  import { spawn } from 'node:child_process';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
10
14
 
11
- /**
12
- * 重建启动调用(与当前宿主相同的命令)。
13
- * @returns {{file:string, args:string[]}}
14
- */
15
+ /** 重建启动调用(与当前宿主相同的命令)。 */
15
16
  export function restartLaunch() {
16
17
  return {
17
18
  file: process.argv[0], // node
18
19
  args: [process.argv[1], ...process.argv.slice(2)], // <bin.js> + web [flags]
20
+ cwd: process.cwd(),
19
21
  };
20
22
  }
21
23
 
24
+ /** 辅助进程代码:等待 1.5s(旧进程释放端口)→ 拉起新 dsh → 输出写日志。 */
25
+ function helperCode(launch, logOut, logErr) {
26
+ const spawn = "const { spawn } = require('node:child_process')";
27
+ const fs = "const fs = require('node:fs')";
28
+ const file = `const file = ${JSON.stringify(launch.file)}`;
29
+ const args = `const args = ${JSON.stringify(launch.args)}`;
30
+ const cwd = `const cwd = ${JSON.stringify(launch.cwd)}`;
31
+ const o = `const logOut = ${JSON.stringify(logOut)}`;
32
+ const e = `const logErr = ${JSON.stringify(logErr)}`;
33
+ const body = [
34
+ 'setTimeout(() => {',
35
+ ' try {',
36
+ ' const out = fs.openSync(logOut, "a")',
37
+ ' const err = fs.openSync(logErr, "a")',
38
+ ' const child = spawn(file, args, { cwd, detached: true, stdio: ["ignore", out, err], env: process.env })',
39
+ ' child.unref()',
40
+ ' } catch {}',
41
+ '}, 1500)',
42
+ ].join('\n');
43
+ return [spawn, fs, file, args, cwd, o, e, body].join('\n');
44
+ }
45
+
22
46
  /**
23
- * 拉起替代宿主(detached),随后退出当前进程。
47
+ * 拉起替代宿主(detached 辅助进程交接),随后结束当前进程。
24
48
  * @param {object} opts
25
- * @param {number} [opts.exitDelayMs] 等待新进程接管后再退出(默认 800ms
26
- * @param {object} [opts.internals] 测试注入:spawnFn / exitFn
27
- * @returns {{spawned:boolean, file:string}}
49
+ * @param {number} [opts.handoffMs] 等待旧进程释放端口的时长(默认 1500ms
50
+ * @param {object} [opts.internals] 测试注入:spawn / kill
51
+ * @returns {{helperPid:number|null, logOut:string, logErr:string}}
28
52
  */
29
- export function restartHost({ exitDelayMs = 800, internals = {} } = {}) {
53
+ export function restartHost({ handoffMs = 1500, internals = {} } = {}) {
30
54
  const spawnFn = internals.spawn ?? spawn;
31
- const exitFn = internals.exit ?? ((code) => process.exit(code));
32
- const { file, args } = restartLaunch();
55
+ const killFn = internals.kill ?? ((pid) => process.kill(pid, 'SIGTERM'));
56
+ const launch = restartLaunch();
57
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
58
+ const logOut = join(tmpdir(), `dsh-pocket-restart-${stamp}.out.log`);
59
+ const logErr = join(tmpdir(), `dsh-pocket-restart-${stamp}.err.log`);
60
+
61
+ let helperPid = null;
33
62
  try {
34
- const child = spawnFn(file, args, {
63
+ const helper = spawnFn(process.execPath, ['-e', helperCode(launch, logOut, logErr)], {
35
64
  detached: true,
36
65
  stdio: 'ignore',
37
66
  env: process.env,
38
67
  });
39
- child.unref?.();
40
- setTimeout(() => exitFn(0), exitDelayMs);
41
- return { spawned: true, file };
68
+ helper.unref?.();
69
+ helperPid = helper.pid ?? null;
70
+ // 短暂等待后结束当前进程(释放端口);由辅助进程在 1.5s 后拉起新宿主
71
+ setTimeout(() => { try { killFn(process.pid); } catch { /* 忽略 */ } }, 500);
42
72
  } catch (err) {
43
- return { spawned: false, error: err?.message ?? String(err) };
73
+ return { helperPid: null, logOut, logErr, error: err?.message ?? String(err) };
44
74
  }
75
+ return { helperPid, logOut, logErr };
45
76
  }
package/lib/web-rpc.js CHANGED
@@ -17,7 +17,7 @@ function fail(code, message) {
17
17
  }
18
18
 
19
19
  /** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。 */
20
- export function installPocketRpc(ctx, { service, push, log = console, runUpdate = null, restart = null }) {
20
+ export function installPocketRpc(ctx, { service, push, log = console, runUpdate = null, restart = null, restartNotice = null }) {
21
21
  if (!ctx?.connection?.rpc?.handle) {
22
22
  log.warn?.('dsh-pocket: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
23
23
  return () => {};
@@ -25,17 +25,24 @@ export function installPocketRpc(ctx, { service, push, log = console, runUpdate
25
25
  return ctx.connection.rpc.handle(POCKET_RPC_CHANNEL, async (endpoint, payload = {}, signal) => {
26
26
  if (signal?.aborted) return fail('cancelled', 'The request was cancelled.');
27
27
 
28
+ // status 响应:redact 后的服务状态 + 重启提示(重启后 30 分钟内有效)
29
+ const statusPayload = async () => {
30
+ let notice = null;
31
+ try { notice = restartNotice?.() ?? null; } catch { notice = null; }
32
+ return ok({ ...redactStatus(await service.status()), restartNotice: notice });
33
+ };
34
+
28
35
  try {
29
36
  if (endpoint === POCKET_ENDPOINTS.status) {
30
- return ok(redactStatus(await service.status()));
37
+ return await statusPayload();
31
38
  }
32
39
  if (endpoint === POCKET_ENDPOINTS.tunnelStart) {
33
40
  await service.startTunnel();
34
- return ok(redactStatus(await service.status()));
41
+ return await statusPayload();
35
42
  }
36
43
  if (endpoint === POCKET_ENDPOINTS.tunnelStop) {
37
44
  service.stopTunnel();
38
- return ok(redactStatus(await service.status()));
45
+ return await statusPayload();
39
46
  }
40
47
  if (endpoint === POCKET_ENDPOINTS.pushVapidKey) {
41
48
  return ok({ publicKey: push.vapidPublicKey() });
@@ -66,7 +73,7 @@ export function installPocketRpc(ctx, { service, push, log = console, runUpdate
66
73
  if (endpoint === POCKET_ENDPOINTS.restart) {
67
74
  if (!restart) return fail('bad-request', '重启不可用 | restart unavailable');
68
75
  const result = restart();
69
- return ok(result);
76
+ return ok({ ...result, hint: '重启后进程在后台运行;如需停止:lsof -ti :3080 | xargs kill -9' });
70
77
  }
71
78
  return fail('bad-request', `Unknown endpoint: ${endpoint}`);
72
79
  } catch (err) {
package/package.json CHANGED
@@ -77,5 +77,5 @@
77
77
  "access": "public",
78
78
  "registry": "https://registry.npmjs.org/"
79
79
  },
80
- "version": "1.0.11"
80
+ "version": "1.0.13"
81
81
  }