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