Files
XStats/Packages/XStatsKit/Sources/XStatsUI/State/DiskToolsController.swift
T

226 lines
8.2 KiB
Swift

import AppKit
import Cleaner
import Foundation
import HelperShared
import Localization
import Metrics
import Observation
/// 磁盘页上能动手的部分:文件系统检查、Time Machine 本地快照、其他已装载的磁盘、家目录空间分析
@MainActor
@Observable
final class DiskToolsController {
struct Outcome: Equatable {
let text: String
let isError: Bool
}
enum VerifyPhase: Equatable {
case idle
case running
case finished(VolumeVerification)
case failed(String)
}
enum ScanPhase: Equatable {
case idle
case scanning(SpaceScanProgress)
case finished(SpaceScanResult)
case failed(String)
}
// MARK: 文件系统检查
private(set) var verifyPhase: VerifyPhase = .idle
private(set) var lastVerified: Date? {
didSet { defaults.set(lastVerified, forKey: Keys.lastVerified) }
}
private(set) var lastVerifiedOK: Bool {
didSet { defaults.set(lastVerifiedOK, forKey: Keys.lastVerifiedOK) }
}
// MARK: 本地快照
private(set) var snapshots: [LocalSnapshot] = []
private(set) var snapshotsLoaded = false
private(set) var isDeletingSnapshots = false
private(set) var snapshotOutcome: Outcome?
// MARK: 其他磁盘
private(set) var volumes: [MountedVolume] = []
private(set) var ejecting: Set<String> = []
private(set) var volumeOutcome: Outcome?
// MARK: 空间占用
private(set) var scanPhase: ScanPhase = .idle
private(set) var trashOutcome: Outcome?
@ObservationIgnored private var scanTask: Task<Void, Never>?
@ObservationIgnored private let helper: HelperClient
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private var volumeObservers: [NSObjectProtocol] = []
init(helper: HelperClient, defaults: UserDefaults = .standard) {
self.helper = helper
self.defaults = defaults
lastVerified = defaults.object(forKey: Keys.lastVerified) as? Date
lastVerifiedOK = defaults.object(forKey: Keys.lastVerifiedOK) as? Bool ?? true
}
/// 磁盘页打开时刷新快照与卷宗;接入、拔出磁盘时跟着刷新
func pageOpened() {
refreshSnapshots()
refreshVolumes()
guard volumeObservers.isEmpty else { return }
let center = NSWorkspace.shared.notificationCenter
for name in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification] {
volumeObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.refreshVolumes() }
})
}
}
// MARK: 检查
func verify() {
guard verifyPhase != .running else { return }
verifyPhase = .running
Task {
let result = await Task.detached { VolumeVerifier.verify() }.value
verifyPhase = .finished(result)
lastVerified = result.date
lastVerifiedOK = result.ok
if result.ok {
Log.app.notice("磁盘检查通过")
} else {
Log.app.error("磁盘检查发现问题:\(result.summary, privacy: .public)")
}
}
}
// MARK: 快照
func refreshSnapshots() {
Task {
let list = await Task.detached { LocalSnapshots.list() }.value
snapshots = list
snapshotsLoaded = true
}
}
/// 删掉全部本地快照:辅助工具够新就走它,否则请求一次管理员授权
func deleteAllSnapshots() {
guard !isDeletingSnapshots, !snapshots.isEmpty else { return }
let identifiers = snapshots.map(\.identifier).filter(LocalSnapshotCommand.isValidIdentifier)
guard !identifiers.isEmpty else {
snapshotOutcome = Outcome(text: tr("快照名称格式不认识,没有删除"), isError: true)
return
}
isDeletingSnapshots = true
snapshotOutcome = nil
Task {
defer { isDeletingSnapshots = false }
let error: String?
if let version = await helper.remoteProtocolVersion(), version >= 4 {
error = await helper.deleteLocalSnapshots(identifiers: identifiers)
} else {
error = await MaintenanceController.runWithAdministratorPrompt(
shell: LocalSnapshotCommand.shellCommand(identifiers: identifiers),
prompt: tr("XStats 需要管理员权限来删除 Time Machine 本地快照。"))
}
if let error {
Log.app.error("删除本地快照失败:\(error, privacy: .public)")
snapshotOutcome = Outcome(text: error, isError: error != MaintenanceController.cancelled)
} else {
Log.app.notice("已删除 \(identifiers.count) 个本地快照")
snapshotOutcome = Outcome(text: tr("已删除 \(identifiers.count) 个快照,腾出的空间稍后会反映在“可清除”里"), isError: false)
}
refreshSnapshots()
}
}
// MARK: 卷宗
func refreshVolumes() {
Task {
volumes = await Task.detached { MountedVolumes.list() }.value
}
}
func eject(_ volume: MountedVolume) {
guard !ejecting.contains(volume.id) else { return }
ejecting.insert(volume.id)
volumeOutcome = nil
Task {
defer { ejecting.remove(volume.id) }
do {
try await Task.detached { try NSWorkspace.shared.unmountAndEjectDevice(at: volume.url) }.value
volumeOutcome = Outcome(text: tr("已推出“\(volume.name)”"), isError: false)
} catch {
volumeOutcome = Outcome(text: tr("无法推出“\(volume.name)”:\(error.localizedDescription)"), isError: true)
}
refreshVolumes()
}
}
// MARK: 空间分析
func startScan() {
guard scanTask == nil else { return }
scanPhase = .scanning(SpaceScanProgress(items: 0, bytes: 0, current: ""))
trashOutcome = nil
scanTask = Task {
defer { scanTask = nil }
do {
// 用户点了按钮在等结果,不用 utility:那一档的磁盘读取会被系统限速
let result = try await Task.detached(priority: .userInitiated) {
try SpaceScanner.scan { progress in
Task { @MainActor in self.report(progress) }
}
}.value
scanPhase = .finished(result)
Log.app.notice("空间分析完成:\(result.scannedItems) 个条目")
} catch is CancellationError {
scanPhase = .idle
} catch {
scanPhase = .failed(error.localizedDescription)
}
}
}
private func report(_ progress: SpaceScanProgress) {
if case .scanning = scanPhase { scanPhase = .scanning(progress) }
}
func cancelScan() {
scanTask?.cancel()
}
/// 把扫描结果里的一个文件移到废纸篓,然后从结果里去掉它
func trash(_ file: SpaceScanResult.File) {
do {
try SpaceScanner.trash(file.url)
trashOutcome = Outcome(text: tr("已把“\(file.name)”移到废纸篓"), isError: false)
if case .finished(let result) = scanPhase {
scanPhase = .finished(SpaceScanResult(root: result.root, folders: result.folders,
largestFiles: result.largestFiles.filter { $0.id != file.id },
scannedItems: result.scannedItems, totalBytes: result.totalBytes, date: result.date))
}
Log.app.notice("空间分析:移到废纸篓 \(file.url.path, privacy: .private)")
} catch {
trashOutcome = Outcome(text: tr("无法移到废纸篓:\(error.localizedDescription)"), isError: true)
}
}
func reveal(_ url: URL) {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
private enum Keys {
static let lastVerified = "diskLastVerified"
static let lastVerifiedOK = "diskLastVerifiedOK"
}
}