// // Logic.swift // Pearcleaner // // Created by Alin Lupascu on 10/31/23. // import Foundation import SwiftUI // Get all apps from /Applications and ~/Applications func getSortedApps() -> [AppInfo] { let fileManager = FileManager.default // Define the paths for system and user applications let systemAppsPath = "/Applications" let userAppsPath = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications").path var apps: [URL] = [] func collectAppPaths(at directoryPath: String) { do { let appURLs = try fileManager.contentsOfDirectory(at: URL(fileURLWithPath: directoryPath), includingPropertiesForKeys: nil, options: []) for appURL in appURLs { if appURL.pathExtension == "app" && !isRestricted(atPath: appURL) { // Add the path to the array apps.append(appURL) } else if appURL.hasDirectoryPath { // If it's a directory, recursively explore it collectAppPaths(at: appURL.path) } } } catch { // Handle any potential errors here printOS("Error: \(error)") } } // Collect system applications collectAppPaths(at: systemAppsPath) // Collect user applications collectAppPaths(at: userAppsPath) // Get app info and sort let sortedApps = apps .compactMap { getAppInfo(atPath: $0) } .sorted { $0.appName.replacingOccurrences(of: ".", with: "").lowercased() < $1.appName.replacingOccurrences(of: ".", with: "").lowercased() } return sortedApps } // Get app bundle information from provided path func getAppInfo(atPath path: URL, wrapped: Bool = false) -> AppInfo? { let filemanager = FileManager.default let wrapperURL = path.appendingPathComponent("Wrapper") if filemanager.fileExists(atPath: wrapperURL.path) { do { let contents = try filemanager.contentsOfDirectory(at: wrapperURL, includingPropertiesForKeys: nil, options: []) let appFiles = contents.filter { $0.pathExtension == "app" } if let firstAppFile = appFiles.first { let fullPath = wrapperURL.appendingPathComponent(firstAppFile.lastPathComponent) if let wrappedAppInfo = getAppInfo(atPath: fullPath, wrapped: true) { return wrappedAppInfo } } else { printOS("No .app files found in the 'Wrapper' directory: \(wrapperURL)") } } catch { printOS("Error reading contents of 'Wrapper' directory: \(error.localizedDescription)\n\(wrapperURL)") } } else { if let bundle = Bundle(url: path) { if let bundleIdentifier = bundle.bundleIdentifier { var appIconFileName = bundle.infoDictionary?["CFBundleIconFile"] as? String ?? "" var appVersion: String? var appIcon: NSImage? var appName: String? var webApp: Bool? var wrappedApp: Bool? var system: Bool? if let shortVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String, !shortVersion.isEmpty { appVersion = shortVersion } else { if let bundleVersion = bundle.infoDictionary?["CFBundleVersion"] as? String, !bundleVersion.isEmpty { appVersion = bundleVersion } else { printOS("Failed to retrieve bundle version") } } if let localizedName = bundle.localizedInfoDictionary?[kCFBundleNameKey as String] as? String { appName = localizedName } else if let bundleName = bundle.infoDictionary?["CFBundleName"] as? String { appName = bundleName } else { appName = path.deletingPathExtension().lastPathComponent } if appIconFileName.isEmpty { if let iconsDict = bundle.infoDictionary?["CFBundleIcons"] as? [String: Any] { if let primaryIconDict = iconsDict["CFBundlePrimaryIcon"] as? [String: Any], let iconFiles = primaryIconDict["CFBundleIconFiles"] as? [String], let primaryIconFile = iconFiles.first { // Now, you can use the primaryIconFile to construct the path to the icon file let iconPath = path.appendingPathComponent(primaryIconFile) appIconFileName = iconPath.path if let contents = try? FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil) { // Find the first file that matches the specified prefix if let foundURL = contents.first(where: { $0.lastPathComponent.hasPrefix(primaryIconFile) }), let image = NSImage(contentsOfFile: foundURL.path) { appIcon = image } else { printOS("No matching image found for \(primaryIconFile) in wrapped app.") } } else { printOS("Unable to access the directory at \(primaryIconFile) for wrapped app.") } } } } // Check if the icon file name has an .icns extension, if not, add it if !appIconFileName.hasSuffix(".icns") { appIconFileName += ".icns" } // Try to find the icon in the main bundle if let iconPath = bundle.path(forResource: appIconFileName, ofType: nil), let icon = NSImage(contentsOfFile: iconPath) { appIcon = icon } // If not found, try to find it in the resources directory if appIcon == nil, let resourcesPath = bundle.resourcePath { let iconURL = URL(fileURLWithPath: resourcesPath).appendingPathComponent(appIconFileName) if let icon = NSImage(contentsOfFile: iconURL.path) { appIcon = icon } } // Convert the icon to a 100x100 PNG image if let pngIcon = appIcon.flatMap({ convertICNSToPNG(icon: $0, size: NSSize(width: 100, height: 100)) }) { appIcon = pngIcon } if appIcon == nil { printOS("App Icon not found for app at path: \(path)") } if bundle.infoDictionary?["LSTemplateApplication"] is Bool { webApp = true } else { webApp = false } if wrapped { wrappedApp = true } else { wrappedApp = false } if !path.path.contains(home) { system = true } else { system = false } return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName ?? "", appVersion: appVersion ?? "", appIcon: appIcon, webApp: webApp ?? false, wrapped: wrappedApp ?? false, system: system ?? false, files: [], fileSize: [:], fileIcon: [:]) } else { printOS("Bundle identifier not found at path: \(path)") } } else { printOS("Bundle not found at path: \(path)") } } return nil } // Get directory path for darwin cache and temp directories func darwinCT() -> (String, String) { let task = Process() task.launchPath = "/bin/bash" task.arguments = ["-c", "getconf DARWIN_USER_CACHE_DIR; getconf DARWIN_USER_TEMP_DIR"] let pipe = Pipe() task.standardOutput = pipe task.launch() let data = pipe.fileHandleForReading.readDataToEndOfFile() if let output = String(data: data, encoding: .utf8) { let components = output.components(separatedBy: "\n") if components.count >= 2 { var cacheDir = components[0].trimmingCharacters(in: .whitespacesAndNewlines) var tempDir = components[1].trimmingCharacters(in: .whitespacesAndNewlines) if cacheDir.hasSuffix("/") { cacheDir.removeLast() } if tempDir.hasSuffix("/") { tempDir.removeLast() } return (cacheDir, tempDir) } } printOS("Could not get DARWIN_USER_CACHE_DIR or DARWIN_USER_TEMP_DIR") return ("", "") } func listAppSupportDirectories() -> [String] { let home = FileManager.default.homeDirectoryForCurrentUser let appSupportLocation = home.appendingPathComponent("Library/Application Support/") let exclusions = ["MobileSync", ".DS_Store", "Xcode", "SyncServices", "networkserviceproxy", "DiskImages", "CallHistoryTransactions", "App Store", "CloudDocs", "icdd", "iCloud", "Instruments", "AddressBook", "FaceTime", "AskPermission", "CallHistoryDB"] let exclusionRegex = try! NSRegularExpression(pattern: "\\bcom\\.apple\\b", options: []) do { let fileManager = FileManager.default let directoryContents = try fileManager.contentsOfDirectory(at: appSupportLocation, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles) let filteredDirectories: [String] = directoryContents.compactMap { url in var isDirectory: ObjCBool = false guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return nil } let directoryName = url.lastPathComponent // Check for exclusions using regex and provided list let excludeByRegex = exclusionRegex.firstMatch(in: directoryName, options: [], range: NSRange(location: 0, length: directoryName.utf16.count)) != nil let excludeByList = exclusions.contains(directoryName) return isDirectory.boolValue && !excludeByRegex && !excludeByList ? directoryName : nil } return filteredDirectories } catch { printOS("Error listing AppSupport directories: \(error.localizedDescription)") return [] } } // Check if app is running before deleting app files func killApp(appId: String, completion: @escaping () -> Void = {}) { let runningApps = NSWorkspace.shared.runningApplications for app in runningApps { if app.bundleIdentifier == appId { app.terminate() } } completion() } // Find all possible paths for an app based on name/bundle id func findPathsForApp(appInfo: AppInfo = .empty, appState: AppState, locations: Locations, backgroundRun: Bool = false, completion: @escaping () -> Void = {}) { Task(priority: .high) { var collection: [URL] = [] if let url = URL(string: appInfo.path.absoluteString) { if !url.path.contains(".Trash") { if url.path.contains("Wrapper") { let modifiedUrl = url.deletingLastPathComponent().deletingLastPathComponent() collection.insert(modifiedUrl, at: 0) } else { collection.insert(url, at: 0) } } } let fileManager = FileManager.default let dispatchGroup = DispatchGroup() var bundleComponents = appInfo.bundleIdentifier.components(separatedBy: ".") if let lastComponent = bundleComponents.last, let rangeOfDash = lastComponent.range(of: "-") { let updatedLastComponent = String(lastComponent[..= 3 { // get last 2 or middle 2 components bundle = bundleComponents[1...2].joined(separator: "").lowercased() } let nameL = appInfo.appName.pearFormat() let nameP = appInfo.path.lastPathComponent.replacingOccurrences(of: ".app", with: "") let bundleIdentifierL = appInfo.bundleIdentifier.pearFormat() for location in locations.apps.paths { if !fileManager.fileExists(atPath: location) { continue } dispatchGroup.enter() // Enter the dispatch group do { let contents = try fileManager.contentsOfDirectory(atPath: location) for item in contents { let itemURL = URL(fileURLWithPath: location).appendingPathComponent(item) let itemL = ("\(item)").replacingOccurrences(of: ".", with: "").replacingOccurrences(of: " ", with: "").lowercased() let filterItem = "\(location)/\(itemL)" // Skip directories that start with com.apple. except for a few if itemL.hasPrefix("comapple") && !itemL.hasPrefix("comappleconfigurator") && !itemL.hasPrefix("comappledt") && !itemL.hasPrefix("comappleiwork") { continue } // Skip adding item if the collection already has it to prevent duplicates if collection.contains(itemURL) { continue } // Skip if not a regular file, directory or symlink if !isSupportedFileType(at: itemURL.path) { continue } // Catch web app plist files if appInfo.webApp { if itemL.contains(bundleIdentifierL) { if collection.contains(itemURL) { continue } collection.append(itemURL) } } else { // Catch Xcode files for Xcodes app if itemL.contains("xcode") && bundleIdentifierL.contains("comappledt") { if filterItem.contains("comrobotsandpencilsxcodesapp") || filterItem.contains("comoneminutegamesxcodecleaner") || filterItem.contains("iohyperappxcodecleaner") || filterItem.contains("xcodesjson") { continue } if itemL.contains(bundle) || itemL.contains(bundleIdentifierL) || (nameL.count < 6 && itemL.contains(nameL)) { collection.append(itemURL) } } else if itemL.contains("xcodes") && bundleIdentifierL.contains("comrobotsandpencilsxcodesapp") { if filterItem.contains("comappledt") { continue } if itemL.contains(bundle) || itemL.contains(bundleIdentifierL) || (nameL.count > 4 && itemL.contains(nameL)) { collection.append(itemURL) } } else { if itemL.contains(bundleIdentifierL) || itemL.contains(bundle) || (nameL.count > 3 && itemL.contains(nameL) || (nameP.count > 3 && itemL.contains(nameP))) { collection.append(itemURL) } } } } } catch { printOS("Error processing location:", location, error) continue } dispatchGroup.leave() // Leave the dispatch group } // Append group containers let groupContainers = getGroupContainers(bundleURL: appState.appInfo.path) collection.append(contentsOf: groupContainers) var sortedCollection = collection.sorted(by: { $0.absoluteString < $1.absoluteString }) // Calculate file details (sizes and icons) var fileSize: [URL: Int64] = [:] var fileIcon: [URL: NSImage?] = [:] var updatedAppInfo = appInfo for path in collection { var size: Int64 var icon: NSImage? = nil size = totalSizeOnDisk(for: path) icon = getIconForFileOrFolderNS(atPath: path) fileSize[path] = size fileIcon[path] = icon } if sortedCollection.count == 1 { if let firstURL = sortedCollection.first, firstURL.path.contains(".Trash") { sortedCollection = [] } } // Save to appState dispatchGroup.notify(queue: .main) { updateOnMain { updatedAppInfo.files = sortedCollection updatedAppInfo.fileSize = fileSize updatedAppInfo.fileIcon = fileIcon if !backgroundRun { appState.appInfo = updatedAppInfo appState.selectedItems = Set(sortedCollection) } appState.appInfoStore.append(updatedAppInfo) } } completion() } } // Load app paths on launch func loadAllPaths(allApps: [AppInfo], appState: AppState, locations: Locations, reverseAddon: Bool = false, completion: @escaping () -> Void = {}) { let dispatchGroup = DispatchGroup() let retryLimit = 120 var currentRetryCount = 0 func checkCompletion() { if appState.appInfoStore.count == allApps.count { if reverseAddon { reversePathsSearch(appState: appState, locations: locations) } completion() } else { if currentRetryCount < retryLimit { currentRetryCount += 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { checkCompletion() } } else { printOS("loadAllPaths - Retry limit timed out. Unable to load all paths within 1 minute.") completion() } } } dispatchGroup.enter() updateOnMain { appState.appInfoStore.removeAll() } dispatchGroup.notify(queue: .main) { checkCompletion() } DispatchQueue.global(qos: .background).async { for app in allApps { findPathsForApp(appInfo: app, appState: appState, locations: locations, backgroundRun: true) } dispatchGroup.leave() } } // Load item in Files view func showAppInFiles(appInfo: AppInfo, mini: Bool, appState: AppState, locations: Locations, showPopover: Binding) { showPopover.wrappedValue = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { updateOnMain { appState.appInfo = .empty appState.selectedItems = [] if let storedAppInfo = appState.appInfoStore.first(where: { $0.path == appInfo.path }) { appState.appInfo = storedAppInfo appState.selectedItems = Set(storedAppInfo.files) withAnimation(Animation.easeIn(duration: 0.4)) { if mini { appState.currentView = .files showPopover.wrappedValue.toggle() } else { appState.currentView = .files } } } else { // Handle the case where the appInfo is not found in the store withAnimation(Animation.easeIn(duration: 0.4)) { appState.showProgress = true if mini { appState.currentView = .files showPopover.wrappedValue.toggle() } else { appState.currentView = .files } } appState.appInfo = appInfo findPathsForApp(appInfo: appInfo, appState: appState, locations: locations) { withAnimation(Animation.easeIn(duration: 0.4)) { updateOnMain { appState.showProgress = false } } } } } } } // Get group containers func getGroupContainers(bundleURL: URL) -> [URL] { let fileManager = FileManager.default var staticCode: SecStaticCode? guard SecStaticCodeCreateWithPath(bundleURL as CFURL, [], &staticCode) == errSecSuccess else { return [] } var signingInformation: CFDictionary? let status = SecCodeCopySigningInformation(staticCode!, SecCSFlags(), &signingInformation) if status != errSecSuccess { printOS("Failed to copy signing information. Status: \(status)") return [] } guard let topDict = signingInformation as? [String: Any], let entitlementsDict = topDict["entitlements-dict"] as? [String: Any], let appGroups = entitlementsDict["com.apple.security.application-groups"] as? [String] else { // printOS("No application groups to extract from entitlements for this app.") return [] } let groupContainersPath = appGroups.map { URL(fileURLWithPath: "\(home)/Library/Group Containers/" + $0) } let existingGroupContainers = groupContainersPath.filter { fileManager.fileExists(atPath: $0.path) } return existingGroupContainers } // Reverse search for leftover zombie files func reversePathsSearch(appState: AppState, locations: Locations, completion: @escaping () -> Void = {}) { Task(priority: .high) { var collection: [URL] = [] let fileManager = FileManager.default let dispatchGroup = DispatchGroup() let allPaths = appState.appInfoStore.flatMap { $0.files.map { $0.path.pearFormat() } } let allNames = appState.appInfoStore.map { $0.appName.pearFormat() } let skipped = ["apple", "temporary", "btserver", "proapps", "scripteditor", "ilife", "livefsd", "siritoday", "addressbook", "animoji", "appstore", "askpermission", "callhistory", "clouddocs", "diskimages", "dock", "facetime", "fileprovider", "instruments", "knowledge", "mobilesync", "syncservices", "homeenergyd", "icloud", "icdd", "networkserviceproxy", "familycircle", "geoservices", "installation", "passkit", "sharedimagecache", "desktop", "mbuseragent", "swiftpm", "baseband", "coresimulator", "photoslegacyupgrade", "photosupgrade", "siritts", "ipod", "globalpreferences", "apmanalytics", "apmexperiment", "avatarcache", "byhost", "contextstoreagent", "mobilemeaccounts", "intentbuilderc", "loginwindow", "momc", "replayd", "sharedfilelistd", "clang", "audiocomponent", "csexattrcryptoservice", "livetranscriptionagent", "sandboxhelper", "statuskitagent", "betaenrollmentd", "contentlinkingd", "diagnosticextensionsd", "gamed", "heard", "homed", "itunescloudd", "lldb", "mds", "mediaanalysisd", "metrickitd", "mobiletimerd", "proactived", "ptpcamerad", "studentd", "talagent", "watchlistd", "apptranslocation", "xcrun", "ds_store", "caches", "crashreporter", "trash", "pearcleaner", "amsdatamigratortool", "arfilecache", "assistant", "chromium", "cloudkit", "webkit", "databases", "diagnostic", "cache", "gamekit", "homebrew", "logi", "microsoft", "mozilla", "sync", "google", "sentinel", "hexnode", "sentry", "tvappservices"] // Skip system folders // Skip locations that might not exist for location in locations.reverse.paths { if !fileManager.fileExists(atPath: location) { continue } dispatchGroup.enter() do { let contents = try fileManager.contentsOfDirectory(atPath: location) for item in contents { let itemURL = URL(fileURLWithPath: location).appendingPathComponent(item) let itemName = ("\(item)").pearFormat() let itemPath = "\(location)/\(itemName)".pearFormat() if skipped.contains(where: { itemName.contains($0) }) { continue } if allPaths.contains(where: { $0 == itemPath }) || allNames.contains(where: { $0 == itemName }) { continue } if !isSupportedFileType(at: itemURL.path) { continue } collection.append(itemURL) } } catch { printOS("Error processing location:", location, error) continue } dispatchGroup.leave() // Leave the dispatch group } // Calculate file details (sizes and icons) var fileSize: [URL: Int64] = [:] var fileIcon: [URL: NSImage?] = [:] var updatedZombieFile = ZombieFile.empty for path in collection { var size: Int64 var icon: NSImage? = nil size = totalSizeOnDisk(for: path) icon = getIconForFileOrFolderNS(atPath: path) fileSize[path] = size fileIcon[path] = icon } // Save to appState dispatchGroup.notify(queue: .main) { updateOnMain { updatedZombieFile.fileSize = fileSize updatedZombieFile.fileIcon = fileIcon appState.zombieFile = updatedZombieFile appState.showProgress = false } } completion() } } // Move files to trash using applescript/Finder so it asks for user password if needed func moveFilesToTrash(at fileURLs: [URL], completion: @escaping () -> Void = {}) { @AppStorage("settings.sentinel.enable") var sentinel: Bool = false // var filesFinder = fileURLs // var filesSudo: [URL] = [] // // for file in fileURLs { // if isSocketFile(at: file) { // if let index = filesFinder.firstIndex(of: file) { // filesFinder.remove(at: index) // filesSudo.insert(file, at: 0) // } // } // } // // if !filesSudo.isEmpty { // // Remove socket files with rm // let filesSudoPaths = filesSudo.map { $0.path } // do { // let fileHandler = try Authorization.executeWithPrivileges("/bin/rm -f \(filesSudoPaths.joined(separator: " "))").get() // printOS(String(bytes: fileHandler.readDataToEndOfFile(), encoding: .utf8)!) // } catch { // printOS("Failed to remove socket file/s with privileges: \(error)") // } // } updateOnBackground { let posixFiles = fileURLs.map { "POSIX file \"\($0.path)\", " }.joined().dropLast(3) let scriptSource = """ tell application \"Finder\" to delete { \(posixFiles)" } """ var error: NSDictionary? if let scriptObject = NSAppleScript(source: scriptSource) { let output: NSAppleEventDescriptor = scriptObject.executeAndReturnError(&error) if let error = error { printOS("Error: \(error)") } else if let outputString = output.stringValue { printOS(outputString) } } DispatchQueue.main.async { completion() } } } func isSocketFile(at url: URL) -> Bool { do { let attributes = try FileManager.default.attributesOfItem(atPath: url.path) if let fileType = attributes[FileAttributeKey.type] as? FileAttributeType { return fileType == .typeSocket } } catch { print("Error: \(error)") } return false } func isSupportedFileType(at path: String) -> Bool { let fileManager = FileManager.default do { let attributes = try fileManager.attributesOfItem(atPath: path) if let fileType = attributes[FileAttributeKey.type] as? FileAttributeType { switch fileType { case .typeRegular, .typeDirectory, .typeSymbolicLink: // The file is a regular file, directory, or symbolic link return true default: // The file is a socket, pipe, or another type not supported return false } } } catch { print("Error getting file attributes: \(error)") } return false } // Undo trash action func undoTrash(appState: AppState, completion: @escaping () -> Void = {}) { let scriptSource = """ tell application "Finder" activate end tell tell application "System Events" keystroke "z" using command down end tell tell application "Pearcleaner" activate end tell """ var error: NSDictionary? updateOnBackground { if let scriptObject = NSAppleScript(source: scriptSource) { let output: NSAppleEventDescriptor = scriptObject.executeAndReturnError(&error) if let error = error { if let value = error["NSAppleScriptErrorNumber"] { if value as! Int == 1002 { _ = checkAndRequestAccessibilityAccess(appState: appState) } } printOS("Error: \(error)") } else if let outputString = output.stringValue { printOS(outputString) } } DispatchQueue.main.async { completion() } } }