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
20 changes: 14 additions & 6 deletions PureMac.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@
47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3AC5F17D7CA94FAB1E8D7A /* StringNormalization.swift */; };
48D1431A7C99C19EEBDB056B /* CleaningEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8DE5D45BA19E2670B57DC5 /* CleaningEngine.swift */; };
4F754D89F4CE5142BE384062 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F91226CDCFBB8E303B7DA /* AppConstants.swift */; };
535B23C0108C06475215B8E8 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB003A7751F05727DBFD1A5 /* AppTheme.swift */; };
75B5F0401D37F2872B1AD85A /* AppListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E6BC61614B27C065BB18C9 /* AppListView.swift */; };
76B132F9C499225D33E0D075 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424F7B28624C271620E13BBC /* EmptyStateView.swift */; };
826A750D2D7EC14C2AE306A3 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3667D46D8E2004EB4D73835A /* Models.swift */; };
8947F0CE448791BD50EECF46 /* Conditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB94E06E145558123BB5BFB3 /* Conditions.swift */; };
93743B036059418560D876E6 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8D39C3C250F26302EC45AB /* SettingsView.swift */; };
95278ABDCF5F4D6AC60503B1 /* AppearancePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92259B3E9F4468865F15DEEC /* AppearancePill.swift */; };
9AA80E035DF7B33F6EE118DF /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED1B71D5F9510582E869CFD /* AppState.swift */; };
9BB5AAA574AFED6C27A3F8E2 /* AppPathFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0FEE7141871ED5F9E36121 /* AppPathFinder.swift */; };
A89DF967EC9E5E8123B2925A /* SmartScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BA091943263913276F3CCF /* SmartScanView.swift */; };
A2AE68CC75CB72D6B10CBDF5 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661A0F64CF93E482CB1728F /* DashboardView.swift */; };
A9C3A1F643C26930F442E729 /* OrphanSafetyPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE790496C936424EF98320A /* OrphanSafetyPolicy.swift */; };
B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */; };
BC6C800216343438413349A3 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4FD34378988D430A582ED0 /* OnboardingView.swift */; };
Expand All @@ -42,7 +44,7 @@
01B2C5F66B6D812572BD4F05 /* CLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLI.swift; sourceTree = "<group>"; };
02E502E2B5C6AECC76E5CFEF /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = "<group>"; };
11BA091943263913276F3CCF /* SmartScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartScanView.swift; sourceTree = "<group>"; };
1AB003A7751F05727DBFD1A5 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
1D3AC5F17D7CA94FAB1E8D7A /* StringNormalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringNormalization.swift; sourceTree = "<group>"; };
23486A54A82EE3865B784D1C /* AppInfoFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoFetcher.swift; sourceTree = "<group>"; };
2641C6376DD6F5889F35510E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
Expand All @@ -63,6 +65,7 @@
63581B70F9B10231964E3602 /* PureMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PureMacApp.swift; sourceTree = "<group>"; };
77D3D9A9BC52839E6D0A22BC /* Locations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locations.swift; sourceTree = "<group>"; };
798B80977D14647A5691B0A0 /* AppFilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFilesView.swift; sourceTree = "<group>"; };
92259B3E9F4468865F15DEEC /* AppearancePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePill.swift; sourceTree = "<group>"; };
9F04B811BB0012F6D2F07F91 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
9F510F232341EE18F11DC934 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
A711CDF5285F68775D9B5513 /* ScanEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanEngine.swift; sourceTree = "<group>"; };
Expand All @@ -75,6 +78,7 @@
E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessManager.swift; sourceTree = "<group>"; };
EEF15CB1B8EFCA78EF491824 /* ScanError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanError.swift; sourceTree = "<group>"; };
F31F91226CDCFBB8E303B7DA /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = "<group>"; };
F661A0F64CF93E482CB1728F /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXGroup section */
Expand Down Expand Up @@ -107,9 +111,9 @@
isa = PBXGroup;
children = (
10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */,
F661A0F64CF93E482CB1728F /* DashboardView.swift */,
9F510F232341EE18F11DC934 /* MainWindow.swift */,
3D4FD34378988D430A582ED0 /* OnboardingView.swift */,
11BA091943263913276F3CCF /* SmartScanView.swift */,
DAD2EC78EB98016ADA27D114 /* Apps */,
8AE26715B5748307483A1A5E /* Components */,
9D3C2DC382F5D4B77DFD218B /* Orphans */,
Expand Down Expand Up @@ -148,6 +152,8 @@
8AE26715B5748307483A1A5E /* Components */ = {
isa = PBXGroup;
children = (
92259B3E9F4468865F15DEEC /* AppearancePill.swift */,
1AB003A7751F05727DBFD1A5 /* AppTheme.swift */,
424F7B28624C271620E13BBC /* EmptyStateView.swift */,
);
path = Components;
Expand Down Expand Up @@ -201,11 +207,11 @@
children = (
B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */,
46660271CFF167AB0FE7371D /* Info.plist */,
241E0895B09C71AB423B2F9E /* Localizable.strings */,
5664D2BDAEAA9AE3A53DB364 /* PureMac.entitlements */,
63581B70F9B10231964E3602 /* PureMacApp.swift */,
D4333B07691BD85CAE0E5B15 /* Core */,
7C1729F88C0E5563E1A3DB40 /* Extensions */,
241E0895B09C71AB423B2F9E /* Localizable.strings */,
F283C00EB52AB140F61500A3 /* Logic */,
3CF46713F75B81F0F86D1C6F /* Models */,
6184B2EC3D01E6E95633406E /* Services */,
Expand Down Expand Up @@ -273,6 +279,7 @@
};
};
buildConfigurationList = 2ABAFAE07AA42044AE58F688 /* Build configuration list for PBXProject "PureMac" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Expand All @@ -286,7 +293,6 @@
mainGroup = 13CF0676D0E93925F46C13AA;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 4562CA9E5625FA4EEEFECB6D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
Expand Down Expand Up @@ -318,10 +324,13 @@
75B5F0401D37F2872B1AD85A /* AppListView.swift in Sources */,
9BB5AAA574AFED6C27A3F8E2 /* AppPathFinder.swift in Sources */,
9AA80E035DF7B33F6EE118DF /* AppState.swift in Sources */,
535B23C0108C06475215B8E8 /* AppTheme.swift in Sources */,
95278ABDCF5F4D6AC60503B1 /* AppearancePill.swift in Sources */,
F2FA881A4B2209CC2A6342FB /* CLI.swift in Sources */,
B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */,
48D1431A7C99C19EEBDB056B /* CleaningEngine.swift in Sources */,
8947F0CE448791BD50EECF46 /* Conditions.swift in Sources */,
A2AE68CC75CB72D6B10CBDF5 /* DashboardView.swift in Sources */,
76B132F9C499225D33E0D075 /* EmptyStateView.swift in Sources */,
2253F11BDF561B617439C96B /* FullDiskAccessManager.swift in Sources */,
E60B0A2C5D0A6CAE35BF4DFB /* Locations.swift in Sources */,
Expand All @@ -336,7 +345,6 @@
D50EB059E741011EB2523731 /* ScanError.swift in Sources */,
27F449EDD1B082FE11FEC9DF /* SchedulerService.swift in Sources */,
93743B036059418560D876E6 /* SettingsView.swift in Sources */,
A89DF967EC9E5E8123B2925A /* SmartScanView.swift in Sources */,
47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */,
340E424F759ACCDE7372F99F /* Theme.swift in Sources */,
);
Expand Down
12 changes: 12 additions & 0 deletions PureMac/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,17 @@
<string>public.app-category.utilities</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright 2026. MIT License.</string>
<key>NSDesktopFolderUsageDescription</key>
<string>PureMac needs access to your Desktop to find junk and orphaned files.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string>PureMac needs access to your Documents to find junk and orphaned files.</string>
<key>NSDownloadsFolderUsageDescription</key>
<string>PureMac needs access to your Downloads to find junk and orphaned files.</string>
<key>NSRemovableVolumesUsageDescription</key>
<string>PureMac needs access to removable volumes to scan for junk files.</string>
<key>NSSystemAdministrationUsageDescription</key>
<string>PureMac needs system administration access to clean caches and uninstall apps completely.</string>
<key>NSAppleEventsUsageDescription</key>
<string>PureMac uses Apple Events to interact with Finder when revealing files.</string>
</dict>
</plist>
20 changes: 14 additions & 6 deletions PureMac/PureMacApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {

func applicationDidFinishLaunching(_ notification: Notification) {
NSWindow.allowsAutomaticWindowTabbing = false
// Touch TCC-protected paths so macOS registers PureMac in the
// Full Disk Access pane on first launch (fixes issue #75).
FullDiskAccessManager.shared.triggerRegistration()
}
}

