├── .github ├── ISSUE_TEMPLATE │ ├── app.md │ ├── bug.md │ └── feature.md └── workflows │ └── brew-bump.yml ├── .gitignore ├── FUNDING.yml ├── FinderOpen ├── Assets.xcassets │ ├── Contents.json │ └── Icon.imageset │ │ ├── Contents.json │ │ └── icon_32x32.png ├── FinderOpen.entitlements ├── FinderOpen.swift └── Info.plist ├── LICENSE.md ├── Pearcleaner.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── FinderOpen.xcscheme │ ├── Pearcleaner Debug.xcscheme │ ├── Pearcleaner Release.xcscheme │ ├── PearcleanerSentinel Release.xcscheme │ └── PearcleanerSentinel.xcscheme ├── Pearcleaner ├── Logic │ ├── AppCommands.swift │ ├── AppInfoFetch.swift │ ├── AppPathsFetch.swift │ ├── AppState.swift │ ├── CLI.swift │ ├── Conditions.swift │ ├── DeepLink.swift │ ├── HelperToolManager.swift │ ├── Lipo.swift │ ├── Locations.swift │ ├── Logic.swift │ ├── MenuBarItem.swift │ ├── PearGroupBox.swift │ ├── ReversePathsFetch.swift │ ├── Styles.swift │ ├── UndoManager.swift │ ├── Utilities.swift │ └── WindowSettings.swift ├── Metal │ ├── AnimatedGradient.metal │ ├── AnimatedGradientView.swift │ ├── LavaLampShader.metal │ └── LavaLampView.swift ├── PearcleanerApp.swift ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── icon_128x128.png │ │ │ ├── icon_16x16.png │ │ │ ├── icon_256x256.png │ │ │ ├── icon_32x32.png │ │ │ └── icon_512x512.png │ │ ├── Contents.json │ │ ├── button.colorset │ │ │ └── Contents.json │ │ ├── grayButton.colorset │ │ │ └── Contents.json │ │ ├── pearLogo.imageset │ │ │ ├── Contents.json │ │ │ └── pearLogo.svg │ │ └── uninstall.colorset │ │ │ └── Contents.json │ ├── Info.plist │ ├── Localizable.xcstrings │ └── Pearcleaner.entitlements ├── Settings │ ├── About.swift │ ├── Folders.swift │ ├── General.swift │ ├── Helper.swift │ ├── Interface.swift │ ├── SettingsWindow.swift │ └── Update.swift ├── Views │ ├── AppListItems.swift │ ├── AppSearchView.swift │ ├── AppsListView.swift │ ├── ConditionBuilderView.swift │ ├── DevelopmentView.swift │ ├── FilesView.swift │ ├── LipoView.swift │ ├── MiniMode.swift │ ├── RegularMode.swift │ ├── TerminalView.swift │ └── ZombieView.swift └── announcements.json ├── PearcleanerHelper ├── com.alienator88.Pearcleaner.PearcleanerHelper.plist └── main.swift ├── PearcleanerSentinel ├── com.alienator88.PearcleanerSentinel.plist └── main.swift ├── README.md └── announcements.json /.github/ISSUE_TEMPLATE/app.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: App 3 | about: Use this to submit bugs for specific apps either crashing when deleting, not showing up in the app list, files/folders not being deleted or unrelated files from other apps being found 4 | title: "[APP] ENTER ISSUE TITLE HERE" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | >NOTE: If a specific app is finding unrelated files or not finding expected files, use the new condition builder to either include or exclude files from the search. Push CMD+B when you're on an app's page to show the condition builder. 11 | 12 | 13 | **Describe the app** 14 | 15 | A clear and concise description of what the affected app issue is. 16 | 17 | **App Details** 18 | 1. Name 19 | 2. Version 20 | 3. Source (AppStore Download, Website Download) 21 | 4. Type (Regular, Web, iOS) 22 | 5. If file/folder deletion issue, enter PATH/S to the files/folders not being deleted 23 | 24 | **Desktop (please complete the following information):** 25 | - OS: [e.g. 13.0] 26 | - Pearcleaner App Mode: [e.g. Regular / Mini Mode] 27 | - Pearcleaner Version: [e.g. 2.0] 28 | 29 | **Screenshots** 30 | 31 | If applicable, add screenshots to help explain your problem. 32 | 33 | **Additional context** 34 | 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: For submitting bugs related to the application functionality, not specific 3rd party app issues. Use APP bug report for that 4 | title: "[BUG] ENTER ISSUE TITLE HERE" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug: 11 | A clear and concise description of what the bug is. 12 | 13 | 14 | ### Steps to reproduce: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | 21 | ### Expected behavior: 22 | A clear and concise description of what you expected to happen. 23 | 24 | 25 | ### Info: 26 | - OS: [e.g. 13.0] 27 | - Pearcleaner Version: [e.g. 3.x.x] 28 | 29 | 30 | ### Screenshots: 31 | If applicable, add screenshots to help explain your problem. 32 | 33 | 34 | ### Console Logs (For app crashes or hard to reproduce issues): 35 | 1. Open the Terminal app and run the following command 36 | ``` 37 | log stream --level debug --style compact --predicate 'subsystem == "com.alienator88.Pearcleaner"' 38 | ``` 39 | 2. Reproduce the issue to capture logs 40 | 3. Copy the logs here 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: For submitting feature requests 4 | title: "[FR] ENTER ISSUE TITLE HERE" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | **Additional context** 23 | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/workflows/brew-bump.yml: -------------------------------------------------------------------------------- 1 | name: brew-bump 2 | on: 3 | # schedule: 4 | # # Time is UTC, so below is running everyday at 8AM and 8PM PST 5 | # - cron: '0 4,16 * * *' 6 | # # Allows you to run this workflow manually from the Actions tab 7 | workflow_dispatch: 8 | 9 | jobs: 10 | bump-casks: 11 | runs-on: macos-latest 12 | steps: 13 | - uses: macauley/action-homebrew-bump-cask@v1 14 | with: 15 | # Required, custom GitHub access token with only the 'public_repo' scope enabled 16 | token: ${{secrets.BUMP_CASK_TOKEN}} 17 | # Bump all outdated casks in this tap 18 | tap: Homebrew/homebrew-cask 19 | # Bump only these casks if outdated 20 | cask: pearcleaner 21 | livecheck: false 22 | dryrun: false 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | .DS_Store 92 | Builds/ 93 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: alienator88 2 | -------------------------------------------------------------------------------- /FinderOpen/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FinderOpen/Assets.xcassets/Icon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "icon_32x32.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FinderOpen/Assets.xcassets/Icon.imageset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alienator88/Pearcleaner/6f7b883a042357270975ecf208cab70ac0447459/FinderOpen/Assets.xcassets/Icon.imageset/icon_32x32.png -------------------------------------------------------------------------------- /FinderOpen/FinderOpen.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.com.alienator88.Pearcleaner 10 | 11 | com.apple.security.temporary-exception.files.absolute-path.read-only 12 | 13 | / 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /FinderOpen/FinderOpen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FinderSync.swift 3 | // FinderOpen 4 | // 5 | // Created by Alin Lupascu on 4/11/24. 6 | // 7 | 8 | import Cocoa 9 | import FinderSync 10 | 11 | class FinderOpen: FIFinderSync { 12 | 13 | override init() { 14 | super.init() 15 | NSLog("FinderSync() launched from %@", Bundle.main.bundlePath as NSString) 16 | // Set the directory URLs that the Finder Sync extension observes 17 | FIFinderSyncController.default().directoryURLs = Set([URL(fileURLWithPath: "/")]) 18 | } 19 | 20 | override func menu(for menuKind: FIMenuKind) -> NSMenu { 21 | let menu = NSMenu(title: "") 22 | 23 | // Ensure we are dealing with the contextual menu for items 24 | if menuKind == .contextualMenuForItems { 25 | // Get the selected items 26 | if let selectedItemURLs = FIFinderSyncController.default().selectedItemURLs(), 27 | selectedItemURLs.count == 1, selectedItemURLs.first?.pathExtension == "app" { 28 | // Add menu item if the selected item is a .app file 29 | let menuItem = NSMenuItem(title: String(localized: "Pearcleaner Uninstall"), action: #selector(openInMyApp), keyEquivalent: "") 30 | // if shouldShowIcon { 31 | // if let appIcon = NSImage(named: "Icon") { 32 | // menuItem.image = appIcon 33 | // } else { 34 | // menuItem.image = NSImage(named: NSImage.trashFullName) 35 | // } 36 | // } 37 | // menuItem.image = NSImage(named: NSImage.trashFullName) 38 | menu.addItem(menuItem) 39 | 40 | } 41 | } 42 | 43 | // Return the menu (which may be empty if the conditions are not met) 44 | return menu 45 | 46 | } 47 | 48 | @objc func openInMyApp(_ sender: AnyObject?) { 49 | // Get the selected items (files/folders) in Finder 50 | guard let selectedItems = FIFinderSyncController.default().selectedItemURLs(), !selectedItems.isEmpty else { 51 | return 52 | } 53 | 54 | // Consider only the first selected item 55 | let firstSelectedItem = selectedItems[0] 56 | let path = firstSelectedItem.path 57 | NSWorkspace.shared.open(URL(string: "pear://com.alienator88.Pearcleaner?path=\(path)")!) 58 | 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /FinderOpen/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionAttributes 8 | 9 | NSExtensionPointIdentifier 10 | com.apple.FinderSync 11 | NSExtensionPrincipalClass 12 | $(PRODUCT_MODULE_NAME).FinderOpen 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Pearcleaner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Pearcleaner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Pearcleaner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "5cd51b257f635a1e0999fc099cdcf5a02d8ea2ac681c7cb0f02d2384086ce2a8", 3 | "pins" : [ 4 | { 5 | "identity" : "alinfoundation", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/alienator88/AlinFoundation", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "399b24c5475ea4ca40ed6d2de7018ed28d55385e" 11 | } 12 | }, 13 | { 14 | "identity" : "filewatcher", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/eonist/FileWatcher.git", 17 | "state" : { 18 | "revision" : "e67c2a99502eade343fecabeca8c57e749a55b59", 19 | "version" : "0.2.3" 20 | } 21 | }, 22 | { 23 | "identity" : "ifrit", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/ukushu/Ifrit.git", 26 | "state" : { 27 | "branch" : "main", 28 | "revision" : "0240251c688cca825f9bd5d6b4b30cf429d62e78" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-argument-parser", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-argument-parser.git", 35 | "state" : { 36 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 37 | "version" : "1.5.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swiftterm", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/migueldeicaza/SwiftTerm", 44 | "state" : { 45 | "revision" : "e2b431dbf73f775fb4807a33e4572ffd3dc6933a", 46 | "version" : "1.2.5" 47 | } 48 | } 49 | ], 50 | "version" : 3 51 | } 52 | -------------------------------------------------------------------------------- /Pearcleaner.xcodeproj/xcshareddata/xcschemes/FinderOpen.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 11 | 17 | 23 | 24 | 25 | 31 | 37 | 38 | 39 | 40 | 41 | 47 | 48 | 60 | 64 | 65 | 66 | 72 | 73 | 74 | 75 | 79 | 80 | 81 | 82 | 90 | 92 | 98 | 99 | 100 | 101 | 103 | 104 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /Pearcleaner.xcodeproj/xcshareddata/xcschemes/Pearcleaner Debug.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 57 | 58 | 59 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /Pearcleaner.xcodeproj/xcshareddata/xcschemes/Pearcleaner Release.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Pearcleaner.xcodeproj/xcshareddata/xcschemes/PearcleanerSentinel Release.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Pearcleaner.xcodeproj/xcshareddata/xcschemes/PearcleanerSentinel.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Pearcleaner/Logic/AppCommands.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppCommands.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 10/31/23. 6 | // 7 | 8 | import SwiftUI 9 | import AlinFoundation 10 | 11 | struct AppCommands: Commands { 12 | 13 | let appState: AppState 14 | let locations: Locations 15 | let fsm: FolderSettingsManager 16 | let updater: Updater 17 | let themeManager: ThemeManager 18 | @Binding var showPopover: Bool 19 | @AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true 20 | @State private var windowController = WindowManager() 21 | 22 | init(appState: AppState, locations: Locations, fsm: FolderSettingsManager, updater: Updater, themeManager: ThemeManager, showPopover: Binding) { 23 | self.appState = appState 24 | self.locations = locations 25 | self.fsm = fsm 26 | self.updater = updater 27 | self.themeManager = themeManager 28 | self._showPopover = showPopover 29 | } 30 | 31 | var body: some Commands { 32 | 33 | // Pearcleaner Menu 34 | CommandGroup(replacing: .appInfo) { 35 | 36 | Button { 37 | updater.checkForUpdates(sheet: true) 38 | } label: { 39 | Text("Check for Updates") 40 | } 41 | .keyboardShortcut("u", modifiers: .command) 42 | 43 | Button { 44 | appState.triggerUninstallAlert() 45 | } label: { 46 | Text("Uninstall Pearcleaner") 47 | } 48 | 49 | } 50 | 51 | 52 | 53 | // Edit Menu 54 | CommandGroup(replacing: .undoRedo) { 55 | 56 | Button 57 | { 58 | if appState.currentView != .zombie { 59 | let result = undoTrash() 60 | if result { 61 | reloadAppsList(appState: appState, fsm: fsm, delay: 1) 62 | if appState.currentView == .files { 63 | showAppInFiles(appInfo: appState.appInfo, appState: appState, locations: locations, showPopover: $showPopover) 64 | } 65 | } 66 | } 67 | 68 | } label: { 69 | Label("Undo Removal", systemImage: "clear") 70 | } 71 | .keyboardShortcut("z", modifiers: .command) 72 | 73 | 74 | } 75 | 76 | 77 | // Window Menu 78 | CommandGroup(after: .sidebar) { 79 | 80 | Menu("Navigate To") { 81 | Button 82 | { 83 | appState.currentPage = .applications 84 | 85 | } label: { 86 | Text("Applications") 87 | } 88 | .keyboardShortcut("1", modifiers: .command) 89 | 90 | Button 91 | { 92 | appState.currentPage = .development 93 | 94 | } label: { 95 | Text("Development") 96 | } 97 | .keyboardShortcut("2", modifiers: .command) 98 | 99 | Button 100 | { 101 | appState.currentPage = .lipo 102 | 103 | } label: { 104 | Text("App Lipo") 105 | } 106 | .keyboardShortcut("3", modifiers: .command) 107 | 108 | Button 109 | { 110 | appState.currentPage = .orphans 111 | 112 | } label: { 113 | Text("Orphaned Files") 114 | } 115 | .keyboardShortcut("4", modifiers: .command) 116 | } 117 | 118 | } 119 | 120 | 121 | // Tools Menu 122 | CommandMenu(Text("Tools", comment: "Tools Menu")) { 123 | 124 | Button { 125 | withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { 126 | reloadAppsList(appState: appState, fsm: fsm) 127 | } 128 | } label: { 129 | Text("Refresh Apps") 130 | } 131 | .keyboardShortcut("r", modifiers: .command) 132 | 133 | Button 134 | { 135 | if !appState.selectedItems.isEmpty { 136 | createTarArchive(appState: appState) 137 | } 138 | } label: { 139 | Label("Bundle Files...", systemImage: "archivebox") 140 | } 141 | .keyboardShortcut("b", modifiers: .command) 142 | .disabled(appState.selectedItems.isEmpty) 143 | 144 | Button 145 | { 146 | if !appState.appInfo.bundleIdentifier.isEmpty { 147 | saveURLsToFile(appState: appState) 148 | } 149 | } label: { 150 | Label("Export File Paths...", systemImage: "square.and.arrow.up") 151 | } 152 | .keyboardShortcut("e", modifiers: .command) 153 | .disabled(appState.selectedItems.isEmpty) 154 | 155 | Button 156 | { 157 | if !appState.appInfo.bundleIdentifier.isEmpty { 158 | saveURLsToFile(appState: appState, copy: true) 159 | } 160 | } label: { 161 | Label("Copy File Paths", systemImage: "square.and.arrow.up") 162 | } 163 | .keyboardShortcut("c", modifiers: [.command, .option]) 164 | .disabled(appState.selectedItems.isEmpty) 165 | 166 | Button { 167 | withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { 168 | windowController.open(with: ConsoleView(), width: 600, height: 400) 169 | } 170 | } label: { 171 | Text("Debug Console") 172 | } 173 | .keyboardShortcut("d", modifiers: .command) 174 | 175 | 176 | 177 | } 178 | 179 | 180 | // GitHub Menu 181 | CommandMenu(Text(verbatim: "GitHub")) { 182 | Button 183 | { 184 | NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner")!) 185 | } label: { 186 | Label("View Repository", systemImage: "paperplane") 187 | } 188 | 189 | 190 | Button 191 | { 192 | NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner/releases")!) 193 | } label: { 194 | Label("View Releases", systemImage: "paperplane") 195 | } 196 | 197 | 198 | Button 199 | { 200 | NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner/issues")!) 201 | } label: { 202 | Label("View Issues", systemImage: "paperplane") 203 | } 204 | 205 | 206 | Divider() 207 | 208 | 209 | Button 210 | { 211 | NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner/issues/new/choose")!) 212 | } label: { 213 | Label("Submit New Issue", systemImage: "paperplane") 214 | } 215 | } 216 | 217 | 218 | 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /Pearcleaner/Logic/CLI.swift: -------------------------------------------------------------------------------- 1 | import AlinFoundation 2 | import ArgumentParser 3 | import Foundation 4 | import ServiceManagement 5 | import SwiftUI 6 | import UniformTypeIdentifiers 7 | 8 | // Main command structure 9 | struct PearCLI: ParsableCommand { 10 | static var configuration = CommandConfiguration( 11 | commandName: "pear", 12 | abstract: "Command-line interface for the Pearcleaner app", 13 | subcommands: [ 14 | // Run.self, 15 | List.self, 16 | ListOrphaned.self, 17 | Uninstall.self, 18 | UninstallAll.self, 19 | RemoveOrphaned.self, 20 | ] 21 | ) 22 | 23 | // For dependency management 24 | static var locations: Locations! 25 | static var fsm: FolderSettingsManager! 26 | 27 | // Set up dependencies before running commands 28 | static func setupDependencies( 29 | locations: Locations, fsm: FolderSettingsManager 30 | ) { 31 | Self.locations = locations 32 | Self.fsm = fsm 33 | } 34 | 35 | // struct Run: ParsableCommand { 36 | // static var configuration = CommandConfiguration( 37 | // commandName: "run", 38 | // abstract: "Launch Pearcleaner in Debug mode to see console logs" 39 | // ) 40 | // 41 | // func run() throws { 42 | // printOS("Pearcleaner CLI | Launching App For Debugging:\n") 43 | // } 44 | // } 45 | 46 | struct List: ParsableCommand { 47 | static var configuration = CommandConfiguration( 48 | commandName: "list", 49 | abstract: "List application files available for uninstall at the specified path" 50 | ) 51 | 52 | @Argument(help: "Path to the application") 53 | var path: String 54 | 55 | func run() throws { 56 | // Convert the provided string path to a URL 57 | let url = URL(fileURLWithPath: path) 58 | 59 | // Fetch the app info and safely unwrap 60 | guard let appInfo = AppInfoFetcher.getAppInfo(atPath: url) else { 61 | printOS("Error: Invalid path or unable to fetch app info at path: \(path)\n") 62 | Foundation.exit(1) 63 | } 64 | 65 | // Use the AppPathFinder to find paths synchronously 66 | let appPathFinder = AppPathFinder(appInfo: appInfo, locations: PearCLI.locations) 67 | 68 | // Call findPaths to get the Set of URLs 69 | let foundPaths = appPathFinder.findPathsCLI() 70 | 71 | // Print each path in the Set to the console 72 | for path in foundPaths { 73 | printOS(path.path) 74 | } 75 | 76 | printOS("\nFound \(foundPaths.count) application files.\n") 77 | Foundation.exit(0) 78 | } 79 | } 80 | 81 | struct ListOrphaned: ParsableCommand { 82 | static var configuration = CommandConfiguration( 83 | commandName: "list-orphaned", 84 | abstract: "List orphaned files available for removal" 85 | ) 86 | 87 | func run() throws { 88 | // Get installed apps for filtering 89 | let sortedApps = getSortedApps(paths: PearCLI.fsm.folderPaths) 90 | 91 | // Find orphaned files 92 | let foundPaths = ReversePathsSearcher( 93 | locations: PearCLI.locations, 94 | fsm: PearCLI.fsm, 95 | sortedApps: sortedApps 96 | ) 97 | .reversePathsSearchCLI() 98 | 99 | // Print each path in the array to the console 100 | for path in foundPaths { 101 | printOS(path.path) 102 | } 103 | printOS("\nFound \(foundPaths.count) orphaned files.\n") 104 | Foundation.exit(0) 105 | } 106 | } 107 | 108 | struct Uninstall: ParsableCommand { 109 | static var configuration = CommandConfiguration( 110 | commandName: "uninstall", 111 | abstract: "Uninstall only the application bundle at the specified path" 112 | ) 113 | 114 | @Argument(help: "Path to the application") 115 | var path: String 116 | 117 | func run() throws { 118 | // Convert the provided string path to a URL 119 | let url = URL(fileURLWithPath: path) 120 | 121 | // Fetch the app info and safely unwrap 122 | guard let appInfo = AppInfoFetcher.getAppInfo(atPath: url) else { 123 | printOS("Error: Invalid path or unable to fetch app info at path: \(path)\n") 124 | Foundation.exit(1) 125 | } 126 | 127 | // Create a semaphore for synchronous operation 128 | let semaphore = DispatchSemaphore(value: 0) 129 | var operationSuccess = false 130 | 131 | killApp(appId: appInfo.bundleIdentifier) { 132 | let success = moveFilesToTrashCLI(at: [appInfo.path]) 133 | operationSuccess = success 134 | semaphore.signal() 135 | } 136 | 137 | // Wait for the async operation to complete 138 | semaphore.wait() 139 | 140 | if operationSuccess { 141 | printOS("Application deleted successfully.\n") 142 | Foundation.exit(0) 143 | } else { 144 | printOS("Failed to delete application.\n") 145 | Foundation.exit(1) 146 | } 147 | } 148 | } 149 | 150 | struct UninstallAll: ParsableCommand { 151 | static var configuration = CommandConfiguration( 152 | commandName: "uninstall-all", 153 | abstract: "Uninstall application bundle and ALL related files at the specified path" 154 | ) 155 | 156 | @Argument(help: "Path to the application") 157 | var path: String 158 | 159 | func run() throws { 160 | // Convert the provided string path to a URL 161 | let url = URL(fileURLWithPath: path) 162 | 163 | // Fetch the app info and safely unwrap 164 | guard let appInfo = AppInfoFetcher.getAppInfo(atPath: url) else { 165 | printOS("Error: Invalid path or unable to fetch app info at path: \(path)") 166 | Foundation.exit(1) 167 | } 168 | 169 | // Use the AppPathFinder to find paths synchronously 170 | let appPathFinder = AppPathFinder(appInfo: appInfo, locations: PearCLI.locations) 171 | 172 | // Call findPaths to get the Set of URLs 173 | let foundPaths = appPathFinder.findPathsCLI() 174 | 175 | // Check if any file is protected (non-writable) 176 | let protectedFiles = foundPaths.filter { 177 | !FileManager.default.isWritableFile(atPath: $0.path) 178 | } 179 | 180 | // If protected files are found, echo message and exit 181 | if !protectedFiles.isEmpty && !HelperToolManager.shared.isHelperToolInstalled { 182 | printOS("Protected files detected. Please run this command with sudo:\n") 183 | printOS("sudo pearcleaner uninstall-all \(path)") 184 | printOS("\nProtected files:\n") 185 | for file in protectedFiles { 186 | printOS(file.path) 187 | } 188 | Foundation.exit(1) 189 | } 190 | 191 | // Create a semaphore for synchronous operation 192 | let semaphore = DispatchSemaphore(value: 0) 193 | var operationSuccess = false 194 | 195 | killApp(appId: appInfo.bundleIdentifier) { 196 | let success = moveFilesToTrashCLI(at: Array(foundPaths)) 197 | operationSuccess = success 198 | semaphore.signal() 199 | } 200 | 201 | // Wait for the async operation to complete 202 | semaphore.wait() 203 | 204 | if operationSuccess { 205 | printOS("The application and related files have been deleted successfully.\n") 206 | Foundation.exit(0) 207 | } else { 208 | printOS("Failed to delete some files, they might be protected or in use.\n") 209 | Foundation.exit(1) 210 | } 211 | } 212 | } 213 | 214 | struct RemoveOrphaned: ParsableCommand { 215 | static var configuration = CommandConfiguration( 216 | commandName: "remove-orphaned", 217 | abstract: 218 | "Remove ALL orphaned files (To ignore files, add them to the exception list within Pearcleaner settings)" 219 | ) 220 | 221 | func run() throws { 222 | 223 | // Get installed apps for filtering 224 | let sortedApps = getSortedApps(paths: PearCLI.fsm.folderPaths) 225 | 226 | // Find orphaned files 227 | let foundPaths = ReversePathsSearcher( 228 | locations: PearCLI.locations, 229 | fsm: PearCLI.fsm, 230 | sortedApps: sortedApps 231 | ) 232 | .reversePathsSearchCLI() 233 | 234 | // Check if any file is protected (non-writable) 235 | let protectedFiles = foundPaths.filter { 236 | !FileManager.default.isWritableFile(atPath: $0.path) 237 | } 238 | 239 | // If protected files are found, echo message and exit 240 | if !protectedFiles.isEmpty && !HelperToolManager.shared.isHelperToolInstalled { 241 | printOS("Protected files detected. Please run this command with sudo:\n") 242 | printOS("sudo pearcleaner remove-orphaned") 243 | printOS("\nProtected files:\n") 244 | for file in protectedFiles { 245 | printOS(file.path) 246 | } 247 | Foundation.exit(1) 248 | } 249 | 250 | let success = moveFilesToTrashCLI(at: foundPaths) 251 | if success { 252 | printOS("Orphaned files have been deleted successfully.\n") 253 | Foundation.exit(0) 254 | } else { 255 | printOS("Failed to delete some orphaned files.\n") 256 | Foundation.exit(1) 257 | } 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /Pearcleaner/Logic/Conditions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Conditions.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 4/15/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | struct Condition: Codable { 12 | var bundle_id: String 13 | var include: [String] 14 | var exclude: [String] 15 | var includeForce: [URL]? 16 | var excludeForce: [URL]? 17 | 18 | init(bundle_id: String, include: [String], exclude: [String], includeForce: [String]? = nil, excludeForce: [String]? = nil) { 19 | self.bundle_id = bundle_id.pearFormat() 20 | self.include = include.map { $0.pearFormat() } 21 | self.exclude = exclude.map { $0.pearFormat() } 22 | self.includeForce = includeForce?.compactMap { path in 23 | if let url = URL(string: path), FileManager.default.fileExists(atPath: url.path) { 24 | return url 25 | } 26 | return nil 27 | } 28 | self.excludeForce = excludeForce?.compactMap { path in 29 | if let url = URL(string: path), FileManager.default.fileExists(atPath: url.path) { 30 | return url 31 | } 32 | return nil 33 | } 34 | } 35 | } 36 | 37 | struct SkipCondition { 38 | var skipPrefix: [String] 39 | var allowPrefixes: [String] 40 | } 41 | 42 | 43 | 44 | // Conditions for some apps that need to include/exclude certain files/folders when names are more complicated 45 | var conditions: [Condition] = [ 46 | Condition( 47 | bundle_id: "com.apple.dt.xcode", 48 | include: ["com.apple.dt", "xcode", "simulator"], 49 | exclude: ["com.robotsandpencils.xcodesapp", "com.oneminutegames.xcodecleaner", "io.hyperapp.xcodecleaner", "xcodes.json"], 50 | includeForce: ["\(home)/Library/Containers/com.apple.iphonesimulator.ShareExtension"] 51 | ), 52 | Condition( 53 | bundle_id: "com.robotsandpencils.xcodesapp", 54 | include: [], 55 | exclude: ["com.apple.dt.xcode", "com.oneminutegames.xcodecleaner", "io.hyperapp.xcodecleaner"], 56 | includeForce: nil 57 | ), 58 | Condition( 59 | bundle_id: "io.hyperapp.xcodecleaner", 60 | include: [], 61 | exclude: ["com.robotsandpencils.xcodesapp", "com.oneminutegames.xcodecleaner", "com.apple.dt.xcode", "xcodes.json"], 62 | includeForce: nil 63 | ), 64 | Condition( 65 | bundle_id: "us.zoom.xos", 66 | include: ["zoom"], 67 | exclude: [], 68 | includeForce: nil 69 | ), 70 | Condition( 71 | bundle_id: "com.brave.browser", 72 | include: ["brave"], 73 | exclude: [], 74 | includeForce: nil 75 | ), 76 | Condition( 77 | bundle_id: "com.okta.mobile", 78 | include: ["okta"], 79 | exclude: [], 80 | includeForce: nil 81 | ), 82 | Condition( 83 | bundle_id: "com.google.chrome", 84 | include: ["google", "chrome"], 85 | exclude: ["iterm", "chromefeaturestate", "monochrome"], 86 | includeForce: nil 87 | ), 88 | Condition( 89 | bundle_id: "com.microsoft.edgemac", 90 | include: ["microsoft"], 91 | exclude: ["vscode", "rdc", "appcenter", "office", "oneauth"], 92 | includeForce: nil 93 | ), 94 | Condition( 95 | bundle_id: "org.mozilla.firefox", 96 | include: ["mozilla", "firefox"], 97 | exclude: [], 98 | includeForce: nil 99 | ), 100 | Condition( 101 | bundle_id: "org.mozilla.firefox.nightly", 102 | include: ["mozilla", "firefox"], 103 | exclude: [], 104 | includeForce: nil 105 | ), 106 | Condition( 107 | bundle_id: "com.logi.optionsplus", 108 | include: ["logi"], 109 | exclude: ["login", "logic"], 110 | includeForce: nil 111 | ), 112 | Condition( 113 | bundle_id: "com.microsoft.vscode", 114 | include: ["vscode"], 115 | exclude: [], 116 | includeForce: ["\(home)/Library/Application Support/Code/"] 117 | ), 118 | Condition( 119 | bundle_id: "com.facebook.archon.developerid", 120 | include: ["archon.loginhelper"], 121 | exclude: [], 122 | includeForce: nil 123 | ), 124 | Condition( 125 | bundle_id: "eu.exelban.stats", 126 | include: [], 127 | exclude: ["video"], 128 | includeForce: nil 129 | ), 130 | Condition( 131 | bundle_id: "me.mhaeuser.BatteryToolkit", 132 | include: ["memhaeuser"], 133 | exclude: [], 134 | includeForce: nil 135 | ), 136 | Condition( 137 | bundle_id: "jetbrains", 138 | include: ["jcef"], 139 | exclude: [], 140 | includeForce: ["\(home)/Library/Application Support/JetBrains/", "\(home)/Library/Caches/JetBrains/", "\(home)/Library/Logs/JetBrains/"] 141 | ), 142 | Condition( 143 | bundle_id: "company.thebrowser.Browser", 144 | include: ["firestore"], 145 | exclude: [], 146 | includeForce: ["\(home)/Library/Application Support/Arc/", "\(home)/Library/Caches/Arc/"] 147 | ), 148 | Condition( 149 | bundle_id: "com.1password.1password", 150 | include: ["waveboxapp", "sidekick"], 151 | exclude: [], 152 | includeForce: nil 153 | ), 154 | Condition( 155 | bundle_id: "com.now.gg.BlueStacks", 156 | include: ["bst_boost_interprocess"], 157 | exclude: [], 158 | includeForce: nil 159 | ), 160 | Condition( 161 | bundle_id: "com.electron.sdm", 162 | include: ["strongdm"], 163 | exclude: [], 164 | includeForce: nil 165 | ), 166 | Condition( 167 | bundle_id: "com.github.githubclient", 168 | include: ["comgithubelectron"], 169 | exclude: [], 170 | includeForce: nil 171 | ), 172 | ] 173 | 174 | 175 | 176 | // Skip com.apple files/folders since most are system originated, allow some for apps 177 | let skipConditions: [SkipCondition] = [ 178 | SkipCondition( 179 | skipPrefix: ["comapple", "mobiledocuments", "reminders", "dsstore", ".DS_Store"], 180 | allowPrefixes: ["comappleconfigurator", "comappledt", "comappleiwork", "comapplesfsymbols", "comappletestflight"] 181 | ) 182 | ] 183 | 184 | 185 | // Skip files/folders during orphaned file search 186 | let skipReverse = ["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", "mobiledocuments", "mobile", "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", "reminders", "pbs", "notarytool", "differentialprivacy", "storeassetd", "webpush", "storedownloadd", "fsck", "crash", "python", "discrecording", "photossearch", "pylint", "jamf", "scopedbookmarkagent", "anonymous", "identifier", "isolated", "nobackup", "privacypreservingmeasurement", "symbols", "stickersd", "privatecloudcomputed", "tipsd", "controlcenter", "contactsd", "staticcheck", "index", "segment", "sparkle", "summaryevents", "launchdarkly", "identityservicesd", "embeddedbinaryvalidationutility", "comalienator88", "aaprofilepicture", "minilauncher", "jna"] 187 | 188 | 189 | 190 | 191 | 192 | 193 | // Store and load conditions locally via SwiftData 194 | class ConditionManager { 195 | static let shared = ConditionManager() 196 | 197 | private init() { 198 | loadConditions() 199 | } 200 | 201 | // Function to save a condition 202 | func saveCondition(_ condition: Condition) { 203 | if condition.include.isEmpty && condition.exclude.isEmpty && (condition.includeForce?.isEmpty ?? true) { 204 | deleteCondition(bundle_id: condition.bundle_id) 205 | return 206 | } 207 | let defaults = UserDefaults.standard 208 | let encoder = JSONEncoder() 209 | let key = "Condition-\(condition.bundle_id)" 210 | 211 | if let encoded = try? encoder.encode(condition) { 212 | defaults.set(encoded, forKey: key) 213 | conditions.append(condition) 214 | } 215 | } 216 | 217 | // Function to delete a condition from defaults and conditions variable 218 | func deleteCondition(bundle_id: String) { 219 | let defaults = UserDefaults.standard 220 | let key = "Condition-\(bundle_id.pearFormat())" 221 | 222 | // Remove from UserDefaults 223 | defaults.removeObject(forKey: key) 224 | 225 | // Remove from conditions variable 226 | conditions.removeAll { $0.bundle_id == bundle_id.pearFormat() } 227 | } 228 | 229 | // Function to load a condition and append to the global variable 230 | func loadConditions() { 231 | let defaults = UserDefaults.standard 232 | let decoder = JSONDecoder() 233 | 234 | for (key, value) in defaults.dictionaryRepresentation() { 235 | if key.starts(with: "Condition-"), let savedCondition = value as? Data { 236 | if let loadedCondition = try? decoder.decode(Condition.self, from: savedCondition) { 237 | conditions.append(loadedCondition) 238 | } 239 | } 240 | } 241 | } 242 | 243 | 244 | } 245 | -------------------------------------------------------------------------------- /Pearcleaner/Logic/HelperToolManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HelperToolManager.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 3/14/25. 6 | // 7 | 8 | import ServiceManagement 9 | import AlinFoundation 10 | 11 | @objc(HelperToolProtocol) 12 | public protocol HelperToolProtocol { 13 | func runCommand(command: String, withReply reply: @escaping (Bool, String) -> Void) 14 | func runThinning(atPath: String, withReply reply: @escaping (Bool, String) -> Void) 15 | } 16 | 17 | enum HelperToolAction { 18 | case none // Only check status 19 | case install // Install the helper tool 20 | case uninstall // Uninstall the helper tool 21 | } 22 | 23 | class HelperToolManager: ObservableObject { 24 | static let shared = HelperToolManager() 25 | private var helperConnection: NSXPCConnection? 26 | let helperToolIdentifier = "com.alienator88.Pearcleaner.PearcleanerHelper" 27 | @Published var isHelperToolInstalled: Bool = false 28 | @Published var message: String = String(localized: "Checking...") 29 | var status: String { 30 | return isHelperToolInstalled ? String(localized:"Enabled") : String(localized:"Disabled") 31 | } 32 | 33 | init() { 34 | Task { 35 | await manageHelperTool() 36 | } 37 | } 38 | 39 | // Function to manage the helper tool installation/uninstallation 40 | func manageHelperTool(action: HelperToolAction = .none) async { 41 | let plistName = "\(helperToolIdentifier).plist" 42 | let service = SMAppService.daemon(plistName: plistName) 43 | var occurredError: NSError? 44 | 45 | // Perform install/uninstall actions if specified 46 | switch action { 47 | case .install: 48 | // Pre-check before registering 49 | switch service.status { 50 | case .requiresApproval: 51 | updateOnMain { 52 | self.message = String(localized: "Registered but requires enabling in System Settings > Login Items.") 53 | } 54 | SMAppService.openSystemSettingsLoginItems() 55 | case .enabled: 56 | updateOnMain { 57 | self.message = String(localized: "Service is already enabled.") 58 | } 59 | default: 60 | do { 61 | try service.register() 62 | if service.status == .requiresApproval { 63 | SMAppService.openSystemSettingsLoginItems() 64 | } 65 | } catch let nsError as NSError { 66 | occurredError = nsError 67 | if nsError.code == 1 { // Operation not permitted 68 | updateOnMain { 69 | self.message = String(localized: "Permission required. Enable in System Settings > Login Items.") 70 | } 71 | SMAppService.openSystemSettingsLoginItems() 72 | } else { 73 | updateOnMain { 74 | self.message = String(localized: "Installation failed: \(nsError.localizedDescription)") 75 | } 76 | printOS("Failed to register helper: \(nsError.localizedDescription)") 77 | } 78 | 79 | } 80 | } 81 | 82 | case .uninstall: 83 | do { 84 | try await service.unregister() 85 | // Close any existing connection 86 | helperConnection?.invalidate() 87 | helperConnection = nil 88 | } catch let nsError as NSError { 89 | occurredError = nsError 90 | printOS("Failed to unregister helper: \(nsError.localizedDescription)") 91 | } 92 | 93 | case .none: 94 | break 95 | } 96 | 97 | await updateStatusMessages(with: service, occurredError: occurredError) 98 | let isEnabled = (service.status == .enabled) 99 | // let whoamiResult = await runCommand("whoami", skipHelperCheck: true) 100 | // let isRoot = whoamiResult.0 && whoamiResult.1.trimmingCharacters(in: .whitespacesAndNewlines) == "root" 101 | updateOnMain { 102 | self.isHelperToolInstalled = isEnabled// && isRoot 103 | } 104 | } 105 | 106 | // Function to open Settings > Login Items 107 | func openSMSettings() { 108 | SMAppService.openSystemSettingsLoginItems() 109 | } 110 | 111 | // Function to run privileged commands 112 | func runCommand(_ command: String, skipHelperCheck: Bool = false) async -> (Bool, String) { 113 | if !skipHelperCheck && !isHelperToolInstalled { 114 | return (false, "XPC: Helper tool is not installed") 115 | } 116 | 117 | guard let connection = getConnection() else { 118 | return (false, "XPC: Connection not available") 119 | } 120 | 121 | return await withCheckedContinuation { continuation in 122 | guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in 123 | continuation.resume(returning: (false, "XPC: Connection error: \(error.localizedDescription)")) 124 | }) as? HelperToolProtocol else { 125 | continuation.resume(returning: (false, "XPC: Failed to get remote object")) 126 | return 127 | } 128 | 129 | proxy.runCommand(command: command, withReply: { success, output in 130 | continuation.resume(returning: (success, output)) 131 | }) 132 | } 133 | } 134 | 135 | // Function to run privileged thinning on apps owned by root 136 | func runThinning(atPath path: String) async -> (Bool, String) { 137 | guard let connection = getConnection() else { 138 | return (false, "XPC: No helper connection") 139 | } 140 | 141 | return await withCheckedContinuation { continuation in 142 | guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in 143 | continuation.resume(returning: (false, "XPC: Error: \(error.localizedDescription)")) 144 | }) as? HelperToolProtocol else { 145 | continuation.resume(returning: (false, "XPC: Proxy failure")) 146 | return 147 | } 148 | 149 | proxy.runThinning(atPath: path) { success, output in 150 | continuation.resume(returning: (success, output)) 151 | } 152 | } 153 | } 154 | 155 | 156 | // Create/reuse XPC connection 157 | private func getConnection() -> NSXPCConnection? { 158 | if let connection = helperConnection { 159 | return connection 160 | } 161 | let connection = NSXPCConnection(machServiceName: helperToolIdentifier, options: .privileged) 162 | connection.remoteObjectInterface = NSXPCInterface(with: HelperToolProtocol.self) 163 | connection.invalidationHandler = { [weak self] in 164 | self?.helperConnection = nil 165 | } 166 | connection.resume() 167 | helperConnection = connection 168 | return connection 169 | } 170 | 171 | 172 | 173 | // Helper to update helper status messages 174 | func updateStatusMessages(with service: SMAppService, occurredError: NSError?) async { 175 | if let nsError = occurredError { 176 | switch nsError.code { 177 | case kSMErrorAlreadyRegistered: 178 | updateOnMain { 179 | self.message = String(localized: "Service is already registered and enabled.") 180 | } 181 | case kSMErrorLaunchDeniedByUser: 182 | updateOnMain { 183 | self.message = String(localized: "User denied permission. Enable in System Settings > Login Items.") 184 | } 185 | case kSMErrorInvalidSignature: 186 | updateOnMain { 187 | self.message = String(localized: "Invalid signature, ensure proper signing on the application and helper tool.") 188 | } 189 | case 1: 190 | updateOnMain { 191 | self.message = String(localized: "Authorization required in Settings > Login Items > \(Bundle.main.name).app.") 192 | } 193 | default: 194 | updateOnMain { 195 | self.message = String(localized: "Operation failed: \(nsError.localizedDescription)") 196 | } 197 | } 198 | } else { 199 | switch service.status { 200 | case .notRegistered: 201 | updateOnMain { 202 | self.message = String(localized: "Service hasn’t been registered. You may register it now.") 203 | } 204 | case .enabled: 205 | let whoamiResult = await runCommand("whoami", skipHelperCheck: true) 206 | let isRoot = whoamiResult.0 && whoamiResult.1.trimmingCharacters(in: .whitespacesAndNewlines) == "root" 207 | updateOnMain { 208 | self.message = String(localized: isRoot ? "Service successfully registered and eligible to run." : "Service successfully registered and eligible to run (Desynced)") 209 | } 210 | case .requiresApproval: 211 | updateOnMain { 212 | self.message = String(localized: "Service registered but requires user approval in Settings > Login Items > \(Bundle.main.name).app.") 213 | } 214 | case .notFound: 215 | updateOnMain { 216 | self.message = String(localized: "Service is not installed.") 217 | } 218 | @unknown default: 219 | updateOnMain { 220 | self.message = String(localized: "Unknown service status (\(service.status.rawValue)).") 221 | } 222 | } 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /Pearcleaner/Logic/Lipo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Lipo.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 4/10/25. 6 | // 7 | 8 | import Foundation 9 | //import AlinFoundation 10 | 11 | // Helper structs for Mach-O parsing 12 | public struct FatHeader { 13 | public let magic: UInt32 14 | public let numArchitectures: UInt32 15 | 16 | public init(magic: UInt32, numArchitectures: UInt32) { 17 | self.magic = magic 18 | self.numArchitectures = numArchitectures 19 | } 20 | } 21 | 22 | public struct FatArch { 23 | public let cpuType: UInt32 24 | public let cpuSubtype: UInt32 25 | public let offset: UInt32 26 | public let size: UInt32 27 | public let align: UInt32 28 | 29 | public init(cpuType: UInt32, cpuSubtype: UInt32, offset: UInt32, size: UInt32, align: UInt32) { 30 | self.cpuType = cpuType 31 | self.cpuSubtype = cpuSubtype 32 | self.offset = offset 33 | self.size = size 34 | self.align = align 35 | } 36 | } 37 | 38 | // Helper function to thin a binary using Mach-O APIs 39 | public func thinBinaryUsingMachO(executablePath: String) -> Bool { 40 | // Determine the target architecture based on the current OS 41 | var targetArch: String 42 | #if arch(arm64) 43 | targetArch = "arm64" 44 | #else 45 | targetArch = "x86_64" 46 | #endif 47 | 48 | // Determine app bundle path 49 | let executableURL = URL(fileURLWithPath: executablePath) 50 | let appBundlePath = executableURL.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() 51 | 52 | do { 53 | let fileData = try Data(contentsOf: URL(fileURLWithPath: executablePath)) 54 | 55 | // Check if file is a fat binary 56 | let FAT_MAGIC: UInt32 = 0xcafebabe 57 | let fatHeader = fileData.subdata(in: 0..<8).withUnsafeBytes { ptr in 58 | FatHeader( 59 | magic: ptr.load(fromByteOffset: 0, as: UInt32.self).bigEndian, 60 | numArchitectures: ptr.load(fromByteOffset: 4, as: UInt32.self).bigEndian 61 | ) 62 | } 63 | 64 | guard fatHeader.magic == FAT_MAGIC else { 65 | print("Mach-O Error: Not a universal binary, skipping thinning.") 66 | return false 67 | } 68 | 69 | var offset = 8 70 | var foundArch: FatArch? 71 | 72 | for _ in 0.. AnyView)? 16 | @AppStorage("settings.interface.selectedMenubarIcon") var selectedMenubarIcon: String = "bubbles.and.sparkles.fill" 17 | 18 | func addMenuBarExtra(withView view: @escaping () -> V) { 19 | guard statusItem == nil else { return } 20 | 21 | // Remember the last view 22 | lastView = { AnyView(view()) } 23 | 24 | // Initialize the status item 25 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) 26 | 27 | // Set up the status item's button 28 | if let button = statusItem?.button{ 29 | button.image = NSImage(systemSymbolName: selectedMenubarIcon, accessibilityDescription: "Pearcleaner") 30 | button.action = #selector(togglePopover(_:)) 31 | button.target = self 32 | button.sendAction(on: [.leftMouseDown, .rightMouseDown]) 33 | } 34 | 35 | // Set up the popover 36 | popover.contentSize = NSSize(width: 300, height: 370) 37 | popover.behavior = .transient 38 | popover.contentViewController = NSHostingController(rootView: view()) 39 | } 40 | 41 | 42 | func removeMenuBarExtra() { 43 | if let item = statusItem { 44 | NSStatusBar.system.removeStatusItem(item) 45 | statusItem = nil 46 | } 47 | } 48 | 49 | func swapMenuBarIcon(icon: String) { 50 | guard let button = statusItem?.button else { return } 51 | if let image = NSImage(systemSymbolName: icon, accessibilityDescription: nil) { 52 | button.image = image 53 | } 54 | } 55 | 56 | func restartMenuBarExtra() { 57 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 58 | self.removeMenuBarExtra() 59 | 60 | // Ensure the last view and icon are available before re-adding 61 | if let lastView = self.lastView { 62 | self.addMenuBarExtra(withView: lastView) 63 | } 64 | } 65 | } 66 | 67 | @objc func togglePopover(_ sender: AnyObject?) { 68 | if let button = statusItem?.button { 69 | if popover.isShown { 70 | popover.performClose(sender) 71 | } else { 72 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) 73 | } 74 | } 75 | } 76 | 77 | 78 | } 79 | -------------------------------------------------------------------------------- /Pearcleaner/Logic/PearGroupBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IceGroupBox.swift 3 | // Pearcleaner 4 | // 5 | // Created by Jordan Baird (Ice), slightly altered for Pearcleaner usage by Alin Lupascu on 9/27/24. 6 | // 7 | import SwiftUI 8 | 9 | struct PearGroupBox: View { 10 | private let header: Header 11 | private let content: Content 12 | private let footer: Footer 13 | private let padding: CGFloat 14 | 15 | /// Example usage: 16 | /// ``` 17 | /// PearGroupBox( 18 | /// header: { Text("Header View") }, 19 | /// content: { Text("Content View") }, 20 | /// footer: { Text("Footer View") } 21 | /// ) 22 | /// ``` 23 | init( 24 | padding: CGFloat = 10, 25 | @ViewBuilder header: () -> Header, 26 | @ViewBuilder content: () -> Content, 27 | @ViewBuilder footer: () -> Footer 28 | ) { 29 | self.padding = padding 30 | self.header = header() 31 | self.content = content() 32 | self.footer = footer() 33 | } 34 | 35 | /// Example usage with no header: 36 | /// ``` 37 | /// PearGroupBox( 38 | /// content: { Text("Content View") }, 39 | /// footer: { Text("Footer View") } 40 | /// ) 41 | /// ``` 42 | init( 43 | padding: CGFloat = 10, 44 | @ViewBuilder content: () -> Content, 45 | @ViewBuilder footer: () -> Footer 46 | ) where Header == EmptyView { 47 | self.init(padding: padding) { 48 | EmptyView() 49 | } content: { 50 | content() 51 | } footer: { 52 | footer() 53 | } 54 | } 55 | 56 | /// Example usage with no footer: 57 | /// ``` 58 | /// PearGroupBox( 59 | /// header: { Text("Header View") }, 60 | /// content: { Text("Content View") } 61 | /// ) 62 | /// ``` 63 | init( 64 | padding: CGFloat = 10, 65 | @ViewBuilder header: () -> Header, 66 | @ViewBuilder content: () -> Content 67 | ) where Footer == EmptyView { 68 | self.init(padding: padding) { 69 | header() 70 | } content: { 71 | content() 72 | } footer: { 73 | EmptyView() 74 | } 75 | } 76 | 77 | /// Example usage with content only: 78 | /// ``` 79 | /// PearGroupBox { 80 | /// Text("Content View Only") 81 | /// } 82 | /// ``` 83 | init( 84 | padding: CGFloat = 10, 85 | @ViewBuilder content: () -> Content 86 | ) where Header == EmptyView, Footer == EmptyView { 87 | self.init(padding: padding) { 88 | EmptyView() 89 | } content: { 90 | content() 91 | } footer: { 92 | EmptyView() 93 | } 94 | } 95 | 96 | /// Example usage with a title and content only: 97 | /// ``` 98 | /// PearGroupBox("Header Title") { 99 | /// Text("Content View") 100 | /// } 101 | /// ``` 102 | init( 103 | _ title: LocalizedStringKey, 104 | padding: CGFloat = 10, 105 | @ViewBuilder content: () -> Content 106 | ) where Header == Text, Footer == EmptyView { 107 | self.init(padding: padding) { 108 | Text(title) 109 | .font(.headline) 110 | } content: { 111 | content() 112 | } 113 | } 114 | 115 | var body: some View { 116 | VStack(alignment: .leading) { 117 | header 118 | content 119 | .padding(padding) 120 | .background { 121 | backgroundShape 122 | .fill(.quinary) 123 | .overlay { 124 | backgroundShape 125 | .stroke(.quaternary) 126 | } 127 | } 128 | footer 129 | } 130 | } 131 | 132 | @ViewBuilder 133 | private var backgroundShape: some Shape { 134 | RoundedRectangle(cornerRadius: 7, style: .circular) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Pearcleaner/Logic/ReversePathsFetch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReversePathsFetch.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 3/20/24. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | import AlinFoundation 11 | 12 | class ReversePathsSearcher { 13 | private let appState: AppState? 14 | private let locations: Locations 15 | private let fsm: FolderSettingsManager 16 | private let fileManager = FileManager.default 17 | private var collection: [URL] = [] 18 | private var fileSize: [URL: Int64] = [:] 19 | private var fileSizeLogical: [URL: Int64] = [:] 20 | private var fileIcon: [URL: NSImage?] = [:] 21 | private let dispatchGroup = DispatchGroup() 22 | private let sortedApps: [AppInfo] 23 | 24 | init(appState: AppState? = nil, locations: Locations, fsm: FolderSettingsManager, sortedApps: [AppInfo]) { 25 | self.appState = appState 26 | self.locations = locations 27 | self.fsm = fsm 28 | self.sortedApps = sortedApps 29 | } 30 | 31 | func reversePathsSearch(completion: @escaping () -> Void = {}) { 32 | Task(priority: .high) { 33 | self.processLocations() 34 | self.calculateFileDetails() 35 | self.updateAppState() 36 | completion() 37 | } 38 | } 39 | 40 | func reversePathsSearchCLI() -> [URL] { 41 | self.processLocationsCLI() 42 | return collection 43 | } 44 | 45 | private func processLocations() { 46 | for location in locations.reverse.paths where fileManager.fileExists(atPath: location) { 47 | dispatchGroup.enter() 48 | processLocation(location) 49 | dispatchGroup.leave() 50 | } 51 | } 52 | 53 | private func processLocationsCLI() { 54 | for location in locations.reverse.paths where fileManager.fileExists(atPath: location) { 55 | processLocation(location) 56 | } 57 | } 58 | 59 | private func processLocation(_ location: String) { 60 | 61 | do { 62 | let contents = try fileManager.contentsOfDirectory(atPath: location) 63 | contents.forEach { itemName in 64 | let itemURL = URL(fileURLWithPath: location).appendingPathComponent(itemName) 65 | processItem(itemName, itemURL: itemURL) 66 | } 67 | } catch { 68 | printOS("Error processing location: \(location), error: \(error)") 69 | } 70 | } 71 | 72 | private func processItem(_ itemName: String, itemURL: URL) { 73 | let itemPath = itemURL.path.pearFormat() 74 | let exclusionList = fsm.fileFolderPathsZ.map { $0.pearFormat() } 75 | 76 | if exclusionList.contains(itemPath) || itemPath.contains("dsstore") || itemPath.contains("daemonnameoridentifierhere") || exclusionList.first(where: { itemPath.contains($0) }) != nil { 77 | return 78 | } 79 | guard !isUUIDFormatted(itemName.pearFormat()), 80 | !skipReverse.contains(where: { itemName.pearFormat().contains($0) }), 81 | isSupportedFileType(at: itemURL.path), 82 | !isRelatedToInstalledApp(itemURL: itemURL), 83 | !isExcludedByConditions(itemPath: itemPath) else { 84 | 85 | return 86 | } 87 | 88 | collection.append(itemURL) 89 | } 90 | 91 | private func isRelatedToInstalledApp(itemURL: URL) -> Bool { 92 | let itemPath = itemURL.path.pearFormat() 93 | 94 | for app in sortedApps { 95 | if itemPath.contains(app.bundleIdentifier.pearFormat()) || 96 | itemPath.contains(app.appName.pearFormat()) { 97 | return true 98 | } 99 | 100 | // Check if the path contains /Containers or /Group Containers 101 | if itemURL.path.contains("/Containers/") { 102 | let containerName = itemURL.containerNameByUUID().pearFormat() 103 | if containerName.contains(app.bundleIdentifier.pearFormat()) { 104 | return true 105 | } 106 | } 107 | } 108 | return false 109 | } 110 | 111 | private func isExcludedByConditions(itemPath: String) -> Bool { 112 | 113 | for condition in conditions { 114 | // Ensure the condition's bundle_id matches an installed app 115 | guard sortedApps.contains(where: { $0.bundleIdentifier.pearFormat() == condition.bundle_id.pearFormat() }) else { 116 | continue 117 | } 118 | 119 | // Include keywords 120 | if condition.include.contains(where: { itemPath.contains($0.pearFormat()) }) { 121 | return true 122 | } 123 | 124 | // Include force 125 | if let includeForce = condition.includeForce, 126 | includeForce.contains(where: { itemPath.contains($0.path.pearFormat()) }) { 127 | return true 128 | } 129 | } 130 | 131 | return false 132 | } 133 | 134 | private func isUUIDFormatted(_ fileName: String) -> Bool { 135 | let uuidRegex = "^[0-9a-fA-F]{32}$" // UUID without dashes 136 | let regex = try? NSRegularExpression(pattern: uuidRegex) 137 | let range = NSRange(location: 0, length: fileName.utf16.count) 138 | return regex?.firstMatch(in: fileName, options: [], range: range) != nil 139 | } 140 | 141 | private func calculateFileDetails() { 142 | collection.forEach { path in 143 | let size = totalSizeOnDisk(for: path) 144 | fileSize[path] = size.real 145 | fileSizeLogical[path] = size.logical 146 | fileIcon[path] = getIconForFileOrFolderNS(atPath: path) 147 | } 148 | } 149 | 150 | private func updateAppState() { 151 | dispatchGroup.notify(queue: .main) { 152 | var updatedZombieFile = ZombieFile.empty 153 | updatedZombieFile.fileSize = self.fileSize 154 | updatedZombieFile.fileSizeLogical = self.fileSizeLogical 155 | updatedZombieFile.fileIcon = self.fileIcon 156 | self.appState?.zombieFile = updatedZombieFile 157 | self.appState?.showProgress = false 158 | } 159 | } 160 | 161 | } 162 | 163 | 164 | -------------------------------------------------------------------------------- /Pearcleaner/Logic/UndoManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UndoManager.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 2/24/25. 6 | // 7 | 8 | import Foundation 9 | import AlinFoundation 10 | 11 | class FileManagerUndo { 12 | // MARK: - Singleton Instance 13 | static let shared = FileManagerUndo() 14 | 15 | // Private initializer to enforce singleton pattern 16 | private init() {} 17 | 18 | // NSUndoManager instance to handle undo/redo actions 19 | let undoManager = UndoManager() 20 | 21 | // Delete a file and register an undo action to restore it 22 | func deleteFiles(at urls: [URL], isCLI: Bool = false) -> Bool { 23 | let trashPath = (NSHomeDirectory() as NSString).appendingPathComponent(".Trash") 24 | let dispatchSemaphore = DispatchSemaphore(value: 0) // Semaphore to make it synchronous 25 | var finalStatus = false // Store the final success/failure status 26 | 27 | var tempFilePairs: [(trashURL: URL, originalURL: URL)] = [] 28 | var seenFileNames: [String: Int] = [:] 29 | 30 | let hasProtectedFiles = urls.contains { $0.isProtected } 31 | 32 | let mvCommands = urls.map { file -> String in 33 | var fileName = file.lastPathComponent 34 | 35 | if let count = seenFileNames[fileName] { 36 | let newCount = count + 1 37 | seenFileNames[fileName] = newCount 38 | let newName = "\(file.deletingPathExtension().lastPathComponent)\(newCount).\(file.pathExtension)" 39 | fileName = newName 40 | } else { 41 | seenFileNames[fileName] = 1 42 | } 43 | 44 | let destinationURL = URL(fileURLWithPath: (trashPath as NSString).appendingPathComponent(fileName)) 45 | tempFilePairs.append((trashURL: destinationURL, originalURL: file)) 46 | 47 | let source = "\"\(file.path)\"" 48 | let destination = "\"\(destinationURL.path)\"" 49 | return "/bin/mv \(source) \(destination)" 50 | }.joined(separator: " ; ") 51 | 52 | let filePairs = tempFilePairs 53 | 54 | if executeFileCommands(mvCommands, isCLI: isCLI, hasProtectedFiles: hasProtectedFiles) { 55 | undoManager.registerUndo(withTarget: self) { target in 56 | let result = target.restoreFiles(filePairs: filePairs) 57 | if !result { 58 | printOS("Trash Error: Could not restore files.") 59 | } 60 | } 61 | undoManager.setActionName("Delete File") 62 | 63 | finalStatus = true 64 | } else { 65 | // printOS("Trash Error: \(isCLI ? "Could not run commands directly with sudo." : "Could not perform privileged commands.")") 66 | updateOnMain { 67 | AppState.shared.trashError = true 68 | } 69 | finalStatus = false 70 | } 71 | 72 | dispatchSemaphore.signal() 73 | 74 | dispatchSemaphore.wait() 75 | return finalStatus 76 | } 77 | 78 | func restoreFiles(filePairs: [(trashURL: URL, originalURL: URL)], isCLI: Bool = false) -> Bool { 79 | let dispatchSemaphore = DispatchSemaphore(value: 0) 80 | var finalStatus = true 81 | 82 | let hasProtectedFiles = filePairs.contains { 83 | $0.originalURL.deletingLastPathComponent().isProtected 84 | } 85 | 86 | let commands = filePairs.map { 87 | let source = "\"\($0.trashURL.path)\"" 88 | let destination = "\"\($0.originalURL.path)\"" 89 | return "/bin/mv \(source) \(destination)" 90 | }.joined(separator: " ; ") 91 | 92 | if executeFileCommands(commands, isCLI: isCLI, hasProtectedFiles: hasProtectedFiles, isRestore: true) { 93 | finalStatus = true 94 | } else { 95 | // printOS("Trash Error: \(isCLI ? "Failed to run restore CLI commands" : "Failed to run restore privileged commands")") 96 | updateOnMain { 97 | AppState.shared.trashError = true 98 | } 99 | finalStatus = false 100 | } 101 | 102 | dispatchSemaphore.signal() 103 | dispatchSemaphore.wait() 104 | return finalStatus 105 | } 106 | 107 | // Helper function to perform shell commands based on available privileges 108 | private func executeFileCommands(_ commands: String, isCLI: Bool, hasProtectedFiles: Bool, isRestore: Bool = false) -> Bool { 109 | var status = false 110 | 111 | if HelperToolManager.shared.isHelperToolInstalled { 112 | printOS(isRestore ? "Attempting restore using helper tool" : "Attempting delete using helper tool") 113 | let semaphore = DispatchSemaphore(value: 0) 114 | var success = false 115 | var output = "" 116 | 117 | Task { 118 | let result = await HelperToolManager.shared.runCommand(commands) 119 | success = result.0 120 | output = result.1 121 | semaphore.signal() 122 | } 123 | semaphore.wait() 124 | 125 | status = success 126 | if !success { 127 | printOS(isRestore ? "Restore Error: \(output)" : "Trash Error: \(output)") 128 | updateOnMain { 129 | AppState.shared.trashError = true 130 | } 131 | } 132 | } else { 133 | if isCLI || !hasProtectedFiles { 134 | printOS(isRestore ? "Attempting restore using direct shell command" : "Attempting delete using direct shell command") 135 | let result = runDirectShellCommand(command: commands) 136 | status = result.0 137 | if !status { 138 | printOS(isRestore ? "Restore Error: \(result.1)" : "Trash Error: \(result.1)") 139 | updateOnMain { 140 | AppState.shared.trashError = true 141 | } 142 | } 143 | } else { 144 | printOS(isRestore ? "Attempting restore using authorization services" : "Attempting delete using authorization services") 145 | let result = performPrivilegedCommands(commands: commands) 146 | status = result.0 147 | if !status { 148 | printOS(isRestore ? "Restore Error: performPrivilegedCommands failed (\(result.1))" : "Trash Error: performPrivilegedCommands failed (\(result.1))") 149 | updateOnMain { 150 | AppState.shared.trashError = true 151 | } 152 | } 153 | } 154 | } 155 | 156 | return status 157 | } 158 | 159 | // Helper to run direct non-privileged shell commands 160 | private func runDirectShellCommand(command: String) -> (Bool, String) { 161 | let task = Process() 162 | task.launchPath = "/bin/sh" 163 | task.arguments = ["-c", command] 164 | 165 | let pipe = Pipe() 166 | task.standardOutput = pipe 167 | task.standardError = pipe 168 | 169 | task.launch() 170 | task.waitUntilExit() 171 | 172 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 173 | let output = String(data: data, encoding: .utf8) ?? "" 174 | 175 | if output.lowercased().contains("permission denied") { 176 | return (false, output) 177 | } 178 | 179 | return (task.terminationStatus == 0, output) 180 | } 181 | 182 | } 183 | 184 | extension URL { 185 | var isProtected: Bool { 186 | !FileManager.default.isWritableFile(atPath: self.path) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /Pearcleaner/Logic/WindowSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindowSettings.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 12/26/23. 6 | // 7 | 8 | import SwiftUI 9 | import AlinFoundation 10 | 11 | class WindowSettings: ObservableObject { 12 | static let shared = WindowSettings() 13 | let menubarEnabled = UserDefaults.standard.bool(forKey: "settings.menubar.enabled") 14 | 15 | private let windowWidthKey = "windowWidthKey" 16 | private let windowHeightKey = "windowHeightKey" 17 | private let windowXKey = "windowXKey" 18 | private let windowYKey = "windowYKey" 19 | var windowRef: NSWindow? 20 | 21 | init() { 22 | registerDefaultWindowSettings() 23 | } 24 | 25 | func trackMainWindow() { 26 | if let mainWindow = NSApplication.shared.windows.first(where: { $0.title == "Pearcleaner" }) { 27 | windowRef = mainWindow 28 | // printOS("Main window detected and tracked") 29 | } else { 30 | printOS("No main Pearcleaner window detected") 31 | } 32 | } 33 | 34 | // Launch new app windows on demand 35 | func newWindow(mini: Bool, withView view: @escaping () -> V) { 36 | let frame = self.resetWindowSettings(mini: mini) 37 | 38 | // if menubarEnabled { 39 | // windowRef = NSWindow( 40 | // contentRect: .zero, 41 | // styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], 42 | // backing: .buffered, defer: false) 43 | // } 44 | // Update the existing windowRef with all desired settings 45 | windowRef?.contentView = NSHostingView(rootView: view()) 46 | windowRef?.setFrame(frame, display: true, animate: true) 47 | windowRef?.isMovableByWindowBackground = true 48 | windowRef?.title = "Pearcleaner" 49 | windowRef?.titlebarAppearsTransparent = true 50 | windowRef?.isRestorable = false 51 | windowRef?.titleVisibility = .hidden 52 | windowRef?.makeKeyAndOrderFront(nil) 53 | windowRef?.isReleasedWhenClosed = false 54 | } 55 | 56 | // Register default sizes if the AppStorage keys are invalid 57 | func registerDefaultWindowSettings(completion: @escaping () -> Void = {}) { 58 | let defaults = UserDefaults.standard 59 | // Get primary screen 60 | let screenFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 800, height: 600) 61 | // Calculate default window sizes and x/y coordinates 62 | let defaultWidth = Float(900) // Default width for regular window 63 | let defaultHeight = Float(628) // Default height for regular window 64 | let defaultX = Float((screenFrame.width - CGFloat(defaultWidth)) / 2 + screenFrame.origin.x) // Default X coordinate 65 | let defaultY = Float((screenFrame.height - CGFloat(defaultHeight)) / 2 + screenFrame.origin.y) // Default Y coordinate 66 | // Set defaults only if they are not already set 67 | if defaults.object(forKey: windowWidthKey) == nil { 68 | defaults.set(defaultWidth, forKey: windowWidthKey) 69 | } 70 | if defaults.object(forKey: windowHeightKey) == nil { 71 | defaults.set(defaultHeight, forKey: windowHeightKey) 72 | } 73 | if defaults.object(forKey: windowXKey) == nil { 74 | defaults.set(defaultX, forKey: windowXKey) 75 | } 76 | if defaults.object(forKey: windowYKey) == nil { 77 | defaults.set(defaultY, forKey: windowYKey) 78 | } 79 | completion() 80 | } 81 | 82 | // Save user window settings 83 | func saveWindowSettings(frame: NSRect) { 84 | UserDefaults.standard.set(Float(frame.size.width), forKey: windowWidthKey) 85 | UserDefaults.standard.set(Float(frame.size.height), forKey: windowHeightKey) 86 | UserDefaults.standard.set(Float(frame.origin.x), forKey: windowXKey) 87 | UserDefaults.standard.set(Float(frame.origin.y), forKey: windowYKey) 88 | } 89 | 90 | // Load default window settings or user defined settings 91 | func loadWindowSettings() -> NSRect { 92 | let width = CGFloat(UserDefaults.standard.float(forKey: windowWidthKey)) 93 | let height = CGFloat(UserDefaults.standard.float(forKey: windowHeightKey)) 94 | let x = CGFloat(UserDefaults.standard.float(forKey: windowXKey)) 95 | let y = CGFloat(UserDefaults.standard.float(forKey: windowYKey)) 96 | return NSRect(x: x, y: y, width: width, height: height) 97 | } 98 | 99 | // Reset to default sizes when toggling the window state 100 | func resetWindowSettings(mini: Bool) -> NSRect { 101 | let screenFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 800, height: 600) 102 | // Define sizes based on whether mini mode is active 103 | let defaultWidth: CGFloat = mini ? 300 : 900 // Mini or Regular width 104 | let defaultHeight: CGFloat = mini ? 373 : 628 // Mini or Regular height 105 | // Calculate the X and Y coordinates based on the size 106 | let defaultX = (screenFrame.width - defaultWidth) / 2 + screenFrame.origin.x 107 | let defaultY = (screenFrame.height - defaultHeight) / 2 + screenFrame.origin.y 108 | // Store these values in UserDefaults (if needed) 109 | UserDefaults.standard.set(Float(defaultWidth), forKey: windowWidthKey) 110 | UserDefaults.standard.set(Float(defaultHeight), forKey: windowHeightKey) 111 | UserDefaults.standard.set(Float(defaultX), forKey: windowXKey) 112 | UserDefaults.standard.set(Float(defaultY), forKey: windowYKey) 113 | // Return the frame for the window with appropriate size and position 114 | return NSRect(x: defaultX, y: defaultY, width: defaultWidth, height: defaultHeight) 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /Pearcleaner/Metal/AnimatedGradient.metal: -------------------------------------------------------------------------------- 1 | // 2 | // AnimatedGradient.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 4/3/25. 6 | // 7 | 8 | #include 9 | using namespace metal; 10 | 11 | struct VertexOut { 12 | float4 position [[position]]; 13 | float2 uv; 14 | }; 15 | 16 | vertex VertexOut vertex_main(uint vertexID [[vertex_id]]) { 17 | float2 positions[4] = { 18 | float2(-1.0, -1.0), 19 | float2( 1.0, -1.0), 20 | float2(-1.0, 1.0), 21 | float2( 1.0, 1.0) 22 | }; 23 | 24 | float2 uvs[4] = { 25 | float2(0.0, 1.0), 26 | float2(1.0, 1.0), 27 | float2(0.0, 0.0), 28 | float2(1.0, 0.0) 29 | }; 30 | 31 | VertexOut out; 32 | out.position = float4(positions[vertexID], 0, 1); 33 | out.uv = uvs[vertexID]; 34 | return out; 35 | } 36 | 37 | struct GradientParams { 38 | float time; 39 | float direction; 40 | uint colorCount; 41 | }; 42 | 43 | fragment float4 fragment_main(VertexOut in [[stage_in]], 44 | constant GradientParams& params [[buffer(0)]], 45 | constant float4* colors [[buffer(1)]]) { 46 | float t; 47 | if (params.direction == 0.0) { 48 | t = sin(params.time + in.uv.y * 3.14) * 0.5 + 0.5; 49 | } else if (params.direction == 1.0) { 50 | t = sin(params.time + (1.0 - in.uv.x) * 3.14) * 0.5 + 0.5; 51 | } else { 52 | float2 center = float2(0.5, 0.5); 53 | float dist = distance(in.uv, center); 54 | t = sin(params.time + dist * 6.28) * 0.5 + 0.5; 55 | } 56 | 57 | if (params.colorCount == 0) return float4(0, 0, 0, 1); 58 | if (params.colorCount == 1) return colors[0]; 59 | 60 | float segment = 1.0 / float(params.colorCount - 1); 61 | uint idx = min(uint(t / segment), params.colorCount - 2); 62 | float localT = (t - float(idx) * segment) / segment; 63 | 64 | return mix(colors[idx], colors[idx + 1], localT); 65 | } 66 | -------------------------------------------------------------------------------- /Pearcleaner/Metal/AnimatedGradientView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimatedGradientView.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 4/3/25. 6 | // 7 | 8 | import SwiftUI 9 | import MetalKit 10 | 11 | enum GradientDirection { 12 | case vertical 13 | case horizontal 14 | case circular 15 | } 16 | 17 | struct AnimatedGradientView: NSViewRepresentable { 18 | var colors: [Color] 19 | var direction: GradientDirection 20 | 21 | func makeCoordinator() -> Coordinator { 22 | Coordinator(colors: colors.map { $0.toSIMD() }, direction: direction) 23 | } 24 | 25 | func makeNSView(context: Context) -> MTKView { 26 | let mtkView = MTKView() 27 | mtkView.device = MTLCreateSystemDefaultDevice() 28 | mtkView.delegate = context.coordinator 29 | mtkView.framebufferOnly = false 30 | return mtkView 31 | } 32 | 33 | func updateNSView(_ nsView: MTKView, context: Context) { 34 | context.coordinator.colors = colors.map { $0.toSIMD() } 35 | context.coordinator.direction = direction 36 | } 37 | 38 | class Coordinator: NSObject, MTKViewDelegate { 39 | var commandQueue: MTLCommandQueue! 40 | var pipelineState: MTLRenderPipelineState! 41 | var startTime = CACurrentMediaTime() 42 | 43 | var colors: [SIMD4] 44 | var direction: GradientDirection 45 | 46 | init(device: MTLDevice = MTLCreateSystemDefaultDevice()!, colors: [SIMD4], direction: GradientDirection) { 47 | self.colors = colors 48 | self.direction = direction 49 | super.init() 50 | let lib = device.makeDefaultLibrary()! 51 | let pipelineDesc = MTLRenderPipelineDescriptor() 52 | pipelineDesc.vertexFunction = lib.makeFunction(name: "vertex_main") 53 | pipelineDesc.fragmentFunction = lib.makeFunction(name: "fragment_main") 54 | pipelineDesc.colorAttachments[0].pixelFormat = .bgra8Unorm 55 | pipelineState = try! device.makeRenderPipelineState(descriptor: pipelineDesc) 56 | commandQueue = device.makeCommandQueue() 57 | } 58 | 59 | func draw(in view: MTKView) { 60 | guard let drawable = view.currentDrawable, 61 | let rpd = view.currentRenderPassDescriptor, 62 | let cmdBuf = commandQueue.makeCommandBuffer(), 63 | let enc = cmdBuf.makeRenderCommandEncoder(descriptor: rpd) else { return } 64 | 65 | let time = Float(CACurrentMediaTime() - startTime) 66 | let directionValue: Float 67 | switch direction { 68 | case .vertical: directionValue = 0 69 | case .horizontal: directionValue = 1 70 | case .circular: directionValue = 2 71 | } 72 | 73 | struct GradientParams { 74 | var time: Float 75 | var direction: Float 76 | var colorCount: UInt32 77 | } 78 | 79 | var params = GradientParams(time: time, direction: directionValue, colorCount: UInt32(colors.count)) 80 | enc.setRenderPipelineState(pipelineState) 81 | enc.setVertexBytes(¶ms, length: MemoryLayout.stride, index: 0) 82 | enc.setFragmentBytes(¶ms, length: MemoryLayout.stride, index: 0) 83 | enc.setFragmentBytes(colors, length: MemoryLayout>.stride * colors.count, index: 1) 84 | enc.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) 85 | enc.endEncoding() 86 | cmdBuf.present(drawable) 87 | cmdBuf.commit() 88 | } 89 | 90 | func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} 91 | } 92 | } 93 | 94 | extension Color { 95 | func toSIMD() -> SIMD4 { 96 | let nsColor = NSColor(self).usingColorSpace(.deviceRGB) ?? .black 97 | var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 98 | nsColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) 99 | return SIMD4(Float(red), Float(green), Float(blue), Float(alpha)) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Pearcleaner/Metal/LavaLampShader.metal: -------------------------------------------------------------------------------- 1 | // 2 | // LavaLampShader.metal 3 | // Playground 4 | // 5 | // Created by Alin Lupascu on 3/26/25. 6 | // 7 | 8 | #include 9 | using namespace metal; 10 | 11 | float metaball(float2 p, float2 center, float radius) { 12 | float2 diff = p - center; 13 | return radius * radius / dot(diff, diff); 14 | } 15 | 16 | vertex float4 vertex_passthrough(uint vertexID [[vertex_id]]) { 17 | float2 positions[6] = { 18 | {-1.0, -1.0}, {1.0, -1.0}, {-1.0, 1.0}, 19 | {-1.0, 1.0}, {1.0, -1.0}, {1.0, 1.0} 20 | }; 21 | return float4(positions[vertexID], 0.0, 1.0); 22 | } 23 | 24 | fragment float4 lavaLampFrag(float4 fragCoord [[position]], 25 | constant float2 *centers [[buffer(0)]], 26 | constant float *radii [[buffer(1)]], 27 | constant uint &count [[buffer(2)]], 28 | constant float &time [[buffer(3)]], 29 | constant float2 &resolution [[buffer(4)]]) { 30 | float2 uv = fragCoord.xy / resolution; 31 | float intensity = 0.0; 32 | float3 color = float3(0.0); 33 | 34 | for (uint i = 0; i < count; ++i) { 35 | float2 animated = centers[i] + 0.25 * float2(sin(time * 0.6 + float(i) * 1.7), cos(time * 0.4 + float(i) * 1.3)); 36 | float radius = radii[i] * (2.0 + 0.5 * sin(time + float(i) * 2.17)); // larger size 37 | float contrib = metaball(uv, animated, radius); 38 | if (i % 3 == 0) { 39 | color += contrib * float3(1.0, 0.4, 0.7) * 0.5; // pink (dimmed) 40 | } else if (i % 3 == 1) { 41 | color += contrib * float3(1.0, 0.6, 0.2) * 0.5; // orange (dimmed) 42 | } else { 43 | color += contrib * float3(0.4, 0.5, 1.0) * 0.5; // purple-blue (dimmed) 44 | } 45 | intensity += contrib; 46 | } 47 | 48 | float threshold = 1.0; 49 | return float4(smoothstep(threshold - 0.3, threshold + 0.3, intensity) * color, 1.0); 50 | } 51 | -------------------------------------------------------------------------------- /Pearcleaner/Metal/LavaLampView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Metal.swift 3 | // Playground 4 | // 5 | // Created by Alin Lupascu on 3/26/25. 6 | // 7 | 8 | import SwiftUI 9 | import MetalKit 10 | 11 | struct MetalView: NSViewRepresentable { 12 | func makeCoordinator() -> Renderer { Renderer() } 13 | 14 | func makeNSView(context: Context) -> MTKView { 15 | let view = MTKView() 16 | view.device = MTLCreateSystemDefaultDevice() 17 | view.colorPixelFormat = .bgra8Unorm 18 | view.isPaused = false 19 | view.enableSetNeedsDisplay = false 20 | view.preferredFramesPerSecond = 60 21 | view.delegate = context.coordinator 22 | context.coordinator.mtkView(view, drawableSizeWillChange: view.drawableSize) 23 | return view 24 | } 25 | 26 | func updateNSView(_ nsView: MTKView, context: Context) {} 27 | } 28 | 29 | 30 | 31 | class Renderer: NSObject, MTKViewDelegate { 32 | var device: MTLDevice! 33 | var commandQueue: MTLCommandQueue! 34 | var pipelineState: MTLRenderPipelineState! 35 | 36 | var centers: [SIMD2] = [] 37 | var radii: [Float] = [] 38 | var time: Float = 0 39 | 40 | override init() { 41 | super.init() 42 | device = MTLCreateSystemDefaultDevice() 43 | commandQueue = device.makeCommandQueue() 44 | setupPipeline() 45 | setupBlobs() 46 | } 47 | 48 | func setupPipeline() { 49 | let library = device.makeDefaultLibrary() 50 | let pipelineDesc = MTLRenderPipelineDescriptor() 51 | pipelineDesc.vertexFunction = library?.makeFunction(name: "vertex_passthrough") 52 | pipelineDesc.fragmentFunction = library?.makeFunction(name: "lavaLampFrag") 53 | pipelineDesc.colorAttachments[0].pixelFormat = .bgra8Unorm 54 | pipelineState = try! device.makeRenderPipelineState(descriptor: pipelineDesc) 55 | } 56 | 57 | func setupBlobs() { 58 | for _ in 0..<6 { 59 | centers.append(SIMD2(Float.random(in: 0.2...0.8), Float.random(in: 0.2...0.8))) 60 | radii.append(Float.random(in: 0.05...0.15)) 61 | } 62 | } 63 | 64 | func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} 65 | 66 | func draw(in view: MTKView) { 67 | guard let drawable = view.currentDrawable, 68 | let rpd = view.currentRenderPassDescriptor else { return } 69 | 70 | time += 0.016 71 | 72 | let cmdBuffer = commandQueue.makeCommandBuffer()! 73 | let encoder = cmdBuffer.makeRenderCommandEncoder(descriptor: rpd)! 74 | encoder.setRenderPipelineState(pipelineState) 75 | 76 | encoder.setFragmentBytes(¢ers, length: MemoryLayout>.stride * centers.count, index: 0) 77 | encoder.setFragmentBytes(&radii, length: MemoryLayout.stride * radii.count, index: 1) 78 | 79 | var count = UInt32(centers.count) 80 | encoder.setFragmentBytes(&count, length: MemoryLayout.stride, index: 2) 81 | encoder.setFragmentBytes(&time, length: MemoryLayout.stride, index: 3) 82 | 83 | var resolution = SIMD2(Float(view.drawableSize.width), Float(view.drawableSize.height)) 84 | encoder.setFragmentBytes(&resolution, length: MemoryLayout>.stride, index: 4) 85 | 86 | encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) 87 | encoder.endEncoding() 88 | cmdBuffer.present(drawable) 89 | cmdBuffer.commit() 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xE8", 9 | "green" : "0x64", 10 | "red" : "0x16" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xE8", 27 | "green" : "0x64", 28 | "red" : "0x16" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "idiom" : "mac", 11 | "scale" : "2x", 12 | "size" : "16x16" 13 | }, 14 | { 15 | "filename" : "icon_32x32.png", 16 | "idiom" : "mac", 17 | "scale" : "1x", 18 | "size" : "32x32" 19 | }, 20 | { 21 | "idiom" : "mac", 22 | "scale" : "2x", 23 | "size" : "32x32" 24 | }, 25 | { 26 | "filename" : "icon_128x128.png", 27 | "idiom" : "mac", 28 | "scale" : "1x", 29 | "size" : "128x128" 30 | }, 31 | { 32 | "idiom" : "mac", 33 | "scale" : "2x", 34 | "size" : "128x128" 35 | }, 36 | { 37 | "filename" : "icon_256x256.png", 38 | "idiom" : "mac", 39 | "scale" : "1x", 40 | "size" : "256x256" 41 | }, 42 | { 43 | "idiom" : "mac", 44 | "scale" : "2x", 45 | "size" : "256x256" 46 | }, 47 | { 48 | "filename" : "icon_512x512.png", 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alienator88/Pearcleaner/6f7b883a042357270975ecf208cab70ac0447459/Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alienator88/Pearcleaner/6f7b883a042357270975ecf208cab70ac0447459/Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alienator88/Pearcleaner/6f7b883a042357270975ecf208cab70ac0447459/Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alienator88/Pearcleaner/6f7b883a042357270975ecf208cab70ac0447459/Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alienator88/Pearcleaner/6f7b883a042357270975ecf208cab70ac0447459/Pearcleaner/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/button.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xE8", 9 | "green" : "0x7D", 10 | "red" : "0x4D" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xBE", 27 | "green" : "0x67", 28 | "red" : "0x3F" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/grayButton.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x82", 9 | "green" : "0x82", 10 | "red" : "0x82" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x7F", 27 | "green" : "0x7F", 28 | "red" : "0x7F" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/pearLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "pearLogo.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/pearLogo.imageset/pearLogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.10, written by Peter Selinger 2001-2011 9 | 10 | 12 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Pearcleaner/Resources/Assets.xcassets/uninstall.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x4D", 9 | "green" : "0x58", 10 | "red" : "0xD7" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x3E", 27 | "green" : "0x47", 28 | "red" : "0xB0" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Pearcleaner/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | CFBundleTypeName 9 | Application Bundle 10 | CFBundleTypeRole 11 | Viewer 12 | LSHandlerRank 13 | Default 14 | LSItemContentTypes 15 | 16 | com.apple.application-bundle 17 | 18 | 19 | 20 | CFBundleGetInfoString 21 | 22 | CFBundleURLTypes 23 | 24 | 25 | CFBundleTypeRole 26 | Editor 27 | CFBundleURLName 28 | com.alienator88.Pearcleaner 29 | CFBundleURLSchemes 30 | 31 | pear 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Pearcleaner/Resources/Pearcleaner.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.com.alienator88.Pearcleaner 8 | 9 | com.apple.security.files.user-selected.read-only 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Pearcleaner/Settings/About.swift: -------------------------------------------------------------------------------- 1 | // 2 | // About.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 11/5/23. 6 | // 7 | 8 | import SwiftUI 9 | import AlinFoundation 10 | 11 | struct AboutSettingsTab: View { 12 | @EnvironmentObject var appState: AppState 13 | @State private var disclose = false 14 | @State private var discloseCredits = false 15 | 16 | var body: some View { 17 | 18 | VStack(alignment: .center) { 19 | 20 | HStack { 21 | Spacer() 22 | Button(action: { 23 | NSWorkspace.shared.open(URL(string: "https://github.com/sponsors/alienator88")!) 24 | }, label: { 25 | HStack(alignment: .center, spacing: 8) { 26 | Image(systemName: "heart") 27 | .resizable() 28 | .scaledToFit() 29 | .frame(width: 16, height: 16) 30 | .foregroundStyle(.pink) 31 | 32 | Text("Sponsor") 33 | .font(.body) 34 | .bold() 35 | } 36 | .padding(5) 37 | }) 38 | } 39 | 40 | VStack(spacing: 10) { 41 | Image(nsImage: NSApp.applicationIconImage) 42 | Text(Bundle.main.name) 43 | .font(.title) 44 | .bold() 45 | HStack { 46 | Text("Version \(Bundle.main.version)") 47 | Text("(Build \(Bundle.main.buildVersion))").font(.footnote) 48 | } 49 | 50 | Text("Made with ❤️ by Alin Lupascu").font(.footnote) 51 | } 52 | .padding(.vertical, 50) 53 | 54 | 55 | VStack(spacing: 20) { 56 | // GitHub 57 | PearGroupBox(header: { Text("Support").font(.title) }, content: { 58 | HStack{ 59 | Image(systemName: "ant") 60 | .resizable() 61 | .scaledToFit() 62 | .frame(width: 20, height: 20) 63 | .padding(.trailing) 64 | 65 | VStack(alignment: .leading){ 66 | Text("Submit a bug or feature request") 67 | .font(.title3) 68 | .foregroundStyle(.primary) 69 | 70 | } 71 | Spacer() 72 | Button { 73 | NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner/issues/new/choose")!) 74 | } label: { 75 | Text("View") 76 | } 77 | .buttonStyle(.borderedProminent) 78 | .tint(.secondary) 79 | } 80 | 81 | }) 82 | 83 | // Translators 84 | PearGroupBox(header: { Text("Translation").font(.title) }, content: { 85 | HStack{ 86 | Image(systemName: "globe") 87 | .resizable() 88 | .scaledToFit() 89 | .frame(width: 20, height: 20) 90 | .padding(.trailing) 91 | 92 | VStack(alignment: .leading, spacing: 10){ 93 | Text("A **huge** thank you to everyone who has contributed so far!") 94 | .font(.title3) 95 | .foregroundStyle(.primary) 96 | Text(translators) 97 | .font(.callout) 98 | .foregroundStyle(.secondary) 99 | 100 | } 101 | 102 | Spacer() 103 | Button { 104 | NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner/discussions/137")!) 105 | } label: { 106 | Text("View") 107 | } 108 | .buttonStyle(.borderedProminent) 109 | .tint(.secondary) 110 | 111 | } 112 | 113 | }) 114 | } 115 | 116 | } 117 | } 118 | } 119 | 120 | 121 | let translators = "changanmoon, L1cardo, funsiyuan, megabitsenmzq, iFloneUEFN, vogt65, kiwamizamurai, exituser, Svec-Tomas, realkeremcam, Ihor-Khomenko, HungThinhIT" 122 | -------------------------------------------------------------------------------- /Pearcleaner/Settings/Helper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Helper.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 3/14/25. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | import AlinFoundation 11 | 12 | struct HelperSettingsTab: View { 13 | @ObservedObject private var helperToolManager = HelperToolManager.shared 14 | @State private var commandOutput: String = "Command output will display here" 15 | @State private var commandToRun: String = "whoami" 16 | @State private var commandToRunManual: String = "" 17 | @State private var showTestingUI: Bool = false 18 | 19 | var body: some View { 20 | VStack(spacing: 20) { 21 | 22 | // === Frequency ============================================================================================ 23 | PearGroupBox( 24 | header: { 25 | HStack { 26 | Text("Management").font(.title2) 27 | Spacer() 28 | Button(action: { 29 | helperToolManager.openSMSettings() 30 | }) { 31 | Label("Login Items", systemImage: "gear") 32 | .padding(4) 33 | } 34 | .buttonStyle(ResetSettingsButtonStyle(isResetting: .constant(false), label: String(localized: "Login Items"), help: "")) 35 | .contextMenu { 36 | Button("Kickstart Service") { 37 | Task { 38 | let result = performPrivilegedCommands(commands: "launchctl kickstart -k system/com.alienator88.Pearcleaner.PearcleanerHelper") 39 | 40 | if !result.0 { 41 | printOS("Helper Kickstart Error: \(result.1)") 42 | } 43 | } 44 | 45 | } 46 | Button("Unregister Service") { 47 | Task { 48 | await helperToolManager.manageHelperTool(action: .uninstall) 49 | } 50 | } 51 | } 52 | } 53 | 54 | }, 55 | content: { 56 | 57 | VStack { 58 | HStack(spacing: 0) { 59 | Image(systemName: "key") 60 | .resizable() 61 | .scaledToFit() 62 | .frame(width: 20, height: 20) 63 | .padding(.trailing) 64 | .foregroundStyle(.primary) 65 | .onTapGesture { 66 | showTestingUI.toggle() 67 | } 68 | Text("Perform privileged operations seamlessly without password prompts") 69 | .font(.callout) 70 | .foregroundStyle(.primary) 71 | .frame(minWidth: 450, maxWidth: .infinity, alignment: .leading) 72 | 73 | // Spacer() 74 | 75 | Toggle(isOn: Binding( 76 | get: { helperToolManager.isHelperToolInstalled }, 77 | set: { newValue in 78 | Task { 79 | if newValue { 80 | await helperToolManager.manageHelperTool(action: .install) 81 | } else { 82 | await helperToolManager.manageHelperTool(action: .uninstall) 83 | } 84 | } 85 | } 86 | ), label: { 87 | }) 88 | .toggleStyle(SettingsToggle()) 89 | .frame(alignment: .trailing) 90 | 91 | } 92 | 93 | Divider() 94 | .padding(.vertical, 5) 95 | 96 | HStack { 97 | Text(helperToolManager.message) 98 | .font(.footnote) 99 | .foregroundStyle(.secondary) 100 | Spacer() 101 | 102 | } 103 | } 104 | .padding(5) 105 | 106 | 107 | 108 | }) 109 | 110 | 111 | if !showTestingUI { 112 | PearGroupBox(header: { 113 | Text("Information").font(.title2) 114 | }, content: { 115 | let message: String.LocalizationValue = """ 116 | Pearcleaner can perform privileged operations in the following ways: 117 | - Helper service 👍🏻 118 | - Authorization services 👎🏻 119 | 120 | Helper service: Pearcleaner will only ask you to enter your password once to enable the helper, then all subsequent operations will run without any prompts as long as the helper stays enabled in Settings > Login Items. This authorization is all managed by macOS via SMAppService. 121 | 122 | Authorization services: Pearcleaner will ask the user for a password prompt on every single privileged operation, which can get overwhelming. These authorizations are managed by Pearcleaner and the user. 123 | 124 | What is a privileged operation: Whenever Pearcleaner needs to delete files from a folder (Ex. /var) the user doesn't have privileges to or undoing/restoring files back to a privileged location. 125 | """ 126 | 127 | VStack(alignment: .leading, spacing: 20) { 128 | Text(String(localized: message)).font(.body).lineSpacing(5) 129 | 130 | Text(String(localized: "Since Authorization services have been deprecated by Apple as a less secure authentication method, it will eventually be removed from Pearcleaner and the helper service will be the only option going forward. I recommend enabling the privileged helper service as soon as possible.")).font(.footnote).foregroundStyle(.secondary) 131 | } 132 | 133 | }) 134 | } 135 | 136 | 137 | 138 | if showTestingUI { 139 | PearGroupBox(header: { 140 | Text("Permission Testing").font(.title2) 141 | }, content: { 142 | VStack { 143 | 144 | Picker("Example privileged commands", selection: $commandToRun) { 145 | Text(verbatim: "whoami").tag("whoami") 146 | Text(verbatim: "systemsetup -getsleep").tag("systemsetup -getsleep") 147 | Text(verbatim: "systemsetup -getcomputername").tag("systemsetup -getcomputername") 148 | } 149 | .pickerStyle(MenuPickerStyle()) 150 | .onChange(of: commandToRun) { newValue in 151 | if helperToolManager.isHelperToolInstalled { 152 | Task { 153 | let (success, output) = await helperToolManager.runCommand(commandToRun) 154 | if success { 155 | commandOutput = output 156 | } else { 157 | commandOutput = "Error: \(output)" 158 | } 159 | } 160 | } 161 | } 162 | .onAppear{ 163 | if helperToolManager.isHelperToolInstalled { 164 | Task { 165 | let (success, output) = await helperToolManager.runCommand(commandToRun) 166 | if success { 167 | commandOutput = output 168 | } else { 169 | commandOutput = "Error: \(output)" 170 | } 171 | } 172 | } 173 | } 174 | 175 | TextField("Enter manual command here, Enter to run", text: $commandToRunManual) 176 | .padding(8) 177 | .background(RoundedRectangle(cornerRadius: 8).strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1)) 178 | .textFieldStyle(.plain) 179 | .onSubmit { 180 | Task { 181 | let (success, output) = await helperToolManager.runCommand(commandToRunManual) 182 | if success { 183 | commandOutput = output 184 | } else { 185 | commandOutput = "Error: \(output)" 186 | } 187 | } 188 | } 189 | 190 | ScrollView { 191 | Text(commandOutput) 192 | .font(.system(.body, design: .monospaced)) 193 | .foregroundStyle(.secondary) 194 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) 195 | .padding() 196 | } 197 | .frame(height: 185) 198 | .frame(maxWidth: .infinity) 199 | .background(.tertiary.opacity(0.1)) 200 | .cornerRadius(8) 201 | } 202 | .padding(5) 203 | }) 204 | .disabled(!helperToolManager.isHelperToolInstalled) 205 | .opacity(helperToolManager.isHelperToolInstalled ? 1 : 0.5) 206 | } 207 | 208 | 209 | } 210 | .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in 211 | Task { 212 | await helperToolManager.manageHelperTool() 213 | } 214 | if helperToolManager.isHelperToolInstalled && showTestingUI { 215 | Task { 216 | let (success, output) = await helperToolManager.runCommand(commandToRun) 217 | if success { 218 | commandOutput = output 219 | } else { 220 | printOS("Helper: \(output)") 221 | } 222 | } 223 | } 224 | } 225 | 226 | } 227 | 228 | } 229 | -------------------------------------------------------------------------------- /Pearcleaner/Settings/SettingsWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsWindow.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 10/31/23. 6 | // 7 | 8 | import SwiftUI 9 | import AlinFoundation 10 | 11 | struct SettingsView: View { 12 | @EnvironmentObject var appState: AppState 13 | @EnvironmentObject var fsm: FolderSettingsManager 14 | @EnvironmentObject var themeManager: ThemeManager 15 | @EnvironmentObject var updater: Updater 16 | @EnvironmentObject var windowSettings: WindowSettings 17 | @Binding var showPopover: Bool 18 | @Binding var search: String 19 | @AppStorage("settings.general.glass") private var glass: Bool = false 20 | @AppStorage("settings.general.selectedTab") private var selectedTab: CurrentTabView = .general 21 | @AppStorage("settings.interface.scrollIndicators") private var scrollIndicators: Bool = false 22 | @State private var isResetting = false 23 | @State private var showPerms = false 24 | 25 | var body: some View { 26 | 27 | NavigationStack { 28 | HStack(spacing: 0) { 29 | sidebarView 30 | Divider().edgesIgnoringSafeArea(.top) 31 | detailView 32 | } 33 | } 34 | .navigationTitle(Text(verbatim: "")) 35 | .frame(width: 800, height: 700) 36 | } 37 | 38 | 39 | /// Sidebar view for navigation items 40 | @ViewBuilder 41 | private var sidebarView: some View { 42 | VStack(alignment: .center, spacing: 4) { 43 | // PearDropViewSmall() 44 | // .padding(.leading, 4) 45 | 46 | // Divider() 47 | // .padding(.bottom, 8) 48 | // .padding(.horizontal, 9) 49 | 50 | Color.clear 51 | .frame(height: 0) 52 | .padding(.bottom) 53 | 54 | VStack(alignment: .leading, spacing: 0) { 55 | SidebarItemView(title: CurrentTabView.general.title, systemImage: "gear", isSelected: selectedTab == .general) { 56 | selectedTab = .general 57 | } 58 | SidebarItemView(title: CurrentTabView.interface.title, systemImage: "macwindow", isSelected: selectedTab == .interface) { 59 | selectedTab = .interface 60 | } 61 | SidebarItemView(title: CurrentTabView.folders.title, systemImage: "folder", isSelected: selectedTab == .folders) { 62 | selectedTab = .folders 63 | } 64 | SidebarItemView(title: CurrentTabView.update.title, systemImage: "cloud", isSelected: selectedTab == .update) { 65 | selectedTab = .update 66 | } 67 | SidebarItemView(title: CurrentTabView.helper.title, systemImage: "key", isSelected: selectedTab == .helper) { 68 | selectedTab = .helper 69 | } 70 | SidebarItemView(title: CurrentTabView.about.title, systemImage: "info.circle", isSelected: selectedTab == .about) { 71 | selectedTab = .about 72 | } 73 | } 74 | 75 | 76 | 77 | Spacer() 78 | 79 | HStack { 80 | Text(verbatim: "v\(Bundle.main.version)").font(.footnote).foregroundStyle(.secondary) 81 | Text(verbatim: "|").font(.footnote).foregroundStyle(.secondary) 82 | 83 | Button() { 84 | showPerms.toggle() 85 | } label: { 86 | Text(String(localized: "Permissions").uppercased()) 87 | .font(.footnote) 88 | .foregroundStyle(.secondary) 89 | } 90 | .buttonStyle(.plain) 91 | .sheet(isPresented: $showPerms, content: { 92 | PermissionsListView() 93 | }) 94 | } 95 | .padding(.bottom, 4) 96 | 97 | 98 | Button { 99 | resetUserDefaults() 100 | } label: { EmptyView() } 101 | .buttonStyle(ResetSettingsButtonStyle(isResetting: $isResetting, label: String(localized: "Reset"), help: String(localized: "Reset all settings to default"))) 102 | .disabled(isResetting) 103 | 104 | 105 | 106 | } 107 | .padding(.bottom) 108 | .padding(.horizontal) 109 | .frame(width: 180) 110 | .background(.ultraThickMaterial) 111 | .background { 112 | MetalView() 113 | .frame(width: 180) 114 | .ignoresSafeArea(.all) 115 | } 116 | // .background(backgroundView(themeManager: themeManager, darker: true, glass: glass)) 117 | } 118 | 119 | /// Detail view content based on the selected tab 120 | @ViewBuilder 121 | private var detailView: some View { 122 | ScrollView() { 123 | // The actual detail views wrapped inside the VStack 124 | switch selectedTab { 125 | case .general: 126 | GeneralSettingsTab() 127 | .environmentObject(appState) 128 | case .interface: 129 | InterfaceSettingsTab(showPopover: $showPopover, search: $search) 130 | .environmentObject(themeManager) 131 | .environmentObject(windowSettings) 132 | case .folders: 133 | FolderSettingsTab() 134 | .environmentObject(themeManager) 135 | case .update: 136 | UpdateSettingsTab() 137 | .environmentObject(updater) 138 | case .helper: 139 | HelperSettingsTab() 140 | case .about: 141 | AboutSettingsTab() 142 | } 143 | } 144 | .scrollIndicators(scrollIndicators ? .automatic : .never) 145 | .frame(maxWidth: .infinity, maxHeight: .infinity) 146 | .padding() 147 | .offset(y: -22) 148 | .background(backgroundView(themeManager: themeManager, glass: false)) 149 | 150 | } 151 | 152 | private func resetUserDefaults() { 153 | isResetting = true 154 | DispatchQueue.global(qos: .background).async { 155 | UserDefaults.standard.dictionaryRepresentation().keys.forEach(UserDefaults.standard.removeObject(forKey:)) 156 | DispatchQueue.main.async { 157 | isResetting = false 158 | } 159 | } 160 | } 161 | 162 | } 163 | 164 | 165 | struct SidebarItemView: View { 166 | var title: String 167 | var systemImage: String 168 | var isSelected: Bool 169 | var onTap: () -> Void 170 | 171 | var body: some View { 172 | HStack(spacing: 14) { 173 | Image(systemName: systemImage) 174 | .resizable() 175 | .aspectRatio(contentMode: .fit) 176 | .foregroundColor(isSelected ? .primary : .primary) 177 | .frame(width: 20, height: 20) 178 | Text(title) 179 | .font(.system(size: 14, weight: .regular)) 180 | .foregroundColor(isSelected ? .primary : .primary) 181 | if !HelperToolManager.shared.isHelperToolInstalled && title.lowercased().contains("helper") { 182 | Image(systemName: "exclamationmark.triangle.fill") 183 | .resizable() 184 | .aspectRatio(contentMode: .fit) 185 | .foregroundColor(.orange) 186 | .frame(width: 14, height: 14) 187 | .help("Please install the helper service") 188 | } 189 | Spacer() 190 | } 191 | .padding(.vertical, 8) 192 | .padding(.leading, 8) 193 | .frame(maxWidth: .infinity, alignment: .leading) 194 | .background(isSelected ? .primary.opacity(0.2) : Color.clear) 195 | .cornerRadius(6) 196 | .contentShape(Rectangle()) 197 | .onTapGesture { 198 | onTap() 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Pearcleaner/Settings/Update.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Update.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 11/5/23. 6 | // 7 | 8 | 9 | import SwiftUI 10 | import Foundation 11 | import AlinFoundation 12 | 13 | struct UpdateSettingsTab: View { 14 | @EnvironmentObject var appState: AppState 15 | @EnvironmentObject var themeManager: ThemeManager 16 | @EnvironmentObject var updater: Updater 17 | 18 | var body: some View { 19 | VStack(spacing: 20) { 20 | 21 | // === Frequency ============================================================================================ 22 | PearGroupBox(header: { Text("Update Frequency").font(.title2) }, content: { 23 | FrequencyView(updater: updater) 24 | }) 25 | 26 | // === Release Notes ======================================================================================== 27 | PearGroupBox(header: { Text("Release Notes").font(.title2) }, content: { 28 | RecentReleasesView(updater: updater) 29 | .frame(height: 380) 30 | .frame(maxWidth: .infinity) 31 | }) 32 | 33 | // === Buttons ============================================================================================== 34 | 35 | HStack(alignment: .center, spacing: 20) { 36 | 37 | Button { 38 | updater.checkForUpdates(sheet: false) 39 | } label: { EmptyView() } 40 | .buttonStyle(SimpleButtonStyle(icon: "arrow.uturn.left.circle", label: String(localized: "Refresh"), help: String(localized: "Refresh updater"))) 41 | .contextMenu { 42 | Button("Force Refresh") { 43 | updater.checkForUpdates(sheet: true, force: true) 44 | } 45 | } 46 | 47 | 48 | Button { 49 | updater.resetAnnouncementAlert() 50 | } label: { EmptyView() } 51 | .buttonStyle(SimpleButtonStyle(icon: "star", label: String(localized: "Announcement"), help: String(localized: "Show announcements badge again"))) 52 | 53 | 54 | Button { 55 | NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner/releases")!) 56 | } label: { EmptyView() } 57 | .buttonStyle(SimpleButtonStyle(icon: "link", label: String(localized: "Releases"), help: String(localized: "View releases on GitHub"))) 58 | } 59 | 60 | } 61 | 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Pearcleaner/Views/AppListItems.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppListDetails.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 11/10/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import AlinFoundation 11 | 12 | struct AppListItems: View { 13 | @EnvironmentObject var appState: AppState 14 | @EnvironmentObject var themeManager: ThemeManager 15 | @Binding var search: String 16 | @State private var isHovered = false 17 | @Environment(\.colorScheme) var colorScheme 18 | @AppStorage("displayMode") var displayMode: DisplayMode = .system 19 | @AppStorage("settings.general.miniview") private var miniView: Bool = true 20 | @AppStorage("settings.general.mini") private var mini: Bool = false 21 | @AppStorage("settings.general.glass") private var glass: Bool = true 22 | @AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false 23 | @AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true 24 | @AppStorage("settings.interface.minimalist") private var minimalEnabled: Bool = true 25 | @Binding var showPopover: Bool 26 | @EnvironmentObject var locations: Locations 27 | let itemId = UUID() 28 | let appInfo: AppInfo 29 | var isSelected: Bool { appState.appInfo.path == appInfo.path } 30 | @State private var hoveredItemPath: URL? = nil 31 | 32 | var body: some View { 33 | 34 | HStack { 35 | 36 | Toggle(isOn: Binding( 37 | get: { self.appState.externalPaths.contains(self.appInfo.path) }, 38 | set: { isChecked in 39 | if isChecked { 40 | if !self.appState.externalPaths.contains(self.appInfo.path) { 41 | let wasEmpty = self.appState.externalPaths.isEmpty 42 | self.appState.externalPaths.append(self.appInfo.path) 43 | 44 | if wasEmpty && appState.currentView != .files { 45 | appState.multiMode = true 46 | showAppInFiles(appInfo: appInfo, appState: appState, locations: locations, showPopover: $showPopover) 47 | } 48 | } 49 | } else { 50 | self.appState.externalPaths.removeAll { $0 == self.appInfo.path } 51 | 52 | if self.appState.externalPaths.isEmpty { 53 | appState.multiMode = false 54 | } 55 | } 56 | } 57 | )) { EmptyView() } 58 | .toggleStyle(SimpleCheckboxToggleStyle()) 59 | .padding(.leading) 60 | 61 | Button(action: { 62 | if !isSelected { 63 | withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { 64 | showAppInFiles(appInfo: appInfo, appState: appState, locations: locations, showPopover: $showPopover) 65 | } 66 | } else { 67 | withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { 68 | updateOnMain { 69 | appState.appInfo = .empty 70 | appState.selectedItems = [] 71 | appState.currentView = miniView ? .apps : .empty 72 | showPopover = false 73 | } 74 | } 75 | } 76 | 77 | }) { 78 | VStack() { 79 | 80 | HStack(alignment: .center) { 81 | 82 | 83 | 84 | if let appIcon = appInfo.appIcon { 85 | ZStack { 86 | Image(nsImage: appIcon) 87 | .resizable() 88 | .aspectRatio(contentMode: .fit) 89 | .frame(width: 30) 90 | .clipShape(RoundedRectangle(cornerRadius: 8)) 91 | } 92 | 93 | } 94 | 95 | if minimalEnabled { 96 | Text(appInfo.appName) 97 | .font(.system(size: (isSelected) ? 14 : 12)) 98 | .lineLimit(1) 99 | .truncationMode(.tail) 100 | } else { 101 | VStack(alignment: .center, spacing: 2) { 102 | HStack { 103 | Text(appInfo.appName) 104 | .font(.system(size: (isSelected) ? 14 : 12)) 105 | .lineLimit(1) 106 | .truncationMode(.tail) 107 | Spacer() 108 | } 109 | 110 | HStack(spacing: 5) { 111 | Text(verbatim: "v\(appInfo.appVersion)") 112 | .font(.footnote) 113 | .lineLimit(1) 114 | .truncationMode(.tail) 115 | .opacity(0.5) 116 | Text(verbatim: "•").font(.footnote).opacity(0.5) 117 | 118 | Text(appInfo.bundleSize == 0 ? String(localized: "calculating") : "\(formatByte(size: appInfo.bundleSize).human)") 119 | .font(.footnote) 120 | .lineLimit(1) 121 | .truncationMode(.tail) 122 | .opacity(0.5) 123 | Spacer() 124 | } 125 | 126 | } 127 | } 128 | 129 | 130 | // if appInfo.webApp { 131 | // Image(systemName: "safari") 132 | // .resizable() 133 | // .aspectRatio(contentMode: .fit) 134 | // .frame(width: 16, height: 16) 135 | // .foregroundStyle(.primary.opacity(0.3)) 136 | // .symbolRenderingMode(.monochrome) 137 | // .help("Web app") 138 | // .padding(.trailing, 5) 139 | // } 140 | // if appInfo.wrapped { 141 | // Image(systemName: "iphone") 142 | // .resizable() 143 | // .aspectRatio(contentMode: .fit) 144 | // .frame(width: 16, height: 16) 145 | // .foregroundStyle(.primary.opacity(0.3)) 146 | // .symbolRenderingMode(.monochrome) 147 | // .help("iOS app") 148 | // .padding(.trailing, 5) 149 | // } 150 | // 151 | // if appInfo.cask != nil { 152 | // Image(systemName: "cup.and.saucer") 153 | // .resizable() 154 | // .aspectRatio(contentMode: .fit) 155 | // .frame(width: 16, height: 16) 156 | // .foregroundStyle(.primary.opacity(0.3)) 157 | // .symbolRenderingMode(.monochrome) 158 | // .help("Homebrew App") 159 | // .padding(.trailing, 5) 160 | // } 161 | 162 | if minimalEnabled { 163 | Spacer() 164 | } 165 | 166 | if minimalEnabled && !isSelected { 167 | Text(appInfo.bundleSize == 0 ? "v\(appInfo.appVersion)" : formatByte(size: appInfo.bundleSize).human) 168 | .font(.system(size: 10)) 169 | .foregroundStyle(.primary.opacity(0.5)) 170 | } 171 | } 172 | 173 | } 174 | .frame(height: 35) 175 | .padding(.trailing) 176 | .padding(.vertical, 5) 177 | } 178 | .buttonStyle(.borderless) 179 | .foregroundStyle(.primary) 180 | .onHover { hovering in 181 | withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.20 : 0)) { 182 | self.isHovered = hovering 183 | self.hoveredItemPath = isHovered ? appInfo.path : nil 184 | } 185 | } 186 | 187 | 188 | } 189 | .background{ 190 | Rectangle() 191 | .fill(isSelected && !glass ? themeManager.pickerColor : .clear) 192 | } 193 | .overlay{ 194 | if (isHovered || isSelected) { 195 | if !minimalEnabled { 196 | HStack { 197 | RoundedRectangle(cornerRadius: 50) 198 | .fill(isSelected ? Color("AccentColor") : .primary.opacity(0.5)) 199 | .frame(width: isSelected ? 4 : 2, height: 25) 200 | .padding(.leading, 9) 201 | Spacer() 202 | } 203 | } else { 204 | HStack { 205 | Spacer() 206 | RoundedRectangle(cornerRadius: 50) 207 | .fill(isSelected ? Color("AccentColor") : .primary.opacity(0.5)) 208 | .frame(width: isSelected ? 4 : 2, height: 25) 209 | .padding(.trailing, 7) 210 | } 211 | } 212 | 213 | 214 | } 215 | } 216 | .onAppear { 217 | if appInfo.bundleSize == 0 { 218 | appState.getBundleSize(for: appInfo) { size in 219 | // printOS("Getting size for: \(appInfo.appName)") 220 | // appInfo.bundleSize = size 221 | } 222 | } 223 | } 224 | 225 | } 226 | 227 | } 228 | -------------------------------------------------------------------------------- /Pearcleaner/Views/AppsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppsListView.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 3/4/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct AppsListView: View { 12 | @Binding var search: String 13 | @Binding var showPopover: Bool 14 | @AppStorage("settings.general.mini") private var mini: Bool = false 15 | @AppStorage("settings.general.selectedSort") var selectedSortAlpha: Bool = true 16 | @AppStorage("settings.interface.scrollIndicators") private var scrollIndicators: Bool = false 17 | 18 | var filteredApps: [AppInfo] 19 | 20 | var body: some View { 21 | ScrollView { 22 | VStack(alignment: .leading, spacing: 0) { 23 | let filteredUserApps = filteredApps.filter { !$0.system } 24 | let filteredSystemApps = filteredApps.filter { $0.system } 25 | 26 | if !filteredUserApps.isEmpty { 27 | SectionView(title: String(localized: "User"), count: filteredUserApps.count, apps: filteredUserApps, search: $search, showPopover: $showPopover) 28 | } 29 | 30 | 31 | if !filteredSystemApps.isEmpty { 32 | SectionView(title: String(localized: "System"), count: filteredSystemApps.count, apps: filteredSystemApps, search: $search, showPopover: $showPopover) } 33 | } 34 | .padding(.top, !mini ? 4 : 0) 35 | } 36 | .scrollIndicators(scrollIndicators ? .automatic : .never) 37 | } 38 | } 39 | 40 | 41 | struct SectionView: View { 42 | var title: String 43 | var count: Int 44 | var apps: [AppInfo] 45 | @Binding var search: String 46 | @Binding var showPopover: Bool 47 | @State private var showItems: Bool = true 48 | @AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true 49 | 50 | var body: some View { 51 | VStack(spacing: 0) { 52 | Header(title: title, count: count, showPopover: $showPopover) 53 | .padding(.leading, 5) 54 | .onTapGesture { 55 | withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { 56 | showItems.toggle() 57 | } 58 | } 59 | 60 | if showItems { 61 | ForEach(apps, id: \.self) { appInfo in 62 | AppListItems(search: $search, showPopover: $showPopover, appInfo: appInfo) 63 | .transition(.opacity) 64 | } 65 | } 66 | 67 | } 68 | } 69 | } 70 | 71 | 72 | 73 | struct Header: View { 74 | let title: String 75 | let count: Int 76 | @EnvironmentObject var appState: AppState 77 | @EnvironmentObject var locations: Locations 78 | @EnvironmentObject var fsm: FolderSettingsManager 79 | @Binding var showPopover: Bool 80 | @AppStorage("settings.general.glass") private var glass: Bool = true 81 | // @AppStorage("settings.general.selectedSortAppsList") var selectedSortAlpha: Bool = true 82 | 83 | 84 | var body: some View { 85 | HStack { 86 | Text(verbatim: "\(title)").foregroundStyle(.primary).opacity(0.5) 87 | 88 | Text(verbatim: "\(count)") 89 | .font(.system(size: 10)) 90 | .monospacedDigit() 91 | .frame(minWidth: count > 99 ? 30 : 24, minHeight: 17) 92 | .background(.primary.opacity(0.1)) 93 | .clipShape(.capsule) 94 | .padding(.leading, 2) 95 | 96 | Spacer() 97 | 98 | } 99 | .frame(minHeight: 20) 100 | .padding(5) 101 | .help("Click header to change sorting order") 102 | // .onTapGesture { 103 | // selectedSortAlpha.toggle() 104 | // } 105 | } 106 | } 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | //List { 118 | // Section { 119 | // ForEach(filteredUserApps, id:\.self) { element in 120 | // Text(element.appName) 121 | // } 122 | // .listRowBackground(Color.clear) 123 | // } header: { 124 | // Text("User Apps") 125 | // } 126 | // 127 | // Section { 128 | // ForEach(filteredSystemApps, id:\.self) { element in 129 | // Text(element.appName) 130 | // } 131 | // .listRowBackground(Color.clear) 132 | // } header: { 133 | // Text("System Apps") 134 | // } 135 | // 136 | //} 137 | //.scrollContentBackground(.hidden) 138 | //.background(.clear) 139 | //.scrollIndicators(.never) 140 | 141 | -------------------------------------------------------------------------------- /Pearcleaner/Views/ConditionBuilderView.swift: -------------------------------------------------------------------------------- 1 | //// 2 | //// ConditionBuilderView.swift 3 | //// Pearcleaner 4 | //// 5 | //// Created by Alin Lupascu on 6/14/24. 6 | //// 7 | // 8 | //import Foundation 9 | //import SwiftUI 10 | //import AlinFoundation 11 | // 12 | //struct ConditionBuilderView: View { 13 | // @Binding var showAlert:Bool 14 | // @State private var include = "" 15 | // @State private var exclude = "" 16 | // @State private var paths = "" 17 | // @State private var pathsEx = "" 18 | // @State var bundle: String 19 | // @State private var conditionExists = false 20 | // 21 | // 22 | // var body: some View { 23 | // VStack(spacing: 10) { 24 | // HStack { 25 | // Spacer() 26 | // Text("Condition Builder") 27 | // .font(.headline) 28 | // Spacer() 29 | // Button("Close") { 30 | // showAlert = false 31 | // } 32 | // .buttonStyle(SimpleButtonStyle(icon: "x.circle", iconFlip: "x.circle.fill", help: String(localized: "Close"))) 33 | // } 34 | // 35 | // Divider() 36 | // Spacer() 37 | // InfoButton(text: String(localized: "Some files/folders are not similar to the app name or bundle id, causing Pearcleaner to either not find them or find unrelated files. \nTo combat this, you may create a custom condition for each application bundle using keywords or direct paths. \n\n- Can add file/folder keywords that you want to either include or exclude in fuzzy searches. \n\n- Can explicitly add or remove a full Finder path to search results if you want a direct search."), label: String(localized: "Instructions"), edge: .bottom) 38 | // Spacer() 39 | // 40 | // VStack { 41 | // HStack { 42 | // Text("Include Keywords:").font(.callout) 43 | // Spacer() 44 | // } 45 | // HStack { 46 | // TextField("keyword-1, keyword-2", text: $include) 47 | // .textFieldStyle(RoundedTextFieldStyle()) 48 | // Button("Clear") { 49 | // include = "" 50 | // } 51 | // .buttonStyle(SimpleButtonStyle(icon: "xmark.circle.fill", help: String(localized: "Clear"), size: 15)) 52 | // } 53 | // 54 | // HStack { 55 | // Text("Exclude Keywords:").font(.callout) 56 | // Spacer() 57 | // } 58 | // HStack { 59 | // TextField("keyword-1, keyword-2", text: $exclude) 60 | // .textFieldStyle(RoundedTextFieldStyle()) 61 | // Button("Clear") { 62 | // exclude = "" 63 | // } 64 | // .buttonStyle(SimpleButtonStyle(icon: "xmark.circle.fill", help: String(localized: "Clear"), size: 15)) 65 | // } 66 | // 67 | // HStack { 68 | // Text("Add Direct Paths:").font(.callout) 69 | // Spacer() 70 | // } 71 | // HStack { 72 | // TextField("/Full/Path/example-1.txt, /Full/Path/example-2.txt", text: $paths) 73 | // .textFieldStyle(RoundedTextFieldStyle()) 74 | // Button("Clear") { 75 | // paths = "" 76 | // } 77 | // .buttonStyle(SimpleButtonStyle(icon: "xmark.circle.fill", help: String(localized: "Clear"), size: 15)) 78 | // } 79 | // HStack { 80 | // Text("Remove Direct Paths:").font(.callout) 81 | // Spacer() 82 | // } 83 | // HStack { 84 | // TextField("/Full/Path/example-1.txt, /Full/Path/example-2.txt", text: $pathsEx) 85 | // .textFieldStyle(RoundedTextFieldStyle()) 86 | // Button("Clear") { 87 | // pathsEx = "" 88 | // } 89 | // .buttonStyle(SimpleButtonStyle(icon: "xmark.circle.fill", help: String(localized: "Clear"), size: 15)) 90 | // } 91 | // } 92 | // .padding(.horizontal) 93 | // 94 | // Spacer() 95 | // 96 | // HStack { 97 | // 98 | // Spacer() 99 | // 100 | // Button("Add/Save") { 101 | // showAlert = false 102 | // let newCondition = Condition(bundle_id: bundle, include: include.toConditionFormat(), exclude: exclude.toConditionFormat(), includeForce: paths.toConditionFormat(), excludeForce: pathsEx.toConditionFormat()) 103 | // ConditionManager.shared.saveCondition(newCondition) 104 | // } 105 | // .buttonStyle(SimpleButtonStyle(icon: "plus.square.fill", label: conditionExists ? String(localized: "Save") : String(localized: "Add"), help: String(localized: "Save the condition for this application"))) 106 | // 107 | // Spacer() 108 | // 109 | // Button("Remove") { 110 | // showAlert = false 111 | // include = "" 112 | // exclude = "" 113 | // paths = "" 114 | // pathsEx = "" 115 | // ConditionManager.shared.deleteCondition(bundle_id: bundle) 116 | // } 117 | // .buttonStyle(SimpleButtonStyle(icon: "minus.square.fill", label: String(localized: "Remove"), help: String(localized: "Remove the condition from this application"))) 118 | // .disabled(!conditionExists) 119 | // 120 | // Spacer() 121 | // 122 | // } 123 | // 124 | // Spacer() 125 | // } 126 | // .padding(15) 127 | // .frame(width: 500, height: 500) 128 | // .background(GlassEffect(material: .hudWindow, blendingMode: .behindWindow)) 129 | // .onAppear { 130 | // loadCondition() 131 | // } 132 | // } 133 | // 134 | // private func loadCondition() { 135 | // let defaults = UserDefaults.standard 136 | // let decoder = JSONDecoder() 137 | // 138 | // let key = "Condition-\(bundle.pearFormat())" 139 | // if let savedCondition = defaults.object(forKey: key) as? Data { 140 | // if let loadedCondition = try? decoder.decode(Condition.self, from: savedCondition) { 141 | // include = loadedCondition.include.joined(separator: ", ") 142 | // exclude = loadedCondition.exclude.joined(separator: ", ") 143 | // if let includeForce = loadedCondition.includeForce { 144 | // paths = includeForce.map { $0.absoluteString }.joined(separator: ", ") 145 | // } 146 | // if let excludeForce = loadedCondition.excludeForce { 147 | // pathsEx = excludeForce.map { $0.absoluteString }.joined(separator: ", ") 148 | // } 149 | // conditionExists = true 150 | // } 151 | // } 152 | // } 153 | //} 154 | // 155 | // 156 | -------------------------------------------------------------------------------- /Pearcleaner/Views/MiniMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MiniMode.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 11/14/23. 6 | // 7 | 8 | 9 | import Foundation 10 | import SwiftUI 11 | import AlinFoundation 12 | 13 | struct MiniMode: View { 14 | @EnvironmentObject var appState: AppState 15 | @EnvironmentObject var themeManager: ThemeManager 16 | @Binding var search: String 17 | @State private var showSys: Bool = true 18 | @State private var showUsr: Bool = true 19 | @AppStorage("settings.general.glass") private var glass: Bool = false 20 | @AppStorage("settings.general.popover") private var popoverStay: Bool = true 21 | @AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true 22 | @Binding var showPopover: Bool 23 | 24 | 25 | var body: some View { 26 | 27 | HStack(alignment: .center, spacing: 0) { 28 | 29 | // Main Mini View 30 | VStack(spacing: 0) { 31 | Group { 32 | if appState.currentView == .empty { 33 | MiniEmptyView(showPopover: $showPopover) 34 | } else { 35 | MiniAppView(search: $search, showPopover: $showPopover) 36 | } 37 | } 38 | .transition(.opacity) 39 | } 40 | } 41 | .frame(minWidth: 300, minHeight: 345) 42 | .edgesIgnoringSafeArea(.all) 43 | .background(backgroundView(themeManager: themeManager, glass: glass)) 44 | 45 | } 46 | } 47 | 48 | 49 | 50 | 51 | 52 | 53 | struct MiniEmptyView: View { 54 | @Environment(\.colorScheme) var colorScheme 55 | @EnvironmentObject var appState: AppState 56 | @EnvironmentObject var locations: Locations 57 | @AppStorage("settings.general.mini") private var mini: Bool = false 58 | @AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true 59 | @Binding var showPopover: Bool 60 | 61 | var body: some View { 62 | ZStack() { 63 | 64 | VStack { 65 | Spacer() 66 | LinearGradient(gradient: Gradient(colors: [.green, .orange]), startPoint: .leading, endPoint: .trailing) 67 | .mask( 68 | Image(systemName: "plus.square.dashed") 69 | .resizable() 70 | .scaledToFit() 71 | .frame(width: 120, height: 120, alignment: .center) 72 | .padding() 73 | .fontWeight(.ultraLight) 74 | .offset(x: 5, y: 5) 75 | ) 76 | Spacer() 77 | } 78 | 79 | VStack { 80 | Spacer() 81 | Text("Drop an app here") 82 | .font(.title3) 83 | .opacity(0.7) 84 | 85 | Text("Click for apps list") 86 | .font(.footnote) 87 | .padding(.bottom, 25) 88 | .opacity(0.5) 89 | } 90 | 91 | } 92 | .onTapGesture { 93 | withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { 94 | appState.currentView = .apps 95 | } 96 | } 97 | } 98 | } 99 | 100 | 101 | 102 | 103 | 104 | struct MiniAppView: View { 105 | @Environment(\.colorScheme) var colorScheme 106 | @EnvironmentObject var appState: AppState 107 | @EnvironmentObject var locations: Locations 108 | @EnvironmentObject var themeManager: ThemeManager 109 | @Binding var search: String 110 | @State private var showSys: Bool = true 111 | @State private var showUsr: Bool = true 112 | @AppStorage("settings.general.mini") private var mini: Bool = false 113 | @AppStorage("settings.general.glass") private var glass: Bool = true 114 | @AppStorage("settings.general.popover") private var popoverStay: Bool = true 115 | @AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false 116 | @AppStorage("settings.general.sidebarWidth") private var sidebarWidth: Double = 265 117 | @Binding var showPopover: Bool 118 | @State private var showMenu = false 119 | @State var isMenuBar: Bool = false 120 | 121 | var body: some View { 122 | 123 | 124 | ZStack { 125 | 126 | if appState.reload { 127 | VStack { 128 | Spacer() 129 | ProgressView() { 130 | Text("Gathering app details") 131 | .font(.callout) 132 | .foregroundStyle(.primary.opacity(0.5)) 133 | .padding(5) 134 | } 135 | Spacer() 136 | } 137 | .padding(.vertical) 138 | } else { 139 | AppSearchView(glass: glass, menubarEnabled: menubarEnabled, mini: mini, search: $search, showPopover: $showPopover, isMenuBar: $isMenuBar) 140 | } 141 | 142 | } 143 | .transition(.opacity) 144 | .frame(minWidth: 300, minHeight: 370) 145 | .edgesIgnoringSafeArea(.all) 146 | .background(backgroundView(themeManager: themeManager, glass: glass).padding(-80)) 147 | .transition(.opacity) 148 | .popover(isPresented: $showPopover, arrowEdge: .trailing) { 149 | VStack { 150 | if appState.currentView == .files { 151 | FilesView(showPopover: $showPopover, search: $search) 152 | .id(appState.appInfo.id) 153 | } else if appState.currentView == .zombie { 154 | ZombieView(showPopover: $showPopover, search: $search) 155 | .id(appState.appInfo.id) 156 | } else if appState.currentView == .terminal { 157 | TerminalSheetView(showPopover: $showPopover, homebrew: true, caskName: appState.appInfo.cask) 158 | .id(appState.appInfo.id) 159 | } 160 | 161 | } 162 | .interactiveDismissDisabled(popoverStay) 163 | .background(backgroundView(themeManager: themeManager, glass: glass).padding(-80)) 164 | .frame(width: 650, height: 500) 165 | 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Pearcleaner/Views/RegularMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppListH.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 11/5/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import AlinFoundation 11 | import FinderSync 12 | 13 | struct RegularMode: View { 14 | @EnvironmentObject var appState: AppState 15 | @EnvironmentObject var themeManager: ThemeManager 16 | @EnvironmentObject var locations: Locations 17 | @EnvironmentObject var fsm: FolderSettingsManager 18 | @AppStorage("settings.general.glass") private var glass: Bool = false 19 | @AppStorage("settings.general.sidebarWidth") private var sidebarWidth: Double = 265 20 | @AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false 21 | @AppStorage("settings.general.mini") private var mini: Bool = false 22 | @AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true 23 | @Binding var search: String 24 | @State private var showSys: Bool = true 25 | @State private var showUsr: Bool = true 26 | @Binding var showPopover: Bool 27 | @State private var showMenu = false 28 | @State var isMenuBar: Bool = false 29 | @State private var isExpanded: Bool = false 30 | 31 | var body: some View { 32 | 33 | // Main App Window 34 | ZStack() { 35 | 36 | if appState.currentPage == .applications { 37 | HStack(alignment: .center, spacing: 0) { 38 | 39 | // App List 40 | AppSearchView(glass: glass, menubarEnabled: menubarEnabled, mini: mini, search: $search, showPopover: $showPopover, isMenuBar: $isMenuBar) 41 | .frame(width: sidebarWidth) 42 | .transition(.opacity) 43 | 44 | SlideableDivider(dimension: $sidebarWidth) 45 | .zIndex(3) 46 | 47 | 48 | // Details View 49 | HStack(spacing: 0) { 50 | Spacer() 51 | Group { 52 | if appState.currentView == .empty || appState.currentView == .apps { 53 | AppDetailsEmptyView() 54 | } else if appState.currentView == .files { 55 | FilesView(showPopover: $showPopover, search: $search) 56 | .id(appState.appInfo.id) 57 | } else if appState.currentView == .zombie { 58 | ZombieView(showPopover: $showPopover, search: $search) 59 | .id(appState.appInfo.id) 60 | } else if appState.currentView == .terminal { 61 | TerminalSheetView(showPopover: $showPopover, homebrew: true, caskName: appState.appInfo.cask) 62 | .id(appState.appInfo.id) 63 | } 64 | } 65 | .transition(.opacity) 66 | if appState.currentView != .terminal { 67 | Spacer() 68 | } 69 | } 70 | .zIndex(2) 71 | } 72 | 73 | } else if appState.currentPage == .orphans { 74 | ZombieView(showPopover: $showPopover, search: $search) 75 | .onAppear { 76 | if appState.zombieFile.fileSize.keys.isEmpty { 77 | appState.showProgress.toggle() 78 | } 79 | withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { 80 | if appState.zombieFile.fileSize.keys.isEmpty { 81 | reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, fsm: fsm) 82 | } 83 | } 84 | } 85 | } else if appState.currentPage == .development { 86 | EnvironmentCleanerView() 87 | } else if appState.currentPage == .lipo { 88 | LipoView() 89 | } 90 | 91 | 92 | 93 | if appState.currentView != .terminal { 94 | VStack(spacing: 0) { 95 | 96 | HStack { 97 | 98 | Spacer() 99 | 100 | CustomPickerButton( 101 | selectedOption: $appState.currentPage, 102 | isExpanded: $isExpanded, 103 | options: CurrentPage.allCases.sorted { $0.title < $1.title } // Sort by title 104 | ) 105 | .padding(6) 106 | } 107 | 108 | 109 | Spacer() 110 | } 111 | } 112 | 113 | 114 | } 115 | .background(backgroundView(themeManager: themeManager)) 116 | .frame(minWidth: appState.currentPage == .orphans ? 700 : 900, minHeight: 600) 117 | .edgesIgnoringSafeArea(.all) 118 | .onTapGesture { 119 | withAnimation(Animation.spring(duration: animationEnabled ? 0.35 : 0)) { 120 | if isExpanded { 121 | isExpanded = false 122 | } 123 | } 124 | 125 | } 126 | 127 | 128 | } 129 | } 130 | 131 | 132 | 133 | 134 | 135 | 136 | struct AppDetailsEmptyView: View { 137 | @EnvironmentObject var appState: AppState 138 | 139 | var body: some View { 140 | VStack(alignment: .center) { 141 | 142 | Spacer() 143 | 144 | PearDropView() 145 | .frame(width: 500) 146 | 147 | Spacer() 148 | 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Pearcleaner/Views/TerminalView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TerminalView.swift 3 | // Pearcleaner 4 | // 5 | // Created by Alin Lupascu on 2/12/25. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftTerm 10 | import AlinFoundation 11 | 12 | struct TerminalSheetView: View { 13 | @EnvironmentObject var appState: AppState 14 | @EnvironmentObject var locations: Locations 15 | @AppStorage("settings.general.mini") private var mini: Bool = false 16 | @AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false 17 | var showPopover: Binding? 18 | let command: String? 19 | let homebrew: Bool 20 | 21 | init(showPopover: Binding? = nil, command: String? = nil, homebrew: Bool = false, caskName: String? = nil) { 22 | self.showPopover = showPopover 23 | self.command = homebrew ? getBrewCleanupCommand(for: caskName ?? "") : command 24 | self.homebrew = homebrew 25 | } 26 | 27 | var body: some View { 28 | VStack(spacing: 0) { 29 | 30 | Text(homebrew ? "Homebrew Cleanup: \(appState.appInfo.appName)" : "Terminal") 31 | .font(.title2) 32 | .padding() 33 | 34 | Divider() 35 | 36 | if let command = command { 37 | TerminalWrapper(command: command) 38 | .frame(maxWidth: .infinity, maxHeight: .infinity) 39 | .padding() 40 | } else { 41 | Text("No command provided") 42 | .foregroundColor(.gray) 43 | .padding() 44 | } 45 | 46 | Divider() 47 | 48 | Button("Close") { 49 | 50 | // Handle close logic based on the presence of showPopover 51 | if let showPopover = showPopover { 52 | // If popover is present 53 | if mini || menubarEnabled { 54 | appState.currentView = .apps 55 | showPopover.wrappedValue = false 56 | } else { 57 | appState.currentView = .empty 58 | } 59 | } else { 60 | // No popover, so just clear the current view and app info 61 | appState.currentView = .empty 62 | } 63 | 64 | appState.appInfo = AppInfo.empty 65 | 66 | // Check if there are more paths to process 67 | if !appState.externalPaths.isEmpty { 68 | // Get the next path 69 | if let nextPath = appState.externalPaths.first { 70 | // Load the next app's info 71 | if let nextApp = AppInfoFetcher.getAppInfo(atPath: nextPath) { 72 | updateOnMain { 73 | appState.appInfo = nextApp 74 | } 75 | showAppInFiles(appInfo: nextApp, appState: appState, locations: locations, showPopover: showPopover ?? .constant(false)) 76 | } 77 | } 78 | } 79 | 80 | } 81 | .buttonStyle(SimpleButtonStyle(icon: "x.circle", iconFlip: "x.circle.fill", help: String(localized: "Close"))) 82 | .padding(5) 83 | } 84 | .ignoresSafeArea(.all) 85 | .background(Color.black) 86 | } 87 | } 88 | 89 | 90 | struct TerminalWrapper: NSViewRepresentable { 91 | let command: String 92 | @State private var terminalDelegate = TerminalDelegate() // Retain delegate 93 | 94 | func makeNSView(context: Context) -> NoScrollTerminalView { 95 | let terminalView = NoScrollTerminalView(frame: .zero) 96 | terminalView.processDelegate = terminalDelegate 97 | 98 | let shell = getShell() 99 | let shellIdiom = "-" + (shell as NSString).lastPathComponent 100 | 101 | terminalView.startProcess(executable: shell, execName: shellIdiom) 102 | 103 | // Run the given command after shell initializes 104 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 105 | terminalView.send(txt: "clear;echo 'Please wait..';\(self.command)\n") 106 | } 107 | 108 | return terminalView 109 | } 110 | 111 | func updateNSView(_ nsView: NoScrollTerminalView, context: Context) {} 112 | } 113 | 114 | 115 | class TerminalDelegate: NSObject, LocalProcessTerminalViewDelegate { 116 | func sizeChanged(source: SwiftTerm.LocalProcessTerminalView, newCols: Int, newRows: Int) { 117 | 118 | } 119 | 120 | func setTerminalTitle(source: SwiftTerm.LocalProcessTerminalView, title: String) { 121 | 122 | } 123 | 124 | func hostCurrentDirectoryUpdate(source: SwiftTerm.TerminalView, directory: String?) { 125 | 126 | } 127 | 128 | func processTerminated(source: SwiftTerm.TerminalView, exitCode: Int32?) { 129 | print("Process terminated with code: \(exitCode ?? -1)") 130 | } 131 | 132 | } 133 | 134 | 135 | class NoScrollTerminalView: LocalProcessTerminalView { 136 | override func viewDidMoveToSuperview() { 137 | super.viewDidMoveToSuperview() 138 | 139 | // Remove any NSScroller subviews 140 | DispatchQueue.main.async { 141 | for subview in self.subviews { 142 | if subview is NSScroller { 143 | subview.removeFromSuperview() 144 | } 145 | } 146 | } 147 | 148 | // Set Nerd Font if available 149 | self.font = getNerdFont() 150 | } 151 | } 152 | 153 | func getShell () -> String 154 | { 155 | let bufsize = sysconf(_SC_GETPW_R_SIZE_MAX) 156 | guard bufsize != -1 else { 157 | return "/bin/bash" 158 | } 159 | let buffer = UnsafeMutablePointer.allocate(capacity: bufsize) 160 | defer { 161 | buffer.deallocate() 162 | } 163 | var pwd = passwd() 164 | var result: UnsafeMutablePointer? = UnsafeMutablePointer.allocate(capacity: 1) 165 | 166 | if getpwuid_r(getuid(), &pwd, buffer, bufsize, &result) != 0 { 167 | return "/bin/bash" 168 | } 169 | return String (cString: pwd.pw_shell) 170 | } 171 | 172 | func getNerdFont() -> NSFont { 173 | let preferredNerdFonts = [ 174 | "Hack Nerd Font", "FiraCode Nerd Font", "JetBrainsMono Nerd Font", 175 | "SourceCodePro Nerd Font", "MesloLGS NF", "Cascadia Code PL" 176 | ] 177 | 178 | for fontName in preferredNerdFonts { 179 | if let font = NSFont(name: fontName, size: 14) { 180 | return font 181 | } 182 | } 183 | 184 | // Fallback to system monospaced font if no Nerd Font is found 185 | return NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) 186 | } 187 | -------------------------------------------------------------------------------- /Pearcleaner/announcements.json: -------------------------------------------------------------------------------- 1 | { 2 | "4.4.0": "- Privileged Helper Service: Pearcleaner now supports using a privileged helper service to perform operations that require administrative permissions. This will eventually replace the current mechanism which uses Authorization Services as it's deprecated by Apple. More information in Settings > Helper tab.|- App Lipo: There's a new Lipo(beta) view available in the menu which will show all universal apps and strip the app binary of the unused architectures to save some space. Universal apps will also show a Lipo button on the App Details view next to the Uninstall button to perform the same procedure on each app individually.", 3 | "3.9.2": "- Multilingual Support: If you would like to help translate Pearcleaner to your language, please see GitHub issue #83", 4 | "3.7.0": "- App List Size Sorting: You can now sort the sidebar app list alphabetically or by descending size. Click the User/System header to toggle or from the searchbar menu|- Leftover Files: On first use, a warning/explanation sheet will be presented to the user", 5 | "3.5.0": "- Finder Extension: When enabled, you can right click an app in finder and uninstall with Pearcleaner directly|- Theme System: You can now select a base theme color for the application window", 6 | "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", 7 | "3.2.0": "- Menubar Item: You can choose to access Pearcleaner from the menubar. This will show a slightly modified Mini Mode view.", 8 | "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.", 9 | "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" 10 | } 11 | -------------------------------------------------------------------------------- /PearcleanerHelper/com.alienator88.Pearcleaner.PearcleanerHelper.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.alienator88.Pearcleaner.PearcleanerHelper 7 | BundleProgram 8 | Contents/MacOS/PearcleanerHelper 9 | MachServices 10 | 11 | com.alienator88.Pearcleaner.PearcleanerHelper 12 | 13 | 14 | AssociatedBundleIdentifiers 15 | 16 | com.alienator88.Pearcleaner 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /PearcleanerHelper/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // PearcleanerHelper 4 | // 5 | // Created by Alin Lupascu on 3/14/25. 6 | // 7 | 8 | import Foundation 9 | 10 | @objc(HelperToolProtocol) 11 | public protocol HelperToolProtocol { 12 | func runCommand(command: String, withReply reply: @escaping (Bool, String) -> Void) 13 | func runThinning(atPath: String, withReply reply: @escaping (Bool, String) -> Void) 14 | } 15 | 16 | // XPC Communication setup 17 | class HelperToolDelegate: NSObject, NSXPCListenerDelegate, HelperToolProtocol { 18 | private var activeConnections = Set() 19 | 20 | override init() { 21 | super.init() 22 | } 23 | 24 | // Accept new XPC connections by setting up the exported interface and object. 25 | func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { 26 | newConnection.exportedInterface = NSXPCInterface(with: HelperToolProtocol.self) 27 | newConnection.exportedObject = self 28 | newConnection.invalidationHandler = { [weak self] in 29 | self?.activeConnections.remove(newConnection) 30 | if self?.activeConnections.isEmpty == true { 31 | exit(0) // Exit when no active connections remain 32 | } 33 | } 34 | activeConnections.insert(newConnection) 35 | newConnection.resume() 36 | return true 37 | } 38 | 39 | // Execute the shell command and reply with output. 40 | func runCommand(command: String, withReply reply: @escaping (Bool, String) -> Void) { 41 | let process = Process() 42 | process.executableURL = URL(fileURLWithPath: "/bin/bash") 43 | process.arguments = ["-c", command] 44 | let pipe = Pipe() 45 | process.standardOutput = pipe 46 | process.standardError = pipe 47 | do { 48 | try process.run() 49 | process.waitUntilExit() 50 | } catch { 51 | reply(false, "Failed to run command: \(error.localizedDescription)") 52 | return 53 | } 54 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 55 | let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 56 | let success = (process.terminationStatus == 0) // Check if process exited successfully 57 | reply(success, output.isEmpty ? "No output" : output) 58 | } 59 | 60 | // Execute app lipo using privileges for apps owned by root 61 | func runThinning(atPath: String, withReply reply: @escaping (Bool, String) -> Void) { 62 | let success = thinBinaryUsingMachO(executablePath: atPath) 63 | reply(success, success ? "Success" : "Failed") 64 | } 65 | } 66 | 67 | // Set up and start the XPC listener. 68 | let delegate = HelperToolDelegate() 69 | let listener = NSXPCListener(machServiceName: "com.alienator88.Pearcleaner.PearcleanerHelper") 70 | listener.delegate = delegate 71 | listener.resume() 72 | RunLoop.main.run() 73 | -------------------------------------------------------------------------------- /PearcleanerSentinel/com.alienator88.PearcleanerSentinel.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.alienator88.PearcleanerSentinel 7 | BundleProgram 8 | Contents/MacOS/PearcleanerSentinel 9 | RunAtLoad 10 | 11 | KeepAlive 12 | 13 | AssociatedBundleIdentifiers 14 | 15 | com.alienator88.Pearcleaner 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /PearcleanerSentinel/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // PearcleanerSentinel 4 | // 5 | // Created by Alin Lupascu on 11/9/23. 6 | // 7 | 8 | 9 | import AppKit 10 | import FileWatcher 11 | 12 | main() 13 | 14 | var globalFileWatcher: FileWatcher? 15 | 16 | func startGlobalFileWatcher() { 17 | let home = FileManager.default.homeDirectoryForCurrentUser.path 18 | globalFileWatcher = FileWatcher(["\(home)/.Trash"]) 19 | globalFileWatcher?.queue = DispatchQueue.global() 20 | globalFileWatcher?.callback = { event in 21 | checkApp(file: event.path) 22 | } 23 | globalFileWatcher?.start() 24 | } 25 | 26 | func stopGlobalFileWatcher() { 27 | globalFileWatcher?.stop() 28 | globalFileWatcher = nil 29 | } 30 | 31 | func setupNotificationListener() { 32 | let notificationCenter = DistributedNotificationCenter.default() 33 | notificationCenter.addObserver(forName: Notification.Name("Pearcleaner.StartFileWatcher"), object: nil, queue: nil) { notification in 34 | print("Received start notification") 35 | startGlobalFileWatcher() 36 | } 37 | notificationCenter.addObserver(forName: Notification.Name("Pearcleaner.StopFileWatcher"), object: nil, queue: nil) { notification in 38 | print("Received stop notification") 39 | stopGlobalFileWatcher() 40 | } 41 | } 42 | 43 | func main() { 44 | setupNotificationListener() 45 | startGlobalFileWatcher() 46 | RunLoop.main.run() 47 | } 48 | 49 | 50 | func checkApp(file: String) { 51 | let app = URL(fileURLWithPath: file) 52 | let appExt = app.pathExtension 53 | if appExt == "app" { 54 | if let appBundle = Bundle(url: app) { 55 | if appBundle.bundleIdentifier == "com.alienator88.Pearcleaner" { 56 | return 57 | } else { 58 | if FileManager.default.isInTrash(app) { 59 | NSWorkspace.shared.open(URL(string: "pear://openApp?path=\(file)")!) 60 | } 61 | } 62 | } else { 63 | print("Error: Unable to get bundle information for \(file)") 64 | } 65 | } 66 | } 67 | 68 | 69 | 70 | // --- Trash Relationship --- 71 | extension FileManager { 72 | public func isInTrash(_ file: URL) -> Bool { 73 | var relationship: URLRelationship = .other 74 | do { 75 | try getRelationship(&relationship, of: .trashDirectory, in: .userDomainMask, toItemAt: file) 76 | return relationship == .contains 77 | } catch { 78 | return false 79 | } 80 | } 81 | } 82 | 83 | 84 | 85 | 86 | 87 | 88 | // For testing and outputing logging to file from cmd line tool 89 | func writeLogMon(string: String) { 90 | let fileManager = FileManager.default 91 | let home = fileManager.homeDirectoryForCurrentUser.path 92 | let logFilePath = "\(home)/Downloads/monitor.txt" 93 | 94 | // Check if the log file exists, and create it if it doesn't 95 | if !fileManager.fileExists(atPath: logFilePath) { 96 | if !fileManager.createFile(atPath: logFilePath, contents: nil, attributes: nil) { 97 | print("Failed to create the log file.") 98 | return 99 | } 100 | } 101 | 102 | do { 103 | if let fileHandle = FileHandle(forWritingAtPath: logFilePath) { 104 | let ns = "\(string)\n" 105 | fileHandle.seekToEndOfFile() 106 | fileHandle.write(ns.data(using: .utf8)!) 107 | fileHandle.closeFile() 108 | } else { 109 | print("Error opening file for appending") 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pearcleaner 2 |

