deepclean-mydisk 0.12.1 → 0.13.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 CHANGED
@@ -39,7 +39,7 @@ let lastReportAt = 0
39
39
  // disk_audit 的临时报告:只给 disk_inspect 解析 itemId / 覆盖路径,绝不参与清理放行。
40
40
  let lastToolReport = null
41
41
  let lastToolReportAt = 0
42
- let activeChild = null
42
+ let scanController = null
43
43
  const lastBatches = []
44
44
 
45
45
  const scanState = {
@@ -49,14 +49,18 @@ const scanState = {
49
49
  startedAt: 0,
50
50
  finishedAt: 0,
51
51
  error: '',
52
+ errorCode: '',
52
53
  full: false,
53
54
  canceled: false,
54
55
  }
55
56
 
56
57
  function killTree(child) {
57
- if (!child || child.killed) return
58
+ if (!child?.pid || child.killed) return
58
59
  if (process.platform === 'win32') {
59
- try { spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) } catch { /* ignore */ }
60
+ try {
61
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
62
+ .on('error', () => {})
63
+ } catch { /* ignore */ }
60
64
  return
61
65
  }
62
66
  try { process.kill(-child.pid, 'SIGTERM') } catch { try { child.kill('SIGTERM') } catch { /* ignore */ } }
@@ -65,20 +69,89 @@ function killTree(child) {
65
69
  }, 1500).unref?.()
66
70
  }
67
71
 
