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
8 changes: 7 additions & 1 deletion DevCleaner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
71FD37D9206AD72C0042812D /* DeviceSupportFileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71FD37D8206AD72C0042812D /* DeviceSupportFileEntry.swift */; };
71FD37DD206AD76D0042812D /* DerivedDataFileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71FD37DC206AD76D0042812D /* DerivedDataFileEntry.swift */; };
A84F5D2C631C44F6A400B49D /* device_identifiers.csv in Resources */ = {isa = PBXBuildFile; fileRef = BAF8F9B5BB114D2196AD27A1 /* device_identifiers.csv */; };
E97697AC2FB775040086DF05 /* SimulatorFileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = E97697AB2FB775040086DF05 /* SimulatorFileEntry.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand Down Expand Up @@ -124,6 +125,7 @@
71FD37D8206AD72C0042812D /* DeviceSupportFileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSupportFileEntry.swift; sourceTree = "<group>"; };
71FD37DC206AD76D0042812D /* DerivedDataFileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DerivedDataFileEntry.swift; sourceTree = "<group>"; };
BAF8F9B5BB114D2196AD27A1 /* device_identifiers.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = device_identifiers.csv; sourceTree = "<group>"; };
E97697AB2FB775040086DF05 /* SimulatorFileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorFileEntry.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -345,6 +347,7 @@
717A3EC828FC3DDC002A0C8F /* DocumentationCacheFileEntry.swift */,
714AB65E22DA87FA00A7FBB7 /* DeviceLogsFileEntry.swift */,
717A9F1A22DBAF9300682A0A /* OldDocumentationFileEntry.swift */,
E97697AB2FB775040086DF05 /* SimulatorFileEntry.swift */,
);
path = Entries;
sourceTree = "<group>";
Expand Down Expand Up @@ -378,7 +381,7 @@
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 2610;
LastUpgradeCheck = 2650;
ORGANIZATIONNAME = "One Minute Games";
TargetAttributes = {
7110B48D2030F17B00EDBFA3 = {
Expand Down Expand Up @@ -490,6 +493,7 @@
71FD37D7206AD6BE0042812D /* ArchiveFileEntry.swift in Sources */,
71C8ACE020A62B8000E7A13B /* ScanReminders.swift in Sources */,
7140B617231A81DC005768A7 /* ReviewRequests.swift in Sources */,
E97697AC2FB775040086DF05 /* SimulatorFileEntry.swift in Sources */,
7100BEE222F6E771002E82B2 /* Signposts.swift in Sources */,
716667D0221A26BA004D4FC4 /* FileManager+HomeFolder.swift in Sources */,
718D4ABE23140ABA001E9382 /* Files.swift in Sources */,
Expand Down Expand Up @@ -577,6 +581,7 @@
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
Expand Down Expand Up @@ -633,6 +638,7 @@
MACOSX_DEPLOYMENT_TARGET = 10.12;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
Expand Down
2 changes: 2 additions & 0 deletions DevCleaner/Base/CmdLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ public final class CmdLine {
return .logs
case "old-documentation":
return .oldDocumentation
case "simulators":
return .simulators
default:
throw Error.wrongOption(option: trimmedOption)
}
Expand Down
36 changes: 36 additions & 0 deletions DevCleaner/Model/Entries/SimulatorFileEntry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// SimulatorFileEntry.swift
// DevCleaner
//
// Created by Thomas Boerkel on 15.05.2026.
// Copyright © 2026 One Minute Games. All rights reserved.
//
// DevCleaner is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// (at your option) any later version.
//
// DevCleaner is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with DevCleaner. If not, see <http://www.gnu.org/licenses/>.

import Foundation
import AppKit

public final class SimulatorFileEntry: XcodeFileEntry {
// MARK: Properties
public let udid: String
public let runtime: String

// MARK: Initialization
public init(name: String, udid: String, runtime: String, selected: Bool) {
self.udid = udid
self.runtime = runtime

super.init(label: name, extraInfo: runtime, icon: .system(name: NSImage.computerName), tooltip: false, selected: selected)
}
}
134 changes: 132 additions & 2 deletions DevCleaner/Model/XcodeFiles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public protocol XcodeFilesDeleteDelegate: AnyObject {
final public class XcodeFiles {
// MARK: Types
public enum Location: Int, CaseIterable {
case deviceSupport, archives, derivedData, documentationCache, logs, oldDocumentation
case deviceSupport, archives, derivedData, documentationCache, logs, oldDocumentation, simulators
}

public enum State {
Expand Down Expand Up @@ -99,7 +99,8 @@ final public class XcodeFiles {
.derivedData: XcodeFileEntry(label: "Derived Data", tooltipText: "Cached project data and symbol index.", tooltip: true, selected: false),
.documentationCache: XcodeFileEntry(label: "Documentation Cache", tooltipText: "Documentation cache for each version of Xcode.", tooltip: true, selected: false),
.logs: XcodeFileEntry(label: "Old Logs", tooltipText: "Old device logs and crash databases. Usually, only the most recent ones are needed as they are duplicates of earlier logs.", tooltip: true, selected: false),
.oldDocumentation: OldDocumentationFileEntry(selected: false)
.oldDocumentation: OldDocumentationFileEntry(selected: false),
.simulators: XcodeFileEntry(label: "Simulators", tooltipText: "Unavailable simulator devices and orphaned XCTest device clones left behind when UI tests are killed mid-run.", tooltip: true, selected: false)
]
}

Expand Down Expand Up @@ -459,6 +460,9 @@ final public class XcodeFiles {
// different for those, as we don't have an option to select separate entries here
case .oldDocumentation:
entry.addPaths(paths: self.scanOldDocumentationLocations())

case .simulators:
entry.addChildren(items: self.scanSimulatorLocations())
}

// check for those files sizes
Expand Down Expand Up @@ -755,6 +759,132 @@ final public class XcodeFiles {
return entries
}

private func scanSimulatorLocations() -> [XcodeFileEntry] {
let devicesDir = self.userDeveloperFolderUrl.appendingPathComponent("CoreSimulator/Devices")

// Collect runtime identifiers of all currently installed runtimes from the filesystem.
// This avoids spawning xcrun simctl, which is blocked in the macOS sandbox.
let availableRuntimeIds = XcodeFiles.installedSimulatorRuntimeIds()
log.info("XcodeFiles: Found \(availableRuntimeIds.count) installed simulator runtime(s)")

guard !availableRuntimeIds.isEmpty else {
// Could not read runtime volumes — sandbox likely blocked access. Nothing to show.
log.warning("XcodeFiles: Cannot read CoreSimulator Volumes — skipping simulator scan")
return []
}

guard let deviceDirs = try? FileManager.default.contentsOfDirectory(
at: devicesDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles
) else {
log.warning("XcodeFiles: Cannot read CoreSimulator Devices directory")
return []
}

var unavailableEntries = [SimulatorFileEntry]()
for deviceDir in deviceDirs {
let plistUrl = deviceDir.appendingPathComponent("device.plist")
guard let plist = NSDictionary(contentsOf: plistUrl),
let name = plist["name"] as? String,
let udid = plist["UDID"] as? String,
let runtime = plist["runtime"] as? String,
plist["isDeleted"] as? Bool != true else {
continue
}

if !availableRuntimeIds.contains(runtime) {
let displayRuntime = XcodeFiles.simulatorRuntimeDisplayName(for: runtime)
let entry = SimulatorFileEntry(name: name, udid: udid, runtime: displayRuntime, selected: false)
entry.addPath(path: deviceDir)
unavailableEntries.append(entry)
log.info("XcodeFiles: Unavailable simulator: \(name) (\(displayRuntime))")
}
}

var result = [XcodeFileEntry]()

if !unavailableEntries.isEmpty {
unavailableEntries.sort { $0.label < $1.label }
let unavailableGroup = XcodeFileEntry(label: "Unavailable", icon: .system(name: NSImage.folderName), selected: false)
unavailableGroup.addChildren(items: unavailableEntries)
result.append(unavailableGroup)
}

result.append(contentsOf: scanXCTestDeviceLocations())
return result
}

private func scanXCTestDeviceLocations() -> [XcodeFileEntry] {
let xcTestDevicesDir = self.userDeveloperFolderUrl.appendingPathComponent("XCTestDevices")
guard let deviceDirs = try? FileManager.default.contentsOfDirectory(
at: xcTestDevicesDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles
) else { return [] }

var cloneEntries = [SimulatorFileEntry]()
for deviceDir in deviceDirs {
let plistUrl = deviceDir.appendingPathComponent("device.plist")
guard let plist = NSDictionary(contentsOf: plistUrl),
let name = plist["name"] as? String,
let udid = plist["UDID"] as? String,
let runtime = plist["runtime"] as? String,
plist["isDeleted"] as? Bool != true else { continue }

let displayRuntime = XcodeFiles.simulatorRuntimeDisplayName(for: runtime)
let entry = SimulatorFileEntry(name: name, udid: udid, runtime: displayRuntime, selected: false)
entry.addPath(path: deviceDir)
cloneEntries.append(entry)
log.info("XcodeFiles: XCTest clone: \(name) (\(displayRuntime))")
}

guard !cloneEntries.isEmpty else { return [] }

cloneEntries.sort { $0.label < $1.label }
let groupEntry = XcodeFileEntry(label: "Clones", icon: .system(name: NSImage.folderName), selected: false)
groupEntry.addChildren(items: cloneEntries)
return [groupEntry]
}

// Reads installed simulator runtimes from the CoreSimulator Volumes directory without
// spawning any subprocess — safe to call from a sandboxed app.
private static func installedSimulatorRuntimeIds() -> Set<String> {
let volumesDir = URL(fileURLWithPath: "/Library/Developer/CoreSimulator/Volumes")
guard let volumes = try? FileManager.default.contentsOfDirectory(
at: volumesDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles
) else {
log.warning("XcodeFiles: Cannot list CoreSimulator Volumes (path: \(volumesDir.path))")
return []
}

var runtimeIds = Set<String>()
for volume in volumes {
let runtimesPath = volume.appendingPathComponent("Library/Developer/CoreSimulator/Profiles/Runtimes")
guard let bundles = try? FileManager.default.contentsOfDirectory(
at: runtimesPath, includingPropertiesForKeys: nil, options: .skipsHiddenFiles
) else { continue }

for bundle in bundles where bundle.pathExtension == "simruntime" {
let infoUrl = bundle.appendingPathComponent("Contents/Info.plist")
if let info = NSDictionary(contentsOf: infoUrl),
let bundleId = info["CFBundleIdentifier"] as? String {
runtimeIds.insert(bundleId)
}
}
}

return runtimeIds
}

// Converts "com.apple.CoreSimulator.SimRuntime.iOS-16-2" → "iOS 16.2"
private static func simulatorRuntimeDisplayName(for runtimeId: String) -> String {
return runtimeId
.replacingOccurrences(of: "com.apple.CoreSimulator.SimRuntime.", with: "")
.replacingOccurrences(of: "-", with: ".")
.replacingOccurrences(of: "iOS.", with: "iOS ")
.replacingOccurrences(of: "watchOS.", with: "watchOS ")
.replacingOccurrences(of: "tvOS.", with: "tvOS ")
.replacingOccurrences(of: "xrOS.", with: "visionOS ")
.replacingOccurrences(of: "visionOS.", with: "visionOS ")
}

private func scanOldDocumentationLocations() -> [URL] {
// get location
let docsLocation = self.userDeveloperFolderUrl.appendingPathComponent("Shared/Documentation")
Expand Down
10 changes: 8 additions & 2 deletions DevCleaner/Resources/Manual/manual.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ <h4>Old Simulator & Device Logs</h4>
<h4>Old Documentation Downloads</h4>
<p>Recent versions of Xcode are using online help. But if you used Xcode in the past, you may still have documentation downloaded locally to your computer. It could take a lot of space and currently there is no use for it.</p>

<h4>Simulators</h4>
<p>Over time Xcode accumulates simulator-related data that is no longer needed. DevCleaner groups this into two sub-nodes:</p>
<p><b>Unavailable</b> — Simulator devices whose runtime has been deleted. When you remove an iOS, watchOS, tvOS, or visionOS runtime from Xcode, the simulator devices that depended on it are left behind on disk. These devices can never be booted again and are safe to delete.</p>
<p><b>Clones</b> — Temporary simulator devices created by Xcode for UI test runs (stored in <code>~/Library/Developer/XCTestDevices/</code>). If a test run is interrupted or Xcode crashes, these clones are not cleaned up automatically and can accumulate over time. They are safe to delete.</p>
<p>Nothing is selected by default — review the entries before cleaning.</p>

<h3>Main Window</h3>
<p>This is the main interface of the application, you select here what you want to clean and performing cleaning itself. You can also <b>share or tip me</b> if you want &#128526;</p>

Expand Down Expand Up @@ -134,8 +140,8 @@ <h4>Usage</h4>

info
Show all items available to clean.
clean <all,device-support,archives,derived-data,docs-cache,old-logs,old-documentation>
Perform cleaning of given items. Available options: all,device-support,archives,derived-data,docs-cache,previews,old-logs,old-documentation. If you want to clean all, pass "all" or nothing
clean <all,device-support,archives,derived-data,docs-cache,old-logs,old-documentation,simulators>
Perform cleaning of given items. Available options: all,device-support,archives,derived-data,docs-cache,previews,old-logs,old-documentation,simulators. If you want to clean all, pass "all" or nothing
--help
Prints this message
</pre>
Expand Down