Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CleanMac.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = CleanMac/CleanMac.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
Expand All @@ -266,6 +267,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = CleanMac;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "CleanMac uses Automation to ask Finder to reveal cleanup items you choose.";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
Expand All @@ -291,6 +293,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = CleanMac/CleanMac.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
Expand All @@ -299,6 +302,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = CleanMac;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "CleanMac uses Automation to ask Finder to reveal cleanup items you choose.";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
Expand Down
8 changes: 8 additions & 0 deletions CleanMac/CleanMac.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
58 changes: 53 additions & 5 deletions CleanMac/Models/CleanMacModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable {
case dashboard
case scan
case results
case applications
case permissions
case settings

Expand All @@ -15,6 +16,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable {
case .dashboard: L.t("section.dashboard")
case .scan: L.t("section.scan")
case .results: L.t("section.results")
case .applications: L.t("section.applications")
case .permissions: L.t("section.permissions")
case .settings: L.t("section.settings")
}
Expand All @@ -25,6 +27,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable {
case .dashboard: "gauge.with.dots.needle.67percent"
case .scan: "magnifyingglass"
case .results: "checklist"
case .applications: "app.badge.checkmark"
case .permissions: "lock.shield"
case .settings: "gearshape"
}
Expand Down Expand Up @@ -222,15 +225,21 @@ enum PermissionState {
case limited
case unknown
case recommended
case later
case notRequested
case denied
case unavailable
case checking

var title: String {
switch self {
case .granted: L.t("permission.state.granted")
case .limited: L.t("permission.state.limited")
case .unknown: L.t("permission.state.unknown")
case .recommended: L.t("permission.state.recommended")
case .later: L.t("permission.state.later")
case .notRequested: L.t("permission.state.notRequested")
case .denied: L.t("permission.state.denied")
case .unavailable: L.t("permission.state.unavailable")
case .checking: L.t("permission.state.checking")
}
}
}
Expand Down Expand Up @@ -365,7 +374,10 @@ enum CleanMacCatalog {
]
}

static func permissions(fullDiskAccess: FullDiskAccessCheckResult) -> [PermissionItem] {
static func permissions(
fullDiskAccess: FullDiskAccessCheckResult,
finderAutomationPermission: FinderAutomationPermission?
) -> [PermissionItem] {
[
PermissionItem(
id: "files",
Expand All @@ -384,9 +396,9 @@ enum CleanMacCatalog {
PermissionItem(
id: "automation",
title: L.t("permission.automation.title"),
detail: L.t("permission.automation.detail"),
detail: automationDetail(for: finderAutomationPermission),
systemImage: "wand.and.stars",
state: .later
state: permissionState(for: finderAutomationPermission)
)
]
}
Expand All @@ -399,6 +411,23 @@ enum CleanMacCatalog {
}
}

private static func permissionState(
for automationPermission: FinderAutomationPermission?
) -> PermissionState {
switch automationPermission {
case nil:
.checking
case .granted:
.granted
case .notDetermined:
.notRequested
case .denied:
.denied
case .targetNotRunning, .unavailable:
.unavailable
}
}

private static func fullDiskDetail(for result: FullDiskAccessCheckResult) -> String {
switch result.state {
case .granted:
Expand All @@ -410,6 +439,25 @@ enum CleanMacCatalog {
}
}

private static func automationDetail(
for automationPermission: FinderAutomationPermission?
) -> String {
switch automationPermission {
case nil:
L.t("permission.automation.detail.checking")
case .granted:
L.t("permission.automation.detail.granted")
case .notDetermined:
L.t("permission.automation.detail.notRequested")
case .denied:
L.t("permission.automation.detail.denied")
case .targetNotRunning:
L.t("permission.automation.detail.finderUnavailable")
case .unavailable:
L.t("permission.automation.detail.unavailable")
}
}

static func area(for category: CleanupCategory) -> CleanupArea {
cleanupAreas.first { $0.category == category } ?? cleanupAreas[0]
}
Expand Down
17 changes: 17 additions & 0 deletions CleanMac/PrivacyInfo.xcprivacy
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>85F4.1</string>
</array>
</dict>
</array>
</dict>
</plist>
102 changes: 102 additions & 0 deletions CleanMac/Support/CleanMacAutomationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import CoreServices
import Foundation

nonisolated enum FinderAutomationPermission: Equatable, Sendable {
case granted
case notDetermined
case denied
case targetNotRunning
case unavailable
}

enum CleanMacAutomationService {
nonisolated private static let finderBundleIdentifier = "com.apple.finder"

static func checkFinderPermission() async -> FinderAutomationPermission {
await Task.detached(priority: .utility) {
determineFinderPermission(askUserIfNeeded: false)
}.value
}

static func requestFinderPermission() async -> FinderAutomationPermission {
await Task.detached(priority: .userInitiated) {
determineFinderPermission(askUserIfNeeded: true)
}.value
}

static func revealInFinder(_ url: URL) async -> Bool {
await Task.detached(priority: .userInitiated) {
guard determineFinderPermission(askUserIfNeeded: false) == .granted else {
return false
}

return sendFinderEvent(
eventID: kAEMakeObjectsVisible,
directObject: NSAppleEventDescriptor(fileURL: url)
) && sendFinderEvent(eventID: kAEActivate)
}.value
}

nonisolated private static func determineFinderPermission(
askUserIfNeeded: Bool
) -> FinderAutomationPermission {
let target = NSAppleEventDescriptor(bundleIdentifier: finderBundleIdentifier)
guard let targetDescriptor = target.aeDesc else {
return .unavailable
}

let status = AEDeterminePermissionToAutomateTarget(
targetDescriptor,
typeWildCard,
typeWildCard,
askUserIfNeeded
)

switch status {
case noErr:
return .granted
case OSStatus(errAEEventWouldRequireUserConsent):
return .notDetermined
case OSStatus(errAEEventNotPermitted):
return .denied
case OSStatus(procNotFound):
return .targetNotRunning
case OSStatus(errAETargetAddressNotPermitted):
return .unavailable
default:
return .unavailable
}
}

nonisolated private static func sendFinderEvent(
eventID: AEEventID,
directObject: NSAppleEventDescriptor? = nil
) -> Bool {
let target = NSAppleEventDescriptor(bundleIdentifier: finderBundleIdentifier)
let event = NSAppleEventDescriptor(
eventClass: kAEMiscStandards,
eventID: eventID,
targetDescriptor: target,
returnID: AEReturnID(kAutoGenerateReturnID),
transactionID: AETransactionID(kAnyTransactionID)
)

if let directObject {
event.setParam(directObject, forKeyword: keyDirectObject)
}

let noPermissionPrompt = NSAppleEventDescriptor.SendOptions(
rawValue: UInt(kAEDoNotPromptForUserConsent)
)

do {
_ = try event.sendEvent(
options: [.waitForReply, .neverInteract, noPermissionPrompt],
timeout: 5
)
return true
} catch {
return false
}
}
}
Loading