3 | 4 | 5 | 6 |
7 | Status: Maintained 8 |
9 | Version: 4.4.4 10 |
11 | Download 12 | · 13 | Commits 14 |
15 |
16 | Pearcleaner - An open-source mac app cleaner | Product Hunt 17 |
18 | Featured|HelloGitHub 19 |

20 |

21 |
22 | 23 | 24 | A free, source-available and fair-code licensed Mac app cleaner inspired by [Freemacsoft's AppCleaner](https://freemacsoft.net/appcleaner/) and [Sun Knudsen's Privacy Guides](https://github.com/sunknudsen/guides/tree/main/archive/how-to-clean-uninstall-macos-apps-using-appcleaner-open-source-alternative) post on his app-cleaner script. 25 | This project was born out of wanting to learn more on how macOS deals with app installation/uninstallation and getting more Swift experience. If you have suggestions I'm open to hearing them, submit a feature request! 26 | 27 | 28 | ### Table of Contents: 29 | [Translations](#translations) | [License](#license) | [Features](#features) | [Screenshots](#screenshots) | [Issues](#issues) | [Requirements](#requirements) | [Download](#getting-pearcleaner) | [Thanks](#thanks) | [Other Apps](#other-apps) 30 | 31 |
32 | 33 | ## Translations 34 | If you are able to contribute to translations for the app, please see this discussion: https://github.com/alienator88/Pearcleaner/discussions/137 35 | 36 | ## License 37 | > [!IMPORTANT] 38 | > Pearcleaner is licensed under Apache 2.0 with [Commons Clause](https://commonsclause.com/). This means that you can do anything you'd like with the source, modify it, contribute to it, etc., but the license explicitly prohibits any form of monetization for Pearcleaner or any modified versions of it. See full license [HERE](https://github.com/alienator88/Pearcleaner/blob/main/LICENSE.md) 39 | 40 | ## Features 41 | - Orphaned file search for finding remaining files from previously uninstalled applications 42 | - Development environments file/cache cleaning 43 | - App Lipo to strip unneeded architectures from universal apps. No dependency on the lipo tool so no need to install xcode or command line tools 44 | - Prune unused translation files from app bundles keeping only the preferred language set on macOS 45 | - Sentinel monitor helper that can be enabled to watch Trash folder for deleted apps to cleanup after the fact(Extremely small (210KB) and uses ~2mb of ram to run in the background and file watch) 46 | - Mini mode which can be enabled from Settings 47 | - Menubar icon option 48 | - CLI support 49 | - Drag/drop applications support 50 | - Deep link support for automation, see [wiki guide](https://github.com/alienator88/Pearcleaner/wiki/Deep-Link-Guide) for instructions 51 | - Optional Finder Extension which allows you to uninstall an app directly from Finder by `right click > Pearcleaner Uninstall` 52 | - Theme System available with custom color selector 53 | - Differentiate between regular, Safari web-apps and mobile apps with badges like **web** and **iOS** 54 | - Has clean uninstall menu option for the Pearcleaner app itself if you want to stop using it and get rid of all files and launch items 55 | - Export app bundles for migrating apps and their cache to a new system 56 | - Export app file list search results 57 | - Optional Homebrew cleanup 58 | - Include extra directories to search for apps in 59 | - Exclude files/folders from the orphaned file search 60 | - Custom auto-updater that pulls latest release notes and binaries from GitHub Releases (Pearcleaner should run from `/Applications` folder to avoid permission issues) 61 | 62 | 63 | ## Screenshots 64 | 65 | 66 | 67 | 68 |

69 | 70 | 71 | 72 |

73 | 74 | ## Issues 75 | > [!WARNING] 76 | > - When submitting issues, please use the appropriate issue template corresponding with your problem [HERE](https://github.com/alienator88/Pearcleaner/issues/new/choose) 77 | > - Beta versions of macOS will not be supported until general release 78 | 79 | 80 | ## Requirements 81 | > [!NOTE] 82 | > - MacOS 13.0+ [Non-beta releases] 83 | > - Full Disk permission to search for files 84 | 85 | 86 | ## Getting Pearcleaner 87 | 88 |
89 | Releases 90 | 91 | Pre-compiled, always up-to-date versions are available from my [releases](https://github.com/alienator88/Pearcleaner/releases) page. 92 |
93 | 94 |
95 | Homebrew 96 | 97 | You can add the app via Homebrew: 98 | ``` 99 | brew install pearcleaner 100 | ``` 101 |
102 | 103 | ## Thanks 104 | 105 | - Much appreciation to [Freemacsoft's AppCleaner](https://freemacsoft.net/appcleaner/) and [Sun Knudsen's app-cleaner script](https://sunknudsen.com/privacy-guides/how-to-clean-uninstall-macos-apps-using-appcleaner-open-source-alternative) 106 | - [DharsanB](https://github.com/dharsanb) for sponsoring my Apple Developer account 107 | 108 | ## Other Apps 109 | 110 | [Pearcleaner](https://github.com/alienator88/Pearcleaner) - An opensource app cleaner with privacy in mind 111 | 112 | [Sentinel](https://github.com/alienator88/Sentinel) - A GUI for controlling gatekeeper status on your Mac 113 | 114 | [Viz](https://github.com/alienator88/Viz) - Utility for extracting text from images, videos, qr/barcodes 115 | 116 | [PearHID](https://github.com/alienator88/PearHID) - Remap your macOS keyboard with a simple SwiftUI frontend 117 | -------------------------------------------------------------------------------- /announcements.json: -------------------------------------------------------------------------------- 1 | { 2 | "4.4.0": "- Privileged Helper Service: Pearcleaner now supports using a privileged helper service to perform operations that require administrative permissions. This will eventually replace the current mechanism which uses Authorization Services as it's deprecated by Apple. More information in Settings > Helper tab.|- App Lipo: There's a new Lipo(beta) view available in the menu which will show all universal apps and strip the app binary of the unused architectures to save some space. Universal apps will also show a Lipo button on the App Details view next to the Uninstall button to perform the same procedure on each app individually.", 3 | "3.9.2": "- Multilingual Support: If you would like to help translate Pearcleaner to your language, please see GitHub issue #83", 4 | "3.7.0": "- App List Size Sorting: You can now sort the sidebar app list alphabetically or by descending size. Click the User/System header to toggle or from the searchbar menu|- Leftover Files: On first use, a warning/explanation sheet will be presented to the user", 5 | "3.5.0": "- Finder Extension: When enabled, you can right click an app in finder and uninstall with Pearcleaner directly|- Theme System: You can now select a base theme color for the application window", 6 | "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", 7 | "3.2.0": "- Menubar Item: You can choose to access Pearcleaner from the menubar. This will show a slightly modified Mini Mode view.", 8 | "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.", 9 | "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" 10 | } 11 | --------------------------------------------------------------------------------