├── .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 |
17 |
18 |
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 |
--------------------------------------------------------------------------------