From eec0bacfd132f8a3008b373990de7e29007090ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9B=D1=83=D0=BA=D1=8C=D1=8F=D0=BD=D0=BE=D0=B2=20=D0=9C?= =?UTF-8?q?=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=92=D0=BB=D0=B0=D0=B4=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Wed, 5 Aug 2026 22:23:46 +0300 Subject: [PATCH] xcode: list and delete simulator runtimes via simctl Surface downloaded runtimes in Xcode Junk, delete through `simctl runtime delete`, and auto-select outdated ones on Smart Scan. Gracefully skip when xcrun is missing; covered by unit tests. Co-authored-by: Cursor --- PureMac.xcodeproj/project.pbxproj | 12 +- .../Utilities/SimulatorRuntimeSupport.swift | 174 ++++++++++++ PureMac/Models/Models.swift | 19 +- PureMac/Services/CleaningEngine.swift | 46 ++++ PureMac/Services/ScanEngine.swift | 56 ++++ PureMac/Views/CategoryDetailView.swift | 14 +- PureMac/ar.lproj/Localizable.strings | 6 +- PureMac/en.lproj/Localizable.strings | 6 +- PureMac/es.lproj/Localizable.strings | 6 +- PureMac/ja.lproj/Localizable.strings | 6 +- PureMac/pl.lproj/Localizable.strings | 6 +- PureMac/pt-BR.lproj/Localizable.strings | 6 +- PureMac/zh-Hans.lproj/Localizable.strings | 6 +- PureMac/zh-Hant.lproj/Localizable.strings | 6 +- .../SimulatorRuntimeSupportTests.swift | 251 ++++++++++++++++++ README.md | 2 +- docs/README.ar.md | 2 +- docs/README.es.md | 2 +- docs/README.ja.md | 2 +- docs/README.zh-Hans.md | 2 +- docs/README.zh-Hant.md | 2 +- 21 files changed, 612 insertions(+), 20 deletions(-) create mode 100644 PureMac/Logic/Utilities/SimulatorRuntimeSupport.swift create mode 100644 PureMacTests/SimulatorRuntimeSupportTests.swift diff --git a/PureMac.xcodeproj/project.pbxproj b/PureMac.xcodeproj/project.pbxproj index b9a48ae..cce3f8f 100644 --- a/PureMac.xcodeproj/project.pbxproj +++ b/PureMac.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 535B23C0108C06475215B8E8 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB003A7751F05727DBFD1A5 /* AppTheme.swift */; }; 59B3096E2AE8ED0397B16798 /* AppLanguagePreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491771F923C93FD61A263893 /* AppLanguagePreferencesTests.swift */; }; 61D285E96DED76FDCD54570F /* PermissionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 069AAE87E3F9EDE5593AD8B3 /* PermissionSheet.swift */; }; + 6753946A301B2EB2FEF6998B /* SimulatorRuntimeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E66594478F53363BCB0F9 /* SimulatorRuntimeSupport.swift */; }; 68F9CE5ED4908F674D673FB2 /* BinaryThinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B24B648B82186FC3B6EF2B /* BinaryThinner.swift */; }; 6D449325D54B9850FC6C119F /* UniversalBinaryScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95EC878D25C244BF94D0094C /* UniversalBinaryScanner.swift */; }; 6E72022A661CBB41331A442C /* PermissionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9DCBD97CC88D77AE8DB01A4 /* PermissionCoordinator.swift */; }; @@ -59,6 +60,7 @@ EDEF28CAD23E936FBED1783B /* PureMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63581B70F9B10231964E3602 /* PureMacApp.swift */; }; F1F506C146E17D6CEC4A12F3 /* AppInfoFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23486A54A82EE3865B784D1C /* AppInfoFetcher.swift */; }; F2FA881A4B2209CC2A6342FB /* CLI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B2C5F66B6D812572BD4F05 /* CLI.swift */; }; + F4F02F966001A1504C2B4D33 /* SimulatorRuntimeSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D81BE7EA5DB17F4A0300AD /* SimulatorRuntimeSupportTests.swift */; }; FDA30FE6349CA01C8D859F04 /* OrphanListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71E8F62DB76D85F41F9A2E9 /* OrphanListView.swift */; }; /* End PBXBuildFile section */ @@ -126,8 +128,10 @@ C0899FE169D270DF95334905 /* LanguageFilesScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageFilesScanner.swift; sourceTree = ""; }; C5963EBBEE5BA6E147700821 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; C5ADDC35F31E40780FB5D017 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + C7D81BE7EA5DB17F4A0300AD /* SimulatorRuntimeSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorRuntimeSupportTests.swift; sourceTree = ""; }; CB94E06E145558123BB5BFB3 /* Conditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conditions.swift; sourceTree = ""; }; CED1B71D5F9510582E869CFD /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + D74E66594478F53363BCB0F9 /* SimulatorRuntimeSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorRuntimeSupport.swift; sourceTree = ""; }; DAA389C4F50505795FB31A51 /* SmartCareCoreArtwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartCareCoreArtwork.swift; sourceTree = ""; }; DB798781B99987F55D77E2E3 /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = ""; }; E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessManager.swift; sourceTree = ""; }; @@ -165,6 +169,7 @@ 491771F923C93FD61A263893 /* AppLanguagePreferencesTests.swift */, 752603285F7DBBA12BB3AA91 /* AppStateTests.swift */, 155CE1B8CDCCCA6F9FD028C1 /* LocalizationFilesTests.swift */, + C7D81BE7EA5DB17F4A0300AD /* SimulatorRuntimeSupportTests.swift */, ); path = PureMacTests; sourceTree = ""; @@ -294,6 +299,7 @@ 8CE522B406791BCE905BC55B /* FileSize.swift */, 5785762276FB5E3209C6DE4D /* Logger.swift */, 4AE790496C936424EF98320A /* OrphanSafetyPolicy.swift */, + D74E66594478F53363BCB0F9 /* SimulatorRuntimeSupport.swift */, ); path = Utilities; sourceTree = ""; @@ -488,6 +494,7 @@ D50EB059E741011EB2523731 /* ScanError.swift in Sources */, 27F449EDD1B082FE11FEC9DF /* SchedulerService.swift in Sources */, 93743B036059418560D876E6 /* SettingsView.swift in Sources */, + 6753946A301B2EB2FEF6998B /* SimulatorRuntimeSupport.swift in Sources */, B928E4369A3FC4369F014A29 /* SmartCareCoreArtwork.swift in Sources */, 47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */, DBFD73E3D9BDA74EC1CA56AB /* SystemMonitor.swift in Sources */, @@ -504,6 +511,7 @@ 59B3096E2AE8ED0397B16798 /* AppLanguagePreferencesTests.swift in Sources */, E2403D82F00D749C9AD4A6D4 /* AppStateTests.swift in Sources */, CFA64F54CDBF8A4765E0068D /* LocalizationFilesTests.swift in Sources */, + F4F02F966001A1504C2B4D33 /* SimulatorRuntimeSupportTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -598,7 +606,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PureMac/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 2.9.3; + MARKETING_VERSION = 2.9.4; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; @@ -716,7 +724,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PureMac/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 2.9.3; + MARKETING_VERSION = 2.9.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; diff --git a/PureMac/Logic/Utilities/SimulatorRuntimeSupport.swift b/PureMac/Logic/Utilities/SimulatorRuntimeSupport.swift new file mode 100644 index 0000000..cbf1cf3 --- /dev/null +++ b/PureMac/Logic/Utilities/SimulatorRuntimeSupport.swift @@ -0,0 +1,174 @@ +import Foundation + +/// Parsing + selection policy for `xcrun simctl runtime`. +/// Kept free of Process I/O so unit tests can cover the scan/clean contract +/// without talking to CoreSimulator. +enum SimulatorRuntimeSupport { + static let xcrunPath = "/usr/bin/xcrun" + static let missingXcrunMessage = + "Xcode command-line tools not found — install Xcode or run xcode-select --install" + + struct RuntimeInfo: Equatable { + let identifier: String + let displayName: String + let sizeBytes: Int64 + let deletable: Bool + let lastUsedAt: Date? + } + + /// True when `/usr/bin/xcrun` exists and is executable. Without it, scan + /// must return no runtime items and delete must fail with a clear message + /// — never crash or attempt `Process.run` on a missing binary. + static func isXcrunAvailable( + fileManager: FileManager = .default + ) -> Bool { + fileManager.isExecutableFile(atPath: xcrunPath) + } + + /// Parse stdout from `xcrun simctl runtime list -j`. + /// Returns nil when the payload is empty or not the expected dictionary. + static func parseRuntimeListJSON(_ jsonText: String) -> [RuntimeInfo]? { + let trimmed = jsonText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]] + else { + return nil + } + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime] + + var runtimes: [RuntimeInfo] = [] + for (key, info) in json { + let identifier = (info["identifier"] as? String) ?? key + let version = info["version"] as? String + let build = info["build"] as? String + let platform = platformName(from: info["platformIdentifier"] as? String) + let sizeBytes = (info["sizeBytes"] as? NSNumber)?.int64Value ?? 0 + let deletable = info["deletable"] as? Bool ?? true + let lastUsedAt: Date? = { + guard let raw = info["lastUsedAt"] as? String else { return nil } + return isoFormatter.date(from: raw) + }() + + let displayName: String = { + var parts: [String] = [] + if let platform { parts.append(platform) } + if let version { parts.append(version) } + let base = parts.isEmpty ? "Simulator Runtime" : parts.joined(separator: " ") + if let build { return "\(base) (\(build))" } + return base + }() + + runtimes.append(RuntimeInfo( + identifier: identifier, + displayName: displayName, + sizeBytes: sizeBytes, + deletable: deletable, + lastUsedAt: lastUsedAt + )) + } + + return runtimes.sorted { + if $0.sizeBytes != $1.sizeBytes { return $0.sizeBytes > $1.sizeBytes } + return $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending + } + } + + /// Parse `xcrun simctl runtime delete --outdated --dry-run` output. + static func parseOutdatedDryRun(_ text: String) -> Set { + guard !text.isEmpty else { return [] } + var ids = Set() + // "Would delete D: A3675326-E2E5-4CEA-B0C9-68930F3E3317 iOS (18.0 - 22A3351) (Ready)" + let pattern = #"Would delete\s+\S+:\s+([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(text.startIndex..= 2, + let idRange = Range(match.range(at: 1), in: text) else { return } + ids.insert(String(text[idRange])) + } + return ids + } + + /// Build cleanable rows: outdated + deletable start selected (Smart Scan + /// proposal); everything else is opt-in. Zero-size entries are dropped. + static func makeCleanableItems( + from runtimes: [RuntimeInfo], + outdatedIDs: Set, + outdatedLabel: String = String(localized: "Outdated") + ) -> [CleanableItem] { + runtimes.compactMap { runtime in + guard runtime.sizeBytes > 0 else { return nil } + let isOutdated = outdatedIDs.contains(runtime.identifier) + let name = isOutdated + ? "\(runtime.displayName) — \(outdatedLabel)" + : runtime.displayName + return CleanableItem( + name: name, + path: CleanableItem.simctlRuntimePathPrefix + runtime.identifier, + size: runtime.sizeBytes, + category: .xcodeJunk, + isSelected: isOutdated && runtime.deletable, + lastModified: runtime.lastUsedAt + ) + } + } + + static func platformName(from platformIdentifier: String?) -> String? { + guard let platformIdentifier else { return nil } + switch platformIdentifier { + case "com.apple.platform.iphonesimulator": return "iOS" + case "com.apple.platform.watchsimulator": return "watchOS" + case "com.apple.platform.appletvsimulator": return "tvOS" + case "com.apple.platform.xrsimulator": return "visionOS" + default: + if platformIdentifier.contains("iphone") { return "iOS" } + if platformIdentifier.contains("watch") { return "watchOS" } + if platformIdentifier.contains("tv") { return "tvOS" } + if platformIdentifier.contains("xr") || platformIdentifier.contains("vision") { + return "visionOS" + } + return nil + } + } + + /// Run `/usr/bin/xcrun` after an availability check. Returns status -1 and + /// `missingXcrunMessage` when the binary is absent so callers can skip + /// scan rows / surface a clean delete error. + static func runXcrun( + _ arguments: [String], + fileManager: FileManager = .default + ) -> (status: Int32, stdout: String, stderr: String) { + guard isXcrunAvailable(fileManager: fileManager) else { + Logger.shared.log("xcrun missing at \(xcrunPath)", level: .warning) + return (-1, "", missingXcrunMessage) + } + + let task = Process() + task.executableURL = URL(fileURLWithPath: xcrunPath) + task.arguments = arguments + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + task.standardOutput = stdoutPipe + task.standardError = stderrPipe + do { + try task.run() + } catch { + Logger.shared.log( + "xcrun \(arguments.joined(separator: " ")) failed to launch: \(error.localizedDescription)", + level: .warning + ) + return (-1, "", error.localizedDescription) + } + let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + return ( + task.terminationStatus, + String(data: outData, encoding: .utf8) ?? "", + String(data: errData, encoding: .utf8) ?? "" + ) + } +} diff --git a/PureMac/Models/Models.swift b/PureMac/Models/Models.swift index 4101fc9..4ccd4ac 100644 --- a/PureMac/Models/Models.swift +++ b/PureMac/Models/Models.swift @@ -49,7 +49,7 @@ enum CleaningCategory: String, CaseIterable, Identifiable, Codable { case .trashBins: return "Files in your Trash" case .largeFiles: return "Files over 100 MB or older than 1 year" case .purgeableSpace: return "Reserved by macOS - freed automatically when space is needed" - case .xcodeJunk: return "Derived data, archives, and simulators" + case .xcodeJunk: return "Derived data, archives, and simulator runtimes" case .brewCache: return "Homebrew download cache" case .nodeCache: return "npm, yarn, and pnpm download caches" case .dockerCache: return "Docker images, containers, and build cache" @@ -119,6 +119,11 @@ enum ScanState: Equatable { // MARK: - Cleanable Item struct CleanableItem: Identifiable, Hashable { + /// Path prefix for virtual items cleaned via `xcrun simctl runtime delete` + /// rather than a filesystem unlink. The UUID after the colon is the + /// runtime disk-image identifier from `simctl runtime list`. + static let simctlRuntimePathPrefix = "simctl-runtime:" + let id = UUID() let name: String let path: String @@ -131,6 +136,18 @@ struct CleanableItem: Identifiable, Hashable { ByteCountFormatter.string(fromByteCount: size, countStyle: .file) } + /// True for action-only items (Docker prune, simctl runtimes) that have + /// no real filesystem path to reveal in Finder. + var isActionItem: Bool { + path.isEmpty || path.hasPrefix(Self.simctlRuntimePathPrefix) + } + + var simctlRuntimeIdentifier: String? { + guard path.hasPrefix(Self.simctlRuntimePathPrefix) else { return nil } + let id = String(path.dropFirst(Self.simctlRuntimePathPrefix.count)) + return id.isEmpty ? nil : id + } + func hash(into hasher: inout Hasher) { hasher.combine(id) } diff --git a/PureMac/Services/CleaningEngine.swift b/PureMac/Services/CleaningEngine.swift index 18b46b1..773bec7 100644 --- a/PureMac/Services/CleaningEngine.swift +++ b/PureMac/Services/CleaningEngine.swift @@ -91,6 +91,22 @@ actor CleaningEngine { continue } + if let runtimeID = item.simctlRuntimeIdentifier { + // Simulator runtimes live in CoreSimulator's secure storage; + // deleting the mount path by hand leaves orphaned disk images. + // Always go through `xcrun simctl runtime delete`. + let deleteOutcome = await deleteSimulatorRuntime(identifier: runtimeID, reportedSize: item.size) + result.freedSpace += deleteOutcome.freed + if deleteOutcome.cleaned { + result.itemsCleaned += 1 + result.cleanedPaths.insert(item.path) + } + if let error = deleteOutcome.error { + result.errors.append(error) + } + continue + } + do { let itemURL = URL(fileURLWithPath: item.path) guard fileManager.fileExists(atPath: item.path) else { continue } @@ -352,6 +368,36 @@ actor CleaningEngine { return (0, nil) } + // MARK: - Simulator Runtimes + + /// Deletes one simulator runtime disk image via + /// `xcrun simctl runtime delete `. Uses the scan-time size as + /// the freed estimate — simctl does not print reclaimable bytes. + /// When `xcrun` is missing, returns a clear error and does not attempt + /// a Process launch (avoids a cryptic "No such file" failure). + func deleteSimulatorRuntime(identifier: String, reportedSize: Int64) async -> (freed: Int64, cleaned: Bool, error: String?) { + guard SimulatorRuntimeSupport.isXcrunAvailable() else { + return (0, false, SimulatorRuntimeSupport.missingXcrunMessage) + } + + let result = SimulatorRuntimeSupport.runXcrun(["simctl", "runtime", "delete", identifier]) + guard result.status == 0 else { + let detail = result.stderr.isEmpty ? result.stdout : result.stderr + if detail == SimulatorRuntimeSupport.missingXcrunMessage { + return (0, false, SimulatorRuntimeSupport.missingXcrunMessage) + } + if detail.localizedCaseInsensitiveContains("CoreSimulator") + || detail.localizedCaseInsensitiveContains("simdiskimaged") { + return (0, false, "Simulator services unavailable — open Xcode once, then try again") + } + Logger.shared.log("simctl runtime delete \(identifier) exited \(result.status): \(detail)", level: .error) + return (0, false, "Couldn't delete simulator runtime \(identifier)") + } + + Logger.shared.log("Deleted simulator runtime \(identifier) (~\(reportedSize) bytes)", level: .info) + return (reportedSize, true, nil) + } + /// Parse Docker's compact size format ("1.23GB", "456MB", "789kB", "0B"). private func parseDockerBytes(_ s: String) -> Int64? { let trimmed = s.trimmingCharacters(in: .whitespaces) diff --git a/PureMac/Services/ScanEngine.swift b/PureMac/Services/ScanEngine.swift index ea0c1c5..77d6a59 100644 --- a/PureMac/Services/ScanEngine.swift +++ b/PureMac/Services/ScanEngine.swift @@ -471,10 +471,66 @@ actor ScanEngine { } } + // Downloaded simulator runtimes (often multi-GB each). Listed via + // `xcrun simctl runtime list -j` and deleted via + // `xcrun simctl runtime delete ` — not a plain filesystem unlink, + // so paths use the simctl-runtime: prefix (see CleaningEngine). + // Outdated runtimes (Apple's own definition: a newer build exists for + // the same runtime identifier) start selected so Smart Scan proposes + // reclaiming them; everything else is opt-in. + items.append(contentsOf: scanSimulatorRuntimes()) + let totalSize = items.reduce(0) { $0 + $1.size } return CategoryResult(category: .xcodeJunk, items: items, totalSize: totalSize) } + /// Enumerate installed simulator runtime disk images via simctl. + /// When `xcrun` is missing (no Xcode / CLT), returns an empty list — + /// Xcode Junk still surfaces DerivedData and the rest of the filesystem + /// targets; runtime rows are simply omitted. + private func scanSimulatorRuntimes() -> [CleanableItem] { + guard SimulatorRuntimeSupport.isXcrunAvailable() else { + Logger.shared.log(SimulatorRuntimeSupport.missingXcrunMessage, level: .info) + return [] + } + guard let runtimes = listSimulatorRuntimes() else { return [] } + let outdatedIDs = outdatedSimulatorRuntimeIDs() + let items = SimulatorRuntimeSupport.makeCleanableItems( + from: runtimes, + outdatedIDs: outdatedIDs + ) + for item in items { + report(item.name) + } + return items + } + + /// Parse `xcrun simctl runtime list -j` into per-image rows. + private func listSimulatorRuntimes() -> [SimulatorRuntimeSupport.RuntimeInfo]? { + let result = SimulatorRuntimeSupport.runXcrun(["simctl", "runtime", "list", "-j"]) + guard result.status == 0, !result.stdout.isEmpty else { + if !result.stderr.isEmpty { + Logger.shared.log("simctl runtime list failed: \(result.stderr)", level: .warning) + } + return nil + } + guard let runtimes = SimulatorRuntimeSupport.parseRuntimeListJSON(result.stdout) else { + Logger.shared.log("simctl runtime list: could not parse JSON", level: .warning) + return nil + } + return runtimes + } + + /// IDs that `simctl runtime delete --outdated --dry-run` would remove. + private func outdatedSimulatorRuntimeIDs() -> Set { + let result = SimulatorRuntimeSupport.runXcrun([ + "simctl", "runtime", "delete", "--outdated", "--dry-run" + ]) + // Exit 0 with "No matching images…" is fine — just an empty set. + let combined = result.stdout + "\n" + result.stderr + return SimulatorRuntimeSupport.parseOutdatedDryRun(combined) + } + private func scanBrewCache() -> CategoryResult { var items: [CleanableItem] = [] diff --git a/PureMac/Views/CategoryDetailView.swift b/PureMac/Views/CategoryDetailView.swift index 1c7f824..2b5d9c3 100644 --- a/PureMac/Views/CategoryDetailView.swift +++ b/PureMac/Views/CategoryDetailView.swift @@ -263,12 +263,17 @@ private struct FileRowView: View { .lineLimit(1) .truncationMode(.middle) - if !item.path.isEmpty { + if !item.isActionItem { Text(item.path) .font(.caption) .foregroundStyle(.tertiary) .lineLimit(1) .truncationMode(.middle) + } else if item.simctlRuntimeIdentifier != nil { + Text(String(localized: "Simulator runtime")) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) } } @@ -276,7 +281,7 @@ private struct FileRowView: View { // Hover-revealed Finder shortcut; stays in the layout so the // trailing size never shifts sideways. - if !item.path.isEmpty { + if !item.isActionItem { Button { NSWorkspace.shared.selectFile(item.path, inFileViewerRootedAtPath: "") } label: { @@ -319,7 +324,7 @@ private struct FileRowView: View { .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: isSelected) .onHover { hovering = $0 } .contextMenu { - if !item.path.isEmpty { + if !item.isActionItem { Button("Reveal in Finder") { NSWorkspace.shared.selectFile(item.path, inFileViewerRootedAtPath: "") } @@ -328,6 +333,9 @@ private struct FileRowView: View { } private var fileIcon: String { + if item.simctlRuntimeIdentifier != nil { + return "iphone" + } let ext = (item.name as NSString).pathExtension.lowercased() switch ext { case "log", "txt": return "doc.text" diff --git a/PureMac/ar.lproj/Localizable.strings b/PureMac/ar.lproj/Localizable.strings index e3e95f1..ab67bd2 100644 --- a/PureMac/ar.lproj/Localizable.strings +++ b/PureMac/ar.lproj/Localizable.strings @@ -231,7 +231,7 @@ "Files in your Trash" = "الملفات الموجودة في سلة المهملات"; "Files over 100 MB or older than 1 year" = "الملفات التي يتجاوز حجمها 100 ميغابايت أو أقدم من سنة"; "APFS purgeable disk space" = "مساحة القرص القابلة للتحرير في APFS"; -"Derived data, archives, and simulators" = "البيانات المشتقة والأرشيفات والمحاكيات"; +"Derived data, archives, and simulator runtimes" = "البيانات المشتقة والأرشيفات والمحاكيات"; "Homebrew download cache" = "ذاكرة تنزيلات Homebrew المؤقتة"; "npm, yarn, and pnpm download caches" = "ذاكرات تنزيلات npm وyarn وpnpm المؤقتة"; "Docker images, containers, and build cache" = "صور Docker والحاويات وذاكرة البناء المؤقتة"; @@ -379,3 +379,7 @@ "Disk" = "القرص"; "Open PureMac" = "فتح PureMac"; "Quit PureMac" = "إنهاء PureMac"; + +/* Simulator runtimes */ +"Outdated" = "قديم"; +"Simulator runtime" = "وقت تشغيل المحاكي"; diff --git a/PureMac/en.lproj/Localizable.strings b/PureMac/en.lproj/Localizable.strings index 49a77b3..6c9ce4e 100644 --- a/PureMac/en.lproj/Localizable.strings +++ b/PureMac/en.lproj/Localizable.strings @@ -270,7 +270,7 @@ "Files in your Trash" = "Files in your Trash"; "Files over 100 MB or older than 1 year" = "Files over 100 MB or older than 1 year"; "APFS purgeable disk space" = "APFS purgeable disk space"; -"Derived data, archives, and simulators" = "Derived data, archives, and simulators"; +"Derived data, archives, and simulator runtimes" = "Derived data, archives, and simulator runtimes"; "Homebrew download cache" = "Homebrew download cache"; "npm, yarn, and pnpm download caches" = "npm, yarn, and pnpm download caches"; "Docker images, containers, and build cache" = "Docker images, containers, and build cache"; @@ -379,3 +379,7 @@ "Disk" = "Disk"; "Open PureMac" = "Open PureMac"; "Quit PureMac" = "Quit PureMac"; + +/* Simulator runtimes */ +"Outdated" = "Outdated"; +"Simulator runtime" = "Simulator runtime"; diff --git a/PureMac/es.lproj/Localizable.strings b/PureMac/es.lproj/Localizable.strings index 8844a69..4b2768e 100644 --- a/PureMac/es.lproj/Localizable.strings +++ b/PureMac/es.lproj/Localizable.strings @@ -231,7 +231,7 @@ "Files in your Trash" = "Archivos en la Papelera"; "Files over 100 MB or older than 1 year" = "Archivos de más de 100 MB o con más de 1 año"; "APFS purgeable disk space" = "Espacio liberable de disco APFS"; -"Derived data, archives, and simulators" = "Datos derivados, archivos y simuladores"; +"Derived data, archives, and simulator runtimes" = "Datos derivados, archivos y simuladores"; "Homebrew download cache" = "Caché de descargas de Homebrew"; "npm, yarn, and pnpm download caches" = "Cachés de descargas de npm, yarn y pnpm"; "Docker images, containers, and build cache" = "Imágenes, contenedores y caché de compilación de Docker"; @@ -379,3 +379,7 @@ "Disk" = "Disco"; "Open PureMac" = "Abrir PureMac"; "Quit PureMac" = "Salir de PureMac"; + +/* Simulator runtimes */ +"Outdated" = "Obsoleto"; +"Simulator runtime" = "Runtime del simulador"; diff --git a/PureMac/ja.lproj/Localizable.strings b/PureMac/ja.lproj/Localizable.strings index b06b045..0345dd6 100644 --- a/PureMac/ja.lproj/Localizable.strings +++ b/PureMac/ja.lproj/Localizable.strings @@ -231,7 +231,7 @@ "Files in your Trash" = "ゴミ箱内のファイル"; "Files over 100 MB or older than 1 year" = "100MB以上または1年以上前のファイル"; "APFS purgeable disk space" = "APFSの削除可能ディスク領域"; -"Derived data, archives, and simulators" = "派生データ、アーカイブ、シミュレータ"; +"Derived data, archives, and simulator runtimes" = "派生データ、アーカイブ、シミュレータ"; "Homebrew download cache" = "Homebrewのダウンロードキャッシュ"; "npm, yarn, and pnpm download caches" = "npm、yarn、pnpm のダウンロードキャッシュ"; "Docker images, containers, and build cache" = "Dockerイメージ、コンテナ、ビルドキャッシュ"; @@ -379,3 +379,7 @@ "Disk" = "ディスク"; "Open PureMac" = "PureMacを開く"; "Quit PureMac" = "PureMacを終了"; + +/* Simulator runtimes */ +"Outdated" = "古い"; +"Simulator runtime" = "シミュレータランタイム"; diff --git a/PureMac/pl.lproj/Localizable.strings b/PureMac/pl.lproj/Localizable.strings index 6950dcc..e6b7c59 100644 --- a/PureMac/pl.lproj/Localizable.strings +++ b/PureMac/pl.lproj/Localizable.strings @@ -270,7 +270,7 @@ "Files in your Trash" = "Pliki w koszu"; "Files over 100 MB or older than 1 year" = "Pliki powyżej 100 MB lub starsze niż 1 rok"; "APFS purgeable disk space" = "Miejsce na dysku do zwolnienia (APFS)"; -"Derived data, archives, and simulators" = "Dane pochodne, archiwa i symulatory"; +"Derived data, archives, and simulator runtimes" = "Dane pochodne, archiwa i symulatory"; "Homebrew download cache" = "Cache pobierania Homebrew"; "npm, yarn, and pnpm download caches" = "Cache pobierania npm, yarn i pnpm"; "Docker images, containers, and build cache" = "Obrazy Dockera, kontenery i cache budowania"; @@ -378,3 +378,7 @@ "Disk" = "Dysk"; "Open PureMac" = "Otwórz PureMac"; "Quit PureMac" = "Zamknij PureMac"; + +/* Simulator runtimes */ +"Outdated" = "Przestarzały"; +"Simulator runtime" = "Środowisko uruchomieniowe symulatora"; diff --git a/PureMac/pt-BR.lproj/Localizable.strings b/PureMac/pt-BR.lproj/Localizable.strings index e4ac453..4199b63 100644 --- a/PureMac/pt-BR.lproj/Localizable.strings +++ b/PureMac/pt-BR.lproj/Localizable.strings @@ -231,7 +231,7 @@ "Files in your Trash" = "Arquivos na sua Lixeira"; "Files over 100 MB or older than 1 year" = "Arquivos com mais de 100 MB ou mais de 1 ano"; "APFS purgeable disk space" = "Espaço de disco liberável do APFS"; -"Derived data, archives, and simulators" = "Derived data, arquivos e simuladores"; +"Derived data, archives, and simulator runtimes" = "Derived data, arquivos e simuladores"; "Homebrew download cache" = "Cache de downloads do Homebrew"; "npm, yarn, and pnpm download caches" = "Caches de download do npm, yarn e pnpm"; "Docker images, containers, and build cache" = "Imagens, containers e cache de build do Docker"; @@ -379,3 +379,7 @@ "Disk" = "Disco"; "Open PureMac" = "Abrir o PureMac"; "Quit PureMac" = "Encerrar o PureMac"; + +/* Simulator runtimes */ +"Outdated" = "Desatualizado"; +"Simulator runtime" = "Runtime do simulador"; diff --git a/PureMac/zh-Hans.lproj/Localizable.strings b/PureMac/zh-Hans.lproj/Localizable.strings index 4e71fc5..0837369 100644 --- a/PureMac/zh-Hans.lproj/Localizable.strings +++ b/PureMac/zh-Hans.lproj/Localizable.strings @@ -231,7 +231,7 @@ "Files in your Trash" = "废纸篓中的文件"; "Files over 100 MB or older than 1 year" = "大于 100 MB 或超过 1 年的文件"; "APFS purgeable disk space" = "APFS 可清理磁盘空间"; -"Derived data, archives, and simulators" = "派生数据、归档和模拟器"; +"Derived data, archives, and simulator runtimes" = "派生数据、归档和模拟器"; "Homebrew download cache" = "Homebrew 下载缓存"; "npm, yarn, and pnpm download caches" = "npm、yarn 和 pnpm 下载缓存"; "Docker images, containers, and build cache" = "Docker 镜像、容器和构建缓存"; @@ -379,3 +379,7 @@ "Disk" = "磁盘"; "Open PureMac" = "打开 PureMac"; "Quit PureMac" = "退出 PureMac"; + +/* Simulator runtimes */ +"Outdated" = "过时"; +"Simulator runtime" = "模拟器运行时"; diff --git a/PureMac/zh-Hant.lproj/Localizable.strings b/PureMac/zh-Hant.lproj/Localizable.strings index df3edf2..cb3c5af 100644 --- a/PureMac/zh-Hant.lproj/Localizable.strings +++ b/PureMac/zh-Hant.lproj/Localizable.strings @@ -231,7 +231,7 @@ "Files in your Trash" = "垃圾桶中的檔案"; "Files over 100 MB or older than 1 year" = "超過 100 MB 或一年以上的檔案"; "APFS purgeable disk space" = "APFS 可清除的磁碟空間"; -"Derived data, archives, and simulators" = "衍生資料、封存與模擬器"; +"Derived data, archives, and simulator runtimes" = "衍生資料、封存與模擬器"; "Homebrew download cache" = "Homebrew 下載快取"; "npm, yarn, and pnpm download caches" = "npm、yarn 與 pnpm 下載快取"; "Docker images, containers, and build cache" = "Docker 映像檔、容器與建置快取"; @@ -379,3 +379,7 @@ "Disk" = "磁碟"; "Open PureMac" = "開啟 PureMac"; "Quit PureMac" = "結束 PureMac"; + +/* Simulator runtimes */ +"Outdated" = "過時"; +"Simulator runtime" = "模擬器執行環境"; diff --git a/PureMacTests/SimulatorRuntimeSupportTests.swift b/PureMacTests/SimulatorRuntimeSupportTests.swift new file mode 100644 index 0000000..2de1963 --- /dev/null +++ b/PureMacTests/SimulatorRuntimeSupportTests.swift @@ -0,0 +1,251 @@ +import XCTest +@testable import PureMac + +final class SimulatorRuntimeSupportTests: XCTestCase { + + // MARK: - JSON list parsing + + func testParseRuntimeListJSONExtractsFieldsAndSortsBySizeDescending() throws { + let json = """ + { + "small-id": { + "identifier": "11111111-1111-1111-1111-111111111111", + "version": "18.5", + "build": "22F77", + "platformIdentifier": "com.apple.platform.iphonesimulator", + "sizeBytes": 1000, + "deletable": true, + "lastUsedAt": "2026-08-05T18:46:32Z" + }, + "large-id": { + "identifier": "22222222-2222-2222-2222-222222222222", + "version": "26.5", + "build": "23F77", + "platformIdentifier": "com.apple.platform.iphonesimulator", + "sizeBytes": 9000, + "deletable": false + } + } + """ + + let runtimes = try XCTUnwrap(SimulatorRuntimeSupport.parseRuntimeListJSON(json)) + XCTAssertEqual(runtimes.count, 2) + XCTAssertEqual(runtimes[0].identifier, "22222222-2222-2222-2222-222222222222") + XCTAssertEqual(runtimes[0].displayName, "iOS 26.5 (23F77)") + XCTAssertEqual(runtimes[0].sizeBytes, 9000) + XCTAssertFalse(runtimes[0].deletable) + XCTAssertNil(runtimes[0].lastUsedAt) + + XCTAssertEqual(runtimes[1].identifier, "11111111-1111-1111-1111-111111111111") + XCTAssertEqual(runtimes[1].displayName, "iOS 18.5 (22F77)") + XCTAssertEqual(runtimes[1].sizeBytes, 1000) + XCTAssertTrue(runtimes[1].deletable) + XCTAssertNotNil(runtimes[1].lastUsedAt) + } + + func testParseRuntimeListJSONReturnsNilForEmptyOrInvalidPayload() { + XCTAssertNil(SimulatorRuntimeSupport.parseRuntimeListJSON("")) + XCTAssertNil(SimulatorRuntimeSupport.parseRuntimeListJSON(" ")) + XCTAssertNil(SimulatorRuntimeSupport.parseRuntimeListJSON("not-json")) + XCTAssertNil(SimulatorRuntimeSupport.parseRuntimeListJSON("[]")) + XCTAssertNil(SimulatorRuntimeSupport.parseRuntimeListJSON("\"string\"")) + } + + func testParseRuntimeListJSONFallsBackToDictionaryKeyAsIdentifier() throws { + let json = """ + { + "ABCDEF01-2345-6789-ABCD-EF0123456789": { + "version": "2.0", + "build": "22N318", + "platformIdentifier": "com.apple.platform.xrsimulator", + "sizeBytes": 50 + } + } + """ + let runtimes = try XCTUnwrap(SimulatorRuntimeSupport.parseRuntimeListJSON(json)) + XCTAssertEqual(runtimes.count, 1) + XCTAssertEqual(runtimes[0].identifier, "ABCDEF01-2345-6789-ABCD-EF0123456789") + XCTAssertEqual(runtimes[0].displayName, "visionOS 2.0 (22N318)") + XCTAssertTrue(runtimes[0].deletable) // default when key absent + } + + func testPlatformNameMapping() { + XCTAssertEqual( + SimulatorRuntimeSupport.platformName(from: "com.apple.platform.iphonesimulator"), + "iOS" + ) + XCTAssertEqual( + SimulatorRuntimeSupport.platformName(from: "com.apple.platform.watchsimulator"), + "watchOS" + ) + XCTAssertEqual( + SimulatorRuntimeSupport.platformName(from: "com.apple.platform.appletvsimulator"), + "tvOS" + ) + XCTAssertEqual( + SimulatorRuntimeSupport.platformName(from: "com.apple.platform.xrsimulator"), + "visionOS" + ) + XCTAssertNil(SimulatorRuntimeSupport.platformName(from: nil)) + XCTAssertNil(SimulatorRuntimeSupport.platformName(from: "com.apple.platform.unknown")) + } + + // MARK: - Outdated dry-run parsing + + func testParseOutdatedDryRunExtractsUUIDs() { + let output = """ + Would delete D: A3675326-E2E5-4CEA-B0C9-68930F3E3317 xrOS (2.0 - 22N318) (Ready) + Would delete B: D5E22916-F2E6-48C4-8FD3-6C8F07FC0646 iOS (15.4 - 19E240) (Ready) + No other noise + """ + let ids = SimulatorRuntimeSupport.parseOutdatedDryRun(output) + XCTAssertEqual(ids, [ + "A3675326-E2E5-4CEA-B0C9-68930F3E3317", + "D5E22916-F2E6-48C4-8FD3-6C8F07FC0646" + ]) + } + + func testParseOutdatedDryRunHandlesNoMatches() { + XCTAssertTrue(SimulatorRuntimeSupport.parseOutdatedDryRun("").isEmpty) + XCTAssertTrue( + SimulatorRuntimeSupport.parseOutdatedDryRun("No matching images found to delete").isEmpty + ) + } + + // MARK: - Selection policy (Smart Scan) + + func testMakeCleanableItemsAutoSelectsOnlyOutdatedAndDeletable() { + let runtimes = [ + SimulatorRuntimeSupport.RuntimeInfo( + identifier: "outdated-deletable", + displayName: "iOS 17.0 (21A)", + sizeBytes: 100, + deletable: true, + lastUsedAt: nil + ), + SimulatorRuntimeSupport.RuntimeInfo( + identifier: "outdated-locked", + displayName: "iOS 16.0 (20A)", + sizeBytes: 200, + deletable: false, + lastUsedAt: nil + ), + SimulatorRuntimeSupport.RuntimeInfo( + identifier: "current", + displayName: "iOS 26.5 (23F77)", + sizeBytes: 300, + deletable: true, + lastUsedAt: nil + ), + SimulatorRuntimeSupport.RuntimeInfo( + identifier: "zero", + displayName: "iOS 0", + sizeBytes: 0, + deletable: true, + lastUsedAt: nil + ) + ] + + let items = SimulatorRuntimeSupport.makeCleanableItems( + from: runtimes, + outdatedIDs: ["outdated-deletable", "outdated-locked"], + outdatedLabel: "Outdated" + ) + + XCTAssertEqual(items.count, 3) // zero-size dropped + + let byID = Dictionary(uniqueKeysWithValues: items.map { + ($0.simctlRuntimeIdentifier ?? "", $0) + }) + + XCTAssertEqual(byID["outdated-deletable"]?.name, "iOS 17.0 (21A) — Outdated") + XCTAssertEqual(byID["outdated-deletable"]?.isSelected, true) + XCTAssertEqual(byID["outdated-deletable"]?.category, .xcodeJunk) + XCTAssertTrue(byID["outdated-deletable"]?.isActionItem == true) + + XCTAssertEqual(byID["outdated-locked"]?.isSelected, false) + XCTAssertTrue(byID["outdated-locked"]?.name.contains("Outdated") == true) + + XCTAssertEqual(byID["current"]?.isSelected, false) + XCTAssertEqual(byID["current"]?.name, "iOS 26.5 (23F77)") + XCTAssertNil(byID["zero"]) + } + + // MARK: - CleanableItem path helpers + + func testSimctlRuntimePathHelpers() { + let item = CleanableItem( + name: "iOS 18.5", + path: CleanableItem.simctlRuntimePathPrefix + "F666EEDE-1029-48D9-BCAB-CF0E12B1D632", + size: 1, + category: .xcodeJunk, + isSelected: false, + lastModified: nil + ) + XCTAssertTrue(item.isActionItem) + XCTAssertEqual(item.simctlRuntimeIdentifier, "F666EEDE-1029-48D9-BCAB-CF0E12B1D632") + + let fileItem = CleanableItem( + name: "DerivedData", + path: "/Users/test/Library/Developer/Xcode/DerivedData", + size: 1, + category: .xcodeJunk, + isSelected: true, + lastModified: nil + ) + XCTAssertFalse(fileItem.isActionItem) + XCTAssertNil(fileItem.simctlRuntimeIdentifier) + + let dockerPrune = CleanableItem( + name: "Docker prune", + path: "", + size: 1, + category: .dockerCache, + isSelected: false, + lastModified: nil + ) + XCTAssertTrue(dockerPrune.isActionItem) + XCTAssertNil(dockerPrune.simctlRuntimeIdentifier) + } + + // MARK: - Missing xcrun + + func testIsXcrunAvailableFalseWhenBinaryMissing() { + let fm = MissingXcrunFileManager() + XCTAssertFalse(SimulatorRuntimeSupport.isXcrunAvailable(fileManager: fm)) + } + + func testRunXcrunReturnsFriendlyErrorWhenBinaryMissing() { + let fm = MissingXcrunFileManager() + let result = SimulatorRuntimeSupport.runXcrun( + ["simctl", "runtime", "list", "-j"], + fileManager: fm + ) + XCTAssertEqual(result.status, -1) + XCTAssertTrue(result.stdout.isEmpty) + XCTAssertEqual(result.stderr, SimulatorRuntimeSupport.missingXcrunMessage) + } + + func testDeleteSimulatorRuntimeReportsMissingXcrun() async { + // CleaningEngine.deleteSimulatorRuntime checks isXcrunAvailable() against + // the real filesystem. When xcrun is present (CI / developer machines), + // we still verify the support-layer contract that delete callers rely on. + let fm = MissingXcrunFileManager() + XCTAssertFalse(SimulatorRuntimeSupport.isXcrunAvailable(fileManager: fm)) + + let result = SimulatorRuntimeSupport.runXcrun( + ["simctl", "runtime", "delete", "deadbeef"], + fileManager: fm + ) + XCTAssertEqual(result.status, -1) + XCTAssertEqual(result.stderr, SimulatorRuntimeSupport.missingXcrunMessage) + } +} + +/// FileManager stub that reports `/usr/bin/xcrun` as missing/non-executable. +private final class MissingXcrunFileManager: FileManager, @unchecked Sendable { + override func isExecutableFile(atPath path: String) -> Bool { + if path == SimulatorRuntimeSupport.xcrunPath { return false } + return super.isExecutableFile(atPath: path) + } +} diff --git a/README.md b/README.md index cec063f..1514bbd 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Smart Scan runs every category in parallel. Each category is its own deliberate - **Mail Files** - downloaded mail attachments - **Trash Bins** - empties all bins, including external volumes - **Large & Old Files** - >100 MB or older than 1 year (never auto-selected) -- **Xcode Junk** - DerivedData, Archives, simulator caches +- **Xcode Junk** - DerivedData, Archives, simulator caches, and downloaded simulator runtimes (`simctl runtime`; outdated runtimes are auto-selected on Smart Scan) - **Brew Cache** - respects custom `HOMEBREW_CACHE` - **Node Cache** - npm, yarn classic, pnpm content-addressable store - **Docker Cache** - images, containers, build cache diff --git a/docs/README.ar.md b/docs/README.ar.md index 8c7abff..a36a131 100644 --- a/docs/README.ar.md +++ b/docs/README.ar.md @@ -89,7 +89,7 @@ open build/Build/Products/Release/PureMac.app - **سلة المهملات** — تفريغ كل سلال المهملات - **الملفات الكبيرة والقديمة** — ملفات يزيد حجمها عن 100 ميغابايت أو أقدم من سنة - **المساحة القابلة للإزالة** — اكتشاف المساحة القابلة للإزالة في APFS -- **ملفات Xcode غير المرغوبة** — DerivedData والأرشيفات وذاكرة التخزين المؤقت للمحاكيات +- **ملفات Xcode غير المرغوبة** — DerivedData والأرشيفات وذاكرة التخزين المؤقت للمحاكيات وruntimes المحاكي المحمّلة (outdated تُحدد تلقائياً في Smart Scan) - **ذاكرة التخزين المؤقت لـ Brew** — ذاكرة تنزيلات Homebrew (مع دعم HOMEBREW_CACHE مخصص) - **التنظيف المجدوَل** — فحص تلقائي وفق فترات قابلة للتخصيص diff --git a/docs/README.es.md b/docs/README.es.md index 1d58ec0..162b06b 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -88,7 +88,7 @@ open build/Build/Products/Release/PureMac.app - **Papeleras** — vacía todas las papeleras - **Archivos grandes y antiguos** — archivos de más de 100 MB o con más de 1 año - **Espacio purgable** — detección de espacio purgable APFS -- **Basura de Xcode** — DerivedData, Archives, cachés de simuladores +- **Basura de Xcode** — DerivedData, Archives, cachés de simuladores y runtimes descargados (outdated se auto-seleccionan en Smart Scan) - **Caché de Brew** — caché de descargas de Homebrew (detecta HOMEBREW_CACHE personalizado) - **Limpieza programada** — análisis automático en intervalos configurables diff --git a/docs/README.ja.md b/docs/README.ja.md index e3be115..79c7c94 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -88,7 +88,7 @@ open build/Build/Products/Release/PureMac.app - **ゴミ箱** — すべてのゴミ箱を空に - **大容量・古いファイル** — 100 MB を超える、または 1 年以上経過したファイル - **消去可能領域** — APFS の消去可能ディスク領域を検出 -- **Xcode ジャンク** — DerivedData、Archives、シミュレータキャッシュ +- **Xcode ジャンク** — DerivedData、Archives、シミュレータキャッシュ、ダウンロード済み simulator runtimes(outdated は Smart Scan で自動選択) - **Brew キャッシュ** — Homebrew ダウンロードキャッシュ(カスタム HOMEBREW_CACHE も検出) - **スケジュールクリーニング** — 設定可能な間隔での自動スキャン diff --git a/docs/README.zh-Hans.md b/docs/README.zh-Hans.md index 413363e..8459fd4 100644 --- a/docs/README.zh-Hans.md +++ b/docs/README.zh-Hans.md @@ -88,7 +88,7 @@ open build/Build/Products/Release/PureMac.app - **废纸篓** — 清空所有废纸篓 - **大文件与旧文件** — 超过 100 MB 或超过 1 年的文件 - **可清除空间** — 检测 APFS 可清除磁盘空间 -- **Xcode 垃圾** — DerivedData、Archives、模拟器缓存 +- **Xcode 垃圾** — DerivedData、Archives、模拟器缓存与已下载的 simulator runtimes(outdated 在 Smart Scan 中自动勾选) - **Brew 缓存** — Homebrew 下载缓存(可识别自定义 HOMEBREW_CACHE) - **定时清理** — 按可配置的间隔自动扫描 diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md index 0a91ac7..327faf4 100644 --- a/docs/README.zh-Hant.md +++ b/docs/README.zh-Hant.md @@ -88,7 +88,7 @@ open build/Build/Products/Release/PureMac.app - **垃圾桶** — 清空所有垃圾桶 - **大型與舊檔案** — 超過 100 MB 或超過 1 年的檔案 - **可清除空間** — 偵測 APFS 可清除磁碟空間 -- **Xcode 垃圾** — DerivedData、Archives、模擬器快取 +- **Xcode 垃圾** — DerivedData、Archives、模擬器快取與下載的 simulator runtimes(outdated 於 Smart Scan 自動勾選) - **Brew 快取** — Homebrew 下載快取(可辨識自訂的 HOMEBREW_CACHE) - **排程清理** — 以可設定的間隔自動掃描