@dshfly/remote-connector 0.2.1

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/index.js ADDED
@@ -0,0 +1,173 @@
1
+ // packages/remote-connector/index.js
2
+ // @dshfly/remote-connector —— DeepSeek Harness (DSH) cordis 插件入口(host 侧)。
3
+ //
4
+ // 把原独立 connector 进程(services/connector)搬进 dsh web 进程:
5
+ // 出站连中继(纯出站 WSS)+ E2EE 终点 + loopback 反代 dsh web(/api + events.mux/.host)
6
+ // + /dshfly 控制路由(loopback-only)
7
+ // + ctx.mobileBridge 服务承载(M4.x-c 整合决策 1b:bridge 是纯库,本插件是服务所有者——
8
+ // 装本插件一个命令即带出移动端插件能力,无独立安装/时序问题)。
9
+ //
10
+ // cordis 插件契约:name / inject / Config / apply。
11
+ // 参考实现:@orbisapp/remote-dsh(orbis),方案见 docs/connector-plugin.md。
12
+
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { createRequire } from 'node:module';
16
+ import { ConnectorCore } from './core/connector-core.js';
17
+ import { createDshflyHttpRoute, installRecommendedPlugin, uninstallPlugin, resolveDshVersion, resolveDshNodeModules } from './http-api.js';
18
+ import { resolveConfig, validateConfig, Config } from './config.js';
19
+ import { MobileBridgeCore, createMobileBridgeService, resolveProfileDir } from './mobile-bridge/index.js';
20
+ import { createDshAdapter } from './core/dsh-web.js';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ /** 本插件版本(设置页显示 + 升级检查;发布时 pnpm 替换为实际版本号)。 */
24
+ const selfVersion = require('./package.json').version;
25
+
26
+ export const name = 'dshfly-remote-connector';
27
+ // 最小依赖:只有 /dshfly 路由需要 webServer;隧道是纯出站,不需要 DSH 业务服务。
28
+ export const inject = ['webServer'];
29
+ export { Config };
30
+
31
+ /** 默认密钥路径:<dshHome>/dshfly/keys.json(决策:从新路径全新开始,不迁移旧 ~/.dshfly)。 */
32
+ async function defaultKeysPath() {
33
+ try {
34
+ const { resolveDshHome } = await import('@deepseek-ai/dsh-home-paths');
35
+ return path.join(resolveDshHome(), 'dshfly', 'keys.json');
36
+ } catch {
37
+ // 非 DSH 宿主环境(单测/开发):用独立临时目录,避免污染真实密钥
38
+ return path.join(os.tmpdir(), 'dshfly-plugin', 'keys.json');
39
+ }
40
+ }
41
+
42
+ export async function apply(ctx, config = {}) {
43
+ const cfg = resolveConfig(config);
44
+
45
+ // 硬约束(决策 8,已定案):配对控制路由含一次性密钥,必须 loopback-only。
46
+ if (!ctx.webServer || ctx.webServer.host !== '127.0.0.1') {
47
+ throw new Error(
48
+ 'dshfly-remote-connector requires dsh web to bind 127.0.0.1 (loopback-only pairing controls)',
49
+ );
50
+ }
51
+
52
+ // 业务配置校验:默认值即可用(relayUrl 内建;D1 无账号层),
53
+ // 仅当显式覆盖成非法值时 warn + 跳过启动(不抛错——DSH 是 fail-loud 启动,
54
+ // apply 抛错会拖垮整个 dsh web;跳过则插件空转,配置后重启生效)。
55
+ const errors = validateConfig(cfg);
56
+ if (errors.length) {
57
+ console.warn(
58
+ `[dshfly-remote-connector] 配置无效,跳过启动: ${errors.join('; ')}` +
59
+ '(默认 relayUrl=https://relay.dshfly.com,一般无需配置)',
60
+ );
61
+ return;
62
+ }
63
+
64
+ // M4.x-c(整合决策 1b):本插件承载 ctx.mobileBridge 服务。
65
+ // 先 provide(插件只要在本插件之后 apply 即可注册),枚举后台化不阻塞启动。
66
+ // 文件树(docs/file-tree-plan.md):注入 loopback 根白名单解析器(工作区 ∪ 主机 cwd),
67
+ // 每次 files.* 调用实时取,工作区增删即时生效;loopback 失败 fail-closed(空白名单)。
68
+ const dshAdapter = createDshAdapter({
69
+ // 惰性函数:typertGateway 依赖 connection,激活可能晚于只依赖 webServer 的 connector;
70
+ // 请求时再取,确保拿到最新服务实例。
71
+ gateway: () => ctx?.get?.('typertGateway') ?? null,
72
+ ctx,
73
+ // host.describe(DSH 版本/主机工作目录/host.commectorVersion):合成时填 DSH 包版本
74
+ // (无 host 命名空间可查);connectorVersion = 本连接器插件(@dshfly/remote-connector)自身
75
+ // 版本(运行中已安装包),供手机端配对设备详情页展示。
76
+ dshVersion: resolveDshVersion(cfg.dshBin || ''),
77
+ connectorVersion: selfVersion,
78
+ });
79
+ // 2026-09:附件树根白名单经进程内 DSH 适配层取工作区(loopback HTTP 旧路径已 401/端点删除)。
80
+ const resolveRoots = async () => {
81
+ try {
82
+ const r = await dshAdapter.workspaceList();
83
+ return (r?.ok ? r.value?.items || [] : []).map((i) => i?.path).filter((p) => typeof p === 'string' && p);
84
+ } catch {
85
+ return [];
86
+ }
87
+ };
88
+ const bridgeCore = new MobileBridgeCore({
89
+ profileDir: resolveProfileDir(cfg),
90
+ resolveRoots,
91
+ // 2026-09:dsh.profile.bundles 里混有 DSH 内置运行时 bundle(@deepseek-ai/dsh-base 等),
92
+ // 它们在 DSH 应用 node_modules、不在 profile node_modules → 传入 DSH 应用 node_modules 作为
93
+ // 额外解析根,避免把 DSH 内置包误报为"bundle 目录解析失败"。
94
+ bundleResolveRoots: [resolveDshNodeModules()].filter(Boolean),
95
+ });
96
+ // App 插件页空状态"一键安装":注入推荐插件安装器(与设置页 /dshfly/plugins/install 同一实现)
97
+ bridgeCore.setPluginInstaller((id) =>
98
+ installRecommendedPlugin({
99
+ id,
100
+ recommendedPlugins: cfg.recommendedPlugins || [],
101
+ bridgeCore,
102
+ loader: ctx.get('loader') ?? null,
103
+ dshBin: cfg.dshBin || '',
104
+ profileName: cfg.profileName,
105
+ }),
106
+ );
107
+ // App 插件详情页"卸载":注入卸载器(与设置页 /dshfly/plugins/uninstall 同一实现)
108
+ bridgeCore.setPluginUninstaller((id) =>
109
+ uninstallPlugin({
110
+ id,
111
+ recommendedPlugins: cfg.recommendedPlugins || [],
112
+ bridgeCore,
113
+ loader: ctx.get('loader') ?? null,
114
+ dshBin: cfg.dshBin || '',
115
+ profileName: cfg.profileName,
116
+ }),
117
+ );
118
+ ctx.provide('mobileBridge', createMobileBridgeService(bridgeCore));
119
+ // 枚举后台化不阻塞启动(决策 10);幂等——listPlugins 会复用同一 promise 等它完成,
120
+ // 消除"插件动态 register 先到、枚举未完成"导致 staticSource 缺失(插件 ID 回退短 id)的竞态。
121
+ bridgeCore.ensureEnumerated();
122
+
123
+ const core = new ConnectorCore({
124
+ relayUrl: cfg.relayUrl,
125
+ deviceName: cfg.deviceName || os.hostname(),
126
+ keysFile: cfg.keysPath || (await defaultKeysPath()),
127
+ target: cfg.target || 'http://127.0.0.1:3080',
128
+ // 2026-09:DSH 0.1.2 起 /api 需 browser-session cookie 且协议结构性改动。同进程内直接调
129
+ // DSH typertGateway(免 HTTP 认证 + 免 remote.mux 线协议);gateway 不可用时 adapter
130
+ // 返回业务错误(不拖垮 dsh web)。
131
+ dshAdapter,
132
+ });
133
+ // 接线:mobile.* RPC 本地处理 + 插件事件注入隧道(connector-core 的 setMobileBridge)
134
+ core.setMobileBridge(createMobileBridgeService(bridgeCore));
135
+
136
+ // 启动时序(决策 10):cordis 保证 inject 的服务(webServer)先于插件 apply 激活,
137
+ // webServer 的 [Service.init] 即 socket bind("Activation listens immediately"),
138
+ // 因此 apply 时目标(127.0.0.1:3080 自环)已可连;tunnel.js 的事件 WS 首连仍有
139
+ // 指数退避重试兜底(路由注册顺序导致的瞬态失败会在 1s 后自愈)。
140
+ //
141
+ // 启动容错:登记/连中继失败(如网络不可达)时 warn + 跳过,不拖垮 dsh web;
142
+ // 恢复后重启 dsh web(或未来 M4 加自动重试)即可。
143
+ try {
144
+ await core.start();
145
+ } catch (e) {
146
+ console.warn(`[dshfly-remote-connector] 启动失败,跳过(重启 dsh web 可重试): ${e.message}`);
147
+ return;
148
+ }
149
+ await bridgeCore.ensureEnumerated(); // 复用第 96 行触发的同一枚举 promise(幂等),等待静态枚举完成
150
+ if (bridgeCore.entries.size === 0 && bridgeCore.health.length === 0) {
151
+ console.log('[dshfly-remote-connector] profile 无移动端插件(或 profile 不存在)——插件 Tab 将显示空状态');
152
+ } else if (bridgeCore.health.length) {
153
+ console.warn(`[dshfly-remote-connector] 移动端插件健康报告: ${JSON.stringify(bridgeCore.health)}`);
154
+ }
155
+
156
+ ctx.effect(async () => {
157
+ const disposeRoute = ctx.webServer.register(
158
+ createDshflyHttpRoute(core, {
159
+ autoConfirm: cfg.autoConfirm,
160
+ bridgeCore,
161
+ loader: ctx.get('loader') ?? null, // 一键安装热加载(沙箱 ctx 已验证可获取)
162
+ recommendedPlugins: cfg.recommendedPlugins || [],
163
+ dshBin: cfg.dshBin || '',
164
+ profileName: cfg.profileName,
165
+ version: selfVersion, // 本插件本地版本(设置页显示 + 升级检查)
166
+ }),
167
+ );
168
+ return async () => {
169
+ disposeRoute();
170
+ core.stop();
171
+ };
172
+ }, 'dshfly-remote-connector: lifecycle');
173
+ }
@@ -0,0 +1,33 @@
1
+ # @dshfly/mobile-bridge
2
+
3
+ DSH 移动端插件规范的**实现库**(M4.x-c)。**注意(2026-08-17 整合决策 1b):本包已不是独立 cordis 插件**——`ctx.mobileBridge` 服务由 [`@dshfly/remote-connector`](../remote-connector/) 承载(它拥有传输层,是服务的天然所有者)。本包是 connector 的运行时依赖,随 connector 一起安装,**不能也不应单独安装**。
4
+
5
+ 规范正文见 [`docs/mobile-plugin-spec.md`](../../docs/mobile-plugin-spec.md)。
6
+
7
+ ## 提供什么
8
+
9
+ ```
10
+ MobileBridgeCore(core/bridge-core.js) # 枚举/注册表/RPC/事件(纯 Node,可单测)
11
+ createMobileBridgeService(core) # 把 core 包装成服务对象(connector ctx.provide 用)
12
+ readProfileBundles / resolveBundleDir # 枚举 dsh.profile.bundles → 包目录
13
+ resolveProfileDir # <dshHome>/profiles/<profileName> 解析
14
+ ```
15
+
16
+ ## 服务契约(消费方不变)
17
+
18
+ - **功能插件**:`ctx.mobileBridge.register({manifest, handler})` / `unregister` / `emit`——与整合前完全一致;
19
+ - **传输层**(当前只有 connector):`handleRpc(method, payload)` + `onEvent(sink)`。
20
+
21
+ ## 为什么整合
22
+
23
+ 桥单独安装没有传输层,等于无用(手机无法触达);整合后:
24
+ - 装 `@dshfly/remote-connector` 一个命令即带出全部能力;
25
+ - bridge 非 bundle,无法被单独误装("装了没用"的状态空间消失);
26
+ - 无插件加载时序问题(服务随 connector 同步提供);
27
+ - 规范实现与通道实现仍在不同包:未来 DSH 上游若吸收移动端规范,本库可直接上贡。
28
+
29
+ ## 测试
30
+
31
+ ```bash
32
+ pnpm --filter @dshfly/mobile-bridge test
33
+ ```
@@ -0,0 +1,367 @@
1
+ // core/bridge-core.js —— MobileBridgeCore:规范实现的核心(纯 Node,无 cordis 依赖,可单测)。
2
+ //
3
+ // 职责(spec §4/§5):
4
+ // 枚举 profile bundles → package.json 的 dshfly.mobile → 两级校验 → 静态注册表
5
+ // 注册 动态 register(同 id 覆盖静态,v0.1 定稿决策)/ unregister
6
+ // RPC listPlugins / getHome / invoke / subscribe(错误码对齐 spec §5.3)
7
+ // 事件 emit → 扇出给传输层 sink(frame: {type:'mobile/event', pluginId, event:{name,payload}})
8
+ //
9
+ // 规则:
10
+ // - listPlugins 只列"可交互"(已注册 handler)的插件;仅静态声明(未注册)的插件
11
+ // 留在内部注册表 + health,供 PC 设置页健康报告。
12
+ // - handler 由插件自己注册(cordis 场景:插件 inject mobileBridge 服务后调 register);
13
+ // handler = { getHome?(params), invoke?(actionId, params), getBadge?() }。
14
+ // - 事件扇出 v1 为广播(所有已连接设备都收到 mobile/event,App 端按 pluginId 过滤);
15
+ // per-device 订阅优化留给传输层/App 端(spec §5.1 subscribe 语义)。
16
+
17
+ import { validateMobileManifest, readMobileManifest } from '@dshfly/mobile-plugin-schema';
18
+ import { readProfileBundles, resolveBundleDir } from './enumerate.js';
19
+ import { FilesService } from './files.js';
20
+
21
+ export class MobileBridgeError extends Error {
22
+ constructor(code, message) {
23
+ super(message);
24
+ this.code = code;
25
+ }
26
+ }
27
+
28
+ /** 默认语言(旧 App 不发 lang 时兜底)。 */
29
+ const DEFAULT_LANG = 'zh';
30
+
31
+ /** 解析 manifest 文本字段:string → 原样;{zh,en} → 按请求语言取(缺省/未知回退 zh,再回退 en/任意键)。 */
32
+ function resolvePluginString(value, lang) {
33
+ if (typeof value === 'string') return value;
34
+ if (value && typeof value === 'object') {
35
+ if (typeof value[lang] === 'string' && value[lang]) return value[lang];
36
+ if (typeof value.zh === 'string' && value.zh) return value.zh;
37
+ if (typeof value.en === 'string' && value.en) return value.en;
38
+ for (const k of Object.keys(value)) if (typeof value[k] === 'string' && value[k]) return value[k];
39
+ }
40
+ return '';
41
+ }
42
+
43
+ export class MobileBridgeCore {
44
+ /** @param {{profileDir?: string|null, resolveRoots?: () => Promise<string[]>, bundleResolveRoots?: string[]}} opts
45
+ * profileDir=null 时只支持动态注册(单测/无 DSH 宿主场景)。
46
+ * resolveRoots:文件树服务(mobile.files.*,方案 docs/file-tree-plan.md)的根白名单提供者,
47
+ * 由宿主(remote-connector)注入;未注入时 files.* RPC 返回 INTERNAL。
48
+ * bundleResolveRoots:额外 bundle 解析根(如 DSH 应用自身的 node_modules)。DSH 的
49
+ * dsh.profile.bundles 里混有"DSH 内置运行时 bundle"(@deepseek-ai/dsh-base 等,在 DSH 应用
50
+ * node_modules,不在 profile node_modules)——枚举时先查 profile node_modules,找不到再查这些
51
+ * 附加根,避免把 DSH 内置包误报为"bundle 目录解析失败"。 */
52
+ constructor({ profileDir = null, resolveRoots = null, bundleResolveRoots = [] } = {}) {
53
+ this.profileDir = profileDir;
54
+ /** 额外 bundle 解析根(DSH 应用 node_modules 等)。 */
55
+ this.bundleResolveRoots = Array.isArray(bundleResolveRoots) ? bundleResolveRoots.filter((d) => typeof d === 'string' && d) : [];
56
+ /** 文件树只读服务(mobile.files.list/read)。 */
57
+ this.files = resolveRoots ? new FilesService({ resolveRoots }) : null;
58
+ /** @type {Map<string, {manifest: object, staticSource: string|null, handler: object|null, registeredAt: number|null}>} */
59
+ this.entries = new Map();
60
+ /** @type {Array<{pluginId?: string, message: string}>} PC 设置页健康报告。 */
61
+ this.health = [];
62
+ /** @type {Set<(frame: object) => void>} 传输层事件 sink。 */
63
+ this.sinks = new Set();
64
+ /** @type {(() => {frames: object[]}) | null} 方案 A:pending 审批/提问缓存提供者
65
+ * (connector 在 dsh 事件流上镜像;手机连接恢复时经 mobile.approvals.list 拉取,
66
+ * 补齐手机离线期间被 relay 丢弃的审批事件)。 */
67
+ this._pendingApprovalProvider = null;
68
+ /** 静态枚举的幂等 promise(2026-08 修竞态):listPlugins 等它完成,避免
69
+ * "插件动态 register 先到、enumerate 后台未完成"时 staticSource 缺失(详情页
70
+ * 插件 ID 回退成短 id 而非 npm 全局标识)。 */
71
+ this._enumeratePromise = null;
72
+ }
73
+
74
+ /** 静态枚举(幂等):返回同一 promise,确保只跑一次;listPlugins 等待其完成以拿到
75
+ * staticSource(npm 包名)。profileDir 缺失/枚举失败 → resolve(失败静默,仅动态注册)。 */
76
+ ensureEnumerated() {
77
+ if (this._enumeratePromise) return this._enumeratePromise;
78
+ if (!this.profileDir) { this._enumeratePromise = Promise.resolve(); return this._enumeratePromise; }
79
+ this._enumeratePromise = this.enumerate().catch((e) => {
80
+ console.warn(`[mobile-bridge] 枚举移动端插件失败,仅支持动态注册: ${e.message}`);
81
+ });
82
+ return this._enumeratePromise;
83
+ }
84
+
85
+ /** 静态枚举:profile bundles → dshfly.mobile 声明 → 两级校验 → 注册表 upsert。 */
86
+ async enumerate() {
87
+ this.health = [];
88
+ if (!this.profileDir) return;
89
+ for (const name of readProfileBundles(this.profileDir)) {
90
+ const dir = resolveBundleDir(this.profileDir, name, this.bundleResolveRoots);
91
+ if (!dir) {
92
+ this.health.push({ pluginId: name, message: 'bundle 目录解析失败(node_modules 中不存在)' });
93
+ continue;
94
+ }
95
+ const read = readMobileManifest(dir);
96
+ if (!read.found) continue; // 不是移动端插件,正常跳过
97
+ const v = validateMobileManifest(read.manifest);
98
+ if (!v.ok) {
99
+ this.health.push({
100
+ pluginId: name,
101
+ message: `manifest 结构无效: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`,
102
+ });
103
+ continue;
104
+ }
105
+ const m = read.manifest;
106
+ const existing = this.entries.get(m.id);
107
+ if (existing) {
108
+ existing.staticSource = name; // 动态注册先到(如热重载):只补静态来源
109
+ } else {
110
+ this.entries.set(m.id, { manifest: m, staticSource: name, handler: null, registeredAt: null });
111
+ }
112
+ }
113
+ }
114
+
115
+ /** 动态注册(spec §4.1 ③)。handler 必带;同 id 覆盖静态 manifest(定稿决策)。 */
116
+ register({ manifest, handler } = {}) {
117
+ const v = validateMobileManifest(manifest);
118
+ if (!v.ok) return { ok: false, errors: v.errors, warnings: v.warnings };
119
+ const id = manifest.id;
120
+ const existing = this.entries.get(id);
121
+ this.entries.set(id, {
122
+ manifest,
123
+ staticSource: existing?.staticSource ?? null,
124
+ handler: handler ?? null,
125
+ registeredAt: Date.now(),
126
+ });
127
+ return { ok: true, errors: [], warnings: v.warnings, pluginId: id };
128
+ }
129
+
130
+ /** 注销 handler:有静态声明的退回"仅声明"态,否则整体删除。 */
131
+ unregister(pluginId) {
132
+ const e = this.entries.get(pluginId);
133
+ if (!e) return;
134
+ if (e.staticSource) {
135
+ e.handler = null;
136
+ e.registeredAt = null;
137
+ } else {
138
+ this.entries.delete(pluginId);
139
+ }
140
+ }
141
+
142
+ /** 卸载后彻底移除条目(静态+动态):插件从 listPlugins 立即消失。
143
+ * enumerate() 只 upsert 静态声明、不清动态 register 条目——卸载必须显式调用本方法,
144
+ * 否则运行中实例的动态条目残留,列表不消失(2026-08 实测)。 */
145
+ removeEntry(pluginId) {
146
+ return this.entries.delete(pluginId);
147
+ }
148
+
149
+ _active(pluginId) {
150
+ const e = this.entries.get(pluginId);
151
+ if (!e || !e.handler) throw new MobileBridgeError('PLUGIN_NOT_FOUND', `plugin not found: ${pluginId}`);
152
+ return e;
153
+ }
154
+
155
+ _requireCapability(e, cap) {
156
+ if (!e.manifest.capabilities?.includes(cap)) {
157
+ throw new MobileBridgeError('CAPABILITY_NOT_DECLARED', `capability "${cap}" not declared by ${e.manifest.id}`);
158
+ }
159
+ }
160
+
161
+ /** 插件列表(仅可交互项)+ 每插件 badge(handler.getBadge,失败记 0)。
162
+ * params.lang(可选,'zh'|'en'):把 manifest 的 name/description 按请求语言解析为字符串下发
163
+ * (manifest 本体仍保留原始 {zh,en},App 端始终拿到字符串——旧 App 不发 lang 时兜底 zh)。 */
164
+ async listPlugins(params = {}) {
165
+ await this.ensureEnumerated(); // 静态来源就绪后再列(防 register 先到/enumerate 未完成的竞态)
166
+ const rawLang = params?.lang; // 手机端实际传的 lang(undefined = 未传/旧客户端)
167
+ const lang = (rawLang === 'en' || rawLang === 'zh') ? rawLang : DEFAULT_LANG;
168
+ const out = [];
169
+ for (const [id, e] of this.entries) {
170
+ if (!e.handler) continue;
171
+ let badge = 0;
172
+ try {
173
+ const b = await e.handler.getBadge?.();
174
+ if (typeof b === 'number' && b > 0) badge = b;
175
+ } catch {}
176
+ out.push({
177
+ manifest: {
178
+ ...e.manifest,
179
+ name: resolvePluginString(e.manifest.name, lang),
180
+ description: resolvePluginString(e.manifest.description, lang),
181
+ },
182
+ badge,
183
+ pkgName: e.staticSource || null,
184
+ });
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /** 插件首页组件树(spec §6:根必须为 screen 节点,结构性 sanity)。 */
190
+ async getHome(pluginId, params = {}) {
191
+ const e = this._active(pluginId);
192
+ this._requireCapability(e, 'home');
193
+ if (typeof e.handler.getHome !== 'function') {
194
+ throw new MobileBridgeError('INTERNAL', `plugin ${pluginId} declares home but implements no getHome()`);
195
+ }
196
+ const tree = await e.handler.getHome(params);
197
+ if (!tree || typeof tree !== 'object' || tree.type !== 'screen') {
198
+ throw new MobileBridgeError('INTERNAL', `plugin ${pluginId} getHome() 返回非法组件树(根必须为 screen)`);
199
+ }
200
+ return tree;
201
+ }
202
+
203
+ /** 触发动作(spec §5.1 invoke;actionId 未知由插件抛 ACTION_NOT_FOUND,透传)。 */
204
+ async invoke(pluginId, actionId, params = {}) {
205
+ const e = this._active(pluginId);
206
+ this._requireCapability(e, 'actions');
207
+ if (typeof actionId !== 'string' || !actionId) {
208
+ throw new MobileBridgeError('INTERNAL', 'invoke: actionId 必填');
209
+ }
210
+ if (typeof e.handler.invoke !== 'function') {
211
+ throw new MobileBridgeError('INTERNAL', `plugin ${pluginId} declares actions but implements no invoke()`);
212
+ }
213
+ return e.handler.invoke(actionId, params);
214
+ }
215
+
216
+ /** 订阅插件事件。v1:校验插件存在后即 ack;扇出为广播(App 端按 pluginId 过滤)。 */
217
+ subscribe(pluginId) {
218
+ this._active(pluginId);
219
+ return { ok: true, note: 'v1 broadcast fan-out' };
220
+ }
221
+
222
+ /** 打开插件聊天(spec §5.1):sessionId 缺省时由 handler.getChatSession?.() 提供(如秘书的持久会话)。 */
223
+ async openChat(pluginId, sessionId) {
224
+ const e = this._active(pluginId);
225
+ this._requireCapability(e, 'chat');
226
+ let sid = sessionId;
227
+ if (!sid) {
228
+ if (typeof e.handler.getChatSession !== 'function') {
229
+ throw new MobileBridgeError('INTERNAL', `plugin ${pluginId} declares chat but implements no getChatSession()`);
230
+ }
231
+ sid = await e.handler.getChatSession();
232
+ }
233
+ if (typeof sid !== 'string' || !sid) {
234
+ throw new MobileBridgeError('INTERNAL', `plugin ${pluginId} 未提供可打开的会话`);
235
+ }
236
+ return { sessionId: sid };
237
+ }
238
+
239
+ /** 插件主动发事件:{type:'mobile/event', pluginId, event:{name, payload}} 扇出给所有 sink。 */
240
+ emit(pluginId, name, payload = null) {
241
+ const frame = { type: 'mobile/event', pluginId, event: { name, payload } };
242
+ let delivered = 0;
243
+ for (const sink of this.sinks) {
244
+ try {
245
+ sink(frame);
246
+ delivered++;
247
+ } catch {}
248
+ }
249
+ return { delivered };
250
+ }
251
+
252
+ /** 传输层注册事件 sink,返回注销函数。 */
253
+ onEvent(sink) {
254
+ this.sinks.add(sink);
255
+ return () => this.sinks.delete(sink);
256
+ }
257
+
258
+ /** 方案 A:注入 pending 审批/提问缓存提供者(connector 隧道持有;未注入时 RPC 返回 INTERNAL)。
259
+ * 返回 { frames: [server-request 帧, ...] }——与实时 mux 帧同形,手机端走与实时帧相同的分发路径。 */
260
+ setPendingApprovalProvider(fn) {
261
+ this._pendingApprovalProvider = typeof fn === 'function' ? fn : null;
262
+ }
263
+
264
+ /** 注入中继用量/配额提供者(2026-08,quota-ux.md §5 定稿链路):connector 持 deviceToken
265
+ * 查 relay GET /api/v1/usage(非计费只读);未注入时 RPC 返回 INTERNAL——与 files/approvals 同模式。
266
+ * fn() → Promise<{ plan, quota, used, resetAt, quotaDisabled }>(quota=null 表示不限量)。 */
267
+ setRelayUsageProvider(fn) {
268
+ this._relayUsageProvider = typeof fn === 'function' ? fn : null;
269
+ }
270
+
271
+ /** 注入一键安装推荐插件提供者(remote-connector 提供 dsh plugin add 实现;
272
+ * 未注入时 RPC 返回 INTERNAL——与 files/approvals 同模式)。fn(id) → Promise<{ok, already?, rebootNeeded?}> */
273
+ setPluginInstaller(fn) {
274
+ this._pluginInstaller = typeof fn === 'function' ? fn : null;
275
+ }
276
+
277
+ /** 注入一键卸载插件提供者(remote-connector 提供 dsh plugin remove 实现;对称 plugins.install)。
278
+ * fn(id) → Promise<{ok, pkgName?, loaded?, rebootNeeded?}> */
279
+ setPluginUninstaller(fn) {
280
+ this._pluginUninstaller = typeof fn === 'function' ? fn : null;
281
+ }
282
+
283
+ /** mobile.approvals.list:拉取 pending 审批/提问(补手机离线窗口的缺失)。 */
284
+ _approvalsList() {
285
+ if (!this._pendingApprovalProvider) {
286
+ throw new MobileBridgeError('INTERNAL', '审批缓存未启用(宿主未注入 pending 提供者)');
287
+ }
288
+ return this._pendingApprovalProvider();
289
+ }
290
+
291
+ /** mobile.getRelayUsage:当前 PC 的中继用量/配额(2026-08,quota-ux.md §5)。 */
292
+ _relayUsage() {
293
+ if (!this._relayUsageProvider) {
294
+ throw new MobileBridgeError('INTERNAL', '用量查询未启用(宿主未注入提供者)');
295
+ }
296
+ return this._relayUsageProvider();
297
+ }
298
+
299
+ /** 统一 RPC 入口(spec §5.1 信封语义):method = 'mobile.xxx'。 */
300
+ async handleRpc(method, payload = {}) {
301
+ const name = method.startsWith('mobile.') ? method.slice('mobile.'.length) : method;
302
+ switch (name) {
303
+ case 'listPlugins': return this.listPlugins(payload?.params);
304
+ case 'getHome': return this.getHome(payload?.pluginId, payload?.params);
305
+ case 'invoke': return this.invoke(payload?.pluginId, payload?.actionId, payload?.params);
306
+ case 'subscribe': return this.subscribe(payload?.pluginId);
307
+ case 'openChat': return this.openChat(payload?.pluginId, payload?.sessionId);
308
+ case 'refresh': await this.enumerate(); return { ok: true };
309
+ // 文件树(只读,方案 docs/file-tree-plan.md):宿主注入 resolveRoots 后启用
310
+ case 'files.list': return this._files('list', payload);
311
+ case 'files.read': return this._files('read', payload);
312
+ // 文件元信息(方案 file-image-download-plan.md §4.1):下载/预览前取 size + mediaType
313
+ case 'files.info': return this._files('info', payload);
314
+ // 二进制分块下载(§4.2):返回 {__raw: Uint8Array},connector 以原始字节作信文明文
315
+ case 'files.download': return this._files('download', payload);
316
+ // 浏览任意目录(2026-08:添加工作区选目录用;无白名单锚定)
317
+ case 'files.browse': return this._files('browse', payload);
318
+ // 创建目录(2026-08:添加工作区用——手机输入 PC 目录路径,先建后采纳)
319
+ case 'files.mkdir': return this._files('mkdir', payload);
320
+ // 方案 A:pending 审批/提问补拉(手机离线期间被丢弃的审批事件)
321
+ case 'approvals.list': return this._approvalsList();
322
+ // 中继用量/配额(2026-08,quota-ux.md §5):当前 PC 的今日用量与额度(非计费)
323
+ case 'getRelayUsage': return this._relayUsage();
324
+ // 一键安装推荐插件(App 插件页空状态入口):宿主注入 installer 后启用
325
+ case 'plugins.install': return this._pluginInstall(payload?.id);
326
+ // 一键卸载插件(App 插件详情页入口):宿主注入 uninstaller 后启用
327
+ case 'plugins.uninstall': return this._pluginUninstall(payload?.id);
328
+ default:
329
+ throw new MobileBridgeError('INTERNAL', `未知方法: ${method}`);
330
+ }
331
+ }
332
+
333
+ /** 插件安装门禁:未注入 installer 时停用(错误码 INTERNAL,细节只进 PC 日志)。 */
334
+ _pluginInstall(id) {
335
+ if (!this._pluginInstaller) {
336
+ throw new MobileBridgeError('INTERNAL', '插件安装未启用(宿主未注入 installer)');
337
+ }
338
+ if (typeof id !== 'string' || !id) {
339
+ throw new MobileBridgeError('INTERNAL', 'plugins.install: id 必填');
340
+ }
341
+ return this._pluginInstaller(id);
342
+ }
343
+
344
+ /** 插件卸载门禁:未注入 uninstaller 时停用(错误码 INTERNAL,细节只进 PC 日志)。 */
345
+ _pluginUninstall(id) {
346
+ if (!this._pluginUninstaller) {
347
+ throw new MobileBridgeError('INTERNAL', '插件卸载未启用(宿主未注入 uninstaller)');
348
+ }
349
+ if (typeof id !== 'string' || !id) {
350
+ throw new MobileBridgeError('INTERNAL', 'plugins.uninstall: id 必填');
351
+ }
352
+ return this._pluginUninstaller(id);
353
+ }
354
+
355
+ /** 文件树 RPC 门禁:未注入 resolveRoots 时停用(错误码 INTERNAL,细节只进 PC 日志)。
356
+ * download 返回原始字节,包成 {__raw} 标记,交由 tunnel 检测后以原始字节作信封明文(§4.2)。 */
357
+ _files(op, payload) {
358
+ if (!this.files) {
359
+ throw new MobileBridgeError('INTERNAL', '文件服务未启用(宿主未注入 resolveRoots)');
360
+ }
361
+ const res = this.files[op](payload || {});
362
+ if (op === 'download') {
363
+ return Promise.resolve(res).then((bytes) => ({ __raw: bytes }));
364
+ }
365
+ return res;
366
+ }
367
+ }
@@ -0,0 +1,44 @@
1
+ // core/enumerate.js —— 枚举 DSH profile 中已安装的插件(spec §4.1 ① ②)。
2
+ //
3
+ // 发现路径(已核实,@deepseek-ai/dsh@0.1.0-rc.6 源码):
4
+ // dsh 插件管理 = profile 目录 pnpm + profile manifest 的 dsh.profile.bundles 清单。
5
+ // 每个 bundle 条目是包名,包本体在 <profileDir>/node_modules/<name>(pnpm 符号链接,
6
+ // path.join 直解即可;scoped 包为 node_modules/@scope/name)。
7
+ // 本模块只做文件系统枚举,不依赖 cordis 内部 API——对未加载的插件也能枚举。
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ /** 读 profile manifest 的 dsh.profile.bundles(string[]);manifest 缺失/损坏返回 []。 */
13
+ export function readProfileBundles(profileDir) {
14
+ let pkg;
15
+ try {
16
+ pkg = JSON.parse(fs.readFileSync(path.join(profileDir, 'package.json'), 'utf8'));
17
+ } catch {
18
+ return [];
19
+ }
20
+ const bundles = pkg?.dsh?.profile?.bundles;
21
+ return Array.isArray(bundles) ? bundles.filter((b) => typeof b === 'string') : [];
22
+ }
23
+
24
+ /**
25
+ * 把 bundle 包名解析为包目录:
26
+ * - 先在 profile 根下找 `<profileDir>/node_modules/<name>`(用户/profile 装的插件);
27
+ * - 再在附加 node_modules 根(extraNodeModulesRoots,本就是 node_modules 目录,如 DSH 应用自身的
28
+ * node_modules——DSH 内置运行时 bundle @deepseek-ai/dsh-* 在那里)下找 `<root>/<name>`。
29
+ * 全失败返回 null。
30
+ */
31
+ export function resolveBundleDir(profileDir, name, extraNodeModulesRoots = []) {
32
+ try {
33
+ const dir = path.join(profileDir, 'node_modules', ...name.split('/'));
34
+ if (fs.statSync(dir).isDirectory()) return dir;
35
+ } catch {}
36
+ for (const root of (extraNodeModulesRoots || [])) {
37
+ if (!root) continue;
38
+ const dir = path.join(root, ...name.split('/'));
39
+ try {
40
+ if (fs.statSync(dir).isDirectory()) return dir;
41
+ } catch {}
42
+ }
43
+ return null;
44
+ }