dsh-mcloud 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,2321 @@
1
+ /**
2
+ * dsh-mcloud — Host half(宿主端)
3
+ *
4
+ * 把「中国移动云盘 / 139」接进 DeepSeek Harness:
5
+ * · 工具(ctx.tools.register):容量、列目录、搜索、下载直链、新建/改名/移动/删除
6
+ * · HTTP 路由(/api/dsh-mcloud/*):给会话页那个「移动云盘」标签面板用
7
+ * · 凭据:Windows 走当前用户 DPAPI 加密,存在 ~/.dsh-mcloud/
8
+ *
9
+ * 协议说明:官方没有公开 MCP,这里按社区逆向出来的 personal_new(新的个人盘)接口实现,
10
+ * 主要参考 OpenList 的 drivers/139(AGPL-3.0)与 cloud_driver_sdk 的 Yun139。
11
+ * 关键点:
12
+ * · 请求要带 Mcloud-Sign:对「实际发出的 JSON 字符串」做 encodeURIComponent → 逐字符排序 →
13
+ * base64 → md5(b64) + md5(ts:rand) → 再 md5 取大写。签名和 body 必须来自同一个字符串。
14
+ * · Authorization: Basic <base64("pc:账号:authToken|...|到期毫秒")>
15
+ * · 业务 host 要先问 /user/route/qryRoutePolicy 拿,失败再回落到 personal-kd-njs。
16
+ *
17
+ * 写入类操作全部默认要过 Harness 审批。
18
+ */
19
+
20
+ import {
21
+ readFileSync,
22
+ writeFileSync,
23
+ mkdirSync,
24
+ renameSync,
25
+ rmSync,
26
+ statSync,
27
+ createWriteStream,
28
+ } from 'node:fs'
29
+ import { readdir } from 'node:fs/promises'
30
+ import { homedir, tmpdir } from 'node:os'
31
+ import { join, resolve, basename, dirname } from 'node:path'
32
+ import { randomUUID, randomBytes, createHash } from 'node:crypto'
33
+ import { spawnSync, spawn } from 'node:child_process'
34
+ import { pipeline } from 'node:stream/promises'
35
+ import {
36
+ PART_BATCH,
37
+ UPLOAD_CONTENT_TYPE,
38
+ buildPartInfos,
39
+ partSizeFor,
40
+ putParts,
41
+ sha256File,
42
+ streamToFile,
43
+ } from './transfer.js'
44
+ import { loginWithPassword } from './login.js'
45
+ import { scanLogin } from './scan-login.js'
46
+
47
+ export const name = 'dsh-mcloud'
48
+ export const inject = ['tools']
49
+
50
+ // ------------------------------------------------------------------ 常量
51
+
52
+ const USER_HOST = 'https://user-njs.yun.139.com'
53
+ const FALLBACK_PERSONAL_HOST = 'https://personal-kd-njs.yun.139.com/hcy'
54
+ const REFRESH_URL = 'https://aas.caiyun.feixin.10086.cn:443/tellin/authTokenRefresh.do'
55
+ const WRITE_HEADER = 'x-dsh-mcloud'
56
+ const REQUEST_TIMEOUT_MS = 25_000
57
+
58
+ /** 瞬时网络故障的重试策略(移动云盘偶发 ECONNRESET / 超时 / 5xx) */
59
+ const MCLOUD_RETRY_ATTEMPTS = 3
60
+ const MCLOUD_RETRY_DELAY_MS = 400
61
+
62
+ /**
63
+ * 目录分页大小。
64
+ * 实测(同一个 100 项的目录,各测 3 次取中位数):
65
+ * pageSize= 10 → 1353 ms
66
+ * pageSize= 20 → 6631 ms
67
+ * pageSize= 30 → 13692 ms
68
+ * pageSize= 50 → 16088 ms
69
+ * pageSize=100 → 25311 ms(还会超时/ECONNRESET)
70
+ * 这个接口对「一次要多少条」极度敏感,几乎是二次方恶化。
71
+ * 所以只请求 10 项 —— 首屏要的是「快」,剩下的靠「加载更多」。
72
+ */
73
+ const LIST_PAGE_SIZE = 10
74
+
75
+ /**
76
+ * 目录列表缓存。点文件夹「慢」的很大一部分原因是每次进出都重新拉一遍,
77
+ * 而移动云盘对同一目录的重复请求还会限流。缓存 60 秒,任何写操作都会清掉。
78
+ */
79
+ const LIST_CACHE_TTL_MS = 60_000
80
+ const LIST_CACHE_MAX = 200
81
+
82
+ const ROOT_ID = '/'
83
+ const MB = 1024 * 1024
84
+
85
+ const DEFAULT_CREDENTIAL_DIR = process.env.DSH_MCLOUD_CREDENTIAL_DIR
86
+ ? resolve(process.env.DSH_MCLOUD_CREDENTIAL_DIR)
87
+ : join(homedir(), '.dsh-mcloud')
88
+
89
+ /**
90
+ * 老版本(插件还叫 dsh-mobile-cloud 时)把凭据存在这里。
91
+ * 新目录没有凭据时回落到它,免得升级后用户还得重新授权一次。
92
+ */
93
+ const LEGACY_CREDENTIAL_DIRS = [
94
+ process.env.DSH_MOBILE_CLOUD_CREDENTIAL_DIR
95
+ ? resolve(process.env.DSH_MOBILE_CLOUD_CREDENTIAL_DIR)
96
+ : join(homedir(), '.dsh-mobile-cloud'),
97
+ ]
98
+
99
+ /** 「下载到本地」不填路径时的默认落地目录 */
100
+ const DEFAULT_DOWNLOAD_DIR = join(homedir(), 'Downloads', 'dsh-mcloud')
101
+
102
+ const TOOL_PREFIX = 'mcloud_'
103
+ const WRITE_TOOL_NAMES = new Set([
104
+ TOOL_PREFIX + 'mkdir',
105
+ TOOL_PREFIX + 'rename',
106
+ TOOL_PREFIX + 'move',
107
+ TOOL_PREFIX + 'remove',
108
+ // 上传会改网盘内容;下载会往本机磁盘写文件(路径是模型给的),都值得确认一下
109
+ TOOL_PREFIX + 'upload',
110
+ TOOL_PREFIX + 'upload_folder',
111
+ TOOL_PREFIX + 'download',
112
+ TOOL_PREFIX + 'download_folder',
113
+ ])
114
+
115
+ const windows = process.platform === 'win32'
116
+
117
+ // ------------------------------------------------------------------ 小工具
118
+
119
+ function unwrapConfig(raw) {
120
+ if (!raw || typeof raw !== 'object') return {}
121
+ if (raw.config && typeof raw.config === 'object') return raw.config
122
+ return raw
123
+ }
124
+
125
+ function md5(input) {
126
+ return createHash('md5').update(input, 'utf8').digest('hex')
127
+ }
128
+
129
+ function randomString(length) {
130
+ const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
131
+ const bytes = randomBytes(length)
132
+ let out = ''
133
+ for (let i = 0; i < length; i += 1) out += alphabet[bytes[i] % alphabet.length]
134
+ return out
135
+ }
136
+
137
+ /** 对应 Go 的 time.Now().Format("2006-01-02 15:04:05"),本地时间 */
138
+ function formatTimestamp(date = new Date()) {
139
+ const pad = (n) => String(n).padStart(2, '0')
140
+ return (
141
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
142
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
143
+ )
144
+ }
145
+
146
+ /**
147
+ * Mcloud-Sign 算法。
148
+ * 注意 encodeURIComponent 与 OpenList 那个辅助函数等价(Go 侧是把 QueryEscape 的
149
+ * + %21 %27 %28 %29 %2A 再改回原字符),所以这里直接用 encodeURIComponent。
150
+ */
151
+ function calcSign(bodyString, timestamp, randStr) {
152
+ const encoded = encodeURIComponent(bodyString)
153
+ const sorted = encoded.split('').sort().join('')
154
+ const b64 = Buffer.from(sorted, 'utf8').toString('base64')
155
+ const mixed = md5(b64) + md5(`${timestamp}:${randStr}`)
156
+ return md5(mixed).toUpperCase()
157
+ }
158
+
159
+ function errorMessage(error) {
160
+ return error instanceof Error ? error.message : String(error)
161
+ }
162
+
163
+ function sleep(ms) {
164
+ return new Promise((resolve) => setTimeout(resolve, ms))
165
+ }
166
+
167
+ function clampPageSize(value) {
168
+ const n = Number(value)
169
+ if (!Number.isFinite(n) || n <= 0) return LIST_PAGE_SIZE
170
+ return Math.min(Math.max(Math.trunc(n), 1), 100)
171
+ }
172
+
173
+ /** 极简 LRU:Map 的插入顺序就是访问顺序,超量就删最旧的。 */
174
+ function createCache(ttlMs, maxEntries) {
175
+ const map = new Map()
176
+ return {
177
+ get(key) {
178
+ const hit = map.get(key)
179
+ if (!hit) return null
180
+ if (Date.now() - hit.at > ttlMs) {
181
+ map.delete(key)
182
+ return null
183
+ }
184
+ // 命中就挪到末尾(最近使用)
185
+ map.delete(key)
186
+ map.set(key, hit)
187
+ return hit.value
188
+ },
189
+ set(key, value) {
190
+ map.set(key, { at: Date.now(), value })
191
+ while (map.size > maxEntries) {
192
+ const oldest = map.keys().next().value
193
+ map.delete(oldest)
194
+ }
195
+ },
196
+ clear() {
197
+ map.clear()
198
+ },
199
+ get size() {
200
+ return map.size
201
+ },
202
+ }
203
+ }
204
+
205
+ /**
206
+ * undici 的 fetch 失败只会说 "fetch failed",真正的原因(ECONNRESET / ENOTFOUND /
207
+ * ETIMEDOUT…)藏在 error.cause 里。把它挖出来,用户才看得懂到底怎么了。
208
+ */
209
+ function describeFetchError(error) {
210
+ if (!error) return '未知网络错误'
211
+ const cause = error.cause
212
+ if (cause) {
213
+ const code = cause.code || cause.errno
214
+ const detail = cause.message || ''
215
+ if (code && detail) return `${code}:${detail}`
216
+ if (code) return String(code)
217
+ if (detail) return detail
218
+ }
219
+ return errorMessage(error)
220
+ }
221
+
222
+ /**
223
+ * 判断「重试也没用」的错误,别白等:
224
+ * 授权过期、参数不合法、文件不存在这类都得用户动手,重试纯浪费时间。
225
+ */
226
+ function isPermanentError(error) {
227
+ if (!error) return true
228
+ if (error.name === 'AbortError') return true
229
+ const message = errorMessage(error)
230
+ // 认证/授权类错误:重试一百次也没用,必须让用户重新登录
231
+ if (/认证失败|暂无权限|授权已过期|还没有授权|不是合法|结构不对|参数不合法|不存在|没有权限|已取消|登录失败/.test(message)) {
232
+ return true
233
+ }
234
+ return false
235
+ }
236
+
237
+ /**
238
+ * 账号脱敏。移动云盘这边没有能查昵称的接口(试过 /user/info 等,全是「资源不存在」),
239
+ * 而 Authorization 里带的账号就是一个手机号,所以面板默认只显示脱敏串。
240
+ */
241
+ function maskAccount(value) {
242
+ if (!value) return null
243
+ const text = String(value)
244
+ // 手机号:只露前 3 位和后 2 位,中间全隐藏
245
+ if (/^\d{11}$/.test(text)) {
246
+ return `${text.slice(0, 3)}${'*'.repeat(6)}${text.slice(-2)}`
247
+ }
248
+ if (text.includes('@')) {
249
+ const at = text.indexOf('@')
250
+ const name = text.slice(0, at)
251
+ const domain = text.slice(at)
252
+ const head = name.slice(0, Math.min(2, name.length))
253
+ return `${head}${'*'.repeat(Math.max(1, name.length - head.length))}${domain}`
254
+ }
255
+ if (text.length <= 5) return '*'.repeat(text.length)
256
+ return `${text.slice(0, 3)}${'*'.repeat(text.length - 5)}${text.slice(-2)}`
257
+ }
258
+
259
+ // ------------------------------------------------------------------ 凭据
260
+
261
+ function dpapi(input, decrypt = false) {
262
+ const script = [
263
+ "$ErrorActionPreference = 'Stop'",
264
+ 'Add-Type -AssemblyName System.Security',
265
+ '$bytes = [Convert]::FromBase64String([Console]::In.ReadToEnd())',
266
+ '$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser',
267
+ `$result = [Security.Cryptography.ProtectedData]::${decrypt ? 'Unprotect' : 'Protect'}($bytes, $null, $scope)`,
268
+ '[Console]::Out.Write([Convert]::ToBase64String($result))',
269
+ ].join('; ')
270
+ const executable = join(
271
+ process.env.SystemRoot ?? 'C:\\Windows',
272
+ 'System32',
273
+ 'WindowsPowerShell',
274
+ 'v1.0',
275
+ 'powershell.exe',
276
+ )
277
+ const result = spawnSync(executable, ['-NoProfile', '-NonInteractive', '-Command', script], {
278
+ input: input.toString('base64'),
279
+ encoding: 'utf8',
280
+ windowsHide: true,
281
+ timeout: 20_000,
282
+ })
283
+ if (result.status !== 0 || !result.stdout?.trim()) {
284
+ throw new Error('Windows 无法访问凭据存储(DPAPI 调用失败)')
285
+ }
286
+ return Buffer.from(result.stdout.trim(), 'base64')
287
+ }
288
+
289
+ function createCredentialStore(directory, legacyDirectories = []) {
290
+ const fileName = windows ? 'authorization.dpapi' : 'authorization.json'
291
+ const file = join(directory, fileName)
292
+
293
+ /** 把某个文件解成凭据;解不开就返回 null。 */
294
+ function readFrom(target) {
295
+ let bytes
296
+ try {
297
+ bytes = readFileSync(target)
298
+ } catch (error) {
299
+ if (error.code === 'ENOENT') return null
300
+ throw error
301
+ }
302
+ try {
303
+ const text = (windows ? dpapi(bytes, true) : bytes).toString('utf8')
304
+ const parsed = JSON.parse(text)
305
+ if (!parsed || typeof parsed.authorization !== 'string' || !parsed.authorization) return null
306
+ return {
307
+ authorization: parsed.authorization,
308
+ userDomainId: typeof parsed.userDomainId === 'string' ? parsed.userDomainId : '',
309
+ // 只有用户主动勾选「保存密码」时才有;明文密码同样走 DPAPI 加密落盘
310
+ savedAccount: typeof parsed.savedAccount === 'string' ? parsed.savedAccount : '',
311
+ savedPassword: typeof parsed.savedPassword === 'string' ? parsed.savedPassword : '',
312
+ }
313
+ } catch {
314
+ // 存储坏了就当没授权,别把整个插件拖死
315
+ return null
316
+ }
317
+ }
318
+
319
+ return {
320
+ file,
321
+ read() {
322
+ // 先看新位置
323
+ const current = readFrom(file)
324
+ if (current) return current
325
+ // 再看旧位置(插件改名前叫 dsh-mobile-cloud)
326
+ for (const legacyDir of legacyDirectories) {
327
+ const legacy = readFrom(join(legacyDir, fileName))
328
+ if (legacy) return legacy
329
+ }
330
+ return null
331
+ },
332
+ write(value) {
333
+ const payload = Buffer.from(JSON.stringify(value), 'utf8')
334
+ const stored = windows ? dpapi(payload) : payload
335
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
336
+ const temporary = join(directory, randomUUID())
337
+ try {
338
+ writeFileSync(temporary, stored, { mode: 0o600, flag: 'wx' })
339
+ renameSync(temporary, file)
340
+ } finally {
341
+ rmSync(temporary, { force: true })
342
+ }
343
+ },
344
+ clear() {
345
+ rmSync(file, { force: true })
346
+ // 老位置也一起清掉,否则「清除授权」看起来没生效
347
+ for (const legacyDir of legacyDirectories) {
348
+ rmSync(join(legacyDir, fileName), { force: true })
349
+ }
350
+ },
351
+ }
352
+ }
353
+
354
+ // ------------------------------------------------------------------ Authorization 解析 / 刷新
355
+
356
+ function decodeAuthorization(authorization) {
357
+ let decoded
358
+ try {
359
+ decoded = Buffer.from(authorization, 'base64').toString('utf8')
360
+ } catch {
361
+ throw new Error('Authorization 不是合法的 base64')
362
+ }
363
+ const firstColon = decoded.indexOf(':')
364
+ const secondColon = firstColon < 0 ? -1 : decoded.indexOf(':', firstColon + 1)
365
+ if (firstColon < 0 || secondColon < 0) {
366
+ throw new Error('Authorization 结构不对,应该是 base64 的 pc:账号:令牌')
367
+ }
368
+ const account = decoded.slice(firstColon + 1, secondColon)
369
+ const tokenPart = decoded.slice(secondColon + 1)
370
+ const fields = tokenPart.split('|')
371
+ const expiration = fields.length >= 4 ? Number(fields[3]) : Number.NaN
372
+ return { account, tokenPart, expiration: Number.isFinite(expiration) ? expiration : null }
373
+ }
374
+
375
+ function readXmlField(xml, tag) {
376
+ const matched = new RegExp(`<${tag}>\\s*([\\s\\S]*?)\\s*</${tag}>`, 'i').exec(xml)
377
+ return matched ? matched[1] : ''
378
+ }
379
+
380
+ // ------------------------------------------------------------------ 原生文件对话框
381
+
382
+ const PICKER_TIMEOUT_MS = 5 * 60 * 1000
383
+
384
+ function powershellPath() {
385
+ return join(
386
+ process.env.SystemRoot ?? 'C:\\Windows',
387
+ 'System32',
388
+ 'WindowsPowerShell',
389
+ 'v1.0',
390
+ 'powershell.exe',
391
+ )
392
+ }
393
+
394
+ // 弹系统对话框踩过的坑,一次说清:
395
+ //
396
+ // 1) **对话框会比浏览器低,用户看不见**。Windows 前台锁定规则不允许后台进程抢焦点,
397
+ // SetForegroundWindow 直接返回 False。做法:对话框一出来就 SetWindowPos(HWND_TOPMOST)
398
+ // 顶上去;因为 ShowDialog 是模态的,得靠 WinForms Timer 在模态消息循环里抢。
399
+ // (实测 z 序:修复前「对话框 54 / DSH 45」,修复后「对话框 13 / DSH 57」。)
400
+ // 2) **千万别给对话框传一个 1x1 的 helper 窗体当 owner**。WinForms 会把对话框
401
+ // **居中到 owner 上**,owner 在 (0,0) 就导致对话框被挤到屏幕左上角、大半在屏幕外
402
+ // —— 用户看到的就是「左上角一块白色长方形」,而且完全不像正常的文件对话框。
403
+ // 所以这里**不加 owner**:ShowDialog() 让系统自己居中。
404
+ // (实测:RECT L=220 T=161 W=664 H=572,1536x864 屏幕上完全可见。)
405
+ // 3) **定时器要耐心**。早期版本只找 6 秒,机器一忙对话框还没出来就放弃了,
406
+ // 于是「第一次能弹、后面点几次才出来」。现在最多找 60 秒,出现后再顶住约 3 秒。
407
+ // 4) **路径不能用管道明文回传**。PowerShell 写管道用控制台代码页(国区 GBK),
408
+ // Node 按 UTF-8 解会乱码 —— 文件名一带中文就废。所以用 base64(纯 ASCII)。
409
+ const PICKER_PRELUDE = `
410
+ $ErrorActionPreference = 'Stop'
411
+ Add-Type -AssemblyName System.Windows.Forms | Out-Null
412
+ Add-Type -AssemblyName System.Drawing | Out-Null
413
+ Add-Type -Namespace MC -Name Win -MemberDefinition @'
414
+ [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
415
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
416
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
417
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
418
+ [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
419
+ [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach);
420
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassNameW(IntPtr hWnd, System.Text.StringBuilder s, int n);
421
+ public delegate bool EnumProc(IntPtr h, IntPtr l);
422
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);
423
+ '@
424
+
425
+ $script:me = [uint32][System.Diagnostics.Process]::GetCurrentProcess().Id
426
+ $script:TOPMOST = [IntPtr]::new([int64](-1))
427
+ $script:FLAGS = 0x0001 -bor 0x0002 -bor 0x0010 -bor 0x0040
428
+
429
+ function Set-MyTopmost([IntPtr]$h) {
430
+ if ($h -eq [IntPtr]::Zero) { return }
431
+ [void][MC.Win]::SetWindowPos($h, $script:TOPMOST, 0, 0, 0, 0, $script:FLAGS)
432
+ $fg = [MC.Win]::GetForegroundWindow()
433
+ $fgPid = 0
434
+ $fgTid = [MC.Win]::GetWindowThreadProcessId($fg, [ref]$fgPid)
435
+ $curTid = [MC.Win]::GetCurrentThreadId()
436
+ $attached = $false
437
+ if ($fgTid -ne 0 -and $fgTid -ne $curTid) {
438
+ $attached = [MC.Win]::AttachThreadInput($curTid, $fgTid, $true)
439
+ }
440
+ try {
441
+ [void][MC.Win]::SetForegroundWindow($h)
442
+ } finally {
443
+ if ($attached) { [void][MC.Win]::AttachThreadInput($curTid, $fgTid, $false) }
444
+ }
445
+ }
446
+
447
+ # 本进程当前开着的那个 #32770 对话框(OpenFileDialog / FolderBrowserDialog 都是这个类)
448
+ function Get-MyDialog {
449
+ $script:hit = [IntPtr]::Zero
450
+ $cb = [MC.Win+EnumProc]{
451
+ param([IntPtr]$h, [IntPtr]$l)
452
+ $p = 0
453
+ [void][MC.Win]::GetWindowThreadProcessId($h, [ref]$p)
454
+ if ($p -eq $script:me) {
455
+ $sb = New-Object System.Text.StringBuilder 64
456
+ [void][MC.Win]::GetClassNameW($h, $sb, 64)
457
+ if ($sb.ToString() -eq '#32770') { $script:hit = $h; return $false }
458
+ }
459
+ return $true
460
+ }
461
+ [void][MC.Win]::EnumWindows($cb, [IntPtr]::Zero)
462
+ return $script:hit
463
+ }
464
+
465
+ $script:ticks = 0
466
+ $script:pushed = 0
467
+ $script:timer = New-Object System.Windows.Forms.Timer
468
+ $script:timer.Interval = 150
469
+ $script:timer.Add_Tick({
470
+ $script:ticks = $script:ticks + 1
471
+ $h = Get-MyDialog
472
+ if ($h -ne [IntPtr]::Zero) {
473
+ Set-MyTopmost $h
474
+ $script:pushed = $script:pushed + 1
475
+ if ($script:pushed -ge 20) { $script:timer.Stop() }
476
+ } elseif ($script:ticks -ge 400) {
477
+ $script:timer.Stop()
478
+ }
479
+ })
480
+ $script:timer.Start()
481
+
482
+ $emit = { param($p) [Console]::Out.WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($p))) }
483
+ `
484
+
485
+ const PICKER_FILES = `${PICKER_PRELUDE}
486
+ $d = New-Object System.Windows.Forms.OpenFileDialog
487
+ $d.AutoUpgradeEnabled = $true
488
+ $d.Title = '选择要上传到移动云盘的文件(可多选)'
489
+ $d.Multiselect = $true
490
+ $d.CheckFileExists = $true
491
+ $r = $d.ShowDialog()
492
+ $script:timer.Stop()
493
+ if ($r -eq [System.Windows.Forms.DialogResult]::OK) {
494
+ foreach ($f in $d.FileNames) { & $emit $f }
495
+ }
496
+ `
497
+
498
+ const PICKER_FOLDER = `${PICKER_PRELUDE}
499
+ $d = New-Object System.Windows.Forms.FolderBrowserDialog
500
+ $d.Description = '选择要上传到移动云盘的文件夹'
501
+ $d.ShowNewFolderButton = $false
502
+ $r = $d.ShowDialog()
503
+ $script:timer.Stop()
504
+ if ($r -eq [System.Windows.Forms.DialogResult]::OK) { & $emit $d.SelectedPath }
505
+ `
506
+
507
+ /**
508
+ * 弹系统原生的「打开文件 / 选择文件夹」对话框,返回选中的真实路径。
509
+ * 走的是宿主端进程,所以拿到的是磁盘真实路径 —— 上传时直接从磁盘流式读,
510
+ * 不用把文件内容先搬到浏览器再搬回来。
511
+ */
512
+ function pickLocalPaths(mode) {
513
+ if (!windows) {
514
+ return Promise.reject(new Error('原生文件对话框目前只支持 Windows,请直接手填路径'))
515
+ }
516
+ const script = mode === 'folder' ? PICKER_FOLDER : PICKER_FILES
517
+ return new Promise((resolvePick, rejectPick) => {
518
+ const child = spawn(powershellPath(), ['-NoProfile', '-STA', '-Command', script], {
519
+ windowsHide: true,
520
+ })
521
+ let stdout = ''
522
+ let stderr = ''
523
+ const timer = setTimeout(() => {
524
+ try {
525
+ child.kill()
526
+ } catch {
527
+ /* 已经退了 */
528
+ }
529
+ rejectPick(new Error('等待选择超时(5 分钟)'))
530
+ }, PICKER_TIMEOUT_MS)
531
+
532
+ child.stdout.setEncoding('utf8')
533
+ child.stderr.setEncoding('utf8')
534
+ child.stdout.on('data', (chunk) => {
535
+ stdout += chunk
536
+ })
537
+ child.stderr.on('data', (chunk) => {
538
+ stderr += chunk
539
+ })
540
+ child.on('error', (error) => {
541
+ clearTimeout(timer)
542
+ rejectPick(error)
543
+ })
544
+ child.on('close', (code) => {
545
+ clearTimeout(timer)
546
+ if (code !== 0 && !stdout.trim()) {
547
+ rejectPick(new Error(`对话框异常退出(code=${code}):${stderr.trim().slice(0, 300) || '无输出'}`))
548
+ return
549
+ }
550
+ const paths = stdout
551
+ .split(/\r?\n/)
552
+ .map((line) => line.trim())
553
+ .filter(Boolean)
554
+ .map((line) => {
555
+ try {
556
+ return Buffer.from(line, 'base64').toString('utf8')
557
+ } catch {
558
+ return line // 万一不是 base64,原样返回也好过直接丢掉
559
+ }
560
+ })
561
+ .filter(Boolean)
562
+ resolvePick(paths)
563
+ })
564
+ })
565
+ }
566
+
567
+ /** 递归列出本地目录,用来做「上传整个文件夹」 */
568
+ async function walkLocal(dir, prefix, tree, depth = 0) {
569
+ if (depth > 32) throw new Error('本地目录层级过深,已中止')
570
+ const entries = await readdir(dir, { withFileTypes: true })
571
+ for (const entry of entries) {
572
+ const full = join(dir, entry.name)
573
+ const relative = prefix ? join(prefix, entry.name) : entry.name
574
+ if (entry.isDirectory()) {
575
+ tree.dirs.push(relative)
576
+ await walkLocal(full, relative, tree, depth + 1)
577
+ } else if (entry.isFile()) {
578
+ let size = 0
579
+ try {
580
+ size = statSync(full).size
581
+ } catch {
582
+ continue // 读不到就跳过,别让一个坏文件毁掉整次上传
583
+ }
584
+ tree.files.push({ local: full, relative, size })
585
+ }
586
+ }
587
+ }
588
+
589
+ // ------------------------------------------------------------------ 客户
590
+
591
+ function createClient(store, logger, config) {
592
+ let credential = store.read()
593
+ let host = null
594
+ let quirkNoUserDomainId = false
595
+
596
+ function current() {
597
+ return credential
598
+ }
599
+
600
+ function update(next) {
601
+ credential = next
602
+ store.write(next)
603
+ quirkNoUserDomainId = false
604
+ }
605
+
606
+ function forget() {
607
+ credential = null
608
+ store.clear()
609
+ }
610
+
611
+ function assertAuthorized() {
612
+ if (!credential?.authorization) {
613
+ throw new Error('还没有登录。请打开「移动云盘」面板,用扫码或账号密码登录。')
614
+ }
615
+ return credential
616
+ }
617
+
618
+ /** 令牌快到期(<15 天)就换新的,跟 OpenList 一个判断 */
619
+ async function ensureFresh() {
620
+ const value = assertAuthorized()
621
+ const info = decodeAuthorization(value.authorization)
622
+ if (info.expiration !== null) {
623
+ const remaining = info.expiration - Date.now()
624
+ if (remaining < 0) throw new Error('登录已过期,请重新登录')
625
+ if (remaining > 15 * 24 * 60 * 60 * 1000) return value
626
+ }
627
+ const body =
628
+ `<root><token>${info.tokenPart}</token><account>${info.account}</account><clienttype>656</clienttype></root>`
629
+ const response = await fetch(REFRESH_URL, {
630
+ method: 'POST',
631
+ headers: { 'content-type': 'application/xml' },
632
+ body,
633
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
634
+ })
635
+ const xml = await response.text()
636
+ if (readXmlField(xml, 'return') !== '0') {
637
+ throw new Error(`刷新授权失败:${readXmlField(xml, 'desc') || xml.slice(0, 200)}`)
638
+ }
639
+ const token = readXmlField(xml, 'token')
640
+ if (!token) throw new Error('刷新授权失败:返回里没有 token')
641
+ const next = {
642
+ authorization: Buffer.from(`pc:${info.account}:${token}`, 'utf8').toString('base64'),
643
+ userDomainId: value.userDomainId,
644
+ }
645
+ update(next)
646
+ logger?.info?.('mobile-cloud: 授权已自动续期')
647
+ return next
648
+ }
649
+
650
+ function buildHeaders(bodyString, personal) {
651
+ const timestamp = formatTimestamp()
652
+ const randStr = randomString(16)
653
+ const sign = calcSign(bodyString, timestamp, randStr)
654
+ const base = {
655
+ accept: 'application/json, text/plain, */*',
656
+ 'content-type': 'application/json;charset=UTF-8',
657
+ authorization: `Basic ${assertAuthorized().authorization}`,
658
+ 'mcloud-channel': '1000101',
659
+ 'mcloud-client': '10701',
660
+ 'mcloud-sign': `${timestamp},${randStr},${sign}`,
661
+ 'mcloud-version': '7.14.0',
662
+ 'x-deviceinfo': '||9|7.14.0|chrome|120.0.0.0|||windows 10||zh-CN|||',
663
+ 'x-huawei-channelsrc': '10000034',
664
+ 'x-inner-ntwk': '2',
665
+ 'x-m4c-caller': 'PC',
666
+ 'x-m4c-src': '10002',
667
+ 'x-svctype': '1',
668
+ }
669
+ if (!personal) {
670
+ return {
671
+ ...base,
672
+ 'cms-device': 'default',
673
+ origin: 'https://yun.139.com',
674
+ referer: 'https://yun.139.com/w/',
675
+ 'inner-hcy-router-https': '1',
676
+ }
677
+ }
678
+ return {
679
+ ...base,
680
+ caller: 'web',
681
+ 'cms-device': 'default',
682
+ 'mcloud-route': '001',
683
+ 'x-yun-api-version': 'v1',
684
+ 'x-yun-app-channel': '10000034',
685
+ 'x-yun-channel-source': '10000034',
686
+ 'x-yun-client-info':
687
+ '||9|7.14.0|chrome|120.0.0.0|||windows 10||zh-CN|||dW5kZWZpbmVk||',
688
+ 'x-yun-module-type': '100',
689
+ 'x-yun-svc-type': '1',
690
+ }
691
+ }
692
+
693
+ /**
694
+ * 发一个带签名的请求。
695
+ * 签名和 body 一定来自同一个字符串 —— 这是这个接口唯一容易踩的坑。
696
+ *
697
+ * 网络层带重试:移动云盘偶发 ECONNRESET / 超时 / 5xx,以前一抖就把
698
+ * 「fetch failed」原样抛给界面(目录加载不出来、删除失败都是它)。
699
+ * 只对**瞬时错误**重试;业务错误(success:false)立即抛出,不浪费用户时间。
700
+ */
701
+ async function call(url, payload, { personal = true, signal } = {}) {
702
+ const bodyString = JSON.stringify(payload ?? {})
703
+ const headers = buildHeaders(bodyString, personal)
704
+
705
+ let lastError = null
706
+ for (let attempt = 1; attempt <= MCLOUD_RETRY_ATTEMPTS; attempt += 1) {
707
+ try {
708
+ const response = await fetch(url, {
709
+ method: 'POST',
710
+ headers,
711
+ body: bodyString,
712
+ signal: signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
713
+ })
714
+
715
+ // 5xx / 429 属于「待会儿再试可能就好」,值得重试
716
+ if ((response.status >= 500 || response.status === 429) && attempt < MCLOUD_RETRY_ATTEMPTS) {
717
+ lastError = new Error(`移动云盘暂时不可用(HTTP ${response.status})`)
718
+ await sleep(MCLOUD_RETRY_DELAY_MS * attempt)
719
+ continue
720
+ }
721
+
722
+ const text = await response.text()
723
+ let json = null
724
+ try {
725
+ json = JSON.parse(text)
726
+ } catch {
727
+ throw new Error(`移动云盘返回了非 JSON 内容(HTTP ${response.status}):${text.slice(0, 200)}`)
728
+ }
729
+ if (json && json.success === false) {
730
+ // 业务错误:重试没用,直接抛。
731
+ throw new Error(json.message || json.code || '移动云盘拒绝了这次请求')
732
+ }
733
+ return json
734
+ } catch (error) {
735
+ lastError = error
736
+ if (isPermanentError(error) || attempt >= MCLOUD_RETRY_ATTEMPTS) {
737
+ // 三次都失败:把真正的原因翻出来,别只给一句 "fetch failed"。
738
+ if (/fetch failed/i.test(errorMessage(error))) {
739
+ throw new Error(`连不上移动云盘(已重试 ${attempt} 次):${describeFetchError(error)}`)
740
+ }
741
+ throw error
742
+ }
743
+ logger?.warn?.(`mobile-cloud: 第 ${attempt} 次请求失败,准备重试(${describeFetchError(error)})`)
744
+ await sleep(MCLOUD_RETRY_DELAY_MS * attempt)
745
+ }
746
+ }
747
+ throw lastError ?? new Error('移动云盘请求失败')
748
+ }
749
+
750
+ function personalUrl(pathname) {
751
+ if (!host) throw new Error('移动云盘 host 还没解析出来')
752
+ return host + pathname
753
+ }
754
+
755
+ async function resolveHost() {
756
+ const value = assertAuthorized()
757
+ const account = decodeAuthorization(value.authorization).account
758
+ try {
759
+ const json = await call(
760
+ `${USER_HOST}/user/route/qryRoutePolicy`,
761
+ {
762
+ userInfo: { userType: 1, accountType: 1, accountName: account },
763
+ modAddrType: 1,
764
+ },
765
+ { personal: false },
766
+ )
767
+ const list = json?.data?.routePolicyList
768
+ if (Array.isArray(list)) {
769
+ for (const item of list) {
770
+ if (item?.modName === 'personal' && typeof item.httpsUrl === 'string' && item.httpsUrl) {
771
+ host = item.httpsUrl.replace(/\/+$/, '')
772
+ logger?.info?.(`mobile-cloud: 个人云 host = ${host}`)
773
+ return host
774
+ }
775
+ }
776
+ }
777
+ } catch (error) {
778
+ logger?.warn?.(`mobile-cloud: 路由查询失败,回落到默认 host(${errorMessage(error)})`)
779
+ }
780
+ host = FALLBACK_PERSONAL_HOST
781
+ return host
782
+ }
783
+
784
+ async function ensureHost() {
785
+ if (!host) await resolveHost()
786
+ return host
787
+ }
788
+
789
+ async function withHostRetry(operation) {
790
+ await ensureHost()
791
+ try {
792
+ return await operation()
793
+ } catch (error) {
794
+ // host 可能是过期的路由,重解析一次再试
795
+ const message = errorMessage(error)
796
+ if (/host|404|not found|权限|无效/i.test(message)) {
797
+ host = null
798
+ await ensureHost()
799
+ return await operation()
800
+ }
801
+ throw error
802
+ }
803
+ }
804
+
805
+ // ---------------------------------------------------------- 具体接口
806
+
807
+ const listCache = createCache(LIST_CACHE_TTL_MS, LIST_CACHE_MAX)
808
+
809
+ async function listFiles(parentFileId, cursor = '', pageSize = LIST_PAGE_SIZE, { force = false } = {}) {
810
+ const key = `${parentFileId || ROOT_ID}|${cursor || ''}|${clampPageSize(pageSize)}`
811
+ if (!force) {
812
+ const cached = listCache.get(key)
813
+ if (cached) return cached
814
+ }
815
+ const result = await withHostRetry(async () => {
816
+ // ⚠️ 不要带 imageThumbnailStyleList!
817
+ // 实测:100 张图的目录带上它,服务端 3 秒直接 ECONNRESET;不带它虽然也要 6 秒但能成功。
818
+ // 而且本插件的界面根本不显示缩略图 —— 纯纯的无谓负担。
819
+ const json = await call(personalUrl('/file/list'), {
820
+ orderBy: 'updated_at',
821
+ orderDirection: 'DESC',
822
+ pageInfo: { pageCursor: cursor || '', pageSize: clampPageSize(pageSize) },
823
+ parentFileId: parentFileId || ROOT_ID,
824
+ })
825
+ const items = Array.isArray(json?.data?.items) ? json.data.items : []
826
+ return {
827
+ items: items.map((item) => ({
828
+ id: String(item.fileId ?? ''),
829
+ name: String(item.name ?? ''),
830
+ size: Number(item.size ?? 0),
831
+ type: String(item.type ?? '').toLowerCase() === 'folder' ? 'folder' : 'file',
832
+ updatedAt: String(item.updatedAt ?? ''),
833
+ thumbnail: null,
834
+ })),
835
+ nextCursor: json?.data?.nextPageCursor ? String(json.data.nextPageCursor) : null,
836
+ }
837
+ })
838
+ listCache.set(key, result)
839
+ return result
840
+ }
841
+
842
+ /** 任何会改动网盘内容的操作之后,缓存一律作废(宁可多拉一次,也不能给旧数据)。 */
843
+ function invalidateListings() {
844
+ listCache.clear()
845
+ }
846
+
847
+ async function getDownloadUrl(fileId) {
848
+ return withHostRetry(async () => {
849
+ const json = await call(personalUrl('/file/getDownloadUrl'), { fileId })
850
+ const data = json?.data ?? {}
851
+ if (data.cdnSwitch && data.cdnUrl) return String(data.cdnUrl)
852
+ if (data.url) return String(data.url)
853
+ if (data.cdnUrl) return String(data.cdnUrl)
854
+ throw new Error('移动云盘没有返回下载直链')
855
+ })
856
+ }
857
+
858
+ async function makeDir(parentFileId, dirName) {
859
+ try {
860
+ return await withHostRetry(() =>
861
+ call(personalUrl('/file/create'), {
862
+ parentFileId: parentFileId || ROOT_ID,
863
+ name: dirName,
864
+ description: '',
865
+ type: 'folder',
866
+ fileRenameMode: 'force_rename',
867
+ }),
868
+ )
869
+ } finally {
870
+ invalidateListings()
871
+ }
872
+ }
873
+
874
+ async function renameNode(fileId, nextName) {
875
+ try {
876
+ return await withHostRetry(() =>
877
+ call(personalUrl('/file/update'), { fileId, name: nextName, description: '' }),
878
+ )
879
+ } finally {
880
+ invalidateListings()
881
+ }
882
+ }
883
+
884
+ async function moveNodes(fileIds, toParentFileId) {
885
+ try {
886
+ return await withHostRetry(() =>
887
+ call(personalUrl('/file/batchMove'), {
888
+ fileIds: fileIds.map(String),
889
+ toParentFileId: toParentFileId || ROOT_ID,
890
+ }),
891
+ )
892
+ } finally {
893
+ invalidateListings()
894
+ }
895
+ }
896
+
897
+ async function removeNodes(fileIds) {
898
+ try {
899
+ return await withHostRetry(() =>
900
+ call(personalUrl('/recyclebin/batchTrash'), { fileIds: fileIds.map(String) }),
901
+ )
902
+ } finally {
903
+ invalidateListings()
904
+ }
905
+ }
906
+
907
+ /** 确保父目录下有个叫 name 的文件夹,返回它的 id。已存在就复用,不重复创建。 */
908
+ async function ensureFolder(parentFileId, name) {
909
+ const parent = parentFileId || ROOT_ID
910
+ const findExisting = async () => {
911
+ let cursor = ''
912
+ do {
913
+ const page = await listFiles(parent, cursor)
914
+ const hit = page.items.find((item) => item.type === 'folder' && item.name === name)
915
+ if (hit) return hit.id
916
+ cursor = page.nextCursor || ''
917
+ } while (cursor)
918
+ return null
919
+ }
920
+
921
+ const existing = await findExisting()
922
+ if (existing) return existing
923
+
924
+ const created = await withHostRetry(() =>
925
+ call(personalUrl('/file/create'), {
926
+ parentFileId: parent,
927
+ name,
928
+ description: '',
929
+ type: 'folder',
930
+ fileRenameMode: 'force_rename',
931
+ }),
932
+ )
933
+ const id = created?.data?.fileId
934
+ if (id) return String(id)
935
+
936
+ // 有些响应不回 id,再列一次兜底
937
+ const after = await findExisting()
938
+ if (after) return after
939
+ throw new Error(`建好文件夹「${name}」之后没能拿到它的 ID`)
940
+ }
941
+
942
+ // ---------------------------------------------------------- 传输(上传 / 下载)
943
+
944
+ const jobs = new Map()
945
+
946
+ function accountOf() {
947
+ return decodeAuthorization(assertAuthorized().authorization).account
948
+ }
949
+
950
+ function snapshot(job) {
951
+ return {
952
+ id: job.id,
953
+ kind: job.kind,
954
+ label: job.label,
955
+ phase: job.phase,
956
+ done: job.done,
957
+ total: job.total,
958
+ state: job.state,
959
+ error: job.error,
960
+ result: job.result,
961
+ currentFile: job.currentFile ?? null,
962
+ fileCount: job.fileCount ?? null,
963
+ startedAt: job.startedAt,
964
+ finishedAt: job.finishedAt,
965
+ }
966
+ }
967
+
968
+ /** 起一个后台传输任务。job.promise 给工具用(可以 await 到结束) */
969
+ function startJob(kind, label, runner) {
970
+ const job = {
971
+ id: randomUUID().slice(0, 8),
972
+ kind,
973
+ label,
974
+ phase: 'prepare',
975
+ done: 0,
976
+ total: 0,
977
+ state: 'running',
978
+ error: null,
979
+ result: null,
980
+ startedAt: Date.now(),
981
+ finishedAt: null,
982
+ controller: new AbortController(),
983
+ }
984
+ job.signal = job.controller.signal
985
+ jobs.set(job.id, job)
986
+
987
+ // 顺手清掉半小时前就结束的任务,别让 Map 无限长
988
+ for (const [id, old] of jobs) {
989
+ if (old.state !== 'running' && Date.now() - (old.finishedAt ?? old.startedAt) > 30 * 60 * 1000) {
990
+ jobs.delete(id)
991
+ }
992
+ }
993
+
994
+ job.promise = (async () => {
995
+ try {
996
+ const result = await runner(job)
997
+ job.state = 'done'
998
+ job.result = result ?? null
999
+ return result
1000
+ } catch (error) {
1001
+ job.state = 'error'
1002
+ job.error = errorMessage(error)
1003
+ throw error
1004
+ } finally {
1005
+ job.finishedAt = Date.now()
1006
+ }
1007
+ })()
1008
+ // 面板只轮询不用 await,这里兜一下避免 unhandled rejection
1009
+ job.promise.catch(() => {})
1010
+ return job
1011
+ }
1012
+
1013
+ function getJob(id) {
1014
+ const job = jobs.get(id)
1015
+ return job ? snapshot(job) : null
1016
+ }
1017
+
1018
+ function listJobs() {
1019
+ return [...jobs.values()]
1020
+ .sort((left, right) => right.startedAt - left.startedAt)
1021
+ .slice(0, 20)
1022
+ .map(snapshot)
1023
+ }
1024
+
1025
+ function cancelJob(id) {
1026
+ const job = jobs.get(id)
1027
+ if (!job || job.state !== 'running') return false
1028
+ job.controller.abort()
1029
+ return true
1030
+ }
1031
+
1032
+ /**
1033
+ * 上传本地文件。流程见 transfer.js 顶部的注释。
1034
+ * 注意:整个文件只流式读一遍算哈希,分片也是流式 PUT,不把文件读进内存。
1035
+ */
1036
+ async function uploadFile(localPath, parentFileId, nameOverride, job) {
1037
+ // 单文件任务是「这个任务就是这个文件」,所以它负责维护 total / phase;
1038
+ // 文件夹任务由 uploadFolder 统一管进度,子文件不要抢。
1039
+ const solo = job.kind === 'upload'
1040
+ const stats = statSync(localPath)
1041
+ if (!stats.isFile()) throw new Error(`不是一个文件:${localPath}`)
1042
+ const size = stats.size
1043
+ const fileName = String(nameOverride || basename(localPath))
1044
+ const partInfos = buildPartInfos(size, partSizeFor(size))
1045
+
1046
+ // 每个文件对进度的贡献恒等于它的字节数 —— 不管是真传了、秒传了,
1047
+ // 还是服务端压根没给分片地址。否则 total(按本地文件大小算出来的)
1048
+ // 永远对不上 done,进度条就卡在中间到不了 100%。
1049
+ const startDone = job.done
1050
+ const settle = () => {
1051
+ const gained = job.done - startDone
1052
+ if (gained < size) job.done += size - gained
1053
+ }
1054
+
1055
+ if (solo) {
1056
+ job.total = size
1057
+ job.phase = 'hash'
1058
+ }
1059
+ const contentHash = await sha256File(localPath)
1060
+ if (job.signal.aborted) throw new Error('已取消')
1061
+
1062
+ if (solo) job.phase = 'create'
1063
+ const created = await withHostRetry(() =>
1064
+ call(personalUrl('/file/create'), {
1065
+ contentHash,
1066
+ contentHashAlgorithm: 'SHA256',
1067
+ contentType: UPLOAD_CONTENT_TYPE,
1068
+ parallelUpload: false,
1069
+ partInfos: partInfos.slice(0, PART_BATCH),
1070
+ size,
1071
+ parentFileId: parentFileId || ROOT_ID,
1072
+ name: fileName,
1073
+ type: 'file',
1074
+ fileRenameMode: 'auto_rename',
1075
+ }),
1076
+ )
1077
+
1078
+ const task = created?.data ?? {}
1079
+ if (task.exist === true || task.rapidUpload === true) {
1080
+ settle() // 秒传:字节没走网络,但这个文件确实「传完了」
1081
+ return {
1082
+ dedup: true,
1083
+ fileId: task.fileId ?? null,
1084
+ name: task.fileName ?? fileName,
1085
+ size,
1086
+ message: '网盘里已有完全相同的内容,服务端直接秒传,没有真的传数据。',
1087
+ }
1088
+ }
1089
+
1090
+ const fileId = task.fileId
1091
+ const uploadId = task.uploadId
1092
+ if (!fileId || !uploadId) {
1093
+ throw new Error('创建上传任务失败:服务端没返回 fileId / uploadId')
1094
+ }
1095
+
1096
+ if (solo) job.phase = 'upload'
1097
+ const account = accountOf()
1098
+ await putParts(partInfos, task.partInfos, localPath, (n) => {
1099
+ job.done += n
1100
+ }, job.signal)
1101
+
1102
+ // 超过 100 个分片要分批取上传地址
1103
+ for (let index = PART_BATCH; index < partInfos.length; index += PART_BATCH) {
1104
+ const batch = partInfos.slice(index, index + PART_BATCH)
1105
+ const more = await withHostRetry(() =>
1106
+ call(personalUrl('/file/getUploadUrl'), {
1107
+ fileId,
1108
+ uploadId,
1109
+ partInfos: batch,
1110
+ commonAccountInfo: { account, accountType: 1 },
1111
+ }),
1112
+ )
1113
+ await putParts(partInfos, more?.data?.partInfos, localPath, (n) => {
1114
+ job.done += n
1115
+ }, job.signal)
1116
+ }
1117
+
1118
+ if (solo) job.phase = 'complete'
1119
+ await withHostRetry(() =>
1120
+ call(personalUrl('/file/complete'), {
1121
+ contentHash,
1122
+ contentHashAlgorithm: 'SHA256',
1123
+ fileId,
1124
+ uploadId,
1125
+ }),
1126
+ )
1127
+
1128
+ settle()
1129
+ invalidateListings()
1130
+
1131
+ return {
1132
+ dedup: false,
1133
+ fileId,
1134
+ name: task.fileName ?? fileName,
1135
+ size,
1136
+ parts: partInfos.length,
1137
+ }
1138
+ }
1139
+
1140
+ /** 上传整个本地文件夹:远端按同样结构建好(同名文件夹直接复用) */
1141
+ async function uploadFolder(localPath, parentFileId, job) {
1142
+ const stats = statSync(localPath)
1143
+ if (!stats.isDirectory()) throw new Error(`不是一个文件夹:${localPath}`)
1144
+
1145
+ job.phase = 'scan'
1146
+ const tree = { dirs: [], files: [] }
1147
+ await walkLocal(localPath, '', tree)
1148
+ job.total = tree.files.reduce((sum, file) => sum + file.size, 0)
1149
+ job.fileCount = tree.files.length
1150
+ if (job.signal.aborted) throw new Error('已取消')
1151
+
1152
+ const rootName = basename(localPath)
1153
+ const rootId = await ensureFolder(parentFileId || ROOT_ID, rootName)
1154
+ const idMap = new Map([['', rootId]])
1155
+
1156
+ job.phase = 'create'
1157
+ for (const dir of tree.dirs) {
1158
+ if (job.signal.aborted) throw new Error('已取消')
1159
+ job.currentFile = dir
1160
+ const parentRel = dirname(dir)
1161
+ const parentRemote = idMap.get(parentRel === '.' ? '' : parentRel) ?? rootId
1162
+ idMap.set(dir, await ensureFolder(parentRemote, basename(dir)))
1163
+ }
1164
+
1165
+ job.phase = 'upload'
1166
+ for (const file of tree.files) {
1167
+ if (job.signal.aborted) throw new Error('已取消')
1168
+ job.currentFile = file.relative
1169
+ const parentRel = dirname(file.relative)
1170
+ const parentRemote = idMap.get(parentRel === '.' ? '' : parentRel) ?? rootId
1171
+ await uploadFile(file.local, parentRemote, basename(file.relative), job)
1172
+ }
1173
+ job.currentFile = null
1174
+ invalidateListings()
1175
+ return { rootId, name: rootName, files: tree.files.length, bytes: job.done }
1176
+ }
1177
+
1178
+ /**
1179
+ * 给「拖拽上传」用:拖进来的文件浏览器只给字节、不给真实路径,
1180
+ * 所以先把请求体落到临时文件,走完正常上传流程再删掉。
1181
+ */
1182
+ async function uploadFromTempPath(tempPath, parentFileId, name, job) {
1183
+ try {
1184
+ return await uploadFile(tempPath, parentFileId, name, job)
1185
+ } finally {
1186
+ rmSync(tempPath, { force: true })
1187
+ }
1188
+ }
1189
+
1190
+ /** 把网盘文件流式下载到本地路径(先写 .part-xxx 再改名,失败不留半个文件) */
1191
+ async function downloadFile(fileId, localPath, job) {
1192
+ const url = await getDownloadUrl(fileId)
1193
+ const response = await fetch(url, { redirect: 'follow', signal: job.signal })
1194
+ if (!response.ok) throw new Error(`下载失败 HTTP ${response.status}`)
1195
+ const total = Number(response.headers.get('content-length')) || 0
1196
+ // 文件夹模式下 total 已经是整棵树的合计,别被单个文件覆盖
1197
+ if (total && !job.total) job.total = total
1198
+
1199
+ job.phase = 'download'
1200
+ await streamToFile(response.body, localPath, (bytes) => {
1201
+ job.done += bytes
1202
+ }, job.signal)
1203
+ return { localPath, bytes: job.done }
1204
+ }
1205
+
1206
+ /**
1207
+ * 递归列出一棵目录树。
1208
+ * 移动云盘没有「打包下载」的公开端点(OpenList 的驱动遇到目录直接 NotFile),
1209
+ * 所以文件夹下载只能先把树走一遍,再逐个文件下。
1210
+ */
1211
+ async function collectFolder(folderId, prefix, out, depth = 0) {
1212
+ if (depth > 32) throw new Error('目录层级过深,可能在打转,已中止')
1213
+ let cursor = ''
1214
+ do {
1215
+ const page = await listFiles(folderId, cursor)
1216
+ for (const item of page.items) {
1217
+ if (item.type === 'folder') {
1218
+ await collectFolder(item.id, join(prefix, item.name), out, depth + 1)
1219
+ } else {
1220
+ out.push({ fileId: item.id, relative: join(prefix, item.name), size: item.size })
1221
+ }
1222
+ }
1223
+ cursor = page.nextCursor || ''
1224
+ } while (cursor)
1225
+ }
1226
+
1227
+ async function downloadFolder(folderId, localDir, job) {
1228
+ job.phase = 'scan'
1229
+ const files = []
1230
+ await collectFolder(folderId, '', files)
1231
+ job.total = files.reduce((sum, file) => sum + (Number(file.size) || 0), 0)
1232
+ job.fileCount = files.length
1233
+ job.phase = 'download'
1234
+
1235
+ if (!files.length) {
1236
+ mkdirSync(localDir, { recursive: true })
1237
+ return { localDir, files: 0, bytes: 0, message: '这个文件夹是空的。' }
1238
+ }
1239
+
1240
+ for (const file of files) {
1241
+ if (job.signal.aborted) throw new Error('已取消')
1242
+ job.currentFile = file.relative
1243
+ await downloadFile(file.fileId, join(localDir, file.relative), job)
1244
+ }
1245
+ job.currentFile = null
1246
+ return { localDir, files: files.length, bytes: job.done }
1247
+ }
1248
+
1249
+ async function getQuota() {
1250
+ const value = assertAuthorized()
1251
+ // 注意:OpenList 在没有 userDomainId 时直接拒绝查询,但那是它自己的保守判断,
1252
+ // 接口本身未必校验。所以这里照样发出去,让上游自己决定。
1253
+ const json = await call(
1254
+ `${USER_HOST}/user/disk/quota/detail`,
1255
+ { userDomainId: value.userDomainId || '' },
1256
+ { personal: false },
1257
+ )
1258
+ const data = json?.data ?? {}
1259
+ const totalBytes = Number(data.diskSize ?? 0) * MB
1260
+ const freeBytes = Number(data.freeDiskSize ?? 0) * MB
1261
+ if (!totalBytes) {
1262
+ throw new Error(
1263
+ value.userDomainId
1264
+ ? '容量接口没返回 diskSize'
1265
+ : '容量接口返回空(大概率确实需要 userDomainId):请在面板「用户域 ID」里填 Cookie 的 ud_id',
1266
+ )
1267
+ }
1268
+ return {
1269
+ totalBytes,
1270
+ freeBytes,
1271
+ usedBytes: Math.max(0, totalBytes - freeBytes),
1272
+ }
1273
+ }
1274
+
1275
+ async function status() {
1276
+ const value = credential
1277
+ let account = null
1278
+ let expiresAt = null
1279
+ let quota = null
1280
+ let quotaError = null
1281
+ if (value?.authorization) {
1282
+ try {
1283
+ const info = decodeAuthorization(value.authorization)
1284
+ account = info.account
1285
+ expiresAt = info.expiration
1286
+ await ensureFresh()
1287
+ } catch (error) {
1288
+ quotaError = errorMessage(error)
1289
+ }
1290
+ if (!quotaError) {
1291
+ try {
1292
+ quota = await getQuota()
1293
+ } catch (error) {
1294
+ quotaError = errorMessage(error)
1295
+ }
1296
+ }
1297
+ try {
1298
+ await ensureHost()
1299
+ } catch {
1300
+ /* host 解析失败不影响状态展示 */
1301
+ }
1302
+ }
1303
+ return {
1304
+ ok: true,
1305
+ authorized: Boolean(value?.authorization),
1306
+ // 只给脱敏版:完整账号(手机号)不出这个接口,免得随手 F12 就能看到。
1307
+ accountMasked: maskAccount(account),
1308
+ expiresAt,
1309
+ // 凭据被服务端判定失效(过期/改密/踢下线)→ 界面直接引导重新登录,
1310
+ // 而不是把「认证失败(05050006)」这种原始错误码丢给用户。
1311
+ needsReauth: Boolean(quotaError && /认证失败|暂无权限|授权已过期|登录失败/.test(quotaError)),
1312
+ userDomainId: value?.userDomainId ?? null,
1313
+ credentialPath: store.file,
1314
+ quota,
1315
+ quotaError,
1316
+ host,
1317
+ defaultDownloadDir: DEFAULT_DOWNLOAD_DIR,
1318
+ storageType: 'personal_new',
1319
+ }
1320
+ }
1321
+
1322
+ async function authorize(authorization, userDomainId) {
1323
+ const cleaned = String(authorization ?? '').trim().replace(/^Basic\s+/i, '')
1324
+ if (!cleaned) throw new Error('Authorization 不能为空')
1325
+ const info = decodeAuthorization(cleaned)
1326
+ update({ authorization: cleaned, userDomainId: String(userDomainId ?? '').trim() })
1327
+ host = null
1328
+ await ensureHost()
1329
+ let quota = null
1330
+ let quotaError = null
1331
+ try {
1332
+ quota = await getQuota()
1333
+ } catch (error) {
1334
+ quotaError = errorMessage(error)
1335
+ }
1336
+ return { accountMasked: maskAccount(info.account), quota, quotaError }
1337
+ }
1338
+
1339
+ /**
1340
+ * 用账号密码登录,换出 Authorization。
1341
+ *
1342
+ * rememberPassword = true 时把账号密码也存进凭据文件(DPAPI 加密),
1343
+ * 否则**只在内存里用一次,用完即弃**。
1344
+ */
1345
+ async function login(account, password, rememberPassword) {
1346
+ const cleanAccount = String(account ?? '').trim()
1347
+ const cleanPassword = String(password ?? '')
1348
+ if (!cleanAccount) throw new Error('请填写手机号或邮箱')
1349
+ if (!cleanPassword) throw new Error('请填写密码')
1350
+
1351
+ const result = await loginWithPassword(cleanAccount, cleanPassword)
1352
+ const next = {
1353
+ authorization: result.authorization,
1354
+ userDomainId: result.userDomainId || '',
1355
+ }
1356
+ if (rememberPassword === true) {
1357
+ next.savedAccount = cleanAccount
1358
+ next.savedPassword = cleanPassword
1359
+ }
1360
+ update(next)
1361
+ host = null
1362
+ await ensureHost()
1363
+
1364
+ let quota = null
1365
+ let quotaError = null
1366
+ try {
1367
+ quota = await getQuota()
1368
+ } catch (error) {
1369
+ quotaError = errorMessage(error)
1370
+ }
1371
+ return {
1372
+ accountMasked: maskAccount(result.account),
1373
+ quota,
1374
+ quotaError,
1375
+ rememberPassword: rememberPassword === true,
1376
+ }
1377
+ }
1378
+
1379
+ /** 读回已保存的账号(密码不回传,只告诉前端「有没有存」)。 */
1380
+ function savedLogin() {
1381
+ const value = credential
1382
+ if (!value || !value.savedAccount) return { hasSaved: false, account: null }
1383
+ return { hasSaved: true, account: value.savedAccount }
1384
+ }
1385
+
1386
+ // ---------------------------------------------------------- 扫码登录
1387
+ /**
1388
+ * 扫码登录:拉起浏览器 → 等用户扫码 → 抓 cookie 里的 authorization。
1389
+ * 这是个长任务(最长 5 分钟),所以做成「后台跑 + 前端轮询进度」。
1390
+ */
1391
+ let scanSession = null
1392
+
1393
+ function startScan() {
1394
+ if (scanSession && scanSession.state === 'running') {
1395
+ return { ok: true, already: true, ...snapshotScan() }
1396
+ }
1397
+ const session = {
1398
+ id: randomUUID().slice(0, 8),
1399
+ state: 'running',
1400
+ phase: 'launching',
1401
+ message: '正在启动浏览器…',
1402
+ startedAt: Date.now(),
1403
+ error: null,
1404
+ controller: new AbortController(),
1405
+ }
1406
+ scanSession = session
1407
+
1408
+ session.promise = (async () => {
1409
+ try {
1410
+ const result = await scanLogin({
1411
+ signal: session.controller.signal,
1412
+ onProgress: (p) => {
1413
+ session.phase = p.phase || session.phase
1414
+ session.message = p.message || session.message
1415
+ },
1416
+ })
1417
+ // 存凭据(扫码拿到的令牌同样支持自动续期)
1418
+ update({
1419
+ authorization: result.authorization,
1420
+ userDomainId: result.udId || '',
1421
+ })
1422
+ host = null
1423
+ try {
1424
+ await ensureHost()
1425
+ } catch {
1426
+ /* host 解析失败不影响保存 */
1427
+ }
1428
+ session.state = 'done'
1429
+ session.phase = 'done'
1430
+ session.message = '登录成功'
1431
+ session.result = {
1432
+ accountMasked: maskAccount(decodeAuthorization(result.authorization).account),
1433
+ }
1434
+ } catch (error) {
1435
+ session.state = error?.message === '已取消扫码登录' ? 'cancelled' : 'error'
1436
+ session.phase = 'error'
1437
+ session.error = errorMessage(error)
1438
+ session.message = session.error
1439
+ }
1440
+ })()
1441
+ session.promise.catch(() => {})
1442
+ return { ok: true, ...snapshotScan() }
1443
+ }
1444
+
1445
+ function snapshotScan() {
1446
+ if (!scanSession) return { state: 'idle', phase: 'idle', message: '' }
1447
+ return {
1448
+ id: scanSession.id,
1449
+ state: scanSession.state,
1450
+ phase: scanSession.phase,
1451
+ message: scanSession.message,
1452
+ error: scanSession.error,
1453
+ result: scanSession.result ?? null,
1454
+ elapsedMs: Date.now() - scanSession.startedAt,
1455
+ }
1456
+ }
1457
+
1458
+ function cancelScan() {
1459
+ if (!scanSession || scanSession.state !== 'running') return { cancelled: false }
1460
+ try {
1461
+ scanSession.controller.abort()
1462
+ } catch {
1463
+ /* 已经停了 */
1464
+ }
1465
+ return { cancelled: true }
1466
+ }
1467
+
1468
+ return {
1469
+ status,
1470
+ authorize,
1471
+ login,
1472
+ savedLogin,
1473
+ startScan,
1474
+ snapshotScan,
1475
+ cancelScan,
1476
+ forget,
1477
+ listFiles,
1478
+ getDownloadUrl,
1479
+ makeDir,
1480
+ renameNode,
1481
+ moveNodes,
1482
+ removeNodes,
1483
+ getQuota,
1484
+ startJob,
1485
+ getJob,
1486
+ listJobs,
1487
+ cancelJob,
1488
+ ensureFolder,
1489
+ uploadFile,
1490
+ uploadFolder,
1491
+ uploadFromTempPath,
1492
+ downloadFile,
1493
+ downloadFolder,
1494
+ isAuthorized: () => Boolean(credential?.authorization),
1495
+ }
1496
+ }
1497
+
1498
+ // ------------------------------------------------------------------ 工具定义
1499
+
1500
+ const outputSpec = {
1501
+ schema: {
1502
+ type: 'object',
1503
+ properties: {
1504
+ content: { type: 'array', items: {} },
1505
+ structuredContent: {},
1506
+ },
1507
+ required: ['content'],
1508
+ additionalProperties: false,
1509
+ },
1510
+ render(_args, result) {
1511
+ return result.content.map((block) =>
1512
+ block.type === 'text' ? block : { type: 'text', text: `[移动云盘返回 ${block.type} 内容]` },
1513
+ )
1514
+ },
1515
+ }
1516
+
1517
+ function textResult(text) {
1518
+ return { content: [{ type: 'text', text }] }
1519
+ }
1520
+
1521
+ function humanSize(bytes) {
1522
+ const n = Number(bytes) || 0
1523
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
1524
+ let value = n
1525
+ let index = 0
1526
+ while (value >= 1024 && index < units.length - 1) {
1527
+ value /= 1024
1528
+ index += 1
1529
+ }
1530
+ return `${index === 0 ? value : value.toFixed(1)} ${units[index]}`
1531
+ }
1532
+
1533
+ function toolDefinitions(client) {
1534
+ return [
1535
+ {
1536
+ name: TOOL_PREFIX + 'quota',
1537
+ description: '查询中国移动云盘(139)的容量使用情况:总量、已用、剩余。',
1538
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
1539
+ async execute() {
1540
+ const quota = await client.getQuota()
1541
+ if (!quota) {
1542
+ return textResult(
1543
+ '拿不到容量:当前凭据里没有 userDomainId(ud_id)。请在「移动云盘」面板补上 Cookie 里的 ud_id 再试。',
1544
+ )
1545
+ }
1546
+ return textResult(
1547
+ `总容量 ${humanSize(quota.totalBytes)}|已用 ${humanSize(quota.usedBytes)}|剩余 ${humanSize(quota.freeBytes)}`,
1548
+ )
1549
+ },
1550
+ },
1551
+ {
1552
+ name: TOOL_PREFIX + 'list',
1553
+ description:
1554
+ '列出中国移动云盘某个目录下的文件和文件夹。parentFileId 用 "/" 表示根目录,从结果里拿子目录的 id 继续深入。',
1555
+ parameters: {
1556
+ type: 'object',
1557
+ properties: {
1558
+ parentFileId: { type: 'string', description: '目录 ID,根目录为 "/"' },
1559
+ cursor: { type: 'string', description: '分页游标,首页留空' },
1560
+ },
1561
+ additionalProperties: false,
1562
+ },
1563
+ async execute(args) {
1564
+ const page = await client.listFiles(args?.parentFileId || ROOT_ID, args?.cursor || '')
1565
+ if (!page.items.length) return textResult('这个目录是空的。')
1566
+ const lines = page.items.map((item) => {
1567
+ const kind = item.type === 'folder' ? '文件夹' : '文件'
1568
+ const size = item.type === 'folder' ? '' : ` ${humanSize(item.size)}`
1569
+ return `- [${kind}] ${item.name}${size} (id=${item.id})`
1570
+ })
1571
+ const more = page.nextCursor ? `\n\n还有下一页,cursor=${page.nextCursor}` : ''
1572
+ return textResult(`共 ${page.items.length} 项:\n${lines.join('\n')}${more}`)
1573
+ },
1574
+ },
1575
+ {
1576
+ name: TOOL_PREFIX + 'search',
1577
+ description: '在中国移动云盘里按文件名关键词搜索(会翻若干页目录,最多 10 页)。',
1578
+ parameters: {
1579
+ type: 'object',
1580
+ properties: {
1581
+ keyword: { type: 'string', description: '文件名关键词' },
1582
+ parentFileId: { type: 'string', description: '从哪个目录开始搜,默认根目录 "/"' },
1583
+ },
1584
+ required: ['keyword'],
1585
+ additionalProperties: false,
1586
+ },
1587
+ async execute(args) {
1588
+ const keyword = String(args?.keyword ?? '').trim()
1589
+ if (!keyword) return textResult('请给出关键词。')
1590
+ const queue = [args?.parentFileId || ROOT_ID]
1591
+ const matches = []
1592
+ let pages = 0
1593
+ while (queue.length && pages < 10) {
1594
+ const current = queue.shift()
1595
+ let cursor = ''
1596
+ do {
1597
+ const page = await client.listFiles(current, cursor)
1598
+ pages += 1
1599
+ for (const item of page.items) {
1600
+ if (item.name.includes(keyword)) matches.push({ ...item, parent: current })
1601
+ if (item.type === 'folder') queue.push(item.id)
1602
+ }
1603
+ cursor = page.nextCursor || ''
1604
+ } while (cursor && pages < 10)
1605
+ }
1606
+ if (!matches.length) return textResult(`没找到文件名包含「${keyword}」的内容。`)
1607
+ const lines = matches.map(
1608
+ (item) => `- ${item.type === 'folder' ? '📁' : '📄'} ${item.name} (id=${item.id}, 在 ${item.parent})`,
1609
+ )
1610
+ return textResult(`找到 ${matches.length} 项:\n${lines.join('\n')}`)
1611
+ },
1612
+ },
1613
+ {
1614
+ name: TOOL_PREFIX + 'download_link',
1615
+ description: '拿到中国移动云盘里某个文件的下载直链(302/CDN 地址,有时效)。',
1616
+ parameters: {
1617
+ type: 'object',
1618
+ properties: { fileId: { type: 'string', description: '文件 ID' } },
1619
+ required: ['fileId'],
1620
+ additionalProperties: false,
1621
+ },
1622
+ async execute(args) {
1623
+ const url = await client.getDownloadUrl(String(args?.fileId ?? ''))
1624
+ return textResult(url)
1625
+ },
1626
+ },
1627
+ {
1628
+ name: TOOL_PREFIX + 'mkdir',
1629
+ description: '在中国移动云盘里新建文件夹。',
1630
+ parameters: {
1631
+ type: 'object',
1632
+ properties: {
1633
+ parentFileId: { type: 'string', description: '父目录 ID,根目录为 "/"' },
1634
+ name: { type: 'string', description: '新文件夹名字' },
1635
+ },
1636
+ required: ['name'],
1637
+ additionalProperties: false,
1638
+ },
1639
+ async execute(args) {
1640
+ const dirName = String(args?.name ?? '').trim()
1641
+ if (!dirName) return textResult('文件夹名不能为空。')
1642
+ await client.makeDir(args?.parentFileId || ROOT_ID, dirName)
1643
+ return textResult(`已创建文件夹「${dirName}」。`)
1644
+ },
1645
+ },
1646
+ {
1647
+ name: TOOL_PREFIX + 'rename',
1648
+ description: '重命名中国移动云盘里的文件或文件夹。',
1649
+ parameters: {
1650
+ type: 'object',
1651
+ properties: {
1652
+ fileId: { type: 'string', description: '文件/文件夹 ID' },
1653
+ name: { type: 'string', description: '新名字' },
1654
+ },
1655
+ required: ['fileId', 'name'],
1656
+ additionalProperties: false,
1657
+ },
1658
+ async execute(args) {
1659
+ const nextName = String(args?.name ?? '').trim()
1660
+ if (!nextName) return textResult('新名字不能为空。')
1661
+ await client.renameNode(String(args?.fileId ?? ''), nextName)
1662
+ return textResult(`已改名为「${nextName}」。`)
1663
+ },
1664
+ },
1665
+ {
1666
+ name: TOOL_PREFIX + 'move',
1667
+ description: '把一个或多个文件/文件夹移动到另一个目录(移动前请先确认目标 ID)。',
1668
+ parameters: {
1669
+ type: 'object',
1670
+ properties: {
1671
+ fileIds: { type: 'array', items: { type: 'string' }, description: '要移动的 ID 列表' },
1672
+ toParentFileId: { type: 'string', description: '目标目录 ID,根目录为 "/"' },
1673
+ },
1674
+ required: ['fileIds', 'toParentFileId'],
1675
+ additionalProperties: false,
1676
+ },
1677
+ async execute(args) {
1678
+ const ids = Array.isArray(args?.fileIds) ? args.fileIds.map(String) : []
1679
+ if (!ids.length) return textResult('fileIds 不能为空。')
1680
+ await client.moveNodes(ids, args?.toParentFileId || ROOT_ID)
1681
+ return textResult(`已移动 ${ids.length} 项。`)
1682
+ },
1683
+ },
1684
+ {
1685
+ name: TOOL_PREFIX + 'remove',
1686
+ description: '把文件/文件夹移入移动云盘回收站(不是永久删除,可在回收站恢复)。',
1687
+ parameters: {
1688
+ type: 'object',
1689
+ properties: {
1690
+ fileIds: { type: 'array', items: { type: 'string' }, description: '要删除的 ID 列表' },
1691
+ },
1692
+ required: ['fileIds'],
1693
+ additionalProperties: false,
1694
+ },
1695
+ async execute(args) {
1696
+ const ids = Array.isArray(args?.fileIds) ? args.fileIds.map(String) : []
1697
+ if (!ids.length) return textResult('fileIds 不能为空。')
1698
+ await client.removeNodes(ids)
1699
+ return textResult(`已把 ${ids.length} 项移入回收站。`)
1700
+ },
1701
+ },
1702
+ {
1703
+ name: TOOL_PREFIX + 'upload',
1704
+ timeoutMs: 6 * 60 * 60 * 1000,
1705
+ description:
1706
+ '把一个本地文件上传到中国移动云盘(自动分片、支持秒传)。localPath 是运行 DSH 这台机器上的绝对路径。',
1707
+ parameters: {
1708
+ type: 'object',
1709
+ properties: {
1710
+ localPath: { type: 'string', description: '本地文件绝对路径' },
1711
+ parentFileId: { type: 'string', description: '传到哪个目录,根目录为 "/"' },
1712
+ name: { type: 'string', description: '可选:上传后的文件名' },
1713
+ },
1714
+ required: ['localPath'],
1715
+ additionalProperties: false,
1716
+ },
1717
+ async execute(args) {
1718
+ const localPath = String(args?.localPath ?? '').trim()
1719
+ if (!localPath) return textResult('localPath 不能为空。')
1720
+ const job = client.startJob('upload', basename(localPath), (active) =>
1721
+ client.uploadFile(localPath, args?.parentFileId, args?.name, active),
1722
+ )
1723
+ const result = await job.promise
1724
+ return textResult(
1725
+ result.dedup
1726
+ ? `「${result.name}」命中秒传(网盘里已有完全相同的内容),没有真的传数据。`
1727
+ : `已上传「${result.name}」(${humanSize(result.size)},${result.parts} 个分片)。`,
1728
+ )
1729
+ },
1730
+ },
1731
+ {
1732
+ name: TOOL_PREFIX + 'upload_folder',
1733
+ timeoutMs: 6 * 60 * 60 * 1000,
1734
+ description:
1735
+ '把一个本地文件夹整个上传到中国移动云盘,远端会按同样的目录结构建好(同名文件夹复用,不会重复创建)。',
1736
+ parameters: {
1737
+ type: 'object',
1738
+ properties: {
1739
+ localPath: { type: 'string', description: '本地文件夹绝对路径' },
1740
+ parentFileId: { type: 'string', description: '传到哪个目录,根目录为 "/"' },
1741
+ },
1742
+ required: ['localPath'],
1743
+ additionalProperties: false,
1744
+ },
1745
+ async execute(args) {
1746
+ const localPath = String(args?.localPath ?? '').trim()
1747
+ if (!localPath) return textResult('localPath 不能为空。')
1748
+ const job = client.startJob('upload-folder', basename(localPath), (active) =>
1749
+ client.uploadFolder(localPath, args?.parentFileId, active),
1750
+ )
1751
+ const result = await job.promise
1752
+ return textResult(
1753
+ `已把文件夹「${result.name}」上传到网盘(${result.files} 个文件,${humanSize(result.bytes)})。`,
1754
+ )
1755
+ },
1756
+ },
1757
+ {
1758
+ name: TOOL_PREFIX + 'download',
1759
+ timeoutMs: 6 * 60 * 60 * 1000,
1760
+ description: '把移动云盘里的一个文件下载到本地磁盘(流式写入,先写临时文件再改名)。',
1761
+ parameters: {
1762
+ type: 'object',
1763
+ properties: {
1764
+ fileId: { type: 'string', description: '文件 ID' },
1765
+ localPath: { type: 'string', description: '本地保存路径,要含文件名' },
1766
+ },
1767
+ required: ['fileId', 'localPath'],
1768
+ additionalProperties: false,
1769
+ },
1770
+ async execute(args) {
1771
+ const fileId = String(args?.fileId ?? '').trim()
1772
+ const localPath = String(args?.localPath ?? '').trim()
1773
+ if (!fileId || !localPath) return textResult('fileId 和 localPath 都不能为空。')
1774
+ const job = client.startJob('download', basename(localPath), (active) =>
1775
+ client.downloadFile(fileId, localPath, active),
1776
+ )
1777
+ const result = await job.promise
1778
+ return textResult(`已下载到 ${result.localPath}(${humanSize(result.bytes)})。`)
1779
+ },
1780
+ },
1781
+ {
1782
+ name: TOOL_PREFIX + 'download_folder',
1783
+ timeoutMs: 6 * 60 * 60 * 1000,
1784
+ description:
1785
+ '递归下载整个文件夹到本地。移动云盘没有打包下载接口,这里是逐个文件下载、在本地还原目录结构,所以文件多会比较慢。',
1786
+ parameters: {
1787
+ type: 'object',
1788
+ properties: {
1789
+ folderId: { type: 'string', description: '文件夹 ID' },
1790
+ localPath: { type: 'string', description: '本地保存目录' },
1791
+ },
1792
+ required: ['folderId', 'localPath'],
1793
+ additionalProperties: false,
1794
+ },
1795
+ async execute(args) {
1796
+ const folderId = String(args?.folderId ?? '').trim()
1797
+ const localPath = String(args?.localPath ?? '').trim()
1798
+ if (!folderId || !localPath) return textResult('folderId 和 localPath 都不能为空。')
1799
+ const job = client.startJob('download-folder', basename(localPath), (active) =>
1800
+ client.downloadFolder(folderId, localPath, active),
1801
+ )
1802
+ const result = await job.promise
1803
+ return textResult(
1804
+ `已把整个文件夹下载到 ${result.localDir}(${result.files} 个文件,${humanSize(result.bytes)})。`,
1805
+ )
1806
+ },
1807
+ },
1808
+ ]
1809
+ }
1810
+
1811
+ // ------------------------------------------------------------------ 路由
1812
+
1813
+ function isLoopback(req) {
1814
+ const address = req.socket?.remoteAddress ?? ''
1815
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
1816
+ }
1817
+
1818
+ function writeJson(res, status, payload) {
1819
+ const body = JSON.stringify(payload)
1820
+ res.writeHead(status, {
1821
+ 'content-type': 'application/json; charset=utf-8',
1822
+ 'cache-control': 'no-store',
1823
+ 'content-length': Buffer.byteLength(body),
1824
+ })
1825
+ res.end(body)
1826
+ }
1827
+
1828
+ function readJsonBody(req, limit = 64 * 1024) {
1829
+ return new Promise((resolveBody) => {
1830
+ let size = 0
1831
+ const chunks = []
1832
+ req.on('data', (chunk) => {
1833
+ size += chunk.length
1834
+ if (size > limit) {
1835
+ resolveBody({ invalid: true })
1836
+ req.destroy()
1837
+ return
1838
+ }
1839
+ chunks.push(chunk)
1840
+ })
1841
+ req.on('end', () => {
1842
+ const text = chunks.length ? Buffer.concat(chunks).toString('utf8') : ''
1843
+ if (!text.trim()) return resolveBody({ value: {} })
1844
+ try {
1845
+ resolveBody({ value: JSON.parse(text) })
1846
+ } catch {
1847
+ resolveBody({ invalid: true })
1848
+ }
1849
+ })
1850
+ req.on('error', () => resolveBody({ invalid: true }))
1851
+ })
1852
+ }
1853
+
1854
+ function createRoutes(client) {
1855
+ const base = '/api/dsh-mcloud'
1856
+
1857
+ function guard(req, res) {
1858
+ if (!isLoopback(req)) {
1859
+ writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
1860
+ return false
1861
+ }
1862
+ return true
1863
+ }
1864
+
1865
+ function guardWrite(req, res) {
1866
+ if (!guard(req, res)) return false
1867
+ if (String(req.method ?? 'GET').toUpperCase() !== 'POST') {
1868
+ writeJson(res, 405, { ok: false, error: 'method not allowed' })
1869
+ return false
1870
+ }
1871
+ if (req.headers[WRITE_HEADER] !== '1') {
1872
+ writeJson(res, 403, { ok: false, error: 'missing plugin header' })
1873
+ return false
1874
+ }
1875
+ return true
1876
+ }
1877
+
1878
+ async function post(req, res, run) {
1879
+ const body = await readJsonBody(req)
1880
+ if (body.invalid) return writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
1881
+ try {
1882
+ const result = await run(body.value ?? {})
1883
+ writeJson(res, result?.ok === false ? 400 : 200, { ok: true, ...(result ?? {}) })
1884
+ } catch (error) {
1885
+ writeJson(res, 400, { ok: false, error: errorMessage(error) })
1886
+ }
1887
+ }
1888
+
1889
+ return [
1890
+ {
1891
+ kind: 'exact',
1892
+ path: `${base}/status`,
1893
+ handler: async (req, res) => {
1894
+ if (!guard(req, res)) return
1895
+ try {
1896
+ writeJson(res, 200, await client.status())
1897
+ } catch (error) {
1898
+ writeJson(res, 500, { ok: false, error: errorMessage(error) })
1899
+ }
1900
+ },
1901
+ },
1902
+ {
1903
+ kind: 'exact',
1904
+ path: `${base}/scan/start`,
1905
+ handler: async (req, res) => {
1906
+ if (!guardWrite(req, res)) return
1907
+ await post(req, res, async () => client.startScan())
1908
+ },
1909
+ },
1910
+ {
1911
+ kind: 'exact',
1912
+ path: `${base}/scan/status`,
1913
+ handler: async (req, res) => {
1914
+ if (!guard(req, res)) return
1915
+ try {
1916
+ writeJson(res, 200, { ok: true, ...client.snapshotScan() })
1917
+ } catch (error) {
1918
+ writeJson(res, 500, { ok: false, error: errorMessage(error) })
1919
+ }
1920
+ },
1921
+ },
1922
+ {
1923
+ kind: 'exact',
1924
+ path: `${base}/scan/cancel`,
1925
+ handler: async (req, res) => {
1926
+ if (!guardWrite(req, res)) return
1927
+ await post(req, res, async () => client.cancelScan())
1928
+ },
1929
+ },
1930
+ {
1931
+ kind: 'exact',
1932
+ path: `${base}/login`,
1933
+ handler: async (req, res) => {
1934
+ if (!guardWrite(req, res)) return
1935
+ await post(req, res, async (value) => {
1936
+ if (typeof value.account !== 'string' || typeof value.password !== 'string') {
1937
+ throw new Error('account 和 password 都必须是字符串')
1938
+ }
1939
+ const result = await client.login(value.account, value.password, value.rememberPassword === true)
1940
+ return result
1941
+ })
1942
+ },
1943
+ },
1944
+ {
1945
+ kind: 'exact',
1946
+ path: `${base}/login/saved`,
1947
+ handler: async (req, res) => {
1948
+ if (!guard(req, res)) return
1949
+ try {
1950
+ writeJson(res, 200, { ok: true, ...client.savedLogin() })
1951
+ } catch (error) {
1952
+ writeJson(res, 500, { ok: false, error: errorMessage(error) })
1953
+ }
1954
+ },
1955
+ },
1956
+ {
1957
+ kind: 'exact',
1958
+ path: `${base}/auth`,
1959
+ handler: async (req, res) => {
1960
+ if (!guardWrite(req, res)) return
1961
+ await post(req, res, async (value) => {
1962
+ if (typeof value.authorization !== 'string') {
1963
+ throw new Error('authorization 必须是字符串')
1964
+ }
1965
+ const result = await client.authorize(value.authorization, value.userDomainId ?? '')
1966
+ return { accountMasked: result.accountMasked, quota: result.quota, quotaError: result.quotaError }
1967
+ })
1968
+ },
1969
+ },
1970
+ {
1971
+ kind: 'exact',
1972
+ path: `${base}/auth/clear`,
1973
+ handler: async (req, res) => {
1974
+ if (!guardWrite(req, res)) return
1975
+ await post(req, res, async () => {
1976
+ client.forget()
1977
+ return {}
1978
+ })
1979
+ },
1980
+ },
1981
+ {
1982
+ kind: 'exact',
1983
+ path: `${base}/list`,
1984
+ handler: async (req, res) => {
1985
+ if (!guard(req, res)) return
1986
+ try {
1987
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
1988
+ const parentFileId = url.searchParams.get('parentFileId') || ROOT_ID
1989
+ const cursor = url.searchParams.get('cursor') || ''
1990
+ const pageSize = url.searchParams.get('pageSize')
1991
+ // refresh=1 强制绕过缓存(「刷新」按钮和写操作之后用)
1992
+ const force = url.searchParams.get('refresh') === '1'
1993
+ const page = await client.listFiles(parentFileId, cursor, pageSize ?? LIST_PAGE_SIZE, { force })
1994
+ writeJson(res, 200, { ok: true, parentFileId, ...page })
1995
+ } catch (error) {
1996
+ writeJson(res, 400, { ok: false, error: errorMessage(error) })
1997
+ }
1998
+ },
1999
+ },
2000
+ {
2001
+ kind: 'exact',
2002
+ path: `${base}/link`,
2003
+ handler: async (req, res) => {
2004
+ if (!guardWrite(req, res)) return
2005
+ await post(req, res, async (value) => {
2006
+ const url = await client.getDownloadUrl(String(value.fileId ?? ''))
2007
+ return { url }
2008
+ })
2009
+ },
2010
+ },
2011
+ {
2012
+ kind: 'exact',
2013
+ path: `${base}/mkdir`,
2014
+ handler: async (req, res) => {
2015
+ if (!guardWrite(req, res)) return
2016
+ await post(req, res, async (value) => {
2017
+ const dirName = String(value.name ?? '').trim()
2018
+ if (!dirName) throw new Error('文件夹名不能为空')
2019
+ await client.makeDir(String(value.parentFileId || ROOT_ID), dirName)
2020
+ return {}
2021
+ })
2022
+ },
2023
+ },
2024
+ {
2025
+ kind: 'exact',
2026
+ path: `${base}/rename`,
2027
+ handler: async (req, res) => {
2028
+ if (!guardWrite(req, res)) return
2029
+ await post(req, res, async (value) => {
2030
+ const nextName = String(value.name ?? '').trim()
2031
+ if (!nextName) throw new Error('新名字不能为空')
2032
+ await client.renameNode(String(value.fileId ?? ''), nextName)
2033
+ return {}
2034
+ })
2035
+ },
2036
+ },
2037
+ {
2038
+ kind: 'exact',
2039
+ path: `${base}/move`,
2040
+ handler: async (req, res) => {
2041
+ if (!guardWrite(req, res)) return
2042
+ await post(req, res, async (value) => {
2043
+ const ids = Array.isArray(value.fileIds) ? value.fileIds.map(String) : []
2044
+ if (!ids.length) throw new Error('fileIds 不能为空')
2045
+ await client.moveNodes(ids, String(value.toParentFileId || ROOT_ID))
2046
+ return {}
2047
+ })
2048
+ },
2049
+ },
2050
+ {
2051
+ kind: 'exact',
2052
+ path: `${base}/remove`,
2053
+ handler: async (req, res) => {
2054
+ if (!guardWrite(req, res)) return
2055
+ await post(req, res, async (value) => {
2056
+ const ids = Array.isArray(value.fileIds) ? value.fileIds.map(String) : []
2057
+ if (!ids.length) throw new Error('fileIds 不能为空')
2058
+ await client.removeNodes(ids)
2059
+ return {}
2060
+ })
2061
+ },
2062
+ },
2063
+ {
2064
+ kind: 'exact',
2065
+ path: `${base}/transfers`,
2066
+ handler: async (req, res) => {
2067
+ if (!guard(req, res)) return
2068
+ try {
2069
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
2070
+ const jobId = url.searchParams.get('jobId')
2071
+ if (jobId) {
2072
+ const job = client.getJob(jobId)
2073
+ if (!job) return writeJson(res, 404, { ok: false, error: 'job not found' })
2074
+ return writeJson(res, 200, { ok: true, job })
2075
+ }
2076
+ writeJson(res, 200, { ok: true, jobs: client.listJobs() })
2077
+ } catch (error) {
2078
+ writeJson(res, 500, { ok: false, error: errorMessage(error) })
2079
+ }
2080
+ },
2081
+ },
2082
+ {
2083
+ kind: 'exact',
2084
+ path: `${base}/upload`,
2085
+ handler: async (req, res) => {
2086
+ if (!guardWrite(req, res)) return
2087
+ await post(req, res, async (value) => {
2088
+ const localPath = String(value.localPath ?? '').trim()
2089
+ if (!localPath) throw new Error('localPath 不能为空')
2090
+ // 先确认文件真在、能读,别等任务跑起来才报错
2091
+ const stats = statSync(localPath)
2092
+ if (!stats.isFile()) throw new Error(`不是一个文件:${localPath}`)
2093
+ const job = client.startJob('upload', String(value.name || basename(localPath)), (active) =>
2094
+ client.uploadFile(localPath, value.parentFileId, value.name, active),
2095
+ )
2096
+ job.total = stats.size
2097
+ return { jobId: job.id }
2098
+ })
2099
+ },
2100
+ },
2101
+ {
2102
+ kind: 'exact',
2103
+ path: `${base}/pick`,
2104
+ handler: async (req, res) => {
2105
+ if (!guardWrite(req, res)) return
2106
+ await post(req, res, async (value) => {
2107
+ const mode = value.mode === 'folder' ? 'folder' : 'files'
2108
+ const paths = await pickLocalPaths(mode)
2109
+ return { paths, cancelled: paths.length === 0 }
2110
+ })
2111
+ },
2112
+ },
2113
+ {
2114
+ kind: 'exact',
2115
+ path: `${base}/upload-folder`,
2116
+ handler: async (req, res) => {
2117
+ if (!guardWrite(req, res)) return
2118
+ await post(req, res, async (value) => {
2119
+ const localPath = String(value.localPath ?? '').trim()
2120
+ if (!localPath) throw new Error('localPath 不能为空')
2121
+ const stats = statSync(localPath)
2122
+ if (!stats.isDirectory()) throw new Error(`不是一个文件夹:${localPath}`)
2123
+ const job = client.startJob('upload-folder', basename(localPath), (active) =>
2124
+ client.uploadFolder(localPath, value.parentFileId, active),
2125
+ )
2126
+ return { jobId: job.id }
2127
+ })
2128
+ },
2129
+ },
2130
+ {
2131
+ // 拖拽上传:body 是文件原始字节,不是 JSON,所以不能走 post() 助手
2132
+ kind: 'exact',
2133
+ path: `${base}/upload-body`,
2134
+ handler: async (req, res) => {
2135
+ if (!guard(req, res)) return
2136
+ if (String(req.method ?? 'GET').toUpperCase() !== 'POST') {
2137
+ writeJson(res, 405, { ok: false, error: 'method not allowed' })
2138
+ return
2139
+ }
2140
+ if (req.headers[WRITE_HEADER] !== '1') {
2141
+ writeJson(res, 403, { ok: false, error: 'missing plugin header' })
2142
+ return
2143
+ }
2144
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
2145
+ const rawName = url.searchParams.get('name') || 'upload.bin'
2146
+ const name = basename(rawName).replace(/[\\/:*?"<>|]/g, '_') || 'upload.bin'
2147
+ const parentFileId = url.searchParams.get('parentFileId') || ROOT_ID
2148
+ const tempPath = join(tmpdir(), `dsh-mcloud-${randomUUID()}-${name}`)
2149
+ try {
2150
+ await pipeline(req, createWriteStream(tempPath))
2151
+ } catch (error) {
2152
+ rmSync(tempPath, { force: true })
2153
+ writeJson(res, 500, { ok: false, error: `接收上传数据失败:${errorMessage(error)}` })
2154
+ return
2155
+ }
2156
+ const job = client.startJob('upload', name, (active) =>
2157
+ client.uploadFromTempPath(tempPath, parentFileId, name, active),
2158
+ )
2159
+ writeJson(res, 200, { ok: true, jobId: job.id, name })
2160
+ },
2161
+ },
2162
+ {
2163
+ kind: 'exact',
2164
+ path: `${base}/download`,
2165
+ handler: async (req, res) => {
2166
+ if (!guardWrite(req, res)) return
2167
+ await post(req, res, async (value) => {
2168
+ const fileId = String(value.fileId ?? '').trim()
2169
+ if (!fileId) throw new Error('fileId 不能为空')
2170
+ const localPath =
2171
+ String(value.localPath ?? '').trim() || join(DEFAULT_DOWNLOAD_DIR, fileId)
2172
+ const job = client.startJob('download', basename(localPath), (active) =>
2173
+ client.downloadFile(fileId, localPath, active),
2174
+ )
2175
+ return { jobId: job.id, localPath }
2176
+ })
2177
+ },
2178
+ },
2179
+ {
2180
+ kind: 'exact',
2181
+ path: `${base}/download-folder`,
2182
+ handler: async (req, res) => {
2183
+ if (!guardWrite(req, res)) return
2184
+ await post(req, res, async (value) => {
2185
+ const folderId = String(value.folderId ?? '').trim()
2186
+ if (!folderId) throw new Error('folderId 不能为空')
2187
+ const localDir =
2188
+ String(value.localPath ?? '').trim() || join(DEFAULT_DOWNLOAD_DIR, folderId)
2189
+ const job = client.startJob('download-folder', basename(localDir), (active) =>
2190
+ client.downloadFolder(folderId, localDir, active),
2191
+ )
2192
+ return { jobId: job.id, localDir }
2193
+ })
2194
+ },
2195
+ },
2196
+ {
2197
+ // 「上传文件夹」走浏览器的 webkitdirectory 时,浏览器只给相对路径,
2198
+ // 所以要按相对路径逐层把远端目录建出来,再把文件塞进对应目录。
2199
+ kind: 'exact',
2200
+ path: `${base}/mkdir-path`,
2201
+ handler: async (req, res) => {
2202
+ if (!guardWrite(req, res)) return
2203
+ await post(req, res, async (value) => {
2204
+ const parentFileId = String(value.parentFileId || ROOT_ID)
2205
+ const parts = String(value.path ?? '')
2206
+ .split(/[\\/]+/)
2207
+ .map((part) => part.trim())
2208
+ .filter((part) => part && part !== '.' && part !== '..')
2209
+ if (!parts.length) throw new Error('path 不能为空')
2210
+ let current = parentFileId
2211
+ for (const part of parts) {
2212
+ current = await client.ensureFolder(current, part)
2213
+ }
2214
+ return { fileId: current }
2215
+ })
2216
+ },
2217
+ },
2218
+ {
2219
+ kind: 'exact',
2220
+ path: `${base}/transfer/cancel`,
2221
+ handler: async (req, res) => {
2222
+ if (!guardWrite(req, res)) return
2223
+ await post(req, res, async (value) => {
2224
+ const cancelled = client.cancelJob(String(value.jobId ?? ''))
2225
+ return { cancelled }
2226
+ })
2227
+ },
2228
+ },
2229
+ ]
2230
+ }
2231
+
2232
+ // ------------------------------------------------------------------ apply
2233
+
2234
+ export function apply(ctx, rawConfig) {
2235
+ const config = unwrapConfig(rawConfig)
2236
+ const credentialDir = typeof config.credentialDir === 'string' && config.credentialDir
2237
+ ? resolve(config.credentialDir)
2238
+ : DEFAULT_CREDENTIAL_DIR
2239
+
2240
+ // 用户自己指定了目录就不用回落;否则允许读旧版本留下的凭据。
2241
+ const legacyDirs = typeof config.credentialDir === 'string' && config.credentialDir
2242
+ ? []
2243
+ : LEGACY_CREDENTIAL_DIRS
2244
+
2245
+ let store
2246
+ try {
2247
+ store = createCredentialStore(credentialDir, legacyDirs)
2248
+ } catch (error) {
2249
+ ctx.logger?.warn?.(`mcloud: 凭据目录不可用(${errorMessage(error)})`)
2250
+ store = createCredentialStore(DEFAULT_CREDENTIAL_DIR, legacyDirs)
2251
+ }
2252
+
2253
+ const client = createClient(store, ctx.logger, config)
2254
+
2255
+ // ---- 工具
2256
+ try {
2257
+ const disposers = []
2258
+ for (const definition of toolDefinitions(client)) {
2259
+ try {
2260
+ const dispose = ctx.tools.register({
2261
+ name: definition.name,
2262
+ description: definition.description,
2263
+ parameters: definition.parameters,
2264
+ output: outputSpec,
2265
+ timeoutMs: definition.timeoutMs ?? 60_000,
2266
+ execute: (args, execution) => definition.execute(args ?? {}, execution),
2267
+ })
2268
+ if (typeof dispose === 'function') disposers.push(dispose)
2269
+ } catch (error) {
2270
+ ctx.logger?.warn?.(`mobile-cloud: 工具 ${definition.name} 注册失败(${errorMessage(error)})`)
2271
+ }
2272
+ }
2273
+ ctx.effect(
2274
+ () => () => {
2275
+ for (const dispose of disposers.reverse()) {
2276
+ try {
2277
+ dispose()
2278
+ } catch {
2279
+ /* 已经卸过了 */
2280
+ }
2281
+ }
2282
+ },
2283
+ 'dsh-mcloud: tools',
2284
+ )
2285
+
2286
+ // 写入类工具一律走审批(跟百度网盘插件同一套闸)
2287
+ if (config.enableWrites !== false) {
2288
+ ctx.on('tools/pre-execute', async (execution, next) => {
2289
+ const decision = await next()
2290
+ if (decision?.kind === 'allow' && WRITE_TOOL_NAMES.has(execution?.name)) {
2291
+ return { kind: 'ask', reason: '这是移动云盘上会改动内容的操作,确认一下再执行。' }
2292
+ }
2293
+ return decision
2294
+ })
2295
+ }
2296
+ } catch (error) {
2297
+ ctx.logger?.warn?.(`mobile-cloud: 工具注册整体失败(${errorMessage(error)})`)
2298
+ }
2299
+
2300
+ // ---- HTTP 路由
2301
+ try {
2302
+ ctx.inject(['webServer'], (webCtx) => {
2303
+ const routes = createRoutes(client)
2304
+ const disposers = routes.map((route) => webCtx.webServer.register(route))
2305
+ webCtx.effect(
2306
+ () => () => {
2307
+ for (const dispose of disposers) {
2308
+ try {
2309
+ dispose()
2310
+ } catch {
2311
+ /* 已经卸过了 */
2312
+ }
2313
+ }
2314
+ },
2315
+ 'dsh-mcloud: routes',
2316
+ )
2317
+ })
2318
+ } catch (error) {
2319
+ ctx.logger?.warn?.(`mobile-cloud: 路由注册失败(${errorMessage(error)})`)
2320
+ }
2321
+ }