@main
struct PureMacApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var appState = AppState()
@StateObject private var theme = ThemeManager.shared
@AppStorage("PureMac.OnboardingComplete") private var onboardingComplete = false

init() {
Expand All @@ -27,13 +31,17 @@ struct PureMacApp: App {

var body: some Scene {
WindowGroup {
if onboardingComplete {
MainWindow()
.environmentObject(appState)
.frame(minWidth: 900, minHeight: 600)
} else {
OnboardingView(isComplete: $onboardingComplete)
Group {
if onboardingComplete {
MainWindow()
.environmentObject(appState)
.frame(minWidth: 900, minHeight: 600)
} else {
OnboardingView(isComplete: $onboardingComplete)
}
}
.environmentObject(theme)
.preferredColorScheme(theme.appearance.colorScheme)
}
.windowStyle(.automatic)
.windowToolbarStyle(.unified)
Expand Down
135 changes: 132 additions & 3 deletions PureMac/Services/CleaningEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ actor CleaningEngine {
var freedSpace: Int64 = 0
var itemsCleaned: Int = 0
var errors: [String] = []
var cleanedPaths: Set<String> = []
// Items that user-level FileManager.removeItem refused with EACCES /
// EPERM. These are root-owned and need an admin-privileged second
// pass via cleanWithAdminPrivileges(items:).
var requiresAdmin: [CleanableItem] = []
}

// MARK: - Public API
Expand Down Expand Up @@ -67,8 +72,25 @@ actor CleaningEngine {
try fileManager.removeItem(at: resolvedURL)
result.freedSpace += item.size
result.itemsCleaned += 1
result.cleanedPaths.insert(item.path)
} catch {
result.errors.append("\(item.name): \(error.localizedDescription)")
let nsError = error as NSError
let isPermissionDenied =
(nsError.domain == NSCocoaErrorDomain &&
(nsError.code == NSFileWriteNoPermissionError ||
nsError.code == NSFileReadNoPermissionError)) ||
(nsError.domain == NSPOSIXErrorDomain &&
(nsError.code == Int(EACCES) || nsError.code == Int(EPERM)))
if isPermissionDenied {
// Defer to the admin pass — these are typically root-owned
// system caches that the user-level process can't unlink.
result.requiresAdmin.append(item)
Logger.shared.log("Deferring to admin pass: \(item.path)", level: .info)
} else {
let detail = "\(item.name) at \(item.path): \(error.localizedDescription)"
result.errors.append(detail)
Logger.shared.log("Clean failed: \(detail)", level: .error)
}
}
}

Expand All @@ -80,6 +102,99 @@ actor CleaningEngine {
return await cleanItems(selectedItems, progressHandler: progressHandler)
}

/// Re-runs the deletion of the supplied items as root via NSAppleScript's
/// "with administrator privileges" clause. Triggers exactly one auth
/// prompt for the whole batch (macOS caches the credential for ~5 min).
///
/// Every path is re-validated against the same allow-list as the user-
/// level pass (isSafeToDelete / isExplicitSingleFileDeletable) before it
/// gets handed off to /bin/rm. Paths are passed via a NUL-separated
/// temp file consumed by xargs -0, so no shell-quoting pitfalls.
func cleanWithAdminPrivileges(items: [CleanableItem]) async -> CleaningResult {
var result = CleaningResult()

Logger.shared.log("Admin pass starting with \(items.count) item(s)", level: .info)

// Re-validate. Don't trust the caller — anything not on the allow-list
// refuses to escalate.
let validated: [(item: CleanableItem, resolved: String)] = items.compactMap { item in
let resolved = URL(fileURLWithPath: item.path).resolvingSymlinksInPath().path
let accepted: Bool = {
if item.category == .largeFiles {
return isExplicitSingleFileDeletable(resolvedPath: resolved)
}
return isSafeToDelete(resolvedPath: resolved)
}()
if !accepted {
Logger.shared.log("Refusing admin escalation for unsafe path: \(item.path)", level: .warning)
}
return accepted ? (item, resolved) : nil
}
guard !validated.isEmpty else {
Logger.shared.log("Admin pass: no items survived validation", level: .warning)
return result
}

// Stage paths NUL-separated so newlines/spaces in paths don't matter.
let staged = validated.map(\.resolved).joined(separator: "\u{0}")
guard let payload = staged.data(using: .utf8) else { return result }

let tempFile = FileManager.default.temporaryDirectory
.appendingPathComponent("puremac-rm-\(UUID().uuidString)")
do {
try payload.write(to: tempFile, options: [.atomic])
} catch {
Logger.shared.log("Couldn't stage admin path list: \(error.localizedDescription)", level: .error)
return result
}
defer { try? FileManager.default.removeItem(at: tempFile) }

// UUIDs are alphanumeric + hyphens, NSTemporaryDirectory is a known
// path with no shell metacharacters, so direct embedding is safe.
let script = """
do shell script "/usr/bin/xargs -0 /bin/rm -rf -- < \(tempFile.path)" with administrator privileges
"""

let runResult: (success: Bool, error: String?) = await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let appleScript = NSAppleScript(source: script)
var errorInfo: NSDictionary?
appleScript?.executeAndReturnError(&errorInfo)
if let errorInfo {
continuation.resume(returning: (false, "\(errorInfo)"))
} else {
continuation.resume(returning: (true, nil))
}
}
}

guard runResult.success else {
// -128 is "user cancelled" — log quietly, no need for an error row.
if let err = runResult.error, !err.contains("-128") {
Logger.shared.log("Admin clean failed: \(err)", level: .error)
result.errors.append("Administrator authorization failed")
}
return result
}

// Verify which items actually disappeared. xargs may have reported a
// partial failure even when the AppleScript exited cleanly, so we
// re-stat every path rather than trust the script's exit status.
for (item, resolved) in validated {
if !FileManager.default.fileExists(atPath: resolved) {
result.cleanedPaths.insert(item.path)
result.itemsCleaned += 1
result.freedSpace += item.size
} else {
let detail = "\(item.name) at \(item.path) survived admin removal"
result.errors.append(detail)
Logger.shared.log("Admin pass survivor: \(detail)", level: .error)
}
}
Logger.shared.log("Admin pass complete: \(result.itemsCleaned) deleted, \(result.errors.count) survived", level: .info)
return result
}

// MARK: - Purgeable Space

func purgePurgeableSpace() async -> Int64 {
Expand Down Expand Up @@ -150,17 +265,31 @@ actor CleaningEngine {
"\(home)/Library/Preferences",
"\(home)/Library/LaunchAgents",
"\(home)/Library/Mail Downloads",
"\(home)/Library/Developer/Xcode/DerivedData",
"\(home)/Library/Developer/Xcode/Archives",
"\(home)/Library/Developer/CoreSimulator/Caches",
"\(home)/.Trash",
"\(home)/.npm",
"\(home)/.cache",
"\(home)/Library/Containers/com.docker.docker",
"/Library/Caches",
"/Library/Logs",
"/private/var/log",
"/private/var/tmp",
// /var is a symlink to /private/var, and resolvingSymlinksInPath
// gives the /var form. Both spellings must be allow-listed or
// every system log/tmp deletion silently fails the safety check.
"/var/log",
"/var/tmp",
"/tmp",
]
// Allow whole-subtree deletion only; require a trailing "/" on the
// root match so siblings like "/tmpfoo" cannot pass the prefix check.
// Either the path equals an allow-listed root (whole-subtree wipe by
// the scanner that emits the root itself, e.g. DerivedData) or it
// sits strictly inside one. The trailing "/" on the prefix match
// prevents siblings like "/tmpfoo" from sneaking past "/tmp".
let normalized = (resolvedPath as NSString).standardizingPath
return allowedRoots.contains { root in
if normalized == root { return true }
let rootWithSeparator = root.hasSuffix("/") ? root : root + "/"
return normalized.hasPrefix(rootWithSeparator)
}
Expand Down
Loading