68
- function pythonCommand() {
69
- const win = process.platform === 'win32'
70
- return { cmd: win ? 'py' : 'python3', args: win ? ['-3'] : [] }
72
+ // DSH 自带运行时里可能就有可用的 Python:用户机器上常见只有 Microsoft Store
73
+ // 的占位别名(执行即退出 9009),PATH 探测会全部失败。这里只做存在性检查,
74
+ // 真正的可用性仍由下面的版本探针判定,所以不会误用一个坏的运行时。
75
+ function bundledPythonCandidates() {
76
+ const relatives = process.platform === 'win32'
77
+ ? [['python', 'python.exe']]
78
+ : [['python', 'bin', 'python3'], ['python', 'bin', 'python'], ['python', 'python3']]
79
+ let runtimes = []
80
+ try {
81
+ runtimes = fs.readdirSync(path.join(DSH_HOME, 'dsh-runtimes'), { withFileTypes: true })
82
+ .filter((entry) => entry.isDirectory())
83
+ .map((entry) => entry.name)
84
+ .sort()
85
+ } catch {
86
+ return []
87
+ }
88
+ const found = []
89
+ for (const runtime of runtimes) {
90
+ for (const relative of relatives) {
91
+ const exe = path.join(DSH_HOME, 'dsh-runtimes', runtime, 'dependencies', ...relative)
92
+ if (!fs.existsSync(exe)) continue
93
+ found.push([exe])
94
+ break
95
+ }
96
+ }
97
+ return found
98
+ }
99
+
100
+ // 解释器探测:只跑版本探针,不跑扫描器,真实扫描失败(含超时)绝不触发换解释器重试。
101
+ const PYTHON_PROBE = ['-I', '-S', '-c', 'import sys; print("deepclean-python-ok" if (3, 9) <= sys.version_info < (4,) else "")']
102
+
103
+ // 顺序:显式 PYTHON → PATH 上的启动器 → DSH 自带运行时(兜底)。
104
+ function pythonCandidates(win) {
105
+ const launchers = win ? [['py', '-3'], ['python'], ['python3']] : [['python3'], ['python']]
106
+ const candidates = []
107
+ const seen = new Set()
108
+ for (const candidate of [process.env.PYTHON ? [process.env.PYTHON] : null, ...launchers, ...bundledPythonCandidates()]) {
109
+ if (!candidate || seen.has(candidate.join('\u0000'))) continue
110
+ seen.add(candidate.join('\u0000'))
111
+ candidates.push(candidate)
112
+ }
113
+ return candidates
114
+ }
115
+
116
+ async function pickPython(candidates, env, opts) {
117
+ for (const [cmd, ...args] of candidates) {
118
+ opts.signal?.throwIfAborted()
119
+ const result = await runScannerProcess(cmd, [...args, ...PYTHON_PROBE], 5000, { env, signal: opts.signal }).catch(() => null)
120
+ opts.signal?.throwIfAborted()
121
+ if (result?.code === 0 && result.out.trim() === 'deepclean-python-ok') return [cmd, ...args]
122
+ }
123
+ return null
71
124
  }
72
125
 
73
- function runScanner(extraArgs, timeoutMs, opts = {}, script = 'deepclean.py') {
74
- const { cmd, args } = pythonCommand()
126
+ async function runScanner(extraArgs, timeoutMs, opts = {}, script = 'deepclean.py') {
127
+ const win = process.platform === 'win32'
75
128
  const target = path.join(RESOURCES, 'scripts', script)
129
+ const env = { ...process.env, PYTHONIOENCODING: 'utf-8', ...opts.env }
130
+ // Windows:启动器或商店别名可能能启动但不能执行 Python,关掉商店自动安装弹窗。
131
+ if (win) env.PYTHON_MANAGER_AUTOMATIC_INSTALL = 'false'
132
+ const candidates = pythonCandidates(win)
133
+ const python = await pickPython(candidates, env, opts)
134
+ if (python) {
135
+ const [cmd, ...args] = python
136
+ return runScannerProcess(cmd, [...args, target, ...extraArgs], timeoutMs, { ...opts, env })
137
+ }
138
+ // 非 Windows 维持历史行为:把首选解释器的真实启动失败原样抛出(errorCode 留空,
139
+ // 客户端不会走进 Windows 专用的 Python 安装引导卡)。
140
+ if (!win) {
141
+ const [cmd, ...args] = candidates[0]
142
+ return runScannerProcess(cmd, [...args, target, ...extraArgs], timeoutMs, { ...opts, env })
143
+ }
144
+ throw Object.assign(new Error('找不到可用的 Python 3(需要 3.9 或更新版本)。请安装 Python 3,确认 PowerShell 中 `py -3 --version`、`python --version` 或 `python3 --version` 能显示版本,然后重启 DSH。'), { code: 'python_unavailable' })
145
+ }
146
+
147
+ function runScannerProcess(cmd, args, timeoutMs, { env, signal, onProgress }) {
76
148
  return new Promise((resolve, reject) => {
149
+ signal?.throwIfAborted()
77
150
  let child
78
151
  try {
79
- child = spawn(cmd, [...args, target, ...extraArgs], {
152
+ child = spawn(cmd, args, {
80
153
  cwd: RESOURCES,
81
- env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...(opts.env || {}) },
154
+ env,
82
155
  stdio: ['ignore', 'pipe', 'pipe'],
83
156
  detached: process.platform !== 'win32',
84
157
  windowsHide: true,
@@ -87,8 +160,8 @@ function runScanner(extraArgs, timeoutMs, opts = {}, script = 'deepclean.py') {
87
160
  reject(new Error(`无法启动 ${cmd}:${err instanceof Error ? err.message : String(err)}`))
88
161
  return
89
162
  }
90
- if (typeof opts.trackChild === 'function') opts.trackChild(child)
91
- else activeChild = child
163
+ const abort = () => killTree(child)
164
+ signal?.addEventListener('abort', abort, { once: true })
92
165
  let out = ''
93
166
  let err = ''
94
167
  let buf = ''
@@ -97,28 +170,29 @@ function runScanner(extraArgs, timeoutMs, opts = {}, script = 'deepclean.py') {
97
170
  child.stdout.on('data', (chunk) => { out += chunk })
98
171
  child.stderr.on('data', (chunk) => {
99
172
  err += chunk
100
- if (typeof opts.onProgress !== 'function') return
173
+ if (typeof onProgress !== 'function') return
101
174
  buf += chunk
102
175
  let idx
103
176
  while ((idx = buf.indexOf('\n')) >= 0) {
104
177
  const line = buf.slice(0, idx).trim()
105
178
  buf = buf.slice(idx + 1)
106
179
  const m = /^PROGRESS (\d+) (.+)$/.exec(line)
107
- if (m) opts.onProgress(Number(m[1]), m[2])
180
+ if (m) onProgress(Number(m[1]), m[2])
108
181
  }
109
182
  })
110
183
  const timer = setTimeout(() => {
184
+ signal?.removeEventListener('abort', abort)
111
185
  try { killTree(child) } catch { /* ignore */ }
112
186
  reject(new Error(`扫描超时(${Math.round(timeoutMs / 1000)}s);可改用快速扫描`))
113
187
  }, timeoutMs)
114
188
  child.on('error', (error) => {
115
189
  clearTimeout(timer)
190
+ signal?.removeEventListener('abort', abort)
116
191
  reject(new Error(`无法启动 ${cmd}:${error.message}(请确认本机已安装 Python 3)`))
117
192
  })
118
193
  child.on('close', (code) => {
119
194
  clearTimeout(timer)
120
- if (typeof opts.trackChild === 'function') opts.trackChild(null)
121
- else if (activeChild === child) activeChild = null
195
+ signal?.removeEventListener('abort', abort)
122
196
  resolve({ code, out, err })
123
197
  })
124
198
  })
@@ -182,6 +256,7 @@ function listCleanBatches(limit = 20) {
182
256
  return []
183
257
  }
184
258
  const batches = []
259
+ const byId = new Map()
185
260
  const latestOwner = new Map()
186
261
  const restores = new Map()
187
262
  const purged = new Set()
@@ -191,28 +266,40 @@ function listCleanBatches(limit = 20) {
191
266
  if (e.action === 'clean' && Array.isArray(e.moved) && e.moved.length) {
192
267
  const items = e.moved.map(m => ({ ...m, restored: false }))
193
268
  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
269
+ const batch = { batchId: e.batchId, at: e.at, items }
270
+ batches.push(batch)
271
+ byId.set(e.batchId, batch)
272
+ } else if ((e.action === 'restore' || e.action === 'purge') && Array.isArray(e.results)) {
273
+ const isRestore = e.action === 'restore'
274
+ if (!isRestore && e.batchId) purged.add(e.batchId)
275
+ for (const item of byId.get(e.batchId)?.items || []) {
276
+ const matched = e.results.some(r => r.ok && (isRestore
277
+ ? r.path === item.from && (!r.to || r.to === item.to)
278
+ : r.from === item.from && r.to === item.to))
279
+ if (!matched) continue
280
+ if (isRestore) {
281
+ item.restored = true
282
+ item.restoredAt ??= e.at
283
+ } else {
284
+ item.deleted = true
285
+ item.deletedAt ??= e.at
203
286
  }
204
287
  }
288
+ if (!isRestore) continue
205
289
  // 最近一次恢复是否部分失败:历史页据此提示「上次恢复失败」并给另存为副本入口。
206
290
  const failed = e.results.filter(r => !r.ok)
207
291
  const codes = {}
208
292
  for (const r of failed) codes[r.code || 'io'] = (codes[r.code || 'io'] || 0) + 1
209
293
  restores.set(e.batchId, { at: e.at, ok: e.results.length - failed.length, failed: failed.length, codes })
294
+ } else if (e.action === 'purge' && e.batchId) {
295
+ purged.add(e.batchId)
210
296
  }
211
297
  } catch { /* ignore */ }
212
298
  }
213
299
  return batches.reverse().map(batch => {
214
300
  const items = batch.items.map(item => ({
215
301
  from: item.from, to: item.to, identity: item.identity, restored: item.restored,
302
+ restoredAt: item.restoredAt, deleted: item.deleted === true, deletedAt: item.deletedAt,
216
303
  // 记录「清理时报告里的大小/分类」,供历史页统计与复清分析;旧日志没有这两个字段。
217
304
  kb: Number.isFinite(item.kb) ? item.kb : null,
218
305
  category: typeof item.category === 'string' ? item.category : null,
@@ -277,6 +364,7 @@ function scanSummary() {
277
364
  stage: scanState.stage,
278
365
  elapsed,
279
366
  error: scanState.error,
367
+ errorCode: scanState.errorCode,
280
368
  canceled: scanState.canceled,
281
369
  full: scanState.full,
282
370
  at: lastReportAt,
@@ -290,12 +378,14 @@ function startScan(full) {
290
378
  const file = path.join(dir, 'report.json')
291
379
  Object.assign(scanState, {
292
380
  status: 'running', progress: 0, stage: '准备扫描', startedAt: Date.now(), finishedAt: 0,
293
- error: '', full: !!full, canceled: false,
381
+ error: '', errorCode: '', full: !!full, canceled: false,
294
382
  })
295
383
  const argv = []
296
384
  if (!full) argv.push('--quick')
297
385
  argv.push('--json', file)
386
+ scanController = new AbortController()
298
387
  runScanner(argv, 300000, {
388
+ signal: scanController.signal,
299
389
  onProgress: (pct, label) => {
300
390
  scanState.progress = pct
301
391
  scanState.stage = label
@@ -309,6 +399,8 @@ function startScan(full) {
309
399
  }
310
400
  if (res.code !== 0) {
311
401
  scanState.status = 'error'
402
+ // macOS 扫描器约定 exit 2 表示完全磁盘访问未授权。
403
+ if (process.platform === 'darwin' && res.code === 2) scanState.errorCode = 'full_disk_access_required'
312
404
  scanState.error = (res.out || res.err || `exit ${res.code}`).trim().slice(0, 500)
313
405
  scanState.finishedAt = Date.now()
314
406
  return
@@ -332,7 +424,8 @@ function startScan(full) {
332
424
  })
333
425
  }).catch((err) => {
334
426
  scanState.status = scanState.canceled ? 'canceled' : 'error'
335
- scanState.error = String(err && err.message ? err.message : err)
427
+ if (scanState.canceled) scanState.stage = '已取消'
428
+ else Object.assign(scanState, scannerError(err))
336
429
  scanState.finishedAt = Date.now()
337
430
  }).finally(() => {
338
431
  try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
@@ -341,9 +434,9 @@ function startScan(full) {
341
434
  }
342
435
 
343
436
  const photosState = {
344
- status: 'idle', progress: 0, stage: '', startedAt: 0, finishedAt: 0, error: '', result: null,
437
+ status: 'idle', progress: 0, stage: '', startedAt: 0, finishedAt: 0, error: '', errorCode: '', result: null,
345
438
  }
346
- let photoChild = null
439
+ let photoController = null
347
440
  let photoAllowed = new Set()
348
441
  let photoAt = 0
349
442
 
@@ -355,6 +448,7 @@ function photosSummary() {
355
448
  stage: photosState.stage,
356
449
  elapsed: photosState.startedAt ? Math.round((end - photosState.startedAt) / 1000) : 0,
357
450
  error: photosState.error,
451
+ errorCode: photosState.errorCode,
358
452
  at: photoAt,
359
453
  hasResult: !!photosState.result,
360
454
  }
@@ -365,12 +459,16 @@ function startPhotoScan() {
365
459
  if (scanState.status === 'running') return false
366
460
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepclean-photos-'))
367
461
  const file = path.join(dir, 'photos.json')
368
- Object.assign(photosState, { status: 'running', progress: 0, stage: '准备', startedAt: Date.now(), finishedAt: 0, error: '', result: null })
462
+ Object.assign(photosState, { status: 'running', progress: 0, stage: '准备', startedAt: Date.now(), finishedAt: 0, error: '', errorCode: '', result: null })
463
+ photoController = new AbortController()
464
+ const { signal } = photoController
369
465
  runScanner(['--json', file], 600000, {
370
- onProgress: (pct, label) => { photosState.progress = pct; photosState.stage = label },
371
- trackChild: (child) => { photoChild = child },
466
+ signal,
467
+ onProgress: (pct, label) => {
468
+ if (!signal.aborted) { photosState.progress = pct; photosState.stage = label }
469
+ },
372
470
  }, 'photos.py').then((res) => {
373
- if (photosState.status !== 'running') return
471
+ if (signal.aborted) return
374
472
  if (res.code !== 0) {
375
473
  photosState.status = 'error'
376
474
  photosState.error = (res.out || res.err || `exit ${res.code}`).trim().slice(0, 500)
@@ -387,8 +485,9 @@ function startPhotoScan() {
387
485
  photoAllowed = new Set()
388
486
  for (const g of result.groups || []) for (const it of g.items || []) if (!isCloudBackedPath(it.path)) photoAllowed.add(it.path)
389
487
  }).catch((err) => {
390
- photosState.status = photosState.status === 'running' ? 'error' : photosState.status
391
- photosState.error = String(err && err.message ? err.message : err)
488
+ if (signal.aborted) return
489
+ photosState.status = 'error'
490
+ Object.assign(photosState, scannerError(err))
392
491
  photosState.finishedAt = Date.now()
393
492
  }).finally(() => {
394
493
  try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
@@ -747,7 +846,11 @@ function estimateReclaim(paths) {
747
846
  function withinHome(p) {
748
847
  const home = path.resolve(process.env.HOME || os.homedir())
749
848
  const resolved = path.resolve(p)
750
- return resolved === home || resolved.startsWith(home + path.sep)
849
+ if (process.platform !== 'win32') return resolved === home || resolved.startsWith(home + path.sep)
850
+ // Windows paths are case-insensitive; keep this consistent with isProtected.
851
+ const lhs = resolved.toLowerCase()
852
+ const rhs = home.toLowerCase()
853
+ return lhs === rhs || lhs.startsWith(rhs + path.sep)
751
854
  }
752
855
 
753
856
  function runShell(cmd, args, timeoutMs = 120000, input = null) {
@@ -826,18 +929,23 @@ function listVolumes() {
826
929
  return out.filter((v) => (seen.has(v.path) ? false : (seen.add(v.path), true)))
827
930
  }
828
931
 
829
- const treeCache = new Map()
932
+ // 导出仅供测试断言播种顺序/字段传播/上限;生产代码只在本文件内使用。
933
+ export const treeCache = new Map()
830
934
  const TREE_TTL_MS = 60 * 1000
831
- const TREE_CACHE_MAX = 500
935
+ // One walk can seed up to MAX_DIRS (500) levels plus the target itself. Keep the
936
+ // cap above that so seeding never evicts the target it just walked.
937
+ const TREE_CACHE_MAX = 2000
832
938
 
833
939
  // tree.py walks the subtree once and returns a `dirs` map of per-directory
834
940
  // listings; seeding every level at once makes drill-down cache hits instead
835
941
  // of re-walking the subtree on each click.
836
- function seedTreeCache(target, data) {
942
+ export function seedTreeCache(target, data) {
837
943
  const at = Date.now()
838
944
  const dirs = data.dirs && typeof data.dirs === 'object' ? data.dirs : {}
839
945
  delete data.dirs
840
- treeCache.set(target, { at, data })
946
+ // Seed the children first and the target last: if the target were inserted
947
+ // first, a walk that seeds MAX_DIRS levels would push it past the cap and
948
+ // evict it, so every home visit re-walked the whole subtree.
841
949
  for (const [p, d] of Object.entries(dirs)) {
842
950
  treeCache.set(path.resolve(p), {
843
951
  at,
@@ -848,26 +956,72 @@ function seedTreeCache(target, data) {
848
956
  metric: data.metric,
849
957
  unavailable: d.unavailable || 0,
850
958
  skipped: d.skipped || 0,
959
+ skippedCloud: d.skippedCloud || 0,
960
+ cloudFiles: d.cloudFiles || 0,
961
+ sharedFiles: d.sharedFiles || 0,
962
+ sharedDupKb: d.sharedDupKb || 0,
963
+ bigFilesCap: d.bigFilesCap || 0,
851
964
  bigFiles: Array.isArray(d.bigFiles) ? d.bigFiles : [],
852
965
  },
853
966
  })
854
967
  }
968
+ treeCache.delete(target)
969
+ treeCache.set(target, { at, data })
970
+ // The target's size changed: refresh the cached parent rows too, otherwise
971
+ // going back up after a refresh shows the old size for up to TREE_TTL_MS.
972
+ const totalKb = (data.children || []).reduce((n, c) => n + (Number(c.kb) || 0), 0)
973
+ let parent = path.dirname(target)
974
+ for (let depth = 0; depth < 64; depth++) {
975
+ const entry = treeCache.get(parent)
976
+ if (!entry) break
977
+ const row = (entry.data.children || []).find((c) => path.resolve(c.path) === target)
978
+ if (row) row.kb = totalKb
979
+ const up = path.dirname(parent)
980
+ if (up === parent) break
981
+ parent = up
982
+ }
855
983
  while (treeCache.size > TREE_CACHE_MAX) treeCache.delete(treeCache.keys().next().value)
856
984
  }
857
985
 
858
- async function listChildren(dirPath, refresh = false) {
986
+ async function listChildren(dirPath, refresh = false, signal) {
859
987
  const home = process.env.HOME || os.homedir()
860
988
  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('符号链接或个人文件夹之外的路径不可浏览')
989
+ if (!withinHome(target)) throw Object.assign(new Error('只允许浏览个人文件夹'), { code: 'tree_outside' })
990
+ let realHome
991
+ let realTarget
992
+ try {
993
+ realHome = fs.realpathSync(process.env.HOME || os.homedir())
994
+ realTarget = fs.realpathSync(target)
995
+ } catch (err) {
996
+ const missing = err && err.code === 'ENOENT'
997
+ throw Object.assign(new Error(missing ? '目录不存在或已被删除' : '目录不可读,请检查权限'), { code: missing ? 'tree_missing' : 'tree_denied' })
998
+ }
999
+ const relative = path.relative(realHome, realTarget)
1000
+ let lst
1001
+ try { lst = fs.lstatSync(target) } catch (err) {
1002
+ const missing = err && err.code === 'ENOENT'
1003
+ throw Object.assign(new Error(missing ? '目录不存在或已被删除' : '目录不可读,请检查权限'), { code: missing ? 'tree_missing' : 'tree_denied' })
1004
+ }
1005
+ if (lst.isSymbolicLink() || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw Object.assign(new Error('链接或个人文件夹之外的路径不可浏览'), { code: 'tree_symlink' })
865
1006
  const cached = treeCache.get(target)
866
- if (!refresh && cached && Date.now() - cached.at < TREE_TTL_MS) return cached.data
1007
+ if (!refresh && cached && Date.now() - cached.at < TREE_TTL_MS) {
1008
+ // LRU: refresh recency so a frequently used directory is not the oldest.
1009
+ treeCache.delete(target)
1010
+ treeCache.set(target, cached)
1011
+ return cached.data
1012
+ }
867
1013
  // The independent read-only helper includes hidden entries and reports partial totals.
868
1014
  // 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('目录统计失败或无权读取,请检查权限后重试')
1015
+ let res
1016
+ try {
1017
+ res = await runScanner([target], 120000, { env: { DEEPCLEAN_CLOUD_ROOTS: JSON.stringify(cloudRoots()) }, signal }, 'tree.py')
1018
+ } catch (err) {
1019
+ if (signal?.aborted) throw Object.assign(new Error('已停止加载'), { code: 'aborted' })
1020
+ if (/扫描超时/.test(String(err && err.message))) throw Object.assign(new Error('目录统计超时'), { code: 'tree_timeout' })
1021
+ throw err
1022
+ }
1023
+ if (signal?.aborted) throw Object.assign(new Error('已停止加载'), { code: 'aborted' })
1024
+ if (res.code !== 0) throw Object.assign(new Error('目录统计失败或无权读取,请检查权限后重试'), { code: 'tree_failed' })
871
1025
  const data = JSON.parse(res.out)
872
1026
  seedTreeCache(target, data)
873
1027
  return data
@@ -881,6 +1035,16 @@ function sendJson(res, status, body) {
881
1035
  res.end(JSON.stringify(body))
882
1036
  }
883
1037
 
1038
+ function scannerError(err) {
1039
+ const error = String(err && err.message ? err.message : err)
1040
+ const code = err && err.code
1041
+ // 只透传可预期的稳定错误码,客户端才能本地化;其他原始 fs 错误不暴露。
1042
+ if (typeof code === 'string' && (code === 'python_unavailable' || code === 'aborted' || code.startsWith('tree_'))) {
1043
+ return { error, errorCode: code }
1044
+ }
1045
+ return { error }
1046
+ }
1047
+
884
1048
  function readBody(req, limit = 1024 * 1024) {
885
1049
  return new Promise((resolve, reject) => {
886
1050
  let size = 0
@@ -1498,7 +1662,7 @@ export function apply(ctx) {
1498
1662
  // 结构化状态由退出码决定(扫描器约定 exit 2 = 未授权),不解析文案。
1499
1663
  sendJson(res, 200, { code: result.code, status: result.code === 2 ? 'denied' : 'ok', text: (result.out || result.err).trim() })
1500
1664
  } catch (err) {
1501
- sendJson(res, 500, { error: String(err && err.message ? err.message : err) })
1665
+ sendJson(res, 500, scannerError(err))
1502
1666
  }
1503
1667
  })
1504
1668
 
@@ -1509,7 +1673,8 @@ export function apply(ctx) {
1509
1673
  if (req.method !== 'POST') return sendJson(res, 405, { error: '只支持 POST' })
1510
1674
  if (process.platform !== 'darwin') return sendJson(res, 400, { error: '仅 macOS 支持' })
1511
1675
  if (process.env.DEEPCLEAN_NO_OPEN !== '1') {
1512
- await runShell('open', ['x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'], 15000)
1676
+ const result = await runShell('open', ['x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'], 15000)
1677
+ if (result.code !== 0) return sendJson(res, 400, { error: '无法打开系统设置' })
1513
1678
  }
1514
1679
  sendJson(res, 200, { ok: true })
1515
1680
  } catch (err) {
@@ -1555,7 +1720,7 @@ export function apply(ctx) {
1555
1720
  try {
1556
1721
  if (scanState.status !== 'running') return sendJson(res, 409, { error: '当前没有进行中的扫描' })
1557
1722
  scanState.canceled = true
1558
- if (activeChild) killTree(activeChild)
1723
+ scanController?.abort()
1559
1724
  sendJson(res, 200, { ok: true, scan: scanSummary() })
1560
1725
  } catch (err) {
1561
1726
  sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
@@ -1584,13 +1749,22 @@ export function apply(ctx) {
1584
1749
  })
1585
1750
 
1586
1751
  route('/dsh-deepclean/api/tree', async (req, res) => {
1752
+ if (req.method !== 'GET') return sendJson(res, 405, { error: '只支持 GET' })
1753
+ // 客户端点「停止加载」会断开请求:把 close 映射成 AbortSignal,杀掉 tree.py,
1754
+ // 避免被放弃的遍历继续跑到 120s 超时。
1755
+ const controller = new AbortController()
1756
+ const onClose = () => { if (!res.writableEnded) controller.abort() }
1757
+ if (typeof res.on === 'function') res.on('close', onClose)
1587
1758
  try {
1588
1759
  const url = new URL(req.url, 'http://dsh.local')
1589
1760
  const target = url.searchParams.get('path') || ''
1590
- const body = await listChildren(target, url.searchParams.get('refresh') === '1')
1591
- sendJson(res, 200, body)
1761
+ const body = await listChildren(target, url.searchParams.get('refresh') === '1', controller.signal)
1762
+ if (!controller.signal.aborted && res.writableEnded !== true) sendJson(res, 200, body)
1592
1763
  } catch (err) {
1593
- sendJson(res, 400, { error: String(err && err.message ? err.message : err) })
1764
+ // 已断开/已取消的请求不再写响应,避免向关闭的流 end()。
1765
+ if (!controller.signal.aborted && err?.code !== 'aborted') sendJson(res, 400, scannerError(err))
1766
+ } finally {
1767
+ if (typeof res.off === 'function') res.off('close', onClose)
1594
1768
  }
1595
1769
  })
1596
1770
 
@@ -1648,7 +1822,7 @@ export function apply(ctx) {
1648
1822
  photosState.status = 'canceled'
1649
1823
  photosState.stage = '已取消'
1650
1824
  photosState.finishedAt = Date.now()
1651
- if (photoChild) killTree(photoChild)
1825
+ photoController?.abort()
1652
1826
  sendJson(res, 200, { ok: true })
1653
1827
  })
1654
1828
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepclean-mydisk",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Read-only disk audit and cleanup for macOS and Windows: caches, leftovers, similar photos, space breakdown, and cross-disk file moves.",
5
5
  "author": "huangxiaoliang",
6
6
  "keywords": [