dsh-mobile-plugin 0.0.0-stage → 0.1.57
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.md +128 -2
- package/config/agent-presets/standard/agent.cordis.yml +170 -0
- package/config/agent-presets/standard/preset.yml +3 -0
- package/cordis.mobile-host.yml +48 -0
- package/cordis.patch.yml +21 -0
- package/dsh-mobile-credentials/lib/credentials.js +135 -0
- package/dsh-mobile-credentials/package.json +13 -0
- package/host.js +128 -0
- package/lib/auto-archive.cjs +98 -0
- package/lib/client.js +690 -0
- package/lib/desktop-half-stats.cjs +101 -0
- package/lib/desktop-view.cjs +109 -0
- package/lib/host-guard.cjs +230 -0
- package/lib/host-patches.cjs +46 -0
- package/lib/http/package.json +3 -0
- package/lib/http/routing.js +84 -0
- package/lib/impl.js +406 -0
- package/lib/mirror/pusher.cjs +706 -0
- package/lib/mirror-snapshot.cjs +39 -0
- package/lib/mobile/agent-presets.js +182 -0
- package/lib/mobile/attachments.js +116 -0
- package/lib/mobile/auth.js +78 -0
- package/lib/mobile/completion.js +114 -0
- package/lib/mobile/credentials.js +150 -0
- package/lib/mobile/directory-browse.js +295 -0
- package/lib/mobile/envelope.js +51 -0
- package/lib/mobile/events.js +293 -0
- package/lib/mobile/history-derive.js +608 -0
- package/lib/mobile/host-bridge/endpoints.js +189 -0
- package/lib/mobile/host-bridge/event-synthesis.js +232 -0
- package/lib/mobile/host-bridge/shims.js +218 -0
- package/lib/mobile/host-bridge/transport.js +168 -0
- package/lib/mobile/host-bridge.js +71 -0
- package/lib/mobile/host-settings.js +282 -0
- package/lib/mobile/idempotency.js +78 -0
- package/lib/mobile/jobs.js +97 -0
- package/lib/mobile/local-images.js +153 -0
- package/lib/mobile/package.json +3 -0
- package/lib/mobile/plugins.js +149 -0
- package/lib/mobile/questions.js +71 -0
- package/lib/mobile/references.js +209 -0
- package/lib/mobile/rpc.js +76 -0
- package/lib/mobile/session-cache.js +263 -0
- package/lib/mobile/session-goal.js +44 -0
- package/lib/mobile/session-log.js +94 -0
- package/lib/mobile/session-mirror.js +177 -0
- package/lib/mobile/session-models.js +153 -0
- package/lib/mobile/session-presets.js +52 -0
- package/lib/mobile/session-search.js +65 -0
- package/lib/mobile/session-send.js +278 -0
- package/lib/mobile/session-snapshot.js +142 -0
- package/lib/mobile/sessions.js +462 -0
- package/lib/mobile/skills.js +80 -0
- package/lib/mobile/stream.js +268 -0
- package/lib/mobile/subagents.js +265 -0
- package/lib/mobile/workspaces.js +214 -0
- package/lib/panel/index.html +33 -0
- package/lib/read-receipt.cjs +124 -0
- package/lib/reconnect-scheduler.cjs +37 -0
- package/lib/relay/core.js +77 -0
- package/lib/relay/lan-network.cjs +85 -0
- package/lib/relay/lan-proxy.js +593 -0
- package/lib/relay/package.json +3 -0
- package/lib/relay/pairing-policy.js +45 -0
- package/lib/relay/port-allocator.cjs +60 -0
- package/lib/relay/preview.js +374 -0
- package/lib/relay/protocol.js +177 -0
- package/lib/relay/status.js +145 -0
- package/lib/relay/tunnel.js +721 -0
- package/lib/routes/control-plane.js +311 -0
- package/lib/routes/mobile-api.js +208 -0
- package/lib/routes/package.json +3 -0
- package/lib/session-cleanup.cjs +153 -0
- package/lib/shared/credentials-entry.js +48 -0
- package/lib/shared/dshtoken.js +62 -0
- package/lib/shared/fresh-request.js +35 -0
- package/lib/shared/http-util.js +71 -0
- package/lib/shared/list-revision.js +51 -0
- package/lib/shared/list-signal.js +87 -0
- package/lib/shared/package.json +3 -0
- package/lib/shared/paths.js +27 -0
- package/lib/shared/session-signal.js +110 -0
- package/lib/shared/workspace-guard.js +127 -0
- package/lib/sidecar-facade-worker.cjs +353 -0
- package/lib/sidecar-facade.cjs +141 -0
- package/lib/sidecar-lifecycle.cjs +246 -0
- package/package.json +74 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端「后台任务」:把宿主会话的 job 视图收敛成手机能画的形状。
|
|
5
|
+
*
|
|
6
|
+
* 桌面端会话头部有一个任务触发器(@deepseek-ai/dsh-client-ui-jobs 的
|
|
7
|
+
* JobListAction):只在会话真的有 job 时才出现,显示「进行中 N / 共 M」,
|
|
8
|
+
* 展开是一行一个任务(状态点 + 标签 + 已用时长)。数据源不是某个 RPC,而是
|
|
9
|
+
* 宿主事件流上的整帧 `session/jobs`:{ type, sessionId, jobs: taskView[] }。
|
|
10
|
+
*
|
|
11
|
+
* 手机端此前**一帧都收不到** —— /session/stream 只转发了
|
|
12
|
+
* session/event / session/queue / session/projection / question / approval,
|
|
13
|
+
* `session/jobs` 落进了 else 分支被静默丢掉,于是后台任务在手机上是隐形的。
|
|
14
|
+
*
|
|
15
|
+
* 宿主已经把帧按 taskViewSchema 校验过,这里只做形状收敛与限长限条数,
|
|
16
|
+
* 避免异常宿主把手机端列表撑爆。空的 jobs 数组必须原样转发:它是「任务都结束了」
|
|
17
|
+
* 的唯一信号(宿主在 jobs.onJobsChanged 里就是靠空数组通知清空的)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const JOB_MAX_ITEMS = 50; // 单帧任务条数上限(桌面端也只是个小列表)
|
|
21
|
+
const JOB_ID_MAX = 200; // 任务 id 长度上限
|
|
22
|
+
const JOB_KIND_MAX = 60; // 任务种类(bash / workflow…)
|
|
23
|
+
const JOB_LABEL_MAX = 200; // 任务标题
|
|
24
|
+
const JOB_DETAIL_MAX = 300; // 任务副标题
|
|
25
|
+
|
|
26
|
+
/** 宿主 wire 的封闭状态集(dsh-host-apiproxy taskViewSchema)。 */
|
|
27
|
+
const JOB_STATUSES = new Set(['running', 'stopping', 'completed', 'killed', 'failed']);
|
|
28
|
+
|
|
29
|
+
/** 必填文本:非字符串或全空白 → '';超长截断(不补省略号,id/kind 不是给人看的)。 */
|
|
30
|
+
function requiredText(value, max) {
|
|
31
|
+
if (typeof value !== 'string') return '';
|
|
32
|
+
const text = value.trim();
|
|
33
|
+
if (!text) return '';
|
|
34
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 可选文本:非法/空白 → undefined;超长截断补省略号。 */
|
|
38
|
+
function optionalText(value, max) {
|
|
39
|
+
if (typeof value !== 'string') return undefined;
|
|
40
|
+
const text = value.trim();
|
|
41
|
+
if (!text) return undefined;
|
|
42
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 非负安全整数,否则 undefined。 */
|
|
46
|
+
function optionalCount(value) {
|
|
47
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 把一条 taskView 压成手机需要的形状。
|
|
52
|
+
*
|
|
53
|
+
* 必填字段(id / kind / label / status / startedAt)任一不合法就丢掉整条:
|
|
54
|
+
* 缺了状态或开始时间的任务行画出来只会误导用户。
|
|
55
|
+
* 可选字段(detail / finishedAt)不合法只丢字段、不丢任务:它不影响这一行
|
|
56
|
+
* 「是什么、还在不在跑」,而状态本身已经说明任务是否结束。
|
|
57
|
+
*/
|
|
58
|
+
function normalizeJob(item) {
|
|
59
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
60
|
+
const id = requiredText(item.id, JOB_ID_MAX);
|
|
61
|
+
if (!id) return null;
|
|
62
|
+
const kind = requiredText(item.kind, JOB_KIND_MAX);
|
|
63
|
+
if (!kind) return null;
|
|
64
|
+
const label = requiredText(item.label, JOB_LABEL_MAX);
|
|
65
|
+
if (!label) return null;
|
|
66
|
+
if (typeof item.status !== 'string' || !JOB_STATUSES.has(item.status)) return null;
|
|
67
|
+
const startedAt = optionalCount(item.startedAt);
|
|
68
|
+
if (startedAt === undefined) return null;
|
|
69
|
+
const detail = optionalText(item.detail, JOB_DETAIL_MAX);
|
|
70
|
+
const finishedAt = optionalCount(item.finishedAt);
|
|
71
|
+
return {
|
|
72
|
+
id,
|
|
73
|
+
kind,
|
|
74
|
+
label,
|
|
75
|
+
status: item.status,
|
|
76
|
+
startedAt,
|
|
77
|
+
...(detail === undefined ? {} : { detail }),
|
|
78
|
+
...(finishedAt === undefined ? {} : { finishedAt }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 一帧里的任务数组:非数组 → [];坏条目丢掉;按 id 去重(保留先出现的);限条数。 */
|
|
83
|
+
function normalizeJobs(value) {
|
|
84
|
+
if (!Array.isArray(value)) return [];
|
|
85
|
+
const out = [];
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
for (const item of value) {
|
|
88
|
+
const job = normalizeJob(item);
|
|
89
|
+
if (!job || seen.has(job.id)) continue;
|
|
90
|
+
seen.add(job.id);
|
|
91
|
+
out.push(job);
|
|
92
|
+
if (out.length >= JOB_MAX_ITEMS) break;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { normalizeJob, normalizeJobs };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 正文图片内联:把消息文本里指向**本机文件**的图片语法(``)换成 data URI,
|
|
5
|
+
* 让手机端能直接把它画出来。
|
|
6
|
+
*
|
|
7
|
+
* 为什么只能在这里换:手机拿到的只是文本,`/Users/…/a.png` 既不是手机上的文件,移动 API 也
|
|
8
|
+
* 没有「按路径读文件」的能力面(见 host-bridge-endpoints 守卫的 forbidden 表),所以地址原样
|
|
9
|
+
* 过去只会显示成一串字面 markdown。会话里用户自己发的图片本来就是内联形状(宿主把附件解成
|
|
10
|
+
* base64,见 attachments.js 与 App 侧 chat.ts 的 images[]),这里只是把「正文里写的本机图片」
|
|
11
|
+
* 拉齐到同一种形状,App 侧因此不需要新增任何通道或依赖。
|
|
12
|
+
*
|
|
13
|
+
* 保守取舍(宁可保留原样的字面 markdown,也不给个半截东西):
|
|
14
|
+
* - 只认能解析成**绝对路径**的地址:`/x/y.png`、`file:///x/y.png`、`~/y.png`;
|
|
15
|
+
* http(s)、`data:`、`www.`、相对路径一律不碰。
|
|
16
|
+
* - 只认 png/jpg/jpeg/webp/gif(与 app/src/services/viewmodels/image-limits.ts 的 mediaTypes
|
|
17
|
+
* 同白名单),并且**核对魔术字节**:字节不是图片就放弃,绝不按扩展名撒谎。
|
|
18
|
+
* - 三级体积预算(单图 / 单段文本 / 单次响应,见下方常量):超预算的图保持字面 markdown,
|
|
19
|
+
* 不截断、不缩水成半张图。
|
|
20
|
+
* - 读不到、不是普通文件、读取失败:原样保留 —— 一条图片取不到不能让整段历史或整帧 SSE 挂掉。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs/promises');
|
|
24
|
+
const os = require('node:os');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* ``。dest 两种写法都认:裸路径(不含空白与右括号)与 `<>` 包裹(里面可以有空格,
|
|
29
|
+
* 因为 `` 是 CommonMark 唯一能给带空格路径的合法写法,而 App 侧解析器认它
|
|
30
|
+
* ——两边必须一致,否则「App 解析得出地址、插件却没内联」会变成一串死链接)。
|
|
31
|
+
* 括号内的首尾空白照 mdast 的做法忽略。
|
|
32
|
+
*/
|
|
33
|
+
const IMAGE_RE = /!\[([^\]]*)\]\(\s*(<[^>\n]*>|[^)\s]+)\s*\)/g;
|
|
34
|
+
|
|
35
|
+
/** 图片类型白名单:扩展名 → mediaType。 */
|
|
36
|
+
const MEDIA_TYPES = {
|
|
37
|
+
'.png': 'image/png',
|
|
38
|
+
'.jpg': 'image/jpeg',
|
|
39
|
+
'.jpeg': 'image/jpeg',
|
|
40
|
+
'.webp': 'image/webp',
|
|
41
|
+
'.gif': 'image/gif',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** 单张原图上限(base64 后约 1.33 倍,见下面的字符预算)。 */
|
|
45
|
+
const MAX_IMAGE_BYTES = 2.5 * 1024 * 1024;
|
|
46
|
+
/** 一段文本里内联进去的 data URI 总字符上限。 */
|
|
47
|
+
const MAX_TEXT_CHARS = 3 * 1024 * 1024;
|
|
48
|
+
/** 一次响应(一页历史 / 一帧 SSE / 一次镜像快照)共用的字符上限。 */
|
|
49
|
+
const MAX_RESPONSE_CHARS = 5 * 1024 * 1024;
|
|
50
|
+
/** data URI 复用条数:同一张图常在同一轮里出现多次(历史 + 流式 + 镜像)。 */
|
|
51
|
+
const MAX_CACHE_ENTRIES = 8;
|
|
52
|
+
|
|
53
|
+
/** path+mtime+size → data URI。会话里同一张图会被反复解析,但文件内容只读一次。 */
|
|
54
|
+
const cache = new Map();
|
|
55
|
+
|
|
56
|
+
function mediaTypeFor(filePath) {
|
|
57
|
+
return MEDIA_TYPES[path.extname(filePath).toLowerCase()] || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 魔术字节判定:只认我们真的能画的那四种位图,不信扩展名。 */
|
|
61
|
+
function sniffMediaType(buf) {
|
|
62
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'image/png';
|
|
63
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
|
|
64
|
+
if (buf.length >= 6 && buf.toString('latin1', 0, 6).startsWith('GIF8')) return 'image/gif';
|
|
65
|
+
if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `` 里的地址 → 本机绝对路径;不是本机图片(远程、data、相对路径、非图片后缀)返回 null。
|
|
71
|
+
* 尖括号包裹的目的地(CommonMark 允许 ``)在这里一并剥掉。
|
|
72
|
+
*/
|
|
73
|
+
function localImagePathOf(url) {
|
|
74
|
+
if (typeof url !== 'string') return null;
|
|
75
|
+
let value = url.trim();
|
|
76
|
+
if (!value) return null;
|
|
77
|
+
if (value.startsWith('<') && value.endsWith('>')) value = value.slice(1, -1).trim();
|
|
78
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
|
|
79
|
+
if (value.slice(0, 7).toLowerCase() !== 'file://') return null;
|
|
80
|
+
try {
|
|
81
|
+
value = decodeURIComponent(value.slice(7));
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (value.startsWith('localhost/')) value = value.slice('localhost'.length);
|
|
86
|
+
} else if (value === '~' || value.startsWith('~/')) {
|
|
87
|
+
value = path.join(os.homedir(), value.slice(1));
|
|
88
|
+
}
|
|
89
|
+
if (!path.isAbsolute(value)) return null;
|
|
90
|
+
if (!mediaTypeFor(value)) return null;
|
|
91
|
+
return path.normalize(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 一次响应共用的内联预算;不给就按单次调用算一份。 */
|
|
95
|
+
function createTextImageBudget(limit = MAX_RESPONSE_CHARS) {
|
|
96
|
+
return { remaining: limit };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 读文件 → data URI(带缓存);不满足条件一律 null。 */
|
|
100
|
+
async function dataUriFor(filePath) {
|
|
101
|
+
let stat;
|
|
102
|
+
try {
|
|
103
|
+
stat = await fs.stat(filePath);
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_IMAGE_BYTES) return null;
|
|
108
|
+
const key = `${filePath}:${stat.size}:${stat.mtimeMs}`;
|
|
109
|
+
const hit = cache.get(key);
|
|
110
|
+
if (hit) {
|
|
111
|
+
cache.delete(key);
|
|
112
|
+
cache.set(key, hit);
|
|
113
|
+
return hit;
|
|
114
|
+
}
|
|
115
|
+
let buf;
|
|
116
|
+
try {
|
|
117
|
+
buf = await fs.readFile(filePath);
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const mediaType = sniffMediaType(buf);
|
|
122
|
+
if (!mediaType) return null;
|
|
123
|
+
const uri = `data:${mediaType};base64,${buf.toString('base64')}`;
|
|
124
|
+
cache.set(key, uri);
|
|
125
|
+
while (cache.size > MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value);
|
|
126
|
+
return uri;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 把一段文本里的本机图片语法换成 data URI。返回原字符串本身(`===`)表示一个字没动,
|
|
131
|
+
* 调用方据此免掉不必要的对象拷贝。
|
|
132
|
+
*/
|
|
133
|
+
async function inlineTextImages(text, budget) {
|
|
134
|
+
if (typeof text !== 'string' || text.indexOf('![') < 0) return text;
|
|
135
|
+
const matches = [...text.matchAll(IMAGE_RE)];
|
|
136
|
+
if (!matches.length) return text;
|
|
137
|
+
const pool = budget || createTextImageBudget();
|
|
138
|
+
let textBudget = MAX_TEXT_CHARS;
|
|
139
|
+
let out = text;
|
|
140
|
+
for (const m of matches) {
|
|
141
|
+
if (textBudget <= 0 || pool.remaining <= 0) break;
|
|
142
|
+
const filePath = localImagePathOf(m[2]);
|
|
143
|
+
if (!filePath) continue;
|
|
144
|
+
const uri = await dataUriFor(filePath);
|
|
145
|
+
if (!uri || uri.length > textBudget || uri.length > pool.remaining) continue;
|
|
146
|
+
textBudget -= uri.length;
|
|
147
|
+
pool.remaining -= uri.length;
|
|
148
|
+
out = out.replace(m[0], `![${m[1]}](${uri})`);
|
|
149
|
+
}
|
|
150
|
+
return out === text ? text : out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端「插件清单」API:把桌面端 设置 → 插件 的只读快照搬到手机上。
|
|
5
|
+
*
|
|
6
|
+
* 桌面端由 dsh-client-ui-settings-plugin-inventory 渲染:一个可搜索的紧凑卡片列表,
|
|
7
|
+
* 每张卡片用模块短名作标题、用小标签表示有效启停、用彩色圆点表示根 fiber 状态,
|
|
8
|
+
* 展开后展示 Loader 条目 id 与 Cordis 状态;选中该标签页时才懒调用一次
|
|
9
|
+
* `ctx.remote.pluginInventory.list()`,不订阅 Loader 变化(重新打开设置才取新快照)。
|
|
10
|
+
*
|
|
11
|
+
* 关键:这个宿主服务 **不在 apiProxy 上** —— 它是 Typert Remote 服务
|
|
12
|
+
* (dsh-host-plugin-inventory 的 pluginInventory)。手机侧本来就能到它:
|
|
13
|
+
* lib/mobile/sessions.js 早已用 `ctx.get('typertGateway')` 派发 commands.execute,
|
|
14
|
+
* lib/mobile/references.js 通了 fileReferences / sessionReferenceResolver,
|
|
15
|
+
* 这里照搬同一条路。
|
|
16
|
+
*
|
|
17
|
+
* 接口(只转发宿主的部署快照,不做任何加工):
|
|
18
|
+
* - GET /api/mobile/v1/plugins → pluginInventory.list
|
|
19
|
+
*
|
|
20
|
+
* 参数:这个方法 **没有声明任何参数**,args 必须是空对象——assertExactArguments
|
|
21
|
+
* 按声明逐位比对,多给一个字段就会被拒。
|
|
22
|
+
*
|
|
23
|
+
* 返回值形状:`gateway.invoke()` 回的是 decode 后的业务结果本体,不是
|
|
24
|
+
* {ok:true,value} 外壳(那层壳是浏览器传输加的,见 lib/mobile/references.js 里的长注释)。
|
|
25
|
+
* pluginInventory.list 声明的是 { entries: […] } 对象,所以 invoke 回来就是这个对象。
|
|
26
|
+
*
|
|
27
|
+
* 语义按宿主原文透传:
|
|
28
|
+
* - moduleName 是 npm 包名(如 @deepseek-ai/dsh-host-directory-picker-native),
|
|
29
|
+
* 展示用的短名截断属于客户端的事。
|
|
30
|
+
* - enabled 是「有效启停状态」,和 fiberPhase 不是一回事:停用的条目 fiberPhase 是 null。
|
|
31
|
+
* - fiberPhase 只有 failed/pending/active/loading/unloading 五种,宿主也可能给 null。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const { sendJson } = require('../shared/http-util');
|
|
35
|
+
|
|
36
|
+
const PLUGIN_ID_MAX_CHARS = 200; // entryId 长度上限
|
|
37
|
+
const PLUGIN_NAME_MAX_CHARS = 300; // moduleName 长度上限
|
|
38
|
+
const PLUGIN_MAX_ITEMS = 500; // 手机侧兜底上限(真实宿主是几十条)
|
|
39
|
+
const PLUGIN_PHASES = new Set(['failed', 'pending', 'active', 'loading', 'unloading']);
|
|
40
|
+
|
|
41
|
+
/** 宿主网关;宿主没组合 dsh-api-gateway 时返回 null,调用方回 503。 */
|
|
42
|
+
function gatewayOf(ctx) {
|
|
43
|
+
return (ctx.get && ctx.get('typertGateway')) || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 网关缺失/命名空间缺失对客户端是同一件事:宿主这个能力没装。 */
|
|
47
|
+
const UNAVAILABLE = Object.freeze({
|
|
48
|
+
code: 'plugin-inventory-unavailable',
|
|
49
|
+
message: '宿主没有组合插件清单服务(dsh-host-plugin-inventory / dsh-api-gateway)',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** 网关自己抛的错(不是业务失败)的文字化。 */
|
|
53
|
+
function errorText(error) {
|
|
54
|
+
if (!error) return '未知错误';
|
|
55
|
+
if (typeof error.message === 'string' && error.message) return error.message;
|
|
56
|
+
return String(error);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 经 typertGateway 取一次插件清单快照。
|
|
61
|
+
*
|
|
62
|
+
* @returns {Promise<{value?: unknown, error?: {code: string, message: string}}>}
|
|
63
|
+
*/
|
|
64
|
+
async function callInventory(ctx) {
|
|
65
|
+
const gateway = gatewayOf(ctx);
|
|
66
|
+
if (!gateway) return { error: { ...UNAVAILABLE } };
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
// 没有声明参数 → args 必须是空对象;这个方法也没声明 cancellation,不能多塞 signal。
|
|
70
|
+
result = await gateway.invoke({ namespace: 'pluginInventory', method: 'list', args: {} });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// 0.2.0 的网关错误码带 `gateway/` 前缀(dsh-api-gateway/src/types.ts:117-136);
|
|
73
|
+
// 0.1.x 时期是不带前缀的。两种都认,否则「宿主没装这个服务」的降级分支形同虚设。
|
|
74
|
+
const raw = error && typeof error.code === 'string' ? error.code.replace(/^gateway\//, '') : ''
|
|
75
|
+
const code = raw === 'invocation-unavailable' || raw === 'service-unavailable' || raw === 'method-unavailable'
|
|
76
|
+
? UNAVAILABLE.code
|
|
77
|
+
: 'plugin-inventory-error';
|
|
78
|
+
return { error: { code, message: errorText(error) } };
|
|
79
|
+
}
|
|
80
|
+
// decode 本体就是 { entries: […] };另外两种形状只是容错,防宿主以后改成联合。
|
|
81
|
+
if (result && typeof result === 'object') {
|
|
82
|
+
if (result.ok === true) return { value: result.value };
|
|
83
|
+
if (result.ok === false) {
|
|
84
|
+
const raw = result.error && typeof result.error === 'object' ? result.error : {};
|
|
85
|
+
const code = typeof raw.code === 'string' && raw.code ? raw.code : 'plugin-inventory-error';
|
|
86
|
+
return { error: { code, message: errorText(raw) } };
|
|
87
|
+
}
|
|
88
|
+
return { value: result };
|
|
89
|
+
}
|
|
90
|
+
return { error: { code: 'plugin-inventory-error', message: '宿主插件清单返回了意外的结果' } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 非空字符串、长度受限,否则 null。 */
|
|
94
|
+
function normalizeId(value, max) {
|
|
95
|
+
if (typeof value !== 'string') return null;
|
|
96
|
+
const text = value.trim();
|
|
97
|
+
if (!text || text.length > max) return null;
|
|
98
|
+
return text;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 归一化一条清单。entryId 与 moduleName 缺一不可(它们是这张卡的身份与标题);
|
|
103
|
+
* fiberPhase 不在枚举里就当 null(桌面端也是「无状态点」的处理)。
|
|
104
|
+
*/
|
|
105
|
+
function normalizePluginEntry(value) {
|
|
106
|
+
if (!value || typeof value !== 'object') return null;
|
|
107
|
+
const entryId = normalizeId(value.entryId, PLUGIN_ID_MAX_CHARS);
|
|
108
|
+
const moduleName = normalizeId(value.moduleName, PLUGIN_NAME_MAX_CHARS);
|
|
109
|
+
if (!entryId || !moduleName) return null;
|
|
110
|
+
const fiberPhase = typeof value.fiberPhase === 'string' && PLUGIN_PHASES.has(value.fiberPhase)
|
|
111
|
+
? value.fiberPhase
|
|
112
|
+
: null;
|
|
113
|
+
return { entryId, moduleName, enabled: value.enabled === true, fiberPhase };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 能力缺失 503(客户端据此显示「宿主没装」),其余业务/网关失败 400。 */
|
|
117
|
+
function statusOf(error) {
|
|
118
|
+
return error && error.code === UNAVAILABLE.code ? 503 : 400;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* GET /api/mobile/v1/plugins
|
|
123
|
+
* 成功:{ ok:true, items:[{ entryId, moduleName, enabled, fiberPhase }] }
|
|
124
|
+
*/
|
|
125
|
+
async function handlePlugins(ctx, req, res) {
|
|
126
|
+
const result = await callInventory(ctx);
|
|
127
|
+
if (result.error) return sendJson(res, statusOf(result.error), { ok: false, error: result.error });
|
|
128
|
+
const raw = result.value && Array.isArray(result.value.entries) ? result.value.entries : [];
|
|
129
|
+
const items = [];
|
|
130
|
+
for (const row of raw) {
|
|
131
|
+
const entry = normalizePluginEntry(row);
|
|
132
|
+
if (!entry) continue;
|
|
133
|
+
items.push(entry);
|
|
134
|
+
if (items.length >= PLUGIN_MAX_ITEMS) break;
|
|
135
|
+
}
|
|
136
|
+
sendJson(res, 200, { ok: true, items });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
handlePlugins,
|
|
141
|
+
normalizePluginEntry,
|
|
142
|
+
normalizeId,
|
|
143
|
+
statusOf,
|
|
144
|
+
callInventory,
|
|
145
|
+
PLUGIN_ID_MAX_CHARS,
|
|
146
|
+
PLUGIN_NAME_MAX_CHARS,
|
|
147
|
+
PLUGIN_MAX_ITEMS,
|
|
148
|
+
PLUGIN_UNAVAILABLE: UNAVAILABLE,
|
|
149
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* dsh-mobile 用户问题回答桥接。
|
|
5
|
+
*
|
|
6
|
+
* ask_user_question 通过 apiProxy events mux 以 question/requested 发出,
|
|
7
|
+
* 原生 App 经本端点回传对应 rpcId 的严格回答载荷。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
11
|
+
|
|
12
|
+
function validAnswer(answer) {
|
|
13
|
+
if (!answer || typeof answer.id !== 'string' || !Array.isArray(answer.selected)) return false;
|
|
14
|
+
if (!answer.selected.every((label) => typeof label === 'string')) return false;
|
|
15
|
+
return answer.custom === undefined || typeof answer.custom === 'string';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function handleQuestionRespond(ctx, req, res) {
|
|
19
|
+
const body = await readJsonBody(req);
|
|
20
|
+
if (!body || typeof body.rpcId !== 'string' || !body.rpcId || typeof body.sessionId !== 'string' || !body.sessionId) {
|
|
21
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { rpcId, sessionId, answer }' } });
|
|
22
|
+
}
|
|
23
|
+
const answers = body.answer && body.answer.answers;
|
|
24
|
+
if (!Array.isArray(answers) || !answers.every(validAnswer)) {
|
|
25
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'answer.answers 格式无效' } });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const receipt = await ctx.apiProxy.respond({
|
|
29
|
+
type: 'client-response',
|
|
30
|
+
rpcId: body.rpcId,
|
|
31
|
+
result: {
|
|
32
|
+
ok: true,
|
|
33
|
+
value: {
|
|
34
|
+
sessionId: body.sessionId,
|
|
35
|
+
answer: {
|
|
36
|
+
answers: answers.map((answer) => ({
|
|
37
|
+
id: answer.id,
|
|
38
|
+
selected: answer.selected,
|
|
39
|
+
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
|
40
|
+
})),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (!receipt || receipt.accepted !== true) {
|
|
46
|
+
return sendJson(res, 409, { ok: false, error: { code: 'not-pending', message: '该问题已完成或不可回答' } });
|
|
47
|
+
}
|
|
48
|
+
sendJson(res, 200, { ok: true, accepted: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function handleApprovalRespond(ctx, req, res) {
|
|
52
|
+
const body = await readJsonBody(req);
|
|
53
|
+
const outcomes = new Set(['allowed-once', 'rejected']);
|
|
54
|
+
if (!body || typeof body.rpcId !== 'string' || !body.rpcId
|
|
55
|
+
|| typeof body.sessionId !== 'string' || !body.sessionId
|
|
56
|
+
|| typeof body.approvalId !== 'string' || !body.approvalId
|
|
57
|
+
|| !outcomes.has(body.outcome)) {
|
|
58
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { rpcId, sessionId, approvalId, outcome }' } });
|
|
59
|
+
}
|
|
60
|
+
const receipt = await ctx.apiProxy.respond({
|
|
61
|
+
type: 'client-response',
|
|
62
|
+
rpcId: body.rpcId,
|
|
63
|
+
result: { ok: true, value: { sessionId: body.sessionId, approvalId: body.approvalId, outcome: body.outcome } },
|
|
64
|
+
});
|
|
65
|
+
if (!receipt || receipt.accepted !== true) {
|
|
66
|
+
return sendJson(res, 409, { ok: false, error: { code: 'not-pending', message: '该审批已完成或不可回答' } });
|
|
67
|
+
}
|
|
68
|
+
sendJson(res, 200, { ok: true, accepted: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { handleQuestionRespond, handleApprovalRespond };
|