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,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端「宿主凭据」API:把密钥的查看 / 写入 / 清除补齐到手机上。
|
|
5
|
+
*
|
|
6
|
+
* 桌面端的设置面板能往宿主写 API Key(dsh-client-ui-settings-plugins/lib/client.js
|
|
7
|
+
* 的 writeKey() → credentials.set,写完再 credentials.describe 复核),手机此前
|
|
8
|
+
* 只能在设置页看到一句「· 1 项密钥未设置」,却没有任何入口去填——这正是本轮补的缺口。
|
|
9
|
+
*
|
|
10
|
+
* 三条接口都只是转发宿主的 credentials 域:
|
|
11
|
+
* - GET /credentials?refs=A,B → credentials.describe
|
|
12
|
+
* - POST /credentials/set → credentials.set
|
|
13
|
+
* - POST /credentials/unset → credentials.unset
|
|
14
|
+
*
|
|
15
|
+
* 密钥值只在这条路上出现一次(手机 → 宿主)。宿主从不回传值:describe 只给
|
|
16
|
+
* {configured, source?, writable},所以这里的响应里也永远不会有 value 字段。
|
|
17
|
+
*
|
|
18
|
+
* 「能不能写」由宿主的 credentials 服务说了算:如果某个引用是启动环境变量提供的,
|
|
19
|
+
* 宿主会回 writable:false,set 则回 credential-rejected("supplied read-only by the
|
|
20
|
+
* launching environment")。这里不自己判断,一律如实透传,让 App 显示宿主的原话。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
24
|
+
const { unwrap } = require('./sessions.js');
|
|
25
|
+
|
|
26
|
+
const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; // 与宿主 credentialRefNameSchema 同一条规则
|
|
27
|
+
const CREDENTIAL_MAX_REFS = 64; // describe 一次最多问这么多个引用(宿主也是 64)
|
|
28
|
+
const CREDENTIAL_MAX_REF_CHARS = 128; // 单个引用名长度上限
|
|
29
|
+
const CREDENTIAL_MAX_VALUE_CHARS = 8192; // 密钥值长度上限:够放任何 API Key,又不至于让请求体失控
|
|
30
|
+
|
|
31
|
+
/** 校验一个凭据引用名;畸形一律拒绝,不把垃圾转发给宿主。 */
|
|
32
|
+
function normalizeRef(value) {
|
|
33
|
+
if (typeof value !== 'string') return null;
|
|
34
|
+
const ref = value.trim();
|
|
35
|
+
if (!ref || ref.length > CREDENTIAL_MAX_REF_CHARS) return null;
|
|
36
|
+
return CREDENTIAL_REF_PATTERN.test(ref) ? ref : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 把宿主的凭据视图压成手机需要的形状;source 只在宿主给出时带上,值永不出现。 */
|
|
40
|
+
function normalizeCredentialView(view) {
|
|
41
|
+
if (!view || typeof view !== 'object' || Array.isArray(view)) return null;
|
|
42
|
+
const source = typeof view.source === 'string' && view.source.trim() ? view.source.trim() : undefined;
|
|
43
|
+
return {
|
|
44
|
+
configured: view.configured === true,
|
|
45
|
+
...(source === undefined ? {} : { source }),
|
|
46
|
+
writable: view.writable === true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** GET /api/mobile/v1/credentials?refs=A,B —— 这批引用是否已配置、能不能写。 */
|
|
51
|
+
async function handleCredentialsDescribe(ctx, req, res) {
|
|
52
|
+
const url = new URL(req.url, 'http://x');
|
|
53
|
+
const parts = (url.searchParams.get('refs') || '').split(',').map((part) => part.trim()).filter(Boolean);
|
|
54
|
+
if (parts.length > CREDENTIAL_MAX_REFS) {
|
|
55
|
+
return sendJson(res, 400, {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: { code: 'bad-request', message: `refs 一次最多 ${CREDENTIAL_MAX_REFS} 个` },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const refs = [];
|
|
61
|
+
for (const part of parts) {
|
|
62
|
+
const ref = normalizeRef(part);
|
|
63
|
+
if (!ref) {
|
|
64
|
+
return sendJson(res, 400, {
|
|
65
|
+
ok: false,
|
|
66
|
+
error: { code: 'bad-request', message: 'refs 必须是逗号分隔的凭据引用名,形如 DEEPSEEK_API_KEY' },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (!refs.includes(ref)) refs.push(ref);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { value, error } = unwrap(await ctx.apiProxy.credentials.describe({
|
|
73
|
+
rpcId: 'mobile',
|
|
74
|
+
method: 'credentials.describe',
|
|
75
|
+
payload: { refs },
|
|
76
|
+
}));
|
|
77
|
+
if (error) return sendJson(res, 400, { ok: false, error });
|
|
78
|
+
|
|
79
|
+
const source = value && typeof value.credentials === 'object' && value.credentials !== null && !Array.isArray(value.credentials)
|
|
80
|
+
? value.credentials
|
|
81
|
+
: {};
|
|
82
|
+
const credentials = {};
|
|
83
|
+
for (const ref of refs) {
|
|
84
|
+
const view = normalizeCredentialView(source[ref]);
|
|
85
|
+
if (view) credentials[ref] = view;
|
|
86
|
+
}
|
|
87
|
+
sendJson(res, 200, { ok: true, credentials });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* POST /api/mobile/v1/credentials/set(token){ ref, value } → { ok }
|
|
92
|
+
*
|
|
93
|
+
* 值只进不出:成功响应里只有 ok,不回显、不回传宿主的任何值。
|
|
94
|
+
*/
|
|
95
|
+
async function handleCredentialsSet(ctx, req, res) {
|
|
96
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
97
|
+
const ref = normalizeRef(body ? body.ref : undefined);
|
|
98
|
+
if (!ref) {
|
|
99
|
+
return sendJson(res, 400, {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: { code: 'bad-request', message: '需要 { ref, value },ref 形如 DEEPSEEK_API_KEY' },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const value = body && typeof body.value === 'string' ? body.value : '';
|
|
105
|
+
if (!value) {
|
|
106
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '密钥值不能为空' } });
|
|
107
|
+
}
|
|
108
|
+
if (value.length > CREDENTIAL_MAX_VALUE_CHARS) {
|
|
109
|
+
return sendJson(res, 400, {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: { code: 'bad-request', message: `密钥值不能超过 ${CREDENTIAL_MAX_VALUE_CHARS} 个字符` },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { error } = unwrap(await ctx.apiProxy.credentials.set({
|
|
116
|
+
rpcId: 'mobile',
|
|
117
|
+
method: 'credentials.set',
|
|
118
|
+
payload: { ref, value },
|
|
119
|
+
}));
|
|
120
|
+
if (error) return sendJson(res, 400, { ok: false, error });
|
|
121
|
+
sendJson(res, 200, { ok: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** POST /api/mobile/v1/credentials/unset(token){ ref } → { ok }(宿主对没设过的引用也回 ok)。 */
|
|
125
|
+
async function handleCredentialsUnset(ctx, req, res) {
|
|
126
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
127
|
+
const ref = normalizeRef(body ? body.ref : undefined);
|
|
128
|
+
if (!ref) {
|
|
129
|
+
return sendJson(res, 400, {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: { code: 'bad-request', message: '需要 { ref },ref 形如 DEEPSEEK_API_KEY' },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { error } = unwrap(await ctx.apiProxy.credentials.unset({
|
|
136
|
+
rpcId: 'mobile',
|
|
137
|
+
method: 'credentials.unset',
|
|
138
|
+
payload: { ref },
|
|
139
|
+
}));
|
|
140
|
+
if (error) return sendJson(res, 400, { ok: false, error });
|
|
141
|
+
sendJson(res, 200, { ok: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
handleCredentialsDescribe,
|
|
146
|
+
handleCredentialsSet,
|
|
147
|
+
handleCredentialsUnset,
|
|
148
|
+
normalizeCredentialView,
|
|
149
|
+
normalizeRef,
|
|
150
|
+
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 手机端目录浏览的**兜底实现**:宿主组合出的目录选择器只服务 native 时,由插件自己
|
|
5
|
+
* 把「列一层目录 / 建一个子目录」补上。
|
|
6
|
+
*
|
|
7
|
+
* 为什么需要它:宿主那套由 `dsh-host-directory-picker-auto` 在启动时二选一
|
|
8
|
+
* (`resolveDirectoryPickerBackend`)——只有「宿主 web 服务绑定到非 127.0.0.1」或者
|
|
9
|
+
* 「平台不是 darwin/win32」才挂 browse。无头移动宿主由本包的 cordis.mobile-host.yml
|
|
10
|
+
* 把那一行钉成 browse,桌面 profile 宿主则被解析成 native。于是
|
|
11
|
+
* **「桌面 profile 宿主 + 手机远程连」**这一格谁都盖不到:宿主按「操作者就在机器前」
|
|
12
|
+
* 选了原生对话框,而手机是远程客户端,看不到也点不到那个框 —— 点「添加工作区」只能
|
|
13
|
+
* 看到 `host.listDirectory needs the browse capability; the composed picker serves
|
|
14
|
+
* "native"`,一个条目都列不出来,「新建工作文件夹」也从根上走不通。
|
|
15
|
+
*
|
|
16
|
+
* 为什么兜底放在这里,而不是把 cordis.mobile-host.yml 里钉 browse 的两行搬进
|
|
17
|
+
* cordis.patch.yml:后者两种宿主都会加载,而桌面操作者就坐在机器前,把它的原生选择框
|
|
18
|
+
* 换成 browse 是纯粹的退步 —— 守卫 `test/directory-picker.test.cjs` 与
|
|
19
|
+
* docs/dev-map.md 的 A3-07 都把「那两行不许留在 bundle 层」钉成了规格。插件自带一份
|
|
20
|
+
* 原语则谁也不动:宿主只要服务 browse,就仍然整条走宿主(策略与 maxEntries 保持单一
|
|
21
|
+
* 真源);只有宿主**明确**只服务 native(或没有 browse 能力)时,才用这一份把手机那条
|
|
22
|
+
* 路补上。判定用同进程直查 `ctx.get('directoryPicker').capability()`,不额外打一趟网关。
|
|
23
|
+
*
|
|
24
|
+
* 语义与 `dsh-host-directory-picker-browse` 逐条对齐(那份是这条 seam 的规范实现):
|
|
25
|
+
* - 隐藏项**标记但返回**(`hidden: name.startsWith('.')`),过滤交给客户端;
|
|
26
|
+
* - **跟随符号链接**:能进入的才列出来,断链/成环的静静跳过;
|
|
27
|
+
* - **全文件系统范围**,没有白名单 —— 这与宿主 browse 后端一致,能力面没有扩大:
|
|
28
|
+
* 手机端本来就经 `workspace.create` 采纳任意已存在目录;
|
|
29
|
+
* - 名字升序有界窗口,默认上限 `maxEntries = 1000`(对齐 GitHub 网页版),满了置
|
|
30
|
+
* `truncated`;
|
|
31
|
+
* - 回 `resolve(path)`,**不是 realpath**(macOS 上 /var 是 /private/var 的软链,
|
|
32
|
+
* 回 realpath 会让面包屑和客户端手里的路径对不上)。
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const { mkdir, opendir, stat } = require('node:fs/promises');
|
|
36
|
+
const { homedir } = require('node:os');
|
|
37
|
+
const { basename, dirname, join, posix, resolve, win32 } = require('node:path');
|
|
38
|
+
|
|
39
|
+
/** 单次 list 最多物化的行数,对齐宿主 browse 后端与 GitHub 网页版。 */
|
|
40
|
+
const DEFAULT_MAX_ENTRIES = 1000;
|
|
41
|
+
|
|
42
|
+
/** 越权/不存在的目录操作:code 与宿主 browse 后端同名,便于映射成同一组 wire 码。 */
|
|
43
|
+
class DirectoryBrowseError extends Error {
|
|
44
|
+
constructor(code, path, message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'DirectoryBrowseError';
|
|
47
|
+
this.code = code;
|
|
48
|
+
this.path = path;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 被控端主目录;`homedir()` 在极小概率下抛错时退到根,不让列目录整体不可用。 */
|
|
53
|
+
function homeDirectory() {
|
|
54
|
+
try {
|
|
55
|
+
return homedir();
|
|
56
|
+
} catch {
|
|
57
|
+
return resolve('/');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 路径是否指名一个**固定**的文件系统位置:POSIX 上就是绝对路径;Windows 上只认盘符
|
|
63
|
+
* 限定(`C:\…`)或完整 UNC(`\\server\share…`)。无盘符的 `\foo` / `/foo` 与残缺的前缀
|
|
64
|
+
* (`\\`、`\\server`)也能过 `isAbsolute`,却仍会随进程当前盘符漂移。
|
|
65
|
+
*/
|
|
66
|
+
function fullyQualified(path, platform = process.platform) {
|
|
67
|
+
return platform === 'win32'
|
|
68
|
+
? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
|
|
69
|
+
: posix.isAbsolute(path);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 从文件系统根到 `target`(含)的祖先链 —— 列表的面包屑,每一节都是可跳的目标。 */
|
|
73
|
+
function ancestryCrumbs(target) {
|
|
74
|
+
const crumbs = [];
|
|
75
|
+
let current = target;
|
|
76
|
+
for (;;) {
|
|
77
|
+
const parent = dirname(current);
|
|
78
|
+
crumbs.unshift({
|
|
79
|
+
name: parent === current ? current : basename(current),
|
|
80
|
+
path: current,
|
|
81
|
+
hidden: false,
|
|
82
|
+
});
|
|
83
|
+
if (parent === current) return crumbs;
|
|
84
|
+
current = parent;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 把流式读到的候选插进名字升序的有界窗口,超出 `keep` 时淘汰名字最大的那个。于是无论
|
|
90
|
+
* 一个目录有多少子项,内存都是 O(keep)。
|
|
91
|
+
*
|
|
92
|
+
* @returns 是否发生了淘汰(= 这一层还有窗口外的候选)。
|
|
93
|
+
*/
|
|
94
|
+
function boundedInsert(window, candidate, keep) {
|
|
95
|
+
if (window.length === keep && candidate.name.localeCompare(window[window.length - 1].name) >= 0) return true;
|
|
96
|
+
let lo = 0;
|
|
97
|
+
let hi = window.length;
|
|
98
|
+
while (lo < hi) {
|
|
99
|
+
const mid = (lo + hi) >>> 1;
|
|
100
|
+
if (candidate.name.localeCompare(window[mid].name) < 0) hi = mid;
|
|
101
|
+
else lo = mid + 1;
|
|
102
|
+
}
|
|
103
|
+
window.splice(lo, 0, candidate);
|
|
104
|
+
if (window.length <= keep) return false;
|
|
105
|
+
window.pop();
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 把任意抛出值收敛成 Error(abort reason 可以不是 Error)。 */
|
|
110
|
+
function asError(reason) {
|
|
111
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 未知抛出值的文案。 */
|
|
115
|
+
function messageOf(error) {
|
|
116
|
+
return error instanceof Error ? error.message : String(error);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* await `operation`,但 signal abort 时立刻 reject。Node 的文件系统读不能撤回,被放弃的
|
|
121
|
+
* 那一步仍在跑,它的迟到 settle 在这里被吞掉,免得变成 unhandled rejection。
|
|
122
|
+
*/
|
|
123
|
+
function raceAbort(operation, signal) {
|
|
124
|
+
if (signal === undefined) return operation;
|
|
125
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
126
|
+
const onAbort = () => {
|
|
127
|
+
operation.catch(() => {});
|
|
128
|
+
rejectPromise(asError(signal.reason));
|
|
129
|
+
};
|
|
130
|
+
if (signal.aborted) {
|
|
131
|
+
onAbort();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
135
|
+
operation.then(
|
|
136
|
+
(value) => {
|
|
137
|
+
signal.removeEventListener('abort', onAbort);
|
|
138
|
+
resolvePromise(value);
|
|
139
|
+
},
|
|
140
|
+
(reason) => {
|
|
141
|
+
signal.removeEventListener('abort', onAbort);
|
|
142
|
+
rejectPromise(asError(reason));
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 一条列表行:符号链接跟到目录上;非目录与断链/成环的返回 null(浏览器只列得进去的)。
|
|
150
|
+
*/
|
|
151
|
+
async function directoryRow(parent, name, isDirectory, isSymbolicLink, signal) {
|
|
152
|
+
const path = join(parent, name);
|
|
153
|
+
let enterable = isDirectory;
|
|
154
|
+
if (!enterable && isSymbolicLink) {
|
|
155
|
+
try {
|
|
156
|
+
enterable = (await raceAbort(stat(path), signal)).isDirectory();
|
|
157
|
+
} catch {
|
|
158
|
+
if (signal && signal.aborted) throw asError(signal.reason);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!enterable) return null;
|
|
163
|
+
return { name, path, hidden: name.startsWith('.') };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 列一层目录。`path` 缺省 = 被控端主目录。
|
|
168
|
+
*
|
|
169
|
+
* @returns `{ path, home, crumbs, entries, truncated }`(与宿主 browse 后端同形状)
|
|
170
|
+
*/
|
|
171
|
+
async function listDirectory(path, options = {}) {
|
|
172
|
+
const { signal, maxEntries = DEFAULT_MAX_ENTRIES } = options;
|
|
173
|
+
const home = homeDirectory();
|
|
174
|
+
if (path !== undefined && path !== null && !fullyQualified(path)) {
|
|
175
|
+
throw new DirectoryBrowseError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`);
|
|
176
|
+
}
|
|
177
|
+
const target = resolve(path === undefined || path === null ? home : path);
|
|
178
|
+
const keep = maxEntries + 1;
|
|
179
|
+
const window = [];
|
|
180
|
+
let evicted = false;
|
|
181
|
+
try {
|
|
182
|
+
const opening = opendir(target);
|
|
183
|
+
const level = await raceAbort(opening, signal).catch((error) => {
|
|
184
|
+
opening.then((dir) => dir.close().catch(() => {}), () => {});
|
|
185
|
+
throw error;
|
|
186
|
+
});
|
|
187
|
+
try {
|
|
188
|
+
for (;;) {
|
|
189
|
+
const dirent = await raceAbort(level.read(), signal);
|
|
190
|
+
if (dirent === null) break;
|
|
191
|
+
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue;
|
|
192
|
+
const candidate = {
|
|
193
|
+
name: dirent.name,
|
|
194
|
+
isDirectory: dirent.isDirectory(),
|
|
195
|
+
isSymbolicLink: dirent.isSymbolicLink(),
|
|
196
|
+
};
|
|
197
|
+
if (boundedInsert(window, candidate, keep)) evicted = true;
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
const closing = level.close();
|
|
201
|
+
if (signal && signal.aborted) closing.catch(() => {});
|
|
202
|
+
else await closing;
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (signal && signal.aborted) throw asError(signal.reason);
|
|
206
|
+
throw new DirectoryBrowseError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`);
|
|
207
|
+
}
|
|
208
|
+
const entries = [];
|
|
209
|
+
let truncated = evicted;
|
|
210
|
+
for (const candidate of window) {
|
|
211
|
+
if (signal) signal.throwIfAborted();
|
|
212
|
+
const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal);
|
|
213
|
+
if (row === null) continue;
|
|
214
|
+
if (entries.length === maxEntries) {
|
|
215
|
+
truncated = true;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
entries.push(row);
|
|
219
|
+
}
|
|
220
|
+
return { path: target, home, crumbs: ancestryCrumbs(target), entries, truncated };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 在 `path` 下建一个名为 `name` 的子目录。
|
|
225
|
+
*
|
|
226
|
+
* @returns 建出来的目录路径
|
|
227
|
+
*/
|
|
228
|
+
async function createDirectory(path, name) {
|
|
229
|
+
if (!fullyQualified(path)) {
|
|
230
|
+
throw new DirectoryBrowseError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`);
|
|
231
|
+
}
|
|
232
|
+
const parent = resolve(path);
|
|
233
|
+
if (typeof name !== 'string' || name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
|
|
234
|
+
throw new DirectoryBrowseError('directory-create-failed', join(parent, String(name)), `"${name}" is not a single path segment`);
|
|
235
|
+
}
|
|
236
|
+
const target = join(parent, name);
|
|
237
|
+
try {
|
|
238
|
+
await mkdir(target);
|
|
239
|
+
return target;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
242
|
+
if (code === 'EEXIST') throw new DirectoryBrowseError('directory-exists', target, `${target} already exists`);
|
|
243
|
+
throw new DirectoryBrowseError('directory-create-failed', target, `cannot create ${target}: ${messageOf(error)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 本地实现抛的 code → 宿主 browse 后端那套 wire 码(App 侧已按这几个码写文案)。 */
|
|
248
|
+
const WIRE_CODES = Object.freeze({
|
|
249
|
+
'directory-unreadable': 'directory-picker/unreadable',
|
|
250
|
+
'directory-exists': 'directory-picker/exists',
|
|
251
|
+
'directory-create-failed': 'directory-picker/create-failed',
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/** 把本地实现的抛出值翻成宿主那套 `{ code, message, path }` 错误体。 */
|
|
255
|
+
function wireError(error) {
|
|
256
|
+
const local = error instanceof DirectoryBrowseError ? error.code : 'directory-create-failed';
|
|
257
|
+
return {
|
|
258
|
+
code: WIRE_CODES[local] || 'directory-picker/create-failed',
|
|
259
|
+
message: messageOf(error),
|
|
260
|
+
path: error && error.path,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 宿主当前组合出的目录选择器是否服务 `browse` 能力。
|
|
266
|
+
*
|
|
267
|
+
* 只认「同进程直查得到、且明确报 browse」这一种肯定答案:拿不到服务(老宿主、被隔离的
|
|
268
|
+
* 上下文)时返回 false 也不影响正确性 —— 那只是让我们用自己这份实现,形状与宿主一致。
|
|
269
|
+
*/
|
|
270
|
+
function hostServesBrowse(ctx, picker) {
|
|
271
|
+
try {
|
|
272
|
+
const source = picker || (ctx && typeof ctx.get === 'function' ? ctx.get('directoryPicker') : null);
|
|
273
|
+
if (!source || typeof source.capability !== 'function') return false;
|
|
274
|
+
const capability = source.capability();
|
|
275
|
+
return !!capability && capability.kind === 'browse';
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = {
|
|
282
|
+
DEFAULT_MAX_ENTRIES,
|
|
283
|
+
DirectoryBrowseError,
|
|
284
|
+
WIRE_CODES,
|
|
285
|
+
ancestryCrumbs,
|
|
286
|
+
asError,
|
|
287
|
+
boundedInsert,
|
|
288
|
+
createDirectory,
|
|
289
|
+
fullyQualified,
|
|
290
|
+
homeDirectory,
|
|
291
|
+
hostServesBrowse,
|
|
292
|
+
listDirectory,
|
|
293
|
+
raceAbort,
|
|
294
|
+
wireError,
|
|
295
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 宿主 apiProxy 的回执信封。
|
|
5
|
+
*
|
|
6
|
+
* 宿主所有 `apiProxy.*` 调用都回同一个形状:
|
|
7
|
+
* 成功 `{ result: { ok: true, value } }`
|
|
8
|
+
* 失败 `{ result: { ok: false, error: { code, message, details? } } }`
|
|
9
|
+
* 异常(只有 envelope.js 之外的调用方会碰到)`{ result: { ok: true, value } }` 的缺位形态
|
|
10
|
+
*
|
|
11
|
+
* 以前这个拆信封的动作在仓库里有 6 种写法:history-derive.js / workspaces.js /
|
|
12
|
+
* agent-presets.js 三份逐字相同;session-cleanup.cjs 是「只取 value」的变体;
|
|
13
|
+
* plugins.js / references.js 在此之上又叠了各自的字段校验与错误码
|
|
14
|
+
* (那两个是有意的领域解码,不算重复);auto-archive.cjs 则干脆绕过信封直接读
|
|
15
|
+
* `response?.value?.items`——它读的那一层压根不是信封,是 bug。
|
|
16
|
+
*
|
|
17
|
+
* 把唯一实现收到这里,是因为 5 个模块为了拿这 5 行去 require 一个 1400 行、
|
|
18
|
+
* 带 9 个模块级 WeakMap 的 sessions.js——那才是 sessions.js 拆不动的真正原因:
|
|
19
|
+
* 它同时扮演了会话领域、缓存中心、RPC 封装和公共工具库四个角色。
|
|
20
|
+
*
|
|
21
|
+
* 依赖方向:envelope.js 是叶子模块,不 require 任何东西。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 拆信封,返回 `{ value }` 或 `{ error }`——**永远只返回其中一个键**,
|
|
26
|
+
* 调用方要么解构 `const { value, error } = unwrap(r)` 再判 `error`,
|
|
27
|
+
* 要么直接用 `unwrap(r).value`(已知成功路径)。
|
|
28
|
+
*
|
|
29
|
+
* 空响应与「宿主没给出 code」都归一成 `{ code, message }` 的形状,
|
|
30
|
+
* 免得每个 handler 各写一遍兜底文案。
|
|
31
|
+
*/
|
|
32
|
+
function unwrap(resp) {
|
|
33
|
+
if (!resp || !resp.result) return { error: { code: 'internal', message: '空响应' } };
|
|
34
|
+
if (!resp.result.ok) return { error: resp.result.error || { code: 'unknown', message: '未知错误' } };
|
|
35
|
+
return { value: resp.result.value };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 只要值的变体:失败或空响应一律返回 null。
|
|
40
|
+
*
|
|
41
|
+
* 用在「拿不到就当没有」的只读旁路(自动归档扫描、清理任务的会话清单)——
|
|
42
|
+
* 这些路径宁可什么都不做,也不该把错误抛给一个没人看日志的定时任务。
|
|
43
|
+
* 注意它与 unwrap 的差别是**刻意的**:这里不产出 error 对象。
|
|
44
|
+
*/
|
|
45
|
+
function envelopeValue(resp) {
|
|
46
|
+
const result = resp && resp.result;
|
|
47
|
+
if (!result || result.ok !== true) return null;
|
|
48
|
+
return result.value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { unwrap, envelopeValue };
|