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/canvas-app.html +441 -0
- package/lib/client.js +40 -1
- package/lib/mobile/attachment-store.js +111 -15
- package/lib/mobile/attachments.js +6 -2
- package/lib/mobile/host-bridge/endpoints.js +7 -1
- package/lib/mobile/local-images.js +1 -1
- package/lib/mobile/magpie-web.js +425 -0
- package/lib/mobile/session-send.js +13 -13
- package/lib/mobile/stats-ledger.cjs +21 -6
- package/lib/mobile/stats.js +7 -3
- package/lib/panel/index.html +8 -8
- package/lib/routes/canvas-desktop.js +196 -0
- package/lib/routes/control-plane.js +40 -0
- package/lib/routes/mobile-api.js +6 -0
- package/package.json +1 -1
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
|
-
/**
|
|
40
|
-
|
|
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
|
-
*
|
|
50
|
-
*
|
|
65
|
+
* 宿主 `normalizedImagePath` 的落盘形状是 `objects/<前两位>/<完整 64 位哈希>` —— 子目录取前两位、
|
|
66
|
+
* **文件名仍是完整哈希**(不是去掉前两位的那段)。曾经拼成 `objects/<前两位>/<其余>`,于是每一份
|
|
67
|
+
* 真实附件都找不到、直读与现量元数据全部落空(取图恒 404)。
|
|
68
|
+
*
|
|
69
|
+
* 形状不对(不是 `sha256:<64 位小写十六进制>`)直接回 null:这条路径要拼进
|
|
70
|
+
* `createReadStream`,校验放在这里、而不是靠调用方记得先校验。
|
|
51
71
|
*/
|
|
52
72
|
function objectPathOf(attachmentId) {
|
|
53
|
-
|
|
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,
|
|
76
|
+
return path.join(root, hex.slice(0, 2), hex);
|
|
56
77
|
}
|
|
57
78
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 };
|