diff --git a/FinderOpen/FinderOpen.entitlements b/FinderOpen/FinderOpen.entitlements index d1b27e8..a22bc37 100644 --- a/FinderOpen/FinderOpen.entitlements +++ b/FinderOpen/FinderOpen.entitlements @@ -4,9 +4,7 @@ com.apple.security.app-sandbox - com.apple.security.files.user-selected.read-write - - com.apple.security.temporary-exception.files.home-relative-path.read-write + com.apple.security.temporary-exception.files.absolute-path.read-only / diff --git a/Pearcleaner.xcodeproj/project.pbxproj b/Pearcleaner.xcodeproj/project.pbxproj index d07ad1d..83e0b3d 100644 --- a/Pearcleaner.xcodeproj/project.pbxproj +++ b/Pearcleaner.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 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 */; }; + C7DFBDE72CAF5AD30013C825 /* CustomTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DFBDE62CAF5ACB0013C825 /* CustomTabView.swift */; }; C7EA06332C1CBE2000F872FC /* ConditionBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EA06322C1CBE2000F872FC /* ConditionBuilderView.swift */; }; C7ED86B12BA8A35500F13DE4 /* Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7ED86B02BA8A35500F13DE4 /* Interface.swift */; }; C7F539382AF60865007DF1B2 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F539372AF60865007DF1B2 /* Utilities.swift */; }; @@ -132,6 +133,7 @@ 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 = ""; }; + C7DFBDE62CAF5ACB0013C825 /* CustomTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTabView.swift; sourceTree = ""; }; C7EA06322C1CBE2000F872FC /* ConditionBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionBuilderView.swift; sourceTree = ""; }; C7ED86B02BA8A35500F13DE4 /* Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interface.swift; sourceTree = ""; }; C7F539372AF60865007DF1B2 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; @@ -266,6 +268,7 @@ C77B901F2AF1B390009CC655 /* Views */ = { isa = PBXGroup; children = ( + C7DFBDE62CAF5ACB0013C825 /* CustomTabView.swift */, C77B90222AF2D616009CC655 /* FilesView.swift */, C71848432B8D2D600046CB13 /* ZombieView.swift */, C76D08542AF89CDE00D07867 /* RegularMode.swift */, @@ -435,6 +438,7 @@ C764680F2CA7790200DC72DF /* PearGroupBox.swift in Sources */, C79947962C34A794007279CC /* Tips.swift in Sources */, C76D08482AF83C3F00D07867 /* Update.swift in Sources */, + C7DFBDE72CAF5AD30013C825 /* CustomTabView.swift in Sources */, C7FB173B2B96321300B96F9A /* AppsListView.swift in Sources */, C77B90162AF19377009CC655 /* AppState.swift in Sources */, C7DD49EE2BAB7F6000CCBA16 /* ReversePathsFetch.swift in Sources */, @@ -791,6 +795,7 @@ DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = BK8443AXLU; ENABLE_HARDENED_RUNTIME = YES; + GCC_OPTIMIZATION_LEVEL = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FinderOpen/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = FinderOpen; diff --git a/Pearcleaner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Pearcleaner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7fa5553..5d21f98 100644 --- a/Pearcleaner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pearcleaner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/alienator88/AlinFoundation", "state" : { "branch" : "main", - "revision" : "579f5dd4730f085ad0163442a6c81d1c5fb65e90" + "revision" : "9e22796895c78f8ecce0b4d8c58917ec36f7c1b0" } }, { diff --git a/Pearcleaner.xcodeproj/xcshareddata/xcschemes/FinderOpen.xcscheme b/Pearcleaner.xcodeproj/xcshareddata/xcschemes/FinderOpen.xcscheme index 3e956a3..c775f82 100644 --- a/Pearcleaner.xcodeproj/xcshareddata/xcschemes/FinderOpen.xcscheme +++ b/Pearcleaner.xcodeproj/xcshareddata/xcschemes/FinderOpen.xcscheme @@ -71,6 +71,13 @@ ReferencedContainer = "container:Pearcleaner.xcodeproj"> + + + + AppInfo? { + // Extract metadata attributes for known fields + let displayName = metadata["kMDItemDisplayName"] as? String ?? "" + let fsName = metadata["kMDItemFSName"] as? String ?? path.lastPathComponent + let appName = displayName.isEmpty ? fsName : displayName + + let bundleIdentifier = metadata["kMDItemCFBundleIdentifier"] as? String ?? "" + let version = metadata["kMDItemVersion"] as? String ?? "" + + // Sizes + let logicalSize = metadata["kMDItemLogicalSize"] as? Int64 ?? 0 + let physicalSize = metadata["kMDItemPhysicalSize"] as? Int64 ?? 0 + + // Check if any of the critical fields are missing or invalid + if appName.isEmpty || bundleIdentifier.isEmpty || version.isEmpty || logicalSize == 0 || physicalSize == 0 { +// print("Metadata is missing critical fields for \(path). Falling back to AppInfoFetcher.") + // Fallback to the regular AppInfoFetcher for this app + return AppInfoFetcher.getAppInfo(atPath: path) + } + + // Extract optional date fields + let creationDate = metadata["kMDItemFSCreationDate"] as? Date + let contentChangeDate = metadata["kMDItemFSContentChangeDate"] as? Date + let lastUsedDate = metadata["kMDItemLastUsedDate"] as? Date + + // Determine architecture type + let arch = determineArchitecture(from: metadata) + + // Use similar helper functions as `AppInfoFetcher` for attributes not found in metadata + let wrapped = AppInfoFetcher.isDirectoryWrapped(path: path) + let appIcon = AppInfoUtils.fetchAppIcon(for: path, wrapped: wrapped, md: true) + let webApp = AppInfoUtils.isWebApp(appPath: path) + let system = !path.path.contains(NSHomeDirectory()) + + return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName, + appVersion: version, appIcon: appIcon, webApp: webApp, wrapped: wrapped, system: system, + arch: arch, bundleSize: logicalSize, fileSize: [:], + fileSizeLogical: [:], fileIcon: [:], creationDate: creationDate, contentChangeDate: contentChangeDate, lastUsedDate: lastUsedDate) + } + + /// Determine the architecture type based on metadata + private static func determineArchitecture(from metadata: [String: Any]) -> Arch { + guard let architectures = metadata["kMDItemExecutableArchitectures"] as? [String] else { + return .empty + } + + // Check for ARM and Intel presence + let containsArm = architectures.contains("arm64") + let containsIntel = architectures.contains("x86_64") + + // Determine the Arch type based on available architectures + if containsArm && containsIntel { + return .universal + } else if containsArm { + return .arm + } else if containsIntel { + return .intel + } else { + return .empty + } + } +} + + + class AppInfoFetcher { static let fileManager = FileManager.default - static func getAppInfo(atPath path: URL, wrapped: Bool = false) -> AppInfo? { + public static func getAppInfo(atPath path: URL, wrapped: Bool = false) -> AppInfo? { if isDirectoryWrapped(path: path) { return handleWrappedDirectory(atPath: path) } else { @@ -20,7 +88,7 @@ class AppInfoFetcher { } } - private static func isDirectoryWrapped(path: URL) -> Bool { + public static func isDirectoryWrapped(path: URL) -> Bool { let wrapperURL = path.appendingPathComponent("Wrapper") return fileManager.fileExists(atPath: wrapperURL.path) } @@ -53,17 +121,36 @@ class AppInfoFetcher { ? bundle.infoDictionary?["CFBundleVersion"] as? String ?? "" : bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" - let appIcon = fetchAppIcon(for: path, wrapped: wrapped) - let webApp = (bundle.infoDictionary?["LSTemplateApplication"] as? Bool ?? false || bundle.infoDictionary?["CFBundleExecutable"] as? String == "app_mode_loader") + let appIcon = AppInfoUtils.fetchAppIcon(for: path, wrapped: wrapped) + let webApp = AppInfoUtils.isWebApp(bundle: bundle) + 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, arch: .empty, bundleSize: 0, files: [], fileSize: [:], fileSizeLogical: [:], fileIcon: [:]) + webApp: webApp, wrapped: wrapped, system: system, arch: .empty, bundleSize: 0, fileSize: [:], fileSizeLogical: [:], fileIcon: [:], creationDate: nil, contentChangeDate: nil, lastUsedDate: nil) } - private static func fetchAppIcon(for path: URL, wrapped: Bool) -> NSImage? { - let iconPath = wrapped ? path.deletingLastPathComponent().deletingLastPathComponent() : path +} + + +class AppInfoUtils { + /// Determines if the app is a web application by directly reading its `Info.plist` using the app path. + static func isWebApp(appPath: URL) -> Bool { + guard let bundle = Bundle(url: appPath) else { return false } + return isWebApp(bundle: bundle) + } + + /// Determines if the app is a web application based on its bundle. + static func isWebApp(bundle: Bundle?) -> Bool { + guard let infoDict = bundle?.infoDictionary else { return false } + return (infoDict["LSTemplateApplication"] as? Bool ?? false) || + (infoDict["CFBundleExecutable"] as? String == "app_mode_loader") + } + + /// Fetch app icon. + static func fetchAppIcon(for path: URL, wrapped: Bool, md: Bool = false) -> NSImage? { + let iconPath = wrapped ? (md ? path : path.deletingLastPathComponent().deletingLastPathComponent()) : path if let appIcon = getIconForFileOrFolderNS(atPath: iconPath) { return convertICNSToPNG(icon: appIcon, size: NSSize(width: 100, height: 100)) } else { @@ -71,5 +158,101 @@ class AppInfoFetcher { return nil } } +} + + + + + + +/// Executes `mdls` with `-plist -` and `-nullMarker ""` options and returns metadata in a structured dictionary. +func getMDLSMetadataAsPlist(for paths: [String]) -> [String: [String: Any]]? { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/mdls") + + // Define the required metadata attributes to include in the output + let attributes = [ + "kMDItemFSCreationDate", + "kMDItemFSContentChangeDate", + "kMDItemLastUsedDate", + "kMDItemDisplayName", + "kMDItemAppStoreCategory", + "kMDItemCFBundleIdentifier", + "kMDItemExecutableArchitectures", + "kMDItemFSName", + "kMDItemVersion", + "kMDItemLogicalSize", + "kMDItemPhysicalSize" + ] + + // Construct the `mdls` arguments: paths + -name for each attribute + -plist - + -nullMarker "" + var arguments = paths + for attribute in attributes { + arguments.append("-name") + arguments.append(attribute) + } + arguments.append("-plist") // Use plist format + arguments.append("-") // Output to stdout + arguments.append("-nullMarker") // Replace null attributes + arguments.append("") // Substitute null values with empty string + + task.arguments = arguments + + // Use Pipe to capture the output + let pipe = Pipe() + let errorPipe = Pipe() + task.standardOutput = pipe + task.standardError = errorPipe // Capture stderr in case there are errors + + do { + // Run the task + try task.run() + + // Read the data from the pipe + let data = pipe.fileHandleForReading.readDataToEndOfFile() + + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + if let errorOutput = String(data: errorData, encoding: .utf8), !errorOutput.isEmpty { + print("Error Output from mdls:\n\(errorOutput)\n") + } + + // Check if there's any output captured + if data.isEmpty { + print("No output captured from mdls.") + return nil + } + + // Attempt to parse the plist output into a Swift array of dictionaries + guard let plistArray = try PropertyListSerialization.propertyList(from: data, format: nil) as? [[String: Any]] else { + print("Failed to parse plist output into expected format.") + return nil + } + +// print("Parsed plist array count: \(plistArray.count)") + + // Ensure the number of plist items matches the number of paths + if plistArray.count != paths.count { + print("Warning: Number of plist items (\(plistArray.count)) does not match the number of paths (\(paths.count)).") + } + + var metadataDictionary = [String: [String: Any]]() + + // Map each metadata dictionary to its corresponding path using indices + for (index, appMetadata) in plistArray.enumerated() { + // Match metadata to path using the index + if index < paths.count { + let path = paths[index] + metadataDictionary[path] = appMetadata +// print("Mapped metadata to path: \(path)") + } else { + print("Warning: More metadata entries than paths.") + } + } + return metadataDictionary + + } catch { + print("Error running mdls: \(error)") + return nil + } } diff --git a/Pearcleaner/Logic/AppState.swift b/Pearcleaner/Logic/AppState.swift index cf8fa29..5917312 100644 --- a/Pearcleaner/Logic/AppState.swift +++ b/Pearcleaner/Logic/AppState.swift @@ -19,6 +19,7 @@ class AppState: ObservableObject { @Published var sortedApps: [AppInfo] = [] @Published var selectedItems = Set() @Published var currentView = CurrentDetailsView.empty + @Published var currentPage = CurrentPage.installed @Published var showAlert: Bool = false @Published var sidebar: Bool = true @Published var reload: Bool = false @@ -67,10 +68,13 @@ class AppState: ObservableObject { system: false, arch: .empty, bundleSize: 0, - files: [], +// files: [], fileSize: [:], fileSizeLogical: [:], - fileIcon: [:] + fileIcon: [:], + creationDate: nil, + contentChangeDate: nil, + lastUsedDate: nil ) self.zombieFile = ZombieFile( @@ -137,12 +141,15 @@ struct AppInfo: Identifiable, Equatable, Hashable { let wrapped: Bool let system: Bool var arch: Arch - var bundleSize: Int64 - var files: [URL] + var bundleSize: Int64 // Only used in the app list view +// var files: [URL] var fileSize: [URL:Int64] var fileSizeLogical: [URL:Int64] var fileIcon: [URL:NSImage?] - var totalSize: Int64 + let creationDate: Date? + let contentChangeDate: Date? + let lastUsedDate: Date? + var totalSize: Int64 { return fileSize.values.reduce(0, +) } @@ -150,9 +157,10 @@ struct AppInfo: Identifiable, Equatable, Hashable { { return fileSizeLogical.values.reduce(0, +) } + - static let empty = AppInfo(id: UUID(), path: URL(fileURLWithPath: ""), bundleIdentifier: "", appName: "", appVersion: "", appIcon: nil, webApp: false, wrapped: false, system: false, arch: .empty, bundleSize: 0, files: [], fileSize: [:], fileSizeLogical: [:], fileIcon: [:]) + static let empty = AppInfo(id: UUID(), path: URL(fileURLWithPath: ""), bundleIdentifier: "", appName: "", appVersion: "", appIcon: nil, webApp: false, wrapped: false, system: false, arch: .empty, bundleSize: 0, fileSize: [:], fileSizeLogical: [:], fileIcon: [:], creationDate: nil, contentChangeDate: nil, lastUsedDate: nil) } @@ -185,6 +193,19 @@ enum Arch { case empty } +enum CurrentPage:Int +{ + case installed + case uninstalled + + var title: String { + switch self { + case .installed: return "Installed" + case .uninstalled: return "Uninstalled" + } + } +} + enum CurrentTabView:Int { diff --git a/Pearcleaner/Logic/Logic.swift b/Pearcleaner/Logic/Logic.swift index 71c5cd3..070346d 100644 --- a/Pearcleaner/Logic/Logic.swift +++ b/Pearcleaner/Logic/Logic.swift @@ -37,9 +37,40 @@ func getSortedApps(paths: [String]) -> [AppInfo] { // Collect system applications paths.forEach { collectAppPaths(at: $0) } +// let startTime = Date() + + // Convert collected paths to string format for metadata query + let combinedPaths = apps.map { $0.path } + + // Get metadata for all collected app paths + var metadataDictionary: [String: [String: Any]] = [:] + if let metadata = getMDLSMetadataAsPlist(for: combinedPaths) { + metadataDictionary = metadata + } + + // Process each app path and construct AppInfo using metadata first, then fallback if necessary + let appInfos: [AppInfo] = apps.compactMap { appURL in + let appPath = appURL.path + + if let appMetadata = metadataDictionary[appPath] { + // Use `MetadataAppInfoFetcher` first + return MetadataAppInfoFetcher.getAppInfo(fromMetadata: appMetadata, atPath: appURL) + } else { + // Fallback to `AppInfoFetcher` if no metadata found + return AppInfoFetcher.getAppInfo(atPath: appURL) + } + } + + // Sort apps by display name + let sortedApps = appInfos.sorted { $0.appName.lowercased() < $1.appName.lowercased() } + // Get app info and sort - let sortedApps = apps - .compactMap { AppInfoFetcher.getAppInfo(atPath: $0) } +// let sortedApps = apps +// .compactMap { AppInfoFetcher.getAppInfo(atPath: $0) } + + +// let elapsedTime = Date().timeIntervalSince(startTime) +// print("Time taken for mdls metadata extraction: \(elapsedTime) seconds") return sortedApps } @@ -169,7 +200,7 @@ func moveFilesToTrash(appState: AppState, at fileURLs: [URL], completion: @escap // Stop Sentinel FileWatcher momentarily to ignore .app bundle being sent to Trash sendStopNotificationFW() - updateOnBackground { + updateOnMain { let posixFiles = fileURLs.map { item in return "POSIX file \"\(item.path)\"" + (item == fileURLs.last ? "" : ", ")}.joined() let scriptSource = """ @@ -182,19 +213,15 @@ func moveFilesToTrash(appState: AppState, at fileURLs: [URL], completion: @escap // Handle any AppleScript errors if let error = error { - DispatchQueue.main.async { - printOS("Trash Error: \(error)") - completion(false) // Indicate failure - } + printOS("Trash Error: \(error)") + completion(false) // Indicate failure return } // Check if output is null, indicating the user canceled the operation if output.descriptorType == typeNull { - DispatchQueue.main.async { - printOS("Trash Error: operation canceled by the user") - completion(false) // Indicate failure due to cancellation - } + printOS("Trash Error: operation canceled by the user") + completion(false) // Indicate failure due to cancellation return } @@ -203,9 +230,7 @@ func moveFilesToTrash(appState: AppState, at fileURLs: [URL], completion: @escap printOS("Trash: \(outputString)") } } - DispatchQueue.main.async { - completion(true) // Indicate success - } + completion(true) // Indicate success } } @@ -265,7 +290,7 @@ func undoTrash(appState: AppState, completion: @escaping () -> Void = {}) { """ var error: NSDictionary? - updateOnBackground { + updateOnMain { if let scriptObject = NSAppleScript(source: scriptSource) { let output: NSAppleEventDescriptor = scriptObject.executeAndReturnError(&error) if let error = error { @@ -274,9 +299,7 @@ func undoTrash(appState: AppState, completion: @escaping () -> Void = {}) { printOS(outputString) } } - DispatchQueue.main.async { - completion() - } + completion() } } diff --git a/Pearcleaner/Logic/Utilities.swift b/Pearcleaner/Logic/Utilities.swift index e34da71..a92c4c0 100644 --- a/Pearcleaner/Logic/Utilities.swift +++ b/Pearcleaner/Logic/Utilities.swift @@ -303,23 +303,26 @@ func manageSymlink(install: Bool) { """ } - // Execute the AppleScript - var error: NSDictionary? - if let scriptObject = NSAppleScript(source: script) { - scriptObject.executeAndReturnError(&error) + updateOnMain { + // Execute the AppleScript + var error: NSDictionary? + if let scriptObject = NSAppleScript(source: script) { + scriptObject.executeAndReturnError(&error) - if let error = error { - printOS("AppleScript Error: \(error)") - } else { - if install { - printOS("Symlink created successfully at \(symlinkPath).") + if let error = error { + printOS("AppleScript Error: \(error)") } else { - printOS("Symlink removed successfully from \(symlinkPath).") + if install { + printOS("Symlink created successfully at \(symlinkPath).") + } else { + printOS("Symlink removed successfully from \(symlinkPath).") + } } + } else { + printOS("Error: Unable to create the AppleScript object.") } - } else { - printOS("Error: Unable to create the AppleScript object.") } + } @@ -361,14 +364,17 @@ func caskCleanup(app: String) { end tell """ - var error: NSDictionary? - if let appleScript = NSAppleScript(source: script) { - appleScript.executeAndReturnError(&error) + updateOnMain { + var error: NSDictionary? + if let appleScript = NSAppleScript(source: script) { + appleScript.executeAndReturnError(&error) + } + + if let error = error { + printOS("AppleScript Error: \(error)") + } } - if let error = error { - printOS("AppleScript Error: \(error)") - } } else { printOS("Brew cleanup: No cask found for \(app).") } @@ -453,15 +459,17 @@ func caskCleanup2(app: String) { fi'" in front window end tell """ + updateOnMain { + var error: NSDictionary? + if let appleScript = NSAppleScript(source: script) { + appleScript.executeAndReturnError(&error) + } - var error: NSDictionary? - if let appleScript = NSAppleScript(source: script) { - appleScript.executeAndReturnError(&error) + if let error = error { + print("AppleScript Error: \(error)") + } } - if let error = error { - print("AppleScript Error: \(error)") - } } } @@ -796,6 +804,15 @@ func isNested(path: URL) -> Bool { } +// Date formatter for metadata +func formattedMDDate(from date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm zzz" + formatter.timeZone = .current // Use the current timezone + return formatter.string(from: date) +} + + // --- Extend String to remove periods, spaces and lowercase the string extension String { diff --git a/Pearcleaner/Logic/WindowSettings.swift b/Pearcleaner/Logic/WindowSettings.swift index 3258938..19b92ab 100644 --- a/Pearcleaner/Logic/WindowSettings.swift +++ b/Pearcleaner/Logic/WindowSettings.swift @@ -35,12 +35,12 @@ class WindowSettings: ObservableObject { func newWindow(mini: Bool, withView view: @escaping () -> V) { let frame = self.resetWindowSettings(mini: mini) - if menubarEnabled { - windowRef = NSWindow( - contentRect: .zero, - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - } +// if menubarEnabled { +// windowRef = NSWindow( +// contentRect: .zero, +// styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], +// backing: .buffered, defer: false) +// } // Update the existing windowRef with all desired settings windowRef?.contentView = NSHostingView(rootView: view()) windowRef?.setFrame(frame, display: true, animate: true) diff --git a/Pearcleaner/PearcleanerApp.swift b/Pearcleaner/PearcleanerApp.swift index 54d7259..e37a4d6 100644 --- a/Pearcleaner/PearcleanerApp.swift +++ b/Pearcleaner/PearcleanerApp.swift @@ -98,21 +98,6 @@ struct PearcleanerApp: App { }) .onAppear { - if miniView { - appState.currentView = .apps - } else { - appState.currentView = .empty - } - - // Track main window within windowSettings class - windowSettings.trackMainWindow() - - // Disable tabbing - NSWindow.allowsAutomaticWindowTabbing = false - - // Load apps list on startup - reloadAppsList(appState: appState, fsm: fsm) - // Enable menubar item if menubarEnabled { MenuBarExtraManager.shared.addMenuBarExtra(withView: { @@ -125,12 +110,36 @@ struct PearcleanerApp: App { .environmentObject(permissionManager) .preferredColorScheme(themeManager.displayMode.colorScheme) }) - - findAndHideWindows(named: ["Pearcleaner"]) NSApplication.shared.setActivationPolicy(.accessory) - + windowSettings.trackMainWindow() + findAndHideWindows(named: ["Pearcleaner", ""]) + // Catch windows in case something gets opened from SwiftUI lifecycle + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { + findAndHideWindows(named: ["Pearcleaner", ""]) + }) } + // Set mini view + if mini && miniView { + appState.currentView = .apps + } else { + appState.currentView = .empty + } + + // Disable tabbing + NSWindow.allowsAutomaticWindowTabbing = false + + // Load apps list on startup + reloadAppsList(appState: appState, fsm: fsm) + + if !menubarEnabled { + Task { + // Track main window within windowSettings class + windowSettings.trackMainWindow() + } + } + + } } .windowStyle(.hiddenTitleBar) @@ -197,6 +206,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + } diff --git a/Pearcleaner/Settings/General.swift b/Pearcleaner/Settings/General.swift index f604d31..d083138 100644 --- a/Pearcleaner/Settings/General.swift +++ b/Pearcleaner/Settings/General.swift @@ -272,8 +272,11 @@ struct GeneralSettingsTab: View { } .onAppear { - appState.updateExtensionStatus() - isCLISymlinked = checkCLISymlink() + Task { + appState.updateExtensionStatus() + isCLISymlinked = checkCLISymlink() + } + } } diff --git a/Pearcleaner/Settings/Interface.swift b/Pearcleaner/Settings/Interface.swift index 03a1cd9..cdbbbd8 100644 --- a/Pearcleaner/Settings/Interface.swift +++ b/Pearcleaner/Settings/Interface.swift @@ -283,7 +283,7 @@ struct InterfaceSettingsTab: View { .preferredColorScheme(themeManager.displayMode.colorScheme) }) NSApplication.shared.setActivationPolicy(.accessory) - findAndHideWindows(named: ["Pearcleaner"]) + findAndHideWindows(named: ["Pearcleaner", ""]) } else { MenuBarExtraManager.shared.removeMenuBarExtra() NSApplication.shared.setActivationPolicy(.regular) diff --git a/Pearcleaner/Views/AppListItems.swift b/Pearcleaner/Views/AppListItems.swift index fe9c88d..58c80ed 100644 --- a/Pearcleaner/Views/AppListItems.swift +++ b/Pearcleaner/Views/AppListItems.swift @@ -82,31 +82,29 @@ struct AppListItems: View { } - Spacer() - if appInfo.webApp { - Text("web") - .font(.footnote) + Image(systemName: "safari") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) .foregroundStyle(.primary.opacity(0.3)) - .frame(minWidth: 30, minHeight: 15) - .padding(2) - .background( - Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1) - ) + .symbolRenderingMode(.monochrome) + .help("Web app") } if appInfo.wrapped { - Text("iOS") - .font(.footnote) + Image(systemName: "iphone") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) .foregroundStyle(.primary.opacity(0.3)) - .frame(minWidth: 30, minHeight: 15) - .padding(2) - .background( - Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1) - ) + .symbolRenderingMode(.monochrome) + .help("iOS app") } + Spacer() + if minimalEnabled { - Text(bundleSize == 0 ? "v\(appInfo.appVersion)" : (isHovered ? "v\(appInfo.appVersion)" : formatByte(size: bundleSize).human)) + Text(appInfo.bundleSize == 0 ? "v\(appInfo.appVersion)" : (isHovered ? "v\(appInfo.appVersion)" : formatByte(size: appInfo.bundleSize).human)) .font(.system(size: (isHovered || isSelected) ? 12 : 10)) .foregroundStyle(.primary.opacity(0.5)) } @@ -119,7 +117,7 @@ struct AppListItems: View { .frame(height: 35) .padding(.horizontal) .padding(.vertical, 5) - .help(appInfo.appName) +// .help(appInfo.appName) .onHover { hovering in withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { self.isHovered = hovering @@ -155,9 +153,10 @@ struct AppListItems: View { } } .onAppear { - if self.bundleSize == 0 { + if appInfo.bundleSize == 0 { appState.getBundleSize(for: appInfo) { size in - self.bundleSize = size +// print("Getting size for: \(appInfo.appName)") +// appInfo.bundleSize = size } } } diff --git a/Pearcleaner/Views/AppSearchView.swift b/Pearcleaner/Views/AppSearchView.swift index 941138e..467a0bd 100644 --- a/Pearcleaner/Views/AppSearchView.swift +++ b/Pearcleaner/Views/AppSearchView.swift @@ -28,23 +28,14 @@ struct AppSearchView: View { var body: some View { VStack(alignment: .center, spacing: 0) { + Spacer() - .frame(height: 10) - .padding(.top, !isMenuBar ? 25 : 0) - - if updater.updateAvailable { - UpdateBadge(updater: updater) - .padding(.horizontal) - } else if let _ = permissionManager.results, !permissionManager.allPermissionsGranted { - PermissionsBadge() - .padding(.horizontal) - } else if updater.announcementAvailable { - FeatureBadge(updater: updater) - .padding(.horizontal) - } + .frame(height: !isMenuBar ? 30 : 10) - AppsListView(search: $search, showPopover: $showPopover, filteredApps: filteredApps) + searchBarComponent + .padding(.horizontal, search.isEmpty ? 10 : 5) + .padding(.bottom, 5) Divider() @@ -55,105 +46,125 @@ struct AppSearchView: View { #endif - HStack(spacing: 10) { - - if search.isEmpty { - Button("Refresh") { - withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { - showPopover = false - reloadAppsList(appState: appState, fsm: fsm) - } - } - .buttonStyle(SimpleButtonStyle(icon: "arrow.counterclockwise.circle", help: "Refresh apps (⌘+R)", size: 16)) - } - - SearchBar(search: $search, darker: (mini || menubarEnabled) ? false : true, glass: glass) - - - if search.isEmpty { - Button("More") { - self.showMenu.toggle() - } - .buttonStyle(SimpleButtonStyle(icon: "ellipsis.circle", help: "More", size: 16, rotate: true)) - .popover(isPresented: $showMenu) { - VStack(alignment: .leading) { - - Button("") { - withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { - selectedSortAlpha.toggle() - } - } - .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Sorting: \(selectedSortAlpha ? "ABC" : "123")", help: "Can also click on User/System headers to toggle this", size: 5)) - - if mini && !menubarEnabled { - Button("") { - withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { - appState.currentView = .empty - appState.appInfo = AppInfo.empty - showPopover = false - } - } - .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Drop Target", help: "Drop Target", size: 5)) - } - - - Button("Leftover Files") { - showMenu = false - withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { - showPopover = false - appState.appInfo = .empty - if appState.zombieFile.fileSize.keys.isEmpty { - appState.currentView = .zombie - appState.showProgress.toggle() - showPopover.toggle() - reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, fsm: fsm) - } else { - appState.currentView = .zombie - showPopover.toggle() - } - } - } - .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Leftover Files", help: "Leftover Files", size: 5)) - - - if #available(macOS 14.0, *) { - SettingsLink {} - .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Settings", help: "Settings", size: 5)) - } else { - Button("Settings") { - if #available(macOS 13.0, *) { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - } else { - NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) - } - showMenu = false - } - .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Settings", help: "Settings", size: 5)) - } - - if menubarEnabled { - Button("Quit") { - NSApp.terminate(nil) - } - .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Quit Pearcleaner", help: "Quit Pearcleaner", size: 5)) - } - - } - .padding() - .background(backgroundView(themeManager: themeManager, glass: glass).padding(-80)) - - } - } - - + AppsListView(search: $search, showPopover: $showPopover, filteredApps: filteredApps) + .padding(.vertical, 4) + if updater.updateAvailable { + Divider() + UpdateBadge(updater: updater) + .padding(8) + } else if let _ = permissionManager.results, !permissionManager.allPermissionsGranted { + Divider() + PermissionsBadge() + .padding(8) + } else if updater.announcementAvailable { + Divider() + FeatureBadge(updater: updater) + .padding(8) } - .padding(.horizontal, search.isEmpty ? 10 : 5) - .padding(.vertical, 5) + } } + + private var searchBarComponent: some View { + HStack(spacing: 10) { + + if search.isEmpty { + Button("Refresh") { + withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { + showPopover = false + reloadAppsList(appState: appState, fsm: fsm) + } + } + .buttonStyle(SimpleButtonStyle(icon: "arrow.counterclockwise.circle", help: "Refresh apps (⌘+R)", size: 16)) + } + + SearchBar(search: $search, darker: (mini || menubarEnabled) ? false : true, glass: glass) + + + if search.isEmpty { + Button("More") { + self.showMenu.toggle() + } + .buttonStyle(SimpleButtonStyle(icon: "ellipsis.circle", help: "More", size: 16, rotate: true)) + .popover(isPresented: $showMenu) { + VStack(alignment: .leading) { + + Button("") { + withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { + selectedSortAlpha.toggle() + } + } + .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Sorting: \(selectedSortAlpha ? "ABC" : "123")", help: "Can also click on User/System headers to toggle this", size: 5)) + + if mini && !menubarEnabled { + Button("") { + withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { + appState.currentView = .empty + appState.appInfo = AppInfo.empty + showPopover = false + } + } + .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Drop Target", help: "Drop Target", size: 5)) + } + + + Button("Leftover Files") { + showMenu = false + withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { + showPopover = false + appState.appInfo = .empty + if appState.zombieFile.fileSize.keys.isEmpty { + appState.currentView = .zombie + appState.showProgress.toggle() + showPopover.toggle() + reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, fsm: fsm) + } else { + appState.currentView = .zombie + showPopover.toggle() + } + } + } + .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Leftover Files", help: "Leftover Files", size: 5)) + + + if #available(macOS 14.0, *) { + SettingsLink {} + .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Settings", help: "Settings", size: 5)) + } else { + Button("Settings") { + if #available(macOS 13.0, *) { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } else { + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + } + showMenu = false + } + .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Settings", help: "Settings", size: 5)) + } + + if menubarEnabled { + Button("Quit") { + NSApp.terminate(nil) + } + .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Quit Pearcleaner", help: "Quit Pearcleaner", size: 5)) + } + + } + .padding() + .background(backgroundView(themeManager: themeManager, glass: glass).padding(-80)) + + } + } + + + + } + } + + private var filteredApps: [AppInfo] { let apps: [AppInfo] if search.isEmpty { diff --git a/Pearcleaner/Views/AppsListView.swift b/Pearcleaner/Views/AppsListView.swift index 9983e08..4a7166c 100644 --- a/Pearcleaner/Views/AppsListView.swift +++ b/Pearcleaner/Views/AppsListView.swift @@ -18,7 +18,7 @@ struct AppsListView: View { var body: some View { ScrollView { - LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + VStack(alignment: .leading, spacing: 0) { let filteredUserApps = filteredApps.filter { !$0.system } let filteredSystemApps = filteredApps.filter { $0.system } @@ -48,7 +48,7 @@ struct SectionView: View { @AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true var body: some View { - LazyVStack(spacing: 0) { + VStack(spacing: 0) { Header(title: title, count: count, showPopover: $showPopover) .padding(.leading, 5) .onTapGesture { diff --git a/Pearcleaner/Views/CustomTabView.swift b/Pearcleaner/Views/CustomTabView.swift new file mode 100644 index 0000000..ca227a6 --- /dev/null +++ b/Pearcleaner/Views/CustomTabView.swift @@ -0,0 +1,68 @@ +// +// CustomTabView.swift +// Pearcleaner +// +// Created by Alin Lupascu on 10/3/24. +// +import SwiftUI + +public struct CustomTabView: View { + private let titles: [String] + private let icons: [String] + private let tabViews: [AnyView] + + @State private var selection = 0 + @State private var indexHovered = -1 + + public init(content: [(title: String, icon: String, view: AnyView)]) { + self.titles = content.map{ $0.title } + self.icons = content.map{ $0.icon } + self.tabViews = content.map{ $0.view } + } + + public var tabBar: some View { + HStack { + Spacer() + ForEach(0..