From c37e599741a0540a3a53574d2047fc892fe78623 Mon Sep 17 00:00:00 2001
From: Alexandre G
Date: Fri, 3 Apr 2026 11:54:06 +1000
Subject: [PATCH] App: Add safe Xcode UserData cache cleanup and avoid
duplicate size scans
---
DevCleaner/Base/CmdLine.swift | 6 +-
DevCleaner/Model/Entries/XcodeFileEntry.swift | 26 +++++++++
DevCleaner/Model/XcodeFiles.swift | 55 ++++++++++++++++++-
DevCleaner/Resources/Manual/manual.html | 9 ++-
README.md | 6 ++
5 files changed, 96 insertions(+), 6 deletions(-)
diff --git a/DevCleaner/Base/CmdLine.swift b/DevCleaner/Base/CmdLine.swift
index ab922ea..9a33eca 100644
--- a/DevCleaner/Base/CmdLine.swift
+++ b/DevCleaner/Base/CmdLine.swift
@@ -101,6 +101,8 @@ public final class CmdLine {
return .derivedData
case "docs-cache":
return .documentationCache
+ case "user-data-cache":
+ return .userDataCache
case "old-logs":
return .logs
case "old-documentation":
@@ -169,8 +171,8 @@ public final class CmdLine {
let argsParser = ArgumentsParser(toolName: "dev-cleaner", description: "Reclaims storage that Xcode stores in caches and old files")
argsParser.addOption(name: "info", description: "Show all items available to clean.")
argsParser.addOptionWithValue(name: "clean",
- description: "Perform cleaning of given items. Available options: all, device-support, archives, derived-data, docs-cache, old-logs, old-documentation. If you want to clean all, pass \"all\" or nothing",
- possibleValues: ["all","device-support","archives","derived-data","docs-cache","old-logs","old-documentation"])
+ description: "Perform cleaning of given items. Available options: all, device-support, archives, derived-data, docs-cache, user-data-cache, old-logs, old-documentation. If you want to clean all, pass \"all\" or nothing",
+ possibleValues: ["all","device-support","archives","derived-data","docs-cache","user-data-cache","old-logs","old-documentation"])
argsParser.addOption(name: "--help", description: "Prints this message")
do {
diff --git a/DevCleaner/Model/Entries/XcodeFileEntry.swift b/DevCleaner/Model/Entries/XcodeFileEntry.swift
index 90ddb31..f6b2689 100644
--- a/DevCleaner/Model/Entries/XcodeFileEntry.swift
+++ b/DevCleaner/Model/Entries/XcodeFileEntry.swift
@@ -197,6 +197,32 @@ open class XcodeFileEntry: NSObject {
return self.size
}
+ /// Reuses already computed child sizes and only recalculates child entries whose size is unknown.
+ @discardableResult
+ public func recalculateSizeUsingCachedChildrenIfNeeded() -> Size? {
+ var result: Int64 = 0
+
+ // calculate sizes of children, preferring already computed values
+ for item in self.items {
+ if let size = item.recalculateSizeIfNeeded(), let sizeInBytes = size.numberOfBytes {
+ result += sizeInBytes
+ }
+ }
+
+ // calculate own size
+ let fileManager = FileManager.default
+ for pathUrl in self.paths {
+ if let dirSize = try? fileManager.allocatedSizeOfDirectory(atUrl: pathUrl) {
+ result += dirSize
+ } else if let fileSize = try? fileManager.allocatedSizeOfFile(at: pathUrl) {
+ result += fileSize
+ }
+ }
+
+ self.size = .value(result)
+ return self.size
+ }
+
@discardableResult
public func recalculateSizeIfNeeded() -> Size? {
guard case .value(let size) = self.size else {
diff --git a/DevCleaner/Model/XcodeFiles.swift b/DevCleaner/Model/XcodeFiles.swift
index 86aba98..e80d762 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, userDataCache, logs, oldDocumentation
}
public enum State {
@@ -98,6 +98,7 @@ final public class XcodeFiles {
.archives: XcodeFileEntry(label: "Archives", tooltipText: "Archived apps. Delete only if you're sure you don't need them.", tooltip: true, selected: false),
.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),
+ .userDataCache: XcodeFileEntry(label: "UserData Cache", tooltipText: "Regenerable caches stored in Xcode/UserData. This excludes editor preferences and other persistent settings.", 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)
]
@@ -453,6 +454,9 @@ final public class XcodeFiles {
case .documentationCache:
entry.addChildren(items: self.scanDocumentationCacheLocations())
+ case .userDataCache:
+ entry.addChildren(items: self.scanUserDataCacheLocations())
+
case .logs:
entry.addChildren(items: self.scanLogsLocations())
@@ -462,7 +466,7 @@ final public class XcodeFiles {
}
// check for those files sizes
- entry.recalculateSizeIfNeeded()
+ entry.recalculateSizeUsingCachedChildrenIfNeeded()
// check for selections
entry.recalculateSelection()
@@ -685,6 +689,53 @@ final public class XcodeFiles {
return entries
}
+ private func scanUserDataCacheLocations() -> [XcodeFileEntry] {
+ typealias CacheLocation = (label: String, relativePath: String, tooltip: String)
+ let safeCacheLocations: [CacheLocation] = [
+ (
+ label: "SwiftUI Previews",
+ relativePath: "Xcode/UserData/Previews",
+ tooltip: "Preview simulator data recreated by Xcode when previews run again."
+ ),
+ (
+ label: "IB Support Simulator Devices",
+ relativePath: "Xcode/UserData/IB Support/Simulator Devices",
+ tooltip: "Interface Builder preview simulator data recreated by Xcode when needed."
+ ),
+ (
+ label: "Capabilities Cache",
+ relativePath: "Xcode/UserData/Capabilities",
+ tooltip: "Downloaded capabilities metadata cache recreated by Xcode when needed."
+ )
+ ]
+
+ var entries = [XcodeFileEntry]()
+ for cacheLocation in safeCacheLocations {
+ let path = self.userDeveloperFolderUrl.appendingPathComponent(cacheLocation.relativePath)
+ guard FileManager.default.fileExists(atPath: path.path) else {
+ continue
+ }
+
+ let entry = XcodeFileEntry(label: cacheLocation.label,
+ extraInfo: path.path,
+ tooltipText: cacheLocation.tooltip,
+ icon: .system(name: NSImage.folderName),
+ tooltip: true,
+ selected: false)
+ entry.addPath(path: path)
+ _ = entry.recalculateSize()
+ entries.append(entry)
+ }
+
+ return entries.sorted { lhs, rhs in
+ if lhs.size == rhs.size {
+ return lhs.label.localizedStandardCompare(rhs.label) == .orderedAscending
+ } else {
+ return lhs.size > rhs.size
+ }
+ }
+ }
+
private func scanLogsLocations() -> [XcodeFileEntry] {
struct LogEntry {
let path: URL
diff --git a/DevCleaner/Resources/Manual/manual.html b/DevCleaner/Resources/Manual/manual.html
index 1773c30..23b1f4f 100644
--- a/DevCleaner/Resources/Manual/manual.html
+++ b/DevCleaner/Resources/Manual/manual.html
@@ -42,6 +42,11 @@ Documentation Cache
amount of space may be wasted due to that.
Removing all cache is not harmful but remember that your most current Xcode will regenerate it anyway if it'll have a chance. It also could slow down access to documentation after cleaning them.
+ UserData Cache
+ Xcode also stores regenerable cache data in ~/Library/Developer/Xcode/UserData, including SwiftUI previews, Interface Builder
+ preview simulator devices and downloaded capabilities metadata.
+ DevCleaner only targets explicit cache folders in this location and does not remove editor preferences or other persistent user settings.
+
Old Simulator & Device Logs
Each version of Xcode appears to have a new version of database that holds logs from devices & simulators. Those databases are migrated with content of the previous ones. If you use Xcode for a long time, it would accumulate to significant amount of unused data.
@@ -134,8 +139,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,user-data-cache,old-logs,old-documentation. If you want to clean all, pass "all" or nothing
--help
Prints this message
diff --git a/README.md b/README.md
index 547c704..ae8bed5 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,12 @@ we would use them again.
Modern Xcodes are caching documentation that is accessed via the web. Unfortunately older caches are not cleaned up by Xcode and significant
amount of space may be wasted due to that.
+### UserData Cache
+
+Xcode also keeps some regenerable data in `~/Library/Developer/Xcode/UserData`, including SwiftUI previews, Interface Builder preview simulator
+devices and downloaded capabilities metadata. DevCleaner only targets explicit cache folders here and does not remove editor preferences or other
+persistent user settings.
+
### Old Simulator & Device Logs
Old device logs & crashes databases, only most recent ones are needed. It seems that new versions of Xcodes migrates old logs database, but keeping older ones on disk.