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 @@
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.
+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.
+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 @@