diff --git a/DevCleaner.xcodeproj/project.pbxproj b/DevCleaner.xcodeproj/project.pbxproj index fb489e1..8d42065 100644 --- a/DevCleaner.xcodeproj/project.pbxproj +++ b/DevCleaner.xcodeproj/project.pbxproj @@ -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 */ @@ -124,6 +125,7 @@ 71FD37D8206AD72C0042812D /* DeviceSupportFileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSupportFileEntry.swift; sourceTree = ""; }; 71FD37DC206AD76D0042812D /* DerivedDataFileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DerivedDataFileEntry.swift; sourceTree = ""; }; BAF8F9B5BB114D2196AD27A1 /* device_identifiers.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = device_identifiers.csv; sourceTree = ""; }; + E97697AB2FB775040086DF05 /* SimulatorFileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorFileEntry.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -345,6 +347,7 @@ 717A3EC828FC3DDC002A0C8F /* DocumentationCacheFileEntry.swift */, 714AB65E22DA87FA00A7FBB7 /* DeviceLogsFileEntry.swift */, 717A9F1A22DBAF9300682A0A /* OldDocumentationFileEntry.swift */, + E97697AB2FB775040086DF05 /* SimulatorFileEntry.swift */, ); path = Entries; sourceTree = ""; @@ -378,7 +381,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 2610; + LastUpgradeCheck = 2650; ORGANIZATIONNAME = "One Minute Games"; TargetAttributes = { 7110B48D2030F17B00EDBFA3 = { @@ -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 */, @@ -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"; }; @@ -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"; }; diff --git a/DevCleaner/Base/CmdLine.swift b/DevCleaner/Base/CmdLine.swift index ab922ea..3caedb9 100644 --- a/DevCleaner/Base/CmdLine.swift +++ b/DevCleaner/Base/CmdLine.swift @@ -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) } diff --git a/DevCleaner/Model/Entries/SimulatorFileEntry.swift b/DevCleaner/Model/Entries/SimulatorFileEntry.swift new file mode 100644 index 0000000..3d528c6 --- /dev/null +++ b/DevCleaner/Model/Entries/SimulatorFileEntry.swift @@ -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 . + +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) + } +} diff --git a/DevCleaner/Model/XcodeFiles.swift b/DevCleaner/Model/XcodeFiles.swift index 86aba98..f3396cc 100644 --- a/DevCleaner/Model/XcodeFiles.swift +++ b/DevCleaner/Model/XcodeFiles.swift @@ -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 { @@ -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) ] } @@ -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 @@ -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 { + 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() + 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") diff --git a/DevCleaner/Resources/Manual/manual.html b/DevCleaner/Resources/Manual/manual.html index 1773c30..456408c 100644 --- a/DevCleaner/Resources/Manual/manual.html +++ b/DevCleaner/Resources/Manual/manual.html @@ -51,6 +51,12 @@

Old Simulator & Device Logs

Old Documentation Downloads

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.

+

Simulators

+

Over time Xcode accumulates simulator-related data that is no longer needed. DevCleaner groups this into two sub-nodes:

+

Unavailable — 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.

+

Clones — Temporary simulator devices created by Xcode for UI test runs (stored in ~/Library/Developer/XCTestDevices/). 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.

+

Nothing is selected by default — review the entries before cleaning.

+

Main Window

This is the main interface of the application, you select here what you want to clean and performing cleaning itself. You can also share or tip me if you want 😎

@@ -134,8 +140,8 @@

Usage

info Show all items available to clean. - clean - 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 + 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