dsh-mobile-web 0.1.2 → 0.1.4
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/LICENSE-APACHE +202 -202
- package/NOTICE +26 -26
- package/README.md +128 -45
- package/assets/apple-touch-icon.png +0 -0
- package/assets/icon-192.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/icon.svg +3 -11
- package/assets/mobile.bundle.js +1259 -326
- package/assets/theme.css +3492 -3439
- package/client.js +468 -141
- package/mobile-src/UPSTREAM.md +58 -50
- package/package.json +3 -2
- package/src/host/assets.js +9 -9
- package/src/host/cleanup.js +380 -0
- package/src/host/config.js +6 -0
- package/src/host/core.js +445 -18
- package/src/host/devices.js +226 -226
- package/src/host/http.js +8 -8
- package/src/host/pages.js +514 -514
- package/src/host/rpc/index.js +32 -32
- package/src/host/rpc/push.js +58 -58
- package/src/host/rpc/settings.js +88 -88
- package/src/host/rpc/subagent.js +27 -27
- package/src/host/state.js +30 -30
package/mobile-src/UPSTREAM.md
CHANGED
|
@@ -1,50 +1,58 @@
|
|
|
1
|
-
# Vendored mobile UI source (dsh-palm fork)
|
|
2
|
-
|
|
3
|
-
## Baseline
|
|
4
|
-
|
|
5
|
-
- Upstream project: **dsh-palm** — <https://github.com/Eternalloveone/dsh-palm>
|
|
6
|
-
- Baseline release: **v1.6.0** (published 2026-10-14)
|
|
7
|
-
- Source obtained from the release tarball:
|
|
8
|
-
`https://codeload.github.com/Eternalloveone/dsh-palm/tar.gz/refs/tags/v1.6.0`
|
|
9
|
-
→ `packages/dsh-palm/src/**` (Apache-2.0, see `LICENSE-APACHE`)
|
|
10
|
-
- This directory is a **fork**, not a submodule or a pinned dependency. Once vendored,
|
|
11
|
-
we do **not** follow upstream feature work. Upstream is kept only as reference
|
|
12
|
-
material for bug fixes we want to cherry-pick by hand.
|
|
13
|
-
|
|
14
|
-
## What we build
|
|
15
|
-
|
|
16
|
-
`../assets/mobile.bundle.js` — the single ES module served to the phone at `/m/mobile.js`.
|
|
17
|
-
`build.mjs` uses esbuild with the same entry/options as upstream's `tsdown.config.ts`
|
|
18
|
-
(`format=esm`, `platform=browser`, `target=es2022`, `jsx=automatic`, minify, no externals).
|
|
19
|
-
|
|
20
|
-
```sh
|
|
21
|
-
cd mobile-src
|
|
22
|
-
npm ci # only needed once / after package-lock.json changes
|
|
23
|
-
npm run build # writes ../assets/mobile.bundle.js (+ .map)
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Build output is deterministic for a given `src/` + lockfile; rebuilds should produce
|
|
27
|
-
byte-identical files (`sha256sum ../assets/mobile.bundle.js`).
|
|
28
|
-
|
|
29
|
-
## Local changes carried in this fork (vs v1.6.0)
|
|
30
|
-
|
|
31
|
-
1. **Send-mode row in Settings** (`src/mobile/views/SettingsView.tsx`, 外观 group):
|
|
32
|
-
the row reads/writes `window.__dmSendMode` (owned by the host plugin's injected
|
|
33
|
-
`SEND_MODE_HTML`), so the phone stores its send mode on the host instead of a
|
|
34
|
-
page-local default. Styled by `assets/theme.css` (`.dm-sendmode-select`).
|
|
35
|
-
2. **Balance label** (`SettingsView.tsx`, usage card): `账户余额` → `充值余额`
|
|
36
|
-
to match the wording used on the desktop side.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
1
|
+
# Vendored mobile UI source (dsh-palm fork)
|
|
2
|
+
|
|
3
|
+
## Baseline
|
|
4
|
+
|
|
5
|
+
- Upstream project: **dsh-palm** — <https://github.com/Eternalloveone/dsh-palm>
|
|
6
|
+
- Baseline release: **v1.6.0** (published 2026-10-14)
|
|
7
|
+
- Source obtained from the release tarball:
|
|
8
|
+
`https://codeload.github.com/Eternalloveone/dsh-palm/tar.gz/refs/tags/v1.6.0`
|
|
9
|
+
→ `packages/dsh-palm/src/**` (Apache-2.0, see `LICENSE-APACHE`)
|
|
10
|
+
- This directory is a **fork**, not a submodule or a pinned dependency. Once vendored,
|
|
11
|
+
we do **not** follow upstream feature work. Upstream is kept only as reference
|
|
12
|
+
material for bug fixes we want to cherry-pick by hand.
|
|
13
|
+
|
|
14
|
+
## What we build
|
|
15
|
+
|
|
16
|
+
`../assets/mobile.bundle.js` — the single ES module served to the phone at `/m/mobile.js`.
|
|
17
|
+
`build.mjs` uses esbuild with the same entry/options as upstream's `tsdown.config.ts`
|
|
18
|
+
(`format=esm`, `platform=browser`, `target=es2022`, `jsx=automatic`, minify, no externals).
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
cd mobile-src
|
|
22
|
+
npm ci # only needed once / after package-lock.json changes
|
|
23
|
+
npm run build # writes ../assets/mobile.bundle.js (+ .map)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Build output is deterministic for a given `src/` + lockfile; rebuilds should produce
|
|
27
|
+
byte-identical files (`sha256sum ../assets/mobile.bundle.js`).
|
|
28
|
+
|
|
29
|
+
## Local changes carried in this fork (vs v1.6.0)
|
|
30
|
+
|
|
31
|
+
1. **Send-mode row in Settings** (`src/mobile/views/SettingsView.tsx`, 外观 group):
|
|
32
|
+
the row reads/writes `window.__dmSendMode` (owned by the host plugin's injected
|
|
33
|
+
`SEND_MODE_HTML`), so the phone stores its send mode on the host instead of a
|
|
34
|
+
page-local default. Styled by `assets/theme.css` (`.dm-sendmode-select`).
|
|
35
|
+
2. **Balance label** (`SettingsView.tsx`, usage card): `账户余额` → `充值余额`
|
|
36
|
+
to match the wording used on the desktop side.
|
|
37
|
+
3. **Question record in chat history** (`messages.ts`, `message-row.tsx`,
|
|
38
|
+
`process-rows.ts`, `mobile-styles.ts`): `ask_user_question` calls are folded into a
|
|
39
|
+
`QuestionRecord` (question text + chosen options) from the `tool/call` and
|
|
40
|
+
`tool/result` events and rendered inline as a compact 「提问 · N/M 已回答」 card
|
|
41
|
+
(expandable). The raw tool row is suppressed for that tool, and a turn whose only
|
|
42
|
+
process content was the question no longer shows an empty process card. Desktop parity:
|
|
43
|
+
the desktop renders the same record from its own projection; this deployment's
|
|
44
|
+
`userQuestions` projection stays empty, so the phone folds the events instead.
|
|
45
|
+
|
|
46
|
+
Everything else is upstream v1.6.0 as published. Host-side behaviours that used to be
|
|
47
|
+
patched into the shipped bundle by hand now live where they belong:
|
|
48
|
+
|
|
49
|
+
- “关于 / 反馈” copy and the GitHub link are rewritten at runtime by the host plugin's
|
|
50
|
+
DOM patch (`index.js`, `SETTINGS_SCOPE_HTML` block) — no source change needed.
|
|
51
|
+
- The web-search artwork in the builtin-plugin rows is host-side (`index.js`,
|
|
52
|
+
`HOST_PAGES_HTML` → `CONFIG_ART['web-search']`) — not part of this bundle.
|
|
53
|
+
|
|
54
|
+
## Updating
|
|
55
|
+
|
|
56
|
+
Do not `git pull` upstream into this tree. If a specific upstream fix is wanted, read
|
|
57
|
+
the upstream commit, re-implement it here, run `npm run build`, and commit both the
|
|
58
|
+
source change and the rebuilt `assets/mobile.bundle.js` in the same commit.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "掌上 dsh —— DSH 的手机端完整聊天界面:6 位码配对、设备信任、工作区/会话/聊天、SSE 实时流,可经局域网或公网反代访问。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -76,5 +76,6 @@
|
|
|
76
76
|
},
|
|
77
77
|
"scripts": {
|
|
78
78
|
"smoke": "node test/smoke.mjs"
|
|
79
|
-
}
|
|
79
|
+
},
|
|
80
|
+
"icon": "./assets/icon.svg"
|
|
80
81
|
}
|
package/src/host/assets.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 静态资源:assets/ 下 bundle/图标/主题,带 mtime 校验的进程内缓存。
|
|
3
|
-
* 本文件位于 src/host/,仓库根的 assets/ 用两个 ../ 定位。
|
|
4
|
-
*/
|
|
5
|
-
import { readFileSync, existsSync, mkdirSync, statSync } from 'node:fs'
|
|
6
|
-
import { join } from 'node:path'
|
|
7
|
-
import { fileURLToPath } from 'node:url'
|
|
8
|
-
import { SHOT_DIR } from './config.js'
|
|
9
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 静态资源:assets/ 下 bundle/图标/主题,带 mtime 校验的进程内缓存。
|
|
3
|
+
* 本文件位于 src/host/,仓库根的 assets/ 用两个 ../ 定位。
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, existsSync, mkdirSync, statSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { SHOT_DIR } from './config.js'
|
|
9
|
+
|
|
10
10
|
export const assetCache = new Map()
|
|
11
11
|
/**
|
|
12
12
|
* 读取 assets 下的静态资源,**按文件 mtime/size 失效**。
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手机端「删除」的宿主实现:会话目录、会话文件、工作区目录的**永久删除**。
|
|
3
|
+
*
|
|
4
|
+
* 设计原则(用户明确要求):删除就是删除,不做回收站。
|
|
5
|
+
* · 会话删除 = 归档墓碑(让所有设备立刻从列表消失)+ rm -rf 会话目录;
|
|
6
|
+
* · 文件删除 = 从「会话日志 write/edit 白名单」里校验后 rm;
|
|
7
|
+
* · 工作区目录删除 = 输入完整路径确认后 rm -rf,但保留硬护栏
|
|
8
|
+
* (主目录 / ~/.dsh / 插件自身目录 / 盘符根一律拒绝)。
|
|
9
|
+
*
|
|
10
|
+
* dsh 的会话存储后端没有删除接口(文档:「不删除会话文件,直到外部移除」),
|
|
11
|
+
* 所以外部移除会话目录就是唯一正确的实现;投影缓存在这里一并清掉。
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
16
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
17
|
+
|
|
18
|
+
const DSH_HOME = join(homedir(), '.dsh')
|
|
19
|
+
|
|
20
|
+
/** 会话日志根(可用 DSH_SESSIONS_DIR 覆盖,与宿主保持一致)。 */
|
|
21
|
+
export const SESSIONS_ROOT = process.env.DSH_SESSIONS_DIR || join(DSH_HOME, 'sessions')
|
|
22
|
+
const PROJ_CACHE_ROOT = process.env.DSH_PROJ_CACHE_DIR || join(DSH_HOME, 'storages', 'session_projcache', 'sessions')
|
|
23
|
+
/** 单个会话最多列出的文件数(防止异常日志把手机撑爆)。 */
|
|
24
|
+
const MAX_FILES = 2000
|
|
25
|
+
const EDIT_TOOLS = new Set(['write', 'edit', 'multi_edit', 'apply_patch', 'write_file', 'create_file', 'str_replace', 'write_text_file'])
|
|
26
|
+
|
|
27
|
+
/** 会话 id 只允许安全字符;去掉可能存在的 session- 前缀。 */
|
|
28
|
+
export function normalizeSessionId(raw) {
|
|
29
|
+
const value = String(raw === undefined || raw === null ? '' : raw).trim()
|
|
30
|
+
if (!/^(?:session-)?[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(value)) return ''
|
|
31
|
+
return value.startsWith('session-') ? value.slice('session-'.length) : value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 会话 id 在日志/接口里的两种写法(有的地方带 session- 前缀)。 */
|
|
35
|
+
export function sessionIdVariants(raw) {
|
|
36
|
+
const id = normalizeSessionId(raw)
|
|
37
|
+
if (id === '') return []
|
|
38
|
+
return [`session-${id}`, id]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 定位会话目录:~/.dsh/sessions/<--cwd-->/<session-id>/。
|
|
43
|
+
* 目录名有 `session-<id>` 与 `<id>` 两种历史写法,都认。
|
|
44
|
+
*/
|
|
45
|
+
export function findSessionDir(sessionId) {
|
|
46
|
+
const variants = sessionIdVariants(sessionId)
|
|
47
|
+
if (variants.length === 0) return undefined
|
|
48
|
+
let entries = []
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(SESSIONS_ROOT)
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
for (const name of entries) {
|
|
55
|
+
const workspaceDir = join(SESSIONS_ROOT, name)
|
|
56
|
+
let isDir = false
|
|
57
|
+
try {
|
|
58
|
+
isDir = statSync(workspaceDir).isDirectory()
|
|
59
|
+
} catch {
|
|
60
|
+
isDir = false
|
|
61
|
+
}
|
|
62
|
+
if (!isDir) continue
|
|
63
|
+
for (const variant of variants) {
|
|
64
|
+
const candidate = join(workspaceDir, variant)
|
|
65
|
+
try {
|
|
66
|
+
if (statSync(candidate).isDirectory()) return candidate
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 会话目录里的日志文件(session*.jsonl[.zstd]),优先最新版本。 */
|
|
74
|
+
function sessionLogFiles(dir) {
|
|
75
|
+
let names = []
|
|
76
|
+
try {
|
|
77
|
+
names = readdirSync(dir).filter((name) => /^session(?:\.[vV][0-9]+)?\.jsonl(?:\.zstd)?$/.test(name))
|
|
78
|
+
} catch {
|
|
79
|
+
return []
|
|
80
|
+
}
|
|
81
|
+
if (names.length === 0) return []
|
|
82
|
+
const versionOf = (name) => {
|
|
83
|
+
const match = /^session\.[vV]([0-9]+)\.jsonl/.exec(name)
|
|
84
|
+
return match === null ? 0 : Number(match[1])
|
|
85
|
+
}
|
|
86
|
+
const best = names.reduce((top, name) => (versionOf(name) > versionOf(top) ? name : top), names[0])
|
|
87
|
+
const compressed = `${best}.zstd`
|
|
88
|
+
return [names.includes(compressed) ? compressed : best]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 解压会话日志。会话日志是**追加写的多个 zstd frame**,Node 的
|
|
93
|
+
* zstdDecompressSync 只解第一帧(实测 13MB → 200 字节),所以按 magic
|
|
94
|
+
* 切帧逐个解压再拼接(zstd magic 落在压缩数据内部概率极低;失败则退回整块解)。
|
|
95
|
+
*/
|
|
96
|
+
function decompressSessionLog(buffer) {
|
|
97
|
+
const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
98
|
+
const offsets = []
|
|
99
|
+
let at = 0
|
|
100
|
+
while ((at = buffer.indexOf(MAGIC, at)) !== -1) {
|
|
101
|
+
offsets.push(at)
|
|
102
|
+
at += MAGIC.length
|
|
103
|
+
}
|
|
104
|
+
if (offsets.length <= 1) {
|
|
105
|
+
try {
|
|
106
|
+
return zstdDecompressSync(buffer)
|
|
107
|
+
} catch {
|
|
108
|
+
return buffer
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
offsets.push(buffer.length)
|
|
112
|
+
const parts = []
|
|
113
|
+
for (let index = 0; index < offsets.length - 1; index += 1) {
|
|
114
|
+
try {
|
|
115
|
+
parts.push(zstdDecompressSync(buffer.subarray(offsets[index], offsets[index + 1])))
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
return parts.length === 0 ? buffer : Buffer.concat(parts)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 会话目录里所有文件的体积信息(用于删除确认页)。 */
|
|
122
|
+
export function sessionDirInfo(sessionId) {
|
|
123
|
+
const dir = findSessionDir(sessionId)
|
|
124
|
+
if (dir === undefined) return { found: false }
|
|
125
|
+
const files = []
|
|
126
|
+
let bytes = 0
|
|
127
|
+
let newest = 0
|
|
128
|
+
try {
|
|
129
|
+
for (const name of readdirSync(dir)) {
|
|
130
|
+
const path = join(dir, name)
|
|
131
|
+
try {
|
|
132
|
+
const stat = statSync(path)
|
|
133
|
+
if (!stat.isFile()) continue
|
|
134
|
+
files.push({ name, bytes: stat.size, mtime: stat.mtimeMs })
|
|
135
|
+
bytes += stat.size
|
|
136
|
+
newest = Math.max(newest, stat.mtimeMs)
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
return { found: true, dir, files, bytes, newest }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 从会话日志里收集 write/edit 类工具调用写过的文件(去重、附带次数)。 */
|
|
144
|
+
export function sessionWrittenFiles(sessionId) {
|
|
145
|
+
const dir = findSessionDir(sessionId)
|
|
146
|
+
if (dir === undefined) return new Map()
|
|
147
|
+
const out = new Map()
|
|
148
|
+
for (const name of sessionLogFiles(dir)) {
|
|
149
|
+
let raw
|
|
150
|
+
try {
|
|
151
|
+
raw = readFileSync(join(dir, name))
|
|
152
|
+
} catch {
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
let text
|
|
156
|
+
try {
|
|
157
|
+
text = (name.endsWith('.zstd') ? decompressSessionLog(raw) : raw).toString('utf8')
|
|
158
|
+
} catch {
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
let start = 0
|
|
162
|
+
while (start < text.length) {
|
|
163
|
+
const end = text.indexOf('\n', start)
|
|
164
|
+
const line = text.slice(start, end === -1 ? text.length : end)
|
|
165
|
+
start = end === -1 ? text.length : end + 1
|
|
166
|
+
if (line === '' || !line.includes('"tool-call"')) continue
|
|
167
|
+
let event
|
|
168
|
+
try {
|
|
169
|
+
event = JSON.parse(line)
|
|
170
|
+
} catch {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
const content = event && event.data && event.data.message && event.data.message.content
|
|
174
|
+
if (!Array.isArray(content)) continue
|
|
175
|
+
for (const part of content) {
|
|
176
|
+
if (!part || part.type !== 'tool-call' || !EDIT_TOOLS.has(part.name)) continue
|
|
177
|
+
let args
|
|
178
|
+
try {
|
|
179
|
+
args = JSON.parse(part.arguments)
|
|
180
|
+
} catch {
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (!args || typeof args !== 'object') continue
|
|
184
|
+
const paths = []
|
|
185
|
+
if (typeof args.file_path === 'string' && args.file_path !== '') paths.push(args.file_path)
|
|
186
|
+
if (typeof args.path === 'string' && args.path !== '') paths.push(args.path)
|
|
187
|
+
if (Array.isArray(args.paths)) {
|
|
188
|
+
for (const item of args.paths) if (typeof item === 'string' && item !== '') paths.push(item)
|
|
189
|
+
}
|
|
190
|
+
for (const path of paths) {
|
|
191
|
+
const existing = out.get(path)
|
|
192
|
+
out.set(path, { path, count: (existing === undefined ? 0 : existing.count) + 1, tool: part.name })
|
|
193
|
+
}
|
|
194
|
+
if (out.size >= MAX_FILES) return out
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** realpath(文件不存在时返回 undefined);用于路径边界校验。 */
|
|
202
|
+
function realpathOrUndefined(path) {
|
|
203
|
+
try {
|
|
204
|
+
return realpathSync(path)
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function isInside(parent, child) {
|
|
211
|
+
const rel = relative(parent, child)
|
|
212
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 会话可删除文件清单(给手机端确认页):
|
|
217
|
+
* logs 会话目录里的日志文件(删掉=会话记录本身)
|
|
218
|
+
* generated 会话日志里 write/edit 写过的、现在仍然存在的普通文件
|
|
219
|
+
* 每项都带 absolute path、bytes、insideWorkspace,外部的默认不勾选。
|
|
220
|
+
*/
|
|
221
|
+
export function listSessionFiles(sessionId, cwd) {
|
|
222
|
+
const dir = findSessionDir(sessionId)
|
|
223
|
+
const base = dir === undefined ? undefined : resolve(dir)
|
|
224
|
+
const workspace = typeof cwd === 'string' && cwd !== '' ? resolve(cwd) : undefined
|
|
225
|
+
const workspaceReal = workspace === undefined ? undefined : (realpathOrUndefined(workspace) || workspace)
|
|
226
|
+
const logs = []
|
|
227
|
+
if (base !== undefined) {
|
|
228
|
+
try {
|
|
229
|
+
for (const name of readdirSync(base)) {
|
|
230
|
+
const path = join(base, name)
|
|
231
|
+
try {
|
|
232
|
+
const stat = statSync(path)
|
|
233
|
+
if (!stat.isFile()) continue
|
|
234
|
+
logs.push({ group: 'logs', path, name, bytes: stat.size, mtime: stat.mtimeMs, insideWorkspace: false })
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
239
|
+
const generated = []
|
|
240
|
+
for (const record of sessionWrittenFiles(sessionId).values()) {
|
|
241
|
+
// 日志里的路径可能是绝对路径,也可能是相对当时 cwd 的路径;两种基准都试。
|
|
242
|
+
const candidates = []
|
|
243
|
+
if (isAbsolute(record.path)) candidates.push(resolve(record.path))
|
|
244
|
+
else {
|
|
245
|
+
if (workspace !== undefined) candidates.push(resolve(workspace, record.path))
|
|
246
|
+
candidates.push(resolve(record.path))
|
|
247
|
+
}
|
|
248
|
+
let path
|
|
249
|
+
let stat
|
|
250
|
+
for (const candidate of candidates) {
|
|
251
|
+
try {
|
|
252
|
+
const probe = statSync(candidate)
|
|
253
|
+
if (probe.isFile()) { path = candidate; stat = probe; break }
|
|
254
|
+
} catch {}
|
|
255
|
+
}
|
|
256
|
+
if (path === undefined) continue
|
|
257
|
+
const real = realpathOrUndefined(path)
|
|
258
|
+
if (real === undefined) continue
|
|
259
|
+
// 符号链接不提供删除(避免把链接目标删掉/穿越到别处)。
|
|
260
|
+
try {
|
|
261
|
+
if (lstatSync(path).isSymbolicLink()) continue
|
|
262
|
+
} catch {}
|
|
263
|
+
const inside = workspaceReal !== undefined && workspace !== undefined
|
|
264
|
+
? isInside(workspace, path) || isInside(workspaceReal, real)
|
|
265
|
+
: false
|
|
266
|
+
generated.push({
|
|
267
|
+
group: 'generated',
|
|
268
|
+
path,
|
|
269
|
+
name: basename(path),
|
|
270
|
+
bytes: stat.size,
|
|
271
|
+
mtime: stat.mtimeMs,
|
|
272
|
+
insideWorkspace: inside,
|
|
273
|
+
tool: record.tool,
|
|
274
|
+
count: record.count,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
generated.sort((a, b) => b.mtime - a.mtime)
|
|
278
|
+
return {
|
|
279
|
+
found: dir !== undefined,
|
|
280
|
+
dir,
|
|
281
|
+
cwd: workspace,
|
|
282
|
+
groups: [
|
|
283
|
+
{ kind: 'logs', title: '会话日志', files: logs },
|
|
284
|
+
{ kind: 'generated', title: '本会话写过的文件', files: generated.slice(0, MAX_FILES) },
|
|
285
|
+
],
|
|
286
|
+
bytes: [...logs, ...generated].reduce((sum, file) => sum + file.bytes, 0),
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 删除会话目录里的投影缓存(否则列表重建时可能残留标题)。 */
|
|
291
|
+
export function removeProjectionCache(sessionId) {
|
|
292
|
+
for (const variant of sessionIdVariants(sessionId)) {
|
|
293
|
+
try {
|
|
294
|
+
const path = join(PROJ_CACHE_ROOT, `${variant}.json`)
|
|
295
|
+
if (existsSync(path)) rmSync(path, { force: true })
|
|
296
|
+
} catch {}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 永久删除会话目录(rm -rf);返回被删目录与体积。 */
|
|
301
|
+
export function purgeSessionDir(sessionId) {
|
|
302
|
+
const info = sessionDirInfo(sessionId)
|
|
303
|
+
if (!info.found) return { ok: false, error: { code: 'not-found', message: '找不到会话目录(可能已经被删除)' } }
|
|
304
|
+
try {
|
|
305
|
+
rmSync(info.dir, { recursive: true, force: true })
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return { ok: false, error: { code: 'delete-failed', message: err && err.message ? err.message : String(err) } }
|
|
308
|
+
}
|
|
309
|
+
removeProjectionCache(sessionId)
|
|
310
|
+
return { ok: true, dir: info.dir, bytes: info.bytes }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 永久删除清单里的文件;路径必须出现在 listSessionFiles 的白名单里。
|
|
315
|
+
* 任何不在白名单里的路径都不会被碰。
|
|
316
|
+
*/
|
|
317
|
+
export function purgeSessionFiles(sessionId, paths, cwd) {
|
|
318
|
+
if (!Array.isArray(paths) || paths.length === 0) return { ok: false, error: { code: 'bad-request', message: '没有选中文件' } }
|
|
319
|
+
const allowed = new Map()
|
|
320
|
+
for (const group of listSessionFiles(sessionId, cwd).groups) {
|
|
321
|
+
for (const file of group.files) allowed.set(resolve(file.path), file)
|
|
322
|
+
}
|
|
323
|
+
const removed = []
|
|
324
|
+
const skipped = []
|
|
325
|
+
for (const raw of paths) {
|
|
326
|
+
if (typeof raw !== 'string' || raw === '') continue
|
|
327
|
+
const path = resolve(raw)
|
|
328
|
+
if (!allowed.has(path)) {
|
|
329
|
+
skipped.push(path)
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
rmSync(path, { force: true })
|
|
334
|
+
removed.push({ path })
|
|
335
|
+
} catch {
|
|
336
|
+
skipped.push(path)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (removed.length === 0) {
|
|
340
|
+
return { ok: false, error: { code: 'not-found', message: skipped.length === 0 ? '没有可删除的文件' : `文件不可删除:${skipped.join('、')}` } }
|
|
341
|
+
}
|
|
342
|
+
return { ok: true, removed, skipped }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ── 工作区目录 ─────────────────────────────────────────────────────────── */
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 永久删除整个工作区目录。调用方必须已经让用户输入过路径确认;
|
|
349
|
+
* 这里再做一层硬护栏:主目录、~/.dsh、插件自身目录、盘符根一律拒绝。
|
|
350
|
+
*/
|
|
351
|
+
export function purgeWorkspaceDir(workspacePath, options = {}) {
|
|
352
|
+
const path = resolve(String(workspacePath || ''))
|
|
353
|
+
if (!isAbsolute(path) || !existsSync(path)) {
|
|
354
|
+
return { ok: false, error: { code: 'not-found', message: '工作区目录不存在' } }
|
|
355
|
+
}
|
|
356
|
+
let stat
|
|
357
|
+
try {
|
|
358
|
+
stat = statSync(path)
|
|
359
|
+
} catch {
|
|
360
|
+
return { ok: false, error: { code: 'not-found', message: '工作区目录不可读' } }
|
|
361
|
+
}
|
|
362
|
+
if (!stat.isDirectory()) return { ok: false, error: { code: 'bad-request', message: '不是目录' } }
|
|
363
|
+
const real = realpathOrUndefined(path) || path
|
|
364
|
+
const home = resolve(homedir())
|
|
365
|
+
const dsh = resolve(DSH_HOME)
|
|
366
|
+
const plugin = options.pluginDir === undefined ? undefined : resolve(String(options.pluginDir))
|
|
367
|
+
const refuse = (message) => ({ ok: false, error: { code: 'refused', message } })
|
|
368
|
+
if (real === home) return refuse('不能删除用户主目录')
|
|
369
|
+
if (real === dirname(real)) return refuse('不能删除盘符根目录')
|
|
370
|
+
if (real === dsh || isInside(real, dsh)) return refuse('不能删除 ~/.dsh 内部目录')
|
|
371
|
+
if (plugin !== undefined && (real === plugin || isInside(real, plugin) || isInside(plugin, real))) {
|
|
372
|
+
return refuse('该目录包含 dsh-mobile 插件自身,拒绝删除')
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
rmSync(real, { recursive: true, force: true })
|
|
376
|
+
} catch (err) {
|
|
377
|
+
return { ok: false, error: { code: 'delete-failed', message: err && err.message ? err.message : String(err) } }
|
|
378
|
+
}
|
|
379
|
+
return { ok: true, dir: real }
|
|
380
|
+
}
|
package/src/host/config.js
CHANGED
|
@@ -71,6 +71,8 @@ export const DEFAULT_MOBILE_PORT = 19387
|
|
|
71
71
|
export const DEFAULT_PAIR_CODE = '123456'
|
|
72
72
|
export const SMALL_BODY = 64 * 1024
|
|
73
73
|
export const PROMPT_BODY = 2 * 1024 * 1024
|
|
74
|
+
/** 手机端「添加文件」的单个文件上限(宿主侧最终把关;客户端也会先挡一次)。 */
|
|
75
|
+
export const MOBILE_FILE_MAX_BYTES = 24 * 1024 * 1024
|
|
74
76
|
// 手机点命令后的单次执行上限:bundle 侧 mobile.commandExec 的 fetch 超时是 180s,
|
|
75
77
|
// 宿主这里留 30s 余量,避免「前端已放弃、宿主还在 await」的悬挂。
|
|
76
78
|
export const COMMAND_EXEC_TIMEOUT_MS = 150 * 1000
|
|
@@ -250,6 +252,10 @@ export const PUSH_CHECK_URL = '/m/push-check'
|
|
|
250
252
|
// https://<公网域名>:45632/m/shot/<file>.png
|
|
251
253
|
// 安全取舍见 serveShot() 的注释(匿名只读 + 单层文件名白名单 + 扩展名白名单)。
|
|
252
254
|
export const SHOT_PREFIX = '/m/shot'
|
|
255
|
+
/** 文件下载路由前缀:GET /m/file/<token> → 会话工作区里的文件(带 Content-Disposition)。 */
|
|
256
|
+
export const FILE_PREFIX = '/m/file'
|
|
257
|
+
/** 下载令牌有效期(毫秒)。 */
|
|
258
|
+
export const FILE_TOKEN_TTL_MS = 10 * 60 * 1000
|
|
253
259
|
// 只允许单层文件名:不含 / \ : % 与空白,天然挡住目录穿越(.. 另做显式检查)。
|
|
254
260
|
export const SHOT_NAME = /^[A-Za-z0-9._-]+$/
|
|
255
261
|
export const SHOT_TYPES = {
|