virlen-remote 0.5.0 → 0.7.0
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/CHANGELOG.md +373 -169
- package/README.md +389 -201
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/protocol/agents.d.ts +14 -0
- package/dist/protocol/agents.d.ts.map +1 -0
- package/dist/protocol/agents.js +14 -0
- package/dist/protocol/agents.js.map +1 -0
- package/dist/protocol/api.d.ts +370 -1
- package/dist/protocol/api.d.ts.map +1 -1
- package/dist/protocol/api.js.map +1 -1
- package/dist/protocol/compress.d.ts +55 -0
- package/dist/protocol/compress.d.ts.map +1 -0
- package/dist/protocol/compress.js +60 -0
- package/dist/protocol/compress.js.map +1 -0
- package/dist/protocol/files.d.ts +261 -0
- package/dist/protocol/files.d.ts.map +1 -0
- package/dist/protocol/files.js +506 -0
- package/dist/protocol/files.js.map +1 -0
- package/dist/protocol/host.d.ts +24 -1
- package/dist/protocol/host.d.ts.map +1 -1
- package/dist/protocol/host.js +9 -1
- package/dist/protocol/host.js.map +1 -1
- package/dist/protocol/message-files.d.ts +79 -0
- package/dist/protocol/message-files.d.ts.map +1 -0
- package/dist/protocol/message-files.js +118 -0
- package/dist/protocol/message-files.js.map +1 -0
- package/dist/protocol/tool-args.d.ts +95 -0
- package/dist/protocol/tool-args.d.ts.map +1 -0
- package/dist/protocol/tool-args.js +439 -0
- package/dist/protocol/tool-args.js.map +1 -0
- package/dist/testing/mock-host.d.ts +44 -1
- package/dist/testing/mock-host.d.ts.map +1 -1
- package/dist/testing/mock-host.js +439 -10
- package/dist/testing/mock-host.js.map +1 -1
- package/dist/transport/rtc.d.ts +17 -0
- package/dist/transport/rtc.d.ts.map +1 -1
- package/dist/transport/rtc.js +49 -12
- package/dist/transport/rtc.js.map +1 -1
- package/dist/transport/signaling.d.ts +12 -1
- package/dist/transport/signaling.d.ts.map +1 -1
- package/dist/transport/signaling.js +17 -3
- package/dist/transport/signaling.js.map +1 -1
- package/package.json +69 -69
- package/src/index.ts +270 -184
- package/src/protocol/agents.ts +13 -0
- package/src/protocol/api.ts +974 -615
- package/src/protocol/compress.ts +65 -0
- package/src/protocol/files.ts +544 -0
- package/src/protocol/host.ts +205 -165
- package/src/protocol/message-actions.ts +60 -60
- package/src/protocol/message-files.ts +161 -0
- package/src/protocol/pairing.ts +254 -254
- package/src/protocol/tool-args.ts +478 -0
- package/src/testing/mock-host.ts +1285 -740
- package/src/transport/link-kind.ts +222 -222
- package/src/transport/rtc.ts +381 -344
- package/src/transport/signaling.ts +331 -317
- package/src/transport/transfer-tier.ts +64 -64
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上下文压缩方式 —— **两端共用的契约**:取值域 + 能力名。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么单独一份
|
|
5
|
+
*
|
|
6
|
+
* 「用一次模型调用把历史浓缩成摘要」还是「把整段历史本地重排成一段文本」是**两端各表一半**
|
|
7
|
+
* 的约定:手机端给出选择(`CompressParams.mode`),电脑端执行(Rust `CompressMode`,
|
|
8
|
+
* 落点 `virlen_core::agent::compress`);产物的差异还要能被界面说清
|
|
9
|
+
* (`ui_data.compressMode`,桌面 `summary-message.tsx` 据此显示「AI 摘要」/「正文压缩」)。
|
|
10
|
+
* 取值域写两份必然漂移 —— 一边 `'summary'`、一边 `'ai'`,而这类不匹配**不报错**,
|
|
11
|
+
* 只表现为「选了没反应」或「压缩方式记成了错的」。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ 本模块只放**取值域与能力名**,不放用户可见文案:文案归各端 i18n(桌面
|
|
14
|
+
* `src/ui/i18n`,手机端是硬编码中文 UI),共享包是零运行时依赖的协议层。
|
|
15
|
+
*
|
|
16
|
+
* ## 两种方式的实际差别(消费方据此写提示,别写反)
|
|
17
|
+
*
|
|
18
|
+
* - `ai`:一次**非流式模型调用**(`prompts/compress-context.md`),最省 token,但慢、且**要花钱**;
|
|
19
|
+
* 模型违约(调工具 / 空正文)时电脑侧回退 `raw`,但那次调用**照常记账** —— 所以
|
|
20
|
+
* 「这次到底花了钱没有」不能只看产物形态。
|
|
21
|
+
* - `raw`:纯本地渲染(`compress/raw.rs`),毫秒级零消耗;用户 / 助手正文**一字不删**,
|
|
22
|
+
* 只丢深度思考并省略超长工具参数与工具输出 —— 所以产物**可能比 `ai` 长得多**(可达数万字符)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** 压缩方式取值(与 Rust `CompressMode::as_str()`、`ui_data.compressMode` 逐字一致)。 */
|
|
26
|
+
export type CompressMode = 'ai' | 'raw'
|
|
27
|
+
|
|
28
|
+
/** 全量取值(顺序 = 界面展示顺序,与 Rust `CompressMode::ALL` 一致:`ai` 在前)。 */
|
|
29
|
+
export const COMPRESS_MODES: readonly CompressMode[] = ['ai', 'raw']
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 请求里**没传** `mode` 时电脑侧实际用哪种 —— 与桌面设置项 `contextCompressMode` 的默认值一致。
|
|
33
|
+
*
|
|
34
|
+
* 消费方注意:这是**缺省**,不是「手机端该默认选它」—— 手机端在旧电脑端(没有下面那个能力名)
|
|
35
|
+
* 上根本不该传 `mode`,此时真正生效的是**电脑侧设置里的那一档**,手机端无从得知。
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_COMPRESS_MODE: CompressMode = 'ai'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 解析压缩方式(**容忍大小写与首尾空白**,与 Rust `CompressMode::parse` 同宽容度)。
|
|
41
|
+
*
|
|
42
|
+
* 为什么返回 `null` 而不是抛错:调用方是「电脑侧 handler 校验手机传来的参数」,它需要区分
|
|
43
|
+
* 「没传」(用缺省)与「传了但不认识」(`E_BAD_REQUEST`)两种情形;手机端则完全不需要运行时
|
|
44
|
+
* 校验 —— 它的取值来自自己的类型化 UI(`CompressParams.mode` 已是 `CompressMode`)。
|
|
45
|
+
*/
|
|
46
|
+
export function compressModeOf(value: unknown): CompressMode | null {
|
|
47
|
+
if (typeof value !== 'string') return null
|
|
48
|
+
const normalized = value.trim().toLowerCase()
|
|
49
|
+
return (COMPRESS_MODES as readonly string[]).includes(normalized)
|
|
50
|
+
? (normalized as CompressMode)
|
|
51
|
+
: null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 手机端声明它 = 「本端会给用户两种压缩方式的选择,并把选择放进 `CompressParams.mode`」;
|
|
56
|
+
* 电脑端列出它 = 「本机认识 `CompressParams.mode`,会照它执行」。
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ 这是**功能标记**,不是权限(与 §36 的 `MESSAGE_QUOTE_CAPABILITY` 同类):压缩本身的授权
|
|
59
|
+
* 仍是 `session.compress`(破坏性、需 `confirm:true`,电脑侧独立 `assert`),本能力名只回答
|
|
60
|
+
* 「本机认不认这个参数」。为什么非得有这么一道闸:**已部署的旧电脑端不认 `mode`** ——
|
|
61
|
+
* 它是普通字段,RPC 会正常成功,然后按**电脑侧设置里的方式**压缩。用户侧表现是
|
|
62
|
+
* 「我明明点了『正文压缩』,结果还是走了 AI 摘要(还花了钱)」,且没有任何报错可查。
|
|
63
|
+
* 所以手机端在旧电脑端上**只给一个「压缩上下文」按钮**(走电脑侧设置),不给选择器。
|
|
64
|
+
*/
|
|
65
|
+
export const COMPRESS_MODE_CAPABILITY = 'session.compress.mode'
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作目录文件访问的**共享契约** —— 「手机端看电脑上的文件」这件事的口径只有这一份。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单开一个文件(而不是塞进 `api.ts`):这里的东西**两端都要用同一份**,且大多与
|
|
5
|
+
* RPC 形状无关 —— 预览分类、分块大小、base64 编解码、相对路径工具。两端口径一旦分叉,
|
|
6
|
+
* 症状是「电脑说这是文本、手机按二进制显示」这类谁也说不清的错位。
|
|
7
|
+
*
|
|
8
|
+
* ## 分工(谁说话算数)
|
|
9
|
+
*
|
|
10
|
+
* - **电脑侧**是权威:文件类型、MIME、大小、是否存在、最终落盘名,全由它给;
|
|
11
|
+
* - **手机侧不猜**:不按扩展名自己挑渲染器以外的判断(分类函数在两边跑同一份,只是为了让
|
|
12
|
+
* 手机能在**下载**时判断「多大才值得下」,以及给出行内文案);
|
|
13
|
+
* - **安全边界不在本文件**:真正确认「这个路径能不能碰」的是电脑侧的
|
|
14
|
+
* `securityService.resolveSafePath`(会话工作目录 + 黑白名单)。这里的路径工具只负责
|
|
15
|
+
* *把手机传来的相对路径规整成可控的形状*(去掉 `\\`、`./`、前导 `/` 与 `..` 段)。
|
|
16
|
+
*
|
|
17
|
+
* ## 传输形态(为什么是 base64 分块)
|
|
18
|
+
*
|
|
19
|
+
* 帧层是「12KB 分片的 UTF-8 JSON」(见 `frame.ts`),**没有二进制帧**。一条逻辑消息要
|
|
20
|
+
* 攒齐所有分片才会交付给上层,所以把整个文件塞进一次 RPC = 让对端在内存里拼一个巨型字符串。
|
|
21
|
+
* 于是文件字节走**应用层分块**:每次 RPC 传 `FILE_CHUNK_BYTES` 字节(base64 后 ~350KB 文本),
|
|
22
|
+
* 手机端拼成 Blob。代价是 base64 的 33% 开销;换来的是:进度可见、随时可取消、
|
|
23
|
+
* 单次请求的内存上界固定、且**不需要改帧格式**(不必升协议主版本)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ───────────────────────────── 能力名 ─────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 列目录 + 读文件元信息(`host.file.list`)。
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ 三个能力名里**它是最低门槛**(要能下载就得先能列目录),但不合并成一个:
|
|
32
|
+
* 「只看不改」与「能往我项目里写文件」是两种授权强度,用户可能只想给前者。
|
|
33
|
+
*/
|
|
34
|
+
export const FILE_BROWSE_CAPABILITY = 'file.browse'
|
|
35
|
+
/** 读取文件内容(`host.file.read`,预览与下载都走它)。 */
|
|
36
|
+
export const FILE_DOWNLOAD_CAPABILITY = 'file.download'
|
|
37
|
+
/** 上传(`host.file.write.*`)—— 往工作目录里**新增**一个文件,与只读两档分开。 */
|
|
38
|
+
export const FILE_UPLOAD_CAPABILITY = 'file.upload'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* **改写已有文件**(编辑保存:`host.file.write.begin` 带 `overwrite: true`)。
|
|
42
|
+
*
|
|
43
|
+
* 为何与 `file.upload` 分开:两者改动的对象不同。上传最坏只是让用户的项目里**多出一个文件**
|
|
44
|
+
* (同名时甚至会自动改成「 - 副本」);而这里是**把已有文件的内容换掉** —— 那个文件可能是源码,
|
|
45
|
+
* 也可能 AI 正在写它。于是「只让手机传照片、不让手机改代码」是一种该被支持的授权。
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ 它同时是**功能标记**:旧电脑端不认识 `overwrite` 字段(会静默忽略它),一次覆盖保存就会
|
|
48
|
+
* 退化成「另存为 - 副本」—— 用户以为改了、其实原文件一动没动。所以手机端**只在电脑端声明了
|
|
49
|
+
* 这个能力名时才显示「编辑」入口**:缺了它就没有「静默做错一件事」的路径。
|
|
50
|
+
*/
|
|
51
|
+
export const FILE_EDIT_CAPABILITY = 'file.edit'
|
|
52
|
+
|
|
53
|
+
// ───────────────────────────── 限额(两端同一个数) ─────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 单次 `host.file.read` / `host.file.write.chunk` 的**原始字节**上限。
|
|
57
|
+
*
|
|
58
|
+
* 为什么是 256KB:base64 后约 350KB 文本,一次 RPC 的 JSON 载荷约 350KB —— 帧层会分成
|
|
59
|
+
* ~30 个 12KB 分片,两端都能平静地处理;再大(如 1MB)单帧组装的峰值内存与手机的
|
|
60
|
+
* `JSON.parse` 都开始难受,而且**一点网络抖动就要重传整块**。
|
|
61
|
+
*/
|
|
62
|
+
export const FILE_CHUNK_BYTES = 256 * 1024
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 单文件**上传**上限(32MB)。
|
|
66
|
+
*
|
|
67
|
+
* 不是技术天花板(分块本身没有硬上限),而是产品口径:手机上的照片/文档几乎都在这个量级内,
|
|
68
|
+
* 再大的文件用手机传是在浪费两边的电和流量 —— 且中继链路下我们本来就拒绝该功能。
|
|
69
|
+
* 超限在 `write.begin` 就拒(而不是传到一半才说),手机端因此能在**开始前**就告诉用户。
|
|
70
|
+
*/
|
|
71
|
+
export const FILE_UPLOAD_MAX_BYTES = 32 * 1024 * 1024
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 文本 / Markdown / 代码预览上限(1MB)。
|
|
75
|
+
*
|
|
76
|
+
* 超过它的「文本」在手机上没有阅读价值(几万行代码刷在 6 寸屏上),却要花掉整条链路的
|
|
77
|
+
* 时间与流量。超限不是错误:文件照旧可以**下载**,只是不给行内预览。
|
|
78
|
+
*/
|
|
79
|
+
export const FILE_TEXT_PREVIEW_MAX_BYTES = 1024 * 1024
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 文本 / Markdown / 代码**编辑**上限(256KB)。
|
|
83
|
+
*
|
|
84
|
+
* 比预览上限(1MB)小,是手机侧的务实取舍:预览只需「渲染出来看」,而编辑要把全文塞进一个
|
|
85
|
+
* `<textarea>`,保存时还要按分块回传 —— 1MB 的文本在手机上敲一个字都开始卡,一次保存也是
|
|
86
|
+
* 4 次往返。超限不是错误:文件照旧能预览 / 下载,只是不给编辑入口(「请在电脑上改」)。
|
|
87
|
+
*/
|
|
88
|
+
export const FILE_EDIT_MAX_BYTES = 256 * 1024
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 图片预览上限(8MB)。
|
|
92
|
+
*
|
|
93
|
+
* 手机上直接渲染整张图,超限的图在移动端内存里放大/解码会直接崩掉标签页(且一眼看不出全貌)。
|
|
94
|
+
*/
|
|
95
|
+
export const FILE_IMAGE_PREVIEW_MAX_BYTES = 8 * 1024 * 1024
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 一次目录列举返回的条目上限。
|
|
99
|
+
*
|
|
100
|
+
* 目录里有几万项时,手机上的列表(与这条 RPC)都没意义;到顶就截断并让手机端如实提示
|
|
101
|
+
* (`FileListResult.truncated`),而不是静默只给一部分。
|
|
102
|
+
*/
|
|
103
|
+
export const FILE_LIST_MAX_ENTRIES = 500
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 上传文件名的长度上限(字符)。
|
|
107
|
+
*
|
|
108
|
+
* 取 200 而不是 255:留出电脑侧冲突消解后缀(` - 副本 (99)`)的余量,避免「改名后反而超长」。
|
|
109
|
+
*/
|
|
110
|
+
export const FILE_NAME_MAX_LEN = 200
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 上传临时文件的后缀。
|
|
114
|
+
*
|
|
115
|
+
* 落盘纪律:**先写临时文件,`finish` 才改名到目标**(见 `host.file.write.*`)。
|
|
116
|
+
* 于是「传输中断 / 用户取消」不会在用户的项目里留下一个**半截的真文件** ——
|
|
117
|
+
* 用户看到的只会是一个明显是临时产物的名字,而不是一个「打开是坏的文件」。
|
|
118
|
+
*/
|
|
119
|
+
export const UPLOAD_PART_SUFFIX = '.virlen-part'
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 「只能直连」时给用户的那句话(**两端同一句**)。
|
|
123
|
+
*
|
|
124
|
+
* 为什么写进共享包:电脑端拒绝时要报它、手机端置灰入口时也要显示它。各写一句的后果是
|
|
125
|
+
* 「手机上写着因为中继、电脑日志里写着别的原因」——而用户只会以为其中一个是 bug。
|
|
126
|
+
*/
|
|
127
|
+
export const FILE_DIRECT_ONLY_MESSAGE =
|
|
128
|
+
'文件传输需要直连链路(当前经 TURN 中继服务器转发):请让手机与电脑处于同一 WiFi 后重试'
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 链路通讯类型 → 「能不能传文件」(**两端唯一口径**)。
|
|
132
|
+
*
|
|
133
|
+
* 口径(用户 2026-10 拍板):**只在确认走了 TURN 中继时拒绝**。
|
|
134
|
+
*
|
|
135
|
+
* - `relay` → 拒:每个字节都要过服务器绕一圈(手机流量 + 服务器带宽),而文件动辄几 MB
|
|
136
|
+
* —— 这正是 `TransferTier` 当初把消息正文砍成 `lean` 的同一笔账,只是量级差三个数量级;
|
|
137
|
+
* - `direct` → 放行;
|
|
138
|
+
* - `unknown` → **也放行**。这里与 `transferTierOf` 的取向**有意不同**:那边「拿不准就少发」
|
|
139
|
+
* 的代价只是内容少一段(且有 `detail:'omitted'` 如实告知),这边「拿不准就不给用」的代价是
|
|
140
|
+
* **功能直接消失** —— 而 `unknown` 是常态(同源 Broadcast 联调、非 WebRTC 链路、
|
|
141
|
+
* 刚打通还没定形的头几秒)。把常态判成禁用,用户看到的是「坏掉」,不是「保守」。
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ 所以本函数**不是**权限:真正的授权是 ACL 的三个能力位。它只回答「这条链路值不值得传大文件」。
|
|
144
|
+
* 消费方:电脑端在 `host.file.*` 入口调(拒绝时如实报 `E_DENIED`),手机端据此置灰入口。
|
|
145
|
+
*/
|
|
146
|
+
export function fileTransferDeniedReason(linkKind: string | null | undefined): string | null {
|
|
147
|
+
return linkKind === 'relay' ? FILE_DIRECT_ONLY_MESSAGE : null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ───────────────────────────── 预览分类 ─────────────────────────────
|
|
151
|
+
|
|
152
|
+
/** 手机端能行内渲染的类别;`binary` = 只能下载。 */
|
|
153
|
+
export type FilePreviewKind = 'text' | 'markdown' | 'image' | 'binary'
|
|
154
|
+
|
|
155
|
+
const IMAGE_EXTS = new Set([
|
|
156
|
+
'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif', 'svg',
|
|
157
|
+
])
|
|
158
|
+
|
|
159
|
+
const MARKDOWN_EXTS = new Set(['md', 'markdown', 'mdx'])
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 文本类扩展名(代码 / 配置 / 数据 / 文档)。
|
|
163
|
+
*
|
|
164
|
+
* 刻意**不收** `.pdf` / `.docx` / `.zip` 等二进制容器:它们在手机上要专门的渲染器,
|
|
165
|
+
* 而「下载到手机再用别处打开」本来就是更好的路径。
|
|
166
|
+
*/
|
|
167
|
+
const TEXT_EXTS = new Set([
|
|
168
|
+
// 代码
|
|
169
|
+
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'vue', 'svelte',
|
|
170
|
+
'py', 'rb', 'php', 'go', 'rs', 'java', 'kt', 'kts', 'swift', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs',
|
|
171
|
+
'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd', 'sql', 'lua', 'dart', 'scala', 'clj',
|
|
172
|
+
'r', 'pl', 'ex', 'exs', 'erl', 'hs', 'ml', 'nim', 'zig', 'asm', 's',
|
|
173
|
+
// 标记 / 样式 / 数据
|
|
174
|
+
'html', 'htm', 'css', 'scss', 'sass', 'less', 'styl', 'xml', 'svg',
|
|
175
|
+
'json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'properties', 'env',
|
|
176
|
+
'csv', 'tsv', 'txt', 'log', 'diff', 'patch', 'graphql', 'gql', 'proto', 'http',
|
|
177
|
+
// 文档 / 脚手架
|
|
178
|
+
'tex', 'rst', 'adoc', 'org',
|
|
179
|
+
])
|
|
180
|
+
|
|
181
|
+
/** 没有扩展名但**必然是文本**的常见文件名(大小写不敏感,前导点忽略)。 */
|
|
182
|
+
const TEXT_FILENAMES = new Set([
|
|
183
|
+
'makefile', 'dockerfile', 'jenkinsfile', 'vagrantfile', 'procfile', 'brewfile',
|
|
184
|
+
'license', 'licence', 'readme', 'changelog', 'notice', 'authors', 'contributing',
|
|
185
|
+
'gitignore', 'gitattributes', 'gitmodules', 'npmrc', 'nvmrc', 'editorconfig',
|
|
186
|
+
'env', 'envrc', 'babelrc', 'eslintrc', 'prettierrc', 'stylelintrc', 'browserslistrc',
|
|
187
|
+
'lock', 'cmakelists.txt', 'go.mod', 'go.sum',
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
/** 拆扩展名(`a.tar.gz` 只认最后一段;与 `file-transfer-service` 同名函数同口径)。 */
|
|
191
|
+
export function splitNameExt(name: string): { base: string; ext: string } {
|
|
192
|
+
const dot = name.lastIndexOf('.')
|
|
193
|
+
// 前导点(`.gitignore`)不算扩展名 —— 那整个名字就是「文件名」
|
|
194
|
+
if (dot <= 0) return { base: name, ext: '' }
|
|
195
|
+
return { base: name.slice(0, dot), ext: name.slice(dot + 1) }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 文件名 → 预览类别(**两端唯一口径**)。
|
|
200
|
+
*
|
|
201
|
+
* 判不出来(未知扩展名 / 无扩展名且不在白名单)一律按 `binary` —— 保守侧是「只给下载」:
|
|
202
|
+
* 把二进制按文本渲染会得到一屏乱码,比「请下载」难受得多。
|
|
203
|
+
*/
|
|
204
|
+
export function previewKindOf(name: string): FilePreviewKind {
|
|
205
|
+
const trimmed = (name || '').trim()
|
|
206
|
+
if (!trimmed) return 'binary'
|
|
207
|
+
const { base, ext } = splitNameExt(trimmed)
|
|
208
|
+
if (IMAGE_EXTS.has(ext.toLowerCase())) return 'image'
|
|
209
|
+
if (MARKDOWN_EXTS.has(ext.toLowerCase())) return 'markdown'
|
|
210
|
+
if (TEXT_EXTS.has(ext.toLowerCase())) return 'text'
|
|
211
|
+
// 无扩展名时看整个名字(`Makefile` / `.gitignore`)——**只有**这一种情形才允许按名字判
|
|
212
|
+
if (!ext) {
|
|
213
|
+
const bare = trimmed.replace(/^\.+/, '').toLowerCase()
|
|
214
|
+
if (TEXT_FILENAMES.has(trimmed.toLowerCase()) || TEXT_FILENAMES.has(bare)) return 'text'
|
|
215
|
+
if (TEXT_FILENAMES.has(base.toLowerCase())) return 'text'
|
|
216
|
+
}
|
|
217
|
+
return 'binary'
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 该类别的行内预览字节上限(`binary` = 0,即不预览)。 */
|
|
221
|
+
export function previewLimitOf(kind: FilePreviewKind): number {
|
|
222
|
+
if (kind === 'image') return FILE_IMAGE_PREVIEW_MAX_BYTES
|
|
223
|
+
if (kind === 'text' || kind === 'markdown') return FILE_TEXT_PREVIEW_MAX_BYTES
|
|
224
|
+
return 0
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
228
|
+
png: 'image/png',
|
|
229
|
+
jpg: 'image/jpeg',
|
|
230
|
+
jpeg: 'image/jpeg',
|
|
231
|
+
gif: 'image/gif',
|
|
232
|
+
webp: 'image/webp',
|
|
233
|
+
bmp: 'image/bmp',
|
|
234
|
+
ico: 'image/x-icon',
|
|
235
|
+
avif: 'image/avif',
|
|
236
|
+
svg: 'image/svg+xml',
|
|
237
|
+
md: 'text/markdown',
|
|
238
|
+
markdown: 'text/markdown',
|
|
239
|
+
mdx: 'text/markdown',
|
|
240
|
+
html: 'text/html',
|
|
241
|
+
htm: 'text/html',
|
|
242
|
+
css: 'text/css',
|
|
243
|
+
scss: 'text/x-scss',
|
|
244
|
+
less: 'text/x-less',
|
|
245
|
+
json: 'application/json',
|
|
246
|
+
jsonc: 'application/json',
|
|
247
|
+
json5: 'application/json',
|
|
248
|
+
yaml: 'text/yaml',
|
|
249
|
+
yml: 'text/yaml',
|
|
250
|
+
toml: 'text/x-toml',
|
|
251
|
+
xml: 'text/xml',
|
|
252
|
+
csv: 'text/csv',
|
|
253
|
+
tsv: 'text/tab-separated-values',
|
|
254
|
+
pdf: 'application/pdf',
|
|
255
|
+
zip: 'application/zip',
|
|
256
|
+
gz: 'application/gzip',
|
|
257
|
+
tar: 'application/x-tar',
|
|
258
|
+
txt: 'text/plain',
|
|
259
|
+
log: 'text/plain',
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* MIME(给手机端决定 `<img src>` / Blob 类型用)。
|
|
264
|
+
*
|
|
265
|
+
* 判不出就给 `application/octet-stream` —— 浏览器遇到未知类型会**下载**而不是渲染,
|
|
266
|
+
* 这正是「binary 只给下载」想要的行为。
|
|
267
|
+
*/
|
|
268
|
+
export function mimeTypeOf(name: string): string {
|
|
269
|
+
const { ext } = splitNameExt((name || '').trim())
|
|
270
|
+
return MIME_BY_EXT[ext.toLowerCase()] ?? 'application/octet-stream'
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ───────────────────────────── base64(零依赖纯函数) ─────────────────────────────
|
|
274
|
+
|
|
275
|
+
const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
276
|
+
|
|
277
|
+
const B64_LOOKUP: Int16Array = (() => {
|
|
278
|
+
const table = new Int16Array(128).fill(-1)
|
|
279
|
+
for (let i = 0; i < B64_ALPHABET.length; i++) table[B64_ALPHABET.charCodeAt(i)] = i
|
|
280
|
+
return table
|
|
281
|
+
})()
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 字节 → 标准 base64(带 `=` 补齐,无换行)。
|
|
285
|
+
*
|
|
286
|
+
* 为什么不用 `btoa`:它只吃 latin1 字符串,喂 `Uint8Array` 前得先拼一个 `String.fromCharCode`
|
|
287
|
+
* 的巨型字符串(几 MB 的 `apply` 参数会直接爆栈);而 `btoa` 在 Node 与浏览器里的可用性
|
|
288
|
+
* 也不一致(本包要在 Node 侧跑测试)。这里自己实现,两端同一条码路。
|
|
289
|
+
*/
|
|
290
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
291
|
+
const n = bytes.length
|
|
292
|
+
const parts: string[] = []
|
|
293
|
+
let i = 0
|
|
294
|
+
for (; i + 2 < n; i += 3) {
|
|
295
|
+
const v = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]
|
|
296
|
+
parts.push(
|
|
297
|
+
B64_ALPHABET[(v >> 18) & 63] +
|
|
298
|
+
B64_ALPHABET[(v >> 12) & 63] +
|
|
299
|
+
B64_ALPHABET[(v >> 6) & 63] +
|
|
300
|
+
B64_ALPHABET[v & 63],
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
const rest = n - i
|
|
304
|
+
if (rest === 1) {
|
|
305
|
+
const v = bytes[i] << 16
|
|
306
|
+
parts.push(B64_ALPHABET[(v >> 18) & 63] + B64_ALPHABET[(v >> 12) & 63] + '==')
|
|
307
|
+
} else if (rest === 2) {
|
|
308
|
+
const v = (bytes[i] << 16) | (bytes[i + 1] << 8)
|
|
309
|
+
parts.push(
|
|
310
|
+
B64_ALPHABET[(v >> 18) & 63] +
|
|
311
|
+
B64_ALPHABET[(v >> 12) & 63] +
|
|
312
|
+
B64_ALPHABET[(v >> 6) & 63] +
|
|
313
|
+
'=',
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
return parts.join('')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* base64 → 字节。
|
|
321
|
+
*
|
|
322
|
+
* 宽容 **`=` 补位缺失**(部分实现会省略),但不宽容非法字符 —— 静默把坏字符当 0 会让
|
|
323
|
+
* 「文件损坏」变成一个查不出来的谜。长度不对时抛错,由调用方包成协议错误。
|
|
324
|
+
*/
|
|
325
|
+
export function base64ToBytes(text: string): Uint8Array {
|
|
326
|
+
const clean = text.trim()
|
|
327
|
+
if (clean.length === 0) return new Uint8Array(0)
|
|
328
|
+
let pad = 0
|
|
329
|
+
let end = clean.length
|
|
330
|
+
while (end > 0 && clean.charCodeAt(end - 1) === 61 /* '=' */) {
|
|
331
|
+
pad++
|
|
332
|
+
end--
|
|
333
|
+
if (pad > 2) throw new Error('base64: too much padding')
|
|
334
|
+
}
|
|
335
|
+
const dataLen = end
|
|
336
|
+
const size = Math.floor((dataLen * 3) / 4)
|
|
337
|
+
const rest = dataLen % 4
|
|
338
|
+
if (rest === 1) throw new Error('base64: invalid length')
|
|
339
|
+
const out = new Uint8Array(size)
|
|
340
|
+
let outIndex = 0
|
|
341
|
+
let acc = 0
|
|
342
|
+
let accBits = 0
|
|
343
|
+
for (let i = 0; i < dataLen; i++) {
|
|
344
|
+
const code = clean.charCodeAt(i)
|
|
345
|
+
const value = code < 128 ? B64_LOOKUP[code] : -1
|
|
346
|
+
if (value < 0) throw new Error(`base64: invalid character at ${i}`)
|
|
347
|
+
acc = (acc << 6) | value
|
|
348
|
+
accBits += 6
|
|
349
|
+
if (accBits >= 8) {
|
|
350
|
+
accBits -= 8
|
|
351
|
+
out[outIndex++] = (acc >> accBits) & 0xff
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return outIndex === size ? out : out.subarray(0, outIndex)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ───────────────────────────── 展示辅助(两端同一个数) ─────────────────────────────
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 字节数 → 人话(`1.2 MB`)。
|
|
361
|
+
*
|
|
362
|
+
* 与电脑侧 `infrastructure/tools/file/common.ts::formatSize` **同一套进位**(1024 进制、
|
|
363
|
+
* 非 B 保留一位小数);那条是**给模型看的工具输出**,不能改,故这里是它的人话版兄弟。
|
|
364
|
+
*/
|
|
365
|
+
export function formatFileSize(bytes: number): string {
|
|
366
|
+
if (!Number.isFinite(bytes) || bytes < 0) return '—'
|
|
367
|
+
if (bytes === 0) return '0 B'
|
|
368
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
369
|
+
const k = 1024
|
|
370
|
+
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(k)))
|
|
371
|
+
return `${(bytes / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ───────────────────────────── 文本编辑(字节的往返) ─────────────────────────────
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 换行风格。
|
|
378
|
+
*
|
|
379
|
+
* 为何必须记住它:**手机上的 `<textarea>` 只有 LF**(HTML 规范要求换行进出一律规范化成 LF),
|
|
380
|
+
* 而 Windows 上的源码 / `.bat` / `.ps1` 大多是 CRLF。照 LF 存回去,等于在「只改了三个字符」的
|
|
381
|
+
* 改动里混进一次**全文行尾改写** —— diff 满屏红,评审时根本看不出到底改了什么。
|
|
382
|
+
*/
|
|
383
|
+
export type EolStyle = 'lf' | 'crlf'
|
|
384
|
+
|
|
385
|
+
/** 这个类别能不能编辑(纯文本 / 代码 / Markdown 源码);图片与二进制一律不能。 */
|
|
386
|
+
export function isEditableKind(kind: FilePreviewKind): boolean {
|
|
387
|
+
return kind === 'text' || kind === 'markdown'
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** 这个名字能不能编辑(= 它的预览类别可编辑 —— 与预览共用同一张扩展名表,不另开一份)。 */
|
|
391
|
+
export function isEditableFileName(name: string): boolean {
|
|
392
|
+
return isEditableKind(previewKindOf(name))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 原文的换行风格:**按多数**判(`\r\n` 多于单跑的 `\n` 才算 CRLF)。
|
|
397
|
+
*
|
|
398
|
+
* 跨平台协作下混用并不罕见,按多数至少不会把整篇都翻过去;一个换行都没有时给 `lf`。
|
|
399
|
+
*/
|
|
400
|
+
export function detectEolStyle(text: string): EolStyle {
|
|
401
|
+
let crlf = 0
|
|
402
|
+
let lf = 0
|
|
403
|
+
for (let i = 0; i < text.length; i++) {
|
|
404
|
+
if (text.charCodeAt(i) !== 10 /* \n */) continue
|
|
405
|
+
if (i > 0 && text.charCodeAt(i - 1) === 13 /* \r */) crlf++
|
|
406
|
+
else lf++
|
|
407
|
+
}
|
|
408
|
+
return crlf > lf ? 'crlf' : 'lf'
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 按指定风格铺开换行。
|
|
413
|
+
*
|
|
414
|
+
* ⚠️ 先把所有换行形式**拉平再铺**,不能只做 `\n → \r\n`:原文里本来就有的 CRLF 会变成
|
|
415
|
+
* `\r\r\n`,那个多出来的 `\r` 在编辑器里就是一个空行 —— 「保存一次多出一堆空行」的经典 bug。
|
|
416
|
+
*/
|
|
417
|
+
export function applyEolStyle(text: string, style: EolStyle): string {
|
|
418
|
+
const normalized = text.replace(/\r\n?/g, '\n')
|
|
419
|
+
return style === 'crlf' ? normalized.replace(/\n/g, '\r\n') : normalized
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 是否带 UTF-8 BOM(保存时原样保留:它是某些 Windows 工具识别编码的唯一依据)。 */
|
|
423
|
+
export function hasUtf8Bom(bytes: Uint8Array): boolean {
|
|
424
|
+
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* **严格** UTF-8 解码:不是合法 UTF-8(GBK 中文注释、二进制混入…)→ `null`。
|
|
429
|
+
*
|
|
430
|
+
* 为何不能宽容解码后再编辑:宽容解出来的乱码一旦被编辑保存回去,就是**把用户的文件毁掉**
|
|
431
|
+
* (原来「在电脑上还能正常看」,存完连电脑上也读不回来了)。所以拿到 `null` 就不给编辑入口,
|
|
432
|
+
* 只给「请在电脑上改」。
|
|
433
|
+
*
|
|
434
|
+
* BOM 在这里被剥掉(编辑区里不该有一个看不见的字符),保存时由 `encodeEditedText` 补回。
|
|
435
|
+
*/
|
|
436
|
+
export function decodeUtf8Strict(bytes: Uint8Array): string | null {
|
|
437
|
+
try {
|
|
438
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
439
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
440
|
+
} catch {
|
|
441
|
+
return null
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 编辑结果 → 落盘字节(**唯一**的编码出口:换行还原与 BOM 还原都在这里)。
|
|
447
|
+
*
|
|
448
|
+
* 于是两端不需要各写一份口径:手机端把「最终字节」传上去,电脑侧只管覆写。
|
|
449
|
+
*/
|
|
450
|
+
export function encodeEditedText(text: string, options: { eol: EolStyle; bom: boolean }): Uint8Array {
|
|
451
|
+
const body = new TextEncoder().encode(applyEolStyle(text, options.eol))
|
|
452
|
+
if (!options.bom) return body
|
|
453
|
+
const out = new Uint8Array(body.length + 3)
|
|
454
|
+
out.set([0xef, 0xbb, 0xbf], 0)
|
|
455
|
+
out.set(body, 3)
|
|
456
|
+
return out
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ───────────────────────────── 相对路径工具 ─────────────────────────────
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 规整手机传来的**相对工作目录**路径。
|
|
463
|
+
*
|
|
464
|
+
* 规则(与电脑侧 `resolveSafePath` 的「相对路径拼接」互补,不是重复校验):
|
|
465
|
+
* - `\\` → `/`;去掉前导 `/`(手机永远说相对路径,绝对路径一律不许);
|
|
466
|
+
* - 去掉 `.` 段与空段;`..` **就地抵消**上一段,抵消不掉的(逃到工作目录之上)**直接丢掉**;
|
|
467
|
+
* - 结果为空串 = 工作目录根。
|
|
468
|
+
*
|
|
469
|
+
* ⚠️ 这**不是**安全边界(安全边界在电脑侧):它只保证「手机说的路径」形状可控,
|
|
470
|
+
* 于是越权尝试会退化成一个**明确不存在的路径**(E_NOT_FOUND),而不是一个含糊的路径穿越事故。
|
|
471
|
+
*/
|
|
472
|
+
export function normalizeRelPath(input: string): string {
|
|
473
|
+
const raw = (input ?? '').replace(/\\/g, '/')
|
|
474
|
+
const out: string[] = []
|
|
475
|
+
for (const segment of raw.split('/')) {
|
|
476
|
+
if (!segment || segment === '.') continue
|
|
477
|
+
if (segment === '..') {
|
|
478
|
+
out.pop()
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
481
|
+
out.push(segment)
|
|
482
|
+
}
|
|
483
|
+
return out.join('/')
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** 拼一个相对路径(空目录段 = 根)。 */
|
|
487
|
+
export function joinRelPath(dir: string, name: string): string {
|
|
488
|
+
return normalizeRelPath(`${dir ?? ''}/${name ?? ''}`)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** 取父目录(根目录的父目录仍是根目录)。 */
|
|
492
|
+
export function parentOfRelPath(path: string): string {
|
|
493
|
+
const normalized = normalizeRelPath(path)
|
|
494
|
+
const idx = normalized.lastIndexOf('/')
|
|
495
|
+
return idx < 0 ? '' : normalized.slice(0, idx)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** 取末段名(根目录 → `''`)。 */
|
|
499
|
+
export function baseNameOfPath(path: string): string {
|
|
500
|
+
const normalized = normalizeRelPath(path)
|
|
501
|
+
const idx = normalized.lastIndexOf('/')
|
|
502
|
+
return idx < 0 ? normalized : normalized.slice(idx + 1)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 条目名是否可用(手机**上传**时它先自检一遍,电脑侧再独立校验一次)。
|
|
507
|
+
*
|
|
508
|
+
* 拒绝:空名、`.` / `..`、含路径分隔符、含 Windows 保留字符、超长。理由不是洁癖 ——
|
|
509
|
+
* 这些名字到了磁盘上会变成别的东西(`..` 直接是目录穿越,`a/b` 会被当子目录创建)。
|
|
510
|
+
*/
|
|
511
|
+
export function isSafeEntryName(name: string): boolean {
|
|
512
|
+
const raw = name ?? ''
|
|
513
|
+
const trimmed = raw.trim()
|
|
514
|
+
if (!trimmed || trimmed.length > FILE_NAME_MAX_LEN) return false
|
|
515
|
+
// 首尾空白直接拒(而不是静默 trim):落盘的应当是用户看到的那串字符
|
|
516
|
+
if (raw !== trimmed) return false
|
|
517
|
+
if (trimmed === '.' || trimmed === '..') return false
|
|
518
|
+
if (/[/\\]/.test(trimmed)) return false
|
|
519
|
+
// Windows 保留字符 + 结尾的点/空格(在 Windows 上会被静默吞掉,造成「名字对不上」)
|
|
520
|
+
if (/[<>:"|?*\u0000-\u001f]/.test(trimmed)) return false
|
|
521
|
+
if (/[. ]$/.test(trimmed)) return false
|
|
522
|
+
return true
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 同名冲突时的候选名:`a.txt` → `a - 副本.txt` → `a - 副本 (2).txt`。
|
|
527
|
+
*
|
|
528
|
+
* 与电脑侧 `file-transfer-service` 的 `DUPLICATE_SUFFIX` **同一条口径**(资源管理器的说法):
|
|
529
|
+
* 用户在这个项目的桌面端看到过「 - 副本」,手机上再看到一次不会觉得是两种行为。
|
|
530
|
+
*/
|
|
531
|
+
export function duplicateNameCandidate(name: string, index: number): string {
|
|
532
|
+
const { base, ext } = splitNameExt(name)
|
|
533
|
+
const suffix = index <= 1 ? ' - 副本' : ` - 副本 (${index})`
|
|
534
|
+
return `${base}${suffix}${ext ? `.${ext}` : ''}`
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 目录列举的稳定排序:**目录在前**,同层按名称(忽略大小写的字典序)。两端同一口径。 */
|
|
538
|
+
export function compareFileEntries(
|
|
539
|
+
a: { name: string; isDir: boolean },
|
|
540
|
+
b: { name: string; isDir: boolean },
|
|
541
|
+
): number {
|
|
542
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
|
543
|
+
return a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
544
|
+
}
|