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.
package/lib/client.js CHANGED
@@ -508,7 +508,10 @@ window.__ModuleLoader__.load({
508
508
  nav: '移动端',
509
509
  heading: '移动端',
510
510
  cardPanel: '配置页面',
511
+ cardCanvas: '画布',
511
512
  openPanel: '打开配置页面',
513
+ openCanvas: '打开画布',
514
+ canvasHint: '在浏览器里打开无限画布;它的库与手机共用同一份。',
512
515
  openHint: '配对、网络配置与插件更新都在这个页面里。',
513
516
  panelPending: '正在读取页面地址…',
514
517
  panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
@@ -566,7 +569,10 @@ window.__ModuleLoader__.load({
566
569
  nav: 'Mobile',
567
570
  heading: 'Mobile',
568
571
  cardPanel: 'Configuration page',
572
+ cardCanvas: 'Canvas',
569
573
  openPanel: 'Open the configuration page',
574
+ openCanvas: 'Open the canvas',
575
+ canvasHint: 'Open the infinite canvas in your browser; it shares one library with your phone.',
570
576
  openHint: 'Pairing, network settings and plugin updates all live on this page.',
571
577
  panelPending: 'Reading the page address…',
572
578
  panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
@@ -794,6 +800,23 @@ window.__ModuleLoader__.load({
794
800
  }
795
801
  }
796
802
 
803
+ /**
804
+ * 画布页在**这个页面自己**的同源地址上(与面板同一台宿主、同一个前缀)。
805
+ *
806
+ * 桌面用户在 DSH 里点「打开画布」,就是跳到浏览器打开这一页;页面由插件托管,库与手机共用。
807
+ */
808
+ function sameOriginCanvasUrl() {
809
+ try {
810
+ const base = typeof document !== 'undefined' && document.baseURI
811
+ ? document.baseURI
812
+ : (typeof location !== 'undefined' ? location.href : '');
813
+ if (!base) return '';
814
+ return new URL('api/relay/canvas', base).href;
815
+ } catch {
816
+ return '';
817
+ }
818
+ }
819
+
797
820
  /**
798
821
  * 一次「打开配置页面」的完整拨号:取地址、必要时开两小时窗口、把地址交出去。
799
822
  *
@@ -1557,6 +1580,10 @@ window.__ModuleLoader__.load({
1557
1580
  }
1558
1581
 
1559
1582
  const hint = url === null ? t('panelPending') : url === '' ? t('panelMissing') : t('openHint');
1583
+ // 画布页与面板同源同宿主,地址从面板地址换尾巴;拿不到面板地址就按页面自身推。
1584
+ const canvasUrl = typeof url === 'string' && url
1585
+ ? url.replace(/\/panel(?:\/(?:setup|settings))?$/, '/canvas')
1586
+ : sameOriginCanvasUrl();
1560
1587
  return h('div', { className: 'dshm_root' },
1561
1588
  h('h2', { className: 'dshm_heading' }, t('heading')),
1562
1589
  h('div', { className: 'dshm_card' },
@@ -1574,7 +1601,19 @@ window.__ModuleLoader__.load({
1574
1601
  failure
1575
1602
  ? h('p', { className: 'dshm_error' },
1576
1603
  `${failure.kind === 'load' ? t('loadFailed') : t('openFailed')}:${failure.message}`)
1577
- : null));
1604
+ : null),
1605
+ // 画布单独一张薄卡:点「打开画布」跳到浏览器打开插件托管的画布页(库与手机共用),
1606
+ // 不嵌在桌面端里。跟面板同源同宿主,地址从面板地址换尾巴拿。
1607
+ h('div', { className: 'dshm_card' },
1608
+ h('h3', { className: 'dshm_cardTitle' }, t('cardCanvas')),
1609
+ h('p', { className: 'dshm_note' }, t('canvasHint')),
1610
+ h('div', { className: 'dshm_actions' },
1611
+ h('a', {
1612
+ className: 'dshm_btn dshm_btnPrimary',
1613
+ href: canvasUrl || undefined,
1614
+ target: '_blank',
1615
+ rel: 'noreferrer',
1616
+ }, t('openCanvas')))));
1578
1617
  }
1579
1618
 
1580
1619
  return {
@@ -25,6 +25,10 @@ const path = require('node:path');
25
25
 
26
26
  const { dshHome } = require('../shared/paths.js');
27
27
  const { chunksHeaders, pipeEncrypted } = require('../shared/e2ee-chunks.js');
28
+ const { imageSizeOf, sniffMediaType } = require('./local-images.js');
29
+
30
+ /** 量宽高只需要文件头;JPEG 的 SOF 段可能在几百字节之后,64KB 覆盖绝大多数实拍图。 */
31
+ const HEAD_BYTES = 64 * 1024;
28
32
 
29
33
  /** 一个会话最多记多少个附件 id(会话里几千张图也不该无限涨)。 */
30
34
  const MAX_IDS_PER_SESSION = 4096;
@@ -36,29 +40,52 @@ const TTL_MS = 30 * 60 * 1000;
36
40
  /** sessionId -> { at, entries: Map<attachmentId, { mediaType, at }> } */
37
41
  const SESSIONS = new Map();
38
42
 
39
- /** 内容哈希是 64 位小写十六进制;别的形状一律不认(顺带挡住路径穿越)。 */
40
- const OBJECT_ID = /^[0-9a-f]{64}$/;
43
+ /**
44
+ * 宿主附件编号的形状:`sha256:<64 位小写十六进制>`(见宿主 `attachment-local` 的 `ID_PATTERN`)。
45
+ *
46
+ * **前缀不能省**:宿主发的编号带 `sha256:`,磁盘上的文件名却是去掉前缀的那段哈希。以前这里
47
+ * 只认裸哈希,于是每个真实编号都被当成「不是我们发过的」,直读这条路一次都没生效过。
48
+ */
49
+ const OBJECT_ID = /^sha256:([0-9a-f]{64})$/;
41
50
 
42
51
  function nowMs() {
43
52
  return Date.now();
44
53
  }
45
54
 
55
+ /** 从附件编号里取出裸哈希(磁盘文件名那段);形状不对回 null。 */
56
+ function hexOf(attachmentId) {
57
+ if (typeof attachmentId !== 'string') return null;
58
+ const match = OBJECT_ID.exec(attachmentId);
59
+ return match ? match[1] : null;
60
+ }
61
+
46
62
  /**
47
63
  * 附件 id → 宿主附件库里的绝对路径。
48
64
  *
49
- * 形状不对(不是 64 位小写十六进制)直接回 null:这条路径要拼进 `createReadStream`,
50
- * 校验放在这里、而不是靠调用方记得先校验。
65
+ * 宿主 `normalizedImagePath` 的落盘形状是 `objects/<前两位>/<完整 64 位哈希>` —— 子目录取前两位、
66
+ * **文件名仍是完整哈希**(不是去掉前两位的那段)。曾经拼成 `objects/<前两位>/<其余>`,于是每一份
67
+ * 真实附件都找不到、直读与现量元数据全部落空(取图恒 404)。
68
+ *
69
+ * 形状不对(不是 `sha256:<64 位小写十六进制>`)直接回 null:这条路径要拼进
70
+ * `createReadStream`,校验放在这里、而不是靠调用方记得先校验。
51
71
  */
52
72
  function objectPathOf(attachmentId) {
53
- if (typeof attachmentId !== 'string' || !OBJECT_ID.test(attachmentId)) return null;
73
+ const hex = hexOf(attachmentId);
74
+ if (!hex) return null;
54
75
  const root = path.join(dshHome(), 'attachments', 'v1', 'objects');
55
- return path.join(root, attachmentId.slice(0, 2), attachmentId.slice(2));
76
+ return path.join(root, hex.slice(0, 2), hex);
56
77
  }
57
78
 
58
- /** 记账:这个附件我们刚在 `sessionId` 的消息里下发过(手机接下来就可能来取字节)。 */
59
- function remember(sessionId, attachmentId, mediaType) {
79
+ /**
80
+ * 记账:这个附件我们刚在 `sessionId` 的消息里下发过(手机接下来就可能来取字节)。
81
+ *
82
+ * 顺手把**完整引用**(媒体类型 / 字节数 / 宽高 / 名字)也记下来。回退去问宿主时,宿主新版
83
+ * 的取附件接口要的是整份 `ImageAttachmentRef`,只有编号它不收。取字节本身宿主只用到编号,
84
+ * 但请求形状这五个字段都必填。
85
+ */
86
+ function remember(sessionId, attachmentId, ref) {
60
87
  if (typeof sessionId !== 'string' || !sessionId) return;
61
- if (typeof attachmentId !== 'string' || !OBJECT_ID.test(attachmentId)) return;
88
+ if (!hexOf(attachmentId)) return;
62
89
  const at = nowMs();
63
90
  let slot = SESSIONS.get(sessionId);
64
91
  if (!slot) {
@@ -72,11 +99,16 @@ function remember(sessionId, attachmentId, mediaType) {
72
99
  }
73
100
  }
74
101
  slot.at = at;
75
- slot.entries.delete(attachmentId);
76
- slot.entries.set(attachmentId, {
102
+ const entry = {
77
103
  at,
78
- mediaType: typeof mediaType === 'string' && mediaType ? mediaType : 'application/octet-stream',
79
- });
104
+ mediaType: ref && typeof ref.mediaType === 'string' && ref.mediaType ? ref.mediaType : 'application/octet-stream',
105
+ };
106
+ if (ref && Number.isFinite(ref.bytes)) entry.bytes = ref.bytes;
107
+ if (ref && Number.isFinite(ref.width)) entry.width = ref.width;
108
+ if (ref && Number.isFinite(ref.height)) entry.height = ref.height;
109
+ if (ref && typeof ref.name === 'string' && ref.name) entry.name = ref.name;
110
+ slot.entries.delete(attachmentId);
111
+ slot.entries.set(attachmentId, entry);
80
112
  while (slot.entries.size > MAX_IDS_PER_SESSION) {
81
113
  const oldest = slot.entries.keys().next().value;
82
114
  if (oldest === undefined) break;
@@ -84,7 +116,9 @@ function remember(sessionId, attachmentId, mediaType) {
84
116
  }
85
117
  }
86
118
 
87
- /** 这个 id 是不是我们为这条会话下发过的;是就带上当时的媒体类型。 */
119
+ /**
120
+ * 这个 id 是不是我们为这条会话下发过的;是就带上当时的完整引用(少 `attachmentId` 与时间戳)。
121
+ */
88
122
  function lookup(sessionId, attachmentId, now = nowMs()) {
89
123
  if (typeof sessionId !== 'string' || !sessionId) return null;
90
124
  if (typeof attachmentId !== 'string' || !attachmentId) return null;
@@ -100,7 +134,8 @@ function lookup(sessionId, attachmentId, now = nowMs()) {
100
134
  slot.entries.delete(attachmentId);
101
135
  return null;
102
136
  }
103
- return { mediaType: hit.mediaType };
137
+ const { at: _at, ...ref } = hit;
138
+ return ref;
104
139
  }
105
140
 
106
141
  /** 打开一条只读流(调用方自己负责在连接关掉时 destroy)。 */
@@ -139,6 +174,66 @@ async function streamIfRemembered(req, res, sessionId, attachmentId) {
139
174
  return true;
140
175
  }
141
176
 
177
+ /**
178
+ * 从盘上这份附件对象量出它的真实元数据(类型 / 字节数 / 宽高)。
179
+ *
180
+ * 宿主取附件时会读同一份对象文件、用 sharp 探一遍,再跟我们递过去的引用**逐字段比对**:
181
+ * 类型、字节数、宽高任一对不上就回 `Stored attachment metadata does not match its reference.`
182
+ * (400)。所以引用不能瞎填占位值,必须和盘上那份字节对上。
183
+ *
184
+ * 附件存的是**归一化后**的字节(携带 EXIF 的图会被重编码、朝向烘进像素),因此文件头读出来的
185
+ * 就是宿主 `probeImage` 会量到的同一组数;`imageSizeOf` 本就按 EXIF 5–8 对调,口径一致。
186
+ * 读不出(不是位图 / 文件不在)回 null,由调用方决定怎么回。
187
+ */
188
+ function probeObject(attachmentId) {
189
+ const target = objectPathOf(attachmentId);
190
+ if (!target) return null;
191
+ let stat;
192
+ try {
193
+ stat = fs.statSync(target);
194
+ } catch {
195
+ return null;
196
+ }
197
+ if (!stat.isFile() || stat.size <= 0) return null;
198
+ let fd = null;
199
+ let head;
200
+ try {
201
+ fd = fs.openSync(target, 'r');
202
+ const length = Math.min(stat.size, HEAD_BYTES);
203
+ head = Buffer.alloc(length);
204
+ fs.readSync(fd, head, 0, length, 0);
205
+ } catch {
206
+ return null;
207
+ } finally {
208
+ if (fd !== null) {
209
+ try { fs.closeSync(fd); } catch { /* 关不上就算了,元数据已经读到 */ }
210
+ }
211
+ }
212
+ const mediaType = sniffMediaType(head);
213
+ const size = imageSizeOf(head);
214
+ if (!mediaType || !size || !(size.width > 0) || !(size.height > 0)) return null;
215
+ return { mediaType, bytes: stat.size, width: size.width, height: size.height };
216
+ }
217
+
218
+ /**
219
+ * 给宿主取附件接口拼一份完整 `ImageAttachmentRef`。
220
+ *
221
+ * 记过账的用记下的整份引用;没记到的(过期 / 超上限 / 插件重启后没重新登记 / 排队条里透传的图)
222
+ * 就从盘上那份对象现量一遍 —— 宿主校验要的就是盘上这份字节的真实元数据,占位值一定对不上。
223
+ * 对象文件不在或量不出时回 null(调用方回 404),不再拿假引用去碰宿主的那条校验。
224
+ */
225
+ function refFor(sessionId, attachmentId) {
226
+ const known = lookup(sessionId, attachmentId);
227
+ if (known && Number.isFinite(known.bytes) && Number.isFinite(known.width) && Number.isFinite(known.height)) {
228
+ return { attachmentId, ...known };
229
+ }
230
+ const probed = probeObject(attachmentId);
231
+ if (probed) {
232
+ return { attachmentId, ...probed, ...(known && typeof known.name === 'string' ? { name: known.name } : {}) };
233
+ }
234
+ return known ? { attachmentId, ...known } : null;
235
+ }
236
+
142
237
  /** 测试用:清空记账。 */
143
238
  function reset() {
144
239
  SESSIONS.clear();
@@ -151,6 +246,7 @@ module.exports = {
151
246
  objectPathOf,
152
247
  remember,
153
248
  lookup,
249
+ refFor,
154
250
  openObject,
155
251
  streamIfRemembered,
156
252
  reset,
@@ -58,12 +58,16 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
58
58
  if (referencesOnly) {
59
59
  // 顺便记一笔:手机接下来就可能拿着这个 id 来取字节。记下来之后那条路可以直接从
60
60
  // 宿主附件库流给它,不必再等宿主把整张图读成 base64(见 attachment-store.js)。
61
- attachmentStore.remember(sessionId, attachmentId, block.attachment.mediaType || block.mediaType);
61
+ // 记的是**整份引用**:回退问宿主时要的正是这个形状,光有 id 不够。
62
+ attachmentStore.remember(sessionId, attachmentId, {
63
+ ...block.attachment,
64
+ mediaType: block.attachment.mediaType || block.mediaType,
65
+ });
62
66
  return block;
63
67
  }
64
68
  let result;
65
69
  try {
66
- result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachmentId }));
70
+ result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachment: block.attachment }));
67
71
  } catch {
68
72
  return dataUnavailableBlock(block);
69
73
  }
@@ -57,7 +57,13 @@ const ENDPOINTS = {
57
57
  'sessions.create': inv('session', 'create'),
58
58
  'sessions.search': inv('session', 'search'),
59
59
  'sessions.selectModel': inv('session', 'selectModel'),
60
- 'sessions.attachment': inv('session', 'attachment'),
60
+ // 宿主 `SessionAttachmentRequest` 是 `{ sessionId, attachment: ImageAttachmentRef }`,
61
+ // `attachment` 必填且内含 attachmentId / mediaType / bytes / width / height。只传
62
+ // `{ sessionId, attachmentId }` 会在 strict codec 的 parse 那一步被挡回
63
+ // `wire field "request" failed boundary validation`(400),App 把 400 catch 掉后图片就没了。
64
+ 'sessions.attachment': inv('session', 'attachment', (p) => ({
65
+ request: { sessionId: p && p.sessionId, attachment: p && p.attachment },
66
+ })),
61
67
  'sessions.updateQueue': inv('session', 'updateQueue'),
62
68
  // 宿主的 fork 请求体只有 sessionId / atSeq,increaseTitle 在 0.2.0 已删除。
63
69
  'sessions.fork': inv('session', 'fork', (p) => ({ request: pick(p, ['sessionId', 'atSeq']) })),
@@ -467,4 +467,4 @@ function inRanges(index, ranges) {
467
467
  return false;
468
468
  }
469
469
 
470
- module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, clearLocalImages };
470
+ module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages };