deepclean-mydisk 0.0.0-stage → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1944 @@
1
+ /**
2
+ * deepclean-mydisk —— DSH host 插件(v0.4)
3
+ *
4
+ * 组成:
5
+ * 1. 模型工具:disk_check_permissions / disk_audit / disk_inspect / disk_report / disk_self_test(全部只读)
6
+ * 2. 侧边栏 API:扫描(后台/进度/取消)、历史、恢复、白名单、清理、撤销
7
+ *
8
+ * 安全边界:清理只移到废纸篓/隔离区;只接受最近一次扫描(10 分钟内)的允许路径;
9
+ * 白名单路径永远拒绝;符号链接拒绝。
10
+ */
11
+ import { spawn, spawnSync } from 'node:child_process'
12
+ import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ import { defineTool } from '@deepseek-ai/dsh-tools'
18
+ import { moveToTrash, restoreMoved, appendAudit, moveAcrossDevices, canRestoreMoved, purgeMoved, summarizeResults, quarantineSummary, MAX_BATCH } from './clean.js'
19
+ import { isProtected } from './platform.js'
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
22
+ const RESOURCES = path.resolve(HERE, '..', 'resources')
23
+ const ENTRY = path.join(RESOURCES, 'scripts', 'deepclean.py')
24
+ const MAX_OUTPUT = 24000
25
+
26
+ const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
27
+ const HISTORY_FILE = path.join(DSH_HOME, 'deepclean-history.json')
28
+ const WHITELIST_FILE = path.join(DSH_HOME, 'deepclean-whitelist.json')
29
+ const AUDIT_FILE = path.join(DSH_HOME, 'deepclean-audit.log')
30
+ const REPORT_TTL_MS = 10 * 60 * 1000
31
+ // 清理目的地的平台词:macOS 为废纸篓,Windows 与其他平台为隔离区(见 docs/copy-terms.md)。
32
+ const TRASH_WORD = process.platform === 'darwin' ? '废纸篓' : '隔离区'
33
+
34
+ export const name = 'deepclean-mydisk'
35
+ export const inject = ['tools']
36
+
37
+ let lastReport = null
38
+ let lastReportAt = 0
39
+ // disk_audit 的临时报告:只给 disk_inspect 解析 itemId / 覆盖路径,绝不参与清理放行。
40
+ let lastToolReport = null
41
+ let lastToolReportAt = 0
42
+ let activeChild = null
43
+ const lastBatches = []
44
+
45
+ const scanState = {
46
+ status: 'idle', // idle | running | done | error | canceled
47
+ progress: 0,
48
+ stage: '',
49
+ startedAt: 0,
50
+ finishedAt: 0,
51
+ error: '',
52
+ full: false,
53
+ canceled: false,
54
+ }
55
+
56
+ function killTree(child) {
57
+ if (!child || child.killed) return
58
+ if (process.platform === 'win32') {
59
+ try { spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) } catch { /* ignore */ }
60
+ return
61
+ }
62
+ try { process.kill(-child.pid, 'SIGTERM') } catch { try { child.kill('SIGTERM') } catch { /* ignore */ } }
63
+ setTimeout(() => {
64
+ try { process.kill(-child.pid, 'SIGKILL') } catch { /* ignore */ }
65
+ }, 1500).unref?.()
66
+ }
67
+
68
+ function pythonCommand() {
69
+ const win = process.platform === 'win32'
70
+ return { cmd: win ? 'py' : 'python3', args: win ? ['-3'] : [] }
71
+ }
72
+
73
+ function runScanner(extraArgs, timeoutMs, opts = {}, script = 'deepclean.py') {
74
+ const { cmd, args } = pythonCommand()
75
+ const target = path.join(RESOURCES, 'scripts', script)
76
+ return new Promise((resolve, reject) => {
77
+ let child
78
+ try {
79
+ child = spawn(cmd, [...args, target, ...extraArgs], {
80
+ cwd: RESOURCES,
81
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...(opts.env || {}) },
82
+ stdio: ['ignore', 'pipe', 'pipe'],
83
+ detached: process.platform !== 'win32',
84
+ windowsHide: true,
85
+ })
86
+ } catch (err) {
87
+ reject(new Error(`无法启动 ${cmd}:${err instanceof Error ? err.message : String(err)}`))
88
+ return
89
+ }
90
+ if (typeof opts.trackChild === 'function') opts.trackChild(child)
91
+ else activeChild = child
92
+ let out = ''
93
+ let err = ''
94
+ let buf = ''
95
+ child.stdout.setEncoding('utf8')
96
+ child.stderr.setEncoding('utf8')
97
+ child.stdout.on('data', (chunk) => { out += chunk })
98
+ child.stderr.on('data', (chunk) => {
99
+ err += chunk
100
+ if (typeof opts.onProgress !== 'function') return
101
+ buf += chunk
102
+ let idx
103
+ while ((idx = buf.indexOf('\n')) >= 0) {
104
+ const line = buf.slice(0, idx).trim()
105
+ buf = buf.slice(idx + 1)
106
+ const m = /^PROGRESS (\d+) (.+)$/.exec(line)
107
+ if (m) opts.onProgress(Number(m[1]), m[2])
108
+ }
109
+ })
110
+ const timer = setTimeout(() => {
111
+ try { killTree(child) } catch { /* ignore */ }
112
+ reject(new Error(`扫描超时(${Math.round(timeoutMs / 1000)}s);可改用快速扫描`))
113
+ }, timeoutMs)
114
+ child.on('error', (error) => {
115
+ clearTimeout(timer)
116
+ reject(new Error(`无法启动 ${cmd}:${error.message}(请确认本机已安装 Python 3)`))
117
+ })
118
+ child.on('close', (code) => {
119
+ clearTimeout(timer)
120
+ if (typeof opts.trackChild === 'function') opts.trackChild(null)
121
+ else if (activeChild === child) activeChild = null
122
+ resolve({ code, out, err })
123
+ })
124
+ })
125
+ }
126
+
127
+ function clip(text) {
128
+ if (text.length <= MAX_OUTPUT) return text
129
+ return `${text.slice(0, MAX_OUTPUT)}\n\n…(输出已截断,共 ${text.length} 字符)`
130
+ }
131
+
132
+ function formatResult(result) {
133
+ const text = (result.out || '').trim()
134
+ if (text) return clip(text)
135
+ return clip((result.err || '').trim() || `(无输出,exit ${result.code})`)
136
+ }
137
+
138
+ const output = {
139
+ schema: { type: 'string' },
140
+ render: (_args, value) => [{ type: 'text', text: value }],
141
+ }
142
+
143
+ function readJsonFile(file, fallback) {
144
+ try {
145
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
146
+ } catch {
147
+ return fallback
148
+ }
149
+ }
150
+
151
+ function writeJsonFile(file, data) {
152
+ fs.mkdirSync(path.dirname(file), { recursive: true })
153
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8')
154
+ }
155
+
156
+ function loadWhitelist() {
157
+ const data = readJsonFile(WHITELIST_FILE, { paths: [] })
158
+ return Array.isArray(data.paths) ? data.paths.filter((p) => typeof p === 'string') : []
159
+ }
160
+
161
+ function saveWhitelist(paths) {
162
+ writeJsonFile(WHITELIST_FILE, { paths })
163
+ }
164
+
165
+ function appendHistory(entry) {
166
+ const data = readJsonFile(HISTORY_FILE, { items: [] })
167
+ const items = Array.isArray(data.items) ? data.items : []
168
+ items.push(entry)
169
+ writeJsonFile(HISTORY_FILE, { items: items.slice(-100) })
170
+ }
171
+
172
+ function loadHistory() {
173
+ const data = readJsonFile(HISTORY_FILE, { items: [] })
174
+ return Array.isArray(data.items) ? data.items : []
175
+ }
176
+
177
+ function listCleanBatches(limit = 20) {
178
+ let lines = []
179
+ try {
180
+ lines = fs.readFileSync(AUDIT_FILE, 'utf8').split('\n').filter(Boolean)
181
+ } catch {
182
+ return []
183
+ }
184
+ const batches = []
185
+ const latestOwner = new Map()
186
+ const restores = new Map()
187
+ const purged = new Set()
188
+ for (const line of lines) {
189
+ try {
190
+ const e = JSON.parse(line)
191
+ if (e.action === 'clean' && Array.isArray(e.moved) && e.moved.length) {
192
+ const items = e.moved.map(m => ({ ...m, restored: false }))
193
+ for (const item of items) latestOwner.set(item.to, item)
194
+ batches.push({ batchId: e.batchId, at: e.at, items })
195
+ } else if (e.action === 'purge' && e.batchId) {
196
+ purged.add(e.batchId)
197
+ } else if (e.action === 'restore' && Array.isArray(e.results)) {
198
+ // Replay legacy logs too, including partial successful restores.
199
+ for (const batch of batches) {
200
+ if (batch.batchId !== e.batchId) continue
201
+ for (const item of batch.items) {
202
+ if (e.results.some(r => r.ok && r.path === item.from && (!r.to || r.to === item.to))) item.restored = true
203
+ }
204
+ }
205
+ // 最近一次恢复是否部分失败:历史页据此提示「上次恢复失败」并给另存为副本入口。
206
+ const failed = e.results.filter(r => !r.ok)
207
+ const codes = {}
208
+ for (const r of failed) codes[r.code || 'io'] = (codes[r.code || 'io'] || 0) + 1
209
+ restores.set(e.batchId, { at: e.at, ok: e.results.length - failed.length, failed: failed.length, codes })
210
+ }
211
+ } catch { /* ignore */ }
212
+ }
213
+ return batches.reverse().map(batch => {
214
+ const items = batch.items.map(item => ({
215
+ from: item.from, to: item.to, identity: item.identity, restored: item.restored,
216
+ // 记录「清理时报告里的大小/分类」,供历史页统计与复清分析;旧日志没有这两个字段。
217
+ kb: Number.isFinite(item.kb) ? item.kb : null,
218
+ category: typeof item.category === 'string' ? item.category : null,
219
+ exists: !item.restored && latestOwner.get(item.to) === item && canRestoreMoved(item),
220
+ }))
221
+ return {
222
+ batchId: batch.batchId, at: batch.at, count: items.length,
223
+ restorable: items.filter(i => i.exists).length, items,
224
+ lastRestore: restores.get(batch.batchId) || null,
225
+ restored: items.filter((i) => i.restored).length,
226
+ purged: purged.has(batch.batchId),
227
+ }
228
+ }).filter((batch, index) => index < limit || batch.restorable > 0)
229
+ }
230
+
231
+ // 隔离区/废纸篓汇总:遍历目录有成本,面板轮询用 3 秒缓存;清空/恢复/清理后主动失效。
232
+ let quarantineCache = { at: 0, data: null }
233
+
234
+ export function quarantineInfo() {
235
+ const now = Date.now()
236
+ if (quarantineCache.data && now - quarantineCache.at < 3000) return quarantineCache.data
237
+ let data
238
+ try { data = quarantineSummary() } catch { data = { root: '', kb: 0, files: 0, batches: [], truncated: false } }
239
+ data = {
240
+ ...data,
241
+ // macOS 扫描器会统计整个 ~/.Trash(含非本插件文件);面板要把两者分开说清楚。
242
+ systemTrashKb: lastReport && Number.isFinite(lastReport.trashKb) ? lastReport.trashKb : null,
243
+ }
244
+ quarantineCache = { at: now, data }
245
+ return data
246
+ }
247
+
248
+ export function invalidateQuarantine() {
249
+ quarantineCache = { at: 0, data: null }
250
+ }
251
+
252
+ function reportSummary(report) {
253
+ const groups = report.leftoverGroups || []
254
+ const safeKb = groups.filter((g) => g.bucket === 'safe').reduce((s, g) => s + g.kb, 0)
255
+ const confirmKb = groups.filter((g) => g.bucket === 'confirm').reduce((s, g) => s + g.kb, 0)
256
+ return {
257
+ safeKb,
258
+ confirmKb,
259
+ cacheKb: report.estimate ? report.estimate.cacheKb : 0,
260
+ devKb: report.estimate ? report.estimate.devKb : 0,
261
+ totalKb: report.estimate ? report.estimate.totalKb : 0,
262
+ }
263
+ }
264
+
265
+ function scanSummary() {
266
+ const end = scanState.finishedAt || Date.now()
267
+ const elapsed = scanState.startedAt ? Math.max(0, Math.round((end - scanState.startedAt) / 1000)) : 0
268
+ let progress = scanState.progress
269
+ if (scanState.status === 'running' && scanState.startedAt) {
270
+ const expectedMs = scanState.full ? 45000 : 28000
271
+ const interpolated = Math.min(95, Math.round((elapsed * 1000 / expectedMs) * 95))
272
+ progress = Math.max(progress, interpolated)
273
+ }
274
+ return {
275
+ status: scanState.status,
276
+ progress,
277
+ stage: scanState.stage,
278
+ elapsed,
279
+ error: scanState.error,
280
+ canceled: scanState.canceled,
281
+ full: scanState.full,
282
+ at: lastReportAt,
283
+ hasReport: !!lastReport,
284
+ }
285
+ }
286
+
287
+ function startScan(full) {
288
+ if (scanState.status === 'running') return false
289
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepclean-'))
290
+ const file = path.join(dir, 'report.json')
291
+ Object.assign(scanState, {
292
+ status: 'running', progress: 0, stage: '准备扫描', startedAt: Date.now(), finishedAt: 0,
293
+ error: '', full: !!full, canceled: false,
294
+ })
295
+ const argv = []
296
+ if (!full) argv.push('--quick')
297
+ argv.push('--json', file)
298
+ runScanner(argv, 300000, {
299
+ onProgress: (pct, label) => {
300
+ scanState.progress = pct
301
+ scanState.stage = label
302
+ },
303
+ }).then((res) => {
304
+ if (scanState.canceled) {
305
+ scanState.status = 'canceled'
306
+ scanState.stage = '已取消'
307
+ scanState.finishedAt = Date.now()
308
+ return
309
+ }
310
+ if (res.code !== 0) {
311
+ scanState.status = 'error'
312
+ scanState.error = (res.out || res.err || `exit ${res.code}`).trim().slice(0, 500)
313
+ scanState.finishedAt = Date.now()
314
+ return
315
+ }
316
+ const report = JSON.parse(fs.readFileSync(file, 'utf8'))
317
+ lastReport = report
318
+ lastReportAt = Date.now()
319
+ scanState.report = report
320
+ scanState.progress = 100
321
+ scanState.stage = '完成'
322
+ scanState.status = 'done'
323
+ scanState.finishedAt = Date.now()
324
+ appendHistory({
325
+ at: lastReportAt,
326
+ platform: report.platform || process.platform,
327
+ free: report.disk ? report.disk.free : 0,
328
+ total: report.disk ? report.disk.total : 0,
329
+ elapsed: report.elapsed || 0,
330
+ full: !!full,
331
+ ...reportSummary(report),
332
+ })
333
+ }).catch((err) => {
334
+ scanState.status = scanState.canceled ? 'canceled' : 'error'
335
+ scanState.error = String(err && err.message ? err.message : err)
336
+ scanState.finishedAt = Date.now()
337
+ }).finally(() => {
338
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
339
+ })
340
+ return true
341
+ }
342
+
343
+ const photosState = {
344
+ status: 'idle', progress: 0, stage: '', startedAt: 0, finishedAt: 0, error: '', result: null,
345
+ }
346
+ let photoChild = null
347
+ let photoAllowed = new Set()
348
+ let photoAt = 0
349
+
350
+ function photosSummary() {
351
+ const end = photosState.finishedAt || Date.now()
352
+ return {
353
+ status: photosState.status,
354
+ progress: photosState.progress,
355
+ stage: photosState.stage,
356
+ elapsed: photosState.startedAt ? Math.round((end - photosState.startedAt) / 1000) : 0,
357
+ error: photosState.error,
358
+ at: photoAt,
359
+ hasResult: !!photosState.result,
360
+ }
361
+ }
362
+
363
+ function startPhotoScan() {
364
+ if (photosState.status === 'running') return false
365
+ if (scanState.status === 'running') return false
366
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepclean-photos-'))
367
+ const file = path.join(dir, 'photos.json')
368
+ Object.assign(photosState, { status: 'running', progress: 0, stage: '准备', startedAt: Date.now(), finishedAt: 0, error: '', result: null })
369
+ runScanner(['--json', file], 600000, {
370
+ onProgress: (pct, label) => { photosState.progress = pct; photosState.stage = label },
371
+ trackChild: (child) => { photoChild = child },
372
+ }, 'photos.py').then((res) => {
373
+ if (photosState.status !== 'running') return
374
+ if (res.code !== 0) {
375
+ photosState.status = 'error'
376
+ photosState.error = (res.out || res.err || `exit ${res.code}`).trim().slice(0, 500)
377
+ photosState.finishedAt = Date.now()
378
+ return
379
+ }
380
+ const result = JSON.parse(fs.readFileSync(file, 'utf8'))
381
+ photosState.result = result
382
+ photosState.progress = 100
383
+ photosState.stage = '完成'
384
+ photosState.status = 'done'
385
+ photosState.finishedAt = Date.now()
386
+ photoAt = Date.now()
387
+ photoAllowed = new Set()
388
+ for (const g of result.groups || []) for (const it of g.items || []) if (!isCloudBackedPath(it.path)) photoAllowed.add(it.path)
389
+ }).catch((err) => {
390
+ photosState.status = photosState.status === 'running' ? 'error' : photosState.status
391
+ photosState.error = String(err && err.message ? err.message : err)
392
+ photosState.finishedAt = Date.now()
393
+ }).finally(() => {
394
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
395
+ })
396
+ return true
397
+ }
398
+
399
+ const moveState = {
400
+ status: 'idle', done: 0, total: 0, current: '', results: [], error: '', startedAt: 0, finishedAt: 0,
401
+ }
402
+ let moveCanceled = false
403
+
404
+ function moveSummary() {
405
+ const end = moveState.finishedAt || Date.now()
406
+ return {
407
+ destination: moveState.destination || '',
408
+ startedAt: moveState.startedAt,
409
+ status: moveState.status,
410
+ done: moveState.done,
411
+ total: moveState.total,
412
+ current: moveState.current,
413
+ results: moveState.results,
414
+ error: moveState.error,
415
+ elapsed: moveState.startedAt ? Math.round((end - moveState.startedAt) / 1000) : 0,
416
+ }
417
+ }
418
+
419
+ function assertMoveSource(p) {
420
+ if (!withinHome(p) || isProtected(p, loadWhitelist())) throw new Error('个人文件夹之外或受保护的路径不可移动')
421
+ const home = path.resolve(process.env.HOME || os.homedir())
422
+ for (let current = path.resolve(p); current !== home; current = path.dirname(current)) {
423
+ if (fs.lstatSync(current).isSymbolicLink()) throw new Error('符号链接不可移动')
424
+ }
425
+ }
426
+
427
+ async function moveJob(paths, dest, destStat) {
428
+ for (const p of paths) {
429
+ if (moveCanceled) break
430
+ moveState.current = path.basename(p)
431
+ try {
432
+ if (!withinHome(p)) throw new Error('只允许移动个人文件夹内的文件')
433
+ assertMoveSource(p)
434
+ const st = fs.statSync(p)
435
+ if (!st.isFile()) throw new Error('只支持移动单个文件')
436
+ let target = path.join(dest, path.basename(p))
437
+ let i = 1
438
+ while (fs.existsSync(target)) target = path.join(dest, `${path.basename(p)}.${i++}`)
439
+ if (st.dev === destStat.dev) {
440
+ fs.renameSync(p, target)
441
+ } else {
442
+ await moveAcrossDevices(p, target)
443
+ }
444
+ moveState.results.push({ path: p, ok: true, to: target })
445
+ } catch (err) {
446
+ moveState.results.push({ path: p, ok: false, error: String(err && err.message ? err.message : err) })
447
+ }
448
+ moveState.done += 1
449
+ }
450
+ moveState.current = ''
451
+ moveState.finishedAt = Date.now()
452
+ moveState.status = moveCanceled ? 'canceled' : 'done'
453
+ appendAudit({ action: 'move', destination: dest, canceled: moveCanceled, results: moveState.results })
454
+ }
455
+
456
+ // ── 云盘 / App 数据目录保护(搬家与照片去重的第二道防线)─────────────────
457
+ const PACKAGE_SUFFIXES = ['.photoslibrary', '.photolibrary', '.sparsebundle', '.fcpbundle',
458
+ '.logicx', '.band', '.imovielibrary', '.tvlibrary', '.aplibrary']
459
+ const CLOUD_DIR_NAMES = ['onedrive', 'dropbox', 'google drive', 'icloud drive']
460
+
461
+ export function cloudRoots() {
462
+ const roots = []
463
+ const home = process.env.HOME || os.homedir()
464
+ if (process.platform === 'darwin') {
465
+ roots.push(path.join(home, 'Library', 'CloudStorage'), path.join(home, 'Library', 'Mobile Documents'))
466
+ } else if (process.platform === 'win32') {
467
+ for (const name of ['OneDrive', 'OneDriveConsumer', 'OneDriveCommercial']) {
468
+ if (process.env[name]) roots.push(process.env[name])
469
+ }
470
+ try {
471
+ for (const name of fs.readdirSync(home)) {
472
+ const low = name.toLowerCase()
473
+ if (low.startsWith('onedrive') || low === 'dropbox' || low === 'google drive') roots.push(path.join(home, name))
474
+ }
475
+ } catch { /* ignore */ }
476
+ }
477
+ const extra = process.env.DEEPCLEAN_CLOUD_ROOTS
478
+ if (extra) {
479
+ try { for (const p of JSON.parse(extra)) if (typeof p === 'string' && path.isAbsolute(p)) roots.push(p) } catch { /* ignore */ }
480
+ }
481
+ const seen = new Set()
482
+ return roots.map((r) => path.resolve(r)).filter((r) => (seen.has(r) ? false : (seen.add(r), true)))
483
+ }
484
+
485
+ function comparablePath(p) {
486
+ const resolved = path.resolve(p)
487
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved
488
+ }
489
+
490
+ /** 路径是否落在云盘根内(OneDrive/iCloud/Dropbox/Google Drive):真实扫描器已排除,宿主再兜底。 */
491
+ export function isCloudBackedPath(p, roots = cloudRoots()) {
492
+ if (typeof p !== 'string' || !path.isAbsolute(p)) return false
493
+ const target = comparablePath(p)
494
+ for (const root of roots) {
495
+ const base = comparablePath(root)
496
+ if (target === base || target.startsWith(base + path.sep)) return true
497
+ }
498
+ return false
499
+ }
500
+
501
+ /** 搬家拒绝原因:云盘文件(会同步删云端)与 App 数据目录(会破坏应用)。返回空串表示允许。 */
502
+ export function moveBlockReason(p) {
503
+ if (typeof p !== 'string' || !path.isAbsolute(p)) return '路径必须是绝对路径'
504
+ if (isCloudBackedPath(p)) return '云盘文件不参与搬家(请在云盘客户端中处理)'
505
+ const segments = path.resolve(p).split(path.sep).map((seg) => seg.toLowerCase())
506
+ const pairs = segments.slice(0, -1).map((seg, i) => `${seg}/${segments[i + 1]}`)
507
+ if (pairs.some((pair) => ['library/mail', 'library/messages', 'library/group containers', 'library/containers', 'library/application support'].includes(pair))) {
508
+ return 'App 数据目录(如邮件、应用容器)内的文件不允许搬家,可能破坏应用数据'
509
+ }
510
+ if (segments.some((seg) => PACKAGE_SUFFIXES.some((ext) => seg.endsWith(ext)))) {
511
+ return 'App 数据目录(如照片图库)内的文件不允许搬家,可能破坏应用数据'
512
+ }
513
+ const joined = segments.join('/')
514
+ const home = process.env.HOME || os.homedir()
515
+ const appRoots = [path.join(home, 'AppData'), process.env.LOCALAPPDATA, process.env.APPDATA].filter(Boolean)
516
+ if (appRoots.some(root => {
517
+ const base = comparablePath(root), target = comparablePath(p)
518
+ return target === base || target.startsWith(base + path.sep)
519
+ })) return 'App 数据目录内的文件不允许搬家,可能破坏应用数据'
520
+ if (/(^|\/)appdata\/local\/packages(\/|$)/.test(joined) || /(^|\/)windowsapps(\/|$)/.test(joined)) {
521
+ return '商店应用数据目录内的文件不允许搬家,可能破坏应用数据'
522
+ }
523
+ return ''
524
+ }
525
+
526
+ // Big files confirmed by a fresh tree walk (<= REPORT_TTL_MS), the second
527
+ // allow-set channel for moves alongside the full-scan report.
528
+ function treeConfirmedPaths() {
529
+ const set = new Set()
530
+ const now = Date.now()
531
+ const clouds = cloudRoots()
532
+ for (const entry of treeCache.values()) {
533
+ if (now - entry.at > REPORT_TTL_MS) continue
534
+ for (const f of entry.data.bigFiles || []) if (!isCloudBackedPath(f.path, clouds)) set.add(f.path)
535
+ }
536
+ return set
537
+ }
538
+
539
+ function startMove(paths, destination) {
540
+ if (moveState.status === 'running') throw new Error('已有移动任务在进行中')
541
+ const reportFresh = !!lastReport && Date.now() - lastReportAt <= REPORT_TTL_MS
542
+ const allowed = treeConfirmedPaths()
543
+ if (reportFresh) for (const b of lastReport.bigFiles || []) allowed.add(b.path)
544
+ if (allowed.size === 0) throw new Error('扫描结果已过期,请先完整扫描')
545
+ if (!Array.isArray(paths) || paths.some(p => !allowed.has(p))) throw new Error('包含未经最近扫描确认的文件')
546
+ const items = [...new Set(paths)]
547
+ for (const p of items) {
548
+ assertMoveSource(p)
549
+ const blocked = moveBlockReason(p)
550
+ if (blocked) throw new Error(blocked)
551
+ }
552
+ if (items.length === 0) throw new Error('没有可移动的大文件(需先「完整扫描」或在「空间」中浏览目录)')
553
+ const dest = path.resolve(String(destination || ''))
554
+ if (!fs.existsSync(dest) || fs.lstatSync(dest).isSymbolicLink() || !fs.statSync(dest).isDirectory()) throw new Error('目标目录不存在')
555
+ try { fs.accessSync(dest, fs.constants.W_OK) } catch { throw new Error('目标目录不可写') }
556
+ const destStat = fs.statSync(dest)
557
+ let total = 0
558
+ for (const p of items) total += fs.statSync(p).size
559
+ try {
560
+ const st = fs.statfsSync(dest)
561
+ if (total > st.bavail * st.bsize * 0.95) throw new Error('目标磁盘空间不足')
562
+ } catch (err) {
563
+ if (String(err.message).includes('空间不足')) throw err
564
+ }
565
+ moveCanceled = false
566
+ Object.assign(moveState, {
567
+ destination: dest, status: 'running', done: 0, total: items.length, current: '', results: [],
568
+ error: '', startedAt: Date.now(), finishedAt: 0,
569
+ })
570
+ moveJob(items, dest, destStat).catch((err) => {
571
+ moveState.status = 'error'
572
+ moveState.error = String(err && err.message ? err.message : err)
573
+ moveState.finishedAt = Date.now()
574
+ })
575
+ return items.length
576
+ }
577
+
578
+ // Exported for tests: the clean allow-set contract (report → panel → clean route).
579
+ export function allowedPaths(report) {
580
+ const whitelist = loadWhitelist()
581
+ const clouds = cloudRoots()
582
+ const set = new Set()
583
+ const running = [...(report.caches || []), ...(report.devCaches || []), ...(report.browsers || []), ...(report.appCaches || [])]
584
+ .filter(item => item.running).flatMap(item => item.paths || [item.path]).filter(Boolean).map(comparablePath)
585
+ const add = (p) => {
586
+ if (!p || isProtected(p, whitelist) || isCloudBackedPath(p, clouds)) return
587
+ const target = comparablePath(p)
588
+ if (running.some(base => target === base || target.startsWith(base + path.sep) || base.startsWith(target + path.sep))) return
589
+ set.add(p)
590
+ }
591
+ for (const g of report.leftoverGroups || []) {
592
+ if (g.bucket !== 'safe') continue
593
+ for (const p of g.paths || []) add(p)
594
+ }
595
+ // admin 项(如 C:\Windows\Temp)与扫描器标记 cleanable=false 的项(如被占用的 Temp)不进允许集合;
596
+ // 运行中的浏览器/应用缓存同样排除(扫描时刻的进程快照,与 appCaches 同口径)。
597
+ for (const c of report.caches || []) if (!c.system && !c.admin && c.cleanable !== false && !c.running) add(c.path)
598
+ for (const d of report.devCaches || []) {
599
+ if (d.cleanable !== false) add(d.path)
600
+ }
601
+ for (const b of report.browsers || []) if (!b.running) add(b.path)
602
+ for (const a of report.appCaches || []) {
603
+ if (a.cleanable === false || a.running) continue
604
+ for (const p of a.paths || []) add(p)
605
+ }
606
+ for (const p of photoAllowed) add(p)
607
+ for (const app of report.special || []) {
608
+ for (const c of app.categories || []) {
609
+ if (!c.cleanable) continue
610
+ for (const p of c.paths || []) add(p)
611
+ }
612
+ }
613
+ return set
614
+ }
615
+
616
+ // 清理成功后把已移动的路径从内存报告中剔除:连续清理不必全量重扫,
617
+ // 同一项的再次清理会得到「不在允许列表」的明确报错而非逐条 lstat 失败。
618
+ // 部分清理的组保留剩余路径、kb 维持扫描值(轻微高估,下次扫描自愈)。
619
+ export function pruneReport(report, cleanedPaths) {
620
+ if (!report || !cleanedPaths || cleanedPaths.size === 0) return report
621
+ const keep = (paths) => (Array.isArray(paths) ? paths : []).filter((p) => !cleanedPaths.has(p))
622
+ const r = { ...report }
623
+ r.leftoverGroups = (report.leftoverGroups || []).map((g) => ({ ...g, paths: keep(g.paths) })).filter((g) => g.paths.length > 0)
624
+ r.caches = (report.caches || []).filter((c) => !cleanedPaths.has(c.path))
625
+ r.devCaches = (report.devCaches || []).filter((d) => !cleanedPaths.has(d.path))
626
+ r.browsers = (report.browsers || []).filter((b) => !cleanedPaths.has(b.path))
627
+ r.appCaches = (report.appCaches || []).map((a) => ({ ...a, paths: keep(a.paths) })).filter((a) => a.paths.length > 0)
628
+ r.special = (report.special || [])
629
+ .map((app) => ({
630
+ ...app,
631
+ categories: (app.categories || []).map((c) => ({ ...c, paths: keep(c.paths) })).filter((c) => c.paths.length > 0),
632
+ }))
633
+ .filter((app) => app.categories.length > 0)
634
+ return r
635
+ }
636
+
637
+ /**
638
+ * 把一次清理的路径映射回扫描报告里的「大小 / 分类」,**只用于审计与复清分析**。
639
+ * 放行判断仍然只看 allowedPaths;这里的映射不参与任何准入决定。
640
+ * 组类条目(一个条目含多个路径)只在本次实际选中的第一个路径上记一次整组大小,
641
+ * 避免同一组被重复累加;其余路径记 null。
642
+ *
643
+ * @param {object|null} report 最近一次扫描报告
644
+ * @param {string[]} paths 本次要清理的路径
645
+ * @returns {Map<string, {kb: number|null, category: string}>}
646
+ */
647
+ export function cleanAnnotations(report, paths) {
648
+ const wanted = new Set(Array.isArray(paths) ? paths.filter((p) => typeof p === 'string' && path.isAbsolute(p)) : [])
649
+ const map = new Map()
650
+ if (!report || wanted.size === 0) return map
651
+ const put = (p, kb, category) => {
652
+ if (!wanted.has(p) || map.has(p)) return
653
+ map.set(p, { kb: Number.isFinite(kb) ? kb : null, category })
654
+ }
655
+ const attribute = (list, kb, category) => {
656
+ const hit = (Array.isArray(list) ? list : []).find((p) => wanted.has(p))
657
+ if (hit) put(hit, kb, category)
658
+ }
659
+ for (const g of report.leftoverGroups || []) attribute(g.paths, g.kb, 'leftover')
660
+ for (const c of report.caches || []) put(c.path, c.kb, c.system ? 'system' : 'other')
661
+ for (const d of report.devCaches || []) put(d.path, d.kb, 'dev')
662
+ for (const b of report.browsers || []) put(b.path, b.kb, 'browser')
663
+ for (const a of report.appCaches || []) attribute(a.paths, a.kb, 'app')
664
+ for (const app of report.special || []) {
665
+ for (const c of app.categories || []) attribute(c.paths, c.kb, 'special')
666
+ }
667
+ return map
668
+ }
669
+
670
+ /** 照片清理的审计标注:每个文件就是一个路径,大小取分组里的单文件 kb。 */
671
+ function photoCleanAnnotations(result, paths) {
672
+ const wanted = new Set(Array.isArray(paths) ? paths.filter((p) => typeof p === 'string') : [])
673
+ const map = new Map()
674
+ if (!result || wanted.size === 0) return map
675
+ for (const g of result.groups || []) {
676
+ for (const it of g.items || []) {
677
+ if (!it || typeof it.path !== 'string' || !wanted.has(it.path) || map.has(it.path)) continue
678
+ map.set(it.path, { kb: Number.isFinite(it.kb) ? it.kb : null, category: 'photo' })
679
+ }
680
+ }
681
+ return map
682
+ }
683
+
684
+ // 服务端分片:单批上限 200 不变,超出时按顺序切成多个批次(每片独立 batchId/审计),
685
+ // 避免「勾 300 项一次全部失败」。导出给测试直接验证切片与边界。
686
+ export function splitBatches(paths, limit = MAX_BATCH) {
687
+ const list = Array.isArray(paths) ? paths : []
688
+ const size = Number.isFinite(limit) && limit > 0 ? limit : MAX_BATCH
689
+ const out = []
690
+ for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size))
691
+ return out
692
+ }
693
+
694
+ // 清理成功后从照片结果里剔除已移动的文件:相似组不足 2 张即消散,
695
+ // 避免 UI 继续展示已移走的照片并再次默认全选(点清理会 404/全部失败)。
696
+ export function prunePhotoResult(result, cleanedPaths) {
697
+ if (!result || !Array.isArray(result.groups) || !cleanedPaths || cleanedPaths.size === 0) return result
698
+ const groups = []
699
+ for (const g of result.groups) {
700
+ const items = (g.items || []).filter((it) => it && !cleanedPaths.has(it.path))
701
+ if (items.length < 2) continue
702
+ const totalKb = items.reduce((n, it) => n + (Number(it.kb) || 0), 0)
703
+ const biggest = items.reduce((a, b) => ((Number(b.kb) || 0) > (Number(a.kb) || 0) ? b : a), items[0])
704
+ groups.push({ ...g, items, totalKb, wastedKb: Math.max(0, totalKb - (Number(biggest.kb) || 0)) })
705
+ }
706
+ return { ...result, groups }
707
+ }
708
+
709
+ // 撤销后把已恢复的条目从内存批次里剔除(否则重试只会对它们报 notfound)。
710
+ export function trimUndoneBatch(moved, results) {
711
+ const restored = new Set((Array.isArray(results) ? results : []).filter((r) => r.ok).map((r) => r.path))
712
+ return (Array.isArray(moved) ? moved : []).filter((m) => !restored.has(m.from))
713
+ }
714
+
715
+ function estimateReclaim(paths) {
716
+ const budget = Date.now() + 8000
717
+ let logical = 0
718
+ let allocated = 0
719
+ let files = 0
720
+ let truncated = false
721
+ const walk = (p) => {
722
+ if (truncated) return
723
+ if (Date.now() > budget) { truncated = true; return }
724
+ let st
725
+ try { st = fs.lstatSync(p) } catch { return }
726
+ if (st.isSymbolicLink()) return
727
+ if (st.isFile()) { logical += st.size; allocated += (st.blocks || 0) * 512; files += 1; return }
728
+ if (!st.isDirectory()) return
729
+ let entries
730
+ try { entries = fs.readdirSync(p, { withFileTypes: true }) } catch { return }
731
+ for (const entry of entries) {
732
+ if (entry.isSymbolicLink()) continue
733
+ walk(path.join(p, entry.name))
734
+ }
735
+ }
736
+ for (const p of paths) if (typeof p === 'string' && path.isAbsolute(p)) walk(p)
737
+ let snapshots = 0
738
+ if (process.platform === 'darwin') {
739
+ try {
740
+ const out = spawnSync('tmutil', ['listlocalsnapshots', '/'], { encoding: 'utf8', timeout: 3000 })
741
+ snapshots = (String(out.stdout || '').match(/com\.apple\.TimeMachine/g) || []).length
742
+ } catch { /* ignore */ }
743
+ }
744
+ return { logicalBytes: logical, allocatedBytes: allocated, files, truncated, snapshots }
745
+ }
746
+
747
+ function withinHome(p) {
748
+ const home = path.resolve(process.env.HOME || os.homedir())
749
+ const resolved = path.resolve(p)
750
+ return resolved === home || resolved.startsWith(home + path.sep)
751
+ }
752
+
753
+ function runShell(cmd, args, timeoutMs = 120000, input = null) {
754
+ return new Promise((resolve, reject) => {
755
+ let child
756
+ try { child = spawn(cmd, args, { stdio: [input === null ? 'ignore' : 'pipe', 'pipe', 'pipe'], windowsHide: true }) }
757
+ catch (err) { reject(err); return }
758
+ if (input !== null) {
759
+ child.stdin.on('error', () => {}) // A decoder may fail before it reads the job.
760
+ child.stdin.end(input, 'utf8')
761
+ }
762
+ let out = ''
763
+ let err = ''
764
+ child.stdout.setEncoding('utf8')
765
+ child.stderr.setEncoding('utf8')
766
+ child.stdout.on('data', (c) => { out += c })
767
+ child.stderr.on('data', (c) => { err += c })
768
+ const timer = setTimeout(() => { try { child.kill() } catch { /* ignore */ } reject(new Error('命令超时')) }, timeoutMs)
769
+ child.on('error', (error) => { clearTimeout(timer); reject(error) })
770
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, err }) })
771
+ })
772
+ }
773
+
774
+ // 打开侧边栏即可显示的磁盘容量:直接 statfs 读取,毫秒级、只读,不触发扫描。
775
+ // 口径与扫描器 report.disk 一致(macOS 数据卷 / Windows 系统盘 / Linux 根分区)。
776
+ function bootMount() {
777
+ if (process.platform === 'darwin') return '/System/Volumes/Data'
778
+ if (process.platform === 'win32') return `${process.env.SystemDrive || 'C:'}\\`
779
+ return '/'
780
+ }
781
+
782
+ function readUsage(mount) {
783
+ const st = fs.statfsSync(mount)
784
+ const free = st.bavail * st.bsize
785
+ const used = (st.blocks - st.bfree) * st.bsize
786
+ return { total: used + free, used, free }
787
+ }
788
+
789
+ function diskSnapshot() {
790
+ const candidates = [bootMount()]
791
+ const home = os.homedir()
792
+ if (!candidates.includes(home)) candidates.push(home)
793
+ for (const mount of candidates) {
794
+ try {
795
+ return { ...readUsage(mount), mount, at: Date.now() }
796
+ } catch { /* 换下一个候选挂载点 */ }
797
+ }
798
+ return null
799
+ }
800
+
801
+ function listVolumes() {
802
+ const out = []
803
+ const add = (p) => {
804
+ try {
805
+ out.push({ path: p, label: path.basename(p) || p, ...readUsage(p) })
806
+ } catch { /* ignore */ }
807
+ }
808
+ if (process.platform === 'darwin') {
809
+ add('/')
810
+ try {
811
+ for (const name of fs.readdirSync('/Volumes')) {
812
+ if (name.startsWith('.')) continue
813
+ add(path.join('/Volumes', name))
814
+ }
815
+ } catch { /* ignore */ }
816
+ } else if (process.platform === 'win32') {
817
+ for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ') {
818
+ const p = `${letter}:\\`
819
+ if (fs.existsSync(p)) add(p)
820
+ }
821
+ } else {
822
+ add('/')
823
+ add(os.homedir())
824
+ }
825
+ const seen = new Set()
826
+ return out.filter((v) => (seen.has(v.path) ? false : (seen.add(v.path), true)))
827
+ }
828
+
829
+ const treeCache = new Map()
830
+ const TREE_TTL_MS = 60 * 1000
831
+ const TREE_CACHE_MAX = 500
832
+
833
+ // tree.py walks the subtree once and returns a `dirs` map of per-directory
834
+ // listings; seeding every level at once makes drill-down cache hits instead
835
+ // of re-walking the subtree on each click.
836
+ function seedTreeCache(target, data) {
837
+ const at = Date.now()
838
+ const dirs = data.dirs && typeof data.dirs === 'object' ? data.dirs : {}
839
+ delete data.dirs
840
+ treeCache.set(target, { at, data })
841
+ for (const [p, d] of Object.entries(dirs)) {
842
+ treeCache.set(path.resolve(p), {
843
+ at,
844
+ data: {
845
+ path: p,
846
+ children: Array.isArray(d.children) ? d.children : [],
847
+ at: data.at,
848
+ metric: data.metric,
849
+ unavailable: d.unavailable || 0,
850
+ skipped: d.skipped || 0,
851
+ bigFiles: Array.isArray(d.bigFiles) ? d.bigFiles : [],
852
+ },
853
+ })
854
+ }
855
+ while (treeCache.size > TREE_CACHE_MAX) treeCache.delete(treeCache.keys().next().value)
856
+ }
857
+
858
+ async function listChildren(dirPath, refresh = false) {
859
+ const home = process.env.HOME || os.homedir()
860
+ const target = dirPath ? path.resolve(dirPath) : path.resolve(home)
861
+ if (!withinHome(target)) throw new Error('只允许浏览个人文件夹')
862
+ const realHome = fs.realpathSync(process.env.HOME || os.homedir())
863
+ const relative = path.relative(realHome, fs.realpathSync(target))
864
+ if (fs.lstatSync(target).isSymbolicLink() || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new Error('符号链接或个人文件夹之外的路径不可浏览')
865
+ const cached = treeCache.get(target)
866
+ if (!refresh && cached && Date.now() - cached.at < TREE_TTL_MS) return cached.data
867
+ // The independent read-only helper includes hidden entries and reports partial totals.
868
+ // It has its own child handle so browsing never cancels an audit or photo scan.
869
+ const res = await runScanner([target], 120000, { trackChild() {}, env: { DEEPCLEAN_CLOUD_ROOTS: JSON.stringify(cloudRoots()) } }, 'tree.py')
870
+ if (res.code !== 0) throw new Error('目录统计失败或无权读取,请检查权限后重试')
871
+ const data = JSON.parse(res.out)
872
+ seedTreeCache(target, data)
873
+ return data
874
+ }
875
+
876
+ function sendJson(res, status, body) {
877
+ res.writeHead(status, {
878
+ 'Content-Type': 'application/json; charset=utf-8',
879
+ 'Cache-Control': 'no-store',
880
+ })
881
+ res.end(JSON.stringify(body))
882
+ }
883
+
884
+ function readBody(req, limit = 1024 * 1024) {
885
+ return new Promise((resolve, reject) => {
886
+ let size = 0
887
+ const chunks = []
888
+ req.on('data', (chunk) => {
889
+ size += chunk.length
890
+ if (size > limit) {
891
+ reject(new Error('请求体过大'))
892
+ req.destroy()
893
+ return
894
+ }
895
+ chunks.push(chunk)
896
+ })
897
+ req.on('end', () => {
898
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) }
899
+ catch (err) { reject(new Error(`JSON 解析失败:${err.message}`)) }
900
+ })
901
+ req.on('error', reject)
902
+ })
903
+ }
904
+
905
+ function humanBytes(n) {
906
+ const bytes = Math.max(0, Number(n) || 0)
907
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
908
+ if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
909
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
910
+ return `${Math.round(bytes)} B`
911
+ }
912
+
913
+ // ── disk_inspect:只读路径事实检查(模型工具) ──────────────────────────────
914
+ // 只做 lstat / readdir / 一次 Spotlight 查询:不跟随符号链接、不写入、不移动任何文件。
915
+ const INSPECT_BUDGET_MS = 1200
916
+ const INSPECT_MAX_ENTRIES = 20000
917
+
918
+ function inspectTree(abs) {
919
+ const out = { sizeBytes: 0, allocatedBytes: 0, files: 0, dirs: 0, truncated: false, top: [] }
920
+ const deadline = Date.now() + INSPECT_BUDGET_MS
921
+ const stack = [abs]
922
+ let visited = 0
923
+ while (stack.length > 0) {
924
+ if (Date.now() > deadline || visited > INSPECT_MAX_ENTRIES) { out.truncated = true; break }
925
+ const dir = stack.pop()
926
+ let entries
927
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { continue }
928
+ for (const entry of entries) {
929
+ visited += 1
930
+ const child = path.join(dir, entry.name)
931
+ let st
932
+ try { st = fs.lstatSync(child) } catch { continue }
933
+ if (st.isSymbolicLink()) continue
934
+ if (st.isFile()) {
935
+ out.sizeBytes += st.size
936
+ out.allocatedBytes += (st.blocks || 0) * 512
937
+ out.files += 1
938
+ if (dir === abs) out.top.push({ name: entry.name, type: 'file', bytes: st.size })
939
+ } else if (st.isDirectory()) {
940
+ out.dirs += 1
941
+ if (dir === abs) out.top.push({ name: entry.name, type: 'dir', bytes: 0 })
942
+ stack.push(child)
943
+ }
944
+ if (visited > INSPECT_MAX_ENTRIES) { out.truncated = true; break }
945
+ }
946
+ }
947
+ out.top.sort((a, b) => (a.type === b.type ? b.bytes - a.bytes : a.type === 'file' ? -1 : 1))
948
+ out.top = out.top.slice(0, 8)
949
+ return out
950
+ }
951
+
952
+ function appInstallState(bundleId) {
953
+ if (!bundleId || process.platform !== 'darwin') return 'unknown'
954
+ try {
955
+ const out = spawnSync('mdfind', [`kMDItemCFBundleIdentifier == "${bundleId}"`], { encoding: 'utf8', timeout: 2500, windowsHide: true })
956
+ if (out.error || out.status !== 0) return 'unknown'
957
+ return String(out.stdout || '').trim() === '' ? 'not-found' : 'installed'
958
+ } catch {
959
+ return 'unknown'
960
+ }
961
+ }
962
+
963
+ /** 在最近一次报告里找该路径所属的条目(只读事实,不参与放行判断)。
964
+ * requireFresh=false 只用于「路径是否被报告覆盖」的准入判断,不用于允许集合。 */
965
+ function reportMatchFor(abs, report = lastReport, options = {}) {
966
+ const requireFresh = !options || options.requireFresh !== false
967
+ if (!report) return null
968
+ if (requireFresh && Date.now() - lastReportAt > REPORT_TTL_MS) return null
969
+ const inside = (p) => typeof p === 'string' && (p === abs || abs.startsWith(p + path.sep))
970
+ for (const g of report.leftoverGroups || []) {
971
+ if ((g.paths || []).some(inside)) return { category: 'leftover', name: g.name || g.baseId, bucket: g.bucket, kb: g.kb, why: g.why }
972
+ }
973
+ for (const a of report.appCaches || []) {
974
+ if ((a.paths || []).some(inside)) return { category: 'app', name: a.app || a.baseId, bucket: a.cleanable === false || a.running ? 'confirm' : 'safe', kb: a.kb }
975
+ }
976
+ for (const c of report.caches || []) {
977
+ if (inside(c.path)) return { category: c.system ? 'system' : 'other', name: path.basename(c.path), bucket: c.system ? 'system' : 'cache', kb: c.kb }
978
+ }
979
+ for (const d of report.devCaches || []) {
980
+ if (inside(d.path)) return { category: 'dev', name: path.basename(d.path), bucket: d.cleanable === false ? 'confirm' : 'safe', kb: d.kb, why: d.advice }
981
+ }
982
+ for (const b of report.browsers || []) {
983
+ if (inside(b.path)) return { category: 'browser', name: b.name, bucket: b.running ? 'confirm' : 'safe', kb: b.kb }
984
+ }
985
+ for (const app of report.special || []) {
986
+ for (const c of app.categories || []) {
987
+ if ((c.paths || []).some(inside)) return { category: 'special', name: `${app.app} ${c.name}`, bucket: c.cleanable ? 'safe' : 'confirm', kb: c.kb }
988
+ }
989
+ }
990
+ for (const f of report.bigFiles || []) {
991
+ if (f.path === abs) return { category: 'bigfile', name: path.basename(abs), bucket: 'info', kb: f.kb }
992
+ }
993
+ return null
994
+ }
995
+
996
+ // 不可信文本净化(宿主侧副本):工具输出会回到模型上下文,文件名/目录名同样可能带换行或指令。
997
+ function untrustedTextHost(value, maxLen = 120) {
998
+ let s = typeof value === 'string' ? value : value == null ? '' : String(value)
999
+ try { s = s.normalize('NFKC') } catch { /* ignore */ }
1000
+ s = s.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u200e\u200f\u200b-\u200d\u2060\ufeff]/g, ' ')
1001
+ s = s.replace(/\s+/g, ' ').trim()
1002
+ if (maxLen > 0 && s.length > maxLen) s = `${s.slice(0, maxLen - 1)}…`
1003
+ return s
1004
+ }
1005
+
1006
+ // 隐私默认:工具输出也不回传完整绝对路径;保留 ~ + 顶层目录 + basename。
1007
+ function safeDisplayPathHost(p) {
1008
+ const home = path.resolve(process.env.HOME || os.homedir()).replace(/\\/g, '/')
1009
+ const raw = String(p || '').replace(/\\/g, '/')
1010
+ if (!raw) return ''
1011
+ const name = untrustedTextHost(path.posix.basename(raw), 100)
1012
+ const insideHome = raw === home || raw.startsWith(home + '/')
1013
+ const rest = (insideHome ? raw.slice(home.length) : raw).replace(/^\/+/, '')
1014
+ const parts = rest.split('/').filter(Boolean)
1015
+ const top = parts.length > 1 && parts[0] !== name ? untrustedTextHost(parts[0], 40) : ''
1016
+ if (insideHome) return top ? `~/${top}/…/${name}` : `~/…/${name}`
1017
+ return top ? `${top}/…/${name}` : `…/${name}`
1018
+ }
1019
+
1020
+ // ── disk_audit 输出净化:剥掉「建议命令」节,给模型可检查的 itemId ─────────────
1021
+ // 模型只做只读解释;扫描器生成的 mv / Move-Item 命令不进入模型上下文,避免它直接照抄执行。
1022
+
1023
+ const SUGGESTION_HEADING = /^## 十、建议命令.*$/m
1024
+ const AUDIT_HINTS_MAX = 30
1025
+ const AUDIT_BOUNDARY = '> 本报告由 disk_audit 生成,供解释使用;清理与恢复仍由用户在 DeepClean 面板体检并确认。报告覆盖可清理候选,不是整盘占用分布。'
1026
+ const AUDIT_HINTS_HEADER = '可检查条目 id(传给 disk_inspect 的 itemId;仅对本次报告有效):'
1027
+
1028
+ /** 去掉扫描器「十、建议命令」一节(到文末)并去掉尾部空白。 */
1029
+ export function sanitizeAuditMarkdown(markdown) {
1030
+ const text = typeof markdown === 'string' ? markdown : ''
1031
+ const match = SUGGESTION_HEADING.exec(text)
1032
+ if (!match) return text.trimEnd()
1033
+ return text.slice(0, match.index).trimEnd()
1034
+ }
1035
+
1036
+ /** 从报告推导可检查的 itemId 列表;编号规则必须与 inspectTargets() 一致。 */
1037
+ export function auditHints(report) {
1038
+ const lines = []
1039
+ if (!report) return lines
1040
+ const push = (id, name) => {
1041
+ if (lines.length >= AUDIT_HINTS_MAX) return
1042
+ const label = untrustedTextHost(name, 80) || '(未命名)'
1043
+ lines.push(`- ${id} · ${label}`)
1044
+ }
1045
+ const base = (p) => path.basename(String(p || '').replace(/\\/g, '/'))
1046
+ ;(report.leftoverGroups || []).forEach((g, i) => push(`leftover:${i}`, g.name || g.baseId))
1047
+ ;(report.appCaches || []).forEach((a, i) => push(`appCaches:${i}`, a.app || a.baseId))
1048
+ ;(report.devCaches || []).forEach((d, i) => push(`devCaches:${i}`, base(d.path)))
1049
+ ;(report.browsers || []).forEach((b, i) => push(`browsers:${i}`, b.name))
1050
+ ;(report.special || []).forEach((app, ai) => (app.categories || []).forEach((c, ci) => push(`special:${ai}:${ci}`, `${app.app || ''} ${c.name || ''}`)))
1051
+ ;(report.caches || []).forEach((c, i) => push(`caches:${i}`, base(c.path)))
1052
+ ;(report.bigFiles || []).forEach((f, i) => push(`bigFiles:${i}`, base(f.path)))
1053
+ return lines
1054
+ }
1055
+
1056
+ /** disk_audit 的最终输出:边界说明 + itemId 清单(放正文前,避免被截断)+ 无命令节报告。 */
1057
+ function renderAuditOutput(markdown, report) {
1058
+ const parts = [AUDIT_BOUNDARY, '']
1059
+ const hints = auditHints(report)
1060
+ if (hints.length > 0) parts.push(AUDIT_HINTS_HEADER, ...hints, '')
1061
+ const body = sanitizeAuditMarkdown(markdown)
1062
+ if (body) parts.push(body)
1063
+ return clip(parts.join('\n'))
1064
+ }
1065
+
1066
+ // 默认 --redact 时扫描器把 HOME 换成 "~"(JSON 也一样);只读检查要还原成真实路径,
1067
+ // 否则 itemId 会被解析到进程工作目录。脱敏只影响模型看到的输出,磁盘路径必须还原。
1068
+ function expandReportPath(p) {
1069
+ const s = typeof p === 'string' ? p : ''
1070
+ if (s === '~') return os.homedir()
1071
+ if (s.startsWith('~/') || s.startsWith('~\\')) return path.join(os.homedir(), s.slice(2))
1072
+ return s
1073
+ }
1074
+
1075
+ /** 把脱敏过的报告路径还原成绝对路径(不修改原始 JSON,返回拷贝)。 */
1076
+ export function normalizeToolReport(report) {
1077
+ if (!report || typeof report !== 'object') return null
1078
+ const fix = (list) => (Array.isArray(list) ? list : []).map((p) => expandReportPath(p))
1079
+ return {
1080
+ ...report,
1081
+ home: expandReportPath(report.home),
1082
+ leftoverGroups: (report.leftoverGroups || []).map((g) => ({ ...g, paths: fix(g.paths) })),
1083
+ appCaches: (report.appCaches || []).map((a) => ({ ...a, paths: fix(a.paths) })),
1084
+ devCaches: (report.devCaches || []).map((d) => ({ ...d, path: expandReportPath(d.path) })),
1085
+ browsers: (report.browsers || []).map((b) => ({ ...b, path: expandReportPath(b.path) })),
1086
+ caches: (report.caches || []).map((c) => ({ ...c, path: expandReportPath(c.path) })),
1087
+ special: (report.special || []).map((app) => ({ ...app, categories: (app.categories || []).map((c) => ({ ...c, paths: fix(c.paths) })) })),
1088
+ bigFiles: (report.bigFiles || []).map((f) => ({ ...f, path: expandReportPath(f.path) })),
1089
+ cloudStorage: (report.cloudStorage || []).map((c) => ({ ...c, path: expandReportPath(c.path) })),
1090
+ }
1091
+ }
1092
+
1093
+ /** 把面板 buildRows 的 inspectKey 映射回真实路径。编号规则必须与 lib/client.js 的 buildRows 一致。 */
1094
+ function inspectTargets(report) {
1095
+ const map = new Map()
1096
+ if (!report) return map
1097
+ const put = (key, p) => { if (typeof p === 'string' && p) map.set(key, p) }
1098
+ ;(report.leftoverGroups || []).forEach((g, i) => put(`leftover:${i}`, (g.paths || [])[0]))
1099
+ ;(report.appCaches || []).forEach((a, i) => put(`appCaches:${i}`, (a.paths || [])[0]))
1100
+ ;(report.devCaches || []).forEach((d, i) => put(`devCaches:${i}`, d.path))
1101
+ ;(report.browsers || []).forEach((b, i) => put(`browsers:${i}`, b.path))
1102
+ ;(report.special || []).forEach((app, ai) => (app.categories || []).forEach((c, ci) => put(`special:${ai}:${ci}`, (c.paths || [])[0])))
1103
+ ;(report.caches || []).forEach((c, i) => put(`caches:${i}`, c.path))
1104
+ ;(report.bigFiles || []).forEach((f, i) => put(`bigFiles:${i}`, f.path))
1105
+ return map
1106
+ }
1107
+
1108
+ /** 只读检查的数据来源:优先时间较新的 toolReport(disk_audit)或面板 lastReport。 */
1109
+ function currentInspectSource() {
1110
+ if (lastToolReport && lastToolReportAt > lastReportAt) return { report: lastToolReport, at: lastToolReportAt, source: 'tool' }
1111
+ if (lastReport) return { report: lastReport, at: lastReportAt, source: 'panel' }
1112
+ return null
1113
+ }
1114
+
1115
+ /** itemId 命中的条目是否包含多个路径(组类条目只检查第一个路径)。 */
1116
+ function inspectItemMeta(report, itemId) {
1117
+ if (!report || typeof itemId !== 'string') return null
1118
+ const parts = itemId.split(':')
1119
+ const idx = (value) => {
1120
+ const n = Number(value)
1121
+ return Number.isInteger(n) && n >= 0 ? n : -1
1122
+ }
1123
+ const keys = { leftover: 'leftoverGroups', appCaches: 'appCaches', browsers: 'browsers', devCaches: 'devCaches', caches: 'caches', bigFiles: 'bigFiles' }
1124
+ if (keys[parts[0]]) {
1125
+ const item = (report[keys[parts[0]]] || [])[idx(parts[1])]
1126
+ if (!item) return null
1127
+ const paths = Array.isArray(item.paths) ? item.paths : item.path ? [item.path] : []
1128
+ return { paths, multiPath: paths.length > 1 }
1129
+ }
1130
+ if (parts[0] === 'special') {
1131
+ const app = (report.special || [])[idx(parts[1])]
1132
+ const category = app && (app.categories || [])[idx(parts[2])]
1133
+ if (!category) return null
1134
+ const paths = Array.isArray(category.paths) ? category.paths : []
1135
+ return { paths, multiPath: paths.length > 1 }
1136
+ }
1137
+ return null
1138
+ }
1139
+
1140
+ /**
1141
+ * 只读检查输出的「是否在清理允许集合内」:只有**新鲜的面板报告**可以判定;
1142
+ * toolReport(disk_audit 的临时报告)只用于定位条目,永远不参与放行。
1143
+ */
1144
+ export function inspectAllowance({ source, stale, report, abs } = {}) {
1145
+ if (source !== 'panel' || stale || !report || !abs) return false
1146
+ return allowedPaths(report).has(abs)
1147
+ }
1148
+
1149
+ export function inspectPath(target, report) {
1150
+ const explicit = report !== undefined && report !== null
1151
+ const src = explicit ? { report, at: Date.now(), source: 'panel' } : (currentInspectSource() || { report: null, at: 0, source: 'panel' })
1152
+ const stale = !!src.at && Date.now() - src.at > REPORT_TTL_MS
1153
+ const result = { path: '', display: '', exists: false, type: 'unknown', error: '', source: src.source, reportAt: src.at, stale }
1154
+ const obj = target && typeof target === 'object' ? target : { path: target }
1155
+ const itemId = typeof obj.itemId === 'string' ? obj.itemId.trim() : ''
1156
+ const rawPath = typeof obj.path === 'string' ? obj.path.trim() : ''
1157
+ let abs = ''
1158
+ if (itemId) {
1159
+ const resolved = inspectTargets(src.report).get(itemId)
1160
+ if (!resolved) return { ...result, error: '未找到该条目:报告可能已过期或该项已被清理(请重新体检后再试)' }
1161
+ abs = path.resolve(resolved)
1162
+ result.itemId = itemId
1163
+ const meta = inspectItemMeta(src.report, itemId)
1164
+ if (meta && meta.multiPath) result.multiPath = true
1165
+ } else if (rawPath) {
1166
+ abs = path.resolve(expandReportPath(rawPath))
1167
+ // fail-closed:只允许检查最近一次体检报告覆盖的路径,避免注入后拿只读工具探测任意目录。
1168
+ if (!reportMatchFor(abs, src.report, { requireFresh: false })) {
1169
+ return { ...result, error: '不在最近一次体检报告范围内(本工具只检查报告覆盖的条目;请先体检,或传入面板提供的 itemId)' }
1170
+ }
1171
+ } else {
1172
+ return { ...result, error: '需要 itemId 或绝对路径' }
1173
+ }
1174
+ result.path = abs
1175
+ result.display = safeDisplayPathHost(abs)
1176
+ let st
1177
+ try {
1178
+ st = fs.lstatSync(abs)
1179
+ } catch (err) {
1180
+ result.error = err && err.code === 'ENOENT' ? '路径不存在(可能已被清理或从未存在)' : `无法读取:${err && err.code ? err.code : String(err)}`
1181
+ return result
1182
+ }
1183
+ result.exists = true
1184
+ result.type = st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'other'
1185
+ result.mtime = st.mtimeMs
1186
+ result.atime = st.atimeMs
1187
+ result.sizeBytes = st.size
1188
+ if (result.type === 'dir') Object.assign(result, inspectTree(abs))
1189
+ else if (result.type === 'file') {
1190
+ result.files = 1
1191
+ result.allocatedBytes = (st.blocks || 0) * 512
1192
+ }
1193
+ result.report = reportMatchFor(abs, src.report, { requireFresh: false })
1194
+ result.allowed = inspectAllowance({ source: src.source, stale, report: src.report, abs })
1195
+ const rawBundleId = (/[\\/]Library[\\/](?:Group )?Containers[\\/]([^\\/]+)/.exec(abs) || [])[1]
1196
+ const bundleId = untrustedTextHost(rawBundleId || '', 128)
1197
+ if (bundleId && /^[A-Za-z0-9._-]{1,128}$/.test(bundleId)) {
1198
+ result.bundleId = bundleId
1199
+ result.installed = appInstallState(bundleId)
1200
+ }
1201
+ const appRoot = (/^(\/Applications\/[^/]+\.app)(?:\/|$)/.exec(abs) || [])[1]
1202
+ if (appRoot) result.appBundle = { display: safeDisplayPathHost(appRoot), exists: fs.existsSync(appRoot) }
1203
+ return result
1204
+ }
1205
+
1206
+ export function renderInspect(info) {
1207
+ const L = []
1208
+ L.push(`# 只读路径检查:\`${info.display || '(空)'}\``)
1209
+ L.push('')
1210
+ if (!info.exists) {
1211
+ L.push(`- 结果:${info.error || '不存在或不可读'}`)
1212
+ L.push('')
1213
+ L.push('说明:本工具只读,未修改、未移动任何文件。')
1214
+ return L.join('\n')
1215
+ }
1216
+ if (info.itemId) L.push(`- 条目 id:${untrustedTextHost(info.itemId, 80)}`)
1217
+ L.push(`- 类型:${{ dir: '目录', file: '文件', symlink: '符号链接(未跟随)', other: '其他' }[info.type] || info.type}`)
1218
+ if (info.type === 'dir' || info.type === 'file') {
1219
+ L.push(`- 占用(逻辑):${humanBytes(info.sizeBytes)};按分配块:${humanBytes(info.allocatedBytes)}`)
1220
+ if (info.type === 'dir') {
1221
+ L.push(`- 文件:${info.files} 个;子目录:${info.dirs} 个;统计${info.truncated ? '**不完整**(达到只读时间/条目预算)' : '完整'}`)
1222
+ if (info.top && info.top.length > 0) {
1223
+ L.push(`- 顶层子项(文件按大小):${info.top.map((t) => (t.type === 'dir' ? `${untrustedTextHost(t.name, 60)}/` : `${untrustedTextHost(t.name, 60)} ${humanBytes(t.bytes)}`)).join(';')}`)
1224
+ }
1225
+ } else {
1226
+ L.push(`- 文件数:1;修改时间:${new Date(info.mtime).toLocaleString()}`)
1227
+ }
1228
+ }
1229
+ if (info.type === 'dir') L.push(`- 修改时间:${new Date(info.mtime).toLocaleString()}`)
1230
+ if (info.report) {
1231
+ const r = info.report
1232
+ const fromTool = info.source === 'tool'
1233
+ L.push(`- 报告来源:${fromTool ? '工具体检(不参与清理放行)' : '面板体检'}`)
1234
+ L.push(`- 报告时间:${info.reportAt ? new Date(info.reportAt).toLocaleString() : '未知'};状态:${info.stale ? '已过期(清理前需重新体检)' : '新鲜'}`)
1235
+ if (info.multiPath) L.push('- 注意:该条目包含多个路径,只检查了第一个')
1236
+ L.push(`- 报告归类(${fromTool ? 'disk_audit 工具报告' : '面板报告'}):${r.category} · 分档 ${r.bucket} · ${humanBytes((r.kb || 0) * 1024)}${r.name ? ` · 名称「${untrustedTextHost(r.name, 120)}」` : ''}`)
1237
+ L.push(`- 该路径是否在清理允许集合内(程序判定,不是清理建议):${info.allowed ? '是' : '否'}`)
1238
+ if (r.why) L.push(`- 扫描器说明:${untrustedTextHost(r.why, 240)}`)
1239
+ } else {
1240
+ L.push('- 扫描器归类:最近一次体检报告未覆盖该路径')
1241
+ }
1242
+ if (info.bundleId) {
1243
+ const state = info.installed === 'installed' ? '已安装' : info.installed === 'not-found' ? '未找到(Spotlight 查询不到)' : '无法确认'
1244
+ L.push(`- Bundle ID:${untrustedTextHost(info.bundleId, 120)};对应 App:${state}`)
1245
+ }
1246
+ if (info.appBundle) L.push(`- App 包:${info.appBundle.display || '(已脱敏)'}(${info.appBundle.exists ? '存在' : '不存在'})`)
1247
+ L.push('')
1248
+ L.push('说明:本工具只读,未修改、未移动任何文件;分档与报告大小来自报告快照,文件数与当前大小来自本次只读统计;输出中的路径已脱敏,文件名已单行化。')
1249
+ return L.join('\n')
1250
+ }
1251
+
1252
+ // ── disk_report:只读取面板最近一次报告做摘要(不触发扫描、不写文件) ───────
1253
+ const RISK_TIERS = ['recommended', 'optional', 'review', 'protected']
1254
+ const RISK_LABEL = { recommended: '推荐清理', optional: '可选清理', review: '需确认', protected: '受保护' }
1255
+
1256
+ function normalizedReportPath(value) {
1257
+ return String(value || '').replace(/\\/g, '/').replace(/\/+$/, '')
1258
+ }
1259
+
1260
+ function insideReportPath(p, parent) {
1261
+ const a0 = normalizedReportPath(p)
1262
+ const b0 = normalizedReportPath(parent)
1263
+ if (!a0 || !b0) return false
1264
+ const win = /^[a-z]:/i.test(a0) || /^[a-z]:/i.test(b0)
1265
+ const a = win ? a0.toLowerCase() : a0
1266
+ const b = win ? b0.toLowerCase() : b0
1267
+ return a === b || a.startsWith(b + '/')
1268
+ }
1269
+
1270
+ /** 摘要里的四档计数:判定规则与面板 buildRows 的 rowRisk 一致(受保护 > 需确认 > 推荐 > 可选)。 */
1271
+ function reportRiskRows(report) {
1272
+ const whitelist = loadWhitelist()
1273
+ const rows = []
1274
+ if (!report) return rows
1275
+ const running = [...(report.caches || []), ...(report.devCaches || []), ...(report.browsers || []), ...(report.appCaches || [])]
1276
+ .filter((item) => item && item.running)
1277
+ .flatMap((item) => item.paths || [item.path])
1278
+ .filter(Boolean)
1279
+ const add = (row) => {
1280
+ row.paths = [...new Set((row.paths || []).filter(Boolean))]
1281
+ if (row.checkable && row.paths.some((p) => running.some((base) => insideReportPath(p, base) || insideReportPath(base, p)))) {
1282
+ row.checkable = false
1283
+ row.recommended = false
1284
+ }
1285
+ row.protected = row.paths.some((p) => whitelist.some((w) => insideReportPath(p, w)))
1286
+ row.checkable = !!row.checkable && !row.protected && row.paths.length > 0
1287
+ row.tier = row.protected ? 'protected' : !row.checkable ? 'review' : row.recommended ? 'recommended' : 'optional'
1288
+ rows.push(row)
1289
+ }
1290
+ const label = (p) => path.basename(normalizedReportPath(p))
1291
+ ;(report.leftoverGroups || []).forEach((g, i) => add({ itemId: `leftover:${i}`, name: g.name || g.baseId, paths: g.paths, kb: g.kb, checkable: g.bucket === 'safe', recommended: g.bucket === 'safe' }))
1292
+ ;(report.appCaches || []).forEach((a, i) => add({ itemId: `appCaches:${i}`, name: a.app || a.baseId, paths: a.paths, kb: a.kb, checkable: a.cleanable !== false && !a.running, recommended: a.cleanable !== false && !a.running }))
1293
+ ;(report.devCaches || []).forEach((d, i) => add({ itemId: `devCaches:${i}`, name: label(d.path), paths: [d.path], kb: d.kb, checkable: d.cleanable !== false, recommended: d.cleanable !== false }))
1294
+ ;(report.browsers || []).forEach((b, i) => add({ itemId: `browsers:${i}`, name: b.name, paths: [b.path], kb: b.kb, checkable: !b.running, recommended: !b.running }))
1295
+ ;(report.special || []).forEach((app, ai) => (app.categories || []).forEach((c, ci) => add({ itemId: `special:${ai}:${ci}`, name: `${app.app || ''} ${c.name || ''}`, paths: c.paths, kb: c.kb, checkable: !!c.cleanable, recommended: !!c.cleanable })))
1296
+ const specific = rows.flatMap((r) => r.paths)
1297
+ ;(report.caches || []).forEach((c, i) => {
1298
+ if (specific.some((p) => insideReportPath(c.path, p) || insideReportPath(p, c.path))) return
1299
+ const locked = !!c.system || !!c.admin || c.cleanable === false || !!c.running
1300
+ add({ itemId: `caches:${i}`, name: label(c.path), paths: [c.path], kb: c.kb, checkable: !locked, recommended: false })
1301
+ })
1302
+ return rows
1303
+ }
1304
+
1305
+ function tierLine(rows, tier) {
1306
+ const list = rows.filter((r) => r.tier === tier)
1307
+ const totalKb = list.reduce((sum, r) => sum + (Number(r.kb) || 0), 0)
1308
+ return `- ${RISK_LABEL[tier]}(${tier}):${list.length} 项,合计 ${humanBytes(totalKb * 1024)}`
1309
+ }
1310
+
1311
+ function tierTop(rows, tier, limit = 5) {
1312
+ return rows
1313
+ .filter((r) => r.tier === tier)
1314
+ .sort((a, b) => (Number(b.kb) || 0) - (Number(a.kb) || 0))
1315
+ .slice(0, limit)
1316
+ .map((r) => `- ${untrustedTextHost(r.name, 80) || '(未命名)'} · ${humanBytes((Number(r.kb) || 0) * 1024)} · ${r.itemId}`)
1317
+ }
1318
+
1319
+ export function renderReportSummary(report, at) {
1320
+ const L = []
1321
+ const now = Date.now()
1322
+ const stamp = Number.isFinite(at) && at > 0 ? at : now
1323
+ const ageMs = Math.max(0, now - stamp)
1324
+ const ageMin = Math.floor(ageMs / 60000)
1325
+ const stale = ageMs > REPORT_TTL_MS
1326
+ const windows = report && report.platform === 'windows'
1327
+ L.push('# DeepClean 面板报告摘要(只读)')
1328
+ L.push('')
1329
+ L.push(`- 生成时间:${untrustedTextHost(report && report.generatedAt, 60) || '未知'}`)
1330
+ L.push(`- 报告年龄:${ageMin < 1 ? '刚生成' : `${ageMin} 分钟前`};状态:${stale ? '已过期(超过 10 分钟,清理前需重新体检)' : '新鲜'}`)
1331
+ L.push(`- 平台:${windows ? 'Windows' : 'macOS'}`)
1332
+ if (report && report.fda) {
1333
+ const status = report.fda.status
1334
+ const fdaText = { granted: '已授权', denied: '未授权(App 容器会显示为空壳)', unknown: '无法确认' }[status] || untrustedTextHost(status, 40)
1335
+ L.push(`- 完全磁盘访问(FDA):${fdaText}(${untrustedTextHost(report.fda.detail, 120)})`)
1336
+ } else {
1337
+ const access = (report && report.access) || {}
1338
+ L.push(`- 权限:${untrustedTextHost(access.detail, 120) || (access.admin ? '管理员' : '普通用户')}`)
1339
+ }
1340
+ const disk = (report && report.disk) || {}
1341
+ L.push(`- 磁盘:共 ${humanBytes(disk.total)},已用 ${humanBytes(disk.used)},可用 ${humanBytes(disk.free)}`)
1342
+ const trashKb = report && Number.isFinite(report.trashKb) ? report.trashKb : null
1343
+ L.push(`- ${windows ? '隔离区' : '废纸篓'}占用:${trashKb === null ? '未统计' : humanBytes(trashKb * 1024)}`)
1344
+ const est = (report && report.estimate) || {}
1345
+ L.push(`- 预计可释放:${humanBytes((Number(est.totalKb) || 0) * 1024)}(推荐残留 ${humanBytes((Number(est.safeKb) || 0) * 1024)} / 缓存 ${humanBytes((Number(est.cacheKb) || 0) * 1024)} / 开发缓存 ${humanBytes((Number(est.devKb) || 0) * 1024)})`)
1346
+ if (Number(est.confirmKb)) L.push(`- 需确认项合计:${humanBytes(Number(est.confirmKb) * 1024)}(不计入预计可释放,永不自动勾选)`)
1347
+ L.push('')
1348
+ const rows = reportRiskRows(report)
1349
+ L.push('## 四档条目(与面板同一判定规则)')
1350
+ L.push('')
1351
+ for (const tier of RISK_TIERS) L.push(tierLine(rows, tier))
1352
+ for (const tier of RISK_TIERS) {
1353
+ L.push('')
1354
+ L.push(`### ${RISK_LABEL[tier]}(${tier}) Top 5`)
1355
+ L.push('')
1356
+ const top = tierTop(rows, tier)
1357
+ if (top.length === 0) L.push('(无)')
1358
+ else L.push(...top)
1359
+ }
1360
+ L.push('')
1361
+ L.push('### 大文件 Top 5')
1362
+ L.push('')
1363
+ const big = (report && Array.isArray(report.bigFiles) ? report.bigFiles : []).slice(0, 5)
1364
+ if (big.length === 0) {
1365
+ L.push('(无:本次可能是快速扫描,未扫描大文件;需要大文件清单请用 disk_audit 的 quick=false)')
1366
+ } else {
1367
+ big.forEach((f, i) => {
1368
+ const mtime = Number(f.mtime)
1369
+ const days = Number.isFinite(mtime) && mtime > 0 ? `${Math.floor((now - mtime) / 86400000)} 天未修改` : '修改时间未知'
1370
+ L.push(`- ${untrustedTextHost(path.basename(normalizedReportPath(f.path)), 80) || '(未命名)'} · ${humanBytes((Number(f.kb) || 0) * 1024)} · ${days} · bigFiles:${i}`)
1371
+ })
1372
+ }
1373
+ L.push('')
1374
+ L.push('## 范围与数据边界')
1375
+ L.push('')
1376
+ if (report && report.scope) L.push(`- 扫描范围:${untrustedTextHost(report.scope, 240)}`)
1377
+ else L.push('- 扫描范围:`~/Library` 的缓存与容器、开发缓存、云盘目录、废纸篓与大文件;不含 `/private/var`、`/Library` 与其他用户目录。')
1378
+ L.push('- 本摘要只读取面板最近一次体检的内存报告:不触发新扫描、不写任何文件、不改变清理分档或允许集合。')
1379
+ L.push('- 报告覆盖可清理候选与部分大文件,不是整盘占用分布;分档与大小来自报告快照,条目的 itemId 只对生成它的这份报告有效。')
1380
+ L.push('- 清理由用户在 DeepClean 面板确认;模型只解释,不清理、不恢复、不给可执行命令。')
1381
+ return L.join('\n')
1382
+ }
1383
+
1384
+ export function apply(ctx) {
1385
+ ctx.tools.register(defineTool({
1386
+ name: 'disk_check_permissions',
1387
+ description: 'Check whether the host process can read every protected disk path: Full Disk Access on macOS, administrator status on Windows. Never scans. Call it before disk_audit when a scan reports missing items or the user asks why data is absent.',
1388
+ parameters: {},
1389
+ output,
1390
+ async execute() {
1391
+ return formatResult(await runScanner(['--check-permissions'], 30000))
1392
+ },
1393
+ }))
1394
+
1395
+ ctx.tools.register(defineTool({
1396
+ name: 'disk_audit',
1397
+ description: 'Scan disk usage on macOS or Windows (Linux is unverified) and return a Markdown report with cleanup tiers (recommended / optional / needs review / protected) and an estimated reclaimable total. Use this instead of du or df when the user asks what takes space or what is safe to clean up. Read-only: never deletes, moves, or modifies user files. Each call starts a new scan: quick mode (default) takes about 30–60 seconds and skips the full-disk large-file scan; pass quick=false to include files over 300 MB (up to 5 minutes, may time out). On macOS, a missing Full Disk Access aborts with authorization steps. The report covers cleanup candidates only, not a full-disk usage breakdown. Cleanup and restore always require the user\'s confirmation in the DeepClean panel.',
1398
+ parameters: {
1399
+ quick: { type: 'boolean', description: 'Skip the full-disk large-file scan (default true). Set false when the user asks about large files; a full scan can take several minutes.' },
1400
+ redact: { type: 'boolean', description: 'Replace the home directory with ~ and mask the hostname (default true; only disable when the user asks for diagnostics).' },
1401
+ platform: { type: 'string', enum: ['auto', 'macos', 'windows'], description: 'Target platform; auto detects the host (default auto).' },
1402
+ },
1403
+ output,
1404
+ async execute(args) {
1405
+ const opts = args && typeof args === 'object' ? args : {}
1406
+ const argv = []
1407
+ if (opts.platform && opts.platform !== 'auto') argv.push('--platform', String(opts.platform))
1408
+ if (opts.quick !== false) argv.push('--quick')
1409
+ if (opts.redact !== false) argv.push('--redact')
1410
+ let jsonDir = null
1411
+ try {
1412
+ // 内部始终用 --json 临时文件读结构化报告;不再暴露 json 参数(完整报告会超过输出上限)。
1413
+ jsonDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepclean-'))
1414
+ const reportFile = path.join(jsonDir, 'report.json')
1415
+ argv.push('--json', reportFile)
1416
+ const result = await runScanner(argv, 300000)
1417
+ if (result.code !== 0) return `扫描未完成(exit ${result.code})\n\n${formatResult(result)}`
1418
+ const text = (result.out || '').trim() || formatResult(result)
1419
+ let report = null
1420
+ try {
1421
+ if (fs.existsSync(reportFile)) report = normalizeToolReport(JSON.parse(fs.readFileSync(reportFile, 'utf8')))
1422
+ } catch {
1423
+ report = null
1424
+ }
1425
+ // 只写 toolReport(给 disk_inspect 定位条目),绝不写面板的 lastReport。
1426
+ if (report) {
1427
+ lastToolReport = report
1428
+ lastToolReportAt = Date.now()
1429
+ }
1430
+ return renderAuditOutput(text, report)
1431
+ } finally {
1432
+ if (jsonDir) {
1433
+ try { fs.rmSync(jsonDir, { recursive: true, force: true }) } catch { /* ignore */ }
1434
+ }
1435
+ }
1436
+ },
1437
+ }))
1438
+
1439
+ ctx.tools.register(defineTool({
1440
+ name: 'disk_inspect',
1441
+ description: 'Read one item from the latest DeepClean report: type, size, file count, modification time, risk tier, whether the app is installed, and whether the program considers it cleanable. Pass itemId from a panel prompt or a disk_audit hint (for example leftover:0 or bigFiles:2), or an absolute path covered by the report; paths outside the report are rejected. An itemId is valid only for the report that produced it and may point to another item after a rescan or cleanup. Reports older than 10 minutes are accepted but marked stale, and their tiers may no longer match the cleanup allow-set. For group items only the first path of the group is inspected. If no report exists, ask the user to run a checkup or call disk_audit first. Read-only.',
1442
+ parameters: {
1443
+ itemId: { type: 'string', description: 'Item id from a panel prompt, disk_audit or disk_report (for example leftover:0 or bigFiles:2); takes precedence over path.' },
1444
+ path: { type: 'string', description: 'Absolute path to inspect (only paths covered by the latest report; use either itemId or path).' },
1445
+ },
1446
+ output,
1447
+ async execute(args) {
1448
+ const target = { path: args && typeof args.path === 'string' ? args.path : '', itemId: args && typeof args.itemId === 'string' ? args.itemId : '' }
1449
+ return clip(renderInspect(inspectPath(target)))
1450
+ },
1451
+ }))
1452
+
1453
+ ctx.tools.register(defineTool({
1454
+ name: 'disk_report',
1455
+ description: 'Read the DeepClean panel\'s most recent checkup report as a bounded Markdown summary without scanning again: time and staleness, platform, Full Disk Access, disk capacity, estimated reclaimable space, per-tier counts, top items with itemId, and large files with itemId. Use it when the user asks what the panel found, or when you need itemIds for disk_inspect without a rescan. Read-only; the summary cannot change cleanup tiers or the allow-set.',
1456
+ parameters: {},
1457
+ output,
1458
+ async execute() {
1459
+ if (!lastReport) return '暂无面板体检报告。请先在 DeepClean 面板运行一次体检,再让我读取报告。'
1460
+ return clip(renderReportSummary(lastReport, lastReportAt))
1461
+ },
1462
+ }))
1463
+
1464
+ ctx.tools.register(defineTool({
1465
+ name: 'disk_self_test',
1466
+ description: 'Run the scanner\'s read-only self-test: the macOS scan chain (4 checks) or the 7 Windows checks covering Python, permissions, registry access, quoting and report rendering. Usually seconds, up to 180 s; writes only to the system temp directory. Use only when diagnosing a broken scanner; normal disk questions use disk_audit. It does not replace disk_check_permissions.',
1467
+ parameters: {},
1468
+ output,
1469
+ async execute() {
1470
+ const result = await runScanner(['--self-test'], 180000)
1471
+ const text = formatResult(result)
1472
+ return result.code === 0 ? text : `自检未通过(exit ${result.code})\n\n${text}`
1473
+ },
1474
+ }))
1475
+
1476
+ ctx.inject(['webServer'], (webCtx) => {
1477
+ const disposers = []
1478
+
1479
+ const route = (routePath, handler) => {
1480
+ disposers.push(webCtx.webServer.register({ kind: 'exact', path: routePath, handler }))
1481
+ }
1482
+
1483
+ // A fixed, bundled illustration; never expose arbitrary filesystem paths.
1484
+ route('/dsh-deepclean/assets/disk.png', async (req, res) => {
1485
+ if (req.method !== 'GET') return sendJson(res, 405, { error: '只支持 GET' })
1486
+ try {
1487
+ const bytes = fs.readFileSync(path.join(RESOURCES, 'images', 'disk.png'))
1488
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600', 'X-Content-Type-Options': 'nosniff' })
1489
+ res.end(bytes)
1490
+ } catch {
1491
+ sendJson(res, 404, { error: '图片不存在' })
1492
+ }
1493
+ })
1494
+
1495
+ route('/dsh-deepclean/api/permissions', async (_req, res) => {
1496
+ try {
1497
+ const result = await runScanner(['--check-permissions'], 30000)
1498
+ // 结构化状态由退出码决定(扫描器约定 exit 2 = 未授权),不解析文案。
1499
+ sendJson(res, 200, { code: result.code, status: result.code === 2 ? 'denied' : 'ok', text: (result.out || result.err).trim() })
1500
+ } catch (err) {
1501
+ sendJson(res, 500, { error: String(err && err.message ? err.message : err) })
1502
+ }
1503
+ })
1504
+
1505
+ // 一键打开 macOS「完全磁盘访问」设置页;URL 是固定常量,不接受用户输入。
1506
+ // DEEPCLEAN_NO_OPEN=1 供 CI/测试跳过真正的 open 调用。
1507
+ route('/dsh-deepclean/api/open-privacy-settings', async (req, res) => {
1508
+ try {
1509
+ if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1510
+ if (process.platform !== 'darwin') return sendJson(res, 400, { error: '仅 macOS 支持' })
1511
+ if (process.env.DEEPCLEAN_NO_OPEN !== '1') {
1512
+ await runShell('open', ['x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'], 15000)
1513
+ }
1514
+ sendJson(res, 200, { ok: true })
1515
+ } catch (err) {
1516
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1517
+ }
1518
+ })
1519
+
1520
+ route('/dsh-deepclean/api/overview', async (_req, res) => {
1521
+ sendJson(res, 200, {
1522
+ at: lastReportAt,
1523
+ report: lastReport,
1524
+ // 面板打开即可用的实时容量快照:数据来自 statfs,与是否扫描无关。
1525
+ disk: diskSnapshot(),
1526
+ scan: scanSummary(),
1527
+ history: loadHistory().slice(-30),
1528
+ cleanBatches: listCleanBatches(),
1529
+ quarantine: quarantineInfo(),
1530
+ whitelist: loadWhitelist(),
1531
+ paths: { audit: AUDIT_FILE, history: HISTORY_FILE, whitelist: WHITELIST_FILE },
1532
+ platform: process.platform,
1533
+ })
1534
+ })
1535
+
1536
+ // 轻量刷新:只读一次 statfs,不触碰历史/扫描状态(面板每 60 秒或回到前台时调用)。
1537
+ route('/dsh-deepclean/api/disk', async (_req, res) => {
1538
+ sendJson(res, 200, { disk: diskSnapshot() })
1539
+ })
1540
+
1541
+ route('/dsh-deepclean/api/scan', async (req, res) => {
1542
+ try {
1543
+ if (req.method === 'POST') {
1544
+ const body = await readBody(req).catch(() => ({}))
1545
+ const started = startScan(!!body.full)
1546
+ return sendJson(res, started ? 202 : 409, started ? { ok: true, scan: scanSummary() } : { error: '扫描已在进行中', scan: scanSummary() })
1547
+ }
1548
+ sendJson(res, 200, { scan: scanSummary(), report: lastReport, at: lastReportAt })
1549
+ } catch (err) {
1550
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1551
+ }
1552
+ })
1553
+
1554
+ route('/dsh-deepclean/api/scan/cancel', async (req, res) => {
1555
+ try {
1556
+ if (scanState.status !== 'running') return sendJson(res, 409, { error: '当前没有进行中的扫描' })
1557
+ scanState.canceled = true
1558
+ if (activeChild) killTree(activeChild)
1559
+ sendJson(res, 200, { ok: true, scan: scanSummary() })
1560
+ } catch (err) {
1561
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1562
+ }
1563
+ })
1564
+
1565
+ route('/dsh-deepclean/api/report', async (req, res) => {
1566
+ try {
1567
+ const url = new URL(req.url, 'http://dsh.local')
1568
+ const full = url.searchParams.get('full') === '1'
1569
+ const cached = url.searchParams.get('cached') === '1'
1570
+ if (cached && lastReport) return sendJson(res, 200, { report: lastReport, at: lastReportAt })
1571
+ const started = startScan(full)
1572
+ sendJson(res, started ? 202 : 409, { scan: scanSummary() })
1573
+ } catch (err) {
1574
+ sendJson(res, 500, { error: String(err && err.message ? err.message : err) })
1575
+ }
1576
+ })
1577
+
1578
+ route('/dsh-deepclean/api/volumes', async (_req, res) => {
1579
+ try {
1580
+ sendJson(res, 200, { volumes: listVolumes() })
1581
+ } catch (err) {
1582
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1583
+ }
1584
+ })
1585
+
1586
+ route('/dsh-deepclean/api/tree', async (req, res) => {
1587
+ try {
1588
+ const url = new URL(req.url, 'http://dsh.local')
1589
+ const target = url.searchParams.get('path') || ''
1590
+ const body = await listChildren(target, url.searchParams.get('refresh') === '1')
1591
+ sendJson(res, 200, body)
1592
+ } catch (err) {
1593
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1594
+ }
1595
+ })
1596
+
1597
+ // Read-only folder picker and fresh capacity preflight. No directory creation.
1598
+ route('/dsh-deepclean/api/destination', async (req, res) => {
1599
+ try {
1600
+ if (req.method !== 'GET') return sendJson(res, 405, { error: '只支持 GET' })
1601
+ const raw = new URL(req.url, 'http://localhost').searchParams.get('path') || ''
1602
+ if (!path.isAbsolute(raw)) throw new Error('请选择绝对路径的目标目录')
1603
+ const target = path.resolve(raw)
1604
+ if (fs.lstatSync(target).isSymbolicLink() || !fs.statSync(target).isDirectory()) throw new Error('目标必须是文件夹,且不能是符号链接')
1605
+ fs.accessSync(target, fs.constants.R_OK)
1606
+ let writable = true
1607
+ try { fs.accessSync(target, fs.constants.W_OK) } catch { writable = false }
1608
+ const folders = fs.readdirSync(target, { withFileTypes: true }).filter(e => e.isDirectory() && !e.isSymbolicLink())
1609
+ .map(e => ({ name: e.name, path: path.join(target, e.name) })).sort((a,b) => a.name.localeCompare(b.name))
1610
+ const usage = readUsage(target)
1611
+ sendJson(res, 200, { path: target, parent: path.dirname(target) === target ? null : path.dirname(target), writable, folders, ...usage })
1612
+ } catch (err) { sendJson(res, 400, { error: String(err.message || err) }) }
1613
+ })
1614
+
1615
+ route('/dsh-deepclean/api/reveal', async (req, res) => {
1616
+ try {
1617
+ if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1618
+ const body = await readBody(req)
1619
+ const target = String(body.path || '')
1620
+ if (!target || !fs.existsSync(target)) throw new Error('路径不存在')
1621
+ const st = fs.statSync(target)
1622
+ if (!withinHome(target) && !st.isDirectory()) throw new Error('只允许打开个人文件夹内的文件或已存在的目录')
1623
+ if (process.platform === 'darwin') await runShell('open', ['-R', target], 15000)
1624
+ else if (process.platform === 'win32') await runShell('explorer', ['/select,' + target], 15000)
1625
+ else await runShell('xdg-open', [path.dirname(target)], 15000)
1626
+ sendJson(res, 200, { ok: true })
1627
+ } catch (err) {
1628
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1629
+ }
1630
+ })
1631
+
1632
+ route('/dsh-deepclean/api/photos', async (_req, res) => {
1633
+ sendJson(res, 200, { scan: photosSummary(), result: photosState.result })
1634
+ })
1635
+
1636
+ route('/dsh-deepclean/api/photos/scan', async (req, res) => {
1637
+ try {
1638
+ if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1639
+ const started = startPhotoScan()
1640
+ sendJson(res, started ? 202 : 409, started ? { ok: true, scan: photosSummary() } : { error: '已有扫描在进行中', scan: photosSummary() })
1641
+ } catch (err) {
1642
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1643
+ }
1644
+ })
1645
+
1646
+ route('/dsh-deepclean/api/photos/cancel', async (_req, res) => {
1647
+ if (photosState.status !== 'running') return sendJson(res, 409, { error: '当前没有照片扫描' })
1648
+ photosState.status = 'canceled'
1649
+ photosState.stage = '已取消'
1650
+ photosState.finishedAt = Date.now()
1651
+ if (photoChild) killTree(photoChild)
1652
+ sendJson(res, 200, { ok: true })
1653
+ })
1654
+
1655
+ route('/dsh-deepclean/api/thumb', async (req, res) => {
1656
+ try {
1657
+ const url = new URL(req.url, 'http://dsh.local')
1658
+ const target = url.searchParams.get('path') || ''
1659
+ if (!withinHome(target) || !fs.existsSync(target) || !fs.statSync(target).isFile()) throw new Error('图片不可读')
1660
+ const size = Math.max(64, Math.min(320, Number(url.searchParams.get('size')) || 160))
1661
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepclean-thumb-'))
1662
+ try {
1663
+ const out = path.join(dir, 't.jpg')
1664
+ let result
1665
+ if (process.platform === 'win32') {
1666
+ const helper = fs.readFileSync(path.join(RESOURCES, 'scripts', 'photos-windows.ps1'), 'utf8')
1667
+ const command = Buffer.from(`& {\n${helper}\n} -Mode thumbnail`, 'utf16le').toString('base64')
1668
+ result = await runShell('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', command], 20000, JSON.stringify({ path: target, output: out, size }))
1669
+ } else {
1670
+ result = await runShell('sips', ['-Z', String(size), '-s', 'format', 'jpeg', target, '--out', out], 20000)
1671
+ }
1672
+ if (result.code !== 0) throw new Error((result.err || '图片解码失败').slice(0, 500))
1673
+ const data = fs.readFileSync(out)
1674
+ res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=300' })
1675
+ res.end(data)
1676
+ } finally {
1677
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
1678
+ }
1679
+ } catch (err) {
1680
+ sendJson(res, 404, { error: String(err && err.message ? err.message : err) })
1681
+ }
1682
+ })
1683
+
1684
+ route('/dsh-deepclean/api/move', async (req, res) => {
1685
+ try {
1686
+ if (req.method === 'GET') return sendJson(res, 200, { move: moveSummary() })
1687
+ if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1688
+ const body = await readBody(req)
1689
+ const paths = Array.isArray(body.paths) ? body.paths : []
1690
+ const count = startMove(paths, body.destination)
1691
+ sendJson(res, 202, { ok: true, count, move: moveSummary() })
1692
+ } catch (err) {
1693
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1694
+ }
1695
+ })
1696
+
1697
+ route('/dsh-deepclean/api/move/cancel', async (_req, res) => {
1698
+ if (moveState.status !== 'running') return sendJson(res, 409, { error: '当前没有移动任务' })
1699
+ moveCanceled = true
1700
+ sendJson(res, 200, { ok: true })
1701
+ })
1702
+
1703
+ route('/dsh-deepclean/api/reclaim', async (req, res) => {
1704
+ try {
1705
+ if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1706
+ const body = await readBody(req)
1707
+ const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === 'string' && path.isAbsolute(p)) : []
1708
+ if (paths.length === 0) return sendJson(res, 400, { error: '没有要估算的路径' })
1709
+ sendJson(res, 200, estimateReclaim(paths))
1710
+ } catch (err) {
1711
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1712
+ }
1713
+ })
1714
+
1715
+ route('/dsh-deepclean/api/clean', async (req, res) => {
1716
+ try {
1717
+ if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1718
+ const reportFresh = !!lastReport && Date.now() - lastReportAt <= REPORT_TTL_MS
1719
+ const photosFresh = !!photosState.result && Date.now() - photoAt <= REPORT_TTL_MS
1720
+ if (!reportFresh && !photosFresh) {
1721
+ return sendJson(res, 409, { error: '扫描结果已过期,请先重新扫描' })
1722
+ }
1723
+ const body = await readBody(req)
1724
+ const paths = Array.isArray(body.paths) ? body.paths : []
1725
+ if (paths.length === 0) return sendJson(res, 400, { error: '没有选择任何条目' })
1726
+ const whitelist = loadWhitelist()
1727
+ const blocked = paths.filter((p) => isProtected(p, whitelist))
1728
+ if (blocked.length > 0) return sendJson(res, 400, { error: `有 ${blocked.length} 项在保护名单中,拒绝清理` })
1729
+ const allowed = new Set()
1730
+ if (reportFresh) for (const p of allowedPaths(lastReport)) allowed.add(p)
1731
+ if (photosFresh) for (const p of photoAllowed) allowed.add(p)
1732
+ // 审计标注:清理时报告里的大小/分类,只写入审计供历史与复清分析使用,不参与放行。
1733
+ const annotations = new Map()
1734
+ if (reportFresh) for (const [p, info] of cleanAnnotations(lastReport, paths)) annotations.set(p, info)
1735
+ if (photosFresh) for (const [p, info] of photoCleanAnnotations(photosState.result, paths)) if (!annotations.has(p)) annotations.set(p, info)
1736
+ // 超过单批上限时由服务端分片:每片独立 batchId/审计,客户端用 batchIds 统一撤销。
1737
+ const batches = []
1738
+ const results = []
1739
+ const chunkErrors = []
1740
+ let auditOk = true
1741
+ for (const part of splitBatches(paths)) {
1742
+ try {
1743
+ const out = moveToTrash(part, allowed, { annotations })
1744
+ auditOk = auditOk && out.auditOk !== false
1745
+ if (out.moved.length > 0) {
1746
+ lastBatches.push({ batchId: out.batchId, moved: out.moved, at: Date.now() })
1747
+ while (lastBatches.length > 20) lastBatches.shift()
1748
+ }
1749
+ batches.push({ batchId: out.batchId, moved: out.moved.length, results: out.results })
1750
+ results.push(...out.results)
1751
+ } catch (err) {
1752
+ chunkErrors.push({ index: batches.length, error: String(err && err.message ? err.message : err) })
1753
+ break
1754
+ }
1755
+ }
1756
+ if (batches.length === 0) {
1757
+ return sendJson(res, 400, { error: chunkErrors[0] ? chunkErrors[0].error : '清理失败' })
1758
+ }
1759
+ const cleaned = new Set(results.filter((r) => r.ok).map((r) => r.path))
1760
+ if (cleaned.size > 0 && reportFresh) lastReport = pruneReport(lastReport, cleaned)
1761
+ if (cleaned.size > 0 && photosFresh) {
1762
+ photosState.result = prunePhotoResult(photosState.result, cleaned)
1763
+ for (const p of cleaned) photoAllowed.delete(p)
1764
+ }
1765
+ invalidateQuarantine()
1766
+ const batchIds = batches.map((b) => b.batchId)
1767
+ // auditOk=false 时文件已移动,但历史里可能看不到恢复记录,客户端必须如实提示。
1768
+ sendJson(res, 200, {
1769
+ batchId: batchIds[batchIds.length - 1], batchIds,
1770
+ moved: cleaned.size, results, batches, chunkErrors, auditOk,
1771
+ })
1772
+ } catch (err) {
1773
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1774
+ }
1775
+ })
1776
+
1777
+ route('/dsh-deepclean/api/undo', async (req, res) => {
1778
+ try {
1779
+ if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1780
+ const body = await readBody(req)
1781
+ const ids = Array.isArray(body.batchIds)
1782
+ ? [...new Set(body.batchIds.filter((b) => typeof b === 'string'))]
1783
+ : (typeof body.batchId === 'string' ? [body.batchId] : [])
1784
+ const targets = (ids.length > 0
1785
+ ? ids.map((id) => lastBatches.find((b) => b.batchId === id))
1786
+ : [lastBatches[lastBatches.length - 1]]).filter(Boolean)
1787
+ if (targets.length === 0) return sendJson(res, 404, { error: '没有可撤销的批次' })
1788
+ const opts = { onConflict: body.onConflict === 'rename' ? 'rename' : 'fail' }
1789
+ const results = []
1790
+ const batches = []
1791
+ let auditOk = true
1792
+ let retryable = false
1793
+ for (const batch of targets) {
1794
+ const out = restoreMoved(batch.moved, batch.batchId, opts)
1795
+ auditOk = auditOk && out.auditOk !== false
1796
+ results.push(...out.results)
1797
+ const summary = summarizeResults(out.results)
1798
+ // 已恢复的条目从批次里剔除:否则重试时它们只会报 notfound,撤销入口永远清不掉。
1799
+ const restoredFrom = new Set(out.results.filter((r) => r.ok).map((r) => r.path))
1800
+ if (restoredFrom.size > 0) batch.moved = trimUndoneBatch(batch.moved, out.results)
1801
+ const pending = out.results.filter((r) => !r.ok)
1802
+ const retryableCodes = new Set(['exists', 'eacces', 'exdev', 'symlink', 'io'])
1803
+ const canRetry = pending.some((r) => retryableCodes.has(r.code))
1804
+ retryable = retryable || (batch.moved.length > 0 && canRetry)
1805
+ batches.push({ batchId: batch.batchId, summary, remaining: batch.moved.length })
1806
+ // 没有剩余条目、或剩下的条目已从隔离区消失(清空废纸篓/被替换)时移除撤销入口。
1807
+ const onlyGone = pending.length > 0 && pending.every((r) => r.code === 'notfound' || r.code === 'changed')
1808
+ if (batch.moved.length === 0 || onlyGone) {
1809
+ const index = lastBatches.indexOf(batch)
1810
+ if (index >= 0) lastBatches.splice(index, 1)
1811
+ }
1812
+ }
1813
+ const summary = summarizeResults(results)
1814
+ invalidateQuarantine()
1815
+ sendJson(res, 200, { batchId: batches[batches.length - 1].batchId, results, summary, batches, retryable, auditOk })
1816
+ } catch (err) {
1817
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1818
+ }
1819
+ })
1820
+
1821
+ route('/dsh-deepclean/api/state', async (_req, res) => {
1822
+ sendJson(res, 200, {
1823
+ at: lastReportAt,
1824
+ canUndo: lastBatches.length > 0,
1825
+ lastBatchId: lastBatches.length > 0 ? lastBatches[lastBatches.length - 1].batchId : null,
1826
+ // 分片清理会生成多个批次;重新挂载面板后要能一次撤销全部。
1827
+ lastBatchIds: lastBatches.map((b) => b.batchId),
1828
+ scan: scanSummary(),
1829
+ })
1830
+ })
1831
+
1832
+ route('/dsh-deepclean/api/history', async (_req, res) => {
1833
+ sendJson(res, 200, { scans: loadHistory().slice(-30), cleans: listCleanBatches() })
1834
+ })
1835
+
1836
+ route('/dsh-deepclean/api/quarantine', async (_req, res) => {
1837
+ try {
1838
+ sendJson(res, 200, quarantineInfo())
1839
+ } catch (err) {
1840
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1841
+ }
1842
+ })
1843
+
1844
+ // 显式「永久删除」:只删本插件移入隔离区/废纸篓的批次条目(purgeMoved 的边界),
1845
+ // 是两阶段释放的第二阶段;必须由客户端二次确认后调用。
1846
+ route('/dsh-deepclean/api/quarantine/purge', async (req, res) => {
1847
+ try {
1848
+ if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1849
+ const body = await readBody(req)
1850
+ const explicitIds = Array.isArray(body.batchIds) ? body.batchIds.filter((b) => typeof b === 'string') : []
1851
+ // 显式意图:要么列 batchIds,要么 all:true;不接受空 body 直接清空全部。
1852
+ if (explicitIds.length === 0 && body.all !== true) {
1853
+ return sendJson(res, 400, { error: '请显式传入 batchIds,或传 all:true 表示清空全部可恢复批次' })
1854
+ }
1855
+ const wanted = explicitIds.length > 0 ? new Set(explicitIds) : null
1856
+ const targets = listCleanBatches(Infinity).filter((b) => b.restorable > 0 && (!wanted || wanted.has(b.batchId)))
1857
+ if (targets.length === 0) return sendJson(res, 400, { error: `没有可永久删除的批次(${TRASH_WORD}可能已清空)` })
1858
+ let purged = 0
1859
+ let freedBytes = 0
1860
+ const batches = []
1861
+ const results = []
1862
+ let auditOk = true
1863
+ for (const batch of targets) {
1864
+ const items = batch.items.filter((i) => i.exists)
1865
+ if (items.length === 0) {
1866
+ batches.push({ batchId: batch.batchId, purged: 0, freedBytes: 0, skipped: true })
1867
+ continue
1868
+ }
1869
+ const out = purgeMoved(items, batch.batchId)
1870
+ auditOk = auditOk && out.auditOk !== false
1871
+ purged += out.purged
1872
+ freedBytes += out.freedBytes
1873
+ results.push(...out.results)
1874
+ batches.push({ batchId: batch.batchId, purged: out.purged, freedBytes: out.freedBytes })
1875
+ const idx = lastBatches.findIndex((b) => b.batchId === batch.batchId)
1876
+ if (idx >= 0) lastBatches.splice(idx, 1)
1877
+ }
1878
+ invalidateQuarantine()
1879
+ sendJson(res, 200, { total: { purged, freedBytes }, batches, results, auditOk })
1880
+ } catch (err) {
1881
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1882
+ }
1883
+ })
1884
+
1885
+ route('/dsh-deepclean/api/restore', async (req, res) => {
1886
+ try {
1887
+ if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1888
+ const body = await readBody(req)
1889
+ const batches = listCleanBatches(Infinity)
1890
+ const batch = batches.find((b) => b.batchId === body.batchId)
1891
+ if (!batch) return sendJson(res, 404, { error: '找不到该清理批次' })
1892
+ const moved = batch.items.filter((i) => i.exists).map((i) => ({ from: i.from, to: i.to, identity: i.identity }))
1893
+ if (moved.length === 0) return sendJson(res, 409, { error: `${TRASH_WORD}里已没有该批次文件(可能已清空)` })
1894
+ const out = restoreMoved(moved, batch.batchId, { onConflict: body.onConflict === 'rename' ? 'rename' : 'fail' })
1895
+ invalidateQuarantine()
1896
+ sendJson(res, 200, {
1897
+ batchId: batch.batchId, results: out.results,
1898
+ summary: summarizeResults(out.results), auditOk: out.auditOk,
1899
+ })
1900
+ } catch (err) {
1901
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1902
+ }
1903
+ })
1904
+
1905
+ route('/dsh-deepclean/api/purge', async (req, res) => {
1906
+ try {
1907
+ if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1908
+ const body = await readBody(req)
1909
+ const batches = listCleanBatches(Infinity)
1910
+ const batch = body.batchId ? batches.find((b) => b.batchId === body.batchId) : batches[0]
1911
+ if (!batch) return sendJson(res, 404, { error: '找不到该清理批次' })
1912
+ const items = batch.items.filter((i) => i.exists)
1913
+ if (items.length === 0) return sendJson(res, 409, { error: `${TRASH_WORD}里已没有该批次文件(可能已清空)` })
1914
+ const { purged, freedBytes, results, auditOk } = purgeMoved(items, batch.batchId)
1915
+ invalidateQuarantine()
1916
+ const idx = lastBatches.findIndex((b) => b.batchId === batch.batchId)
1917
+ if (idx >= 0) lastBatches.splice(idx, 1)
1918
+ sendJson(res, 200, { batchId: batch.batchId, purged, freedBytes, results, auditOk })
1919
+ } catch (err) {
1920
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1921
+ }
1922
+ })
1923
+
1924
+ route('/dsh-deepclean/api/whitelist', async (req, res) => {
1925
+ try {
1926
+ if (req.method === 'GET') return sendJson(res, 200, { paths: loadWhitelist() })
1927
+ if (req.method !== 'PUT' && req.method !== 'POST') return sendJson(res, 405, { error: '只支持 GET/PUT' })
1928
+ const body = await readBody(req)
1929
+ const paths = Array.isArray(body.paths) ? body.paths : []
1930
+ const cleaned = [...new Set(paths.filter((p) => typeof p === 'string' && path.isAbsolute(p)))]
1931
+ saveWhitelist(cleaned)
1932
+ sendJson(res, 200, { paths: cleaned })
1933
+ } catch (err) {
1934
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1935
+ }
1936
+ })
1937
+
1938
+ ctx.effect(() => () => {
1939
+ for (const dispose of disposers) {
1940
+ try { dispose() } catch { /* ignore */ }
1941
+ }
1942
+ })
1943
+ })
1944
+ }