Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions PureMac/Models/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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"
}
}

Expand All @@ -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"
}
}

Expand All @@ -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
}
}

Expand Down
115 changes: 115 additions & 0 deletions PureMac/Services/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ actor ScanEngine {
return scanBrewCache()
case .nodeCache:
return scanNodeCache()
case .dockerCache:
return scanDockerCache()
}
}

Expand Down Expand Up @@ -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 {
Comment on lines +502 to +504
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says the CLI check runs when docker is “on PATH”, but the implementation only checks two hard-coded locations (/usr/local/bin/docker, /opt/homebrew/bin/docker). If Docker is installed elsewhere (e.g., symlinked into a different directory), reclaimable space won’t be surfaced. Consider resolving via which docker//usr/bin/env docker (while still keeping the allowlist if desired).

Copilot uses AI. Check for mistakes.
items.append(CleanableItem(
name: "Reclaimable (run `docker system prune -af`)",
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This label is hard-coded English even though corresponding keys were added to Localizable.strings. Since this value is passed around as a String (not a LocalizedStringKey), it won’t be localized at render time—prefer String(localized: "Reclaimable (run …)") (or NSLocalizedString) here so the new translations are actually used.

Suggested change
name: "Reclaimable (run `docker system prune -af`)",
name: String(localized: "Reclaimable (run `docker system prune -af`)"),

Copilot uses AI. Check for mistakes.
path: dockerBin,
size: reclaimable,
category: .dockerCache,
isSelected: false,
lastModified: nil
))
Comment on lines +502 to +512
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “Reclaimable …” virtual item uses the docker executable path as its path. In the current cleaning flow, selected items are deleted by item.path (with a safety allowlist), so if this row ends up selected it will attempt to delete /usr/local/bin/docker or /opt/homebrew/bin/docker and then surface a “Skipped … unsafe path” error. This should be modeled as a non-deletable/virtual item (e.g., add an isVirtual/action field and have the UI/cleaner skip it), and avoid using an actual filesystem path that implies it can be removed.

Copilot uses AI. Check for mistakes.
Comment on lines +504 to +512
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isSelected: false on the reclaimable-space row won’t have the intended effect with the current selection model: AppState defaults to “selected unless in deselectedItems” and clears deselectedItems on every scan, without initializing it from CleanableItem.isSelected. As a result this new virtual row will appear selected by default (and may be included in “Select All” flows). Either wire CleanableItem.isSelected into deselectedItems initialization when storing scan results, or remove isSelected from scan outputs and manage selection consistently in one place.

Copilot uses AI. Check for mistakes.
Comment on lines +505 to +512
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The item name includes backticks (`docker system prune -af`). SwiftUI Text doesn’t render Markdown, so these backticks will show up literally in the UI. Consider removing the backticks (or presenting the command in a separate UI affordance like a copy button) to avoid confusing display text.

Copilot uses AI. Check for mistakes.
}
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()
Comment on lines +521 to +528
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc comment mismatch: this function says it parses output from docker system df --format json, but the code runs docker system df --format "{{.Reclaimable}}". Update the comment (or the command) so future maintainers don’t assume JSON parsing here.

Copilot uses AI. Check for mistakes.
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(
Expand Down
5 changes: 5 additions & 0 deletions PureMac/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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`)";
Comment on lines +127 to +130
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new Docker strings are only added for en and zh-Hans, but the repo also ships zh-Hant.lproj/Localizable.strings. Without adding equivalent keys there, users in that locale will see fallback/English for the new Docker UI strings. Consider adding the same three keys to zh-Hant (and any other supported locales) to keep localization coverage consistent.

Copilot uses AI. Check for mistakes.
5 changes: 5 additions & 0 deletions PureMac/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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`)";
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string includes backticks (`docker system prune -af`). If this is shown in a normal label, backticks will appear literally (no code formatting). Consider removing the backticks or adjusting the presentation so the command is shown in a clearer way.

Suggested change
"Reclaimable (run `docker system prune -af`)" = "可回收空间(运行 `docker system prune -af`)";
"Reclaimable (run docker system prune -af)" = "可回收空间(运行 docker system prune -af)";

Copilot uses AI. Check for mistakes.
Loading