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/README.md +5 -4
- package/docs/plugin/DETAIL.en.md +2 -2
- package/docs/plugin/DETAIL.zh.md +2 -2
- package/lib/client.js +653 -161
- package/lib/index.js +229 -55
- package/package.json +1 -1
- package/resources/scripts/tree.py +199 -71
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
|
|
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 {
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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,
|
|
152
|
+
child = spawn(cmd, args, {
|
|
80
153
|
cwd: RESOURCES,
|
|
81
|
-
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
|
-
|
|
91
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
} else if (e.action === 'restore' && Array.isArray(e.results)) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
371
|
-
|
|
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 (
|
|
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
|
-
|
|
391
|
-
photosState.
|
|
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
|
-
|
|
932
|
+
// 导出仅供测试断言播种顺序/字段传播/上限;生产代码只在本文件内使用。
|
|
933
|
+
export const treeCache = new Map()
|
|
830
934
|
const TREE_TTL_MS = 60 * 1000
|
|
831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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)
|
|
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
|
-
|
|
870
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|