dsh-pocket 0.1.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/LICENSE +21 -0
- package/README.md +59 -0
- package/bin/dsh-pocket.mjs +99 -0
- package/client/api.js +22 -0
- package/client/build.mjs +40 -0
- package/client/client.js +170 -0
- package/client/index.jsx +120 -0
- package/cordis.patch.yml +5 -0
- package/lib/index.js +43 -0
- package/lib/proxy.mjs +87 -0
- package/lib/service.mjs +111 -0
- package/lib/tunnel.mjs +109 -0
- package/lib/web-rpc.js +40 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shaobeichen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# DSH Pocket
|
|
2
|
+
|
|
3
|
+
把 **DeepSeek Harness 装进你的口袋**:一个包、一个设置页,手机扫二维码就实时看到电脑上的同一个界面——人在外面也能用。
|
|
4
|
+
|
|
5
|
+
> EN: Put DeepSeek Harness in your pocket. One package, one settings tab — scan a QR code and your phone shows exactly what's on your computer screen, live, anywhere.
|
|
6
|
+
|
|
7
|
+
## 这是什么
|
|
8
|
+
|
|
9
|
+
`dsh web` 默认只允许本机访问(`127.0.0.1:3080`)。DSH Pocket 在它前面加一层**改头代理**,并直接在 **DSH 网页设置页**里给你二维码:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
手机 ──扫码──> dsh-pocket 代理 ──> dsh web :3080
|
|
13
|
+
(改写 Host/Origin 为 loopback,DSH 信任栅栏放行)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **局域网**:装好插件,设置 → 插件 → **手机访问**,打开就有一个局域网二维码——手机连同一 WiFi 扫码即用
|
|
17
|
+
- **公网**:同一页点「**开启公网访问**」,出第二个二维码——人在外面(4G/公司网)也能访问
|
|
18
|
+
- **实时同步**:DSH 的流式输出走 WebSocket,代理原样透传——**电脑上看到什么,手机上就是什么**
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh plugin --profile web add dsh-pocket -w
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
重启 `dsh web` → 设置 → 插件 → **手机访问**。**就这一个包,没有核心/适配器要分开装。**
|
|
27
|
+
|
|
28
|
+
## ⚠️ 安全(必读)
|
|
29
|
+
|
|
30
|
+
- **DSH 能执行你电脑上的代码**。二维码/URL 就是钥匙,**请勿把二维码或 URL 发给任何人**
|
|
31
|
+
- 公网 URL 由 cloudflared 随机分配,**每次重启 dsh web 会变化**(旧链接自动失效,相当于天然轮换)
|
|
32
|
+
- 适合个人自用;多设备/分享场景后续会加访问令牌
|
|
33
|
+
|
|
34
|
+
## 原理
|
|
35
|
+
|
|
36
|
+
DSH 的 `/api` 浏览器信任栅栏只认 loopback 或 `--trusted-host`(官方还禁了 `0.0.0.0` 绑定,防止把远程执行代码暴露给网络)。Pocket 把入站请求的 `Host` / `Origin` 统一改写成 `127.0.0.1:<dsh端口>`,栅栏永远看到 loopback——**不需要改 dsh 的任何配置**。代理随插件自动启动,公网隧道按需开启(首次自动下载 cloudflared)。
|
|
37
|
+
|
|
38
|
+
## 架构(单包)
|
|
39
|
+
|
|
40
|
+
| 文件 | 说明 |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `lib/index.js` | 插件入口:自动起代理 + 注册 RPC(`inject: connection, webServer`) |
|
|
43
|
+
| `lib/service.mjs` | 服务:代理生命周期、公网隧道、状态快照(含二维码 data URL) |
|
|
44
|
+
| `lib/proxy.mjs` | 改头反向代理:Host/Origin → loopback,HTTP + WebSocket 全透传 |
|
|
45
|
+
| `lib/tunnel.mjs` | cloudflared 快速隧道:下载/启动/解析公网 URL |
|
|
46
|
+
| `lib/web-rpc.js` | loopback RPC:`pocket.status` / `tunnel.start` / `tunnel.stop` |
|
|
47
|
+
| `client/` | 设置页「手机访问」标签(React,esbuild 打包) |
|
|
48
|
+
|
|
49
|
+
## 开发
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npm install
|
|
53
|
+
node client/build.mjs # 改 client/index.jsx 后重新打包
|
|
54
|
+
npm test # 代理改写 + WS 透传 + 服务 + RPC(5 个测试)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dsh-pocket — 把 DeepSeek Harness 装进你的口袋
|
|
3
|
+
//
|
|
4
|
+
// 用法:
|
|
5
|
+
// dsh-pocket # 局域网模式:手机同一 WiFi 扫码访问
|
|
6
|
+
// dsh-pocket --public # 公网模式:cloudflared 隧道,人在外面也能访问
|
|
7
|
+
// dsh-pocket --port 3081 # 自定义代理端口(默认 3081;dsh web 保持 3080)
|
|
8
|
+
//
|
|
9
|
+
// 前提:dsh web 已在 127.0.0.1:3080 运行。
|
|
10
|
+
// 手机看到的界面 = 电脑上的界面,实时同步(WebSocket 流式透传)。
|
|
11
|
+
|
|
12
|
+
import { networkInterfaces } from 'node:os';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { createPocketProxy } from '../lib/proxy.mjs';
|
|
15
|
+
import { startQuickTunnel } from '../lib/tunnel.mjs';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const args = { port: 3081, host: '0.0.0.0', public: false, upstream: { host: '127.0.0.1', port: 3080 } };
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const a = argv[i];
|
|
23
|
+
if (a === '--public') args.public = true;
|
|
24
|
+
else if (a === '--port') args.port = Number(argv[++i]) || 3081;
|
|
25
|
+
else if (a === '--host') args.host = argv[++i] ?? '0.0.0.0';
|
|
26
|
+
else if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
|
|
27
|
+
}
|
|
28
|
+
return args;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function printHelp() {
|
|
32
|
+
console.log(`dsh-pocket — 手机访问电脑上的 DeepSeek Harness
|
|
33
|
+
|
|
34
|
+
用法:
|
|
35
|
+
dsh-pocket 局域网模式(手机同一 WiFi)
|
|
36
|
+
dsh-pocket --public 公网模式(cloudflared 隧道,人在外面)
|
|
37
|
+
dsh-pocket --port 3081 自定义代理端口
|
|
38
|
+
dsh-pocket --help 帮助
|
|
39
|
+
|
|
40
|
+
前提:dsh web 已在 127.0.0.1:3080 运行(npx @deepseek-ai/dsh web)。
|
|
41
|
+
|
|
42
|
+
安全提醒:dsh web 能执行代码。二维码/URL 就是钥匙,请勿发给别人。
|
|
43
|
+
`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function lanIPv4() {
|
|
47
|
+
const addrs = [];
|
|
48
|
+
for (const ifaces of Object.values(networkInterfaces())) {
|
|
49
|
+
for (const i of ifaces ?? []) {
|
|
50
|
+
if (i.family === 'IPv4' && !i.internal) addrs.push(i.address);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return addrs[0] ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function printQr(url, label) {
|
|
57
|
+
const qrcodeTerminal = require('qrcode-terminal');
|
|
58
|
+
console.log(`\n${label}\n ${url}`);
|
|
59
|
+
qrcodeTerminal.generate(url, { small: true }, (qr) => console.log(qr));
|
|
60
|
+
console.log('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main() {
|
|
64
|
+
const args = parseArgs(process.argv.slice(2));
|
|
65
|
+
|
|
66
|
+
console.log('🚀 dsh-pocket 启动中…');
|
|
67
|
+
const { port, close } = await createPocketProxy(args);
|
|
68
|
+
|
|
69
|
+
const lan = lanIPv4();
|
|
70
|
+
if (lan) {
|
|
71
|
+
printQr(`http://${lan}:${port}`, '📶 局域网访问(手机连同一 WiFi):');
|
|
72
|
+
} else {
|
|
73
|
+
console.log('⚠️ 未检测到局域网 IP,跳过局域网二维码');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (args.public) {
|
|
77
|
+
console.log('🌐 正在建立公网隧道(cloudflared)…');
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
process.on('SIGINT', () => controller.abort());
|
|
80
|
+
try {
|
|
81
|
+
const tunnel = await startQuickTunnel({ port, signal: controller.signal });
|
|
82
|
+
printQr(tunnel.url, '🌐 公网访问(人在外面也能用):');
|
|
83
|
+
console.log(' 隧道会持续运行;Ctrl+C 退出(下次启动会换新 URL)');
|
|
84
|
+
process.on('SIGINT', () => { tunnel.kill(); });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(`❌ 公网隧道失败:${err.message}(局域网二维码仍可用)`);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
console.log(` (加 --public 开启公网隧道)`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`✅ dsh-pocket 已就绪:手机扫码上面的二维码,看到的界面与电脑完全一致、实时同步。\n 按 Ctrl+C 停止。`);
|
|
93
|
+
await new Promise(() => {});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
main().catch((err) => {
|
|
97
|
+
console.error(`❌ dsh-pocket: ${err?.message ?? err}`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
});
|
package/client/api.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// dsh-pocket 设置页签 RPC 契约(client 与 host 共享)
|
|
2
|
+
export const POCKET_RPC_CHANNEL = '/dsh-pocket';
|
|
3
|
+
|
|
4
|
+
export const POCKET_ENDPOINTS = Object.freeze({
|
|
5
|
+
status: 'pocket.status',
|
|
6
|
+
tunnelStart: 'tunnel.start',
|
|
7
|
+
tunnelStop: 'tunnel.stop',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/** 浏览器可见的状态字段(无敏感信息;含二维码 data URL)。 */
|
|
11
|
+
export function redactStatus(s) {
|
|
12
|
+
return {
|
|
13
|
+
proxyRunning: s?.proxyRunning === true,
|
|
14
|
+
proxyPort: s?.proxyPort ?? null,
|
|
15
|
+
lanUrl: s?.lanUrl ?? null,
|
|
16
|
+
lanQr: s?.lanQr ?? null,
|
|
17
|
+
tunnelRunning: s?.tunnelRunning === true,
|
|
18
|
+
tunnelUrl: s?.tunnelUrl ?? null,
|
|
19
|
+
tunnelQr: s?.tunnelQr ?? null,
|
|
20
|
+
dshPort: s?.dshPort ?? null,
|
|
21
|
+
};
|
|
22
|
+
}
|
package/client/build.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// dsh-pocket 网页客户端打包:client/index.jsx → client/client.js
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { build } from 'esbuild';
|
|
6
|
+
|
|
7
|
+
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const packageRoot = resolve(sourceDir, '..');
|
|
9
|
+
const outputPath = resolve(packageRoot, 'client/client.js');
|
|
10
|
+
const loaderId = process.env.DSH_POCKET_CLIENT_ID ?? 'dsh-pocket';
|
|
11
|
+
|
|
12
|
+
const result = await build({
|
|
13
|
+
entryPoints: [resolve(sourceDir, 'index.jsx')],
|
|
14
|
+
bundle: true,
|
|
15
|
+
format: 'cjs',
|
|
16
|
+
platform: 'browser',
|
|
17
|
+
target: ['chrome100'],
|
|
18
|
+
external: ['react', 'react/jsx-runtime'],
|
|
19
|
+
write: false,
|
|
20
|
+
minify: process.env.NODE_ENV === 'production',
|
|
21
|
+
legalComments: 'none',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const bundled = result.outputFiles?.[0]?.text;
|
|
25
|
+
if (!bundled) throw new Error('esbuild did not produce a client bundle');
|
|
26
|
+
|
|
27
|
+
const wrapped = `window.__ModuleLoader__.load({
|
|
28
|
+
id: ${JSON.stringify(loaderId)},
|
|
29
|
+
factory: (require) => {
|
|
30
|
+
var module = { exports: {} };
|
|
31
|
+
var exports = module.exports;
|
|
32
|
+
${bundled}
|
|
33
|
+
return module.exports;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
39
|
+
await writeFile(outputPath, wrapped, 'utf8');
|
|
40
|
+
console.log(`Wrote ${outputPath}`);
|
package/client/client.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-pocket",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
var __defProp = Object.defineProperty;
|
|
7
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
8
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
9
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
10
|
+
var __export = (target, all) => {
|
|
11
|
+
for (var name2 in all)
|
|
12
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
13
|
+
};
|
|
14
|
+
var __copyProps = (to, from, except, desc) => {
|
|
15
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
16
|
+
for (let key of __getOwnPropNames(from))
|
|
17
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
18
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
19
|
+
}
|
|
20
|
+
return to;
|
|
21
|
+
};
|
|
22
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
23
|
+
|
|
24
|
+
// client/index.jsx
|
|
25
|
+
var index_exports = {};
|
|
26
|
+
__export(index_exports, {
|
|
27
|
+
apply: () => apply,
|
|
28
|
+
inject: () => inject,
|
|
29
|
+
name: () => name,
|
|
30
|
+
redactStatus: () => redactStatus
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
var import_react = require("react");
|
|
34
|
+
|
|
35
|
+
// client/api.js
|
|
36
|
+
var POCKET_RPC_CHANNEL = "/dsh-pocket";
|
|
37
|
+
var POCKET_ENDPOINTS = Object.freeze({
|
|
38
|
+
status: "pocket.status",
|
|
39
|
+
tunnelStart: "tunnel.start",
|
|
40
|
+
tunnelStop: "tunnel.stop"
|
|
41
|
+
});
|
|
42
|
+
function redactStatus(s) {
|
|
43
|
+
return {
|
|
44
|
+
proxyRunning: s?.proxyRunning === true,
|
|
45
|
+
proxyPort: s?.proxyPort ?? null,
|
|
46
|
+
lanUrl: s?.lanUrl ?? null,
|
|
47
|
+
lanQr: s?.lanQr ?? null,
|
|
48
|
+
tunnelRunning: s?.tunnelRunning === true,
|
|
49
|
+
tunnelUrl: s?.tunnelUrl ?? null,
|
|
50
|
+
tunnelQr: s?.tunnelQr ?? null,
|
|
51
|
+
dshPort: s?.dshPort ?? null
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// client/index.jsx
|
|
56
|
+
var name = "dsh-pocket";
|
|
57
|
+
var inject = ["slots", "connection"];
|
|
58
|
+
var styles = {
|
|
59
|
+
card: { background: "var(--dsw-alias-bg-layer-1,#fff)", border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", borderRadius: 12, padding: "14px 16px", maxWidth: 480 },
|
|
60
|
+
block: { borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", marginTop: 12, paddingTop: 12 },
|
|
61
|
+
muted: { color: "var(--dsw-alias-label-tertiary,#8b93a1)", fontSize: 12 },
|
|
62
|
+
code: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 12, wordBreak: "break-all", margin: "4px 0 8px" },
|
|
63
|
+
primary: { font: "inherit", cursor: "pointer", border: "none", background: "var(--dsw-alias-brand-primary,#4f6ef7)", color: "#fff", borderRadius: 8, padding: "6px 14px", fontSize: 13 },
|
|
64
|
+
btn: { font: "inherit", cursor: "pointer", border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", background: "var(--dsw-alias-bg-layer-1,#fff)", borderRadius: 8, padding: "6px 14px", fontSize: 13 },
|
|
65
|
+
qr: { width: 220, height: 220, borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", margin: "6px 0" },
|
|
66
|
+
warn: { color: "var(--dsw-alias-state-warn-primary,#b45309)", fontSize: 12 }
|
|
67
|
+
};
|
|
68
|
+
function PocketSettingsTab({ rpcCall }) {
|
|
69
|
+
const [status, setStatus] = (0, import_react.useState)(null);
|
|
70
|
+
const [busy, setBusy] = (0, import_react.useState)(false);
|
|
71
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
72
|
+
const call = async (endpoint, payload) => {
|
|
73
|
+
const res = await rpcCall(endpoint, payload);
|
|
74
|
+
if (!res?.ok) throw new Error(res?.error?.message ?? "RPC failed");
|
|
75
|
+
return res.value;
|
|
76
|
+
};
|
|
77
|
+
const load = async () => {
|
|
78
|
+
try {
|
|
79
|
+
setStatus(await call(POCKET_ENDPOINTS.status, {}));
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
(0, import_react.useEffect)(() => {
|
|
84
|
+
load();
|
|
85
|
+
const t = setInterval(load, 3e3);
|
|
86
|
+
return () => clearInterval(t);
|
|
87
|
+
}, []);
|
|
88
|
+
const startTunnel = async () => {
|
|
89
|
+
setBusy(true);
|
|
90
|
+
setError(null);
|
|
91
|
+
try {
|
|
92
|
+
setStatus(await call(POCKET_ENDPOINTS.tunnelStart, {}));
|
|
93
|
+
} catch (err) {
|
|
94
|
+
setError(err.message);
|
|
95
|
+
} finally {
|
|
96
|
+
setBusy(false);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const stopTunnel = async () => {
|
|
100
|
+
try {
|
|
101
|
+
setStatus(await call(POCKET_ENDPOINTS.tunnelStop, {}));
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const lanUrl = status?.lanUrl;
|
|
106
|
+
const tunnelUrl = status?.tunnelUrl;
|
|
107
|
+
return (0, import_react.createElement)(
|
|
108
|
+
"div",
|
|
109
|
+
{ style: styles.card },
|
|
110
|
+
(0, import_react.createElement)(
|
|
111
|
+
"div",
|
|
112
|
+
null,
|
|
113
|
+
(0, import_react.createElement)("strong", null, "\u{1F4F1} \u624B\u673A\u8BBF\u95EE | Phone access"),
|
|
114
|
+
(0, import_react.createElement)("div", { style: styles.muted }, "\u624B\u673A\u626B\u7801\u6253\u5F00\u7684\u5C31\u662F\u7535\u8111\u4E0A\u7684\u8FD9\u4E2A\u754C\u9762\uFF0C\u5B9E\u65F6\u540C\u6B65 | the phone shows this exact screen, live")
|
|
115
|
+
),
|
|
116
|
+
// 局域网
|
|
117
|
+
(0, import_react.createElement)(
|
|
118
|
+
"div",
|
|
119
|
+
{ style: styles.block },
|
|
120
|
+
(0, import_react.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, "\u{1F4F6} \u5C40\u57DF\u7F51\uFF08\u540C\u4E00 WiFi\uFF09| LAN"),
|
|
121
|
+
lanUrl ? (0, import_react.createElement)(
|
|
122
|
+
"div",
|
|
123
|
+
null,
|
|
124
|
+
(0, import_react.createElement)("img", { src: status.lanQr, alt: "LAN QR", style: styles.qr }),
|
|
125
|
+
(0, import_react.createElement)("div", { style: styles.code }, lanUrl),
|
|
126
|
+
(0, import_react.createElement)("div", { style: styles.muted }, "\u624B\u673A\u8FDE\u63A5\u540C\u4E00 WiFi \u540E\u626B\u7801\u5373\u53EF\u6253\u5F00")
|
|
127
|
+
) : (0, import_react.createElement)("div", { style: styles.muted }, "\u4EE3\u7406\u672A\u5C31\u7EEA\u2026 | proxy starting\u2026")
|
|
128
|
+
),
|
|
129
|
+
// 公网
|
|
130
|
+
(0, import_react.createElement)(
|
|
131
|
+
"div",
|
|
132
|
+
{ style: styles.block },
|
|
133
|
+
(0, import_react.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, "\u{1F310} \u516C\u7F51\uFF08\u4EBA\u5728\u5916\u9762\uFF09| Anywhere"),
|
|
134
|
+
tunnelUrl ? (0, import_react.createElement)(
|
|
135
|
+
"div",
|
|
136
|
+
null,
|
|
137
|
+
(0, import_react.createElement)("img", { src: status.tunnelQr, alt: "Tunnel QR", style: styles.qr }),
|
|
138
|
+
(0, import_react.createElement)("div", { style: styles.code }, tunnelUrl),
|
|
139
|
+
(0, import_react.createElement)("div", { style: styles.muted }, "\u4EFB\u4F55\u7F51\u7EDC\u626B\u7801\u5373\u7528\uFF08URL \u6BCF\u6B21\u91CD\u542F\u4F1A\u53D8\uFF09"),
|
|
140
|
+
(0, import_react.createElement)("button", { style: styles.btn, onClick: stopTunnel }, "\u5173\u95ED\u516C\u7F51 | Stop")
|
|
141
|
+
) : (0, import_react.createElement)(
|
|
142
|
+
"div",
|
|
143
|
+
null,
|
|
144
|
+
(0, import_react.createElement)("button", { style: styles.primary, onClick: startTunnel, disabled: busy }, busy ? "\u5F00\u542F\u4E2D\u2026\uFF08\u9996\u6B21\u9700\u4E0B\u8F7D cloudflared\uFF09" : "\u5F00\u542F\u516C\u7F51\u8BBF\u95EE | Enable anywhere"),
|
|
145
|
+
(0, import_react.createElement)("div", { style: styles.warn, marginTop: 8 }, "\u26A0\uFE0F DSH \u80FD\u6267\u884C\u7535\u8111\u4EE3\u7801\uFF1A\u4E8C\u7EF4\u7801/URL \u5C31\u662F\u94A5\u5319\uFF0C\u8BF7\u52FF\u53D1\u7ED9\u522B\u4EBA")
|
|
146
|
+
)
|
|
147
|
+
),
|
|
148
|
+
error ? (0, import_react.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${error}`) : null
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
function apply(ctx) {
|
|
152
|
+
const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(POCKET_RPC_CHANNEL, endpoint, payload, signal);
|
|
153
|
+
ctx.slots.inject(
|
|
154
|
+
"settings.plugins.tab",
|
|
155
|
+
() => ctx.slots.register(
|
|
156
|
+
{
|
|
157
|
+
name: "settings.plugins.tab",
|
|
158
|
+
id: "pocket",
|
|
159
|
+
order: 10,
|
|
160
|
+
label: "\u624B\u673A\u8BBF\u95EE",
|
|
161
|
+
inject: () => ({ rpcCall })
|
|
162
|
+
},
|
|
163
|
+
PocketSettingsTab
|
|
164
|
+
)
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return module.exports;
|
|
169
|
+
}
|
|
170
|
+
});
|
package/client/index.jsx
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// dsh-pocket 网页设置页签(设置 → 插件 → 手机访问)
|
|
2
|
+
//
|
|
3
|
+
// 打开即显示局域网二维码;点「开启公网」出公网二维码。
|
|
4
|
+
// 手机扫码打开的就是电脑上的 dsh web,实时同步。
|
|
5
|
+
|
|
6
|
+
import { createElement as h, useEffect, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, redactStatus } from './api.js';
|
|
9
|
+
|
|
10
|
+
const name = 'dsh-pocket';
|
|
11
|
+
const inject = ['slots', 'connection'];
|
|
12
|
+
|
|
13
|
+
const styles = {
|
|
14
|
+
card: { background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', borderRadius: 12, padding: '14px 16px', maxWidth: 480 },
|
|
15
|
+
block: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', marginTop: 12, paddingTop: 12 },
|
|
16
|
+
muted: { color: 'var(--dsw-alias-label-tertiary,#8b93a1)', fontSize: 12 },
|
|
17
|
+
code: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 12, wordBreak: 'break-all', margin: '4px 0 8px' },
|
|
18
|
+
primary: { font: 'inherit', cursor: 'pointer', border: 'none', background: 'var(--dsw-alias-brand-primary,#4f6ef7)', color: '#fff', borderRadius: 8, padding: '6px 14px', fontSize: 13 },
|
|
19
|
+
btn: { font: 'inherit', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', background: 'var(--dsw-alias-bg-layer-1,#fff)', borderRadius: 8, padding: '6px 14px', fontSize: 13 },
|
|
20
|
+
qr: { width: 220, height: 220, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', margin: '6px 0' },
|
|
21
|
+
warn: { color: 'var(--dsw-alias-state-warn-primary,#b45309)', fontSize: 12 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function PocketSettingsTab({ rpcCall }) {
|
|
25
|
+
const [status, setStatus] = useState(null);
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
const [error, setError] = useState(null);
|
|
28
|
+
|
|
29
|
+
const call = async (endpoint, payload) => {
|
|
30
|
+
const res = await rpcCall(endpoint, payload);
|
|
31
|
+
if (!res?.ok) throw new Error(res?.error?.message ?? 'RPC failed');
|
|
32
|
+
return res.value;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const load = async () => {
|
|
36
|
+
try { setStatus(await call(POCKET_ENDPOINTS.status, {})); } catch { /* 忽略瞬时失败 */ }
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
load();
|
|
41
|
+
const t = setInterval(load, 3000);
|
|
42
|
+
return () => clearInterval(t);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const startTunnel = async () => {
|
|
46
|
+
setBusy(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
setStatus(await call(POCKET_ENDPOINTS.tunnelStart, {}));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
setError(err.message);
|
|
52
|
+
} finally {
|
|
53
|
+
setBusy(false);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const stopTunnel = async () => {
|
|
58
|
+
try { setStatus(await call(POCKET_ENDPOINTS.tunnelStop, {})); } catch { /* 忽略 */ }
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const lanUrl = status?.lanUrl;
|
|
62
|
+
const tunnelUrl = status?.tunnelUrl;
|
|
63
|
+
|
|
64
|
+
return h('div', { style: styles.card },
|
|
65
|
+
h('div', null,
|
|
66
|
+
h('strong', null, '📱 手机访问 | Phone access'),
|
|
67
|
+
h('div', { style: styles.muted }, '手机扫码打开的就是电脑上的这个界面,实时同步 | the phone shows this exact screen, live'),
|
|
68
|
+
),
|
|
69
|
+
|
|
70
|
+
// 局域网
|
|
71
|
+
h('div', { style: styles.block },
|
|
72
|
+
h('div', { style: { fontWeight: 600, fontSize: 13 } }, '📶 局域网(同一 WiFi)| LAN'),
|
|
73
|
+
lanUrl
|
|
74
|
+
? h('div', null,
|
|
75
|
+
h('img', { src: status.lanQr, alt: 'LAN QR', style: styles.qr }),
|
|
76
|
+
h('div', { style: styles.code }, lanUrl),
|
|
77
|
+
h('div', { style: styles.muted }, '手机连接同一 WiFi 后扫码即可打开'),
|
|
78
|
+
)
|
|
79
|
+
: h('div', { style: styles.muted }, '代理未就绪… | proxy starting…'),
|
|
80
|
+
),
|
|
81
|
+
|
|
82
|
+
// 公网
|
|
83
|
+
h('div', { style: styles.block },
|
|
84
|
+
h('div', { style: { fontWeight: 600, fontSize: 13 } }, '🌐 公网(人在外面)| Anywhere'),
|
|
85
|
+
tunnelUrl
|
|
86
|
+
? h('div', null,
|
|
87
|
+
h('img', { src: status.tunnelQr, alt: 'Tunnel QR', style: styles.qr }),
|
|
88
|
+
h('div', { style: styles.code }, tunnelUrl),
|
|
89
|
+
h('div', { style: styles.muted }, '任何网络扫码即用(URL 每次重启会变)'),
|
|
90
|
+
h('button', { style: styles.btn, onClick: stopTunnel }, '关闭公网 | Stop'),
|
|
91
|
+
)
|
|
92
|
+
: h('div', null,
|
|
93
|
+
h('button', { style: styles.primary, onClick: startTunnel, disabled: busy }, busy ? '开启中…(首次需下载 cloudflared)' : '开启公网访问 | Enable anywhere'),
|
|
94
|
+
h('div', { style: styles.warn, marginTop: 8 }, '⚠️ DSH 能执行电脑代码:二维码/URL 就是钥匙,请勿发给别人'),
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
|
|
98
|
+
error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${error}`) : null,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function apply(ctx) {
|
|
103
|
+
const rpcCall = (endpoint, payload, signal) =>
|
|
104
|
+
ctx.connection.rpc.call(POCKET_RPC_CHANNEL, endpoint, payload, signal);
|
|
105
|
+
|
|
106
|
+
ctx.slots.inject('settings.plugins.tab', () =>
|
|
107
|
+
ctx.slots.register(
|
|
108
|
+
{
|
|
109
|
+
name: 'settings.plugins.tab',
|
|
110
|
+
id: 'pocket',
|
|
111
|
+
order: 10,
|
|
112
|
+
label: '手机访问',
|
|
113
|
+
inject: () => ({ rpcCall }),
|
|
114
|
+
},
|
|
115
|
+
PocketSettingsTab,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { name, inject, redactStatus };
|
package/cordis.patch.yml
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// dsh-pocket 插件入口(单包单插件:手机扫码访问 DSH,全在这一个包里)
|
|
2
|
+
//
|
|
3
|
+
// 设置页「设置 → 插件 → 手机访问」:
|
|
4
|
+
// - 局域网二维码:自动显示(代理随插件启动)
|
|
5
|
+
// - 公网二维码:点「开启公网」→ cloudflared 隧道 → 扫码即用,人在外面也能访问
|
|
6
|
+
// 手机看到的界面 = 电脑上的 dsh web,实时同步(WebSocket 透传)。
|
|
7
|
+
|
|
8
|
+
import { createPocketService } from './service.mjs';
|
|
9
|
+
import { installPocketRpc } from './web-rpc.js';
|
|
10
|
+
|
|
11
|
+
const name = 'dsh-pocket';
|
|
12
|
+
const inject = ['connection', 'webServer'];
|
|
13
|
+
|
|
14
|
+
export function apply(ctx, config = {}, internals = {}) {
|
|
15
|
+
const logger = ctx.logger?.(name) ?? console;
|
|
16
|
+
const dshPort = internals.dshPort ?? ctx.webServer?.port;
|
|
17
|
+
if (!dshPort) {
|
|
18
|
+
logger.error('dsh-pocket: webServer port unavailable — cannot start proxy | 拿不到 dsh web 端口,无法启动代理');
|
|
19
|
+
return () => {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const service = internals.service ?? createPocketService({
|
|
23
|
+
dshPort,
|
|
24
|
+
port: internals.port ?? config.port ?? 3081,
|
|
25
|
+
internals,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const disposeRpc = installPocketRpc(ctx, { service, log: logger });
|
|
29
|
+
|
|
30
|
+
// 代理随插件自动启动(局域网二维码开箱即用,零配置)
|
|
31
|
+
void service.startProxy().then((proxy) => {
|
|
32
|
+
logger.info('dsh-pocket: proxy ready on :%d | 局域网代理已就绪', proxy.port);
|
|
33
|
+
}).catch((err) => {
|
|
34
|
+
logger.error('dsh-pocket: proxy start failed | 代理启动失败: %s', err?.message ?? err);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
ctx.effect(() => async () => {
|
|
38
|
+
disposeRpc();
|
|
39
|
+
await service.dispose();
|
|
40
|
+
}, 'dsh-pocket: stop proxy and tunnel');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { name, inject };
|
package/lib/proxy.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// dsh-pocket 核心:Host/Origin 改写反向代理
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:DSH 的 /api 浏览器信任栅栏只认 loopback(127.0.0.1)或
|
|
4
|
+
// `--trusted-host` 白名单(且官方禁了 0.0.0.0 绑定,防止把远程执行代码暴露给网络)。
|
|
5
|
+
// 本代理把入站请求的 Host / Origin 统一改写成 loopback 权威(127.0.0.1:3080),
|
|
6
|
+
// 转发给本机 dsh web——栅栏永远看到 loopback,于是:
|
|
7
|
+
// - 局域网:手机直接访问 http://<电脑IP>:端口
|
|
8
|
+
// - 公网:cloudflared 隧道指到本代理,任意域名都能进
|
|
9
|
+
// 都不需要改 dsh 的任何配置。
|
|
10
|
+
//
|
|
11
|
+
// 同步保证:普通请求与 WebSocket upgrade(/api/events.host 流式推送)都原样透传,
|
|
12
|
+
// 手机看到的界面与电脑完全一致、实时。
|
|
13
|
+
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { request as httpRequest } from 'node:http';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_UPSTREAM = { host: '127.0.0.1', port: 3080 };
|
|
18
|
+
|
|
19
|
+
/** 把浏览器可见的权威改写成 loopback 权威(Host 和 Origin 都改)。 */
|
|
20
|
+
function loopbackAuthority(headers, upstream) {
|
|
21
|
+
const authority = `${upstream.host}:${upstream.port}`;
|
|
22
|
+
headers.Host = authority;
|
|
23
|
+
if (headers.origin) headers.origin = `http://${authority}`;
|
|
24
|
+
if (headers.Origin) headers.Origin = `http://${authority}`;
|
|
25
|
+
return headers;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 启动 dsh-pocket 代理。
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* @param {number} [opts.port] 监听端口(默认 3081;dsh web 保持 3080)
|
|
32
|
+
* @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到)
|
|
33
|
+
* @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
|
|
34
|
+
* @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
|
|
35
|
+
*/
|
|
36
|
+
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM } = {}) {
|
|
37
|
+
const server = createServer((req, res) => {
|
|
38
|
+
const headers = loopbackAuthority({ ...req.headers }, upstream);
|
|
39
|
+
const proxyReq = httpRequest(
|
|
40
|
+
{ host: upstream.host, port: upstream.port, method: req.method, path: req.url, headers, agent: false },
|
|
41
|
+
(proxyRes) => {
|
|
42
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
43
|
+
proxyRes.pipe(res);
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
proxyReq.on('error', (err) => {
|
|
47
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
|
48
|
+
res.end(`dsh-pocket: 无法连接上游 dsh web(${upstream.host}:${upstream.port})——先启动 dsh web | ${err.message}`);
|
|
49
|
+
});
|
|
50
|
+
req.pipe(proxyReq);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// WebSocket upgrade(DSH 的 /api/events.host 流式通道)原样透传
|
|
54
|
+
server.on('upgrade', (req, socket, head) => {
|
|
55
|
+
const headers = loopbackAuthority({ ...req.headers }, upstream);
|
|
56
|
+
const proxyReq = httpRequest({
|
|
57
|
+
host: upstream.host, port: upstream.port, method: req.method, path: req.url, headers, agent: false,
|
|
58
|
+
});
|
|
59
|
+
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
60
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\n');
|
|
61
|
+
// 原样回传上游的 upgrade 头(Sec-WebSocket-Accept 等)
|
|
62
|
+
const raw = [];
|
|
63
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
64
|
+
raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
65
|
+
}
|
|
66
|
+
socket.write(`${raw.join('\r\n')}\r\n\r\n`);
|
|
67
|
+
socket.write(proxyHead);
|
|
68
|
+
proxySocket.pipe(socket);
|
|
69
|
+
socket.pipe(proxySocket);
|
|
70
|
+
});
|
|
71
|
+
proxyReq.on('error', () => socket.destroy());
|
|
72
|
+
proxyReq.end();
|
|
73
|
+
socket.on('error', () => socket.destroy());
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
server.once('error', reject);
|
|
78
|
+
server.listen(port, host, () => {
|
|
79
|
+
const actualPort = server.address().port;
|
|
80
|
+
resolve({
|
|
81
|
+
server,
|
|
82
|
+
port: actualPort,
|
|
83
|
+
close: () => new Promise((r) => server.close(() => r())),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
package/lib/service.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// dsh-pocket 服务:在 dsh web 进程内跑改头代理 + 公网隧道
|
|
2
|
+
//
|
|
3
|
+
// - 代理:监听 0.0.0.0:<port>(默认 3081),把入站 Host/Origin 改写成
|
|
4
|
+
// 127.0.0.1:<dshPort>(dsh web 实际端口),HTTP + WebSocket 全透传。
|
|
5
|
+
// 这样 DSH 的 /api 浏览器信任栅栏永远看到 loopback,局域网/公网都能进,
|
|
6
|
+
// 且不需要改 dsh 的任何配置(0.0.0.0 绑定被 dsh 官方禁用)。
|
|
7
|
+
// - 隧道:cloudflared 快速隧道(可选),公网 https URL,供人在外面访问。
|
|
8
|
+
|
|
9
|
+
import { networkInterfaces } from 'node:os';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { createPocketProxy } from './proxy.mjs';
|
|
12
|
+
import { startQuickTunnel } from './tunnel.mjs';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
/** URL → 二维码 data URL(浏览器 <img> 直接显示,全本地不依赖第三方)。 */
|
|
17
|
+
export async function qrDataUrl(text, { width = 220, margin = 1 } = {}) {
|
|
18
|
+
const QRCode = require('qrcode');
|
|
19
|
+
return QRCode.toDataURL(text, { errorCorrectionLevel: 'M', margin, width, type: 'image/png' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function lanIPv4() {
|
|
23
|
+
for (const ifaces of Object.values(networkInterfaces())) {
|
|
24
|
+
for (const i of ifaces ?? []) {
|
|
25
|
+
if (i.family === 'IPv4' && !i.internal) return i.address;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 创建 Pocket 服务。
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {number} opts.dshPort dsh web 实际端口(从 ctx.webServer.port 取)
|
|
35
|
+
* @param {number} [opts.port] 代理端口(默认 3081)
|
|
36
|
+
* @param {object} [opts.internals] 测试注入:createProxy / startTunnel / lanIPv4
|
|
37
|
+
* @returns {PocketService}
|
|
38
|
+
*/
|
|
39
|
+
export function createPocketService({
|
|
40
|
+
dshPort,
|
|
41
|
+
port = 3081,
|
|
42
|
+
internals = {},
|
|
43
|
+
} = {}) {
|
|
44
|
+
const createProxy = internals.createProxy ?? createPocketProxy;
|
|
45
|
+
const startTunnel = internals.startTunnel ?? startQuickTunnel;
|
|
46
|
+
const getLan = internals.lanIPv4 ?? lanIPv4;
|
|
47
|
+
|
|
48
|
+
let proxy = null;
|
|
49
|
+
let tunnel = null;
|
|
50
|
+
let tunnelAbort = null;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
/** 启动局域网代理(幂等)。 */
|
|
54
|
+
async startProxy() {
|
|
55
|
+
if (proxy) return proxy;
|
|
56
|
+
proxy = await createProxy({
|
|
57
|
+
port,
|
|
58
|
+
host: '0.0.0.0',
|
|
59
|
+
upstream: { host: '127.0.0.1', port: dshPort },
|
|
60
|
+
});
|
|
61
|
+
return proxy;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/** 启动公网隧道(幂等;返回公网 URL)。 */
|
|
65
|
+
async startTunnel() {
|
|
66
|
+
await this.startProxy();
|
|
67
|
+
if (tunnel) return tunnel.url;
|
|
68
|
+
tunnelAbort = new AbortController();
|
|
69
|
+
const result = await startTunnel({ port: proxy.port, signal: tunnelAbort.signal });
|
|
70
|
+
// 归一化:startTunnel 契约返回 {url, kill}(字符串也兼容)
|
|
71
|
+
tunnel = typeof result === 'string' ? { url: result, kill: () => {} } : result;
|
|
72
|
+
return tunnel.url;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/** 停止公网隧道(代理保持)。 */
|
|
76
|
+
stopTunnel() {
|
|
77
|
+
tunnelAbort?.abort();
|
|
78
|
+
tunnelAbort = null;
|
|
79
|
+
if (tunnel) tunnel.kill();
|
|
80
|
+
tunnel = null;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/** 状态快照(RPC 返回,不含敏感信息;二维码 data URL 本地生成)。 */
|
|
84
|
+
async status() {
|
|
85
|
+
const lan = getLan();
|
|
86
|
+
const proxyPort = proxy?.port ?? null;
|
|
87
|
+
const lanUrl = lan && proxyPort ? `http://${lan}:${proxyPort}` : null;
|
|
88
|
+
const encode = internals.encodeQr ?? qrDataUrl;
|
|
89
|
+
return {
|
|
90
|
+
proxyRunning: proxy !== null,
|
|
91
|
+
proxyPort,
|
|
92
|
+
lanUrl,
|
|
93
|
+
lanQr: lanUrl ? await encode(lanUrl) : null,
|
|
94
|
+
tunnelRunning: tunnel !== null,
|
|
95
|
+
tunnelUrl: tunnel?.url ?? null,
|
|
96
|
+
tunnelQr: tunnel?.url ? await encode(tunnel.url) : null,
|
|
97
|
+
dshPort,
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
/** 停止一切(插件卸载时)。 */
|
|
102
|
+
async dispose() {
|
|
103
|
+
this.stopTunnel();
|
|
104
|
+
if (proxy) {
|
|
105
|
+
const p = proxy;
|
|
106
|
+
proxy = null;
|
|
107
|
+
await p.close();
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
package/lib/tunnel.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// cloudflared 快速隧道:把本机代理暴露成公网 https URL
|
|
2
|
+
//
|
|
3
|
+
// 手机在任何网络都能访问;URL 由 cloudflared 随机分配(每次重启会变)。
|
|
4
|
+
// 无密码模式:URL 即钥匙(dsh web 能执行代码,请勿把二维码/URL 发给别人)。
|
|
5
|
+
|
|
6
|
+
import { spawn, execSync } from 'node:child_process';
|
|
7
|
+
import { mkdtemp, chmod } from 'node:fs/promises';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { pipeline } from 'node:stream/promises';
|
|
11
|
+
import { Readable } from 'node:stream';
|
|
12
|
+
import { createWriteStream } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
const QUICK_TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
15
|
+
|
|
16
|
+
function platformBinary() {
|
|
17
|
+
const archMap = { x64: 'amd64', arm64: 'arm64' };
|
|
18
|
+
const a = archMap[process.arch] ?? process.arch;
|
|
19
|
+
const os = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux';
|
|
20
|
+
return { os, a, ext: os === 'windows' ? '.exe' : '' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function downloadCloudflared(target) {
|
|
24
|
+
const { os, a, ext } = platformBinary();
|
|
25
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${os}-${a}${ext}`;
|
|
26
|
+
console.log(`⬇️ 正在下载 cloudflared(首次运行,之后跳过)…`);
|
|
27
|
+
const res = await fetch(url);
|
|
28
|
+
if (!res.ok) throw new Error(`cloudflared 下载失败(HTTP ${res.status})`);
|
|
29
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(target));
|
|
30
|
+
if (os !== 'windows') await chmod(target, 0o755);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** PATH 里是否已有 cloudflared。 */
|
|
34
|
+
function cloudflaredOnPath() {
|
|
35
|
+
try {
|
|
36
|
+
execSync(process.platform === 'win32' ? 'where cloudflared' : 'command -v cloudflared', { stdio: 'ignore' });
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 拿一个可用的 cloudflared 路径(PATH 没有就下载到临时目录)。 */
|
|
44
|
+
async function resolveCloudflared() {
|
|
45
|
+
if (cloudflaredOnPath()) return 'cloudflared';
|
|
46
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-pocket-'));
|
|
47
|
+
const bin = join(dir, `cloudflared${platformBinary().ext}`);
|
|
48
|
+
await downloadCloudflared(bin);
|
|
49
|
+
return bin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 启动 cloudflared 快速隧道,返回公网 URL。
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* @param {number} opts.port 本机代理端口
|
|
56
|
+
* @param {AbortSignal} [opts.signal]
|
|
57
|
+
* @returns {Promise<{url:string, kill:()=>void}>}
|
|
58
|
+
*/
|
|
59
|
+
export async function startQuickTunnel({ port, signal }) {
|
|
60
|
+
const bin = await resolveCloudflared();
|
|
61
|
+
const child = spawn(bin, ['tunnel', '--url', `http://127.0.0.1:${port}`, '--no-autoupdate'], {
|
|
62
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const url = await new Promise((resolve, reject) => {
|
|
66
|
+
let buf = '';
|
|
67
|
+
const onData = (chunk) => {
|
|
68
|
+
buf += String(chunk);
|
|
69
|
+
const m = buf.match(QUICK_TUNNEL_URL_RE);
|
|
70
|
+
if (m) {
|
|
71
|
+
cleanup();
|
|
72
|
+
resolve(m[0]);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const onExit = (code) => {
|
|
76
|
+
cleanup();
|
|
77
|
+
reject(new Error(`cloudflared 退出(code=${code})`));
|
|
78
|
+
};
|
|
79
|
+
const cleanup = () => {
|
|
80
|
+
child.stdout.off('data', onData);
|
|
81
|
+
child.stderr.off('data', onData);
|
|
82
|
+
child.off('exit', onExit);
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
signal?.removeEventListener('abort', onAbort);
|
|
85
|
+
};
|
|
86
|
+
const onAbort = () => {
|
|
87
|
+
cleanup();
|
|
88
|
+
child.kill();
|
|
89
|
+
reject(new Error('已取消 | cancelled'));
|
|
90
|
+
};
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
cleanup();
|
|
93
|
+
child.kill();
|
|
94
|
+
reject(new Error('cloudflared 启动超时(30s)'));
|
|
95
|
+
}, 30_000);
|
|
96
|
+
|
|
97
|
+
child.stdout.on('data', onData);
|
|
98
|
+
child.stderr.on('data', onData);
|
|
99
|
+
child.once('exit', onExit);
|
|
100
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
url,
|
|
105
|
+
kill: () => {
|
|
106
|
+
try { child.kill(); } catch { /* 忽略 */ }
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
package/lib/web-rpc.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// dsh-pocket Web RPC(loopback-only):设置页 ⇄ Host 的手机访问通道
|
|
2
|
+
|
|
3
|
+
import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, redactStatus } from '../client/api.js';
|
|
4
|
+
|
|
5
|
+
function ok(value) {
|
|
6
|
+
return { ok: true, value };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function fail(code, message) {
|
|
10
|
+
return { ok: false, error: { code, message } };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。 */
|
|
14
|
+
export function installPocketRpc(ctx, { service, log = console }) {
|
|
15
|
+
if (!ctx?.connection?.rpc?.handle) {
|
|
16
|
+
log.warn?.('dsh-pocket: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
|
|
17
|
+
return () => {};
|
|
18
|
+
}
|
|
19
|
+
return ctx.connection.rpc.handle(POCKET_RPC_CHANNEL, async (endpoint, _payload = {}, signal) => {
|
|
20
|
+
if (signal?.aborted) return fail('cancelled', 'The request was cancelled.');
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
if (endpoint === POCKET_ENDPOINTS.status) {
|
|
24
|
+
return ok(redactStatus(await service.status()));
|
|
25
|
+
}
|
|
26
|
+
if (endpoint === POCKET_ENDPOINTS.tunnelStart) {
|
|
27
|
+
await service.startTunnel();
|
|
28
|
+
return ok(redactStatus(await service.status()));
|
|
29
|
+
}
|
|
30
|
+
if (endpoint === POCKET_ENDPOINTS.tunnelStop) {
|
|
31
|
+
service.stopTunnel();
|
|
32
|
+
return ok(redactStatus(await service.status()));
|
|
33
|
+
}
|
|
34
|
+
return fail('bad-request', `Unknown endpoint: ${endpoint}`);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
log.error?.('dsh-pocket: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
|
|
37
|
+
return fail('internal', err?.message ?? String(err));
|
|
38
|
+
}
|
|
39
|
+
}, { authority: 'loopback' });
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-pocket",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "把 DeepSeek Harness 装进你的口袋:一个包、一个设置页,手机扫码即同步访问电脑上的 DSH(局域网 + 公网,实时同屏)。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dsh-pocket": "bin/dsh-pocket.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"default": "./lib/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./client": "./client/client.js",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"lib",
|
|
20
|
+
"client",
|
|
21
|
+
"cordis.patch.yml",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build:client": "node client/build.mjs",
|
|
27
|
+
"test": "node --test --test-timeout=30000 \"test/*.test.js\""
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"qrcode": "^1.5.4",
|
|
31
|
+
"qrcode-terminal": "^0.12.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"esbuild": "^0.25.9",
|
|
35
|
+
"ws": "^8.18.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"keywords": [
|
|
45
|
+
"deepseek-harness",
|
|
46
|
+
"dsh",
|
|
47
|
+
"dsh-plugin",
|
|
48
|
+
"mobile",
|
|
49
|
+
"remote",
|
|
50
|
+
"qr",
|
|
51
|
+
"tunnel"
|
|
52
|
+
],
|
|
53
|
+
"dsh": {
|
|
54
|
+
"bundle": {
|
|
55
|
+
"patch": "./cordis.patch.yml"
|
|
56
|
+
},
|
|
57
|
+
"client": {
|
|
58
|
+
"inject": [
|
|
59
|
+
"@deepseek-ai/dsh-client-connection",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
61
|
+
],
|
|
62
|
+
"platform": "web"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "git+https://github.com/shaobeichen/dsh-pocket.git"
|
|
68
|
+
},
|
|
69
|
+
"bugs": {
|
|
70
|
+
"url": "https://github.com/shaobeichen/dsh-pocket/issues"
|
|
71
|
+
},
|
|
72
|
+
"homepage": "https://github.com/shaobeichen/dsh-pocket",
|
|
73
|
+
"publishConfig": {
|
|
74
|
+
"access": "public",
|
|
75
|
+
"registry": "https://registry.npmjs.org/"
|
|
76
|
+
}
|
|
77
|
+
}
|