diff --git a/Pearcleaner.xcodeproj/project.pbxproj b/Pearcleaner.xcodeproj/project.pbxproj index 6a69049..6b4f6e1 100644 --- a/Pearcleaner.xcodeproj/project.pbxproj +++ b/Pearcleaner.xcodeproj/project.pbxproj @@ -37,6 +37,10 @@ C7D31D4A2AFEB26700C7ED9E /* AppListItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D31D492AFEB26700C7ED9E /* AppListItems.swift */; }; C7D31D512AFF00F300C7ED9E /* Locations.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D31D502AFF00F300C7ED9E /* Locations.swift */; }; C7DC1FEE2B9F7D3D009AC317 /* features.json in Resources */ = {isa = PBXBuildFile; fileRef = C7DC1FED2B9F7D3D009AC317 /* features.json */; }; + C7DD49EA2BAB4D8100CCBA16 /* AppInfoFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DD49E92BAB4D8100CCBA16 /* AppInfoFetch.swift */; }; + C7DD49EC2BAB58AA00CCBA16 /* AppPathsFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DD49EB2BAB58AA00CCBA16 /* AppPathsFetch.swift */; }; + C7DD49EE2BAB7F6000CCBA16 /* ReversePathsFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DD49ED2BAB7F6000CCBA16 /* ReversePathsFetch.swift */; }; + C7DD49F02BABA14400CCBA16 /* Folders.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DD49EF2BABA14400CCBA16 /* Folders.swift */; }; C7DE672B2BA6343D00EB1633 /* MenuBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DE672A2BA6343D00EB1633 /* MenuBarItem.swift */; }; C7DE672D2BA6356500EB1633 /* MenuBarMiniAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DE672C2BA6356500EB1633 /* MenuBarMiniAppView.swift */; }; C7ED86B12BA8A35500F13DE4 /* Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7ED86B02BA8A35500F13DE4 /* Interface.swift */; }; @@ -101,6 +105,10 @@ C7D31D492AFEB26700C7ED9E /* AppListItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppListItems.swift; sourceTree = ""; }; C7D31D502AFF00F300C7ED9E /* Locations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locations.swift; sourceTree = ""; }; C7DC1FED2B9F7D3D009AC317 /* features.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = features.json; sourceTree = SOURCE_ROOT; }; + C7DD49E92BAB4D8100CCBA16 /* AppInfoFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoFetch.swift; sourceTree = ""; }; + C7DD49EB2BAB58AA00CCBA16 /* AppPathsFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPathsFetch.swift; sourceTree = ""; }; + C7DD49ED2BAB7F6000CCBA16 /* ReversePathsFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReversePathsFetch.swift; sourceTree = ""; }; + C7DD49EF2BABA14400CCBA16 /* Folders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folders.swift; sourceTree = ""; }; C7DE672A2BA6343D00EB1633 /* MenuBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarItem.swift; sourceTree = ""; }; C7DE672C2BA6356500EB1633 /* MenuBarMiniAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarMiniAppView.swift; sourceTree = ""; }; C7ED86B02BA8A35500F13DE4 /* Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interface.swift; sourceTree = ""; }; @@ -144,6 +152,9 @@ isa = PBXGroup; children = ( C77B90192AF1938F009CC655 /* Logic.swift */, + C7DD49E92BAB4D8100CCBA16 /* AppInfoFetch.swift */, + C7DD49EB2BAB58AA00CCBA16 /* AppPathsFetch.swift */, + C7DD49ED2BAB7F6000CCBA16 /* ReversePathsFetch.swift */, C77B90172AF19382009CC655 /* AppCommands.swift */, C77B90152AF19377009CC655 /* AppState.swift */, C7F539372AF60865007DF1B2 /* Utilities.swift */, @@ -179,6 +190,7 @@ C77B901D2AF1A9DB009CC655 /* SettingsWindow.swift */, C76D08492AF83C6E00D07867 /* General.swift */, C7ED86B02BA8A35500F13DE4 /* Interface.swift */, + C7DD49EF2BABA14400CCBA16 /* Folders.swift */, C76D08472AF83C3F00D07867 /* Update.swift */, C76D08502AF850F300D07867 /* About.swift */, ); @@ -354,11 +366,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C7DD49EA2BAB4D8100CCBA16 /* AppInfoFetch.swift in Sources */, C76D08482AF83C3F00D07867 /* Update.swift in Sources */, C7FB173B2B96321300B96F9A /* AppsListView.swift in Sources */, C7045A342B068B2900376976 /* UpdateView.swift in Sources */, C77B90162AF19377009CC655 /* AppState.swift in Sources */, + C7DD49EE2BAB7F6000CCBA16 /* ReversePathsFetch.swift in Sources */, C7045A322B068AD700376976 /* NewWin.swift in Sources */, + C7DD49F02BABA14400CCBA16 /* Folders.swift in Sources */, C7DE672D2BA6356500EB1633 /* MenuBarMiniAppView.swift in Sources */, C7ED86B32BA8ED8800F13DE4 /* Trash.swift in Sources */, C7D31D512AFF00F300C7ED9E /* Locations.swift in Sources */, @@ -378,6 +393,7 @@ C7D31D4A2AFEB26700C7ED9E /* AppListItems.swift in Sources */, C77B901E2AF1A9DB009CC655 /* SettingsWindow.swift in Sources */, C7D31D482AFEB23700C7ED9E /* TopBar.swift in Sources */, + C7DD49EC2BAB58AA00CCBA16 /* AppPathsFetch.swift in Sources */, C7ACADE62B92A737000B5845 /* Features.swift in Sources */, C7F539382AF60865007DF1B2 /* Utilities.swift in Sources */, C76D08552AF89CDE00D07867 /* RegularMode.swift in Sources */, @@ -559,7 +575,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = BK8443AXLU; @@ -577,7 +593,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 3.2.1; + MARKETING_VERSION = 3.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.alienator88.Pearcleaner; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -594,7 +610,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = BK8443AXLU; @@ -612,7 +628,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 3.2.1; + MARKETING_VERSION = 3.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.alienator88.Pearcleaner; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Pearcleaner/Logic/AppCommands.swift b/Pearcleaner/Logic/AppCommands.swift index 9b1e811..3ad4720 100644 --- a/Pearcleaner/Logic/AppCommands.swift +++ b/Pearcleaner/Logic/AppCommands.swift @@ -12,10 +12,12 @@ struct AppCommands: Commands { let appState: AppState let locations: Locations + let fsm: FolderSettingsManager - init(appState: AppState, locations: Locations) { + init(appState: AppState, locations: Locations, fsm: FolderSettingsManager) { self.appState = appState self.locations = locations + self.fsm = fsm } var body: some Commands { @@ -36,7 +38,7 @@ struct AppCommands: Commands { updateOnMain { appState.reload.toggle() } - let sortedApps = getSortedApps() + let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { appState.sortedApps = [] // appState.sortedApps.systemApps = [] @@ -71,7 +73,7 @@ struct AppCommands: Commands { Button { undoTrash(appState: appState) { - let sortedApps = getSortedApps() + let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { appState.sortedApps = sortedApps // if instantSearch { diff --git a/Pearcleaner/Logic/AppInfoFetch.swift b/Pearcleaner/Logic/AppInfoFetch.swift new file mode 100644 index 0000000..e98e947 --- /dev/null +++ b/Pearcleaner/Logic/AppInfoFetch.swift @@ -0,0 +1,174 @@ +// +// AppInfoFetch.swift +// Pearcleaner +// +// Created by Alin Lupascu on 3/20/24. +// + +import Foundation +import SwiftUI + +class AppInfoFetcher { + static let fileManager = FileManager.default + + static func getAppInfo(atPath path: URL, wrapped: Bool = false) -> AppInfo? { + if isDirectoryWrapped(path: path) { + return handleWrappedDirectory(atPath: path) + } else { + return createAppInfoFromBundle(atPath: path, wrapped: wrapped) + } + } + + private static func isDirectoryWrapped(path: URL) -> Bool { + let wrapperURL = path.appendingPathComponent("Wrapper") + return fileManager.fileExists(atPath: wrapperURL.path) + } + + private static func handleWrappedDirectory(atPath path: URL) -> AppInfo? { + let wrapperURL = path.appendingPathComponent("Wrapper") + do { + let contents = try fileManager.contentsOfDirectory(at: wrapperURL, includingPropertiesForKeys: nil) + guard let firstAppFile = contents.first(where: { $0.pathExtension == "app" }) else { + printOS("No .app files found in the 'Wrapper' directory: \(wrapperURL)") + return nil + } + let fullPath = wrapperURL.appendingPathComponent(firstAppFile.lastPathComponent) + return getAppInfo(atPath: fullPath, wrapped: true) + } catch { + printOS("Error reading contents of 'Wrapper' directory: \(error.localizedDescription)\n\(wrapperURL)") + return nil + } + } + + private static func createAppInfoFromBundle(atPath path: URL, wrapped: Bool) -> AppInfo? { + guard let bundle = Bundle(url: path), let bundleIdentifier = bundle.bundleIdentifier else { + printOS("Bundle not found or missing bundle identifier at path: \(path)") + return nil + } + + let appName = bundle.localizedInfoDictionary?[kCFBundleNameKey as String] as? String + ?? bundle.infoDictionary?["CFBundleName"] as? String + ?? path.deletingPathExtension().lastPathComponent + + let appVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String + ?? bundle.infoDictionary?["CFBundleVersion"] as? String + ?? "" + + let appIcon = fetchAppIcon(for: path, wrapped: wrapped) + + let webApp = bundle.infoDictionary?["LSTemplateApplication"] as? Bool ?? false + let system = !path.path.contains(NSHomeDirectory()) + + return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName, appVersion: appVersion, appIcon: appIcon, + webApp: webApp, wrapped: wrapped, system: system, files: [], fileSize: [:], fileIcon: [:]) + } + + private static func fetchAppIcon(for path: URL, wrapped: Bool) -> NSImage? { + let iconPath = wrapped ? path.deletingLastPathComponent().deletingLastPathComponent() : path + if let appIcon = getIconForFileOrFolderNS(atPath: iconPath) { + return convertICNSToPNG(icon: appIcon, size: NSSize(width: 100, height: 100)) + } else { + printOS("App Icon not found for app at path: \(path)") + return nil + } + } + +} + + + +// OLD FUNCTION ============================================================ + +//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 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 +// } +// +// // Get icon from app +// if path.absoluteString.contains("Wrapper") { +// appIcon = getIconForFileOrFolderNS(atPath: path.deletingLastPathComponent().deletingLastPathComponent()) +// } else { +// appIcon = getIconForFileOrFolderNS(atPath: path) +// } +// +// // 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 +//} diff --git a/Pearcleaner/Logic/AppPathsFetch.swift b/Pearcleaner/Logic/AppPathsFetch.swift new file mode 100644 index 0000000..fef0e65 --- /dev/null +++ b/Pearcleaner/Logic/AppPathsFetch.swift @@ -0,0 +1,226 @@ +// +// AppPathsFetch.swift +// Pearcleaner +// +// Created by Alin Lupascu on 3/20/24. +// + +import Foundation +import AppKit +import SwiftUI + +class AppPathFinder { + private var appInfo: AppInfo + private var appState: AppState + private var locations: Locations + private var backgroundRun: Bool + private var completion: () -> Void = {} + private var collection: [URL] = [] + private let collectionAccessQueue = DispatchQueue(label: "com.alienator88.Pearcleaner.appPathFinder.collectionAccess") + @AppStorage("settings.general.instant") var instantSearch: Bool = true + + init(appInfo: AppInfo = .empty, appState: AppState, locations: Locations, backgroundRun: Bool = false, completion: @escaping () -> Void = {}) { + self.appInfo = appInfo + self.appState = appState + self.locations = locations + self.backgroundRun = backgroundRun + self.completion = completion + } + + func findPaths() { + Task(priority: .background) { + self.initialURLProcessing() + let dispatchGroup = DispatchGroup() + + for location in self.locations.apps.paths { + dispatchGroup.enter() + DispatchQueue.global(qos: .userInitiated).async { + self.processLocation(location, with: dispatchGroup) + dispatchGroup.leave() + } + } + + dispatchGroup.notify(queue: .main) { + self.finalizeCollection() + } + } + } + + private func initialURLProcessing() { + if let url = URL(string: self.appInfo.path.absoluteString), !url.path.contains(".Trash") { + let modifiedUrl = url.path.contains("Wrapper") ? url.deletingLastPathComponent().deletingLastPathComponent() : url + self.collection.append(modifiedUrl) + } + } + + private func processLocation(_ location: String, with dispatchGroup: DispatchGroup) { + // Check if the directory exists before attempting to read its contents + if FileManager.default.fileExists(atPath: location) { + do { + let contents = try FileManager.default.contentsOfDirectory(atPath: location) + + for item in contents { + let itemURL = URL(fileURLWithPath: location).appendingPathComponent(item) + let itemL = item.replacingOccurrences(of: ".", with: "").replacingOccurrences(of: " ", with: "").lowercased() + + if shouldSkipItem(itemL, at: itemURL) { continue } + + if specificCondition(itemL: itemL, itemURL: itemURL) { + collectionAccessQueue.async { + self.collection.append(itemURL) + } + } + } + } catch { + // If an error occurs while trying to read the directory's contents, log or handle it here if needed + printOS("Error processing location: \(location), \(error)") + } + } else { + // The directory does not exist; skip it + } + } + + private func shouldSkipItem(_ itemL: String, at itemURL: URL) -> Bool { + var containsItem = false + collectionAccessQueue.sync { + containsItem = self.collection.contains(itemURL) + } + return itemL.hasPrefix("comapple") && !["comappleconfigurator", "comappledt", "comappleiwork"].contains(where: itemL.hasPrefix) || containsItem || !isSupportedFileType(at: itemURL.path) + } + + private func specificCondition(itemL: String, itemURL: URL) -> Bool { + let bundleIdentifierL = self.appInfo.bundleIdentifier.pearFormat() + let bundleComponents = self.appInfo.bundleIdentifier.components(separatedBy: ".").compactMap { $0 != "-" ? $0.lowercased() : nil } + let bundle = bundleComponents.suffix(2).joined() + let nameL = self.appInfo.appName.pearFormat() + let nameP = self.appInfo.path.lastPathComponent.replacingOccurrences(of: ".app", with: "") + + if self.appInfo.webApp { + return itemL.contains(bundleIdentifierL) + } else { + if itemL.contains("xcode") && bundleIdentifierL.contains("comappledt") { + return !(itemURL.path.contains("comrobotsandpencilsxcodesapp") || itemURL.path.contains("comoneminutegamesxcodecleaner") || itemURL.path.contains("iohyperappxcodecleaner") || itemURL.path.contains("xcodesjson")) && (itemL.contains(bundle) || itemL.contains(bundleIdentifierL) || (nameL.count < 6 && itemL.contains(nameL))) + } else if itemL.contains("xcodes") && bundleIdentifierL.contains("comrobotsandpencilsxcodesapp") { + return !(itemURL.path.contains("comappledt")) && (itemL.contains(bundle) || itemL.contains(bundleIdentifierL) || (nameL.count > 4 && itemL.contains(nameL))) + } else { + return itemL.contains(bundleIdentifierL) || itemL.contains(bundle) || (nameL.count > 3 && itemL.contains(nameL)) || (nameP.count > 3 && itemL.contains(nameP)) + } + } + } + + + + private func filterParentDirectories(in collection: [URL]) -> [URL] { + // Normalize URLs by removing percent encoding and trailing slashes for accurate comparison + var filteredURLs: [URL] = [] + let normalizedURLs = collection.map { url -> URL in + let path = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" + return URL(fileURLWithPath: path) + } + + for url in normalizedURLs { + // Determine if 'url' is a parent of any path in 'filteredURLs' + if !filteredURLs.contains(where: { $0.absoluteString.hasPrefix(url.absoluteString) }) { + filteredURLs.append(url) + } + } + + // Remove URLs that are parents of another URL in the list + filteredURLs = filteredURLs.filter { parentUrl in + !filteredURLs.contains { childUrl in + childUrl != parentUrl && childUrl.absoluteString.hasPrefix(parentUrl.absoluteString) + } + } + + return filteredURLs + } + + + private func getGroupContainers(bundleURL: URL) -> [URL] { + // Get group containers + 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.default.fileExists(atPath: $0.path) } + + return existingGroupContainers + } + + private func finalizeCollection() { + DispatchQueue.global(qos: .userInitiated).async { + let groupContainers = self.getGroupContainers(bundleURL: self.appInfo.path) + var tempCollection: [URL] = [] + self.collectionAccessQueue.sync { + tempCollection = self.collection + } + tempCollection.append(contentsOf: groupContainers) + + // Apply the filter to remove parent directories + let filteredCollection = self.filterParentDirectories(in: tempCollection) + + // Continue with the sorted collection + let sortedCollection = filteredCollection.sorted(by: { $0.absoluteString < $1.absoluteString }) + self.handlePostProcessing(sortedCollection: sortedCollection) + } + + } + + private func handlePostProcessing(sortedCollection: [URL]) { + // Calculate file details (sizes and icons), update app state, and call completion + var fileSize: [URL: Int64] = [:] + var fileIcon: [URL: NSImage?] = [:] + + for path in sortedCollection { + fileSize[path] = totalSizeOnDisk(for: path) + fileIcon[path] = getIconForFileOrFolderNS(atPath: path) + } + + DispatchQueue.main.async { + var updatedCollection = sortedCollection + if updatedCollection.count == 1, let firstURL = updatedCollection.first, firstURL.path.contains(".Trash") { + updatedCollection.removeAll() + } + + // Update appInfo and appState with the new values + self.appInfo.files = updatedCollection + self.appInfo.fileSize = fileSize + self.appInfo.fileIcon = fileIcon + + if !self.backgroundRun { + self.appState.appInfo = self.appInfo + self.appState.selectedItems = Set(updatedCollection) + } + self.appState.appInfoStore.append(self.appInfo) + + if self.instantSearch { + self.appState.instantProgress += 1 + } + + self.completion() + } + } +} + + + +//assert(!Thread.isMainThread, "This method should not run on the main thread") diff --git a/Pearcleaner/Logic/AppState.swift b/Pearcleaner/Logic/AppState.swift index d80e1f2..da09df7 100644 --- a/Pearcleaner/Logic/AppState.swift +++ b/Pearcleaner/Logic/AppState.swift @@ -28,6 +28,8 @@ class AppState: ObservableObject @Published var reload: Bool = false @Published var showProgress: Bool = false @Published var popCount: Int = 0 + @Published var instantProgress: Double = 0.0 + @Published var instantTotal: Double = 0.0 init() { @@ -106,6 +108,7 @@ enum CurrentTabView:Int { case general case interface + case folders case update case about @@ -113,6 +116,7 @@ enum CurrentTabView:Int switch self { case .general: return "General" case .interface: return "Interface" + case .folders: return "Folders" case .update: return "Update" case .about: return "About" } diff --git a/Pearcleaner/Logic/DeepLink.swift b/Pearcleaner/Logic/DeepLink.swift index 387682e..a4539ec 100644 --- a/Pearcleaner/Logic/DeepLink.swift +++ b/Pearcleaner/Logic/DeepLink.swift @@ -33,7 +33,7 @@ class DeeplinkManager { let queryItems = components.queryItems { if let path = queryItems.first(where: { $0.name == DeepLinkConstants.query })?.value { let pathURL = URL(fileURLWithPath: path) - let appInfo = getAppInfo(atPath: pathURL) + let appInfo = AppInfoFetcher.getAppInfo(atPath: pathURL) showAppInFiles(appInfo: appInfo!, appState: appState, locations: locations, showPopover: $showPopover) } else { printOS("No path query parameter found in the URL") @@ -45,7 +45,7 @@ class DeeplinkManager { func handleAppBundle(url: URL, appState: AppState, locations: Locations) { - let appInfo = getAppInfo(atPath: url) + let appInfo = AppInfoFetcher.getAppInfo(atPath: url) showAppInFiles(appInfo: appInfo!, appState: appState, locations: locations, showPopover: $showPopover) } diff --git a/Pearcleaner/Logic/Locations.swift b/Pearcleaner/Logic/Locations.swift index 5325f85..9e859a9 100644 --- a/Pearcleaner/Logic/Locations.swift +++ b/Pearcleaner/Logic/Locations.swift @@ -14,7 +14,6 @@ class Locations: ObservableObject { var paths: [String] } - let home: String let cacheDir: String let tempDir: String @@ -24,7 +23,6 @@ class Locations: ObservableObject { // var plugins: Category init() { - self.home = FileManager.default.homeDirectoryForCurrentUser.path let (cacheDir, tempDir) = darwinCT() self.cacheDir = cacheDir self.tempDir = tempDir diff --git a/Pearcleaner/Logic/Logic.swift b/Pearcleaner/Logic/Logic.swift index 2699bcd..8a6c0aa 100644 --- a/Pearcleaner/Logic/Logic.swift +++ b/Pearcleaner/Logic/Logic.swift @@ -8,16 +8,13 @@ import Foundation import SwiftUI + // Get all apps from /Applications and ~/Applications -func getSortedApps() -> [AppInfo] { +func getSortedApps(paths: [String], appState: AppState) -> [AppInfo] { + @AppStorage("settings.general.instant") var instantSearch: Bool = true 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: []) @@ -37,214 +34,78 @@ func getSortedApps() -> [AppInfo] { } } - // Collect system applications - collectAppPaths(at: systemAppsPath) - - // Collect user applications - collectAppPaths(at: userAppsPath) + paths.forEach { collectAppPaths(at: $0) } // Get app info and sort let sortedApps = apps - .compactMap { getAppInfo(atPath: $0) } + .compactMap { AppInfoFetcher.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)") + if instantSearch { + updateOnMain { + appState.instantTotal = Double(sortedApps.count) } } - return nil + + return sortedApps } // 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 command = "echo $(getconf DARWIN_USER_CACHE_DIR) $(getconf DARWIN_USER_TEMP_DIR)" + let process = Process() + process.launchPath = "/bin/bash" + process.arguments = ["-c", command] + let pipe = Pipe() - task.standardOutput = pipe - task.launch() + process.standardOutput = pipe + process.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) - } + guard let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else { + printOS("Could not get DARWIN_USER_CACHE_DIR or DARWIN_USER_TEMP_DIR") + return ("", "") } - printOS("Could not get DARWIN_USER_CACHE_DIR or DARWIN_USER_TEMP_DIR") - return ("", "") + + let paths = output.split(separator: " ").map(String.init) + guard paths.count >= 2 else { + printOS("Could not parse DARWIN_USER_CACHE_DIR or DARWIN_USER_TEMP_DIR") + return ("", "") + } + return (paths[0].trimmingCharacters(in: .whitespaces), paths[1].trimmingCharacters(in: .whitespaces)) } + 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 fileManager = FileManager.default + let home = fileManager.homeDirectoryForCurrentUser + let appSupportLocation = home.appendingPathComponent("Library/Application Support").path + let exclusions = Set(["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 directoryContents = try fileManager.contentsOfDirectory(atPath: appSupportLocation) - let filteredDirectories: [String] = directoryContents.compactMap { url in + return directoryContents.compactMap { directoryName in + let fullPath = appSupportLocation.appending("/\(directoryName)") var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + + guard fileManager.fileExists(atPath: fullPath, isDirectory: &isDirectory), + isDirectory.boolValue 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 + if exclusions.contains(directoryName) || excludeByRegex { + return nil + } + return directoryName } - - return filteredDirectories } catch { printOS("Error listing AppSupport directories: \(error.localizedDescription)") return [] @@ -253,382 +114,97 @@ func listAppSupportDirectories() -> [String] { -// 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 + appState.appInfoStore.removeAll() - 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() - } + for app in allApps { + dispatchGroup.enter() + DispatchQueue.global(qos: .background).async { + let pathFinder = AppPathFinder(appInfo: app, appState: appState, locations: locations, backgroundRun: true) + pathFinder.findPaths() + dispatchGroup.leave() } } - 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.wait(timeout: .now() + 60) + + func checkAllAppsProcessed(retryCount: Int = 0, maxRetry: Int = 120) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if appState.appInfoStore.count == allApps.count { + if reverseAddon { + let reverse = ReversePathsSearcher(appState: appState, locations: locations) + reverse.reversePathsSearch() + } + // Reset progress values to 0 + appState.instantProgress = 0 + appState.instantTotal = 0 + + completion() + } else if retryCount < maxRetry { + checkAllAppsProcessed(retryCount: retryCount + 1, maxRetry: maxRetry) + } else { + printOS("loadAllPaths - Retry limit reached. Not all paths were loaded.") + completion() + } + } } - dispatchGroup.leave() + + checkAllAppsProcessed() } - } + + // Load item in Files view func showAppInFiles(appInfo: AppInfo, 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)) { - appState.currentView = .files - showPopover.wrappedValue.toggle() - } - } else { - // Handle the case where the appInfo is not found in the store - withAnimation(Animation.easeIn(duration: 0.4)) { - appState.showProgress = true - appState.currentView = .files - showPopover.wrappedValue.toggle() - } - appState.appInfo = appInfo - findPathsForApp(appInfo: appInfo, appState: appState, locations: locations) { - withAnimation(Animation.easeIn(duration: 0.4)) { - updateOnMain { - appState.showProgress = false - } - } - } + + updateOnMain { + appState.appInfo = .empty + appState.selectedItems = [] + + // Check if the appInfo exists in the appState.appInfoStore + if let storedAppInfo = appState.appInfoStore.first(where: { $0.path == appInfo.path }) { + // Update appState with the stored app info and selected items. + appState.appInfo = storedAppInfo + appState.selectedItems = Set(storedAppInfo.files) + + // Trigger the animation for changing views and showing the popover. + withAnimation(Animation.easeIn(duration: 0.4)) { + appState.currentView = .files + showPopover.wrappedValue.toggle() } - } - - } -} - - -// 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) { + } else { + // When the appInfo is not found, show progress, and search for paths. + appState.showProgress = true + // Initialize the path finder and execute its search. + let pathFinder = AppPathFinder(appInfo: appInfo, appState: appState, locations: locations) { updateOnMain { - updatedZombieFile.fileSize = fileSize - updatedZombieFile.fileIcon = fileIcon - appState.zombieFile = updatedZombieFile + // Update the progress indicator on the main thread once the search completes. appState.showProgress = false } - } + pathFinder.findPaths() + appState.appInfo = appInfo - completion() - + // Animate the view change and popover display. + withAnimation(Animation.easeIn(duration: 0.4)) { + appState.currentView = .files + showPopover.wrappedValue.toggle() + } + } } } - // 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 @@ -658,37 +234,6 @@ func moveFilesToTrash(at fileURLs: [URL], completion: @escaping () -> Void = {}) } -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 diff --git a/Pearcleaner/Logic/ReversePathsFetch.swift b/Pearcleaner/Logic/ReversePathsFetch.swift new file mode 100644 index 0000000..919b4ea --- /dev/null +++ b/Pearcleaner/Logic/ReversePathsFetch.swift @@ -0,0 +1,90 @@ +// +// ReversePathsFetch.swift +// Pearcleaner +// +// Created by Alin Lupascu on 3/20/24. +// + +import Foundation +import AppKit + +class ReversePathsSearcher { + private let appState: AppState + private let locations: Locations + private let fileManager = FileManager.default + private var collection: [URL] = [] + private var fileSize: [URL: Int64] = [:] + private var fileIcon: [URL: NSImage?] = [:] + private var skipped: Set + private let dispatchGroup = DispatchGroup() + + init(appState: AppState, locations: Locations) { + self.appState = appState + self.locations = locations + self.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"] + } + + func reversePathsSearch(completion: @escaping () -> Void = {}) { + Task(priority: .high) { + self.processLocations() + self.calculateFileDetails() + self.updateAppState() + completion() + } + } + + private func processLocations() { + let allPaths = appState.appInfoStore.flatMap { $0.files.map { $0.path.pearFormat() } } + let allNames = appState.appInfoStore.map { $0.appName.pearFormat() } + + for location in locations.reverse.paths where fileManager.fileExists(atPath: location) { + dispatchGroup.enter() + processLocation(location, allPaths: allPaths, allNames: allNames) + dispatchGroup.leave() + } + } + + private func processLocation(_ location: String, allPaths: [String], allNames: [String]) { + do { + let contents = try fileManager.contentsOfDirectory(atPath: location) + contents.forEach { itemName in + let itemURL = URL(fileURLWithPath: location).appendingPathComponent(itemName) + processItem(itemName, itemURL: itemURL, allPaths: allPaths, allNames: allNames) + } + } catch { + printOS("Error processing location: \(location), error: \(error)") + } + } + + private func processItem(_ itemName: String, itemURL: URL, allPaths: [String], allNames: [String]) { + let formattedItemName = itemName.pearFormat() + let itemPath = itemURL.path.pearFormat() + + guard !skipped.contains(where: { formattedItemName.contains($0) }), + !allPaths.contains(itemPath), + !allNames.contains(formattedItemName), + isSupportedFileType(at: itemURL.path) else { + return + } + + collection.append(itemURL) + } + + private func calculateFileDetails() { + collection.forEach { path in + fileSize[path] = totalSizeOnDisk(for: path) + fileIcon[path] = getIconForFileOrFolderNS(atPath: path) + } + } + + private func updateAppState() { + dispatchGroup.notify(queue: .main) { + var updatedZombieFile = ZombieFile.empty + updatedZombieFile.fileSize = self.fileSize + updatedZombieFile.fileIcon = self.fileIcon + self.appState.zombieFile = updatedZombieFile + self.appState.showProgress = false + } + } + +} diff --git a/Pearcleaner/Logic/Styles.swift b/Pearcleaner/Logic/Styles.swift index 29bfc09..69776ca 100644 --- a/Pearcleaner/Logic/Styles.swift +++ b/Pearcleaner/Logic/Styles.swift @@ -308,6 +308,7 @@ struct AnimatedSearchStyle: TextFieldStyle { @FocusState private var isFocused: Bool @Binding var text: String @EnvironmentObject var appState: AppState + @EnvironmentObject var fsm: FolderSettingsManager func _body(configuration: TextField) -> some View { @@ -332,7 +333,7 @@ struct AnimatedSearchStyle: TextFieldStyle { withAnimation { // Refresh Apps list appState.reload.toggle() - let sortedApps = getSortedApps() + let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { appState.sortedApps = sortedApps appState.reload.toggle() @@ -435,13 +436,55 @@ struct SimpleSearchStyle: TextFieldStyle { } } + .disabled(appState.instantProgress != 0 && appState.instantProgress != appState.instantTotal) .padding(6) .overlay( Group { - RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color("mode").opacity(0.2), lineWidth: 1) - .allowsHitTesting(false) - + GeometryReader { geometry in + ZStack(alignment: .leading) { + + + if appState.instantProgress != 0 && appState.instantProgress != appState.instantTotal { + let totalWidth = geometry.size.width + let progressFraction = CGFloat(appState.instantProgress / appState.instantTotal) + let progressWidth = totalWidth * progressFraction + + RoundedRectangle(cornerRadius: 6) + .fill(Color.accentColor) + .padding(4) + .frame(width: progressWidth) + .animation(.linear, value: progressWidth) + .allowsHitTesting(false) + + } + + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.gray.opacity(0.2), lineWidth: 1) + .allowsHitTesting(false) + } + } +// ZStack { +// RoundedRectangle(cornerRadius: 6) +// .strokeBorder(Color("mode").opacity(0.2), lineWidth: 1) +// .allowsHitTesting(false) +// +// if appState.instantTotal > 0 { +// let progressWidth = CGFloat(appState.instantProgress / appState.instantTotal) +// * NSScreen.main.bounds.width // Assuming full width for simplicity; adjust as needed +// RoundedRectangle(cornerRadius: 6) +// .fill(Color.accentColor.opacity(0.5)) // Adjust color and opacity as needed +// .frame(width: progressWidth) +// .animation(.linear, value: progressWidth) +// .allowsHitTesting(false) +// } +//// if appState.instantProgress != 0 && appState.instantProgress != appState.instantTotal { +//// ProgressView("", value: appState.instantProgress, total: appState.instantTotal) +//// .progressViewStyle(.linear) +//// .padding() +//// } +// } + + } ) .onHover { hovering in diff --git a/Pearcleaner/Logic/Utilities.swift b/Pearcleaner/Logic/Utilities.swift index 61b4bc4..1c6f6a4 100644 --- a/Pearcleaner/Logic/Utilities.swift +++ b/Pearcleaner/Logic/Utilities.swift @@ -312,7 +312,7 @@ func startEnd(_ function: @escaping () -> Void) { function() let endTime = Date() let executionTime = endTime.timeIntervalSince(startTime) - print("Function executed in: \n\(executionTime) seconds") + printOS("Function executed in: \n\(executionTime) seconds") } @@ -327,6 +327,17 @@ func relaunchApp(afterDelay seconds: TimeInterval = 0.5) -> Never { exit(0) } +// 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() +} + // Remove app from cache func removeApp(appState: AppState, withId id: UUID) { DispatchQueue.main.async { @@ -379,41 +390,80 @@ extension FileManager { // --- Extend print command to also output to the Console --- func printOS(_ items: Any..., separator: String = " ", terminator: String = "\n") { let message = items.map { "\($0)" }.joined(separator: separator) - let log = OSLog(subsystem: "com.alienator88.Pearcleaner", category: "Application") - os_log("%@", log: log, type: .debug, message) + let log = OSLog(subsystem: "pearcleaner", category: "Application") + os_log("%@", log: log, type: .default, message) } +//func totalSizeOnDisk2(for paths: [URL]) -> Int64 { +// var totalSize = 0 +// +// let process = Process() +// process.launchPath = "/usr/bin/du" +// process.arguments = ["-sk"] + paths.map { (url: URL) -> String in +// return url.path +// } +// let pipe = Pipe() +// process.standardOutput = pipe +// process.standardError = pipe//FileHandle.nullDevice +// +// try? process.run() +// process.waitUntilExit() +// +// let data = pipe.fileHandleForReading.readDataToEndOfFile() +// if let output = String(data: data, encoding: .utf8) { +// let lines = output.components(separatedBy: .newlines) +// for line in lines { +// let components = line.components(separatedBy: "\t") +// if let sizeString = components.first, let size = Int(sizeString) { +// totalSize += size +// } +// } +// } +// +// return Int64(totalSize) * 1024 // Convert the size from kilobytes to bytes +//} + + // Get total size of folders and files using DU cli command func totalSizeOnDisk(for paths: [URL]) -> Int64 { - var totalSize = 0 - - let process = Process() - process.launchPath = "/usr/bin/du" - process.arguments = ["-sk"] + paths.map { (url: URL) -> String in - return url.path - } - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe//FileHandle.nullDevice + let fileManager = FileManager.default + var totalSize: Int64 = 0 - try? process.run() - process.waitUntilExit() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - if let output = String(data: data, encoding: .utf8) { - let lines = output.components(separatedBy: .newlines) - for line in lines { - let components = line.components(separatedBy: "\t") - if let sizeString = components.first, let size = Int(sizeString) { - totalSize += size + for url in paths { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) { + if isDirectory.boolValue { + // It's a directory, recurse into it + if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], errorHandler: nil) { + for case let fileURL as URL in enumerator { + do { + let fileAttributes = try fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]) + if let fileSize = fileAttributes.totalFileAllocatedSize { + totalSize += Int64(fileSize) + } + } catch { + printOS("Error getting file size for \(fileURL): \(error)") + } + } + } + } else { + // It's a file + do { + let fileAttributes = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]) + if let fileSize = fileAttributes.totalFileAllocatedSize { + totalSize += Int64(fileSize) + } + } catch { + printOS("Error getting file size for \(url): \(error)") + } } } } - return Int64(totalSize) * 1024 // Convert the size from kilobytes to bytes + return totalSize } func totalSizeOnDisk(for path: URL) -> Int64 { @@ -428,53 +478,25 @@ func formatByte(size: Int64) -> String { return byteCountFormatter.string(fromByteCount: size) } - -// Get total size of folders and files using straight swift -extension URL { - func totalAllocatedSize(includingSubfolders: Bool = false) throws -> Int? { - return try [self].totalAllocatedSize(includingSubfolders: includingSubfolders) - } - - func totalSizeOnDisk(includingSubfolders: Bool = false) throws -> String? { - return try [self].totalSizeOnDisk(includingSubfolders: includingSubfolders) - } -} - -extension Array where Element == URL { - func totalAllocatedSize(includingSubfolders: Bool = false) throws -> Int? { - var totalSize = 0 - - for path in self { - let resourceValues = try path.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey, .totalFileAllocatedSizeKey]) - - if resourceValues.isDirectory == true || path.pathExtension == "app" { - if includingSubfolders { - let filePaths = FileManager.default.subpaths(atPath: path.path) ?? [] - for filePath in filePaths { - let fileUrl = path.appendingPathComponent(filePath) - let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileUrl.path) - let fileSize = fileAttributes[.size] as? Int64 ?? 0 - totalSize += Int(fileSize) - } - } - } else { - totalSize += resourceValues.totalFileAllocatedSize ?? 0 - } - +// Only process supported files +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 + } } - - return totalSize - } - - func totalSizeOnDisk(includingSubfolders: Bool = false) throws -> String? { - if let totalSize = try self.totalAllocatedSize(includingSubfolders: includingSubfolders) { - let byteCountFormatter = ByteCountFormatter() - byteCountFormatter.countStyle = .file - byteCountFormatter.allowedUnits = [.useBytes, .useKB, .useMB, .useTB] - return byteCountFormatter.string(fromByteCount: Int64(totalSize)) - } - return nil + } catch { + printOS("Error getting file attributes: \(error)") } + return false } @@ -518,11 +540,13 @@ func uninstallPearcleaner(appState: AppState, locations: Locations) { launchctl(load: false) // Get app info for Pearcleaner - let appInfo = getAppInfo(atPath: Bundle.main.bundleURL) + let appInfo = AppInfoFetcher.getAppInfo(atPath: Bundle.main.bundleURL) // appState.appInfo = appInfo! // Find application files for Pearcleaner - findPathsForApp(appInfo: appInfo!,appState: appState, locations: locations) + let pathFinder = AppPathFinder(appInfo: appInfo!, appState: appState, locations: locations) + pathFinder.findPaths() +// findPathsForApp(appInfo: appInfo!,appState: appState, locations: locations) // Kill Pearcleaner and tell Finder to trash the files DispatchQueue.main.asyncAfter(deadline: .now() + 2) { @@ -587,34 +611,32 @@ func writeLog(string: String) { // --- Load Plist file with launchctl --- func launchctl(load: Bool, completion: @escaping () -> Void = {}) { let cmd = load ? "load" : "unload" + if let plistPath = Bundle.main.path(forResource: "com.alienator88.PearcleanerSentinel", ofType: "plist") { var plistContent = try! String(contentsOfFile: plistPath) let executableURL = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/PearcleanerSentinel") - + // Replace the placeholder with the actual executable path plistContent = plistContent.replacingOccurrences(of: "__EXECUTABLE_PATH__", with: executableURL.path) - + // Create a temporary plist file with the updated content let temporaryPlistURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("com.alienator88.PearcleanerSentinel.plist") - + do { - try plistContent.write(to: temporaryPlistURL, atomically: false, encoding: .utf8) + try plistContent.write(to: temporaryPlistURL, atomically: true, encoding: .utf8) } catch { printOS("Error writing the temporary plist file: \(error)") return } + let task = Process() task.launchPath = "/bin/launchctl" task.arguments = [cmd, "-w", temporaryPlistURL.path] - - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice task.launch() completion() - } } diff --git a/Pearcleaner/PearcleanerApp.swift b/Pearcleaner/PearcleanerApp.swift index c61db89..ad67510 100644 --- a/Pearcleaner/PearcleanerApp.swift +++ b/Pearcleaner/PearcleanerApp.swift @@ -14,6 +14,7 @@ struct PearcleanerApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject var appState = AppState() @StateObject var locations = Locations() + @StateObject var fsm = FolderSettingsManager() @State private var windowSettings = WindowSettings() @AppStorage("settings.updater.updateTimeframe") private var updateTimeframe: Int = 1 @AppStorage("settings.permissions.disk") private var diskP: Bool = false @@ -57,6 +58,7 @@ struct PearcleanerApp: App { } .environmentObject(appState) .environmentObject(locations) + .environmentObject(fsm) .preferredColorScheme(displayMode.colorScheme) .handlesExternalEvents(preferring: Set(arrayLiteral: "pear"), allowing: Set(arrayLiteral: "*")) .onOpenURL(perform: { url in @@ -96,25 +98,26 @@ struct PearcleanerApp: App { NSApplication.shared.windows.first?.setFrame(frame, display: true) // Get Apps - let sortedApps = getSortedApps() + let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState) appState.sortedApps = sortedApps - + // Find all app paths/information on load if instantSearch is enabled if instantSearch { loadAllPaths(allApps: sortedApps, appState: appState, locations: locations) } + if menubarEnabled { MenuBarExtraManager.shared.addMenuBarExtra(withView: { MenuBarMiniAppView(search: $search, showPopover: $showPopover) .environmentObject(locations) .environmentObject(appState) + .environmentObject(fsm) .preferredColorScheme(displayMode.colorScheme) }, icon: selectedMenubarIcon) } - #if !DEBUG Task { @@ -153,7 +156,7 @@ struct PearcleanerApp: App { .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) .commands { - AppCommands(appState: appState, locations: locations) + AppCommands(appState: appState, locations: locations, fsm: fsm) CommandGroup(replacing: .newItem, addition: { }) } @@ -165,6 +168,7 @@ struct PearcleanerApp: App { SettingsView(showPopover: $showPopover, search: $search, showFeature: $showFeature) .environmentObject(appState) .environmentObject(locations) + .environmentObject(fsm) .toolbarBackground(.clear) .preferredColorScheme(displayMode.colorScheme) } diff --git a/Pearcleaner/Settings/Folders.swift b/Pearcleaner/Settings/Folders.swift new file mode 100644 index 0000000..738b94d --- /dev/null +++ b/Pearcleaner/Settings/Folders.swift @@ -0,0 +1,202 @@ +// +// Folders.swift +// Pearcleaner +// +// Created by Alin Lupascu on 3/20/24. +// + +// +// General.swift +// Pearcleaner +// +// Created by Alin Lupascu on 11/5/23. +// + +import Foundation +import SwiftUI +import AppKit + +struct FolderSettingsTab: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var locations: Locations + @EnvironmentObject var fsm: FolderSettingsManager + @State private var isHovered = false + @State private var isHoveredPlus = false + + var body: some View { + Form { + VStack { + + HStack(spacing: 0) { + Text("Apps").font(.title2) + InfoButton(text: "Locations that will be searched for .app files. Click a non-default path to remove it. Add new folders below or drag/drop a folder over the list.", color: nil, label: "") + Spacer() + } + + + ScrollView { + VStack(spacing: 5) { + ForEach(fsm.folderPaths.indices, id: \.self) { index in + HStack { + + Text(fsm.folderPaths[index]) + .font(.callout) + .opacity(fsm.defaultPaths.contains(fsm.folderPaths[index]) ? 0.5 : 1) + .padding(5) + Spacer() + } + .disabled(fsm.defaultPaths.contains(fsm.folderPaths[index])) + .onHover { hovering in + withAnimation(Animation.easeInOut(duration: 0.4)) { + isHovered = hovering + } + if isHovered && !fsm.defaultPaths.contains(fsm.folderPaths[index]) { + NSCursor.disappearingItem.push() + } else { + NSCursor.pop() + } + } + .onTapGesture { + if !fsm.defaultPaths.contains(fsm.folderPaths[index]) { + fsm.removePath(at: index) + } + } + + if index != fsm.folderPaths.indices.last { + Divider().opacity(0.5) + } + } + + } + + } + .scrollIndicators(.automatic) + .padding() + .background(Color("mode").opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .onDrop(of: ["public.file-url"], isTargeted: nil) { providers -> Bool in + providers.forEach { provider in + provider.loadDataRepresentation(forTypeIdentifier: "public.file-url") { (data, error) in + guard let data = data, error == nil, + let url = URL(dataRepresentation: data, relativeTo: nil), + url.hasDirectoryPath else { + printOS("FSM: Failed to load URL or the item is not a folder") + return + } + updateOnMain { + fsm.addPath(url.path) + } + } + } + return true + } + + + + + + // === OTHER ================================================================================================ + + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color("mode").opacity(0.1)) +// .strokeBorder(Color("mode").opacity(0.1), lineWidth: 2) + .frame(width: 300, height: 100) + + Image(systemName: "plus") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .foregroundStyle(isHoveredPlus ? Color("mode") : Color("mode").opacity(0.5)) + } + .padding(.top) + .onTapGesture { + selectFolder() + } + .onHover { hovering in + withAnimation(Animation.easeInOut(duration: 0.4)) { + isHoveredPlus = hovering + } + if isHoveredPlus { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + + + + + Spacer() + } + + + } + .padding(20) + .frame(width: 500, height: 420) + + } + + + private func selectFolder() { + let dialog = NSOpenPanel() + dialog.title = "Choose a folder" + dialog.showsResizeIndicator = false + dialog.showsHiddenFiles = false + dialog.canChooseDirectories = true + dialog.canCreateDirectories = true + dialog.canChooseFiles = false + + if dialog.runModal() == NSApplication.ModalResponse.OK { + if let result = dialog.url { + fsm.addPath(result.path) + } + } else { + return + } + } + +} + + + +class FolderSettingsManager: ObservableObject { + @Published var folderPaths: [String] = [] + private let userDefaultsKey = "settings.folders.apps" + let defaultPaths = ["/Applications", "\(NSHomeDirectory())/Applications"] + + init() { + loadDefaultPathsIfNeeded() + } + + private func loadDefaultPathsIfNeeded() { + var paths = UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? defaultPaths + if paths.count < 2 { + paths = defaultPaths + } + UserDefaults.standard.set(paths, forKey: userDefaultsKey) + self.folderPaths = paths + } + + func addPath(_ path: String) { + if !self.folderPaths.contains(path) { + self.folderPaths.append(path) + UserDefaults.standard.set(self.folderPaths, forKey: userDefaultsKey) + } + } + + func removePath(at index: Int) { + guard self.folderPaths.indices.contains(index) else { return } + self.folderPaths.remove(at: index) // Update local state + UserDefaults.standard.set(self.folderPaths, forKey: userDefaultsKey) + } + + func refreshPaths() { + self.folderPaths = UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? defaultPaths + } + + func getPaths() -> [String] { + return UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? defaultPaths + } +} diff --git a/Pearcleaner/Settings/Interface.swift b/Pearcleaner/Settings/Interface.swift index fb023df..03f420d 100644 --- a/Pearcleaner/Settings/Interface.swift +++ b/Pearcleaner/Settings/Interface.swift @@ -13,6 +13,7 @@ import ServiceManagement struct InterfaceSettingsTab: View { @EnvironmentObject var appState: AppState @EnvironmentObject var locations: Locations + @EnvironmentObject var fsm: FolderSettingsManager @State private var windowSettings = WindowSettings() @AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false @AppStorage("settings.general.mini") private var mini: Bool = false @@ -151,9 +152,12 @@ struct InterfaceSettingsTab: View { MiniMode(search: $search, showPopover: $showPopover) .environmentObject(locations) .environmentObject(appState) + .environmentObject(fsm) .preferredColorScheme(displayMode.colorScheme) } - resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner") + updateOnMain(after: 0.1, { + resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner") + }) } else { if appState.appInfo.appName.isEmpty { appState.currentView = .empty @@ -164,9 +168,12 @@ struct InterfaceSettingsTab: View { RegularMode(search: $search, showPopover: $showPopover) .environmentObject(locations) .environmentObject(appState) + .environmentObject(fsm) .preferredColorScheme(displayMode.colorScheme) } - resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner") + updateOnMain(after: 0.1, { + resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner") + }) } @@ -262,6 +269,7 @@ struct InterfaceSettingsTab: View { MenuBarMiniAppView(search: $search, showPopover: $showPopover) .environmentObject(locations) .environmentObject(appState) + .environmentObject(fsm) .preferredColorScheme(displayMode.colorScheme) }, icon: selectedMenubarIcon) NSApplication.shared.setActivationPolicy(.accessory) @@ -276,6 +284,7 @@ struct InterfaceSettingsTab: View { MiniMode(search: $search, showPopover: $showPopover) .environmentObject(locations) .environmentObject(appState) + .environmentObject(fsm) .preferredColorScheme(displayMode.colorScheme) } resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner") @@ -284,6 +293,7 @@ struct InterfaceSettingsTab: View { RegularMode(search: $search, showPopover: $showPopover) .environmentObject(locations) .environmentObject(appState) + .environmentObject(fsm) .preferredColorScheme(displayMode.colorScheme) } resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner") diff --git a/Pearcleaner/Settings/SettingsWindow.swift b/Pearcleaner/Settings/SettingsWindow.swift index c45e3b6..bee400b 100644 --- a/Pearcleaner/Settings/SettingsWindow.swift +++ b/Pearcleaner/Settings/SettingsWindow.swift @@ -9,6 +9,7 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var appState: AppState + @EnvironmentObject var fsm: FolderSettingsManager @Binding var showPopover: Bool @Binding var search: String @Binding var showFeature: Bool @@ -29,6 +30,12 @@ struct SettingsView: View { } .tag(CurrentTabView.interface) + FolderSettingsTab() + .tabItem { + Label(CurrentTabView.folders.title, systemImage: "folder") + } + .tag(CurrentTabView.interface) + UpdateSettingsTab(showFeature: $showFeature) .tabItem { Label(CurrentTabView.update.title, systemImage: "cloud") diff --git a/Pearcleaner/Views/FilesView.swift b/Pearcleaner/Views/FilesView.swift index bf81041..a90e251 100644 --- a/Pearcleaner/Views/FilesView.swift +++ b/Pearcleaner/Views/FilesView.swift @@ -22,7 +22,9 @@ struct FilesView: View { @Binding var search: String var regularWin: Bool @State private var selectedOption = "Default" - + @State private var elapsedTime = 0 + @State private var timer: Timer? = nil + var body: some View { VStack(alignment: .center) { if appState.showProgress { @@ -30,14 +32,33 @@ struct FilesView: View { Spacer() Text("Searching the file system").font(.title3) .foregroundStyle((.gray.opacity(0.8))) - ProgressView() - .progressViewStyle(.linear) - .frame(width: 400, height: 10) + + HStack { + ProgressView() + .progressViewStyle(.linear) + .frame(width: 400, height: 10) + Image(systemName: "\(elapsedTime).circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .foregroundStyle((.gray.opacity(0.8))) + .opacity(elapsedTime == 0 ? 0 : 1) + } Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) .transition(.opacity) + .onAppear { + self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + self.elapsedTime += 1 + } + } + .onDisappear { + self.timer?.invalidate() + self.timer = nil + self.elapsedTime = 0 + } } else { // Titlebar if !regularWin { @@ -69,8 +90,14 @@ struct FilesView: View { .resizable() .scaledToFit() .frame(width: 50, height: 50) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding(.trailing) +// .clipShape(RoundedRectangle(cornerRadius: 8)) +// .padding(.trailing) + .padding() + .background{ + RoundedRectangle(cornerRadius: 16) + .fill(Color((appState.appInfo.appIcon?.averageColor)!)) + + } } VStack(alignment: .leading, spacing: 5){ @@ -86,6 +113,7 @@ struct FilesView: View { Text("\(appState.appInfo.bundleIdentifier)").font(.title3) .foregroundStyle((.gray.opacity(0.8))) } + .padding(.leading) Spacer() @@ -97,6 +125,7 @@ struct FilesView: View { } + HStack(alignment: .center, spacing: 10) { Spacer() @@ -157,7 +186,7 @@ struct FilesView: View { VStack { FileDetailsItem(size: fileSize, icon: iconImage, path: path) if path != appState.appInfo.files.last { - Divider().padding(.leading, 40) + Divider().padding(.leading, 40).opacity(0.5) } } } @@ -268,17 +297,6 @@ struct FilesView: View { } } - func refreshAppList(_ appInfo: AppInfo) { - showPopover = false - let sortedApps = getSortedApps() - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - appState.sortedApps = sortedApps -// appState.sortedApps.systemApps = sortedApps.systemApps -// if instantSearch { -// loadAllPaths(allApps: sortedApps.userApps + sortedApps.systemApps, appState: appState, locations: locations) -// } - } - } } @@ -321,7 +339,7 @@ struct FileDetailsItem: View { .help(path.lastPathComponent) Text(path.path) .font(.footnote) - .lineLimit(2) + .lineLimit(1) .truncationMode(.tail) .opacity(0.5) .help(path.path) diff --git a/Pearcleaner/Views/MenuBarMiniAppView.swift b/Pearcleaner/Views/MenuBarMiniAppView.swift index ae68296..ef96060 100644 --- a/Pearcleaner/Views/MenuBarMiniAppView.swift +++ b/Pearcleaner/Views/MenuBarMiniAppView.swift @@ -70,7 +70,9 @@ struct MenuBarMiniAppView: View { appState.showProgress.toggle() showPopover.toggle() if instantSearch { - reversePathsSearch(appState: appState, locations: locations) + let reverse = ReversePathsSearcher(appState: appState, locations: locations) + reverse.reversePathsSearch() +// reversePathsSearch(appState: appState, locations: locations) } else { loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true) } diff --git a/Pearcleaner/Views/MiniMode.swift b/Pearcleaner/Views/MiniMode.swift index 284cf3a..7de3f03 100644 --- a/Pearcleaner/Views/MiniMode.swift +++ b/Pearcleaner/Views/MiniMode.swift @@ -32,6 +32,7 @@ struct MiniMode: View { } else { TopBarMini(search: $search, showPopover: $showPopover) MiniAppView(search: $search, showPopover: $showPopover) + } } .transition(.opacity) diff --git a/Pearcleaner/Views/RegularMode.swift b/Pearcleaner/Views/RegularMode.swift index da66817..f9d438f 100644 --- a/Pearcleaner/Views/RegularMode.swift +++ b/Pearcleaner/Views/RegularMode.swift @@ -178,6 +178,7 @@ struct Header: View { @State private var hovered = false @EnvironmentObject var appState: AppState @EnvironmentObject var locations: Locations + @EnvironmentObject var fsm: FolderSettingsManager @Binding var showPopover: Bool @AppStorage("settings.general.instant") private var instantSearch: Bool = true @AppStorage("settings.general.glass") private var glass: Bool = true @@ -199,7 +200,7 @@ struct Header: View { // Refresh Apps list appState.reload.toggle() showPopover = false - let sortedApps = getSortedApps() + let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { appState.sortedApps = sortedApps if instantSearch { diff --git a/Pearcleaner/Views/TopBar.swift b/Pearcleaner/Views/TopBar.swift index 922a1e4..1da9699 100644 --- a/Pearcleaner/Views/TopBar.swift +++ b/Pearcleaner/Views/TopBar.swift @@ -42,6 +42,23 @@ struct TopBar: View { HStack() { Spacer() + if appState.currentView == .zombie { + Button("Rescan") { + updateOnMain { + appState.zombieFile = .empty + appState.showProgress.toggle() + if instantSearch { + let reverse = ReversePathsSearcher(appState: appState, locations: locations) + reverse.reversePathsSearch() + // reversePathsSearch(appState: appState, locations: locations) + } else { + loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true) + } + } + } + .buttonStyle(NavButtonBottomBarStyle(image: "arrow.counterclockwise.circle.fill", help: "Rescan files")) + } + if appState.currentView != .zombie { Button("") { withAnimation(.easeInOut(duration: 0.5)) { @@ -53,7 +70,9 @@ struct TopBar: View { appState.showProgress.toggle() showPopover.toggle() if instantSearch { - reversePathsSearch(appState: appState, locations: locations) + let reverse = ReversePathsSearcher(appState: appState, locations: locations) + reverse.reversePathsSearch() +// reversePathsSearch(appState: appState, locations: locations) } else { loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true) } @@ -81,6 +100,8 @@ struct TopBar: View { } + + } diff --git a/Pearcleaner/Views/TopBarMini.swift b/Pearcleaner/Views/TopBarMini.swift index 2be97f8..6987292 100644 --- a/Pearcleaner/Views/TopBarMini.swift +++ b/Pearcleaner/Views/TopBarMini.swift @@ -37,7 +37,9 @@ struct TopBarMini: View { appState.showProgress.toggle() showPopover.toggle() if instantSearch { - reversePathsSearch(appState: appState, locations: locations) + let reverse = ReversePathsSearcher(appState: appState, locations: locations) + reverse.reversePathsSearch() +// reversePathsSearch(appState: appState, locations: locations) } else { loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true) } @@ -78,7 +80,9 @@ struct TopBarMini: View { appState.showProgress.toggle() showPopover.toggle() if instantSearch { - reversePathsSearch(appState: appState, locations: locations) + let reverse = ReversePathsSearcher(appState: appState, locations: locations) + reverse.reversePathsSearch() +// reversePathsSearch(appState: appState, locations: locations) } else { loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true) } @@ -130,10 +134,8 @@ struct SearchBarMiniBottom: View { var body: some View { HStack { - TextField(" Search", text: $search) + TextField("\(appState.instantTotal > appState.instantProgress ? " Searching the file system" : " Search")", text: $search) .textFieldStyle(SimpleSearchStyle(trash: true, text: $search)) } -// .padding(.horizontal) -// .padding(.bottom, 0) } } diff --git a/Pearcleaner/Views/ZombieView.swift b/Pearcleaner/Views/ZombieView.swift index ded1dcf..48ed057 100644 --- a/Pearcleaner/Views/ZombieView.swift +++ b/Pearcleaner/Views/ZombieView.swift @@ -22,6 +22,8 @@ struct ZombieView: View { @State private var searchZ: String = "" @State private var selectedOption = "Default" var regularWin: Bool + @State private var elapsedTime = 0 + @State private var timer: Timer? = nil var body: some View { @@ -49,9 +51,19 @@ struct ZombieView: View { Text("Searching the file system").font(.title3) .foregroundStyle((.gray.opacity(0.8))) - ProgressView() - .progressViewStyle(.linear) - .frame(width: 400, height: 10) + + HStack { + ProgressView() + .progressViewStyle(.linear) + .frame(width: 400, height: 10) + Image(systemName: "\(elapsedTime).circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .foregroundStyle((.gray.opacity(0.8))) + .opacity(elapsedTime == 0 ? 0 : 1) + } + Spacer() } @@ -59,6 +71,16 @@ struct ZombieView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + self.elapsedTime += 1 + } + } + .onDisappear { + self.timer?.invalidate() + self.timer = nil + self.elapsedTime = 0 + } } else { // Titlebar if !regularWin { @@ -70,7 +92,9 @@ struct ZombieView: View { appState.zombieFile = .empty appState.showProgress.toggle() if instantSearch { - reversePathsSearch(appState: appState, locations: locations) + let reverse = ReversePathsSearcher(appState: appState, locations: locations) + reverse.reversePathsSearch() +// reversePathsSearch(appState: appState, locations: locations) } else { loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true) } @@ -131,6 +155,7 @@ struct ZombieView: View { } SearchBarMiniBottom(search: $searchZ) + .padding(.top) Divider() .padding() @@ -146,7 +171,7 @@ struct ZombieView: View { .padding(.trailing) if file != appState.zombieFile.fileSize.keys.sorted(by: { $0.absoluteString < $1.absoluteString }).last { - Divider().padding(.leading, 40) + Divider().padding(.leading, 40).opacity(0.5) } } } @@ -271,7 +296,7 @@ struct ZombieFileDetailsItem: View { .help(path.lastPathComponent) Text(path.path) .font(.footnote) - .lineLimit(2) + .lineLimit(1) .truncationMode(.tail) .opacity(0.5) // .foregroundStyle(Color("AccentColor")) diff --git a/features.json b/features.json index 8d3111c..11c0e4d 100644 --- a/features.json +++ b/features.json @@ -1,4 +1,5 @@ { + "3.3.0": "- Folder Settings: Add more directories where Pearcleaner should search for app files|- Progress: Show progress bar on startup when loading all app files with instant search enabled|- App Icon: Show background color behind app icons in FilesView based on icon's average color mapping|- Progress: Show time counter on regular file search views", "3.2.0": "- Menubar Item: You can choose to access Pearcleaner from the menubar. This will show a slightly modified Mini Mode view.", "3.1.0": "- Homebrew Cleanup: If you install apps using brew, you can now enable homebrew cleanup in the settings to have Pearcleaner alert Homebrew that the app was removed externally from Homebrew and keep the brew app list synced.", "3.0.0": "- Instant Search: Enable in settings to load app files on startup|- Semantic Versioning: Going forward will use semver (Ex. v0.0.0)|-Feature Alert: For each version, a feature alert will popup once on startup to show details|- Leftover File Cleaning: Search your mac for files leftover by uninstalled apps|- Sidebar Drag Handle: Resize the sidebar in regular mode|- Redesign UI/Settings/Icon: Some new buttons, layout changes, new official app icon and pear color theme accents|- File Sort: Sort files alphabetically or by size|- Socket File Removal: Finds socket files that aren't even visible in Finder with show hidden files enabled|- OSLog Output: Will print errors to the Console app for easier troubleshooting"