diff --git a/PureMac/Models/Models.swift b/PureMac/Models/Models.swift index c5bb975..de85333 100644 --- a/PureMac/Models/Models.swift +++ b/PureMac/Models/Models.swift @@ -13,6 +13,7 @@ enum CleaningCategory: String, CaseIterable, Identifiable, Codable { case xcodeJunk = "Xcode Junk" case brewCache = "Brew Cache" case nodeCache = "Node Cache" + case dockerCache = "Docker Cache" var id: String { rawValue } @@ -28,6 +29,7 @@ enum CleaningCategory: String, CaseIterable, Identifiable, Codable { case .xcodeJunk: return "hammer.fill" case .brewCache: return "mug.fill" case .nodeCache: return "leaf.fill" + case .dockerCache: return "shippingbox.fill" } } @@ -43,6 +45,7 @@ enum CleaningCategory: String, CaseIterable, Identifiable, Codable { case .xcodeJunk: return "Derived data, archives, and simulators" case .brewCache: return "Homebrew download cache" case .nodeCache: return "npm, yarn, and pnpm download caches" + case .dockerCache: return "Docker images, containers, and build cache" } } @@ -58,6 +61,7 @@ enum CleaningCategory: String, CaseIterable, Identifiable, Codable { case .xcodeJunk: return .cyan case .brewCache: return .mint case .nodeCache: return .pink + case .dockerCache: return .indigo } } diff --git a/PureMac/Services/ScanEngine.swift b/PureMac/Services/ScanEngine.swift index 5acb306..903551b 100644 --- a/PureMac/Services/ScanEngine.swift +++ b/PureMac/Services/ScanEngine.swift @@ -33,6 +33,8 @@ actor ScanEngine { return scanBrewCache() case .nodeCache: return scanNodeCache() + case .dockerCache: + return scanDockerCache() } } @@ -456,6 +458,119 @@ actor ScanEngine { return String(data: data, encoding: .utf8) } + private func scanDockerCache() -> CategoryResult { + var items: [CleanableItem] = [] + + // Docker Desktop on macOS keeps its VM disk + caches under + // ~/Library/Containers/com.docker.docker/Data. The caches we + // surface here are *recoverable* — they will be regenerated by + // Docker on next pull/build, and `docker system prune` is the + // CLI equivalent of cleaning them. + let dockerDataDirs = [ + // Build cache (BuildKit), per-user + "\(home)/Library/Containers/com.docker.docker/Data/cache", + // Vmnetd / vpnkit log + telemetry caches + "\(home)/Library/Containers/com.docker.docker/Data/log", + "\(home)/Library/Containers/com.docker.docker/Data/tmp", + // Group containers caches (Docker Desktop helper apps) + "\(home)/Library/Group Containers/group.com.docker/Caches", + // CLI plugin download cache + "\(home)/.docker/cli-plugins/.cache", + // Buildx / containerd inline cache + "\(home)/.docker/buildx/cache", + ] + + for path in dockerDataDirs { + guard fileManager.fileExists(atPath: path) else { continue } + let size = directorySize(path: path) + guard size > 0 else { continue } + items.append(CleanableItem( + name: URL(fileURLWithPath: path).lastPathComponent, + path: path, + size: size, + category: .dockerCache, + isSelected: true, + lastModified: nil + )) + } + + // If the `docker` CLI is available, surface reclaimable space + // reported by `docker system df` as a single virtual entry. + // We don't try to delete it directly — the user runs + // `docker system prune` themselves, which is the safe path. + // We just show how much they can recover. + let dockerBinPaths = ["/usr/local/bin/docker", "/opt/homebrew/bin/docker"] + for dockerBin in dockerBinPaths where fileManager.fileExists(atPath: dockerBin) { + if let reclaimable = reclaimableDockerSpace(dockerBin: dockerBin), reclaimable > 0 { + items.append(CleanableItem( + name: "Reclaimable (run `docker system prune -af`)", + path: dockerBin, + size: reclaimable, + category: .dockerCache, + isSelected: false, + lastModified: nil + )) + } + break + } + + let totalSize = items.reduce(0) { $0 + $1.size } + return CategoryResult(category: .dockerCache, items: items, totalSize: totalSize) + } + + /// Sum the reclaimable bytes reported by `docker system df --format json`. + /// Returns nil when Docker isn't running or the command fails — callers + /// should treat that as "no reclaimable info available", not as an error. + private func reclaimableDockerSpace(dockerBin: String) -> Int64? { + let task = Process() + task.executableURL = URL(fileURLWithPath: dockerBin) + task.arguments = ["system", "df", "--format", "{{.Reclaimable}}"] + let stdoutPipe = Pipe() + task.standardOutput = stdoutPipe + task.standardError = Pipe() + do { + try task.run() + task.waitUntilExit() + } catch { + Logger.shared.log("docker system df failed: \(error.localizedDescription)", level: .warning) + return nil + } + guard task.terminationStatus == 0 else { return nil } + let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return nil } + // Each line looks like e.g. "1.234GB (45%)" — parse the leading number. + var total: Int64 = 0 + for line in output.split(separator: "\n") { + let raw = line.split(separator: " ").first.map(String.init) ?? "" + if let bytes = parseHumanBytes(raw) { + total += bytes + } + } + return total + } + + /// Parse Docker's compact size format ("1.23GB", "456MB", "789kB") into bytes. + private func parseHumanBytes(_ s: String) -> Int64? { + let trimmed = s.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let units: [(String, Double)] = [ + ("TB", 1_000_000_000_000), + ("GB", 1_000_000_000), + ("MB", 1_000_000), + ("kB", 1_000), + ("KB", 1_000), + ("B", 1), + ] + for (suffix, multiplier) in units { + if trimmed.hasSuffix(suffix) { + let numberPart = String(trimmed.dropLast(suffix.count)) + if let value = Double(numberPart) { + return Int64(value * multiplier) + } + } + } + return nil + } // MARK: - Helpers private func scanDirectory( diff --git a/PureMac/en.lproj/Localizable.strings b/PureMac/en.lproj/Localizable.strings index c9e7a02..37ee79e 100644 --- a/PureMac/en.lproj/Localizable.strings +++ b/PureMac/en.lproj/Localizable.strings @@ -123,3 +123,8 @@ "npm cache" = "npm cache"; "yarn classic cache" = "yarn classic cache"; "pnpm content-addressable store" = "pnpm content-addressable store"; + +/* Docker Cache */ +"Docker Cache" = "Docker Cache"; +"Docker images, containers, and build cache" = "Docker images, containers, and build cache"; +"Reclaimable (run `docker system prune -af`)" = "Reclaimable (run `docker system prune -af`)"; diff --git a/PureMac/zh-Hans.lproj/Localizable.strings b/PureMac/zh-Hans.lproj/Localizable.strings index 14d2e90..c9c030b 100644 --- a/PureMac/zh-Hans.lproj/Localizable.strings +++ b/PureMac/zh-Hans.lproj/Localizable.strings @@ -123,3 +123,8 @@ "npm cache" = "npm 缓存"; "yarn classic cache" = "yarn 经典缓存"; "pnpm content-addressable store" = "pnpm 内容寻址存储"; + +/* Docker Cache */ +"Docker Cache" = "Docker 缓存"; +"Docker images, containers, and build cache" = "Docker 镜像、容器和构建缓存"; +"Reclaimable (run `docker system prune -af`)" = "可回收空间(运行 `docker system prune -af`)";