Skip to content
Open
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
12 changes: 10 additions & 2 deletions PureMac.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */

Expand Down Expand Up @@ -126,8 +128,10 @@
C0899FE169D270DF95334905 /* LanguageFilesScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageFilesScanner.swift; sourceTree = "<group>"; };
C5963EBBEE5BA6E147700821 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
C5ADDC35F31E40780FB5D017 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
C7D81BE7EA5DB17F4A0300AD /* SimulatorRuntimeSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorRuntimeSupportTests.swift; sourceTree = "<group>"; };
CB94E06E145558123BB5BFB3 /* Conditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conditions.swift; sourceTree = "<group>"; };
CED1B71D5F9510582E869CFD /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
D74E66594478F53363BCB0F9 /* SimulatorRuntimeSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorRuntimeSupport.swift; sourceTree = "<group>"; };
DAA389C4F50505795FB31A51 /* SmartCareCoreArtwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartCareCoreArtwork.swift; sourceTree = "<group>"; };
DB798781B99987F55D77E2E3 /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = "<group>"; };
E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessManager.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -165,6 +169,7 @@
491771F923C93FD61A263893 /* AppLanguagePreferencesTests.swift */,
752603285F7DBBA12BB3AA91 /* AppStateTests.swift */,
155CE1B8CDCCCA6F9FD028C1 /* LocalizationFilesTests.swift */,
C7D81BE7EA5DB17F4A0300AD /* SimulatorRuntimeSupportTests.swift */,
);
path = PureMacTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -294,6 +299,7 @@
8CE522B406791BCE905BC55B /* FileSize.swift */,
5785762276FB5E3209C6DE4D /* Logger.swift */,
4AE790496C936424EF98320A /* OrphanSafetyPolicy.swift */,
D74E66594478F53363BCB0F9 /* SimulatorRuntimeSupport.swift */,
);
path = Utilities;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand All @@ -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;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
174 changes: 174 additions & 0 deletions PureMac/Logic/Utilities/SimulatorRuntimeSupport.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
guard !text.isEmpty else { return [] }
var ids = Set<String>()
// "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..<text.endIndex, in: text)
regex.enumerateMatches(in: text, range: range) { match, _, _ in
guard let match, match.numberOfRanges >= 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<String>,
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) ?? ""
)
}
}
19 changes: 18 additions & 1 deletion PureMac/Models/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
46 changes: 46 additions & 0 deletions PureMac/Services/CleaningEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -352,6 +368,36 @@ actor CleaningEngine {
return (0, nil)
}

// MARK: - Simulator Runtimes

/// Deletes one simulator runtime disk image via
/// `xcrun simctl runtime delete <identifier>`. 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)
Expand Down
Loading