dsh-mobile-plugin 0.5.19 → 0.5.20

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.
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 桌面端画布的库读写(`/api/relay/canvas/*`):本机信任、明文。
5
+ *
6
+ * 手机那条走的是 `/api/mobile/v1/canvas/*`,字节过设备加密信封;桌面端这一页由插件在同一个
7
+ * 源上吐出,同机回环直接用明文 HTTP 就够了,不必也不该把设备凭据搬到桌面。两条路由读写的
8
+ * 是**同一份库**(`mobile/canvas-store.js`):清单复用它的两个 handler,字节直接落
9
+ * `objects/`(盘上本来就是明文)。
10
+ *
11
+ * 能力边界:客户端只给域、哈希、字节,给不了任意路径;`file-put` 只按内容哈希落库(去重),
12
+ * 与手机那条同一口径。
13
+ */
14
+
15
+ const fs = require('node:fs');
16
+ const fsp = require('node:fs/promises');
17
+ const path = require('node:path');
18
+ const crypto = require('node:crypto');
19
+ const { pipeline } = require('node:stream/promises');
20
+
21
+ const { sendJson, readJsonBody } = require('../shared/http-util');
22
+ const store = require('../mobile/canvas-store');
23
+
24
+ const MAX_UPLOAD_BYTES = store.MAX_OBJECT_BYTES;
25
+
26
+ /** POST /api/relay/canvas/file {hash,mimeType} → 原始字节(明文)。 */
27
+ async function handleFileGet(req, res) {
28
+ const body = await readJsonBody(req, 64 * 1024);
29
+ const hash = body && typeof body.hash === 'string' ? body.hash : '';
30
+ const target = store.objectPathOf(hash);
31
+ if (!target) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'hash 形状不对' } });
32
+ let stat;
33
+ try {
34
+ stat = await fsp.stat(target);
35
+ } catch {
36
+ return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '这份字节不在电脑上了' } });
37
+ }
38
+ if (!stat.isFile()) return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '这份字节不在电脑上了' } });
39
+ const mime = body && typeof body.mimeType === 'string' && body.mimeType ? body.mimeType.slice(0, 120) : 'application/octet-stream';
40
+ res.writeHead(200, { 'content-type': mime, 'content-length': String(stat.size) });
41
+ try {
42
+ await pipeline(fs.createReadStream(target), res);
43
+ } catch {
44
+ // 头已经发出去了,只能断开让对端按截断处理(与会话文件那条一致)。
45
+ try { res.destroy(); } catch { /* 连接已关闭 */ }
46
+ }
47
+ }
48
+
49
+ /** POST /api/relay/canvas/file-put?name=… 请求体是原始字节 → { hash, bytes }。 */
50
+ async function handleFilePut(req, res) {
51
+ const temp = path.join(store.rootDir(), `tmp-upload-${crypto.randomBytes(8).toString('hex')}`);
52
+ await fsp.mkdir(path.dirname(temp), { recursive: true });
53
+ let total = 0;
54
+ try {
55
+ await new Promise((resolve, reject) => {
56
+ const out = fs.createWriteStream(temp);
57
+ let aborted = false;
58
+ req.on('data', (chunk) => {
59
+ total += chunk.length;
60
+ if (total > MAX_UPLOAD_BYTES && !aborted) {
61
+ aborted = true;
62
+ const error = Object.assign(new Error('这份文件太大,画布库装不下'), { status: 413 });
63
+ req.unpipe(out);
64
+ out.destroy();
65
+ reject(error);
66
+ }
67
+ });
68
+ req.on('error', reject);
69
+ out.on('error', reject);
70
+ out.on('finish', resolve);
71
+ req.pipe(out);
72
+ });
73
+ if (total === 0) {
74
+ try { await fsp.unlink(temp); } catch { /* 已经不在了 */ }
75
+ return sendJson(res, 400, { ok: false, error: { code: 'empty', message: '请求体里没有字节' } });
76
+ }
77
+ const result = await store.storeUploadedFile(temp);
78
+ return sendJson(res, 200, { ok: true, hash: result.hash, bytes: result.bytes });
79
+ } catch (error) {
80
+ try { await fsp.unlink(temp); } catch { /* 已经不在了 */ }
81
+ if (error && error.status === 413) {
82
+ return sendJson(res, 413, { ok: false, error: { code: 'too-large', message: error.message } });
83
+ }
84
+ return sendJson(res, 500, { ok: false, error: { code: 'upload-failed', message: String((error && error.message) || error) } });
85
+ }
86
+ }
87
+
88
+ /** 转发请求体的上限(模型接口带 base64 图的 POST 也不小;给足但别让整份进内存失控)。 */
89
+ const MAX_PROXY_BODY_BYTES = 128 * 1024 * 1024;
90
+ /** 转发时不该带给上游的请求头:跳数头、压缩协商(交给 fetch 自己解压)。 */
91
+ const STRIP_REQUEST_HEADERS = new Set([
92
+ 'host', 'connection', 'accept-encoding', 'content-length', 'transfer-encoding',
93
+ 'origin', 'referer', 'upgrade', 'keep-alive',
94
+ ]);
95
+ /** 回给页面时不该带回来的响应头:长度与压缩由这一层重新决定(body 已解压)。 */
96
+ const STRIP_RESPONSE_HEADERS = new Set(['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'keep-alive']);
97
+
98
+ function readRawBody(req) {
99
+ return new Promise((resolve, reject) => {
100
+ const chunks = [];
101
+ let total = 0;
102
+ req.on('data', (chunk) => {
103
+ total += chunk.length;
104
+ if (total > MAX_PROXY_BODY_BYTES) {
105
+ reject(Object.assign(new Error('请求体太大'), { status: 413 }));
106
+ req.destroy();
107
+ return;
108
+ }
109
+ chunks.push(chunk);
110
+ });
111
+ req.on('error', reject);
112
+ req.on('end', () => resolve(Buffer.concat(chunks)));
113
+ });
114
+ }
115
+
116
+ /**
117
+ * GET/POST /api/relay/canvas/net?u=<上游地址> —— 桌面端画布的本地转发。
118
+ *
119
+ * 和手机上那只原生环回服务同一件事:页面以为自己在请求同源地址,插件用 Node 的 fetch 去取
120
+ * 上游,响应边收边写回。同源请求没有预检;`accept-encoding` 剥掉让 fetch 自己解压,回写时
121
+ * 再把长度 / 压缩头去掉,body 交给页面按流读(SSE 那条才有用)。
122
+ */
123
+ async function handleNet(req, res) {
124
+ let upstreamUrl;
125
+ try {
126
+ upstreamUrl = new URL(new URL(req.url, 'http://x').searchParams.get('u') || '');
127
+ } catch {
128
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少或非法的上游地址' } });
129
+ }
130
+ if (upstreamUrl.protocol !== 'http:' && upstreamUrl.protocol !== 'https:') {
131
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '只支持 http / https' } });
132
+ }
133
+ const method = (req.method || 'GET').toUpperCase();
134
+ const headers = {};
135
+ for (const [name, value] of Object.entries(req.headers)) {
136
+ if (STRIP_REQUEST_HEADERS.has(name.toLowerCase())) continue;
137
+ if (value === undefined) continue;
138
+ headers[name] = Array.isArray(value) ? value.join(', ') : value;
139
+ }
140
+ let body;
141
+ if (method !== 'GET' && method !== 'HEAD') {
142
+ try {
143
+ body = await readRawBody(req);
144
+ } catch (error) {
145
+ return sendJson(res, error && error.status === 413 ? 413 : 400, { ok: false, error: { code: 'bad-request', message: String((error && error.message) || error) } });
146
+ }
147
+ }
148
+ let upstream;
149
+ try {
150
+ upstream = await fetch(upstreamUrl, { method, headers, body, redirect: 'follow' });
151
+ } catch (error) {
152
+ // 上游根本没连上:回一个带标记的 502,页面据此还原成网络错误(与手机那条一致)。
153
+ res.writeHead(502, { 'content-type': 'application/json', 'x-dsh-net-error': '1' });
154
+ return res.end(JSON.stringify({ ok: false, error: { code: 'network', message: String((error && error.message) || error) } }));
155
+ }
156
+ const outHeaders = {};
157
+ for (const [name, value] of upstream.headers.entries()) {
158
+ if (STRIP_RESPONSE_HEADERS.has(name.toLowerCase())) continue;
159
+ outHeaders[name] = value;
160
+ }
161
+ res.writeHead(upstream.status, outHeaders);
162
+ if (!upstream.body) return res.end();
163
+ try {
164
+ const reader = upstream.body.getReader();
165
+ for (;;) {
166
+ const { done, value } = await reader.read();
167
+ if (done) break;
168
+ if (!res.write(Buffer.from(value))) {
169
+ await new Promise((resolve) => res.once('drain', resolve));
170
+ }
171
+ }
172
+ res.end();
173
+ } catch {
174
+ try { res.destroy(); } catch { /* 连接已关闭 */ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 挂四条桌面端画布数据路由。`ctx` 原样转给 canvas-store 的 handler(它们目前不用它)。
180
+ *
181
+ * @param {{ registerRoute: Function, localOnly: Function, ctx?: object }} deps
182
+ */
183
+ function registerCanvasDesktopRoutes({ registerRoute, localOnly, ctx = null }) {
184
+ const manifestGet = localOnly('GET', (req, res) => store.handleCanvasManifestGet(ctx, req, res));
185
+ const manifestSave = localOnly('POST', (req, res) => store.handleCanvasManifestSave(ctx, req, res));
186
+ const fileGet = localOnly('POST', (req, res) => handleFileGet(req, res));
187
+ const filePut = localOnly('POST', (req, res) => handleFilePut(req, res));
188
+ const net = localOnly(['GET', 'POST'], (req, res) => handleNet(req, res));
189
+ registerRoute({ kind: 'exact', path: '/api/relay/canvas/manifest', handler: manifestGet });
190
+ registerRoute({ kind: 'exact', path: '/api/relay/canvas/manifest/save', handler: manifestSave });
191
+ registerRoute({ kind: 'exact', path: '/api/relay/canvas/file', handler: fileGet });
192
+ registerRoute({ kind: 'exact', path: '/api/relay/canvas/file-put', handler: filePut });
193
+ registerRoute({ kind: 'exact', path: '/api/relay/canvas/net', handler: net });
194
+ }
195
+
196
+ module.exports = { registerCanvasDesktopRoutes };
@@ -53,6 +53,8 @@ const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobi
53
53
  const { recordSessionRead } = require('../mobile/session-last-turn.js');
54
54
  // 收件目录的占用与清理:和移动端那条路由是同一份实现(`lib/mobile/inbox-clean.js`)。
55
55
  const { scanInbox, clearInbox, usageView, setAutoClear } = require('../mobile/inbox-clean.js');
56
+ // 桌面端画布的库读写(本机明文,与手机共用同一份 `mobile/canvas-store.js`)。
57
+ const { registerCanvasDesktopRoutes } = require('./canvas-desktop.js');
56
58
 
57
59
  /** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
58
60
  const MAX_DESKTOP_READ_IDS = 200;
@@ -107,6 +109,12 @@ function createWebAccessLocalOnly({ localOnly, trustedLocalUi, webAccessWindow,
107
109
  * 版本的页面。改版式只有一条路:`React/` 里重建 → 提交产物 → 发新版本 → 完整重启 DSH。
108
110
  */
109
111
  const PANEL_FILE = path.join(__dirname, '..', 'panel', 'index.html');
112
+ /**
113
+ * 桌面端画布页:与 `lib/panel/index.html` 同一个做法 —— 整页内联 HTML 整份吐给浏览器。
114
+ * 产物由 App 的 `scripts/build-canvas-page.mjs` 从同一份 `vendor/canvas/` 顺带生成过来,
115
+ * 源只有一份;这里只读不构建。
116
+ */
117
+ const CANVAS_FILE = path.join(__dirname, '..', 'canvas-app.html');
110
118
 
111
119
  /** 插件包根(`<pkg>/lib/routes` 往上两级):诊断时读它的 `package.json` 与 `React/`。 */
112
120
  const PACKAGE_DIR = path.join(__dirname, '..', '..');
@@ -129,6 +137,7 @@ function generationVersion() {
129
137
 
130
138
  /** 按 mtime 缓存:文件被换掉之后内容变了,缓存自然失效,不必手工清。 */
131
139
  let panelCache = { file: '', mtime: -1, html: '' };
140
+ let canvasCache = { file: '', mtime: -1, html: '' };
132
141
 
133
142
  function panelHtml() {
134
143
  try {
@@ -142,6 +151,18 @@ function panelHtml() {
142
151
  }
143
152
  }
144
153
 
154
+ function canvasHtml() {
155
+ try {
156
+ const stat = fs.statSync(CANVAS_FILE);
157
+ if (canvasCache.file === CANVAS_FILE && canvasCache.mtime === stat.mtimeMs) return canvasCache.html;
158
+ const html = fs.readFileSync(CANVAS_FILE, 'utf8');
159
+ canvasCache = { file: CANVAS_FILE, mtime: stat.mtimeMs, html };
160
+ return html;
161
+ } catch {
162
+ return '';
163
+ }
164
+ }
165
+
145
166
  /**
146
167
  * 面板读不到时,说清楚是哪一种读不到。
147
168
  *
@@ -506,6 +527,25 @@ function registerControlPlaneRoutes(deps) {
506
527
  registerRoute({ kind: 'exact', path: '/api/relay/panel', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
507
528
  registerRoute({ kind: 'exact', path: '/api/relay/panel/setup', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
508
529
  registerRoute({ kind: 'exact', path: '/api/relay/panel/settings', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
530
+ // 桌面端画布页:整页吐同一份内联 HTML,并在 `</head>` 前补一句「这是桌面端」的标记 ——
531
+ // 画布据此把路由从 BrowserRouter 换成 HashRouter(它被挂在 `/api/relay/canvas` 这种非根
532
+ // 路径下,BrowserRouter 的 `/` 匹配不上会 404)。手机 WebView 那份不带这个标记,一个字不改。
533
+ const serveCanvas = (res) => {
534
+ const html = canvasHtml();
535
+ if (!html) {
536
+ process.stderr.write(`[dsh-mobile-plugin] canvas page unavailable ${CANVAS_FILE}\n`);
537
+ return sendJson(res, 500, { ok: false, error: { code: 'canvas_missing', message: '画布页产物不在:跑 node scripts/build-canvas-page.mjs 重新生成' } });
538
+ }
539
+ const withFlag = html.replace(
540
+ '</head>',
541
+ '<script>window.__dshCanvasDesktop=true;window.__dshCanvasHostBase=location.origin;</script></head>',
542
+ );
543
+ return sendHtml(res, 200, withFlag);
544
+ };
545
+ registerRoute({ kind: 'exact', path: '/api/relay/canvas', handler: localOnly('GET', async (_req, res) => serveCanvas(res), { allowDocumentNavigation: true }) })
546
+ // 桌面端画布的库读写:本机信任、明文,与手机 `/api/mobile/v1/canvas/*` 共用同一份存储
547
+ // (`mobile/canvas-store.js`)。桌面这一页是同源的,字节没必要再过设备加密信封。
548
+ registerCanvasDesktopRoutes({ registerRoute, localOnly, ctx })
509
549
  // 桌面端工作区视图(侧栏展开态)的回传/读取。桌面端只把展开态记在浏览器
510
550
  // localStorage 里,宿主进程读不到,只能由页面内的客户端半边(lib/client.js)
511
551
  // 每 2s 变了就 POST 到这里;App 再经 /api/mobile/v1/desktop-view 取走。
@@ -34,6 +34,7 @@ const hostSettings = require('../mobile/host-settings.js');
34
34
  const channels = require('../mobile/channels.js');
35
35
  const modelCaps = require('../mobile/model-caps.js');
36
36
  const deepSeekAccount = require('../mobile/deepseek-account.js');
37
+ const magpieWeb = require('../mobile/magpie-web.js');
37
38
  const skills = require('../mobile/skills.js');
38
39
  const mobileCredentials = require('../mobile/credentials.js');
39
40
  const subagents = require('../mobile/subagents.js');
@@ -426,6 +427,11 @@ function registerMobileRoutes(deps) {
426
427
  route('/api/mobile/v1/host-settings', hostSettings.handleHostSettings)
427
428
  // DeepSeek 账号只读投影:账户资料、钱包余额与官方用量/充值链接;不开放登录操作或凭据。
428
429
  route('/api/mobile/v1/deepseek-account', deepSeekAccount.handleDeepSeekAccount)
430
+ // magpie 自带的网页版(Agents / Providers / Routing / Usage / Settings 全在里面)。
431
+ // 手机那边把这一页整个嵌进来,不再自己画一套:它本来就是给手机做的,页面里写着
432
+ // 「加到主屏后作为独立 App 打开」。网页版是独立进程,没在跑就在这里替用户拉起来。
433
+ // key 是凭据,所以走 guarded,与 `host-web` 那条同一个口径。
434
+ route('/api/mobile/v1/magpie/web', magpieWeb.handleMagpieWeb)
429
435
  // 改设置:与桌面端同一个 settings.mutate(按路径寻址的 op + expectedRevision 乐观并发)。
430
436
  route('/api/mobile/v1/host-settings/mutate', hostSettings.handleHostSettingsMutate)
431
437
  route('/api/mobile/v1/llm/providers', hostSettings.handleLlmProviders)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.19",
3
+ "version": "0.5.20",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "icon": "./icon.png",