From 305da1b859586b70b4f5faed5dde76ff2084037d Mon Sep 17 00:00:00 2001 From: Alin Date: Wed, 20 Nov 2024 16:41:37 -0700 Subject: [PATCH] Add bundle export feature --- Pearcleaner/Logic/AppCommands.swift | 27 +- Pearcleaner/Logic/Logic.swift | 588 ++++++++++++++++++++ Pearcleaner/Logic/Utilities.swift | 531 ------------------ Pearcleaner/Resources/Localizable.xcstrings | 6 + Pearcleaner/Views/AppListItems.swift | 11 +- 5 files changed, 611 insertions(+), 552 deletions(-) diff --git a/Pearcleaner/Logic/AppCommands.swift b/Pearcleaner/Logic/AppCommands.swift index eae4607..3a589d2 100644 --- a/Pearcleaner/Logic/AppCommands.swift +++ b/Pearcleaner/Logic/AppCommands.swift @@ -93,35 +93,38 @@ struct AppCommands: Commands { } .keyboardShortcut("r", modifiers: .command) -// Button -// { -// if !appState.appInfo.bundleIdentifier.isEmpty { -// appState.showConditionBuilder = true -// } -// } label: { -// Label("Condition Builder", systemImage: "hammer") -// } -// .keyboardShortcut("b", modifiers: .command) + Button + { + if !appState.selectedItems.isEmpty { + createTarArchive(appState: appState) + } + } label: { + Label("Bundle Files...", systemImage: "archivebox") + } + .keyboardShortcut("b", modifiers: .command) + .disabled(appState.selectedItems.isEmpty) Button { if !appState.appInfo.bundleIdentifier.isEmpty { - saveURLsToFile(urls: appState.selectedItems, appState: appState) + saveURLsToFile(appState: appState) } } label: { - Label("Export File Paths", systemImage: "square.and.arrow.up") + Label("Export File Paths...", systemImage: "square.and.arrow.up") } .keyboardShortcut("e", modifiers: .command) + .disabled(appState.selectedItems.isEmpty) Button { if !appState.appInfo.bundleIdentifier.isEmpty { - saveURLsToFile(urls: appState.selectedItems, appState: appState, copy: true) + saveURLsToFile(appState: appState, copy: true) } } label: { Label("Copy File Paths", systemImage: "square.and.arrow.up") } .keyboardShortcut("c", modifiers: [.command, .option]) + .disabled(appState.selectedItems.isEmpty) } diff --git a/Pearcleaner/Logic/Logic.swift b/Pearcleaner/Logic/Logic.swift index 5735788..aee1b81 100644 --- a/Pearcleaner/Logic/Logic.swift +++ b/Pearcleaner/Logic/Logic.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import AlinFoundation +import UniformTypeIdentifiers // Get all apps from /Applications and ~/Applications @@ -295,3 +296,590 @@ func undoTrash(appState: AppState, completion: @escaping () -> Void = {}) { } +// Reload apps list +func reloadAppsList(appState: AppState, fsm: FolderSettingsManager) { + appState.reload = true + updateOnBackground { + let sortedApps = getSortedApps(paths: fsm.folderPaths) + // Update UI on the main thread + updateOnMain { + appState.sortedApps = sortedApps + appState.reload = false + } + } +} + + +// Process CLI // ======================================================================================================== +func processCLI(arguments: [String], appState: AppState, locations: Locations, fsm: FolderSettingsManager) { + let options = Array(arguments.dropFirst()) // Remove the first argument (binary path) + + // Launch app in terminal for debugging purposes + func debugLaunch() { + print("[BETA] Pearcleaner CLI | Launching App For Debugging:\n") + } + + // Private function to list files for uninstall, using the provided path + func listFiles(at path: String) { + // Convert the provided string path to a URL + let url = URL(fileURLWithPath: path) + + print("[BETA] Pearcleaner CLI | List Application Files:\n") + + // Fetch the app info and safely unwrap + guard let appInfo = AppInfoFetcher.getAppInfo(atPath: url) else { + print("Error: Invalid path or unable to fetch app info at path: \(path)\n") + exit(1) // Exit with non-zero code to indicate failure + } + + // Use the AppPathFinderCLI to find paths synchronously + let appPathFinder = AppPathFinder(appInfo: appInfo, locations: locations) + + // Call findPaths to get the Set of URLs + let foundPaths = appPathFinder.findPathsCLI() + + // Print each path in the Set to the console + for path in foundPaths { + print(path.path) + } + + print("\nFound \(foundPaths.count) application files.\n") + + } + + // Private function to list orphaned files for uninstall, using the provided path + func listOrphanedFiles() { + print("[BETA] Pearcleaner CLI | List Orphaned Files:\n") + + // Get installed apps for filtering + let sortedApps = getSortedApps(paths: fsm.folderPaths) + + // Find orphaned files + let foundPaths = ReversePathsSearcher(locations: locations, fsm: fsm, sortedApps: sortedApps) + .reversePathsSearchCLI() + + // Print each path in the array to the console + for path in foundPaths { + print(path.path) + } + print("\nFound \(foundPaths.count) orphaned files.\n") + } + + // Private function to uninstall the application bundle at a given path + func uninstallApp(at path: String) { + // Convert the provided string path to a URL + let url = URL(fileURLWithPath: path) + print("[BETA] Pearcleaner CLI | Uninstall Application:\n") + + // Fetch the app info and safely unwrap + guard let appInfo = AppInfoFetcher.getAppInfo(atPath: url) else { + print("Error: Invalid path or unable to fetch app info at path: \(path)\n") + exit(1) // Exit with non-zero code to indicate failure + } + + killApp(appId: appInfo.bundleIdentifier) { + let success = moveFilesToTrashCLI(at: [appInfo.path]) + if success { + print("Application moved to the trash successfully.\n") + exit(0) + } else { + print("Failed to move application to trash.\n") + exit(1) + } + } + } + + // Private function to uninstall the application and all related files at a given path + func uninstallAll(at path: String) { + // Convert the provided string path to a URL + let url = URL(fileURLWithPath: path) + print("[BETA] Pearcleaner CLI | Uninstall Application & Related Files:\n") + + // Fetch the app info and safely unwrap + guard let appInfo = AppInfoFetcher.getAppInfo(atPath: url) else { + print("Error: Invalid path or unable to fetch app info at path: \(path)") + exit(1) // Exit with non-zero code to indicate failure + } + + // Use the AppPathFinderCLI to find paths synchronously + let appPathFinder = AppPathFinder(appInfo: appInfo, locations: locations) + + // Call findPaths to get the Set of URLs + let foundPaths = appPathFinder.findPathsCLI() + + killApp(appId: appInfo.bundleIdentifier) { + let success = moveFilesToTrashCLI(at: Array(foundPaths)) + if success { + print("The application and related files have been moved to the trash successfully.\n") + exit(0) + } else { + print("Failed to move application and related files to trash.\n") + exit(1) + } + } + } + + // Private function to remove the orphaned files + func removeOrphanedFiles() { + print("[BETA] Pearcleaner CLI | Remove Orphaned Files:\n") + + // Get installed apps for filtering + let sortedApps = getSortedApps(paths: fsm.folderPaths) + + // Find orphaned files + let foundPaths = ReversePathsSearcher(locations: locations, fsm: fsm, sortedApps: sortedApps) + .reversePathsSearchCLI() + + let success = moveFilesToTrashCLI(at: foundPaths) + if success { + print("Orphaned files have been moved to the trash successfully.\n") + exit(0) + } else { + print("Failed to move orphaned files to trash.\n") + exit(1) + } + } + + // Handle run option (-r or --run) + if options.contains("-r") || options.contains("--run") { + debugLaunch() + return + } + + // Handle help option (-h or --help) + if options.contains("-h") || options.contains("--help") { + displayHelp() + exit(0) + } + + // Handle --list or -l option with a path argument for listing app bundle files + if let listIndex = options.firstIndex(where: { $0 == "--list" || $0 == "-l" }), listIndex + 1 < options.count { + let path = options[listIndex + 1] // Path provided after --list or -l + listFiles(at: path) + exit(0) + } + + // Handle --listlf or -lf option with a path argument for listing orphaned files + if options.contains("--list-orphaned") || options.contains("-lo") { + listOrphanedFiles() + exit(0) + } + + // Handle --uninstall or -u option with a path argument to uninstall app bundle only + if let uninstallIndex = options.firstIndex(where: { $0 == "--uninstall" || $0 == "-u" }), uninstallIndex + 1 < options.count { + let path = options[uninstallIndex + 1] // Path provided after --uninstall or -u + uninstallApp(at: path) + exit(0) + } + + // Handle --uninstall-all or -ua option with a path argument to uninstall app bundle and related files + if let uninstallAllIndex = options.firstIndex(where: { $0 == "--uninstall-all" || $0 == "-ua" }), uninstallAllIndex + 1 < options.count { + let path = options[uninstallAllIndex + 1] // Path provided after --uninstall-all or -ua + uninstallAll(at: path) + exit(0) + } + + // Handle --uninstall-lf or -ulf option with a path argument for listing orphaned files + if options.contains("--remove-orphaned") || options.contains("-ro") { + removeOrphanedFiles() + exit(0) + } + + // If no valid option was provided, show the help menu by default + displayHelp() + exit(0) +} + + +// Private function to display help message +func displayHelp() { + print(""" + + [BETA] Pearcleaner CLI | Usage: + + --run, -r Launch Pearcleaner in Debug mode to see console logs + --list , -l List application files available for uninstall at the specified path + --list-orphaned, -lo List orphaned files available for removal + --uninstall , -u Uninstall only the application bundle at the specified path + --uninstall-all , -ua Uninstall application bundle and ALL related files at the specified path + --remove-orphaned, -ro Remove ALL orphaned files (To ignore files, add them to the exception list within Pearcleaner settings) + --help, -h Show this help message + + """) +} + + + +// FinderExtension Sequoia Fix +func manageFinderPlugin(install: Bool) { + let task = Process() + task.launchPath = "/usr/bin/pluginkit" + + task.arguments = ["-e", "\(install ? "use" : "ignore")", "-i", "com.alienator88.Pearcleaner"] + + task.launch() + task.waitUntilExit() +} + + + +// Brew cleanup +func caskCleanup(app: String) { + Task(priority: .high) { + +#if arch(x86_64) + let cmd = "/usr/local/bin/brew" +#elseif arch(arm64) + let cmd = "/opt/homebrew/bin/brew" +#endif + + if let formattedApp = getCaskIdentifier(for: app) { + // App found, proceed with cleanup + let script = """ + tell application "Terminal" + activate + do script "/bin/bash --noprofile --norc -c ' + clear; + echo \\"[Pearcleaner] Homebrew cleanup for \(app):\n\\"; + \(cmd) uninstall --cask \(formattedApp) --force; + \(cmd) cleanup; + killall Terminal; + '" in front window + end tell + """ + + updateOnMain { + var error: NSDictionary? + if let appleScript = NSAppleScript(source: script) { + appleScript.executeAndReturnError(&error) + } + + if let error = error { + printOS("AppleScript Error: \(error)") + } + } + + } else { + printOS("Brew cleanup: No cask found for \(app).") + } + } +} + + +func getCaskIdentifier(for appName: String) -> String? { + +#if arch(x86_64) + let caskroomPath = "/usr/local/Caskroom/" +#elseif arch(arm64) + let caskroomPath = "/opt/homebrew/Caskroom/" +#endif + + let fileManager = FileManager.default + let lowercasedAppName = appName.lowercased() + + do { + // Get all cask directories from Caskroom, ignoring hidden files + let casks = try fileManager.contentsOfDirectory(atPath: caskroomPath).filter { !$0.hasPrefix(".") } + + for cask in casks { + // Construct the path to the cask directory + let caskSubPath = caskroomPath + cask + + // Get all version directories for this cask, ignoring hidden files + let versions = try fileManager.contentsOfDirectory(atPath: caskSubPath).filter { !$0.hasPrefix(".") } + + // Only check the first valid version directory to improve efficiency + if let latestVersion = versions.first { + let appDirectory = "\(caskSubPath)/\(latestVersion)/" + + // List all files in the version directory and check for .app file + let appsInDir = try fileManager.contentsOfDirectory(atPath: appDirectory).filter { !$0.hasPrefix(".") } + + if let appFile = appsInDir.first(where: { $0.hasSuffix(".app") }) { + let realAppName = appFile.replacingOccurrences(of: ".app", with: "").lowercased() + + // Compare the lowercased app names for case-insensitive match + if realAppName == lowercasedAppName { + return realAppName.replacingOccurrences(of: " ", with: "-").lowercased() + } + } + } + } + } catch { + print("Error reading cask metadata: \(error)") + } + + // If no match is found, return nil + return nil +} + + + +// Print list of files locally +func saveURLsToFile(appState: AppState, copy: Bool = false) { + let urls = Set(appState.selectedItems) + + if copy { + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path + var fileContent = "" + let sortedUrls = urls.sorted { $0.path < $1.path } + + for url in sortedUrls { + let pathWithTilde = url.path.replacingOccurrences(of: homeDirectory, with: "~") + fileContent += "\(pathWithTilde)\n" + } + copyToClipboard(fileContent) + } else { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "Select Folder" + + if panel.runModal() == .OK, let selectedFolder = panel.url { + let filePath = selectedFolder.appendingPathComponent("Export-\(appState.appInfo.appName)(v\(appState.appInfo.appVersion)).txt") + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path + var fileContent = "" + let sortedUrls = urls.sorted { $0.path < $1.path } + + for url in sortedUrls { + let pathWithTilde = url.path.replacingOccurrences(of: homeDirectory, with: "~") + fileContent += "\(pathWithTilde)\n" + } + + do { + try fileContent.write(to: filePath, atomically: true, encoding: .utf8) + printOS("File saved successfully at \(filePath.path)") + // Open Finder and select the file + NSWorkspace.shared.selectFile(filePath.path, inFileViewerRootedAtPath: filePath.deletingLastPathComponent().path) + } catch { + printOS("Error saving file: \(error)") + } + + } else { + printOS("Folder selection was canceled.") + } + } + + +} + + +// Remove app from cache +func removeApp(appState: AppState, withPath path: URL) { + @AppStorage("settings.general.brew") var brew: Bool = false + DispatchQueue.main.async { + + // Remove from sortedApps if found + if let index = appState.sortedApps.firstIndex(where: { $0.path == path }) { + appState.sortedApps.remove(at: index) + // return // Exit the function if the app was found and removed + } + + // Brew cleanup if enabled + if brew { + caskCleanup(app: appState.appInfo.appName) + } + + appState.appInfo = AppInfo.empty + + + + } +} + + + +// --- Pearcleaner Uninstall -- +func uninstallPearcleaner(appState: AppState, locations: Locations) { + + // Unload Sentinel Monitor if running + launchctl(load: false) + + // Get app info for Pearcleaner + let appInfo = AppInfoFetcher.getAppInfo(atPath: Bundle.main.bundleURL) + + // Find application files for Pearcleaner + AppPathFinder(appInfo: appInfo!, locations: locations, appState: appState, completion: { + // Kill Pearcleaner and tell Finder to trash the files + let selectedItemsArray = Array(appState.selectedItems).filter { !$0.path.contains(".Trash") } + let posixFiles = selectedItemsArray.map { item in + return "POSIX file \"\(item.path)\"" + (item == selectedItemsArray.last ? "" : ", ")}.joined() + let scriptSource = """ + tell application \"Finder\" to delete { \(posixFiles) } + """ + let task = Process() + task.launchPath = "/bin/sh" + task.arguments = ["-c", "sleep 1; osascript -e '\(scriptSource)'"] + task.launch() + exit(0) + }).findPaths() +} + + +// --- Load Plist file with launchctl --- +func launchctl(load: Bool, completion: @escaping () -> Void = {}) { + let fileManager = FileManager.default + let cmd = load ? "load" : "unload" + + // Define the destination path in LaunchAgents + let launchAgentsURL = fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents") + let destinationPlistURL = launchAgentsURL + .appendingPathComponent("com.alienator88.PearcleanerSentinel.plist") + + // Check if LaunchAgents directory exists, and create it if not + if !fileManager.fileExists(atPath: launchAgentsURL.path) { + do { + try fileManager.createDirectory(at: launchAgentsURL, withIntermediateDirectories: true, attributes: nil) + } catch { + printOS("Error creating LaunchAgents directory: \(error)") + return + } + } + + 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) + + do { + if load { + // Copy the plist content to LaunchAgents + try plistContent.write(to: destinationPlistURL, atomically: true, encoding: .utf8) + } else { + // Run the unload command first + let task = Process() + task.launchPath = "/bin/launchctl" + task.arguments = [cmd, destinationPlistURL.path] + + let combinedPipe = Pipe() + task.standardOutput = combinedPipe + task.standardError = combinedPipe + task.launch() + + // Capture and print combined output + let outputData = combinedPipe.fileHandleForReading.readDataToEndOfFile() + if let output = String(data: outputData, encoding: .utf8), !output.isEmpty { + printOS("Output/Error: \(output)") + } + + // Wait for the task to complete before deleting + task.waitUntilExit() + + // Remove plist after unloading + try fileManager.removeItem(at: destinationPlistURL) + } + } catch { + printOS("Error writing to LaunchAgents or removing plist: \(error)") + return + } + + // Only run the load command if loading + if load { + let task = Process() + task.launchPath = "/bin/launchctl" + task.arguments = [cmd, destinationPlistURL.path] + + let combinedPipe = Pipe() + task.standardOutput = combinedPipe + task.standardError = combinedPipe + task.launch() + + // Capture and print combined output + let outputData = combinedPipe.fileHandleForReading.readDataToEndOfFile() + if let output = String(data: outputData, encoding: .utf8), !output.isEmpty { + printOS("Output/Error: \(output)") + } + } + + completion() + } +} + + + +func createTarArchive(appState: AppState) { + // Filter the array to include only paths under /Users/, /Applications/, or /Library/ + let allowedPaths = Array(appState.selectedItems).filter { + $0.path.starts(with: "/Users/") || + $0.path.starts(with: "/Applications/") + } + + guard !allowedPaths.isEmpty else { + printOS("No valid paths provided.") + return + } + + // Create save panel + let savePanel = NSSavePanel() +// savePanel.allowedContentTypes = [.zip] + savePanel.canCreateDirectories = true + savePanel.showsTagField = false + + // Set default filename + savePanel.nameFieldStringValue = "Bundle-\(appState.appInfo.appName).tar" + savePanel.allowedContentTypes = [UTType(filenameExtension: "tar")!] + + + // Show save panel + let response = savePanel.runModal() + guard response == .OK, let finalDestination = savePanel.url else { + printOS("Archive export cancelled.") + return + } + + do { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("Bundle-" + appState.appInfo.appName) + + // Create a temporary directory to organize the paths + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: nil) + + for path in allowedPaths { + // Compute the relative path for each file + let relativePath: String + if path.path.starts(with: "/Users/") { + relativePath = String(path.path.dropFirst("/Users/".count)) + } else if path.path.starts(with: "/Applications/") { + relativePath = "Applications/" + String(path.path.dropFirst("/Applications/".count)) + } else { + continue + } + + // Create subdirectories as needed in the temporary directory + let destinationPath = tempDir.appendingPathComponent(relativePath) + try FileManager.default.createDirectory(at: destinationPath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) + + // Copy the file to the corresponding relative path in the temporary directory + try FileManager.default.copyItem(at: path, to: destinationPath) + } + + // Use `ditto` to create the tar archive + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + process.arguments = ["-c", "-k", "--sequesterRsrc", "--keepParent", tempDir.path, finalDestination.path] + + let pipe = Pipe() + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + // Check for process errors + if process.terminationStatus != 0 { + let errorData = pipe.fileHandleForReading.readDataToEndOfFile() + let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error" + throw NSError(domain: "com.alienator88.Pearcleaner.archiveExport", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: errorMessage]) + } + + // Clean up the temporary directory + try FileManager.default.removeItem(at: tempDir) + + printOS("Archive created successfully at \(finalDestination.path)") + + } catch { + printOS("Error creating tar archive: \(error)") + } +} diff --git a/Pearcleaner/Logic/Utilities.swift b/Pearcleaner/Logic/Utilities.swift index 8003d5f..0111a53 100644 --- a/Pearcleaner/Logic/Utilities.swift +++ b/Pearcleaner/Logic/Utilities.swift @@ -10,20 +10,6 @@ import SwiftUI import AlinFoundation import AppKit -// Reload apps list -func reloadAppsList(appState: AppState, fsm: FolderSettingsManager) { - appState.reload = true - updateOnBackground { - let sortedApps = getSortedApps(paths: fsm.folderPaths) - // Update UI on the main thread - updateOnMain { - appState.sortedApps = sortedApps - appState.reload = false - } - } -} - - func resizeWindowAuto(windowSettings: WindowSettings, title: String) { if let window = NSApplication.shared.windows.first(where: { $0.title == title }) { @@ -59,202 +45,9 @@ func findAndSetWindowFrame(named titles: [String], windowSettings: WindowSetting } -// Process CLI // ======================================================================================================== -func processCLI(arguments: [String], appState: AppState, locations: Locations, fsm: FolderSettingsManager) { - let options = Array(arguments.dropFirst()) // Remove the first argument (binary path) - - // Launch app in terminal for debugging purposes - func debugLaunch() { - print("[BETA] Pearcleaner CLI | Launching App For Debugging:\n") - } - - // Private function to list files for uninstall, using the provided path - func listFiles(at path: String) { - // Convert the provided string path to a URL - let url = URL(fileURLWithPath: path) - - print("[BETA] Pearcleaner CLI | List Application Files:\n") - - // Fetch the app info and safely unwrap - guard let appInfo = AppInfoFetcher.getAppInfo(atPath: url) else { - print("Error: Invalid path or unable to fetch app info at path: \(path)\n") - exit(1) // Exit with non-zero code to indicate failure - } - - // Use the AppPathFinderCLI to find paths synchronously - let appPathFinder = AppPathFinder(appInfo: appInfo, locations: locations) - - // Call findPaths to get the Set of URLs - let foundPaths = appPathFinder.findPathsCLI() - - // Print each path in the Set to the console - for path in foundPaths { - print(path.path) - } - - print("\nFound \(foundPaths.count) application files.\n") - } - // Private function to list orphaned files for uninstall, using the provided path - func listOrphanedFiles() { - print("[BETA] Pearcleaner CLI | List Orphaned Files:\n") - // Get installed apps for filtering - let sortedApps = getSortedApps(paths: fsm.folderPaths) - - // Find orphaned files - let foundPaths = ReversePathsSearcher(locations: locations, fsm: fsm, sortedApps: sortedApps) - .reversePathsSearchCLI() - - // Print each path in the array to the console - for path in foundPaths { - print(path.path) - } - print("\nFound \(foundPaths.count) orphaned files.\n") - } - - // Private function to uninstall the application bundle at a given path - func uninstallApp(at path: String) { - // Convert the provided string path to a URL - let url = URL(fileURLWithPath: path) - print("[BETA] Pearcleaner CLI | Uninstall Application:\n") - - // Fetch the app info and safely unwrap - guard let appInfo = AppInfoFetcher.getAppInfo(atPath: url) else { - print("Error: Invalid path or unable to fetch app info at path: \(path)\n") - exit(1) // Exit with non-zero code to indicate failure - } - - killApp(appId: appInfo.bundleIdentifier) { - let success = moveFilesToTrashCLI(at: [appInfo.path]) - if success { - print("Application moved to the trash successfully.\n") - exit(0) - } else { - print("Failed to move application to trash.\n") - exit(1) - } - } - } - - // Private function to uninstall the application and all related files at a given path - func uninstallAll(at path: String) { - // Convert the provided string path to a URL - let url = URL(fileURLWithPath: path) - print("[BETA] Pearcleaner CLI | Uninstall Application & Related Files:\n") - - // Fetch the app info and safely unwrap - guard let appInfo = AppInfoFetcher.getAppInfo(atPath: url) else { - print("Error: Invalid path or unable to fetch app info at path: \(path)") - exit(1) // Exit with non-zero code to indicate failure - } - - // Use the AppPathFinderCLI to find paths synchronously - let appPathFinder = AppPathFinder(appInfo: appInfo, locations: locations) - - // Call findPaths to get the Set of URLs - let foundPaths = appPathFinder.findPathsCLI() - - killApp(appId: appInfo.bundleIdentifier) { - let success = moveFilesToTrashCLI(at: Array(foundPaths)) - if success { - print("The application and related files have been moved to the trash successfully.\n") - exit(0) - } else { - print("Failed to move application and related files to trash.\n") - exit(1) - } - } - } - - // Private function to remove the orphaned files - func removeOrphanedFiles() { - print("[BETA] Pearcleaner CLI | Remove Orphaned Files:\n") - - // Get installed apps for filtering - let sortedApps = getSortedApps(paths: fsm.folderPaths) - - // Find orphaned files - let foundPaths = ReversePathsSearcher(locations: locations, fsm: fsm, sortedApps: sortedApps) - .reversePathsSearchCLI() - - let success = moveFilesToTrashCLI(at: foundPaths) - if success { - print("Orphaned files have been moved to the trash successfully.\n") - exit(0) - } else { - print("Failed to move orphaned files to trash.\n") - exit(1) - } - } - - // Handle run option (-r or --run) - if options.contains("-r") || options.contains("--run") { - debugLaunch() - return - } - - // Handle help option (-h or --help) - if options.contains("-h") || options.contains("--help") { - displayHelp() - exit(0) - } - - // Handle --list or -l option with a path argument for listing app bundle files - if let listIndex = options.firstIndex(where: { $0 == "--list" || $0 == "-l" }), listIndex + 1 < options.count { - let path = options[listIndex + 1] // Path provided after --list or -l - listFiles(at: path) - exit(0) - } - - // Handle --listlf or -lf option with a path argument for listing orphaned files - if options.contains("--list-orphaned") || options.contains("-lo") { - listOrphanedFiles() - exit(0) - } - - // Handle --uninstall or -u option with a path argument to uninstall app bundle only - if let uninstallIndex = options.firstIndex(where: { $0 == "--uninstall" || $0 == "-u" }), uninstallIndex + 1 < options.count { - let path = options[uninstallIndex + 1] // Path provided after --uninstall or -u - uninstallApp(at: path) - exit(0) - } - - // Handle --uninstall-all or -ua option with a path argument to uninstall app bundle and related files - if let uninstallAllIndex = options.firstIndex(where: { $0 == "--uninstall-all" || $0 == "-ua" }), uninstallAllIndex + 1 < options.count { - let path = options[uninstallAllIndex + 1] // Path provided after --uninstall-all or -ua - uninstallAll(at: path) - exit(0) - } - - // Handle --uninstall-lf or -ulf option with a path argument for listing orphaned files - if options.contains("--remove-orphaned") || options.contains("-ro") { - removeOrphanedFiles() - exit(0) - } - - // If no valid option was provided, show the help menu by default - displayHelp() - exit(0) -} - -// Private function to display help message -func displayHelp() { - print(""" - - [BETA] Pearcleaner CLI | Usage: - - --run, -r Launch Pearcleaner in Debug mode to see console logs - --list , -l List application files available for uninstall at the specified path - --list-orphaned, -lo List orphaned files available for removal - --uninstall , -u Uninstall only the application bundle at the specified path - --uninstall-all , -ua Uninstall application bundle and ALL related files at the specified path - --remove-orphaned, -ro Remove ALL orphaned files (To ignore files, add them to the exception list within Pearcleaner settings) - --help, -h Show this help message - - """) -} // Check if pearcleaner symlink exists @@ -338,203 +131,11 @@ func manageSymlink(install: Bool) { } -// FinderExtension Sequoia Fix -func manageFinderPlugin(install: Bool) { - let task = Process() - task.launchPath = "/usr/bin/pluginkit" - task.arguments = ["-e", "\(install ? "use" : "ignore")", "-i", "com.alienator88.Pearcleaner"] - task.launch() - task.waitUntilExit() -} -// Brew cleanup -func caskCleanup(app: String) { - Task(priority: .high) { - -#if arch(x86_64) - let cmd = "/usr/local/bin/brew" -#elseif arch(arm64) - let cmd = "/opt/homebrew/bin/brew" -#endif - - if let formattedApp = getCaskIdentifier(for: app) { - // App found, proceed with cleanup - let script = """ - tell application "Terminal" - activate - do script "/bin/bash --noprofile --norc -c ' - clear; - echo \\"[Pearcleaner] Homebrew cleanup for \(app):\n\\"; - \(cmd) uninstall --cask \(formattedApp) --force; - \(cmd) cleanup; - killall Terminal; - '" in front window - end tell - """ - - updateOnMain { - var error: NSDictionary? - if let appleScript = NSAppleScript(source: script) { - appleScript.executeAndReturnError(&error) - } - - if let error = error { - printOS("AppleScript Error: \(error)") - } - } - - } else { - printOS("Brew cleanup: No cask found for \(app).") - } - } -} - - -func getCaskIdentifier(for appName: String) -> String? { - -#if arch(x86_64) - let caskroomPath = "/usr/local/Caskroom/" -#elseif arch(arm64) - let caskroomPath = "/opt/homebrew/Caskroom/" -#endif - - let fileManager = FileManager.default - let lowercasedAppName = appName.lowercased() - - do { - // Get all cask directories from Caskroom, ignoring hidden files - let casks = try fileManager.contentsOfDirectory(atPath: caskroomPath).filter { !$0.hasPrefix(".") } - - for cask in casks { - // Construct the path to the cask directory - let caskSubPath = caskroomPath + cask - - // Get all version directories for this cask, ignoring hidden files - let versions = try fileManager.contentsOfDirectory(atPath: caskSubPath).filter { !$0.hasPrefix(".") } - - // Only check the first valid version directory to improve efficiency - if let latestVersion = versions.first { - let appDirectory = "\(caskSubPath)/\(latestVersion)/" - - // List all files in the version directory and check for .app file - let appsInDir = try fileManager.contentsOfDirectory(atPath: appDirectory).filter { !$0.hasPrefix(".") } - - if let appFile = appsInDir.first(where: { $0.hasSuffix(".app") }) { - let realAppName = appFile.replacingOccurrences(of: ".app", with: "").lowercased() - - // Compare the lowercased app names for case-insensitive match - if realAppName == lowercasedAppName { - return realAppName.replacingOccurrences(of: " ", with: "-").lowercased() - } - } - } - } - } catch { - print("Error reading cask metadata: \(error)") - } - - // If no match is found, return nil - return nil -} - - -func caskCleanup2(app: String) { - Task(priority: .high) { - let formattedApp = app.lowercased().replacingOccurrences(of: " ", with: "-") - -#if arch(x86_64) - let cmd = "/usr/local/bin/brew" -#elseif arch(arm64) - let cmd = "/opt/homebrew/bin/brew" -#endif - - let script = """ - tell application "Terminal" - activate - do script "/bin/bash --noprofile --norc -c ' - found_cask=$(\(cmd) list --cask | grep '\(formattedApp)'); - if [ -n \\"$found_cask\\" ]; then - clear; - echo \\"[Pearcleaner] Homebrew cleanup for \(formattedApp):\n\\"; - \(cmd) uninstall --cask \\"$found_cask\\" --force; - \(cmd) cleanup; - killall Terminal; - else - clear; - echo \\"[Pearcleaner] \(formattedApp) cask not found for Homebrew cleanup!\nClosing window in 5 seconds..\\"; - sleep 5; - killall Terminal; - fi'" in front window - end tell - """ - updateOnMain { - var error: NSDictionary? - if let appleScript = NSAppleScript(source: script) { - appleScript.executeAndReturnError(&error) - } - - if let error = error { - print("AppleScript Error: \(error)") - } - } - - } -} - - -// Print list of files locally -func saveURLsToFile(urls: Set, appState: AppState, copy: Bool = false) { - - if copy { - let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path - var fileContent = "" - let sortedUrls = urls.sorted { $0.path < $1.path } - - for url in sortedUrls { - let pathWithTilde = url.path.replacingOccurrences(of: homeDirectory, with: "~") - fileContent += "\(pathWithTilde)\n" - } - copyToClipboard(fileContent) - } else { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.prompt = "Select Folder" - - if panel.runModal() == .OK, let selectedFolder = panel.url { - let filePath = selectedFolder.appendingPathComponent("Export-\(appState.appInfo.appName)(v\(appState.appInfo.appVersion)).txt") - let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path - var fileContent = "" - let sortedUrls = urls.sorted { $0.path < $1.path } - - for url in sortedUrls { - let pathWithTilde = url.path.replacingOccurrences(of: homeDirectory, with: "~") - fileContent += "\(pathWithTilde)\n" - } - - do { - try fileContent.write(to: filePath, atomically: true, encoding: .utf8) - printOS("File saved successfully at \(filePath.path)") - // Open Finder and select the file - NSWorkspace.shared.selectFile(filePath.path, inFileViewerRootedAtPath: filePath.deletingLastPathComponent().path) - } catch { - printOS("Error saving file: \(error)") - } - - } else { - printOS("Folder selection was canceled.") - } - } - - -} - - // Open trash folder func openTrash() { if let trashURL = try? FileManager.default.url(for: .trashDirectory, in: .userDomainMask, appropriateFor: nil, create: false) { @@ -607,28 +208,7 @@ func killApp(appId: String, completion: @escaping () -> Void = {}) { completion() } -// Remove app from cache -func removeApp(appState: AppState, withPath path: URL) { - @AppStorage("settings.general.brew") var brew: Bool = false - DispatchQueue.main.async { - - // Remove from sortedApps if found - if let index = appState.sortedApps.firstIndex(where: { $0.path == path }) { - appState.sortedApps.remove(at: index) -// return // Exit the function if the app was found and removed - } - - // Brew cleanup if enabled - if brew { - caskCleanup(app: appState.appInfo.appName) - } - appState.appInfo = AppInfo.empty - - - - } -} // Check if file/folder name has localized variant @@ -849,117 +429,6 @@ extension String { - -// --- Pearcleaner Uninstall -- -func uninstallPearcleaner(appState: AppState, locations: Locations) { - - // Unload Sentinel Monitor if running - launchctl(load: false) - - // Get app info for Pearcleaner - let appInfo = AppInfoFetcher.getAppInfo(atPath: Bundle.main.bundleURL) - - // Find application files for Pearcleaner - AppPathFinder(appInfo: appInfo!, locations: locations, appState: appState, completion: { - // Kill Pearcleaner and tell Finder to trash the files - let selectedItemsArray = Array(appState.selectedItems).filter { !$0.path.contains(".Trash") } - let posixFiles = selectedItemsArray.map { item in - return "POSIX file \"\(item.path)\"" + (item == selectedItemsArray.last ? "" : ", ")}.joined() - let scriptSource = """ - tell application \"Finder\" to delete { \(posixFiles) } - """ - let task = Process() - task.launchPath = "/bin/sh" - task.arguments = ["-c", "sleep 1; osascript -e '\(scriptSource)'"] - task.launch() - exit(0) - }).findPaths() -} - - -// --- Load Plist file with launchctl --- -func launchctl(load: Bool, completion: @escaping () -> Void = {}) { - let fileManager = FileManager.default - let cmd = load ? "load" : "unload" - - // Define the destination path in LaunchAgents - let launchAgentsURL = fileManager.homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents") - let destinationPlistURL = launchAgentsURL - .appendingPathComponent("com.alienator88.PearcleanerSentinel.plist") - - // Check if LaunchAgents directory exists, and create it if not - if !fileManager.fileExists(atPath: launchAgentsURL.path) { - do { - try fileManager.createDirectory(at: launchAgentsURL, withIntermediateDirectories: true, attributes: nil) - } catch { - printOS("Error creating LaunchAgents directory: \(error)") - return - } - } - - 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) - - do { - if load { - // Copy the plist content to LaunchAgents - try plistContent.write(to: destinationPlistURL, atomically: true, encoding: .utf8) - } else { - // Run the unload command first - let task = Process() - task.launchPath = "/bin/launchctl" - task.arguments = [cmd, destinationPlistURL.path] - - let combinedPipe = Pipe() - task.standardOutput = combinedPipe - task.standardError = combinedPipe - task.launch() - - // Capture and print combined output - let outputData = combinedPipe.fileHandleForReading.readDataToEndOfFile() - if let output = String(data: outputData, encoding: .utf8), !output.isEmpty { - printOS("Output/Error: \(output)") - } - - // Wait for the task to complete before deleting - task.waitUntilExit() - - // Remove plist after unloading - try fileManager.removeItem(at: destinationPlistURL) - } - } catch { - printOS("Error writing to LaunchAgents or removing plist: \(error)") - return - } - - // Only run the load command if loading - if load { - let task = Process() - task.launchPath = "/bin/launchctl" - task.arguments = [cmd, destinationPlistURL.path] - - let combinedPipe = Pipe() - task.standardOutput = combinedPipe - task.standardError = combinedPipe - task.launch() - - // Capture and print combined output - let outputData = combinedPipe.fileHandleForReading.readDataToEndOfFile() - if let output = String(data: outputData, encoding: .utf8), !output.isEmpty { - printOS("Output/Error: \(output)") - } - } - - completion() - } -} - - func sendStartNotificationFW() { DistributedNotificationCenter.default().postNotificationName(Notification.Name("Pearcleaner.StartFileWatcher"), object: nil, userInfo: nil, deliverImmediately: true) } diff --git a/Pearcleaner/Resources/Localizable.xcstrings b/Pearcleaner/Resources/Localizable.xcstrings index edb4fce..6bbbc92 100644 --- a/Pearcleaner/Resources/Localizable.xcstrings +++ b/Pearcleaner/Resources/Localizable.xcstrings @@ -1058,6 +1058,9 @@ } } } + }, + "Bundle Files..." : { + }, "calculating" : { "extractionState" : "manual", @@ -2568,6 +2571,9 @@ } } } + }, + "Export File Paths..." : { + }, "Failed to display release notes" : { "extractionState" : "manual", diff --git a/Pearcleaner/Views/AppListItems.swift b/Pearcleaner/Views/AppListItems.swift index cb0fd08..950b89a 100644 --- a/Pearcleaner/Views/AppListItems.swift +++ b/Pearcleaner/Views/AppListItems.swift @@ -102,7 +102,7 @@ struct AppListItems: View { Spacer() - if minimalEnabled { + if minimalEnabled && !isSelected { 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)) @@ -138,14 +138,7 @@ struct AppListItems: View { } .onTapGesture { withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { - if isSelected { - appState.appInfo = .empty - appState.selectedItems = [] - appState.currentView = miniView ? .apps : .empty - showPopover = false - } else { - showAppInFiles(appInfo: appInfo, appState: appState, locations: locations, showPopover: $showPopover) - } + showAppInFiles(appInfo: appInfo, appState: appState, locations: locations, showPopover: $showPopover) } } .background{