dsh-crossterm 0.3.36 → 0.3.38
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 +13 -0
- package/client/client.js +48 -0
- package/lib/bus.js +59 -6
- package/lib/cloud/devices.js +95 -0
- package/lib/cloud/pan.js +22 -4
- package/lib/cloud/sync.js +5 -1
- package/lib/web.js +87 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -244,6 +244,19 @@ dsh plugin --profile desktop remove dsh-crossterm
|
|
|
244
244
|
|
|
245
245
|
> 插件、npm 包、B 机安装包**都不携带任何凭证**;换机器要各自配自己的。
|
|
246
246
|
|
|
247
|
+
### 看有哪些机器连着(0.3.38)
|
|
248
|
+
|
|
249
|
+
插件页卡片展开后有「云端设备」一行,也可以直接 `GET /crossterm/api/devices`:
|
|
250
|
+
|
|
251
|
+
- 数据来自云端总线的 `presence/*.json`(每台机器 10 秒一次心跳),字段含名字、nodeId、版本、平台、会话数、入链;
|
|
252
|
+
- 最后心跳 3 分钟内的算「在线」,其余显示「N 分钟前」;
|
|
253
|
+
- 本机永远在列表里(标「(本机)」),取自本地 presence,不依赖云端往返;
|
|
254
|
+
- 没配云盘的机器只显示「未连接云盘」,不会假装有别的设备。
|
|
255
|
+
|
|
256
|
+
> 0.3.38 之前这里常常显示「云端还没有其他设备」:云端 `presence/` 里堆了 220 多个 `.tmp`
|
|
257
|
+
> 残骸,而百度网盘列目录一页只给 200 条 —— 真实 json 被挤到了第二页。现在列目录会翻页,
|
|
258
|
+
> 云端也不再上传 `.tmp`。
|
|
259
|
+
|
|
247
260
|
### 手动 / 自动注入开关(三层)
|
|
248
261
|
|
|
249
262
|
| 层 | 开关 | 粒度 |
|
package/client/client.js
CHANGED
|
@@ -131,11 +131,37 @@ window.__ModuleLoader__.load({
|
|
|
131
131
|
);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
function agoText(ts) {
|
|
135
|
+
if (!ts) return "未知";
|
|
136
|
+
const mins = Math.round((Date.now() - ts) / 60000);
|
|
137
|
+
if (mins <= 0) return "刚刚";
|
|
138
|
+
if (mins < 60) return mins + " 分钟前";
|
|
139
|
+
const hours = Math.round(mins / 60);
|
|
140
|
+
if (hours < 24) return hours + " 小时前";
|
|
141
|
+
return Math.round(hours / 24) + " 天前";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function devicesText(status, devices) {
|
|
145
|
+
if (!(status && status.cloud && status.cloud.configured)) return "未连接云盘";
|
|
146
|
+
if (!devices || devices.loading) return "读取中…";
|
|
147
|
+
if (!devices.ok) return devices.message || "读取失败";
|
|
148
|
+
const list = devices.devices || [];
|
|
149
|
+
if (!list.length) return "云端还没有其他设备";
|
|
150
|
+
return list
|
|
151
|
+
.map((d) => {
|
|
152
|
+
const label = (d.name || d.nodeId || "未知设备") + (d.nodeId === devices.me ? "(本机)" : "");
|
|
153
|
+
const state = d.online ? "在线" : agoText(d.updatedAt);
|
|
154
|
+
return label + " · " + state + (d.version ? " · v" + d.version : "");
|
|
155
|
+
})
|
|
156
|
+
.join(";");
|
|
157
|
+
}
|
|
158
|
+
|
|
134
159
|
function Card() {
|
|
135
160
|
const [open, setOpen] = React.useState(false);
|
|
136
161
|
const [status, setStatus] = React.useState(null);
|
|
137
162
|
const [busy, setBusy] = React.useState("");
|
|
138
163
|
const [msg, setMsg] = React.useState("");
|
|
164
|
+
const [devices, setDevices] = React.useState(null);
|
|
139
165
|
|
|
140
166
|
const load = React.useCallback(() => {
|
|
141
167
|
call("/crossterm/api/status")
|
|
@@ -150,6 +176,21 @@ window.__ModuleLoader__.load({
|
|
|
150
176
|
load();
|
|
151
177
|
}, [load]);
|
|
152
178
|
|
|
179
|
+
const loadDevices = React.useCallback(async () => {
|
|
180
|
+
setDevices({ loading: true, devices: [] });
|
|
181
|
+
try {
|
|
182
|
+
const data = await call("/crossterm/api/devices");
|
|
183
|
+
setDevices({ loading: false, ...(data || {}) });
|
|
184
|
+
} catch (error) {
|
|
185
|
+
setDevices({ loading: false, ok: false, devices: [], message: String((error && error.message) || error) });
|
|
186
|
+
}
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
// 展开时自动读一次云端设备(收起不读,避免每次进页面都等网盘)
|
|
190
|
+
React.useEffect(() => {
|
|
191
|
+
if (open && !devices) void loadDevices();
|
|
192
|
+
}, [open, devices, loadDevices]);
|
|
193
|
+
|
|
153
194
|
const onCheck = async () => {
|
|
154
195
|
setBusy("check");
|
|
155
196
|
setMsg("正在检查更新…");
|
|
@@ -265,6 +306,12 @@ window.__ModuleLoader__.load({
|
|
|
265
306
|
label: "当前终端",
|
|
266
307
|
value: status ? `${status.node}(${status.nodeId})· ${status.hostname}` : "",
|
|
267
308
|
}),
|
|
309
|
+
h(
|
|
310
|
+
"div",
|
|
311
|
+
{ style: S.grid },
|
|
312
|
+
h("div", { style: S.key }, "云端设备"),
|
|
313
|
+
h("div", { style: S.val }, devicesText(status, devices)),
|
|
314
|
+
),
|
|
268
315
|
h(
|
|
269
316
|
"div",
|
|
270
317
|
{ style: S.grid },
|
|
@@ -318,6 +365,7 @@ window.__ModuleLoader__.load({
|
|
|
318
365
|
{ style: S.actions },
|
|
319
366
|
h("button", { style: S.btn, onClick: onCheck, disabled: busy !== "" }, busy === "check" ? "检查中…" : "检查更新"),
|
|
320
367
|
h("button", { style: S.btnPrimary, onClick: onUpdate, disabled: busy !== "" }, busy === "update" ? "更新中…" : "更新"),
|
|
368
|
+
h("button", { style: S.btn, onClick: () => void loadDevices(), disabled: Boolean(devices && devices.loading) }, devices && devices.loading ? "读取中…" : "刷新设备"),
|
|
321
369
|
),
|
|
322
370
|
msg ? h("div", { style: S.msg }, msg) : null,
|
|
323
371
|
),
|
package/lib/bus.js
CHANGED
|
@@ -22,7 +22,7 @@ import os from 'node:os'
|
|
|
22
22
|
import path from 'node:path'
|
|
23
23
|
import crypto from 'node:crypto'
|
|
24
24
|
|
|
25
|
-
export const VERSION = '0.3.
|
|
25
|
+
export const VERSION = '0.3.38'
|
|
26
26
|
|
|
27
27
|
/** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
|
|
28
28
|
export function dshHome() {
|
|
@@ -203,8 +203,9 @@ export function formatBytes(n) {
|
|
|
203
203
|
export async function writeJsonAtomic(file, value) {
|
|
204
204
|
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
205
205
|
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
|
|
206
|
-
|
|
207
|
-
await fsp.
|
|
206
|
+
const data = JSON.stringify(value, null, 2)
|
|
207
|
+
await fsp.writeFile(tmp, data, 'utf8')
|
|
208
|
+
await renameWithRetry(tmp, file, data)
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
/** 原子写文本:同上,用于邮件正文 */
|
|
@@ -212,7 +213,53 @@ export async function writeFileAtomic(file, text) {
|
|
|
212
213
|
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
213
214
|
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
|
|
214
215
|
await fsp.writeFile(tmp, text, 'utf8')
|
|
215
|
-
await
|
|
216
|
+
await renameWithRetry(tmp, file, text)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Windows 上目标文件被别的进程(读方、杀毒、同步盘)短暂占用时 rename 会 EPERM/EBUSY,
|
|
220
|
+
// 以前就这么把 .tmp 漏在原地:presence/ 攒了两百多个。这里重试几次,最后退化成直接覆盖,
|
|
221
|
+
// 无论成败都把 tmp 删掉,绝不留垃圾。
|
|
222
|
+
async function renameWithRetry(tmp, file, data) {
|
|
223
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
224
|
+
try {
|
|
225
|
+
await fsp.rename(tmp, file)
|
|
226
|
+
return
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const code = error && error.code
|
|
229
|
+
if (!['EPERM', 'EBUSY', 'EACCES', 'ENOENT'].includes(code)) throw error
|
|
230
|
+
await new Promise((resolve) => setTimeout(resolve, 15 * (attempt + 1)))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
await fsp.writeFile(file, data, 'utf8')
|
|
234
|
+
await fsp.rm(tmp, { force: true }).catch(() => {})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 清掉总线目录里超过 maxAgeMs 的 .tmp 残骸(原子写的漏网之鱼) */
|
|
238
|
+
export async function sweepTmp(root, maxAgeMs = 10 * 60 * 1000, dirs = ['presence', 'chains', 'links', 'inbox', 'mail']) {
|
|
239
|
+
const now = Date.now()
|
|
240
|
+
let removed = 0
|
|
241
|
+
for (const dir of dirs) {
|
|
242
|
+
const abs = path.join(root, dir)
|
|
243
|
+
let entries
|
|
244
|
+
try {
|
|
245
|
+
entries = await fsp.readdir(abs, { withFileTypes: true })
|
|
246
|
+
} catch {
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
for (const entry of entries) {
|
|
250
|
+
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue
|
|
251
|
+
const target = path.join(abs, entry.name)
|
|
252
|
+
try {
|
|
253
|
+
const st = await fsp.stat(target)
|
|
254
|
+
if (now - st.mtimeMs < maxAgeMs) continue
|
|
255
|
+
await fsp.rm(target, { force: true })
|
|
256
|
+
removed += 1
|
|
257
|
+
} catch {
|
|
258
|
+
/* ignore */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return removed
|
|
216
263
|
}
|
|
217
264
|
|
|
218
265
|
export async function readJson(file) {
|
|
@@ -300,8 +347,14 @@ export class Bus {
|
|
|
300
347
|
const file = this.mailFile(msg.id)
|
|
301
348
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
302
349
|
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
|
|
303
|
-
|
|
304
|
-
fs.
|
|
350
|
+
const text = `${head.join('\n')}\n${body}\n`
|
|
351
|
+
fs.writeFileSync(tmp, text, 'utf8')
|
|
352
|
+
try {
|
|
353
|
+
fs.renameSync(tmp, file)
|
|
354
|
+
} catch {
|
|
355
|
+
fs.writeFileSync(file, text, 'utf8')
|
|
356
|
+
try { fs.rmSync(tmp, { force: true }) } catch { /* ignore */ }
|
|
357
|
+
}
|
|
305
358
|
return file
|
|
306
359
|
}
|
|
307
360
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const ONLINE_MS = 3 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
function trimRoot(root) {
|
|
8
|
+
return String(root || '').replace(/\/+$/, '')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function summarize(data, now) {
|
|
12
|
+
const updatedAt = Number(data.updatedAt) || 0
|
|
13
|
+
return {
|
|
14
|
+
nodeId: String(data.nodeId || ''),
|
|
15
|
+
name: String(data.name || data.hostname || data.nodeId || ''),
|
|
16
|
+
hostname: String(data.hostname || ''),
|
|
17
|
+
version: String(data.version || ''),
|
|
18
|
+
platform: String(data.platform || ''),
|
|
19
|
+
pid: Number(data.pid) || 0,
|
|
20
|
+
sessions: Array.isArray(data.sessions) ? data.sessions.length : 0,
|
|
21
|
+
chains: Array.isArray(data.chains)
|
|
22
|
+
? [...new Set(data.chains.map((c) => (typeof c === 'string' ? c : c && c.code)).filter(Boolean))]
|
|
23
|
+
: [],
|
|
24
|
+
updatedAt,
|
|
25
|
+
lastSeen: updatedAt ? new Date(updatedAt).toISOString() : '',
|
|
26
|
+
online: updatedAt > 0 && now - updatedAt < ONLINE_MS,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 云端设备列表:读 <cloudRoot>/presence/*.json(翻页列目录,跳过 .tmp 残骸),
|
|
32
|
+
* 逐个下载解析出 nodeId/名字/版本/最后心跳。
|
|
33
|
+
* 注意 cfg.cloudRoot 本身就是总线根(形如 /dsh云盘/crossterm/bus),不再拼一层 bus。
|
|
34
|
+
*/
|
|
35
|
+
export async function listCloudDevices(pan, cloudRoot, opts = {}) {
|
|
36
|
+
const now = opts.now || Date.now()
|
|
37
|
+
const root = trimRoot(cloudRoot)
|
|
38
|
+
const dir = `${root}/presence`
|
|
39
|
+
const result = { ok: false, dir, devices: [], files: 0, tmpFiles: 0, error: '' }
|
|
40
|
+
if (!root) {
|
|
41
|
+
result.error = '还没配置云盘'
|
|
42
|
+
return result
|
|
43
|
+
}
|
|
44
|
+
let listing
|
|
45
|
+
try {
|
|
46
|
+
listing = await pan.lsAll(dir)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
result.error = String((error && error.message) || error)
|
|
49
|
+
return result
|
|
50
|
+
}
|
|
51
|
+
if (!listing || listing.errno !== 0) {
|
|
52
|
+
// 4 = 目录不存在(还没同步过)
|
|
53
|
+
result.ok = true
|
|
54
|
+
result.error = listing && listing.errno === -9 ? '云端目录不存在' : ''
|
|
55
|
+
return result
|
|
56
|
+
}
|
|
57
|
+
const entries = listing.list || []
|
|
58
|
+
const jsonFiles = entries.filter((f) => f.isdir === 0 && f.server_filename.endsWith('.json'))
|
|
59
|
+
result.files = jsonFiles.length
|
|
60
|
+
result.tmpFiles = entries.filter((f) => f.isdir === 0 && f.server_filename.endsWith('.tmp')).length
|
|
61
|
+
|
|
62
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'ct-devices-'))
|
|
63
|
+
try {
|
|
64
|
+
const byId = new Map()
|
|
65
|
+
for (const entry of jsonFiles) {
|
|
66
|
+
const name = entry.server_filename
|
|
67
|
+
const nodeId = name.slice(0, -'.json'.length)
|
|
68
|
+
const local = path.join(tmpDir, name)
|
|
69
|
+
try {
|
|
70
|
+
await pan.get(`${dir}/${name}`, local)
|
|
71
|
+
const data = JSON.parse(await fsp.readFile(local, 'utf8'))
|
|
72
|
+
if (!data || !data.nodeId) continue
|
|
73
|
+
byId.set(data.nodeId, summarize(data, now))
|
|
74
|
+
} catch {
|
|
75
|
+
/* 单个坏文件不影响整体 */
|
|
76
|
+
}
|
|
77
|
+
if (byId.size >= 50) break
|
|
78
|
+
}
|
|
79
|
+
result.devices = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt)
|
|
80
|
+
result.ok = true
|
|
81
|
+
return result
|
|
82
|
+
} finally {
|
|
83
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 本地自己的 presence(不依赖云端,用来兜底显示"我") */
|
|
88
|
+
export async function localDevice(presenceDir, nodeId, now = Date.now()) {
|
|
89
|
+
try {
|
|
90
|
+
const raw = await fsp.readFile(path.join(presenceDir, `${nodeId}.json`), 'utf8')
|
|
91
|
+
return summarize(JSON.parse(raw), now)
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
package/lib/cloud/pan.js
CHANGED
|
@@ -73,9 +73,27 @@ export function createPan({ appFile, authFile, log = () => {} }) {
|
|
|
73
73
|
return api(`https://pan.baidu.com/rest/2.0/xpan/nas?method=uinfo&access_token=${encodeURIComponent(tok)}`)
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
async function
|
|
76
|
+
async function lsPage(dir, start, limit) {
|
|
77
77
|
await ensureToken()
|
|
78
|
-
return api(`https://pan.baidu.com/rest/2.0/xpan/file?method=list&access_token=${encodeURIComponent(tok)}&dir=${encodeURIComponent(dir)}&order=time&start
|
|
78
|
+
return api(`https://pan.baidu.com/rest/2.0/xpan/file?method=list&access_token=${encodeURIComponent(tok)}&dir=${encodeURIComponent(dir)}&order=time&start=${start}&limit=${limit}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function ls(dir = '/') {
|
|
82
|
+
return lsPage(dir, 0, 200)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 一个目录超过 200 个文件时必须翻页,否则第 201 个之后永远看不见
|
|
86
|
+
// (presence/ 里被 .tmp 残骸占满时,真实 node json 就躲在第二页)。
|
|
87
|
+
async function lsAll(dir = '/', pageSize = 200) {
|
|
88
|
+
const all = []
|
|
89
|
+
for (let page = 0; page < 50; page += 1) {
|
|
90
|
+
const j = await lsPage(dir, page * pageSize, pageSize)
|
|
91
|
+
if (j.errno !== 0) return j
|
|
92
|
+
const list = j.list || []
|
|
93
|
+
all.push(...list)
|
|
94
|
+
if (list.length < pageSize) return { ...j, list: all, errno: 0 }
|
|
95
|
+
}
|
|
96
|
+
return { errno: 0, list: all }
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
async function put(localPath, remotePath) {
|
|
@@ -123,7 +141,7 @@ export function createPan({ appFile, authFile, log = () => {} }) {
|
|
|
123
141
|
await ensureToken()
|
|
124
142
|
const dir = path.posix.dirname(remotePath)
|
|
125
143
|
const name = path.posix.basename(remotePath)
|
|
126
|
-
const list = await
|
|
144
|
+
const list = await lsAll(dir)
|
|
127
145
|
if (list.errno !== 0) throw new Error('列目录失败:' + JSON.stringify(list))
|
|
128
146
|
const hit = (list.list || []).find((f) => f.server_filename === name && f.isdir === 0)
|
|
129
147
|
if (!hit) throw new Error('远端没有该文件:' + remotePath)
|
|
@@ -148,5 +166,5 @@ export function createPan({ appFile, authFile, log = () => {} }) {
|
|
|
148
166
|
)
|
|
149
167
|
}
|
|
150
168
|
|
|
151
|
-
return { uinfo, ls, put, get, rm, ensureToken }
|
|
169
|
+
return { uinfo, ls, lsAll, put, get, rm, ensureToken }
|
|
152
170
|
}
|
package/lib/cloud/sync.js
CHANGED
|
@@ -44,6 +44,9 @@ export function createCloud(opts) {
|
|
|
44
44
|
if (!r.includes('/')) continue
|
|
45
45
|
if (EXCLUDE_TOP.has(r)) continue
|
|
46
46
|
}
|
|
47
|
+
// 原子写的临时文件(xxx.json.<pid>.<hex>.tmp)绝不能当数据同步,
|
|
48
|
+
// 以前漏过滤,云端 presence/ 里攒了两百多个残骸。
|
|
49
|
+
if (e.name.endsWith('.tmp')) continue
|
|
47
50
|
let st
|
|
48
51
|
try { st = fs.statSync(path.join(root, r)) } catch { continue }
|
|
49
52
|
out.push({
|
|
@@ -62,10 +65,11 @@ export function createCloud(opts) {
|
|
|
62
65
|
async function walkCloud() {
|
|
63
66
|
const out = new Map()
|
|
64
67
|
async function rec(dir, rel) {
|
|
65
|
-
const j = await pan.
|
|
68
|
+
const j = await pan.lsAll(dir)
|
|
66
69
|
if (j.errno !== 0) return
|
|
67
70
|
for (const f of j.list || []) {
|
|
68
71
|
if (EXCLUDE_DIRS.has(f.server_filename)) continue
|
|
72
|
+
if (f.server_filename.endsWith('.tmp')) continue
|
|
69
73
|
const r = rel ? path.posix.join(rel, f.server_filename) : f.server_filename
|
|
70
74
|
if (f.isdir === 1) { await rec(f.path, r); continue }
|
|
71
75
|
out.set(r, { size: f.size, mtimeMs: (f.server_mtime || 0) * 1000, fs_id: f.fs_id })
|
package/lib/web.js
CHANGED
|
@@ -5,6 +5,8 @@ import os from 'node:os'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
7
|
import { VERSION, readConfig, writeConfig } from './bus.js'
|
|
8
|
+
import { createPan } from './cloud/pan.js'
|
|
9
|
+
import { listCloudDevices, localDevice } from './cloud/devices.js'
|
|
8
10
|
import { runSetup } from './cloud/setup.js'
|
|
9
11
|
|
|
10
12
|
const PKG = 'dsh-crossterm'
|
|
@@ -147,15 +149,16 @@ function decodeOutput(buffer) {
|
|
|
147
149
|
return text.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '\n')
|
|
148
150
|
}
|
|
149
151
|
|
|
150
|
-
function run(cmd, args, cwd) {
|
|
152
|
+
function run(cmd, args, cwd, opts) {
|
|
151
153
|
return new Promise((resolve) => {
|
|
152
154
|
const chunks = []
|
|
153
155
|
let size = 0
|
|
154
156
|
let child
|
|
157
|
+
const shell = opts && typeof opts.shell === 'boolean' ? opts.shell : process.platform === 'win32'
|
|
155
158
|
try {
|
|
156
159
|
child = spawn(cmd, args, {
|
|
157
160
|
cwd,
|
|
158
|
-
shell
|
|
161
|
+
shell,
|
|
159
162
|
windowsHide: true,
|
|
160
163
|
})
|
|
161
164
|
} catch (error) {
|
|
@@ -191,6 +194,39 @@ function updateErrorText(result) {
|
|
|
191
194
|
return tail
|
|
192
195
|
}
|
|
193
196
|
|
|
197
|
+
// 有些机器没把 `dsh` 命令放进 PATH(桌面版就是),这时退回 DSH 自带运行时里的
|
|
198
|
+
// node + pnpm 直接装:<DSH_HOME>\dsh-runtimes\dsh-primary-runtime\dependencies\{node,pnpm}
|
|
199
|
+
function runtimeTools(cfg) {
|
|
200
|
+
const busDir = (cfg && cfg.busDir) || ''
|
|
201
|
+
const home = busDir ? path.dirname(busDir) : path.join(os.homedir(), '.dsh')
|
|
202
|
+
const deps = path.join(home, 'dsh-runtimes', 'dsh-primary-runtime', 'dependencies')
|
|
203
|
+
return {
|
|
204
|
+
node: path.join(deps, 'node', 'bin', 'node.exe'),
|
|
205
|
+
pnpm: path.join(deps, 'pnpm', 'bin', 'pnpm.cjs'),
|
|
206
|
+
ok:
|
|
207
|
+
fs.existsSync(path.join(deps, 'node', 'bin', 'node.exe')) &&
|
|
208
|
+
fs.existsSync(path.join(deps, 'pnpm', 'bin', 'pnpm.cjs')),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function looksMissingCommand(result) {
|
|
213
|
+
return /not recognized|不是内部或外部命令|无法将.{0,12}项识别|command not found|ENOENT|is not recognized/i.test(
|
|
214
|
+
result.out || '',
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function updatePlugin(term, info) {
|
|
219
|
+
const target = `${PKG}@latest`
|
|
220
|
+
const direct = await run('dsh', ['plugin', '--profile', info.name, 'add', target], info.dir)
|
|
221
|
+
if (direct.code === 0) return { ok: true, via: 'dsh', out: direct.out }
|
|
222
|
+
const miss = looksMissingCommand(direct) || direct.out.trim() === ''
|
|
223
|
+
if (!miss) return { ok: false, via: 'dsh', out: direct.out }
|
|
224
|
+
const tools = runtimeTools(term.cfg)
|
|
225
|
+
if (!tools.ok) return { ok: false, via: 'none', out: updateErrorText(direct) }
|
|
226
|
+
const fallback = await run(tools.node, [tools.pnpm, 'add', target], info.dir, { shell: false })
|
|
227
|
+
return { ok: fallback.code === 0, via: 'pnpm', out: fallback.out || updateErrorText(fallback) }
|
|
228
|
+
}
|
|
229
|
+
|
|
194
230
|
function statusOf(term) {
|
|
195
231
|
const cfg = term.cfg || {}
|
|
196
232
|
const info = profileInfo(cfg.busDir)
|
|
@@ -243,6 +279,43 @@ export function registerWeb(ctx, term, log) {
|
|
|
243
279
|
handler: (req, res) => json(res, 200, statusOf(term)),
|
|
244
280
|
}),
|
|
245
281
|
)
|
|
282
|
+
disposers.push(
|
|
283
|
+
webCtx.webServer.register({
|
|
284
|
+
kind: 'exact',
|
|
285
|
+
path: '/crossterm/api/devices',
|
|
286
|
+
handler: async (req, res) => {
|
|
287
|
+
const cfg = term.cfg || {}
|
|
288
|
+
const info = statusOf(term)
|
|
289
|
+
if (!info.cloud.configured) {
|
|
290
|
+
json(res, 200, {
|
|
291
|
+
ok: false,
|
|
292
|
+
configured: false,
|
|
293
|
+
devices: [],
|
|
294
|
+
message: '这台机器还没连接云盘,看不到其他设备',
|
|
295
|
+
})
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const pan = createPan({
|
|
300
|
+
appFile: cfg.baiduAppFile,
|
|
301
|
+
authFile: cfg.baiduAuthFile,
|
|
302
|
+
log: (m) => log('devices: ' + m),
|
|
303
|
+
})
|
|
304
|
+
const result = await listCloudDevices(pan, cfg.cloudRoot)
|
|
305
|
+
const me = await localDevice(path.join(cfg.busDir || '', 'presence'), info.nodeId || '')
|
|
306
|
+
if (me && !result.devices.some((d) => d.nodeId === me.nodeId)) result.devices.unshift(me)
|
|
307
|
+
json(res, 200, { ...result, configured: true, me: info.nodeId || '' })
|
|
308
|
+
} catch (error) {
|
|
309
|
+
json(res, 200, {
|
|
310
|
+
ok: false,
|
|
311
|
+
configured: true,
|
|
312
|
+
devices: [],
|
|
313
|
+
message: String((error && error.message) || error),
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
)
|
|
246
319
|
disposers.push(
|
|
247
320
|
webCtx.webServer.register({
|
|
248
321
|
kind: 'exact',
|
|
@@ -352,15 +425,17 @@ export function registerWeb(ctx, term, log) {
|
|
|
352
425
|
})
|
|
353
426
|
return
|
|
354
427
|
}
|
|
355
|
-
const result = await
|
|
428
|
+
const result = await updatePlugin(term, info)
|
|
356
429
|
json(res, 200, {
|
|
357
|
-
ok: result.
|
|
430
|
+
ok: result.ok,
|
|
358
431
|
mode: 'registry',
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
432
|
+
via: result.via,
|
|
433
|
+
needRestart: result.ok,
|
|
434
|
+
message: result.ok
|
|
435
|
+
? `已更新${result.via === 'pnpm' ? '(用 DSH 自带 pnpm)' : ''},重启 DSH 后生效`
|
|
436
|
+
: result.via === 'none'
|
|
437
|
+
? result.out
|
|
438
|
+
: (result.out || '').slice(-1200) || `更新失败(退出码不明)`,
|
|
364
439
|
})
|
|
365
440
|
},
|
|
366
441
|
}),
|
|
@@ -382,3 +457,6 @@ export function registerWeb(ctx, term, log) {
|
|
|
382
457
|
if (typeof log === 'function') log(`web 路由注册失败:${(error && error.message) || error}`)
|
|
383
458
|
}
|
|
384
459
|
}
|
|
460
|
+
|
|
461
|
+
// 只给探针用:把内部小工具导出来,宿主运行时不依赖这些导出
|
|
462
|
+
export { decodeOutput, runtimeTools, looksMissingCommand, updateErrorText }
|