Showing preview only (4,534K chars total). Download the full file or copy to clipboard to get everything.
Repository: alienator88/Pearcleaner
Branch: main
Commit: 3222dc8f305a
Files: 179
Total size: 15.0 MB
Directory structure:
gitextract_u4mjrmj6/
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug.md
│ │ ├── config.yml
│ │ ├── feature.md
│ │ └── project.md
│ └── workflows/
│ └── issues.yml
├── .gitignore
├── Builds/
│ ├── ExportOptions.plist
│ └── changes.md
├── FUNDING.yml
├── FinderOpen/
│ ├── Assets.xcassets/
│ │ ├── Contents.json
│ │ └── Glass.imageset/
│ │ └── Contents.json
│ ├── FinderOpen.entitlements
│ ├── FinderOpen.swift
│ ├── Info.plist
│ └── Localizable.xcstrings
├── LICENSE.md
├── Pear Resources/
│ ├── Glass.icon/
│ │ ├── Assets/
│ │ │ └── pear.heic
│ │ └── icon.json
│ └── Pear.psd
├── Pearcleaner/
│ ├── Logic/
│ │ ├── AppCommands.swift
│ │ ├── AppInfoFetch.swift
│ │ ├── AppPathsFetch.swift
│ │ ├── AppState.swift
│ │ ├── AppsUpdater/
│ │ │ ├── AppStoreReset.swift
│ │ │ ├── AppStoreUpdateChecker.swift
│ │ │ ├── AppStoreUpdater.swift
│ │ │ ├── HomebrewAdoption.swift
│ │ │ ├── HomebrewUpdateChecker.swift
│ │ │ ├── IOSAppInstaller.swift
│ │ │ ├── Models.swift
│ │ │ ├── PrivateFrameworks/
│ │ │ │ ├── CFBundle/
│ │ │ │ │ └── CFBundle_Private.h
│ │ │ │ ├── CommerceKit/
│ │ │ │ │ ├── CKDownloadDirectory.h
│ │ │ │ │ ├── CKDownloadQueue.h
│ │ │ │ │ ├── CKDownloadQueueObserver-Protocol.h
│ │ │ │ │ ├── CKPurchaseController.h
│ │ │ │ │ ├── CKServiceInterface.h
│ │ │ │ │ ├── CommerceKit.h
│ │ │ │ │ └── module.modulemap
│ │ │ │ └── StoreFoundation/
│ │ │ │ ├── ISAccountService-Protocol.h
│ │ │ │ ├── ISServiceProxy.h
│ │ │ │ ├── ISStoreAccount.h
│ │ │ │ ├── SSDownload.h
│ │ │ │ ├── SSDownloadMetadata.h
│ │ │ │ ├── SSDownloadPhase.h
│ │ │ │ ├── SSDownloadStatus.h
│ │ │ │ ├── SSPurchase.h
│ │ │ │ ├── SSPurchaseResponse.h
│ │ │ │ ├── StoreFoundation.h
│ │ │ │ └── module.modulemap
│ │ │ ├── SSPurchase+Extension.swift
│ │ │ ├── SparkleUpdateChecker.swift
│ │ │ ├── SparkleUpdateDriver.swift
│ │ │ ├── SparkleUpdateOperation.swift
│ │ │ ├── UpdateManager.swift
│ │ │ ├── UpdateQueue.swift
│ │ │ ├── UpdaterDebugLogger.swift
│ │ │ ├── UpdaterSettings.swift
│ │ │ └── VersionComparison.swift
│ │ ├── Brew/
│ │ │ ├── HomebrewAutoUpdateManager.swift
│ │ │ ├── HomebrewController.swift
│ │ │ ├── HomebrewManager.swift
│ │ │ ├── HomebrewPackage.swift
│ │ │ ├── HomebrewTap.swift
│ │ │ └── HomebrewUninstaller.swift
│ │ ├── CLI.swift
│ │ ├── Conditions.swift
│ │ ├── DeepLink.swift
│ │ ├── FileSearch/
│ │ │ ├── FileSearchLogic.swift
│ │ │ └── FileSearchModels.swift
│ │ ├── FuzzySearch.swift
│ │ ├── GlobalConsoleManager.swift
│ │ ├── HelperToolManager.swift
│ │ ├── KeychainPasswordManager.swift
│ │ ├── Lipo.swift
│ │ ├── Locations.swift
│ │ ├── Logic.swift
│ │ ├── PKG/
│ │ │ ├── PKBOM.h
│ │ │ ├── PKBundleComponent.h
│ │ │ ├── PKBundleComponentVersion.h
│ │ │ ├── PKComponent.h
│ │ │ ├── PKGManager.swift
│ │ │ ├── PKInstallHistory.h
│ │ │ ├── PKPackage.h
│ │ │ ├── PKPackageChecker.h
│ │ │ ├── PKPackageInfo.h
│ │ │ ├── PKProductInfo.h
│ │ │ ├── PKReceipt.h
│ │ │ └── Pearcleaner-Bridging-Header.h
│ │ ├── PasswordRequestHandler.swift
│ │ ├── ProcessEnv.swift
│ │ ├── ReversePathsFetch.swift
│ │ ├── TCC/
│ │ │ ├── TCCModels.swift
│ │ │ └── TCCQueryHelper.swift
│ │ ├── UndoHistoryManager.swift
│ │ ├── UndoManager.swift
│ │ └── Utilities.swift
│ ├── PearcleanerApp.swift
│ ├── Resources/
│ │ ├── Assets.xcassets/
│ │ │ ├── AccentColor.colorset/
│ │ │ │ └── Contents.json
│ │ │ └── Contents.json
│ │ ├── Glass.icon/
│ │ │ ├── Assets/
│ │ │ │ └── pear.heic
│ │ │ └── icon.json
│ │ ├── Info.plist
│ │ ├── Localizable.xcstrings
│ │ ├── Pearcleaner.entitlements
│ │ └── askpass.sh
│ ├── Style/
│ │ ├── ChromeBorder.swift
│ │ ├── CircularProgressView.swift
│ │ ├── ControlGroupChrome.swift
│ │ ├── PearGroupBox.swift
│ │ ├── SparkleProgressBar.swift
│ │ ├── Styles.swift
│ │ └── Theme.swift
│ ├── Views/
│ │ ├── AppsUpdaterView/
│ │ │ ├── AdoptionSheetView.swift
│ │ │ ├── AppsUpdaterView.swift
│ │ │ ├── CaskAdoptionContentView.swift
│ │ │ ├── ExpandableActionButton.swift
│ │ │ ├── UpdateDetailView.swift
│ │ │ ├── UpdateRowViewSidebar.swift
│ │ │ └── UpdaterDetailsSidebar.swift
│ │ ├── AppsView/
│ │ │ ├── AppListItems.swift
│ │ │ ├── AppSearchView.swift
│ │ │ ├── AppsListView.swift
│ │ │ └── GridAppItem.swift
│ │ ├── Brew/
│ │ │ ├── AutoUpdateSection.swift
│ │ │ ├── HomebrewView.swift
│ │ │ ├── LogViewerSheet.swift
│ │ │ ├── MaintenanceSection.swift
│ │ │ ├── PackageDetailsSidebar.swift
│ │ │ ├── SearchInstallSection.swift
│ │ │ └── TapManagementSection.swift
│ │ ├── Components/
│ │ │ ├── BadgeOverlay.swift
│ │ │ ├── GlobalConsoleView.swift
│ │ │ ├── PermissionsSheetView.swift
│ │ │ ├── SidebarDetailView/
│ │ │ │ ├── GenericSidebarListView.swift
│ │ │ │ └── SidebarDetailLayout.swift
│ │ │ ├── StandardSheetView.swift
│ │ │ └── TCCPermissionViewer.swift
│ │ ├── DaemonView.swift
│ │ ├── DeleteHistoryView.swift
│ │ ├── DevelopmentView.swift
│ │ ├── FileSearchView.swift
│ │ ├── FilesView/
│ │ │ ├── FileCategory.swift
│ │ │ ├── FileListView.swift
│ │ │ ├── FilesSidebarView.swift
│ │ │ ├── FilesView.swift
│ │ │ └── TranslationSelectionSheet.swift
│ │ ├── LipoView/
│ │ │ ├── LipoSidebarView.swift
│ │ │ └── LipoView.swift
│ │ ├── MainWindow.swift
│ │ ├── PackageView.swift
│ │ ├── PluginsView.swift
│ │ ├── Settings/
│ │ │ ├── About.swift
│ │ │ ├── Folders.swift
│ │ │ ├── General.swift
│ │ │ ├── Helper.swift
│ │ │ ├── Interface.swift
│ │ │ ├── SettingsWindow.swift
│ │ │ └── Update.swift
│ │ └── ZombieView/
│ │ ├── ZombieSidebarView.swift
│ │ └── ZombieView.swift
│ └── announcements.json
├── 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
├── PearcleanerHelper/
│ ├── CodesignCheck.swift
│ ├── com.alienator88.Pearcleaner.PearcleanerHelper.plist
│ └── main.swift
├── PearcleanerSentinel/
│ ├── FileWatcher.swift
│ ├── com.alienator88.PearcleanerSentinel.plist
│ └── main.swift
├── README.md
├── Shared/
│ └── AppGroupDefaults.swift
└── announcements.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/ISSUE_TEMPLATE/bug.md
================================================
---
name: 🐛 Bug Report
about: For submitting new bugs related to the application functionality
title: "[BUG] ENTER ISSUE TITLE HERE"
labels: 'bug'
assignees: ''
---
<!--
WARNING: Issues with the template below removed will be closed!
-->
<!--
Thanks for helping make Pearcleaner better! Before you submit your issue, please make sure you follow the list below and check the appropriate boxes by putting an x inside the [ ]: [x]
-->
### New Issue Checklist
- [ ] I updated Pearcleaner to the latest version and still observe the issue
- [ ] I searched for [existing GitHub issues](https://github.com/alienator88/pearcleaner/issues)
- [ ] OS Version: [e.g. 13.0]
- [ ] Pearcleaner Version: [e.g. 3.x.x]
---
### Issue:
<!-- A clear and concise description of what the bug is -->
#### Steps:
<!-- Provide step by step directions on how the issue is reproduced -->
#### Screenshots:
<!-- If applicable, add screenshots to show the bug -->
<!-- If your issue has logging that might help, you can grab them via the steps below -->
<!--
1. While Pearcleaner is running, push `CMD+D` to open the debug console and show captured logs
2. If relevant logs are seen in the console, copy them below
-->
#### Debug Console
<pre> [REPLACE THIS TEXT BETWEEN THE PRE TAGS WITH YOUR LOGS] </pre>
---
<!--
1. Open the Terminal app and run the following command:
log stream --level debug --style compact --predicate 'subsystem == "com.alienator88.Pearcleaner"'
2. Launch Pearcleaner to reproduce the startup issue
3. Copy the logs below from Terminal
-->
#### Console Logs
<pre> [REPLACE THIS TEXT BETWEEN THE PRE TAGS WITH YOUR LOGS] </pre>
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
- name: PEARCLEANER IS NOW IN MAINTENANCE MODE
url: https://github.com/alienator88/Pearcleaner/blob/main/.github/ISSUE_TEMPLATE/project.md
about: New feature requests will be ignored/closed. Click here for more details.
- name: ❓ Ask a Question
url: https://github.com/alienator88/Pearcleaner/discussions/new?category=general
about: Ask the community for help
================================================
FILE: .github/ISSUE_TEMPLATE/feature.md
================================================
<!--
---
name: 🌟 Feature Request
about: For submitting new feature ideas
title: "[FR] ENTER TITLE HERE"
labels: 'enhancement'
assignees: ''
---
-->
<!--
WARNING: Issues with the template below removed will be closed!
-->
<!--
Thanks for helping make Pearcleaner better! Before you submit your issue, please make sure you follow the list below and check the appropriate boxes by putting an x inside the [ ]: [x]
-->
### New Feature Checklist
- [ ] I searched for existing open/closed feature requests and found none related to this request
- [ ] My request is not design/opinion based <!-- I'm fairly opinionated on the design -->
- [ ] My request will be beneficial to most users <!-- Fringe/edge-case requests will likely not be accepted -->
---
### Request:
<!-- Explain clearly and in detail -->
#### Desired Solution:
<!-- Describe how this should be implemented -->
#### Additional Context:
<!-- Add any other context or screenshots that might help. -->
================================================
FILE: .github/ISSUE_TEMPLATE/project.md
================================================
This project is now in **maintenance mode**.
---
I’m no longer accepting **NEW** feature requests as Pearcleaner has grown to be fairly feature rich,
and I want to avoid it ballooning further in size or scope. Keeping the project stable, focused, and maintainable is now the priority. Going forward, the focus will be on:
- Bug fixes
- Regressions
- Small improvements to already existing features
Issues related to bugs are still welcome, and reasonable pull requests that focus on fixes or reliability
improvements will still be reviewed. New feature requests will be ignored/closed if submitted.
Thanks to everyone who has used the project, filed issues, and contributed over time!
================================================
FILE: .github/workflows/issues.yml
================================================
name: Close empty issues and templates
on:
issues:
types:
- reopened
- opened
- edited
jobs:
closeEmptyIssuesAndTemplates:
name: Close empty issues and templates
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3 # NOTE: Retrieve issue templates.
- name: Run empty issues closer action
uses: rickstaa/empty-issues-closer-action@v1
env:
github_token: ${{ secrets.GITHUB_TOKEN }}
with:
close_comment: Closing this issue because it appears to be empty. Please update the issue for it to be reopened.
open_comment: Reopening this issue because the author provided more information.
check_templates: true
template_close_comment: Closing this issue since the issue template was not filled in. Please provide us with more information to have this issue reopened.
template_open_comment: Reopening this issue because the author provided more information.
================================================
FILE: .gitignore
================================================
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# Accio dependency management
Dependencies/
.accio/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Code Injection
#
# After new code Injection tools there's a generated folder /iOSInjectionProject
# https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/
.DS_Store
Builds/Export
.claude
Pearcleaner/Resources/Localizable.xcstrings.bak
================================================
FILE: Builds/ExportOptions.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>mac-application</string>
<key>teamID</key>
<string>BK8443AXLU</string>
<key>signingCertificate</key>
<string>Developer ID Application: Marius Lupascu (BK8443AXLU)</string>
<key>signingStyle</key>
<string>manual</string>
<key>stripSwiftSymbols</key>
<true/>
<key>destination</key>
<string>export</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<true/>
<key>thinning</key>
<string><none></string>
<key>embedOnDemandResourcesAssetPacksInBundle</key>
<true/>
<key>generateAppStoreInformation</key>
<false/>
<key>testFlightInternalTestingOnly</key>
<false/>
</dict>
</plist>
================================================
FILE: Builds/changes.md
================================================
### What's New
- [x] Ability to hide unused utility pages from global menu in Settings > Interface > Startup View - #476
- [x] "Update All" button now shows progress in toolbar for Updater view
- [x] Add pinyin sorting to homebrew view and updater view - #479
- [x] Warn if updating MAS app with a different source as it can break MAS connection - #484
### Fixes
- [x] Fix pear list-orphaned cli command - #472
- [x] Fix owner of receipt to root:wheel for installd workaround - #475
- [x] Add contentShape around sidebar toggle icons - #485
- [x] Fix computed property not refreshing action buttons in some cases for Updater view
- [x] Reorder some layout components in Updater view
- [x] Translations
================================================
FILE: FUNDING.yml
================================================
github: alienator88
================================================
FILE: FinderOpen/Assets.xcassets/Contents.json
================================================
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: FinderOpen/Assets.xcassets/Glass.imageset/Contents.json
================================================
{
"images" : [
{
"filename" : "Glass.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: FinderOpen/FinderOpen.entitlements
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.alienator88.Pearcleaner</string>
</array>
<key>com.apple.security.temporary-exception.files.absolute-path.read-only</key>
<array>
<string>/</string>
</array>
</dict>
</plist>
================================================
FILE: FinderOpen/FinderOpen.swift
================================================
//
// FinderSync.swift
// FinderOpen
//
// Created by Alin Lupascu on 4/11/24.
//
import Cocoa
import FinderSync
class FinderOpen: FIFinderSync {
override init() {
super.init()
NSLog("FinderSync() launched from %@", Bundle.main.bundlePath as NSString)
// Set the directory URLs that the Finder Sync extension observes
FIFinderSyncController.default().directoryURLs = Set([URL(fileURLWithPath: "/")])
}
override func menu(for menuKind: FIMenuKind) -> NSMenu {
let menu = NSMenu(title: "")
// Ensure we are dealing with the contextual menu for items
if menuKind == .contextualMenuForItems {
// Get the selected items
if let selectedItemURLs = FIFinderSyncController.default().selectedItemURLs(),
selectedItemURLs.count == 1, selectedItemURLs.first?.pathExtension == "app" {
// Add menu item if the selected item is a .app file
let menuItem = NSMenuItem(title: String(localized: "Pearcleaner Uninstall"), action: #selector(openInMyApp), keyEquivalent: "")
// Add icon if enabled in main app
if UserDefaults.showAppIconInMenu {
if let appIcon = NSApp.applicationIconImage {
appIcon.size = NSSize(width: 16, height: 16)
menuItem.image = appIcon
} else if let fallbackIcon = NSImage(named: "Glass") {
fallbackIcon.size = NSSize(width: 16, height: 16)
menuItem.image = fallbackIcon
}
}
menu.addItem(menuItem)
}
}
// Return the menu (which may be empty if the conditions are not met)
return menu
}
@objc func openInMyApp(_ sender: AnyObject?) {
// Get the selected items (files/folders) in Finder
guard let selectedItems = FIFinderSyncController.default().selectedItemURLs(), !selectedItems.isEmpty else {
return
}
// Consider only the first selected item
let firstSelectedItem = selectedItems[0]
let path = firstSelectedItem.path
NSWorkspace.shared.open(URL(string: "pear://com.alienator88.Pearcleaner?path=\(path)")!)
}
}
================================================
FILE: FinderOpen/Info.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict/>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.FinderSync</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).FinderOpen</string>
</dict>
</dict>
</plist>
================================================
FILE: FinderOpen/Localizable.xcstrings
================================================
{
"sourceLanguage" : "en",
"strings" : {
"Pearcleaner Uninstall" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pearcleaner Deinstallation"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Desinstalar Pearcleaner"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Désinstaller avec Pearcleaner"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hapus Instalasi Pearlcleaner"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pearcleanerでアンインストール"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Odinstaluj za pomocą Pearcleaner"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Desinstalar o Pearcleaner"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Удалить через Pearcleaner"
}
},
"sk" : {
"stringUnit" : {
"state" : "translated",
"value" : "Odinštalovať Pearcleanerom"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pearcleaner ile Kaldırma"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "Видалити через Pearcleaner"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Gỡ cài đặt Pearcleaner"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pearcleaner 卸载"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pearcleaner 解除安裝"
}
},
"zh-HK" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pearcleaner 解除安裝"
}
}
}
}
},
"version" : "1.0"
}
================================================
FILE: LICENSE.md
================================================
“Commons Clause” License Condition v1.0
The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software.
For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice.
Software: Pearcleaner
License: Apache 2.0 with Commons Clause
Licensor: alienator88
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: Pear Resources/Glass.icon/icon.json
================================================
{
"fill" : {
"linear-gradient" : [
"display-p3:0.93159,0.94081,0.94081,1.00000",
"display-p3:0.75900,0.75900,0.75900,1.00000"
]
},
"groups" : [
{
"layers" : [
{
"blend-mode" : "normal",
"glass-specializations" : [
{
"value" : false
},
{
"appearance" : "dark",
"value" : true
},
{
"appearance" : "tinted",
"value" : true
}
],
"hidden" : false,
"image-name" : "pear.heic",
"name" : "pear",
"position" : {
"scale" : 1.5,
"translation-in-points" : [
11.3046875,
3.296875
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"squares" : [
"macOS"
]
}
}
================================================
FILE: Pear Resources/Pear.psd
================================================
[File too large to display: 10.7 MB]
================================================
FILE: Pearcleaner/Logic/AppCommands.swift
================================================
//
// AppCommands.swift
// Pearcleaner
//
// Created by Alin Lupascu on 10/31/23.
//
import SwiftUI
import AlinFoundation
struct AppCommands: Commands {
let appState: AppState
let locations: Locations
let fsm: FolderSettingsManager
let updater: Updater
@AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true
@AppStorage("settings.general.selectedTab") private var selectedTab: CurrentTabView = .general
@State private var windowController = WindowManager()
@ObservedObject private var debugLogger = UpdaterDebugLogger.shared
init(appState: AppState, locations: Locations, fsm: FolderSettingsManager, updater: Updater) {
self.appState = appState
self.locations = locations
self.fsm = fsm
self.updater = updater
}
var body: some Commands {
// Pearcleaner Menu
CommandGroup(replacing: .appInfo) {
Button {
openAppSettingsWindow(tab: .about, updater: updater)
} label: {
Label("About \(Bundle.main.name)", systemImage: "info.circle.fill")
}
Divider()
Button {
openAppSettingsWindow(updater: updater)
} label: {
Label("Settings", systemImage: "gearshape")
}
.keyboardShortcut(",", modifiers: .command)
Button {
updater.checkForUpdates(sheet: true, force: true)
} label: {
Label("Check for Updates", systemImage: "tray.and.arrow.down.fill")
}
.keyboardShortcut("u", modifiers: .command)
Button {
showCustomAlert(
title: "Warning!",
message: "Pearcleaner and all of its files will be cleanly removed, are you sure?",
okText: "Uninstall",
style: .warning,
onOk: {
uninstallPearcleaner(appState: appState, locations: locations)
}
)
} label: {
Label("Uninstall Pearcleaner", systemImage: "trash.fill")
}
}
// Edit Menu
CommandGroup(replacing: .undoRedo) {
Button
{
let result = undoTrash()
if result {
if appState.currentPage == .plugins {
// For plugins view, post notification to refresh
NotificationCenter.default.post(name: NSNotification.Name("PluginsViewShouldRefresh"), object: nil)
} else if appState.currentPage == .fileSearch {
// For file search view, post notification to undo (has unique cache logic)
NotificationCenter.default.post(name: NSNotification.Name("FileSearchViewShouldUndo"), object: nil)
} else if appState.currentPage == .orphans {
// For orphans view, post notification to refresh
NotificationCenter.default.post(name: NSNotification.Name("ZombieViewShouldRefresh"), object: nil)
} else if appState.currentPage == .packages {
// For packages view, post notification to refresh
NotificationCenter.default.post(name: NSNotification.Name("PackagesViewShouldRefresh"), object: nil)
} else if appState.currentPage == .development {
// For development view, post notification to refresh
NotificationCenter.default.post(name: NSNotification.Name("DevelopmentViewShouldRefresh"), object: nil)
} else {
loadApps(folderPaths: fsm.folderPaths)
// After reload, if we're viewing files, refresh the file view
if appState.currentView == .files {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 500_000_000)
showAppInFiles(appInfo: appState.appInfo, appState: appState, locations: locations)
}
}
}
}
} label: {
Label("Undo Removal", systemImage: "clear")
}
.keyboardShortcut("z", modifiers: .command)
.disabled(!FileManagerUndo.shared.undoManager.canUndo)
Divider()
Button {
appState.showDeleteHistory = true
} label: {
Label("Delete History", systemImage: "clock.arrow.circlepath")
}
.keyboardShortcut("y", modifiers: [.command, .shift])
.disabled(UndoHistoryManager.shared.history.isEmpty)
}
// Window Menu
CommandGroup(after: .sidebar) {
Menu {
Button
{
appState.currentPage = .applications
} label: {
Text("Applications")
}
.keyboardShortcut("1", modifiers: .command)
Button
{
appState.currentPage = .development
} label: {
Text("Development")
}
.keyboardShortcut("2", modifiers: .command)
Button
{
appState.currentPage = .fileSearch
} label: {
Text("File Search")
}
.keyboardShortcut("3", modifiers: .command)
Button
{
appState.currentPage = .homebrew
} label: {
Text("Homebrew")
}
.keyboardShortcut("4", modifiers: .command)
Button
{
appState.currentPage = .lipo
} label: {
Text("App Lipo")
}
.keyboardShortcut("5", modifiers: .command)
Button
{
appState.currentPage = .orphans
} label: {
Text("Orphaned Files")
}
.keyboardShortcut("6", modifiers: .command)
Button
{
appState.currentPage = .packages
} label: {
Text("Packages")
}
.keyboardShortcut("7", modifiers: .command)
Button
{
appState.currentPage = .plugins
} label: {
Text("Plugins")
}
.keyboardShortcut("8", modifiers: .command)
Button
{
appState.currentPage = .services
} label: {
Text("Services")
}
.keyboardShortcut("9", modifiers: .command)
Button
{
appState.currentPage = .updater
} label: {
Text("Updater")
}
.keyboardShortcut("0", modifiers: .command)
} label: {
Label("Navigate To", systemImage: "location.north.fill")
}
}
// Tools Menu
CommandMenu(Text("Tools", comment: "Tools Menu")) {
Button {
Task { @MainActor in
switch appState.currentPage {
case .applications:
if appState.currentView == .files {
// User is viewing an app's files - refresh the files list
let currentAppInfo = appState.appInfo
updateOnMain {
appState.selectedItems = []
}
withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) {
showAppInFiles(appInfo: currentAppInfo, appState: appState, locations: locations)
}
} else {
// User is on empty view or app list - refresh the app list
withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) {
// Flush bundle caches before reloading to ensure fresh version info
flushBundleCaches(for: appState.sortedApps)
loadApps(folderPaths: fsm.folderPaths)
}
}
case .development:
NotificationCenter.default.post(name: NSNotification.Name("DevelopmentViewShouldRefresh"), object: nil)
case .fileSearch:
NotificationCenter.default.post(name: NSNotification.Name("FileSearchViewShouldRefresh"), object: nil)
case .homebrew:
NotificationCenter.default.post(name: NSNotification.Name("HomebrewViewShouldRefresh"), object: nil)
case .lipo:
NotificationCenter.default.post(name: NSNotification.Name("LipoViewShouldRefresh"), object: nil)
case .orphans:
NotificationCenter.default.post(name: NSNotification.Name("ZombieViewShouldRefresh"), object: nil)
case .packages:
NotificationCenter.default.post(name: NSNotification.Name("PackagesViewShouldRefresh"), object: nil)
case .plugins:
NotificationCenter.default.post(name: NSNotification.Name("PluginsViewShouldRefresh"), object: nil)
case .services:
NotificationCenter.default.post(name: NSNotification.Name("DaemonViewShouldRefresh"), object: nil)
case .updater:
NotificationCenter.default.post(name: NSNotification.Name("UpdaterViewShouldRefresh"), object: nil)
}
}
} label: {
Label("Refresh", systemImage: "arrow.counterclockwise.circle")
}
.keyboardShortcut("r", modifiers: .command)
Button
{
if !appState.selectedItems.isEmpty {
createTarArchive(appState: appState)
}
} label: {
Label("Bundle Files...", systemImage: "archivebox")
}
.keyboardShortcut("b", modifiers: .command)
.disabled(appState.selectedItems.isEmpty)
Button
{
if !appState.appInfo.bundleIdentifier.isEmpty {
saveURLsToFile(appState: appState)
}
} label: {
Label("Export File Paths...", systemImage: "square.and.arrow.up")
}
.keyboardShortcut("e", modifiers: .command)
.disabled(appState.selectedItems.isEmpty)
Button
{
if !appState.appInfo.bundleIdentifier.isEmpty {
saveURLsToFile(appState: appState, copy: true)
}
} label: {
Label("Copy File Paths", systemImage: "square.and.arrow.up")
}
.keyboardShortcut("c", modifiers: [.command, .option])
.disabled(appState.selectedItems.isEmpty)
}
CommandGroup(after: .help) {
// Debug options
Button {
withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) {
windowController.open(with: ConsoleView(), width: 600, height: 400)
}
} label: {
Label("Debug Console", systemImage: "ladybug")
}
.keyboardShortcut("d", modifiers: .command)
Button {
// Export debug info to file
exportDebugInfo(appState: appState)
} label: {
Label("Export Debug Info...", systemImage: "info.circle")
}
.keyboardShortcut("i", modifiers: [.command, .shift])
// Updater debug log export (only visible on Updater page)
if appState.currentPage == .updater {
Button {
exportUpdaterDebugInfo()
} label: {
Label("Export Updater Debug Log...", systemImage: "arrow.triangle.2.circlepath.circle")
}
.keyboardShortcut("u", modifiers: [.command, .shift])
.disabled(!debugLogger.hasLogs)
}
Divider()
// GitHub Menu
Button
{
NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner")!)
} label: {
Label("View Repository", systemImage: "paperplane")
}
Button
{
NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner/releases")!)
} label: {
Label("View Releases", systemImage: "paperplane")
}
Button
{
NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner/issues")!)
} label: {
Label("View Issues", systemImage: "paperplane")
}
Divider()
Button
{
NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner/issues/new/choose")!)
} label: {
Label("Submit New Issue", systemImage: "paperplane")
}
}
}
}
================================================
FILE: Pearcleaner/Logic/AppInfoFetch.swift
================================================
//
// AppInfoFetch.swift
// Pearcleaner
//
// Created by Alin Lupascu on 3/20/24.
//
import Foundation
import SwiftUI
import AlinFoundation
// MARK: - Helper Functions
/// Read Info.plist directly from disk without using Bundle cache
/// This is useful when Bundle(url:) returns nil due to macOS not yet indexing a newly installed app
private func readInfoPlistDirect(at appPath: URL) -> [String: Any]? {
let infoPlistURL = appPath.appendingPathComponent("Contents/Info.plist")
return NSDictionary(contentsOf: infoPlistURL) as? [String: Any]
}
// Metadata-based AppInfo Fetcher Class
class MetadataAppInfoFetcher {
static func getAppInfo(fromMetadata metadata: [String: Any], atPath path: URL) -> AppInfo? {
// Extract metadata attributes for known fields
var displayName = metadata["kMDItemDisplayName"] as? String ?? ""
displayName = displayName.replacingOccurrences(of: ".app", with: "")
let fsName = metadata["kMDItemFSName"] as? String ?? path.lastPathComponent
let appName = displayName.isEmpty ? fsName : displayName
let bundleIdentifier = metadata["kMDItemCFBundleIdentifier"] as? String ?? ""
// Get version and build number directly from bundle Info.plist instead of metadata (always up-to-date)
let (version, buildNumber): (String, String?) = {
// Try Bundle(url:) first
if let bundle = Bundle(url: path) {
// Extract marketing version (CFBundleShortVersionString), fallback to CFBundleVersion if missing
let shortVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let buildVer = bundle.infoDictionary?["CFBundleVersion"] as? String ?? ""
// Use shortVersion if available, otherwise use buildVersion
let marketingVersion = shortVersion.isEmpty ? buildVer : shortVersion
// Build number is CFBundleVersion (only set if different from marketing version)
let build = shortVersion.isEmpty ? nil : buildVer
return (marketingVersion, build)
}
// Fallback: Read Info.plist directly from disk (useful for newly installed apps)
if let infoDict = readInfoPlistDirect(at: path) {
let shortVersion = infoDict["CFBundleShortVersionString"] as? String ?? ""
let buildVer = infoDict["CFBundleVersion"] as? String ?? ""
let marketingVersion = shortVersion.isEmpty ? buildVer : shortVersion
let build = shortVersion.isEmpty ? nil : buildVer
return (marketingVersion, build)
}
return ("", nil)
}()
// Size
let logicalSize = metadata["kMDItemLogicalSize"] as? Int64 ?? 0
// Extract optional date fields early so we can pass them to fallback if needed
let creationDate = metadata["kMDItemFSCreationDate"] as? Date
let contentChangeDate = metadata["kMDItemFSContentChangeDate"] as? Date
let lastUsedDate = metadata["kMDItemLastUsedDate"] as? Date
let dateAdded = metadata["kMDItemDateAdded"] as? Date
// Check if any of the critical fields are missing or invalid
// Note: Size can be 0 for some apps where Spotlight hasn't indexed size yet, so only require core identifiers
if appName.isEmpty || bundleIdentifier.isEmpty || version.isEmpty {
// Fallback to the regular AppInfoFetcher for this app, but preserve dates we extracted from metadata
return AppInfoFetcher.getAppInfo(atPath: path, dates: (creationDate, contentChangeDate, lastUsedDate, dateAdded))
}
// Determine architecture type
let arch = checkAppBundleArchitecture(at: path.path)
// Use similar helper functions as `AppInfoFetcher` for attributes not found in metadata
let wrapped = AppInfoFetcher.isDirectoryWrapped(path: path)
let appIcon = AppInfoUtils.fetchAppIcon(for: path, wrapped: wrapped, md: true)
let webApp = AppInfoUtils.isWebApp(appPath: path)
let system = !path.path.contains(NSHomeDirectory())
// Get cask metadata (includes cask name and auto_updates flag)
// Pass both display name, path, and bundle ID to handle various matching scenarios
let caskInfo = getCaskInfo(for: appName, appPath: path, bundleId: bundleIdentifier)
let cask = caskInfo?.caskName
let autoUpdates = caskInfo?.autoUpdates
// Get entitlements for the app
let entitlements = getEntitlements(for: path.path)
let teamIdentifier = getTeamIdentifier(for: path.path)
// Detect update sources (done at load time for performance)
let bundle = Bundle(url: path)
let hasSparkle = AppCategoryDetector.checkForSparkle(bundle: bundle, infoDict: bundle?.infoDictionary)
let isAppStore = AppCategoryDetector.checkForAppStore(bundle: bundle, path: path, wrapped: wrapped)
// Extract App Store adamID from metadata (if available)
let adamID: UInt64? = {
if let adamValue = metadata["kMDItemAppStoreAdamID"] {
// Handle NSNumber conversion
if let number = adamValue as? NSNumber {
return number.uint64Value
}
// Handle direct UInt64
if let uint = adamValue as? UInt64 {
return uint
}
}
return nil
}()
return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName,
appVersion: version, appBuildNumber: buildNumber, appIcon: appIcon, webApp: webApp, wrapped: wrapped, system: system,
arch: arch, cask: cask, steam: false, hasSparkle: hasSparkle, isAppStore: isAppStore, adamID: adamID, autoUpdates: autoUpdates, bundleSize: logicalSize, fileSize: [:],
fileIcon: [:], creationDate: creationDate, contentChangeDate: contentChangeDate, lastUsedDate: lastUsedDate, dateAdded: dateAdded, entitlements: entitlements, teamIdentifier: teamIdentifier)
}
// MARK: - Phase 1: Fast Loading with AppInfoMini
/// Fast lightweight app info loading for initial display
/// Skips expensive operations: arch detection, entitlements, cask lookup, team identifier
/// Always calculates bundleSize (required for sorting)
static func getAppInfoMini(fromMetadata metadata: [String: Any], atPath path: URL) -> AppInfoMini? {
// Extract basic info from metadata
var displayName = metadata["kMDItemDisplayName"] as? String ?? ""
displayName = displayName.replacingOccurrences(of: ".app", with: "")
let fsName = metadata["kMDItemFSName"] as? String ?? path.lastPathComponent
let appName = displayName.isEmpty ? fsName : displayName
let bundleIdentifier = metadata["kMDItemCFBundleIdentifier"] as? String ?? ""
// Get version from bundle Info.plist
let version: String = {
if let bundle = Bundle(url: path) {
let shortVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let buildVer = bundle.infoDictionary?["CFBundleVersion"] as? String ?? ""
return shortVersion.isEmpty ? buildVer : shortVersion
}
if let infoDict = readInfoPlistDirect(at: path) {
let shortVersion = infoDict["CFBundleShortVersionString"] as? String ?? ""
let buildVer = infoDict["CFBundleVersion"] as? String ?? ""
return shortVersion.isEmpty ? buildVer : shortVersion
}
return ""
}()
// Require critical fields
if appName.isEmpty || bundleIdentifier.isEmpty || version.isEmpty {
return nil
}
// Get app icon (required for display, fast enough ~5-10ms)
let wrapped = AppInfoFetcher.isDirectoryWrapped(path: path)
let appIcon = AppInfoUtils.fetchAppIcon(for: path, wrapped: wrapped, md: true)
// Determine if system app
let system = !path.path.contains(NSHomeDirectory())
// Get bundleSize - ALWAYS calculate, never 0 (required for sorting)
let bundleSize: Int64 = {
// Try mdls metadata first (fast)
if let mdlsSize = metadata["kMDItemLogicalSize"] as? Int64, mdlsSize > 0 {
return mdlsSize
}
// Fallback: Calculate using totalSizeOnDisk (same as AppInfoFetcher)
return totalSizeOnDisk(for: path)
}()
// Extract date fields from metadata
let creationDate = metadata["kMDItemFSCreationDate"] as? Date
let contentChangeDate = metadata["kMDItemFSContentChangeDate"] as? Date
let lastUsedDate = metadata["kMDItemLastUsedDate"] as? Date
let dateAdded = metadata["kMDItemDateAdded"] as? Date
return AppInfoMini(
id: UUID(),
path: path,
bundleIdentifier: bundleIdentifier,
appName: appName,
appVersion: version,
appIcon: appIcon,
system: system,
bundleSize: bundleSize, // ✅ Always calculated
creationDate: creationDate,
contentChangeDate: contentChangeDate,
lastUsedDate: lastUsedDate,
dateAdded: dateAdded
)
}
// MARK: - Phase 2: Upgrade to Full AppInfo
/// Upgrade AppInfoMini to full AppInfo with all expensive properties
/// Runs expensive operations: arch, entitlements, cask lookup, team identifier
static func upgradeToFullAppInfo(mini: AppInfoMini) -> AppInfo {
let path = mini.path
// Get bundle for further inspection
let bundle = Bundle(url: path)
let infoDict = bundle?.infoDictionary ?? readInfoPlistDirect(at: path)
// NOW do the expensive operations that were skipped in Phase 1
// Architecture detection (expensive: reads Mach-O binary headers)
let arch = checkAppBundleArchitecture(at: path.path)
// Entitlements scanning (expensive: code signing + recursive bundle scan)
let entitlements = getEntitlements(for: path.path)
// Team identifier (expensive: code signing read)
let teamIdentifier = getTeamIdentifier(for: path.path)
// Cask lookup (expensive: first-time builds entire lookup table)
let caskInfo = getCaskInfo(for: mini.appName, appPath: path, bundleId: mini.bundleIdentifier)
let cask = caskInfo?.caskName
let autoUpdates = caskInfo?.autoUpdates
// Detect app properties
let wrapped = AppInfoFetcher.isDirectoryWrapped(path: path)
let webApp = AppInfoUtils.isWebApp(appPath: path)
// Detect update sources
let hasSparkle = AppCategoryDetector.checkForSparkle(bundle: bundle, infoDict: infoDict)
let isAppStore = AppCategoryDetector.checkForAppStore(bundle: bundle, path: path, wrapped: wrapped)
// Get build number
let appBuildNumber: String? = {
if let dict = infoDict {
let shortVersion = dict["CFBundleShortVersionString"] as? String ?? ""
let buildVer = dict["CFBundleVersion"] as? String ?? ""
return shortVersion.isEmpty ? nil : buildVer
}
return nil
}()
// Steam detection (not implemented in fast path, defaults to false)
let steam = false
// Adam ID (App Store ID) - would need to query mdls again, skip for now
let adamID: UInt64? = nil
return AppInfo(
id: mini.id,
path: mini.path,
bundleIdentifier: mini.bundleIdentifier,
appName: mini.appName,
appVersion: mini.appVersion,
appBuildNumber: appBuildNumber,
appIcon: mini.appIcon,
webApp: webApp,
wrapped: wrapped,
system: mini.system,
arch: arch, // ✅ Phase 2 populated
cask: cask, // ✅ Phase 2 populated
steam: steam,
hasSparkle: hasSparkle, // ✅ Phase 2 populated
isAppStore: isAppStore, // ✅ Phase 2 populated
adamID: adamID,
autoUpdates: autoUpdates,
bundleSize: mini.bundleSize, // Keep from Phase 1
lipoSavings: nil,
fileSize: [:], // Populated when user selects app
fileIcon: [:], // Populated when user selects app
creationDate: mini.creationDate,
contentChangeDate: mini.contentChangeDate,
lastUsedDate: mini.lastUsedDate,
dateAdded: mini.dateAdded,
entitlements: entitlements, // ✅ Phase 2 populated
teamIdentifier: teamIdentifier // ✅ Phase 2 populated
)
}
}
// MARK: - Update Source Detection Helpers
class AppCategoryDetector {
/// Check if app has Sparkle update framework
/// Detects Sparkle by checking for common Info.plist keys
/// Excludes SetApp apps (they use Sparkle but are managed by SetApp)
static func checkForSparkle(bundle: Bundle?, infoDict: [String: Any]?) -> Bool {
guard let dict = infoDict ?? bundle?.infoDictionary else { return false }
// Check for common Sparkle keys
let hasSparkleKeys = dict["SUFeedURL"] != nil ||
dict["SUFeedUrl"] != nil ||
dict["SUPublicEDKey"] != nil ||
dict["SUPublicDSAKeyFile"] != nil ||
dict["SUEnableAutomaticChecks"] != nil
// Exclude SetApp apps (they use Sparkle but are managed by SetApp)
if hasSparkleKeys && isSetAppApp(bundle: bundle, infoDict: dict) {
return false
}
return hasSparkleKeys
}
/// Check if app is a SetApp-managed app
/// SetApp requires all apps to use the "-setapp" bundle ID suffix
static func isSetAppApp(bundle: Bundle?, infoDict: [String: Any]?) -> Bool {
// Try to get bundle ID from bundle first, then from infoDict
if let bundleID = bundle?.bundleIdentifier ?? infoDict?["CFBundleIdentifier"] as? String {
return bundleID.hasSuffix("-setapp")
}
return false
}
/// Check if app is from App Store
/// Detects by checking for receipt or iTunes metadata
static func checkForAppStore(bundle: Bundle?, path: URL, wrapped: Bool) -> Bool {
// Check for wrapped iPad/iOS app first
if wrapped {
// Determine if path is wrapped or not
// No wrapper: /Applications/App.app
// With wrapper: /Applications/App.app/Wrapper/App.app
let wrapperDir = path.appendingPathComponent("Wrapper")
let isOuterWrapper = FileManager.default.fileExists(atPath: wrapperDir.path)
// Use path directly if it's the outer wrapper, otherwise go up two levels to find it
let outerWrapperPath = isOuterWrapper ? path : path.deletingLastPathComponent().deletingLastPathComponent()
let iTunesMetadataPath = outerWrapperPath.appendingPathComponent("Wrapper/iTunesMetadata.plist").path
if FileManager.default.fileExists(atPath: iTunesMetadataPath) {
return true
}
}
// Check for traditional Mac app receipt
guard let receiptPath = bundle?.appStoreReceiptURL?.path else { return false }
return FileManager.default.fileExists(atPath: receiptPath)
}
}
class AppInfoUtils {
/// Determines if the app is a web application by directly reading its `Info.plist` using the app path.
static func isWebApp(appPath: URL) -> Bool {
let infoPlistURL = appPath.appendingPathComponent("Contents/Info.plist")
guard let infoDict = NSDictionary(contentsOf: infoPlistURL) as? [String: Any] else {
return false
}
return (infoDict["LSTemplateApplication"] as? Bool ?? false) ||
(infoDict["CFBundleExecutable"] as? String == "app_mode_loader")
}
/// Determines if the app is a web application based on its bundle.
static func isWebApp(bundle: Bundle?) -> Bool {
guard let infoDict = bundle?.infoDictionary else { return false }
return (infoDict["LSTemplateApplication"] as? Bool ?? false) ||
(infoDict["CFBundleExecutable"] as? String == "app_mode_loader")
}
/// Fetch app icon.
static func fetchAppIcon(for path: URL, wrapped: Bool, md: Bool = false) -> NSImage? {
let iconPath = wrapped ? (md ? path : path.deletingLastPathComponent().deletingLastPathComponent()) : path
guard let appIcon = getIconForFileOrFolderNS(atPath: iconPath) else {
printOS("App Icon not found for app at path: \(path)")
return nil
}
let targetSize = NSSize(width: 50, height: 50)
// OPTIMIZATION: Pre-render icon with cached representation using modern API
// This must run on main thread to avoid deadlock with AppKit initialization
func createRenderedIcon() -> NSImage {
return NSImage(size: targetSize, flipped: false) { rect in
appIcon.draw(in: rect)
return true
}
}
if Thread.isMainThread {
return createRenderedIcon()
} else {
return DispatchQueue.main.sync {
createRenderedIcon()
}
}
}
}
func getMDLSMetadata(for paths: [String]) -> [String: [String: Any]]? {
let kMDItemLogicalSize: CFString = "kMDItemLogicalSize" as CFString
let kMDItemPhysicalSize: CFString = "kMDItemPhysicalSize" as CFString
let kMDItemAppStoreAdamID: CFString = "kMDItemAppStoreAdamID" as CFString
// List of metadata attributes to fetch
let attributes: [CFString] = [
kMDItemFSCreationDate,
kMDItemFSContentChangeDate,
kMDItemLastUsedDate,
kMDItemDateAdded,
kMDItemDisplayName,
kMDItemCFBundleIdentifier,
kMDItemFSName,
kMDItemLogicalSize,
kMDItemPhysicalSize,
kMDItemAppStoreAdamID
]
// OPTIMIZATION: Process in parallel chunks
let chunks = createOptimalChunks(from: paths, minChunkSize: 15, maxChunkSize: 50)
let queue = DispatchQueue(label: "com.pearcleaner.metadata", qos: .userInitiated, attributes: .concurrent)
let group = DispatchGroup()
var allResults: [String: [String: Any]] = [:]
let resultsQueue = DispatchQueue(label: "com.pearcleaner.metadata.results")
for chunk in chunks {
group.enter()
queue.async {
autoreleasepool {
var chunkResults: [String: [String: Any]] = [:]
// Process each path in this chunk
for path in chunk {
autoreleasepool {
guard let mdItem = MDItemCreate(nil, path as CFString) else {
return
}
var itemMetadata = [String: Any]()
for attribute in attributes {
if let value = MDItemCopyAttribute(mdItem, attribute) {
itemMetadata[attribute as String] = value
}
}
chunkResults[path] = itemMetadata
}
}
// Safely merge results
resultsQueue.sync {
allResults.merge(chunkResults) { _, new in new }
}
}
group.leave()
}
}
group.wait()
return allResults.isEmpty ? nil : allResults
}
// Add this helper extension if you don't have it yet:
extension Array {
func chunked(into size: Int) -> [[Element]] {
return stride(from: 0, to: count, by: size).map {
Array(self[$0..<Swift.min($0 + size, count)])
}
}
}
//MARK: Fallback legacy function in case metadata doesn't contain the needed information
class AppInfoFetcher {
static let fileManager = FileManager.default
public static func getAppInfo(atPath path: URL, wrapped: Bool = false, dates: (creation: Date?, contentChange: Date?, lastUsed: Date?, dateAdded: Date?)? = nil) -> AppInfo? {
if isDirectoryWrapped(path: path) {
return handleWrappedDirectory(atPath: path, dates: dates)
} else {
return createAppInfoFromBundle(atPath: path, wrapped: wrapped, dates: dates)
}
}
public static func isDirectoryWrapped(path: URL) -> Bool {
let wrapperURL = path.appendingPathComponent("Wrapper")
return fileManager.fileExists(atPath: wrapperURL.path)
}
private static func handleWrappedDirectory(atPath path: URL, dates: (creation: Date?, contentChange: Date?, lastUsed: Date?, dateAdded: Date?)? = nil) -> AppInfo? {
let wrapperURL = path.appendingPathComponent("Wrapper")
do {
let contents = try fileManager.contentsOfDirectory(at: wrapperURL, includingPropertiesForKeys: nil)
guard let firstAppFile = contents.first(where: { $0.pathExtension == "app" }) else {
printOS("No .app files found in the 'Wrapper' directory: \(wrapperURL)")
return nil
}
let fullPath = wrapperURL.appendingPathComponent(firstAppFile.lastPathComponent)
return getAppInfo(atPath: fullPath, wrapped: true, dates: dates)
} catch {
printOS("Error reading contents of 'Wrapper' directory: \(error.localizedDescription)\n\(wrapperURL)")
return nil
}
}
private static func createAppInfoFromBundle(atPath path: URL, wrapped: Bool, dates: (creation: Date?, contentChange: Date?, lastUsed: Date?, dateAdded: Date?)? = nil) -> AppInfo? {
// Try Bundle(url:) first
if let bundle = Bundle(url: path), let bundleIdentifier = bundle.bundleIdentifier {
let appName = wrapped ? path.deletingLastPathComponent().deletingLastPathComponent().deletingPathExtension().lastPathComponent : path.localizedName()
// Extract marketing version (CFBundleShortVersionString) - no fallback to build number
let appVersion = (bundle.infoDictionary?["CFBundleShortVersionString"] as? String)?.isEmpty ?? true
? ""
: bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
// Extract build number (CFBundleVersion) separately
let appBuildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String
let appIcon = AppInfoUtils.fetchAppIcon(for: path, wrapped: wrapped)
let webApp = AppInfoUtils.isWebApp(bundle: bundle)
let system = !path.path.contains(NSHomeDirectory())
// Get cask metadata (includes cask name and auto_updates flag)
// Pass both display name, path, and bundle ID to handle various matching scenarios
let caskInfo = getCaskInfo(for: appName, appPath: path, bundleId: bundleIdentifier)
let cask = caskInfo?.caskName
let autoUpdates = caskInfo?.autoUpdates
let arch = checkAppBundleArchitecture(at: path.path)
// Get entitlements for the app
let entitlements = getEntitlements(for: path.path)
let teamIdentifier = getTeamIdentifier(for: path.path)
// Detect update sources (done at load time for performance)
let hasSparkle = AppCategoryDetector.checkForSparkle(bundle: bundle, infoDict: bundle.infoDictionary)
let isAppStore = AppCategoryDetector.checkForAppStore(bundle: bundle, path: path, wrapped: wrapped)
// adamID not available in fallback path (no mdls metadata)
let adamID: UInt64? = nil
return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName, appVersion: appVersion, appBuildNumber: appBuildNumber, appIcon: appIcon,
webApp: webApp, wrapped: wrapped, system: system, arch: arch, cask: cask, steam: false, hasSparkle: hasSparkle, isAppStore: isAppStore, adamID: adamID, autoUpdates: autoUpdates, bundleSize: 0, fileSize: [:], fileIcon: [:], creationDate: dates?.creation, contentChangeDate: dates?.contentChange, lastUsedDate: dates?.lastUsed, dateAdded: dates?.dateAdded, entitlements: entitlements, teamIdentifier: teamIdentifier)
}
// Fallback: Read Info.plist directly from disk (useful for newly installed apps where Bundle cache isn't ready)
if let infoDict = readInfoPlistDirect(at: path), let bundleIdentifier = infoDict["CFBundleIdentifier"] as? String {
let appName = wrapped ? path.deletingLastPathComponent().deletingLastPathComponent().deletingPathExtension().lastPathComponent : path.localizedName()
// Extract marketing version (CFBundleShortVersionString) - no fallback to build number
let appVersion = (infoDict["CFBundleShortVersionString"] as? String)?.isEmpty ?? true
? ""
: infoDict["CFBundleShortVersionString"] as? String ?? ""
// Extract build number (CFBundleVersion) separately
let appBuildNumber = infoDict["CFBundleVersion"] as? String
let appIcon = AppInfoUtils.fetchAppIcon(for: path, wrapped: wrapped)
let webApp = AppInfoUtils.isWebApp(appPath: path) // Use path-based version since we don't have bundle
let system = !path.path.contains(NSHomeDirectory())
// Get cask metadata (includes cask name and auto_updates flag)
// Pass both display name, path, and bundle ID to handle various matching scenarios
let caskInfo = getCaskInfo(for: appName, appPath: path, bundleId: bundleIdentifier)
let cask = caskInfo?.caskName
let autoUpdates = caskInfo?.autoUpdates
let arch = checkAppBundleArchitecture(at: path.path)
// Get entitlements for the app
let entitlements = getEntitlements(for: path.path)
let teamIdentifier = getTeamIdentifier(for: path.path)
// Detect update sources (done at load time for performance)
let hasSparkle = AppCategoryDetector.checkForSparkle(bundle: nil, infoDict: infoDict)
let isAppStore = AppCategoryDetector.checkForAppStore(bundle: nil, path: path, wrapped: wrapped)
// adamID not available in fallback path (no mdls metadata)
let adamID: UInt64? = nil
return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName, appVersion: appVersion, appBuildNumber: appBuildNumber, appIcon: appIcon,
webApp: webApp, wrapped: wrapped, system: system, arch: arch, cask: cask, steam: false, hasSparkle: hasSparkle, isAppStore: isAppStore, adamID: adamID, autoUpdates: autoUpdates, bundleSize: 0, fileSize: [:], fileIcon: [:], creationDate: dates?.creation, contentChangeDate: dates?.contentChange, lastUsedDate: dates?.lastUsed, dateAdded: dates?.dateAdded, entitlements: entitlements, teamIdentifier: teamIdentifier)
}
// If both Bundle and direct reading failed, check if this might be a Steam game
if let steamAppInfo = SteamAppInfoFetcher.checkForSteamGame(launcherPath: path) {
return steamAppInfo
}
printOS("Bundle not found or missing bundle identifier at path: \(path)")
return nil
}
}
//MARK: Steam Games Support
class SteamAppInfoFetcher {
static let fileManager = FileManager.default
/// Check if a failed app bundle is actually a Steam game launcher and find the real bundle
static func checkForSteamGame(launcherPath: URL) -> AppInfo? {
// Extract the app name from the launcher path (e.g., "Helltaker" from "Helltaker.app")
let appName = launcherPath.deletingPathExtension().lastPathComponent
// Check if this app exists in the Steam common directory
let steamCommonPath = "\(NSHomeDirectory())/Library/Application Support/Steam/steamapps/common"
let steamGamePath = steamCommonPath + "/" + appName
guard fileManager.fileExists(atPath: steamGamePath) else {
return nil
}
// Look for the actual .app bundle within the Steam game directory
guard let actualAppBundle = findAppBundle(in: steamGamePath) else {
return nil
}
// Create AppInfo using the actual Steam game bundle but keep the launcher path
return createSteamAppInfo(launcherPath: launcherPath, actualBundlePath: actualAppBundle, gameFolderName: appName)
}
/// Find the .app bundle within a Steam game directory
private static func findAppBundle(in directory: String) -> URL? {
do {
let contents = try fileManager.contentsOfDirectory(atPath: directory)
// Look for .app files
for item in contents {
if item.hasSuffix(".app") {
let appPath = directory + "/" + item
let appURL = URL(fileURLWithPath: appPath)
// Verify it has an Info.plist
let infoPlistPath = appPath + "/Contents/Info.plist"
if fileManager.fileExists(atPath: infoPlistPath) {
return appURL
}
}
}
} catch {
printOS("Error searching for app bundle in \(directory): \(error)")
}
return nil
}
/// Create AppInfo for Steam games using the launcher path but actual bundle info
private static func createSteamAppInfo(launcherPath: URL, actualBundlePath: URL, gameFolderName: String) -> AppInfo? {
guard let bundle = Bundle(url: actualBundlePath) else {
printOS("Steam game bundle not found at path: \(actualBundlePath)")
return nil
}
// Handle missing bundle identifier by providing a fallback
let bundleIdentifier = bundle.bundleIdentifier?.isEmpty == false ? bundle.bundleIdentifier! : "com.no.bundleid"
// Use the game folder name as the app name
let appName = gameFolderName.capitalizingFirstLetter()
// Extract marketing version (CFBundleShortVersionString) - no fallback to build number
let appVersion = (bundle.infoDictionary?["CFBundleShortVersionString"] as? String)?.isEmpty ?? true
? ""
: bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
// Extract build number (CFBundleVersion) separately
let appBuildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String
// Use the actual bundle path for the icon (proper game icon) instead of launcher
let appIcon = AppInfoUtils.fetchAppIcon(for: actualBundlePath, wrapped: false)
let webApp = false
let system = false // Steam games are never system apps
let arch = checkAppBundleArchitecture(at: actualBundlePath.path)
// Use the launcher path as the main path (so users see the ~/Applications version)
// but store the actual bundle path info
// Get entitlements for the Steam app
let entitlements = getEntitlements(for: actualBundlePath.path)
let teamIdentifier = getTeamIdentifier(for: actualBundlePath.path)
// Steam games: typically no Sparkle or App Store (distributed via Steam)
let hasSparkle = AppCategoryDetector.checkForSparkle(bundle: bundle, infoDict: bundle.infoDictionary)
let isAppStore = false // Steam games are never from App Store
let adamID: UInt64? = nil // Steam games don't have App Store adamID
let autoUpdates: Bool? = nil // Steam games don't use Homebrew
return AppInfo(id: UUID(), path: launcherPath, bundleIdentifier: bundleIdentifier, appName: appName,
appVersion: appVersion, appBuildNumber: appBuildNumber, appIcon: appIcon, webApp: webApp, wrapped: false,
system: system, arch: arch, cask: nil, steam: true, hasSparkle: hasSparkle, isAppStore: isAppStore, adamID: adamID, autoUpdates: autoUpdates, bundleSize: 0, fileSize: [:],
fileIcon: [:], creationDate: nil, contentChangeDate: nil, lastUsedDate: nil, dateAdded: nil, entitlements: entitlements, teamIdentifier: teamIdentifier)
}
}
private func getEntitlements(for appPath: String) -> [String]? {
return autoreleasepool { () -> [String]? in
let appURL = URL(fileURLWithPath: appPath) as CFURL
var staticCode: SecStaticCode?
// Create a static code object for the app
guard SecStaticCodeCreateWithPath(appURL, [], &staticCode) == errSecSuccess,
let code = staticCode else {
return nil
}
// 1 << 2 is the bitmask for entitlements (kSecCSEntitlements)
var info: CFDictionary?
if SecCodeCopySigningInformation(code,
SecCSFlags(rawValue: 1 << 2),
&info) == errSecSuccess,
let dict = info as? [String: Any],
let entitlements = dict[kSecCodeInfoEntitlementsDict as String] as? [String: Any] {
var results: [String] = []
// com.apple.security.application-groups
if let appGroups = entitlements["com.apple.security.application-groups"] as? [String] {
results.append(contentsOf: appGroups)
}
// com.apple.developer.icloud-container-identifiers
if let icloudContainers = entitlements["com.apple.developer.icloud-container-identifiers"] as? [String] {
results.append(contentsOf: icloudContainers)
}
// Note: Path-based entitlements (like temporary-exception.files paths) are not extracted
// as they cause false positives by matching generic folder names like "Desktop", "Documents"
// Skip generic binary names that could cause false positives
let excludedNames = ["crashhandler", "crash handler", "electron"]
// Scan Contents/MacOS folder for binary names
// App binaries often leave behind files/folders matching their names
let macosPath = URL(fileURLWithPath: appPath).appendingPathComponent("Contents/MacOS")
if FileManager.default.fileExists(atPath: macosPath.path) {
do {
let files = try FileManager.default.contentsOfDirectory(atPath: macosPath.path)
for file in files where !file.hasPrefix(".") {
let fileNameLower = file.lowercased()
// Add binary name if not already present, length >= 5, and not excluded
if !results.contains(file) && file.count >= 5 && !excludedNames.contains(fileNameLower) {
results.append(file)
}
}
} catch {
// Silently ignore errors (e.g., permission denied, folder doesn't exist)
}
}
// Scan nested bundles one level deep in Contents/*/ for helper apps
// Example: Contents/SharedSupport/wpscloudsvr.app
let contentsPath = URL(fileURLWithPath: appPath).appendingPathComponent("Contents")
if FileManager.default.fileExists(atPath: contentsPath.path) {
do {
let subdirs = try FileManager.default.contentsOfDirectory(at: contentsPath, includingPropertiesForKeys: nil)
for subdir in subdirs where subdir.hasDirectoryPath {
// Check for .app bundles in this subdirectory
if let bundles = try? FileManager.default.contentsOfDirectory(at: subdir, includingPropertiesForKeys: nil) {
for bundle in bundles where bundle.pathExtension == "app" {
// Add bundle name (without .app extension)
let bundleName = bundle.deletingPathExtension().lastPathComponent
let bundleNameLower = bundleName.lowercased()
if !results.contains(bundleName) && bundleName.count >= 5 && !excludedNames.contains(bundleNameLower) {
results.append(bundleName)
}
// Scan nested bundle's MacOS folder for binary names
let nestedMacOS = bundle.appendingPathComponent("Contents/MacOS")
if let binaries = try? FileManager.default.contentsOfDirectory(atPath: nestedMacOS.path) {
for binary in binaries where !binary.hasPrefix(".") && binary.count >= 5 {
let binaryNameLower = binary.lowercased()
if !results.contains(binary) && !excludedNames.contains(binaryNameLower) {
results.append(binary)
}
}
}
}
}
}
} catch {
// Silently ignore errors (e.g., permission denied, folder doesn't exist)
}
}
return results.isEmpty ? nil : results
}
return nil
}
}
private func getTeamIdentifier(for appPath: String) -> String? {
return autoreleasepool {
let appURL = URL(fileURLWithPath: appPath) as CFURL
var staticCode: SecStaticCode?
// Create a static code object for the app
guard SecStaticCodeCreateWithPath(appURL, [], &staticCode) == errSecSuccess,
let code = staticCode else {
return nil
}
// Get signing information with team identifier
var info: CFDictionary?
if SecCodeCopySigningInformation(code,
SecCSFlags(rawValue: kSecCSSigningInformation),
&info) == errSecSuccess,
let dict = info as? [String: Any],
let teamIdentifier = dict[kSecCodeInfoTeamIdentifier as String] as? String {
return teamIdentifier
}
return nil
}
}
================================================
FILE: Pearcleaner/Logic/AppPathsFetch.swift
================================================
//
// AppPathsFetch.swift
// Pearcleaner
//
// Created by Alin Lupascu on 2/6/25.
//
import Foundation
import AppKit
import SwiftUI
import AlinFoundation
extension String {
/// Strips trailing version numbers and digits from app names
/// "Bartender 6" → "Bartender"
/// "Firefox 120.0" → "Firefox"
func strippingTrailingDigits() -> String {
return self.replacingOccurrences(
of: #"\s+\d+(\.\d+)*\s*$"#,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
}
}
class AppPathFinder {
// Shared properties
private var appInfo: AppInfo
private var locations: Locations
private var containerCollection: [URL] = []
private let collectionAccessQueue = DispatchQueue(label: "com.alienator88.Pearcleaner.appPathFinder.collectionAccess")
@AppStorage("settings.general.searchSensitivity") private var sensitivityLevel: SearchSensitivityLevel = .strict
@AppStorage("settings.general.searchTextContent") private var searchTextContent: Bool = false
// Optional override sensitivity level for per-app settings
private var overrideSensitivityLevel: SearchSensitivityLevel?
// GUI-specific properties (can be nil for CLI)
private var appState: AppState?
private var undo: Bool = false
private var completion: (() -> Void)?
// Use a Set for fast membership testing
private var collectionSet: Set<URL> = []
// Precompiled UUID regex
private static let uuidRegex: NSRegularExpression = {
return try! NSRegularExpression(
pattern: "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$",
options: .caseInsensitive
)
}()
// Change from lazy var to regular property initialized in init
private let cachedIdentifiers: (formattedBundleId: String, bundleLastTwoComponents: String, formattedAppName: String, formattedAppNameStripped: String?, appNameLettersOnly: String, pathComponentName: String, useBundleIdentifier: Bool, formattedCompanyName: String?, formattedEntitlements: [String], formattedTeamIdentifier: String?, formattedBaseBundleId: String?)
// Exclusion list for app file search (computed property to always get current list)
private var formattedAppExclusionList: [String] {
return FolderSettingsManager.shared.fileFolderPathsApps.map { $0.pearFormat() }
}
// Computed property to get the effective sensitivity level
private var effectiveSensitivityLevel: SearchSensitivityLevel {
return overrideSensitivityLevel ?? sensitivityLevel
}
// Initializer for both CLI and GUI
init(appInfo: AppInfo, locations: Locations, appState: AppState? = nil, undo: Bool = false, sensitivityOverride: SearchSensitivityLevel? = nil, completion: (() -> Void)? = nil) {
self.appInfo = appInfo
self.locations = locations
self.appState = appState
self.undo = undo
self.overrideSensitivityLevel = sensitivityOverride
self.completion = completion
// Initialize cachedIdentifiers eagerly and thread-safely
let formattedBundleId = appInfo.bundleIdentifier.pearFormat()
let bundleComponents = appInfo.bundleIdentifier
.components(separatedBy: ".")
.compactMap { $0 != "-" ? $0.lowercased() : nil }
let bundleLastTwoComponents = bundleComponents.suffix(2).joined()
let formattedAppName = appInfo.appName.pearFormat()
let appNameLettersOnly = formattedAppName.filter { $0.isLetter }
let pathComponentName = appInfo.path.lastPathComponent.replacingOccurrences(of: ".app", with: "")
let useBundleIdentifier = AppPathFinder.isValidBundleIdentifier(appInfo.bundleIdentifier)
// Extract company/dev name from 3-component bundle IDs (e.g., "com.knollsoft.Rectangle" -> "knollsoft")
let formattedCompanyName: String?
let rawComponents = appInfo.bundleIdentifier.components(separatedBy: ".")
if rawComponents.count == 3 {
formattedCompanyName = rawComponents[1].pearFormat()
} else {
formattedCompanyName = nil
}
// Pre-format entitlements once to avoid repeated formatting in the hot path
let formattedEntitlements: [String] = appInfo.entitlements?.compactMap { entitlement in
let formatted = entitlement.pearFormat()
return formatted.isEmpty ? nil : formatted
} ?? []
// Pre-format team identifier once
let formattedTeamIdentifier = appInfo.teamIdentifier?.pearFormat()
// Create base bundle ID by stripping common suffixes (for matching launch daemons/agents)
// e.g., "com.objective-see.blockblock.helper" -> "com.objective-see.blockblock"
let formattedBaseBundleId: String?
let commonSuffixes = ["helper", "agent", "daemon", "service", "xpc", "launcher", "updater", "installer", "uninstaller", "login", "extension", "plugin"]
if rawComponents.count >= 4 {
let lastComponent = rawComponents.last?.lowercased() ?? ""
if commonSuffixes.contains(lastComponent) {
// Remove last component and format
let baseBundleId = rawComponents.dropLast().joined(separator: ".")
formattedBaseBundleId = baseBundleId.pearFormat()
} else {
formattedBaseBundleId = nil
}
} else {
formattedBaseBundleId = nil
}
// Strip trailing digits from app name for Enhanced/Deep mode matching
// "Bartender 6" → "bartender6" (regular) + "bartender" (stripped)
let appNameStripped = appInfo.appName.strippingTrailingDigits()
let formattedAppNameStripped: String? = {
let stripped = appNameStripped.pearFormat()
// Only use if different from regular formatted name and not empty
return (stripped != formattedAppName && !stripped.isEmpty) ? stripped : nil
}()
self.cachedIdentifiers = (formattedBundleId, bundleLastTwoComponents, formattedAppName, formattedAppNameStripped, appNameLettersOnly, pathComponentName, useBundleIdentifier, formattedCompanyName, formattedEntitlements, formattedTeamIdentifier, formattedBaseBundleId)
}
// Process the initial URL
private func initialURLProcessing() {
if let url = URL(string: self.appInfo.path.absoluteString), !url.path.contains(".Trash") {
let modifiedUrl = url.path.contains("Wrapper") ? url.deletingLastPathComponent().deletingLastPathComponent() : url
collectionSet.insert(modifiedUrl)
}
}
// Get all container URLs
private func getAllContainers(bundleURL: URL) -> [URL] {
var containers: [URL] = []
let bundleIdentifier = Bundle(url: bundleURL)?.bundleIdentifier
guard let containerBundleIdentifier = bundleIdentifier else {
return containers
}
if let groupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: containerBundleIdentifier) {
if FileManager.default.fileExists(atPath: groupContainer.path) {
containers.append(groupContainer)
Task { @MainActor in
GlobalConsoleManager.shared.appendOutput("Found group container: \(groupContainer.lastPathComponent)\n", source: CurrentPage.applications.title)
}
}
}
if let containersPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.appendingPathComponent("Containers") {
do {
let containerDirectories = try FileManager.default.contentsOfDirectory(at: containersPath, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
for directory in containerDirectories {
let directoryName = directory.lastPathComponent
if AppPathFinder.uuidRegex.firstMatch(in: directoryName, options: [], range: NSRange(location: 0, length: directoryName.utf16.count)) != nil {
let metadataPlistURL = directory.appendingPathComponent(".com.apple.containermanagerd.metadata.plist")
if let metadataDict = NSDictionary(contentsOf: metadataPlistURL),
let applicationBundleID = metadataDict["MCMMetadataIdentifier"] as? String {
if applicationBundleID == self.appInfo.bundleIdentifier {
containers.append(directory)
Task { @MainActor in
GlobalConsoleManager.shared.appendOutput("Found app container: \(directoryName)\n", source: CurrentPage.applications.title)
}
}
}
}
}
} catch {
printOS("Error accessing Containers directory: \(error)")
}
}
return containers
}
// Combined processing for directories and files
private func processLocation(_ location: String) {
processLocation(location, currentDepth: 0, maxDepth: 1, isLibraryRootSearch: false)
}
private func processLocation(_ location: String, currentDepth: Int, maxDepth: Int, isLibraryRootSearch: Bool = false) {
if let contents = try? FileManager.default.contentsOfDirectory(atPath: location) {
var localResults: [URL] = []
var subdirectoriesToSearch: [URL] = []
for scannedItem in contents {
let scannedItemURL = URL(fileURLWithPath: location).appendingPathComponent(scannedItem)
let normalizedItemName: String
if scannedItemURL.hasDirectoryPath || scannedItemURL.pathExtension.isEmpty {
// It's a directory or has no extension - don't remove anything
normalizedItemName = scannedItem.pearFormat()
} else {
// It's a file with an extension - remove the extension
normalizedItemName = (scannedItem as NSString).deletingPathExtension.pearFormat()
}
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: scannedItemURL.path, isDirectory: &isDirectory) {
if shouldSkipItem(normalizedItemName, at: scannedItemURL) { continue }
if specificCondition(normalizedItemName: normalizedItemName, scannedItemURL: scannedItemURL) {
// Determine what to add: matched item or its parent (for vendor folders)
let itemToAdd: URL
// For depth=2 matches in Library root searches, check if parent is a vendor folder
if isLibraryRootSearch && currentDepth == 2 {
let parentURL = scannedItemURL.deletingLastPathComponent()
let parentName = parentURL.lastPathComponent
// Add parent directory if it's NOT a standard macOS directory
// This captures vendor folders like /Library/Objective-See/ instead of /Library/Objective-See/LuLu/
if !Locations.standardLibrarySubdirectories.contains(parentName) {
itemToAdd = parentURL
} else {
itemToAdd = scannedItemURL
}
} else {
itemToAdd = scannedItemURL
}
localResults.append(itemToAdd)
}
// If this is a directory and we haven't reached max depth, mark for recursive search
// Skip directories in the exclusion list when doing Library root deep searches
if isDirectory.boolValue && currentDepth < maxDepth {
// For Library root searches at depth 0, check if directory should be excluded from deep search
if isLibraryRootSearch && currentDepth == 0 {
let dirName = scannedItemURL.lastPathComponent
if !skipDeepSearch.contains(dirName) {
subdirectoriesToSearch.append(scannedItemURL)
}
} else {
subdirectoriesToSearch.append(scannedItemURL)
}
}
}
}
collectionAccessQueue.sync {
collectionSet.formUnion(localResults)
}
// Recursively search subdirectories if we haven't reached max depth
if currentDepth < maxDepth {
for subdirectory in subdirectoriesToSearch {
processLocation(subdirectory.path, currentDepth: currentDepth + 1, maxDepth: maxDepth, isLibraryRootSearch: isLibraryRootSearch)
}
}
}
}
// Asynchronous collection for GUI usage
private func collectLocations() {
let dispatchGroup = DispatchGroup()
for location in self.locations.apps.paths {
dispatchGroup.enter()
DispatchQueue.global(qos: .userInitiated).async {
// Use depth=2 for Library directories to find files in vendor subdirectories
// Example: /Library/Objective-See/LuLu, ~/Library/Microsoft/Edge
let isLibRoot = self.isLibraryDirectory(location)
let maxDepth = isLibRoot ? 2 : 1
self.processLocation(location, currentDepth: 0, maxDepth: maxDepth, isLibraryRootSearch: isLibRoot)
dispatchGroup.leave()
}
}
dispatchGroup.wait()
}
// Synchronous collection for CLI usage
private func collectLocationsCLI() {
for location in self.locations.apps.paths {
// Use depth=2 for Library directories to find files in vendor subdirectories
let isLibRoot = isLibraryDirectory(location)
let maxDepth = isLibRoot ? 2 : 1
processLocation(location, currentDepth: 0, maxDepth: maxDepth, isLibraryRootSearch: isLibRoot)
}
}
// Helper to determine if a location is a Library directory
private func isLibraryDirectory(_ location: String) -> Bool {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return location == "\(home)/Library" || location == "/Library"
}
// Skip items based on conditions and membership in collectionSet
private func shouldSkipItem(_ normalizedItemName: String, at scannedItemURL: URL) -> Bool {
var containsItem = false
collectionAccessQueue.sync {
containsItem = self.collectionSet.contains(scannedItemURL)
}
if containsItem {
return true
}
for skipCondition in skipConditions {
// Check path-based exclusions first
for skipPath in skipCondition.skipPaths {
if scannedItemURL.path.hasPrefix(skipPath) {
return true
}
}
// Check prefix-based exclusions
if skipCondition.skipPrefix.contains(where: normalizedItemName.hasPrefix) {
let isAllowed = skipCondition.allowPrefixes.contains(where: normalizedItemName.hasPrefix)
if !isAllowed {
return true
}
}
}
return false
}
// Check if an item meets specific conditions using cached identifiers
private func specificCondition(normalizedItemName: String, scannedItemURL: URL) -> Bool {
let cached = self.cachedIdentifiers
// Special handling for Steam games: also check Desktop for shortcuts
if scannedItemURL.path.contains("/Desktop/") && scannedItemURL.pathExtension == "app" {
let desktopAppName = scannedItemURL.deletingPathExtension().lastPathComponent.pearFormat()
if desktopAppName == cached.formattedAppName || desktopAppName == cached.appNameLettersOnly {
return true
}
}
// Special handling for Steam game main folder
if self.appInfo.steam && scannedItemURL.path.contains("/Library/Application Support/Steam/steamapps/common/") {
let folderName = scannedItemURL.lastPathComponent.pearFormat()
// Check if this folder matches the game name
if folderName == cached.formattedAppName || folderName == cached.appNameLettersOnly {
return true
}
}
// Special handling for Steam game manifest files
if self.appInfo.steam && scannedItemURL.path.contains("/Library/Application Support/Steam/steamapps/") &&
scannedItemURL.lastPathComponent.hasPrefix("appmanifest_") && scannedItemURL.pathExtension == "acf" {
// Extract the game ID from the filename (e.g., "appmanifest_1289310.acf" -> "1289310")
let filename = scannedItemURL.lastPathComponent
if let gameIdFromFile = extractGameId(from: filename) {
// Get the game ID from the Steam launcher's run.sh file
if let gameIdFromLauncher = getSteamGameId(from: self.appInfo.path) {
return gameIdFromFile == gameIdFromLauncher
}
}
}
// Check entitlements-based matching (using pre-formatted entitlements)
// Strict: exact match only, Enhanced/Deep: contains match
for entitlementFormatted in cached.formattedEntitlements {
let isMatch = effectiveSensitivityLevel == .strict
? normalizedItemName == entitlementFormatted
: normalizedItemName.contains(entitlementFormatted)
if isMatch {
return true
}
}
for condition in conditions {
if cached.useBundleIdentifier && cached.formattedBundleId.contains(condition.bundle_id) {
if condition.exclude.contains(where: { normalizedItemName.contains($0) }) {
return false
}
if condition.include.contains(where: { normalizedItemName.contains($0) }) {
return true
}
}
}
if self.appInfo.webApp {
return normalizedItemName.contains(cached.formattedBundleId)
}
let fullBundleMatch = normalizedItemName.contains(cached.formattedBundleId)
let sensitivity = effectiveSensitivityLevel == .strict
// Prevent false matches when cached values are empty strings
let appNameMatch = !cached.formattedAppName.isEmpty && (sensitivity ? normalizedItemName == cached.formattedAppName : normalizedItemName.contains(cached.formattedAppName))
let pathNameMatch = !cached.pathComponentName.isEmpty && (sensitivity ? normalizedItemName == cached.pathComponentName : normalizedItemName.contains(cached.pathComponentName))
let appNameLettersMatch = !cached.appNameLettersOnly.isEmpty && (sensitivity ? normalizedItemName == cached.appNameLettersOnly : normalizedItemName.contains(cached.appNameLettersOnly))
// Bundle ID component matching (Enhanced/Deep levels only)
let twoComponentMatch: Bool
if effectiveSensitivityLevel != .strict {
twoComponentMatch = normalizedItemName.contains(cached.bundleLastTwoComponents)
} else {
twoComponentMatch = false
}
// Company name matching (Deep level only)
let companyMatch: Bool
if effectiveSensitivityLevel == .deep, let company = cached.formattedCompanyName, !company.isEmpty {
companyMatch = normalizedItemName.contains(company)
} else {
companyMatch = false
}
// Team identifier matching (Deep level only)
let teamIdMatch: Bool
if effectiveSensitivityLevel == .deep, let teamId = cached.formattedTeamIdentifier, !teamId.isEmpty {
teamIdMatch = normalizedItemName.contains(teamId)
} else {
teamIdMatch = false
}
// Base bundle ID matching (for apps with .helper/.agent/etc. suffixes)
// Matches launch daemons/agents that use base bundle ID without suffix
let baseBundleIdMatch: Bool
if let baseBundleId = cached.formattedBaseBundleId, !baseBundleId.isEmpty {
baseBundleIdMatch = normalizedItemName.contains(baseBundleId)
} else {
baseBundleIdMatch = false
}
// Stripped app name matching (Enhanced/Deep only)
// Matches files with version-stripped names: "Bartender 6" → also matches "bartender" files
let strippedAppNameMatch: Bool
if effectiveSensitivityLevel != .strict, let stripped = cached.formattedAppNameStripped, !stripped.isEmpty {
strippedAppNameMatch = normalizedItemName.contains(stripped)
} else {
strippedAppNameMatch = false
}
return (cached.useBundleIdentifier && fullBundleMatch) || (appNameMatch || pathNameMatch || appNameLettersMatch) || twoComponentMatch || companyMatch || teamIdMatch || baseBundleIdMatch || strippedAppNameMatch
}
// Helper function to extract game ID from manifest filename
private func extractGameId(from filename: String) -> String? {
// Extract from "appmanifest_1289310.acf" -> "1289310"
let components = filename.components(separatedBy: "_")
if components.count >= 2 {
let gameIdWithExtension = components[1]
return gameIdWithExtension.components(separatedBy: ".").first
}
return nil
}
// Helper function to get Steam game ID from the launcher's run.sh file
private func getSteamGameId(from appPath: URL) -> String? {
let runShPath = appPath.appendingPathComponent("Contents/MacOS/run.sh")
guard FileManager.default.fileExists(atPath: runShPath.path) else {
return nil
}
do {
let content = try String(contentsOf: runShPath, encoding: .utf8)
// Look for "steam://run/" pattern and extract the number after it
if let range = content.range(of: "steam://run/") {
let afterRun = String(content[range.upperBound...])
// Extract the number (game ID) which should be at the beginning
let gameId = afterRun.components(separatedBy: CharacterSet.decimalDigits.inverted).first
return gameId?.isEmpty == false ? gameId : nil
}
} catch {
printOS("Error reading run.sh file: \(error)")
}
return nil
}
// Check for associated zombie files
private func fetchAssociatedZombieFiles() -> [URL] {
let storedFiles = ZombieFileStorage.shared.getAssociatedFiles(for: self.appInfo.path)
// Only return files that actually exist on disk
// Keep associations in storage so they can be restored if file comes back
return storedFiles.filter { FileManager.default.fileExists(atPath: $0.path) }
}
// Helper method to check bundle identifier validity - now static
private static func isValidBundleIdentifier(_ bundleIdentifier: String) -> Bool {
let components = bundleIdentifier.components(separatedBy: ".")
if components.count == 1 {
return bundleIdentifier.count >= 5
}
return true
}
// Check spotlight index for leftovers missed by manual search
private func spotlightSupplementalPaths() -> [URL] {
// Spotlight enabled for all levels with sensitivity-appropriate matching
// Strict: Exact matches only (via ==[cd] predicate and post-filter)
// Enhanced/Deep: Contains matches (via CONTAINS[cd] predicate)
updateOnMain {
self.appState?.progressStep = 1
}
var results: [URL] = []
let query = NSMetadataQuery()
let appName = self.appInfo.appName
let bundleID = self.appInfo.bundleIdentifier
// Build predicate based on sensitivity level
switch self.effectiveSensitivityLevel {
case .strict:
// Strict: Only exact filename matches
query.predicate = NSPredicate(format:
"kMDItemDisplayName ==[cd] %@ OR kMDItemDisplayName ==[cd] %@",
appName, bundleID)
case .enhanced:
// Enhanced: Partial matching in name and path
query.predicate = NSPredicate(format:
"kMDItemDisplayName CONTAINS[cd] %@ OR kMDItemPath CONTAINS[cd] %@",
appName, bundleID)
case .deep:
// Deep: Fuzzy search with metadata and AND logic for multi-word names
var subpredicates: [NSPredicate] = [
// DisplayName: appName OR bundleID
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "kMDItemDisplayName CONTAINS[cd] %@", appName),
NSPredicate(format: "kMDItemDisplayName CONTAINS[cd] %@", bundleID)
]),
// Path: appName OR bundleID
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "kMDItemPath CONTAINS[cd] %@", appName),
NSPredicate(format: "kMDItemPath CONTAINS[cd] %@", bundleID)
]),
// Comment: appName OR bundleID
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "kMDItemComment CONTAINS[cd] %@", appName),
NSPredicate(format: "kMDItemComment CONTAINS[cd] %@", bundleID)
]),
// Creator: appName OR bundleID
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "kMDItemCreator ==[cd] %@", appName),
NSPredicate(format: "kMDItemCreator ==[cd] %@", bundleID)
]),
// Copyright: appName OR bundleID (often contains developer/company info)
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "kMDItemCopyright CONTAINS[cd] %@", appName),
NSPredicate(format: "kMDItemCopyright CONTAINS[cd] %@", bundleID)
]),
// EncodingApplications: appName only (array of apps that processed the file)
NSPredicate(format: "kMDItemEncodingApplications CONTAINS[cd] %@", appName)
]
// TextContent: appName OR bundleID (optional, controlled by user setting)
if searchTextContent {
subpredicates.append(
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "kMDItemTextContent CONTAINS[cd] %@", appName),
NSPredicate(format: "kMDItemTextContent CONTAINS[cd] %@", bundleID)
])
)
}
// Add wildcard predicate: ALL name parts must be present (AND logic)
let nameParts = appName.split(separator: " ")
if nameParts.count > 1 {
// For multi-word names: each part must appear in display name OR path
let partPredicates = nameParts.map { part in
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "kMDItemDisplayName LIKE[cd] %@", "*\(part)*"),
NSPredicate(format: "kMDItemPath LIKE[cd] %@", "*\(part)*")
])
}
// All parts must be present (AND)
let allPartsPresent = NSCompoundPredicate(andPredicateWithSubpredicates: partPredicates)
subpredicates.append(allPartsPresent)
} else {
// Single word: just add LIKE for that word
subpredicates.append(NSPredicate(format: "kMDItemDisplayName LIKE[cd] %@", "*\(appName)*"))
}
// Combine all conditions with OR (any match wins)
query.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: subpredicates)
}
query.searchScopes = [NSMetadataQueryUserHomeScope]
let currentRunLoop = CFRunLoopGetCurrent()
let finishedNotification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NSMetadataQueryDidFinishGathering, object: query, queue: nil) { _ in
query.disableUpdates()
query.stop()
results = query.results.compactMap {
($0 as? NSMetadataItem)?.value(forAttribute: kMDItemPath as String)
}.compactMap {
URL(fileURLWithPath: $0 as! String)
}
// Post-filter: Only for Strict level
if self.effectiveSensitivityLevel == .strict {
let nameFormatted = appName.pearFormat()
let bundleFormatted = bundleID.pearFormat()
results = results.filter { url in
let pathFormatted = url.lastPathComponent.pearFormat()
return pathFormatted == nameFormatted || pathFormatted == bundleFormatted
}
}
CFRunLoopStop(currentRunLoop)
}
query.start()
// Timeout after 5 seconds
DispatchQueue.global().asyncAfter(deadline: .now() + 5) {
CFRunLoopStop(currentRunLoop)
}
CFRunLoopRun()
// Limit results to prevent excessive post-processing delays
if results.count > 500 {
results = Array(results.prefix(500))
}
NotificationCenter.default.removeObserver(finishedNotification)
return results
}
// Finalize the collection for GUI usage
private func finalizeCollection() {
DispatchQueue.global(qos: .userInitiated).async {
let outliers = self.handleOutliers()
let outliersEx = self.handleOutliers(include: false)
var tempCollection: [URL] = []
self.collectionAccessQueue.sync {
tempCollection = Array(self.collectionSet)
}
Task { @MainActor in
GlobalConsoleManager.shared.appendOutput("Found \(tempCollection.count) files from manual search\n", source: CurrentPage.applications.title)
}
tempCollection.append(contentsOf: self.containerCollection)
tempCollection.append(contentsOf: outliers)
// Insert spotlight results before sorting and filtering
Task { @MainActor in
GlobalConsoleManager.shared.appendOutput("Running Spotlight supplemental search...\n", source: CurrentPage.applications.title)
}
let spotlightResults = self.spotlightSupplementalPaths()
let spotlightOnly = spotlightResults.filter { !self.collectionSet.contains($0) }
if spotlightOnly.count > 0 {
Task { @MainActor in
GlobalConsoleManager.shared.appendOutput("Spotlight found \(spotlightOnly.count) additional files\n", source: CurrentPage.applications.title)
}
}
tempCollection.append(contentsOf: spotlightOnly)
let excludePaths = outliersEx.map { $0.path }
tempCollection.removeAll { url in
excludePaths.contains(url.path)
}
// Apply app exclusion filter to ALL discovered files (from all code paths)
tempCollection.removeAll { fileURL in
let normalizedPath = fileURL.standardizedFileURL.path.pearFormat()
return self.formattedAppExclusionList.contains(normalizedPath) ||
self.formattedAppExclusionList.contains(where: { normalizedPath.contains($0) })
}
let sortedCollection = tempCollection.map { $0.standardizedFileURL }.sorted(by: { $0.path < $1.path })
var filteredCollection: [URL] = []
for url in sortedCollection {
// Remove any existing child paths of the current URL
filteredCollection.removeAll { $0.path.hasPrefix(url.path + "/") }
// Only add if it's not already a subpath of an existing item
if !filteredCollection.contains(where: { url.path.hasPrefix($0.path + "/") }) {
filteredCollection.append(url)
}
}
Task { @MainActor in
GlobalConsoleManager.shared.appendOutput("Calculating file sizes for \(filteredCollection.count) items...\n", source: CurrentPage.applications.title)
}
self.handlePostProcessing(sortedCollection: filteredCollection)
}
}
// Finalize the collection for CLI usage
private func finalizeCollectionCLI() -> Set<URL> {
let outliers = handleOutliers()
let outliersEx = handleOutliers(include: false)
var tempCollection: [URL] = []
self.collectionAccessQueue.sync {
tempCollection = Array(self.collectionSet)
}
tempCollection.append(contentsOf: self.containerCollection)
tempCollection.append(contentsOf: outliers)
// Insert spotlight results before sorting and filtering
let spotlightResults = self.spotlightSupplementalPaths()
let spotlightOnly = spotlightResults.filter { !self.collectionSet.contains($0) }
// printOS("Spotlight index found: \(spotlightOnly.count)")
tempCollection.append(contentsOf: spotlightOnly)
let excludePaths = outliersEx.map { $0.path }
tempCollection.removeAll { url in
excludePaths.contains(url.path)
}
// Apply app exclusion filter to ALL discovered files (from all code paths)
tempCollection.removeAll { fileURL in
let normalizedPath = fileURL.standardizedFileURL.path.pearFormat()
return formattedAppExclusionList.contains(normalizedPath) ||
formattedAppExclusionList.contains(where: { normalizedPath.contains($0) })
}
let sortedCollection = tempCollection.map { $0.standardizedFileURL }.sorted(by: { $0.path < $1.path })
var filteredCollection: [URL] = []
var previousUrl: URL?
for url in sortedCollection {
if let previousUrl = previousUrl, url.path.hasPrefix(previousUrl.path + "/") {
continue
}
filteredCollection.append(url)
previousUrl = url
}
if filteredCollection.count == 1, let firstURL = filteredCollection.first, firstURL.path.contains(".Trash") {
filteredCollection.removeAll()
}
return Set(filteredCollection)
}
// Handle outlier paths based on conditions
private func handleOutliers(include: Bool = true) -> [URL] {
var outliers: [URL] = []
let bundleIdentifier = self.appInfo.bundleIdentifier.pearFormat()
let matchingConditions = conditions.filter { condition in
bundleIdentifier.contains(condition.bundle_id)
}
for condition in matchingConditions {
if include {
if let forceIncludes = condition.includeForce {
for path in forceIncludes {
outliers.append(path)
}
}
} else {
if let excludeForce = condition.excludeForce {
for path in excludeForce {
outliers.append(path)
}
}
}
}
return outliers
}
// Post-processing: calculate file details, update state, and call completion
private func handlePostProcessing(sortedCollection: [URL]) {
// Fetch associated zombie files and add them to the collection
var tempCollection = sortedCollection
let associatedFiles = fetchAssociatedZombieFiles()
for file in associatedFiles {
if !tempCollection.contains(file) {
tempCollection.append(file) // Now it's properly included
}
}
var fileSize: [URL: Int64] = [:]
var fileIcon: [URL: NSImage?] = [:]
let chunks = createOptimalChunks(from: tempCollection)
let queue = DispatchQueue(label: "size-calculation", qos: .userInitiated, attributes: .concurrent)
let group = DispatchGroup()
for chunk in chunks {
group.enter()
queue.async {
var localFileSize: [URL: Int64] = [:]
var localFileIcon: [URL: NSImage?] = [:]
for path in chunk {
let size = spotlightSizeForURL(path)
localFileSize[path] = size
localFileIcon[path] = self.getSmartIcon(for: path)
}
// Merge results safely
DispatchQueue.main.sync {
fileSize.merge(localFileSize) { $1 }
fileIcon.merge(localFileIcon) { $1 }
}
group.leave()
}
}
group.wait()
let arch = checkAppBundleArchitecture(at: self.appInfo.path.path)
var updatedCollection = tempCollection
if updatedCollection.count == 1, let firstURL = updatedCollection.first, firstURL.path.contains(".Trash") {
updatedCollection.removeAll()
}
DispatchQueue.main.async {
self.appInfo.fileSize = fileSize
self.appInfo.fileIcon = fileIcon
self.appInfo.arch = arch
self.appState?.appInfo = self.appInfo
if !self.undo {
self.appState?.selectedItems = Set(updatedCollection)
}
self.appState?.progressStep = 0
self.appState?.showProgress = false
let totalSize = fileSize.values.reduce(0, +)
let sizeString = ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
GlobalConsoleManager.shared.appendOutput("✓ Found \(updatedCollection.count) items (\(sizeString))\n", source: CurrentPage.applications.title)
self.completion?()
}
}
// Public method for GUI
func findPaths() {
Task(priority: .background) {
await GlobalConsoleManager.shared.appendOutput("Searching for files related to \(self.appInfo.appName)...\n", source: CurrentPage.applications.title)
if self.appInfo.webApp {
await GlobalConsoleManager.shared.appendOutput("Detected web app, scanning containers...\n", source: CurrentPage.applications.title)
self.containerCollection = self.getAllContainers(bundleURL: self.appInfo.path)
self.initialURLProcessing()
self.finalizeCollection()
} else {
await GlobalConsoleManager.shared.appendOutput("Scanning \(self.locations.apps.paths.count) system locations...\n", source: CurrentPage.applications.title)
self.containerCollection = self.getAllContainers(bundleURL: self.appInfo.path)
self.initialURLProcessing()
self.collectLocations()
self.finalizeCollection()
}
}
}
// Public method for CLI
func findPathsCLI() -> Set<URL> {
if self.appInfo.webApp {
self.containerCollection = self.getAllContainers(bundleURL: self.appInfo.path)
self.initialURLProcessing()
return finalizeCollectionCLI()
} else {
self.containerCollection = self.getAllContainers(bundleURL: self.appInfo.path)
self.initialURLProcessing()
self.collectLocationsCLI()
return finalizeCollectionCLI()
}
}
// Custom icon function that handles .app folders intelligently
private func getSmartIcon(for path: URL) -> NSImage? {
// For wrapped apps, check if this is the container path
if self.appInfo.wrapped {
// Get container path from inner app path
let containerPath = self.appInfo.path
.deletingLastPathComponent() // Remove ActualApp.app
.deletingLastPathComponent() // Remove Wrapper -> get Container.app
// If the current path matches the container, use the app's icon
if path.absoluteString == containerPath.absoluteString {
return self.appInfo.appIcon
}
}
// Regular logic for all other files
if path.pathExtension == "app" {
let contentsPath = path.appendingPathComponent("Contents")
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: contentsPath.path, isDirectory: &isDirectory) && isDirectory.boolValue {
// It's a real app bundle, get the app icon
return getIconForFileOrFolderNS(atPath: path)
} else {
// It's just a folder that ends with .app, get the folder icon
return NSWorkspace.shared.icon(for: .folder)
}
} else {
// For all other files/folders, use the standard function
return getIconForFileOrFolderNS(atPath: path)
}
}
}
// Get size using Spotlight metadata, fallback to manual calculation if needed
private func spotlightSizeForURL(_ url: URL) -> Int64 {
// Check if this is a directory (not a bundle like .app)
var isDirectory: ObjCBool = false
FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
// For plain directories, Spotlight metadata is unreliable (reports wrong size)
// Skip to manual calculation for directories (Containers, regular folders, etc.)
if isDirectory.boolValue {
return totalSizeOnDisk(for: url)
}
// For files and bundles (.app, .framework), use Spotlight metadata
let metadataItem = NSMetadataItem(url: url)
if let logical = metadataItem?.value(forAttribute: "kMDItemLogicalSize") as? Int64 {
return logical
}
// Fallback to manual calculation if Spotlight has no data
return totalSizeOnDisk(for: url)
}
================================================
FILE: Pearcleaner/Logic/AppState.swift
================================================
//
// AppState.swift
// Pearcleaner
//
// Created by Alin Lupascu on 10/31/23.
//
import AlinFoundation
import FinderSync
import Foundation
import SwiftUI
let home = FileManager.default.homeDirectoryForCurrentUser.path
struct AutoSlimStats: Codable {
var originalSize: Int64
var currentSize: Int64
var lastRunVersion: String
}
class AppState: ObservableObject {
// MARK: - Singleton Instance
static let shared = AppState()
@Published var appInfo: AppInfo
@Published var zombieFile: ZombieFile
@Published var sortedApps: [AppInfo] = []
@Published var selectedItems = Set<URL>()
@Published var currentView = CurrentDetailsView.empty
@Published var currentPage: CurrentPage // Initialized from stored preference in init()
@Published var showAlert: Bool = false
@Published var showDeleteHistory: Bool = false
@Published var sidebar: Bool = true
@Published var showProgress: Bool = false
@Published var isBrewCleanupInProgress: Bool = false
@Published var progressStep: Int = 0
@Published var leftoverProgress: (String, Double) = ("", 0.0)
@Published var finderExtensionEnabled: Bool = false
@Published var externalMode: Bool = false
@Published var multiMode: Bool = false
@Published var externalPaths: [URL] = [] // for handling multiple app from drops or deeplinks
@Published var selectedEnvironment: PathEnv? // for handling dev environments
@Published var trashError: Bool = false
@Published var isGridMode: Bool = false
// Volume information
@Published var volumeInfos: [VolumeInfo] = []
@Published var volumeAnimationShown: Bool = false
// Per-app sensitivity level (session-only, not persisted)
@Published var perAppSensitivity: [String: SearchSensitivityLevel] = [:]
func getBundleSize(for appInfo: AppInfo, updateState: @escaping (Int64) -> Void) {
DispatchQueue.global(qos: .userInitiated).async {
// Step 1: Check if the size is available and not 0 in the sortedApps cache
if let existingAppInfo = self.sortedApps.first(where: { $0.path == appInfo.path }) {
if existingAppInfo.bundleSize > 0 {
// Cached size is available, update the state immediately
DispatchQueue.main.async {
updateState(existingAppInfo.bundleSize)
}
return
}
}
// Step 2: If we reach here, we need to calculate the size
let calculatedSize = totalSizeOnDisk(for: appInfo.path)
DispatchQueue.main.async {
// Update the state and the array
updateState(calculatedSize)
if let index = self.sortedApps.firstIndex(where: { $0.path == appInfo.path }) {
var updatedAppInfo = self.sortedApps[index]
updatedAppInfo.bundleSize = calculatedSize
updatedAppInfo.arch = isOSArm() ? .arm : .intel
self.sortedApps[index] = updatedAppInfo
}
}
}
}
init() {
// Initialize currentPage from stored startup view preference
let storedStartupView = UserDefaults.standard.integer(forKey: "settings.interface.startupView")
// Load hidden pages
let hiddenPages = AppState.loadHiddenPages()
// Validate: If startup page is hidden, default to .applications
if hiddenPages.contains(storedStartupView) {
self.currentPage = .applications
UserDefaults.standard.set(CurrentPage.applications.rawValue, forKey: "settings.interface.startupView")
} else {
self.currentPage = CurrentPage(rawValue: storedStartupView) ?? .applications
}
self.appInfo = AppInfo(
id: UUID(),
path: URL(fileURLWithPath: ""),
bundleIdentifier: "",
appName: "",
appVersion: "",
appBuildNumber: nil,
appIcon: nil,
webApp: false,
wrapped: false,
system: false,
arch: .empty,
cask: nil,
steam: false,
hasSparkle: false,
isAppStore: false,
adamID: nil,
autoUpdates: nil,
bundleSize: 0,
fileSize: [:],
fileIcon: [:],
creationDate: nil,
contentChangeDate: nil,
lastUsedDate: nil,
dateAdded: nil,
entitlements: nil,
teamIdentifier: nil
)
self.zombieFile = ZombieFile(
id: UUID(),
fileSize: [:],
fileIcon: [:]
)
updateExtensionStatus()
NotificationCenter.default.addObserver(
self,
selector: #selector(updateExtensionStatus),
name: NSApplication.didBecomeActiveNotification,
object: nil
)
}
@objc func updateExtensionStatus() {
let extensionStatus = FIFinderSyncController.isExtensionEnabled
DispatchQueue.main.async {
self.finderExtensionEnabled = extensionStatus
}
}
// MARK: - Hidden Pages Management
static func loadHiddenPages() -> Set<Int> {
guard let data = UserDefaults.standard.data(forKey: "settings.interface.hiddenPages"),
let decoded = try? JSONDecoder().decode(Set<Int>.self, from: data) else {
return []
}
return decoded
}
static func saveHiddenPages(_ pages: Set<Int>) {
if let encoded = try? JSONEncoder().encode(pages) {
UserDefaults.standard.set(encoded, forKey: "settings.interface.hiddenPages")
}
}
// Add this method to restore zombie file associations
func restoreZombieAssociations() {
let zombieStorage = ZombieFileStorage.shared
// Clean up invalid associations first
let validAppPaths = sortedApps.map { $0.path }
zombieStorage.cleanupInvalidAssociations(validAppPaths: validAppPaths)
// For each app that has associations, add the zombie file URLs to their fileSize dictionary
// The actual sizes will be calculated later during the scan
for appInfo in sortedApps {
let associatedFiles = zombieStorage.getAssociatedFiles(for: appInfo.path)
// Add zombie files to this app's file tracking
// We'll add them with size 0 - the real sizes will be calculated during scan
for zombieFile in associatedFiles {
// Only add if the file still exists
if FileManager.default.fileExists(atPath: zombieFile.path) {
// Add to the current appInfo if it matches, or find and update the correct one
if let appIndex = sortedApps.firstIndex(where: { $0.path == appInfo.path }) {
sortedApps[appIndex].fileSize[zombieFile] = 0 // Placeholder size
// Icon will be fetched during normal scan process
}
}
}
}
}
func loadVolumeInfo() {
DispatchQueue.global(qos: .userInitiated).async {
var volumes: [VolumeInfo] = []
// First, add root volume (/)
if let rootVolume = self.getVolumeInfo(for: URL(fileURLWithPath: "/")) {
volumes.append(rootVolume)
#if DEBUG
// Duplicate for testing
let duplicateRoot = VolumeInfo(
name: "\(rootVolume.name) Debug",
path: rootVolume.path,
icon: rootVolume.icon,
totalSpace: rootVolume.totalSpace,
usedSpace: rootVolume.usedSpace,
realAvailableSpace: rootVolume.realAvailableSpace,
purgeableSpace: rootVolume.purgeableSpace,
isExternal: false
)
volumes.append(duplicateRoot)
#endif
}
// Then enumerate all mounted volumes in /Volumes
let volumesPath = "/Volumes"
if let volumeContents = try? FileManager.default.contentsOfDirectory(
atPath: volumesPath)
{
for volumeName in volumeContents {
// Skip dot folders (hidden folders like .timemachine)
if volumeName.hasPrefix(".") {
continue
}
let volumePath = "\(volumesPath)/\(volumeName)"
let volumeURL = URL(fileURLWithPath: volumePath)
// Resolve symlinks
let resolvedURL = volumeURL.resolvingSymlinksInPath()
// Skip if it's the same as root (to avoid duplicates)
if resolvedURL.path == "/" {
continue
}
// Skip Time Machine volumes
if !self.isTimeMachineVolume(url: resolvedURL) {
if let volumeInfo = self.getVolumeInfo(
for: resolvedURL, displayName: volumeName)
{
volumes.append(volumeInfo)
}
}
}
}
DispatchQueue.main.async {
// Preserve hasAnimated state from existing volumes
for i in 0..<volumes.count {
if let existingVolume = self.volumeInfos.first(where: {
$0.path == volumes[i].path
}) {
volumes[i].hasAnimated = existingVolume.hasAnimated
}
}
self.volumeInfos = volumes
}
}
}
private func getVolumeInfo(for url: URL, displayName: String? = nil) -> VolumeInfo? {
let keys: [URLResourceKey] = [
.volumeNameKey,
.volumeAvailableCapacityKey,
.volumeAvailableCapacityForImportantUsageKey,
.volumeTotalCapacityKey,
.volumeIsRemovableKey,
.volumeIsEjectableKey,
]
guard let resource = try? url.resourceValues(forKeys: Set(keys)),
let total = resource.volumeTotalCapacity,
let availableWithPurgeable = resource.volumeAvailableCapacity,
let realAvailable = resource.volumeAvailableCapacityForImportantUsage
else {
return nil
}
// Use regular available capacity if important usage capacity is 0 (common for DMGs)
let effectiveAvailable = realAvailable > 0 ? Int(realAvailable) : availableWithPurgeable
let finderTotalAvailable = Int64(effectiveAvailable)
let realAvailableSpace = Int64(availableWithPurgeable)
let purgeableSpace = max(0, finderTotalAvailable - realAvailableSpace)
let realUsedSpace = Int64(total) - finderTotalAvailable
let name = displayName ?? resource.volumeName ?? url.lastPathComponent
let icon = NSWorkspace.shared.icon(forFile: url.path)
icon.size = NSSize(width: 32, height: 32)
// Debug prints
// print("=== Volume: \(name) ===")
// print("Total: \(ByteCountFormatter.string(fromByteCount: Int64(total), countStyle: .file))")
// print("Available (with purgeable): \(ByteCountFormatter.string(fromByteCount: Int64(availableWithPurgeable), countStyle: .file))")
// print("Available (important usage): \(ByteCountFormatter.string(fromByteCount: Int64(realAvailable), countStyle: .file))")
// print("Calculated used: \(ByteCountFormatter.string(fromByteCount: realUsedSpace, countStyle: .file))")
// print("Calculated purgeable: \(ByteCountFormatter.string(fromByteCount: purgeableSpace, countStyle: .file))")
// print("========================")
// Check if volume is external (removable or ejectable)
let isRemovable = resource.volumeIsRemovable ?? false
let isEjectable = resource.volumeIsEjectable ?? false
let isExternal = isRemovable || isEjectable
return VolumeInfo(
name: name,
path: url.path,
icon: Image(nsImage: icon),
totalSpace: Int64(total),
usedSpace: realUsedSpace,
realAvailableSpace: realAvailableSpace,
purgeableSpace: purgeableSpace,
isExternal: isExternal
)
}
private func isTimeMachineVolume(url: URL) -> Bool {
let backupsPath = url.appendingPathComponent("Backups.backupdb")
let tmDirectoryPath = url.appendingPathComponent(".com.apple.timemachine")
// Check for common Time Machine indicators
let hasBackupsDB = FileManager.default.fileExists(atPath: backupsPath.path)
let hasTMDirectory = FileManager.default.fileExists(atPath: tmDirectoryPath.path)
// Check if volume name contains "TimeMachine"
let volumeName = url.lastPathComponent.lowercased()
let isNamedTimeMachine =
volumeName.contains("timemachine") || volumeName.contains("time machine")
|| volumeName.contains("time_machine")
return hasBackupsDB || hasTMDirectory || isNamedTimeMachine
}
}
struct VolumeInfo: Identifiable, Equatable {
let id = UUID()
let name: String
let path: String
let icon: Image
let totalSpace: Int64
let usedSpace: Int64
let realAvailableSpace: Int64
let purgeableSpace: Int64
let isExternal: Bool
var hasAnimated: Bool = false
static func == (lhs: VolumeInfo, rhs: VolumeInfo) -> Bool {
return lhs.id == rhs.id && lhs.name == rhs.name && lhs.path == rhs.path
&& lhs.totalSpace == rhs.totalSpace && lhs.usedSpace == rhs.usedSpace
&& lhs.realAvailableSpace == rhs.realAvailableSpace
&& lhs.purgeableSpace == rhs.purgeableSpace && lhs.isExternal == rhs.isExternal
&& lhs.hasAnimated == rhs.hasAnimated
}
}
struct AppInfo: Identifiable, Equatable, Hashable {
let id: UUID
let path: URL
let bundleIdentifier: String
let appName: String
let appVersion: String
let appBuildNumber: String?
let appIcon: NSImage?
let webApp: Bool
let wrapped: Bool
let system: Bool
var arch: Arch
let cask: String?
let steam: Bool // New property to mark Steam games
let hasSparkle: Bool // Has Sparkle framework or Sparkle keys in Info.plist (detected at load time)
let isAppStore: Bool // Has App Store receipt or iTunes metadata (detected at load time)
let adamID: UInt64? // App Store adamID from mdls metadata (nil if not App Store or not indexed)
let autoUpdates: Bool? // Homebrew cask auto_updates flag from cask JSON (nil if not Homebrew or unknown)
var bundleSize: Int64 // Only used in the app list view
var lipoSavings: Int64? // Cached lipo savings (nil=not calculated, 0=no savings, >0=savings available)
var fileSize: [URL: Int64] // Logical file sizes (matches Finder)
var fileIcon: [URL: NSImage?]
let creationDate: Date?
let contentChangeDate: Date?
let lastUsedDate: Date?
let dateAdded: Date?
let entitlements: [String]?
let teamIdentifier: String?
var totalSize: Int64 {
return fileSize.values.reduce(0, +)
}
var brew: Bool {
return cask != nil
}
var executableURL: URL? {
let infoPlistURL = path.appendingPathComponent("Contents/Info.plist")
guard let info = NSDictionary(contentsOf: infoPlistURL) as? [String: Any],
let execName = info["CFBundleExecutable"] as? String
else {
return nil
}
return path.appendingPathComponent("Contents/MacOS").appendingPathComponent(execName)
}
var averageColor: Color? {
Color(appIcon?.averageColor ?? .clear)
}
var isEmpty: Bool {
return path == URL(fileURLWithPath: "./") && bundleIdentifier.isEmpty && appName.isEmpty
}
static let empty = AppInfo(
id: UUID(), path: URL(fileURLWithPath: ""), bundleIdentifier: "", appName: "",
appVersion: "", appBuildNumber: nil, appIcon: nil, webApp: false, wrapped: false, system: false, arch: .empty,
cask: nil, steam: false, hasSparkle: false, isAppStore: false, adamID: nil, autoUpdates: nil, bundleSize: 0, lipoSavings: 0, fileSize: [:], fileIcon: [:],
creationDate: nil, contentChangeDate: nil, lastUsedDate: nil, dateAdded: nil, entitlements: nil, teamIdentifier: nil)
}
// MARK: - AppInfoMini (Phase 1 Fast Loading)
/// Lightweight version of AppInfo for fast initial app list display
/// Contains only essential properties needed for list rendering and sorting
/// Converts to full AppInfo with placeholder values for deferred properties
struct AppInfoMini {
let id: UUID
let path: URL
let bundleIdentifier: String
let appName: String
let appVersion: String
let appIcon: NSImage?
let system: Bool
let bundleSize: Int64 // Always calculated (mdls or totalSizeOnDisk)
let creationDate: Date?
let contentChangeDate: Date?
let lastUsedDate: Date?
let dateAdded: Date?
/// Convert AppInfoMini to full AppInfo with placeholder values for expensive properties
/// Phase 2 will populate these expensive properties in background
func toAppInfo() -> AppInfo {
return AppInfo(
id: self.id,
path: self.path,
bundleIdentifier: self.bundleIdentifier,
appName: self.appName,
appVersion: self.appVersion,
appBuildNumber: nil, // Phase 2
appIcon: self.appIcon,
webApp: false, // Phase 2
wrapped: false, // Phase 2
system: self.system,
arch: .empty, // Phase 2 (expensive)
cask: nil, // Phase 2 (expensive)
steam: false, // Phase 2
hasSparkle: false, // Phase 2 (expensive)
isAppStore: false, // Phase 2 (expensive)
adamID: nil, // Phase 2
autoUpdates: nil, // Phase 2
bundleSize: self.bundleSize, // ✅ Already calculated
lipoSavings: nil, // Phase 2
fileSize: [:], // Populated when user selects app
fileIcon: [:], // Populated when user selects app
creationDate: self.creationDate,
contentChangeDate: self.contentChangeDate,
lastUsedDate: self.lastUsedDate,
dateAdded: self.dateAdded,
entitlements: nil, // Phase 2 (expensive)
teamIdentifier: nil // Phase 2 (expensive)
)
}
}
extension AppInfo {
/// Convert full AppInfo to lightweight AppInfoMini (for fallback case when no mdls metadata)
func toMini() -> AppInfoMini {
return AppInfoMini(
id: self.id,
path: self.path,
bundleIdentifier: self.bundleIdentifier,
appName: self.appName,
appVersion: self.appVersion,
appIcon: self.appIcon,
system: self.system,
bundleSize: self.bundleSize,
creationDate: self.creationDate,
contentChangeDate: self.contentChangeDate,
lastUsedDate: self.lastUsedDate,
dateAdded: self.dateAdded
)
}
/// Generate debug string that excludes NSImage properties for cleaner output
func getDebugString() -> String {
// Format file paths with sizes
let filePathsFormatted = fileSize
.sorted { $0.key.path < $1.key.path }
.map { " • \($0.key.path) (\(formatBytes($0.value)))" }
.joined(separator: "\n")
return """
====================================
AppInfo Debug Output
====================================
ID: \(id)
App Name: \(appName)
Bundle ID: \(bundleIdentifier)
Path: \(path.path)
Version: \(appVersion)
Build Number: \(appBuildNumber ?? "nil")
====================================
Architecture: \(arch)
Web App: \(webApp)
Wrapped: \(wrapped)
System App: \(system)
Steam Game: \(steam)
Cask: \(cask ?? "nil")
Auto Updates: \(autoUpdates ?? false)
====================================
Bundle Size: \(formatBytes(bundleSize))
Total Size: \(formatBytes(totalSize))
Lipo Savings: \(lipoSavings.map { formatBytes($0) } ?? "nil")
====================================
Creation Date: \(creationDate?.description ?? "nil")
Content Change: \(contentChangeDate?.description ?? "nil")
Last Used: \(lastUsedDate?.description ?? "nil")
Date Added: \(dateAdded?.description ?? "nil")
====================================
Entitlements/Binaries:
\(entitlements?.joined(separator: "\n") ?? "nil")
====================================
Team ID: \(teamIdentifier ?? "nil")
====================================
Update Sources:
App Store: \(isAppStore)
Homebrew: \(brew)
Sparkle: \(hasSparkle)
====================================
Files (\(fileSize.count)):
\(filePathsFormatted.isEmpty ? " (none)" : filePathsFormatted)
====================================
"""
}
}
// MARK: - Fuzzy Search
extension AppInfo: FuzzySearchable {
var searchableString: String {
return appName
}
}
struct ZombieFile: Identifiable, Equatable, Hashable {
let id: UUID
var fileSize: [URL: Int64] // Logical file sizes (matches Finder)
var fileIcon: [URL: NSImage?]
var totalSize: Int64 {
return fileSize.values.reduce(0, +)
}
static let empty = ZombieFile(id: UUID(), fileSize: [:], fileIcon: [:])
}
struct AssociatedZombieFile: Codable {
let appPath: URL // Unique identifier for the app
let filePath: URL // The zombie file to be processed
}
class ZombieFileStorage {
static let shared = ZombieFileStorage()
var associatedFiles: [URL: [URL]] = [:] // Key: App Path, Value: Zombie File URLs
// UserDefaults key for persistence
private let associationsKey = "settings.general.zombie.associations"
private init() {
loadAssociations()
}
// Load associations from UserDefaults
private func loadAssociations() {
if let data = UserDefaults.standard.data(forKey: associationsKey),
let storedAssociations = try? JSONDecoder().decode([String: [String]].self, from: data)
{
associatedFiles = storedAssociations.reduce(into: [URL: [URL]]()) { result, pair in
let appURL = URL(fileURLWithPath: pair.key)
let zombieURLs = pair.value.map { URL(fileURLWithPath: $0) }
result[appURL] = zombieURLs
}
}
}
// Save associations to UserDefaults
private func saveAssociations() {
let storableAssociations = associatedFiles.reduce(into: [String: [String]]()) {
result, pair in
result[pair.key.path] = pair.value.map { $0.path }
}
if let encoded = try? JSONEncoder().encode(storableAssociations) {
UserDefaults.standard.set(encoded, forKey: associationsKey)
}
}
func addAssociation(appPath: URL, zombieFilePath: URL) {
if associatedFiles[appPath] == nil {
associatedFiles[appPath] = []
}
if !associatedFiles[appPath]!.contains(zombieFilePath) {
associatedFiles[appPath]?.append(zombieFilePath)
saveAssociations()
}
}
func getAssociatedFiles(for appPath: URL) -> [URL] {
return associatedFiles[appPath] ?? []
}
func isPathAssociated(_ path: URL) -> Bool {
return associatedFiles.values.contains { $0.contains(path) }
}
func clearAssociations(for appPath: URL) {
associatedFiles[appPath] = nil
saveAssociations()
}
func removeAssociation(appPath: URL, zombieFilePath: URL) {
guard var associatedFilesList = associatedFiles[appPath] else { return }
associatedFilesList.removeAll { $0 == zombieFilePath }
if associatedFilesList.isEmpty {
associatedFiles.removeValue(forKey: appPath) // Remove key if no files are left
} else {
associatedFiles[appPath] = associatedFilesList
}
saveAssociations()
}
// Clean up associations for apps that no longer exist
func cleanupInvalidAssociations(validAppPaths: [URL]) {
let currentAppPaths = Set(associatedFiles.keys)
let validAppPathsSet = Set(validAppPaths)
let invalidPaths = currentAppPaths.subtracting(validAppPathsSet)
for invalidPath in invalidPaths {
associatedFiles.removeValue(forKey: invalidPath)
}
if !invalidPaths.isEmpty {
saveAssociations()
}
}
}
enum Arch {
case arm
case intel
case universal
case empty
var type: String {
switch self {
case .arm:
return "arm"
case .intel:
return "intel"
case .universal:
return String(localized: "universal")
case .empty:
return ""
}
}
}
enum CurrentPage: Int, CaseIterable, Identifiable {
case applications
case development
case fileSearch
case homebrew
case lipo
case orphans
case packages
case plugins
case services
case updater
var id: Int { rawValue }
/// Pages that are hidden in release builds (only visible in DEBUG mode)
static var debugOnlyPages: [CurrentPage] {
return []
}
/// Returns all pages filtered based on build configuration and user visibility settings
static var availablePages: [CurrentPage] {
let hiddenPages = AppState.loadHiddenPages()
#if DEBUG
return CurrentPage.allCases.filter { !hiddenPages.contains($0.rawValue) }
#else
return CurrentPage.allCases
.filter { !debugOnlyPages.contains($0) }
.filter { !hiddenPages.contains($0.rawValue) }
#endif
}
var details: (title: String, icon: String) {
switch self {
case .applications:
return (String(localized: "Apps"), "macwindow")
case .development:
return (String(localized: "Developer"), "hammer.circle")
case .fileSearch:
return (String(localized: "File Search"), "magnifyingglass")
case .homebrew:
return (String(localized: "Homebrew"), "mug")
case .lipo:
return (String(localized: "Lipo"), "scissors")
case .orphans:
return (String(localized: "Orphans"), "doc.text.magnifyingglass")
case .packages:
return (String(localized: "Packages"), "shippingbox")
case .plugins:
return (String(localized: "Plugins"), "puzzlepiece")
case .services:
return (String(localized: "Services"), "gearshape.2")
case .updater:
return (String(localized: "Updater"), "arrow.down.circle")
}
}
var title: String { details.title }
var icon: String { details.icon }
}
//MARK: Sorting for sidebar apps list
enum SortOption: Int, CaseIterable, Identifiable {
case alphabetical
case size
case creationDate
case dateAdded
case contentChangeDate
case lastUsedDate
var id: Int { rawValue }
var title: String {
let titles: [String] = [
String(localized: "App Name"),
String(localized: "App Size"),
String(localized: "Date Created"),
String(localized: "Date Added"),
String(localized: "Modified Date"),
String(localized: "Last Used Date"),
]
return titles[rawValue]
}
}
//MARK: Sorting for file list view
enum SortOptionList: String, CaseIterable {
case name = "name"
case path = "path"
case size = "size"
var title: String {
switch self {
case .name: return String(localized: "Name")
case .path: return String(localized: "Path")
case .size: return String(localized: "Size")
}
}
var systemImage: String {
switch self {
case .name: return "textformat"
case .path: return "folder"
case .size: return "number"
}
}
}
enum CurrentTabView: Int, CaseIterable {
case general
case interface
case folders
case update
case helper
case about
var title: String {
switch self {
case .general: return String(localized: "General")
case .interface: return String(localized: "Interface")
case .folders: return String(localized: "Folders")
case .update: return String(localized: "Update")
case .helper: return String(localized: "Helper")
case .about: return String(localized: "About")
}
}
}
enum CurrentDetailsView: Int {
case empty
case files
// case zombie
}
extension AppState {
// Call this when switching to view an app to ensure associated files are loaded
func loadAssociatedFilesForCurrentApp() {
guard !appInfo.isEmpty else { return }
let associatedFiles = ZombieFileStorage.shared.getAssociatedFiles(for: appInfo.path)
for zombieFile in associatedFiles {
if FileManager.default.fileExists(atPath: zombieFile.path) {
// Add to current app's tracking if not already present
if appInfo.fileSize[zombieFile] == nil {
appInfo.fileSize[zombieFile] = 0 // Will be calculated during scan
}
}
}
}
}
================================================
FILE: Pearcleaner/Logic/AppsUpdater/AppStoreReset.swift
================================================
//
// AppStoreReset.swift
// Pearcleaner
//
// Based on mas-cli's reset command
// Copyright © 2018 mas-cli. All rights reserved.
// License: MIT
//
import Foundation
import AppKit
import StoreFoundation
import CommerceKit
/// Manages App Store service reset operations
/// Based on mas-cli's Reset.swift implementation
/// Use this when App Store downloads are stuck or broken
class AppStoreReset {
/// Result of a reset operation
enum ResetResult {
case success
case failure(String)
}
/// Resets the App Store by:
/// 1. Terminating Dock and storeuid processes (handles App Store UI)
/// 2. Killing Mac App Store system daemons
/// 3. Deleting download cache directory
///
/// - Returns: ResetResult indicating success or failure with error message
static func reset() async -> ResetResult {
// Step 1: Terminate App Store UI processes
let terminatedApps = terminateAppStoreApps()
// Step 2: Kill system daemons
let killedDaemons = await killAppStoreDaemons()
// Step 3: Delete download cache
let deletedCache = deleteDownloadCache()
// Check if all steps succeeded
if terminatedApps && killedDaemons && deletedCache {
return .success
} else {
var errors: [String] = []
if !terminatedApps { errors.append("Failed to terminate App Store apps") }
if !killedDaemons { errors.append("Failed to kill system daemons") }
if !deletedCache { errors.append("Failed to delete download cache") }
return .failure(errors.joined(separator: ", "))
}
}
// MARK: - Private Implementation
/// Terminates Dock and storeuid processes (handles App Store UI)
private static func terminateAppStoreApps() -> Bool {
let appNames = ["Dock", "storeuid"]
var allTerminated = true
for appName in appNames {
// Find running instance
guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.\(appName)").first else {
// App not running, skip (not an error)
continue
}
// Terminate app
if !app.terminate() {
allTerminated = false
}
}
return allTerminated
}
/// Kills Mac App Store system daemons using sysctl + proc_pidpath
/// This is the low-level approach mas-cli uses (not using NSRunningApplication)
private static func killAppStoreDaemons() async -> Bool {
let daemonPaths = [
"/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Resources/storekitagent",
"/System/Library/PrivateFrameworks/AppStoreDaemon.framework/Support/appstoreagent",
"/System/Library/PrivateFrameworks/AppStoreDaemon.framework/Support/appstored",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/Current/Resources/storekitagent",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Resources/storekitagent",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Resources/storeaccountd",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/Current/Resources/storeaccountd",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Resources/storeaccountd",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Resources/storeassetd",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/Current/Resources/storeassetd",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Resources/storeassetd",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Resources/storedownloadd",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/Current/Resources/storedownloadd",
"/System/Library/PrivateFrameworks/CommerceKit.framework/Resources/storedownloadd",
"/System/Library/PrivateFrameworks/AppStoreDaemon.framework/Versions/A/Support/appstorecomponentsd",
"/System/Library/PrivateFrameworks/AppStoreDaemon.framework/Support/appstorecomponentsd"
]
var allKilled = true
for daemonPath in daemonPaths {
// Find PID for daemon path using sysctl
if let pid = findPID(forExecutablePath: daemonPath) {
// Kill process
let result = kill(pid, SIGTERM)
if result != 0 {
allKilled = false
}
}
// If daemon not running, that's fine (not an error)
}
return allKilled
}
/// Finds the PID for a given executable path using sysctl
/// This is how mas-cli does it (low-level process enumeration)
private static func findPID(forExecutablePath targetPath: String) -> pid_t? {
// Get all process PIDs using sysctl
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
var length: size_t = 0
// Get required buffer size
if sysctl(&name, u_int(name.count), nil, &length, nil, 0) == -1 {
return nil
}
// Allocate buffer
let count = length / MemoryLayout<kinfo_proc>.stride
var procs = [kinfo_proc](repeating: kinfo_proc(), count: count)
// Get process list
if sysctl(&name, u_int(name.count), &procs, &length, nil, 0) == -1 {
return nil
}
// Iterate through processes
let actualCount = length / MemoryLayout<kinfo_proc>.stride
for i in 0..<actualCount {
let pid = procs[i].kp_proc.p_pid
// Get executable path for this PID using proc_pidpath
var pathBuffer = [Int8](repeating: 0, count: Int(MAXPATHLEN))
let pathLength = proc_pidpath(pid, &pathBuffer, UInt32(MAXPATHLEN))
if pathLength > 0 {
let executablePath = String(cString: pathBuffer)
if executablePath == targetPath {
return pid
}
}
}
return nil
}
/// Deletes the App Store download cache using CommerceKit's CKDownloadDirectory()
private static func deleteDownloadCache() -> Bool {
// Get download directory from CommerceKit (mas-cli approach)
let downloadDirPath = CKDownloadDirectory(nil)
let fileManager = FileManager.default
// Check if directory exists
guard fileManager.fileExists(atPath: downloadDirPath) else {
// Directory doesn't exist, nothing to delete (not an error)
return true
}
// Delete directory
do {
try fileManager.removeItem(atPath: downloadDirPath)
return true
} catch {
return false
}
}
}
================================================
FILE: Pearcleaner/Logic/AppsUpdater/AppStoreUpdateChecker.swift
================================================
//
// AppStoreUpdateChecker.swift
// Pearcleaner
//
// Created by Alin Lupascu on 10/13/25.
//
import Foundation
import CommerceKit
import StoreFoundation
import AlinFoundation
class AppStoreUpdateChecker {
private static let logger = UpdaterDebugLogger.shared
/// Fallback regions to check if app not found in primary region
/// Ordered by usage: CN, US, HK, JP, KR, SG
private static let fallbackRegions = ["CN", "US", "HK", "JP", "KR", "SG"]
/// Check if an app is a wrapped iPad/iOS app
/// app.wrapped is already set correctly during app launch
private static func isIOSApp(_ app: AppInfo) -> Bool {
return app.wrapped
}
static func checkForUpdates(apps: [AppInfo]) async -> [UpdateableApp] {
guard !apps.isEmpty else { return [] }
logger.log(.appStore, "Starting App Store update check for \(apps.count) apps")
await GlobalConsoleManager.shared.appendOutput("Checking for App Store updates (\(apps.count) apps)...\n", source: CurrentPage.updater.title)
// Create optimal chunks based on CPU cores (smaller chunks for App Store API calls)
let chunks = createOptimalChunks(from: apps, minChunkSize: 3, maxChunkSize: 10)
// Process chunks concurrently using TaskGroup
return await withTaskGroup(of: [UpdateableApp].self) { group in
for chunk in chunks {
group.addTask {
await checkChunk(chunk: chunk)
}
}
// Collect results from all chunks
var allUpdates: [UpdateableApp] = []
for await chunkUpdates in group {
// Check for cancellation between chunks
if Task.isCancelled {
break
}
allUpdates.append(contentsOf: chunkUpdates)
}
logger.log(.appStore, "Found \(allUpdates.count) available App Store updates")
await GlobalConsoleManager.shared.appendOutput("Found \(allUpdates.count) App Store update(s)\n", source: CurrentPage.updater.title)
return allUpdates
}
}
/// Check a chunk of apps for updates concurrently
private static func checkChunk(chunk: [AppInfo]) async -> [UpdateableApp] {
await withTaskGroup(of: UpdateableApp?.self) { group in
for app in chunk {
group.addTask {
await checkSingleApp(app: app)
}
}
// Collect non-nil results
var updates: [UpdateableApp] = []
for await update in group {
if let update = update {
updates.append(update)
}
}
return updates
}
}
/// Check a single app for updates
private static func checkSingleApp(app: AppInfo) async -> UpdateableApp? {
logger.log(.appStore, "Checking: \(app.appName) (\(app.bundleIdentifier))")
// OPTIMIZATION: Try direct adamID lookup first if available (much faster than bundleID search)
let result: (AppStoreInfo, String)?
let isWrappedApp = isIOSApp(app)
if let adamID = app.adamID {
logger.log(.appStore, " 🚀 Fast path - using cached adamID: \(adamID)")
let primaryRegion = await getAppStoreRegion()
// Use entity filtering based on app type (prevents iOS version pollution for Mac apps)
let entity = isWrappedApp ? "macSoftware" : "desktopSoftware"
if let appStoreInfo = await fetchAppStoreInfoByAdamID(adamID: adamID, region: primaryRegion, entity: entity) {
result = (appStoreInfo, primaryRegion)
logger.log(.appStore, " ✅ Found via adamID lookup")
} else {
// Fallback to bundleID search if adamID lookup fails (app might have been transferred)
logger.log(.appStore, " ⚠️ adamID lookup failed, falling back to bundleID search")
result = await getAppStoreInfo(bundleID: app.bundleIdentifier, isWrappedIOSApp: isWrappedApp)
}
} else {
// No cached adamID - use standard bundleID search
logger.log(.appStore, " 📍 Standard path - using bundleID lookup")
result = await getAppStoreInfo(bundleID: app.bundleIdentifier, isWrappedIOSApp: isWrappedApp)
}
guard let (appStoreInfo, foundRegion) = result else {
logger.log(.appStore, " ❌ API lookup failed - not found in App Store")
return nil
}
logger.log(.appStore, " ✅ Found in App Store: v\(appStoreInfo.version) (adamID: \(appStoreInfo.adamID)) in region: \(foundRegion)")
// Use Version for robust comparison (handles 1, 2, 3+ component versions)
let installedVer = Version(versionNumber: app.appVersion, buildNumber: nil)
let availableVer = Version(versionNumber: appStoreInfo.version, buildNumber: nil)
// Skip if versions are empty/invalid
guard !installedVer.isEmpty && !availableVer.isEmpty else {
logger.log(.appStore, " ⚠️ Skipped - empty/invalid version (Installed: \(app.appVersion), Available: \(appStoreInfo.version))")
return nil
}
logger.log(.appStore, " Comparing versions - Installed: \(app.appVersion), Available: \(appStoreInfo.version)")
// Detect if this is a wrapped iOS app
let isIOSApp = Self.isIOSApp(app)
// Only add if App Store version is GREATER than installed version
if availableVer > installedVer {
logger.log(.appStore, " 📦 UPDATE AVAILABLE: \(app.appVersion) → \(appStoreInfo.version)\(isIOSApp ? " (iOS app)" : "")")
return UpdateableApp(
appInfo: app,
availableVersion: appStoreInfo.version,
availableBuildNumber: nil, // App Store doesn't provide separate build numbers
source: .appStore,
adamID: appStoreInfo.adamID,
appStoreURL: appStoreInfo.appStoreURL,
status: .idle,
progress: 0.0,
isSelectedForUpdate: true,
releaseTitle: nil,
releaseDescription: appStoreInfo.releaseNotes,
releaseNotesLink: nil,
releaseDate: appStoreInfo.releaseDate,
isPreRelease: false, // App Store updates are not pre-releases
isIOSApp: isIOSApp,
foundInRegion: foundRegion,
fetchedReleaseNotes: nil, // App Store has inline release notes
appcastItem: nil // App Store doesn't use Sparkle
)
}
logger.log(.appStore, " ✓ Up to date")
return nil
}
private struct AppStoreInfo {
let adamID: UInt64
let version: String
let appStoreURL: String
let releaseNotes: String?
let releaseDate: String?
}
private static func getAppStoreInfo(bundleID: String, isWrappedIOSApp: Bool) async -> (info: AppStoreInfo, region: String)? {
// Get user's primary region
let primaryRegion = await getAppStoreRegion()
logger.log(.appStore, " Primary region: \(primaryRegion)")
// Try primary region first with all entity types
if let info = await tryAllEntities(bundleID: bundleID, region: primaryRegion, isWrappedIOSApp: isWrappedIOSApp) {
return (info, primaryRegion)
}
// If not found, try fallback regions
logger.log(.appStore, " Not found in primary region, trying fallback regions...")
for region in fallbackRegions where region != primaryRegion {
logger.log(.appStore, " Trying region: \(region)")
if let info = await tryAllEntities(bundleID: bundleID, region: region, isWrappedIOSApp: isWrappedIOSApp) {
logger.log(.appStore, " ✓ Found in region: \(region)")
return (info, region)
}
}
logger.log(.appStore, " ❌ Not found in any region")
return nil
}
/// Try all entity types for a given region based on app type
private static func tryAllEntities(bundleID: String, region: String, isWrappedIOSApp: Bool) async -> AppStoreInfo? {
if isWrappedIOSApp {
// Wrapped iOS apps: Only try macSoftware (covers "Designed for iPad" apps)
logger.log(.appStore, " Trying entity: macSoftware (iOS app)")
if let info = await fetchAppStoreInfo(bundleID: bundleID, region: region, entity: "macSoftware") {
logger.log(.appStore, " ✓ Found with macSoftware")
return info
}
} else {
// Regular Mac apps: Only try desktopSoftware (prevents iOS version pollution)
logger.log(.appStore, " Trying entity: desktopSoftware (Mac app)")
if let info = await fetchAppStoreInfo(bundleID: bundleID, region: region, entity: "desktopSoftware") {
logger.log(.appStore, " ✓ Found with desktopSoftware")
return info
}
}
return nil
}
private static func fetchAppStoreInfo(bundleID: String, region: String, entity: String?) async -> AppStoreInfo? {
// Query iTunes Search API using bundle ID
guard let endpoint = URL(string: "https://itunes.apple.com/lookup") else {
return nil
}
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
var queryItems = [
URLQueryItem(name: "bundleId", value: bundleID),
URLQueryItem(name: "country", value: region),
URLQueryItem(name: "limit", value: "1")
]
// Add entity parameter if provided (desktopSoftware or macSoftware)
if let entity = entity {
queryItems.append(URLQueryItem(name: "entity", value: entity))
}
components?.queryItems = queryItems
guard let url = components?.url else {
return nil
}
do {
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 30)
let (data, _) = try await URLSession.shared.data(for: request)
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let resultCount = json["resultCount"] as? Int,
resultCount > 0,
let results = json["results"] as? [[String: Any]],
let firstResult = results.first,
let trackId = firstResult["trackId"] as? UInt64,
let version = firstResult["version"] as? String,
let trackViewUrl = firstResult["trackViewUrl"] as? String {
// Extract optional metadata
let releaseNotes = firstResult["releaseNotes"] as? String
let releaseDate = firstResult["currentVersionReleaseDate"] as? String
return AppStoreInfo(
adamID: trackId,
version: version,
appStoreURL: trackViewUrl,
releaseNotes: releaseNotes,
releaseDate: releaseDate
)
}
} catch {
// Error querying iTunes API - silently fail
}
return nil
}
/// Fetch App Store info using adamID (faster than bundleID lookup)
/// Uses direct adamID lookup, avoiding multi-region/entity fallback overhead
private static func fetchAppStoreInfoByAdamID(adamID: UInt64, region: String, entity: String?) async -> AppStoreInfo? {
// Query iTunes Search API using adamID
guard let endpoint = URL(string: "https://itunes.apple.com/lookup") else {
return nil
}
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
var queryItems = [
URLQueryItem(name: "id", value: String(adamID)),
URLQueryItem(name: "country", value: region),
URLQueryItem(name: "limit", value: "1")
]
// Add entity parameter if provided (desktopSoftware for Mac apps, macSoftware for iOS apps)
if let entity = entity {
queryItems.append(URLQueryItem(name: "entity", value: entity))
}
components?.queryItems = queryItems
guard let url = components?.url else {
return nil
}
do {
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 30)
let (data, _) = try await URLSession.shared.data(for: request)
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let resultCount = json["resultCount"] as? Int,
resultCount > 0,
let results = json["results"] as? [[String: Any]],
let firstResult = results.first,
let trackId = firstResult["trackId"] as? UInt64,
let version = firstResult["version"] as? String,
let trackViewUrl = firstResult["trackViewUrl"] as? String {
// Extract optional metadata
let releaseNotes = firstResult["releaseNotes"] as? String
let releaseDate = firstResult["currentVersionReleaseDate"] as? String
return AppStoreInfo(
adamID: trackId,
version: version,
appStoreURL: trackViewUrl,
releaseNotes: releaseNotes,
releaseDate: releaseDate
)
}
} catch {
// Error querying iTunes API - silently fail
}
return nil
}
// MARK: - Private Helpers
/// Get the user's App Store region (2-letter ISO 3166-1 alpha-2 code)
/// Locale.region.identifier already returns alpha-2 codes (e.g., "US", "GB", "FR")
private static func getAppStoreRegion() async -> String {
return Locale.autoupdatingCurrent.region?.identifier ?? "US"
}
}
================================================
FILE: Pearcleaner/Logic/AppsUpdater/AppStoreUpdater.swift
================================================
//
// AppStoreUpdater.swift
// Pearcleaner
//
// Created by Alin Lupascu on 10/13/25.
//
import Foundation
import CommerceKit
import StoreFoundation
import AlinFoundation
// MARK: - Error Types
enum AppStoreUpdateError: Error {
case noDownloads
case downloadFailed(String)
case downloadCancelled
case networkError(Error)
}
// MARK: - AppStoreUpdater
class AppStoreUpdater {
static let shared = AppStoreUpdater()
private init() {}
/// Check if running macOS version affected by installd bug
/// Affected: 14.8.2 (Darwin 24.6.2), 15.7.2 (Darwin 25.7.2), and 26.1+ (Darwin 26.1+)
private func needsInstalldWorkaround() -> Bool {
let version = ProcessInfo.processInfo.operatingSystemVersion
// macOS 26.1+ (all versions)
if version.majorVersion >= 26 && version.minorVersion >= 1 {
return true
}
// macOS 15.7.2 (Darwin 25.7.2)
if version.majorVersion == 25 && version.minorVersion == 7 && version.patchVersion >= 2 {
return true
}
// macOS 14.8.2 (Darwin 24.6.2)
if version.majorVersion == 24 && version.minorVersion == 6 && version.patchVersion >= 2 {
return true
}
return false
}
/// Update an app from the App Store with progress tracking
/// - Parameters:
/// - adamID: The App Store ID of the app
/// - appPath: Path to the installed app (for receipt injection)
/// - progress: Progress callback (percent: 0.0-1.0, status message)
/// - attemptCount: Number of retry attempts for network errors (default: 3)
func updateApp(
adamID: UInt64,
appPath: URL,
isIOSApp: Bool = false,
progress: @escaping @Sendable (Double, String) -> Void,
attemptCount: UInt32 = 3
) async throws {
await GlobalConsoleManager.shared.appendOutput("Initiating App Store download for adamID \(adamID)...\n", source: CurrentPage.updater.title)
do {
// Create SSPurchase for downloading (purchasing: false = update existing app)
// NOTE: For iOS apps, all entity types ("software", "macSoftware", "desktopSoftware")
// download the same universal variant, which is NOT Mac-compatible.
// iOS app updates are currently non-functional due to Apple's CDN serving wrong variant.
let purchase = await SSPurchase(adamID: adamID, purchasing: false, kind: "software")
// Mark as update to indicate this is a redownload of owned app
purchase.isUpdate = true
// iOS apps need special handling on ALL macOS versions (flag passed from caller)
// Check if workaround is needed for macOS apps on affected OS versions
let needsWorkaround = needsInstalldWorkaround()
if isIOSApp {
await GlobalConsoleManager.shared.appendOutput("Detected iOS app - using custom installer\n", source: CurrentPage.updater.title)
} else if needsWorkaround {
await GlobalConsoleManager.shared.appendOutput("Detected macOS version with installer bug - using custom installer\n", source: CurrentPage.updater.title)
}
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
// NOTE: iOS apps will show "Current Version Not Compatible" dialog
// User must click "Download Last Compatible" to proceed with download
CKPurchaseController.shared().perform(purchase, withOptions: 0) { _, _, error, response in
if let error = error {
Task {
await GlobalConsoleManager.shared.appendOutput("✗ App Store purchase failed: \(error.localizedDescription)\n", source: CurrentPage.updater.title)
}
continuation.resume(throwing: error)
} else if response?.downloads?.isEmpty == false {
// Download started - create observer to track it
Task {
await GlobalConsoleManager.shared.appendOutput("Download started, monitoring progress...\n", source: CurrentPage.updater.title)
do {
if isIOSApp {
// iOS apps: Use dedicated iOS observer (all macOS versions)
printOS("⚠️ Detected iOS app - using custom installer")
let observer = IOSDownloadObserver(adamID: adamID, appPath: appPath, progress: progress)
try await observer.observeDownloadQueue()
} else if needsWorkaround {
// macOS apps on affected OS versions: Use workaround observer
printOS("⚠️ Detected macOS version with private frameworks installer bug - using custom installer")
let observer = MacOSDownloadObserverWithWorkaround(adamID: adamID, appPath: appPath, progress: progress)
try await observer.observeDownloadQueue()
} else {
// macOS apps on unaffected OS versions: Use standard observer
let observer = AppStoreDownloadObserver(adamID: adamID, progress: progress)
try await observer.observeDownloadQueue()
}
await GlobalConsoleManager.shared.appendOutput("Download and installation completed successfully\n", source: CurrentPage.updater.title)
continuation.resume()
} catch {
await GlobalConsoleManager.shared.appendOutput("✗ Download/installation failed: \(error.localizedDescription)\n", source: CurrentPage.updater.title)
continuation.resume(throwing: error)
}
}
} else {
// No downloads means already up to date
Task {
await GlobalConsoleManager.shared.appendOutput("App is already up to date\n", source: CurrentPage.updater.title)
}
progress(1.0, "Already up to date")
continuation.resume()
}
}
}
} catch {
// Retry logic for network errors (like mas does)
guard attemptCount > 1 else {
await GlobalConsoleManager.shared.appendOutput("✗ Update failed after retries: \(error.localizedDescription)\n", source: CurrentPage.updater.title)
throw error
}
// Only retry network errors
guard (error as NSError).domain == NSURLErrorDomain else {
await GlobalConsoleManager.shared.appendOutput("✗ Non-network error, not retrying: \(error.localizedDescription)\n", source: CurrentPage.updater.title)
throw error
}
let remainingAttempts = attemptCount - 1
await GlobalConsoleManager.shared.appendOutput("Network error, retrying... (\(remainingAttempts) attempts remaining)\n", source: CurrentPage.updater.title)
try await updateApp(adamID: adamID, appPath: appPath, isIOSApp: isIOSApp, progress: progress, attemptCount: remainingAttempts)
}
}
}
// MARK: - AppStoreDownloadObserver
/// Per-download observer that tracks a single App Store download/update
/// This matches the architecture used by mas CLI tool
private final class AppStoreDownloadObserver: NSObject, CKDownloadQueueObserver {
private let adamID: UInt64
private let progressCallback: @Sendable (Double, String) -> Void
private var completionHandler: (() -> Void)?
private var errorHandler: ((Error) -> Void)?
init(adamID: UInt64, progress: @escaping @Sendable (Double, String) -> Void) {
self.adamID = adamID
self.progressCallback = progress
super.init()
}
/// Observe the download queue until this download completes
/// Uses defer to ensure observer is always removed when done
func observeDownloadQueue(_ queue: CKDownloadQueue = .shared()) async throws {
let observerID = queue.add(self)
defer {
queue.removeObserver(observerID)
}
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
completionHandler = { [weak self] in
self?.completionHandler = nil
self?.errorHandler = nil
continuation.resume()
}
errorHandler = { [weak self] error in
self?.completionHandler = nil
self?.errorHandler = nil
continuation.resume(throwing: error)
}
}
}
// MARK: - CKDownloadQueueObserver Delegate Methods
func downloadQueue(_ queue: CKDownloadQueue, changedWithAddition download: SSDownload) {
// Download was added to queue - no action needed
}
func downloadQueue(_ queue: CKDownloadQueue, changedWithRemoval download: SSDownload) {
guard let metadata = download.metadata,
metadata.itemIdentifier == adamID,
let status = download.status else {
return
}
// This is the official completion signal from CommerceKit
if status.isFailed {
let error = status.error ?? AppStoreUpdateError.downloadFailed("Download failed")
Task {
await GlobalConsoleManager.shared.appendOutput("✗ App Store download failed: \(error.localizedDescription)\n", source: CurrentPage.updater.title)
}
errorHandler?(error)
} else if status.isCancelled {
Task {
await GlobalConsoleManager.shared.appendOutput("Download cancelled by user\n", source: CurrentPage.updater.title)
}
errorHandler?(AppStoreUpdateError.downloadCancelled)
} else {
// Success!
Task {
await GlobalConsoleManager.shared.appendOutput("App Store download completed\n", source: CurrentPage.updater.title)
}
progressCallback(1.0, "Completed")
completionHandler?()
}
}
func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) {
guard let metadata = download.metadata,
metadata.itemIdentifier == adamID,
let status = download.status,
let activePhase = status.activePhase else {
return
}
let phaseType = activePhase.phaseType
let percentComplete = status.percentComplete // Float: 0.0 to 1.0
let progress = max(0.0, min(1.0, Double(percentComplete)))
// Report progress based on phase
// Special case: at 100%, always show "Installing..." (CommerceKit sometimes resets to phase 0 at completion)
if progress >= 1.0 {
progressCallback(progress, "Installing...")
} else {
switch phaseType {
case 0: // Downloading
progressCallback(progress, "Downloading...")
case 1: // Installing
progressCallback(progress, "Installing...")
case 4: // Initial/Preparing
progressCallback(progress, "Preparing...")
case 5: // Downloaded (not complete yet - wait for changedWithRemoval)
progressCallback(progress, "Installing...")
default:
progressCallback(progress, "Processing...")
}
}
}
}
// MARK: - IOSDownloadObserver
/// Observer for iOS/iPad app downloads (IPA files)
/// Preserves IPA to /tmp for installation
/// Used for all iOS apps regardless of macOS version
private final class IOSDownloadObserver: NSObject, CKDownloadQueueObserver {
private let adamID: UInt64
private let appPath: URL
private let progressCallback: @Sendable (Double, String) -> Void
private var completionHandler: (() -> Void)?
private var errorHandler: ((Error) -> Void)?
private var iosFilesPreserved = false // Track if IPA was already preserved
private var hardLinkedIPAPath: String? // Path to hard-linked IPA in /tmp
private var isManuallyInstalling = false
init(adamID: UInt64, appPath: URL, progress: @escaping @Sendable (Double, String) -> Void) {
self.adamID = adamID
self.appPath = appPath
self.progressCallback = progress
super.init()
}
func observeDownloadQueue(_ queue: CKDownloadQueue = .shared()) async throws {
let observerID = queue.add(self)
defer {
queue.removeObserver(observerID)
}
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
completionHandler = { [weak self] in
self?.completionHandler = nil
self?.errorHandler = nil
continuation.resume()
}
errorHandler = { [weak self] error in
self?.completionHandler = nil
self?.errorHandler = nil
continuation.resume(throwing: error)
}
}
}
// MARK: - CKDownloadQueueObserver Delegate Methods
func downloadQueue(_ queue: CKDownloadQueue, changedWithAddition download: SSDownload) {
// Download was added to queue - no action needed
}
func downloadQueue(_ queue: CKDownloadQueue, changedWithRemoval download: SSDownload) {
guard let metadata = download.metadata,
metadata.itemIdentifier == adamID,
let status = download.status else {
return
}
// iOS app download completed - always perform manual installation if IPA was preserved
// (CommerceKit cannot install iOS apps, so we handle it ourselves regardless of reported status)
if let ipaPath = hardLinkedIPAPath {
Task {
await performManualInstallation(ipaPath: ipaPath)
}
} else {
// No IPA preserved - this shouldn't happen, but handle gracefully
if status.isFailed || status.isCancelled {
errorHandler?(AppStoreUpdateError.downloadFailed("Failed to preserve IPA file"))
} else {
// Unexpected: CommerceKit claims success but we don't have an IPA
printOS("⚠️ Download completed but no IPA was preserved")
progressCallback(1.0, "Completed")
completionHandler?()
}
}
}
func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) {
guard let metadata = download.metadata,
metadata.itemIdentifier == adamID,
let status = download.status,
let activePhase = status.activePhase else {
return
}
let phaseType = activePhase.phaseType
let percentComplete = status.percentComplete
let progress = max(0.0, min(1.0, Double(percentComplete)))
// At 80% progress, preserve IPA file before CommerceKit potentially cleans it up
if progress >= 0.80 && progress < 1.0 && !iosFilesPreserved {
preserveIPAFile()
}
// Report progress
if isManuallyInstalling {
progressCallback(progress, "Installing...")
} else if progress >= 1.0 {
progressCallback(progress, "Downloading...")
} else {
switch phaseType {
case 0: // Downloading
progressCallback(progress, "Downloading...")
case 4: // Initial/Preparing
progressCallback(progress, "Preparing...")
default:
progressCallback(progress, "Downloading...")
}
}
}
// MARK: - Helper Methods
private func performManualInstallation(ipaPath: String) async {
isManuallyInstalling = true
// 80%: Preparing installation
progressCallback(0.80, "Preparing installation...")
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 sec
do {
// 80-100%: IOSAppInstaller handles the rest (extraction, metadata, installation, cleanup)
try await IOSAppInstaller.installIOSApp(
ipaPath: ipaPath,
adamID: adamID,
existingAppPath: appPath,
progress: progressCallback
)
completionHandler?()
} catch {
errorHandler?(error)
}
}
private func preserveIPAFile() {
guard !iosFilesPreserved else { return }
let downloadDir = "\(CKDownloadDirectory(nil))/\(adamID)"
let tempDir = "/tmp/pearcleaner-ios-\(adamID)"
do {
// Create temp directory
try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true)
let contents = try FileManager.default.contentsOfDirectory(atPath: downloadDir)
if let ipaFile = contents.first(where: { $0.hasSuffix(".ipa") }) {
// Hard link IPA to /tmp (same pattern as PKG files)
let ipaSource = "\(downloadDir)/\(ipaFile)"
let ipaDest = "\(tempDir)/app.ipa"
try FileManager.default.linkItem(atPath: ipaSource, toPath: ipaDest)
hardLinkedIPAPath = ipaDest
iosFilesPreserved = true
} else {
printOS("⚠️ No IPA file found in \(downloadDir)")
}
} catch {
printOS("❌ Failed to preserve IPA: \(error.localizedDescription)")
}
}
}
// MARK: - MacOSDownloadObserverWithWorkaround
/// Special observer for macOS versions affected by installd bug
/// Downloads PKG, hard links it, then manually installs via HelperToolManager
/// Used only for macOS apps on affected OS versions
private final class MacOSDownloadObserverWithWorkaround: NSObject, CKDownloadQueueObserver {
private let adamID: UInt64
private let appPath: URL
private let progressCallback: @Sendable (Double, String) -> Void
private var completionHandler: (() -> Void)?
private var errorHandler: ((Error) -> Void)?
private var hardLinkedPkgPath: String?
private var hardLinkedReceiptPath: String?
private var isManuallyInstalling = false
init(adamID: UInt64, appPath: URL, progress: @escaping @Sendable (Double, String) -> Void) {
self.adamID = adamID
self.appPath = appPath
self.progressCallback = progress
super.init()
}
func observeDownloadQueue(_ queue: CKDownloadQueue = .shared()) async throws {
let observerID = queue.add(self)
defer {
queue.removeObserver(observerID)
}
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
completionHandler = { [weak self] in
self?.completionHandler = nil
self?.errorHandler = nil
continuation.resume()
}
errorHandler = { [weak self] error in
self?.completionHandler = nil
self?.errorHandler = nil
continuation.resume(throwing: error)
}
}
}
// MARK: - CKDownloadQueueObserver Delegate Methods
func downloadQueue(_ queue: CKDownloadQueue, changedWithAddition download: SSDownload) {
// Download was added to queue - no action needed
}
func downloadQueue(_ queue: CKDownloadQueue, changedWithRemoval download: SSDownload) {
guard let metadata = download.metadata,
metadata.itemIdentifier == adamID,
let status = download.status else {
return
}
// Download completed (installd will fail, but we have the PKG hard linked)
if status.isFailed || status.isCancelled {
// Expected failure on affected macOS versions - proceed with manual installation
if let pkgPath = hardLinkedPkgPath {
Task {
await performManualInstallation(pkgPath: pkgPath)
}
} else {
errorHandler?(AppStoreUpdateError.downloadFailed("Failed to preserve PKG file"))
}
} else {
// Unexpected success (installd worked somehow)
if let pkgPath = hardLinkedPkgPath {
// Clean up temp directory since installd succeeded
let tempDir = (pkgPath as NSString).deletingLastPathComponent
try? FileManager.default.removeItem(atPath: tempDir)
hardLinkedPkgPath = nil
hardLinkedReceiptPath = nil
}
progressCallback(1.0, "Completed")
completionHandler?()
}
}
func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) {
guard let metadata = download.metadata,
metadata.itemIdentifier == adamID,
let status = download.status,
let activePhase = status.activePhase else {
return
}
let phaseType = activePhase.phaseType
let percentComplete = status.percentComplete
let progress = max(0.0, min(1.0, Double(percentComplete)))
// At 80% progress, create hard link to preserve PKG before installd fails
if progress >= 0.80 && progress < 1.0 && hardLinkedPkgPath ==
gitextract_u4mjrmj6/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug.md │ │ ├── config.yml │ │ ├── feature.md │ │ └── project.md │ └── workflows/ │ └── issues.yml ├── .gitignore ├── Builds/ │ ├── ExportOptions.plist │ └── changes.md ├── FUNDING.yml ├── FinderOpen/ │ ├── Assets.xcassets/ │ │ ├── Contents.json │ │ └── Glass.imageset/ │ │ └── Contents.json │ ├── FinderOpen.entitlements │ ├── FinderOpen.swift │ ├── Info.plist │ └── Localizable.xcstrings ├── LICENSE.md ├── Pear Resources/ │ ├── Glass.icon/ │ │ ├── Assets/ │ │ │ └── pear.heic │ │ └── icon.json │ └── Pear.psd ├── Pearcleaner/ │ ├── Logic/ │ │ ├── AppCommands.swift │ │ ├── AppInfoFetch.swift │ │ ├── AppPathsFetch.swift │ │ ├── AppState.swift │ │ ├── AppsUpdater/ │ │ │ ├── AppStoreReset.swift │ │ │ ├── AppStoreUpdateChecker.swift │ │ │ ├── AppStoreUpdater.swift │ │ │ ├── HomebrewAdoption.swift │ │ │ ├── HomebrewUpdateChecker.swift │ │ │ ├── IOSAppInstaller.swift │ │ │ ├── Models.swift │ │ │ ├── PrivateFrameworks/ │ │ │ │ ├── CFBundle/ │ │ │ │ │ └── CFBundle_Private.h │ │ │ │ ├── CommerceKit/ │ │ │ │ │ ├── CKDownloadDirectory.h │ │ │ │ │ ├── CKDownloadQueue.h │ │ │ │ │ ├── CKDownloadQueueObserver-Protocol.h │ │ │ │ │ ├── CKPurchaseController.h │ │ │ │ │ ├── CKServiceInterface.h │ │ │ │ │ ├── CommerceKit.h │ │ │ │ │ └── module.modulemap │ │ │ │ └── StoreFoundation/ │ │ │ │ ├── ISAccountService-Protocol.h │ │ │ │ ├── ISServiceProxy.h │ │ │ │ ├── ISStoreAccount.h │ │ │ │ ├── SSDownload.h │ │ │ │ ├── SSDownloadMetadata.h │ │ │ │ ├── SSDownloadPhase.h │ │ │ │ ├── SSDownloadStatus.h │ │ │ │ ├── SSPurchase.h │ │ │ │ ├── SSPurchaseResponse.h │ │ │ │ ├── StoreFoundation.h │ │ │ │ └── module.modulemap │ │ │ ├── SSPurchase+Extension.swift │ │ │ ├── SparkleUpdateChecker.swift │ │ │ ├── SparkleUpdateDriver.swift │ │ │ ├── SparkleUpdateOperation.swift │ │ │ ├── UpdateManager.swift │ │ │ ├── UpdateQueue.swift │ │ │ ├── UpdaterDebugLogger.swift │ │ │ ├── UpdaterSettings.swift │ │ │ └── VersionComparison.swift │ │ ├── Brew/ │ │ │ ├── HomebrewAutoUpdateManager.swift │ │ │ ├── HomebrewController.swift │ │ │ ├── HomebrewManager.swift │ │ │ ├── HomebrewPackage.swift │ │ │ ├── HomebrewTap.swift │ │ │ └── HomebrewUninstaller.swift │ │ ├── CLI.swift │ │ ├── Conditions.swift │ │ ├── DeepLink.swift │ │ ├── FileSearch/ │ │ │ ├── FileSearchLogic.swift │ │ │ └── FileSearchModels.swift │ │ ├── FuzzySearch.swift │ │ ├── GlobalConsoleManager.swift │ │ ├── HelperToolManager.swift │ │ ├── KeychainPasswordManager.swift │ │ ├── Lipo.swift │ │ ├── Locations.swift │ │ ├── Logic.swift │ │ ├── PKG/ │ │ │ ├── PKBOM.h │ │ │ ├── PKBundleComponent.h │ │ │ ├── PKBundleComponentVersion.h │ │ │ ├── PKComponent.h │ │ │ ├── PKGManager.swift │ │ │ ├── PKInstallHistory.h │ │ │ ├── PKPackage.h │ │ │ ├── PKPackageChecker.h │ │ │ ├── PKPackageInfo.h │ │ │ ├── PKProductInfo.h │ │ │ ├── PKReceipt.h │ │ │ └── Pearcleaner-Bridging-Header.h │ │ ├── PasswordRequestHandler.swift │ │ ├── ProcessEnv.swift │ │ ├── ReversePathsFetch.swift │ │ ├── TCC/ │ │ │ ├── TCCModels.swift │ │ │ └── TCCQueryHelper.swift │ │ ├── UndoHistoryManager.swift │ │ ├── UndoManager.swift │ │ └── Utilities.swift │ ├── PearcleanerApp.swift │ ├── Resources/ │ │ ├── Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── Glass.icon/ │ │ │ ├── Assets/ │ │ │ │ └── pear.heic │ │ │ └── icon.json │ │ ├── Info.plist │ │ ├── Localizable.xcstrings │ │ ├── Pearcleaner.entitlements │ │ └── askpass.sh │ ├── Style/ │ │ ├── ChromeBorder.swift │ │ ├── CircularProgressView.swift │ │ ├── ControlGroupChrome.swift │ │ ├── PearGroupBox.swift │ │ ├── SparkleProgressBar.swift │ │ ├── Styles.swift │ │ └── Theme.swift │ ├── Views/ │ │ ├── AppsUpdaterView/ │ │ │ ├── AdoptionSheetView.swift │ │ │ ├── AppsUpdaterView.swift │ │ │ ├── CaskAdoptionContentView.swift │ │ │ ├── ExpandableActionButton.swift │ │ │ ├── UpdateDetailView.swift │ │ │ ├── UpdateRowViewSidebar.swift │ │ │ └── UpdaterDetailsSidebar.swift │ │ ├── AppsView/ │ │ │ ├── AppListItems.swift │ │ │ ├── AppSearchView.swift │ │ │ ├── AppsListView.swift │ │ │ └── GridAppItem.swift │ │ ├── Brew/ │ │ │ ├── AutoUpdateSection.swift │ │ │ ├── HomebrewView.swift │ │ │ ├── LogViewerSheet.swift │ │ │ ├── MaintenanceSection.swift │ │ │ ├── PackageDetailsSidebar.swift │ │ │ ├── SearchInstallSection.swift │ │ │ └── TapManagementSection.swift │ │ ├── Components/ │ │ │ ├── BadgeOverlay.swift │ │ │ ├── GlobalConsoleView.swift │ │ │ ├── PermissionsSheetView.swift │ │ │ ├── SidebarDetailView/ │ │ │ │ ├── GenericSidebarListView.swift │ │ │ │ └── SidebarDetailLayout.swift │ │ │ ├── StandardSheetView.swift │ │ │ └── TCCPermissionViewer.swift │ │ ├── DaemonView.swift │ │ ├── DeleteHistoryView.swift │ │ ├── DevelopmentView.swift │ │ ├── FileSearchView.swift │ │ ├── FilesView/ │ │ │ ├── FileCategory.swift │ │ │ ├── FileListView.swift │ │ │ ├── FilesSidebarView.swift │ │ │ ├── FilesView.swift │ │ │ └── TranslationSelectionSheet.swift │ │ ├── LipoView/ │ │ │ ├── LipoSidebarView.swift │ │ │ └── LipoView.swift │ │ ├── MainWindow.swift │ │ ├── PackageView.swift │ │ ├── PluginsView.swift │ │ ├── Settings/ │ │ │ ├── About.swift │ │ │ ├── Folders.swift │ │ │ ├── General.swift │ │ │ ├── Helper.swift │ │ │ ├── Interface.swift │ │ │ ├── SettingsWindow.swift │ │ │ └── Update.swift │ │ └── ZombieView/ │ │ ├── ZombieSidebarView.swift │ │ └── ZombieView.swift │ └── announcements.json ├── 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 ├── PearcleanerHelper/ │ ├── CodesignCheck.swift │ ├── com.alienator88.Pearcleaner.PearcleanerHelper.plist │ └── main.swift ├── PearcleanerSentinel/ │ ├── FileWatcher.swift │ ├── com.alienator88.PearcleanerSentinel.plist │ └── main.swift ├── README.md ├── Shared/ │ └── AppGroupDefaults.swift └── announcements.json
SYMBOL INDEX (26 symbols across 19 files)
FILE: Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/CommerceKit/CKDownloadQueue.h
function interface (line 8) | interface CKDownloadQueue : CKServiceInterface {
FILE: Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/CommerceKit/CKPurchaseController.h
function interface (line 10) | interface CKPurchaseController : CKServiceInterface {
FILE: Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/ISStoreAccount.h
function interface (line 8) | interface ISStoreAccount : NSObject <NSSecureCoding> {
FILE: Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSDownload.h
function interface (line 8) | interface SSDownload : NSObject <NSSecureCoding> {
FILE: Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSDownloadMetadata.h
type _NSZone (line 61) | struct _NSZone
FILE: Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSDownloadPhase.h
type _NSZone (line 20) | struct _NSZone
FILE: Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSDownloadStatus.h
type _NSZone (line 23) | struct _NSZone
FILE: Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSPurchase.h
type _NSZone (line 40) | struct _NSZone
FILE: Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSPurchaseResponse.h
function interface (line 8) | interface SSPurchaseResponse : NSObject <NSSecureCoding> {
FILE: Pearcleaner/Logic/PKG/PKBOM.h
type _BOMBom (line 9) | struct _BOMBom
type _BOMFSObject (line 10) | struct _BOMFSObject
function interface (line 12) | interface PKBOM : NSObject
type _BOMFSObject (line 18) | struct _BOMFSObject
type _BOMFSObject (line 19) | struct _BOMFSObject
type _BOMBom (line 26) | struct _BOMBom
FILE: Pearcleaner/Logic/PKG/PKBundleComponent.h
function interface (line 11) | interface PKBundleComponent : PKComponent
FILE: Pearcleaner/Logic/PKG/PKBundleComponentVersion.h
function interface (line 11) | interface PKBundleComponentVersion : NSObject
type __CFBundle (line 24) | struct __CFBundle
type __CFBundle (line 26) | struct __CFBundle
FILE: Pearcleaner/Logic/PKG/PKComponent.h
function interface (line 11) | interface PKComponent : NSObject
FILE: Pearcleaner/Logic/PKG/PKInstallHistory.h
function interface (line 9) | interface PKInstallHistory : NSObject
FILE: Pearcleaner/Logic/PKG/PKPackage.h
type _NSZone (line 48) | struct _NSZone
FILE: Pearcleaner/Logic/PKG/PKPackageChecker.h
function interface (line 11) | interface PKPackageChecker : NSObject
FILE: Pearcleaner/Logic/PKG/PKPackageInfo.h
function interface (line 11) | interface PKPackageInfo : NSObject
FILE: Pearcleaner/Logic/PKG/PKProductInfo.h
type _NSZone (line 19) | struct _NSZone
FILE: Pearcleaner/Logic/PKG/PKReceipt.h
function interface (line 9) | interface PKReceipt : NSObject
Condensed preview — 179 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (4,861K chars).
[
{
"path": ".github/ISSUE_TEMPLATE/bug.md",
"chars": 1650,
"preview": "---\nname: 🐛 Bug Report\nabout: For submitting new bugs related to the application functionality\ntitle: \"[BUG] ENTER ISSUE"
},
{
"path": ".github/ISSUE_TEMPLATE/config.yml",
"chars": 426,
"preview": "blank_issues_enabled: false\ncontact_links:\n - name: PEARCLEANER IS NOW IN MAINTENANCE MODE\n url: https://github.com/"
},
{
"path": ".github/ISSUE_TEMPLATE/feature.md",
"chars": 968,
"preview": "<!--\n---\nname: 🌟 Feature Request\nabout: For submitting new feature ideas\ntitle: \"[FR] ENTER TITLE HERE\"\nlabels: 'enhance"
},
{
"path": ".github/ISSUE_TEMPLATE/project.md",
"chars": 692,
"preview": "This project is now in **maintenance mode**.\n\n---\n\nI’m no longer accepting **NEW** feature requests as Pearcleaner has g"
},
{
"path": ".github/workflows/issues.yml",
"chars": 966,
"preview": "name: Close empty issues and templates\non:\n issues:\n types:\n - reopened\n - opened\n - edited\n\njobs:\n "
},
{
"path": ".gitignore",
"chars": 2250,
"preview": "# Xcode\n#\n# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore\n\n"
},
{
"path": "Builds/ExportOptions.plist",
"chars": 833,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "Builds/changes.md",
"chars": 705,
"preview": "### What's New\n\n- [x] Ability to hide unused utility pages from global menu in Settings > Interface > Startup View - #47"
},
{
"path": "FUNDING.yml",
"chars": 20,
"preview": "github: alienator88\n"
},
{
"path": "FinderOpen/Assets.xcassets/Contents.json",
"chars": 63,
"preview": "{\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\n"
},
{
"path": "FinderOpen/Assets.xcassets/Glass.imageset/Contents.json",
"chars": 303,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"Glass.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n {\n "
},
{
"path": "FinderOpen/FinderOpen.entitlements",
"chars": 483,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "FinderOpen/FinderOpen.swift",
"chars": 2306,
"preview": "//\n// FinderSync.swift\n// FinderOpen\n//\n// Created by Alin Lupascu on 4/11/24.\n//\n\nimport Cocoa\nimport FinderSync\n\ncl"
},
{
"path": "FinderOpen/Info.plist",
"chars": 446,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "FinderOpen/Localizable.xcstrings",
"chars": 2430,
"preview": "{\n \"sourceLanguage\" : \"en\",\n \"strings\" : {\n \"Pearcleaner Uninstall\" : {\n \"localizations\" : {\n \"de\" : {\n"
},
{
"path": "LICENSE.md",
"chars": 12301,
"preview": "“Commons Clause” License Condition v1.0\n\nThe Software is provided to you by the Licensor under the License, as defined b"
},
{
"path": "Pear Resources/Glass.icon/icon.json",
"chars": 1063,
"preview": "{\n \"fill\" : {\n \"linear-gradient\" : [\n \"display-p3:0.93159,0.94081,0.94081,1.00000\",\n \"display-p3:0.75900,0"
},
{
"path": "Pearcleaner/Logic/AppCommands.swift",
"chars": 14033,
"preview": "//\n// AppCommands.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/31/23.\n//\n\nimport SwiftUI\nimport AlinFound"
},
{
"path": "Pearcleaner/Logic/AppInfoFetch.swift",
"chars": 38765,
"preview": "//\n// AppInfoFetch.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 3/20/24.\n//\n\nimport Foundation\nimport SwiftU"
},
{
"path": "Pearcleaner/Logic/AppPathsFetch.swift",
"chars": 42543,
"preview": "//\n// AppPathsFetch.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 2/6/25.\n//\n\nimport Foundation\nimport AppKit"
},
{
"path": "Pearcleaner/Logic/AppState.swift",
"chars": 30314,
"preview": "//\n// AppState.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/31/23.\n//\n\nimport AlinFoundation\nimport Finde"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/AppStoreReset.swift",
"chars": 6928,
"preview": "//\n// AppStoreReset.swift\n// Pearcleaner\n//\n// Based on mas-cli's reset command\n// Copyright © 2018 mas-cli. All rig"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/AppStoreUpdateChecker.swift",
"chars": 14134,
"preview": "//\n// AppStoreUpdateChecker.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/13/25.\n//\n\nimport Foundation\nimp"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/AppStoreUpdater.swift",
"chars": 26753,
"preview": "//\n// AppStoreUpdater.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/13/25.\n//\n\nimport Foundation\nimport Co"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/HomebrewAdoption.swift",
"chars": 7126,
"preview": "//\n// HomebrewAdoption.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/18/25.\n//\n\nimport Foundation\n\n// MARK"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/HomebrewUpdateChecker.swift",
"chars": 12356,
"preview": "//\n// HomebrewUpdateChecker.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/13/25.\n//\n\nimport Foundation\n\ncl"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/IOSAppInstaller.swift",
"chars": 16414,
"preview": "//\n// IOSAppInstaller.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/12/25.\n//\n\nimport Foundation\nimport Ap"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/Models.swift",
"chars": 3277,
"preview": "//\n// Models.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/13/25.\n//\n\nimport Foundation\nimport SwiftUI\nimp"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/CFBundle/CFBundle_Private.h",
"chars": 465,
"preview": "//\n// CFBundle_Private.h\n// Pearcleaner\n//\n// Private API for flushing bundle caches\n//\n\n#import <Foundation/Foundati"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/CommerceKit/CKDownloadDirectory.h",
"chars": 162,
"preview": "//\n// CKDownloadDirectory.h\n// mas\n//\n// Copyright © 2018 mas-cli. All rights reserved.\n//\n\nNSString * _Nonnull CKDownlo"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/CommerceKit/CKDownloadQueue.h",
"chars": 2574,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/CommerceKit/CKDownloadQueueObserver-Protocol.h",
"chars": 493,
"preview": "//\n// CKDownloadQueueObserver-Protocol.h\n// mas\n//\n// Copyright © 2018 mas-cli. All rights reserved.\n//\n\n@protocol CKDow"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/CommerceKit/CKPurchaseController.h",
"chars": 1970,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/CommerceKit/CKServiceInterface.h",
"chars": 269,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/CommerceKit/CommerceKit.h",
"chars": 488,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/CommerceKit/module.modulemap",
"chars": 173,
"preview": "module CommerceKit [system] [no_undeclared_includes] {\n\trequires macos, objc\n\tuse StoreFoundation\n\tlink framework \"Comme"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/ISAccountService-Protocol.h",
"chars": 6278,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/ISServiceProxy.h",
"chars": 2899,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/ISStoreAccount.h",
"chars": 1706,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSDownload.h",
"chars": 1940,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSDownloadMetadata.h",
"chars": 3720,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSDownloadPhase.h",
"chars": 1040,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSDownloadStatus.h",
"chars": 1153,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSPurchase.h",
"chars": 2222,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/SSPurchaseResponse.h",
"chars": 982,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/StoreFoundation.h",
"chars": 814,
"preview": "//\n// Generated by https://github.com/blacktop/ipsw (Version: 3.1.626, BuildCommit: Homebrew)\n//\n// - LC_BUILD_VERSION: "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/PrivateFrameworks/StoreFoundation/module.modulemap",
"chars": 180,
"preview": "module StoreFoundation [system] [no_undeclared_includes] {\n\trequires macos, objc\n\tuse Foundation\n\tlink framework \"StoreF"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/SSPurchase+Extension.swift",
"chars": 1412,
"preview": "//\n// SSPurchase+Extension.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/13/25.\n// Based on mas-cli imple"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/SparkleUpdateChecker.swift",
"chars": 28885,
"preview": "//\n// SparkleUpdateChecker.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/28/25.\n//\n// Simplified Sparkle "
},
{
"path": "Pearcleaner/Logic/AppsUpdater/SparkleUpdateDriver.swift",
"chars": 12169,
"preview": "//\n// SparkleUpdateDriver.swift\n// Pearcleaner\n//\n// Custom SPUUserDriver for programmatically controlling Sparkle up"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/SparkleUpdateOperation.swift",
"chars": 2458,
"preview": "//\n// SparkleUpdateOperation.swift\n// Pearcleaner\n//\n// Operation subclass that blocks until Sparkle update completes"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/UpdateManager.swift",
"chars": 45785,
"preview": "//\n// UpdateManager.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/13/25.\n//\n\nimport Foundation\nimport Swif"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/UpdateQueue.swift",
"chars": 1156,
"preview": "//\n// UpdateQueue.swift\n// Pearcleaner\n//\n// Manages concurrent Sparkle update operations with proper queuing to prev"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/UpdaterDebugLogger.swift",
"chars": 4020,
"preview": "//\n// UpdaterDebugLogger.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/25/25.\n//\n\nimport Foundation\nimport"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/UpdaterSettings.swift",
"chars": 1643,
"preview": "//\n// UpdaterSettings.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/21/25.\n//\n\nimport Foundation\n\n// MARK:"
},
{
"path": "Pearcleaner/Logic/AppsUpdater/VersionComparison.swift",
"chars": 12579,
"preview": "//\n// VersionComparison.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/22/25.\n//\n// Based on Sparkle Frame"
},
{
"path": "Pearcleaner/Logic/Brew/HomebrewAutoUpdateManager.swift",
"chars": 23505,
"preview": "//\n// HomebrewAutoUpdateManager.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/25/25.\n//\n\nimport Foundation"
},
{
"path": "Pearcleaner/Logic/Brew/HomebrewController.swift",
"chars": 103481,
"preview": "//\n// HomebrewController.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/01/25.\n//\n\nimport Foundation\nimport"
},
{
"path": "Pearcleaner/Logic/Brew/HomebrewManager.swift",
"chars": 24117,
"preview": "//\n// HomebrewManager.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/01/25.\n//\n\nimport Foundation\nimport Al"
},
{
"path": "Pearcleaner/Logic/Brew/HomebrewPackage.swift",
"chars": 9075,
"preview": "//\n// HomebrewPackage.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/01/25.\n//\n\nimport Foundation\n\n// MARK:"
},
{
"path": "Pearcleaner/Logic/Brew/HomebrewTap.swift",
"chars": 489,
"preview": "//\n// HomebrewTap.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/01/25.\n//\n\nimport Foundation\n\nstruct Homeb"
},
{
"path": "Pearcleaner/Logic/Brew/HomebrewUninstaller.swift",
"chars": 23776,
"preview": "//\n// HomebrewUninstaller.swift\n// Pearcleaner\n//\n// Created by Pearcleaner on 2025-10-03.\n//\n\nimport Foundation\nimpo"
},
{
"path": "Pearcleaner/Logic/CLI.swift",
"chars": 16840,
"preview": "import AlinFoundation\nimport ArgumentParser\nimport Foundation\nimport ServiceManagement\nimport SwiftUI\nimport UniformType"
},
{
"path": "Pearcleaner/Logic/Conditions.swift",
"chars": 11657,
"preview": "//\n// Conditions.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 4/15/24.\n//\n\nimport Foundation\n\nstruct Conditi"
},
{
"path": "Pearcleaner/Logic/DeepLink.swift",
"chars": 12410,
"preview": "//\n// DeepLink.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/9/23.\n//\n\nimport Foundation\nimport SwiftUI\nim"
},
{
"path": "Pearcleaner/Logic/FileSearch/FileSearchLogic.swift",
"chars": 19951,
"preview": "//\n// FileSearchLogic.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 09/29/25.\n//\n\nimport Foundation\nimport Ap"
},
{
"path": "Pearcleaner/Logic/FileSearch/FileSearchModels.swift",
"chars": 6957,
"preview": "//\n// FileSearchModels.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 09/29/25.\n//\n\nimport Foundation\nimport A"
},
{
"path": "Pearcleaner/Logic/FuzzySearch.swift",
"chars": 6306,
"preview": "import Foundation\n\n/// FuzzySearchCharacters is used to normalise strings\nstruct FuzzySearchCharacter {\n let content:"
},
{
"path": "Pearcleaner/Logic/GlobalConsoleManager.swift",
"chars": 2279,
"preview": "//\n// GlobalConsoleManager.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/13/24.\n//\n\nimport Foundation\nimpo"
},
{
"path": "Pearcleaner/Logic/HelperToolManager.swift",
"chars": 14430,
"preview": "//\n// HelperToolManager.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 3/14/25.\n//\n\nimport ServiceManagement\ni"
},
{
"path": "Pearcleaner/Logic/KeychainPasswordManager.swift",
"chars": 4617,
"preview": "//\n// KeychainPasswordManager.swift\n// Pearcleaner\n//\n// Manages sudo password caching in macOS Keychain with time-ba"
},
{
"path": "Pearcleaner/Logic/Lipo.swift",
"chars": 18663,
"preview": "//\n// Lipo.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 4/10/25.\n//\n\nimport Foundation\n\n\n// Helper structs f"
},
{
"path": "Pearcleaner/Logic/Locations.swift",
"chars": 9581,
"preview": "//\n// Locations.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/10/23.\n//\n\nimport Foundation\n\n\nclass Locatio"
},
{
"path": "Pearcleaner/Logic/Logic.swift",
"chars": 60497,
"preview": "//\n// Logic.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/31/23.\n//\n\nimport AlinFoundation\nimport Foundati"
},
{
"path": "Pearcleaner/Logic/PKG/PKBOM.h",
"chars": 751,
"preview": "//\n// Generated by class-dump 3.5 (64 bit) (Debug version compiled May 21 2020 17:01:39).\n//\n// Copyright (C) 1997-"
},
{
"path": "Pearcleaner/Logic/PKG/PKBundleComponent.h",
"chars": 1232,
"preview": "//\n// Generated by class-dump 3.5 (64 bit) (Debug version compiled May 21 2020 17:01:39).\n//\n// Copyright (C) 1997-"
},
{
"path": "Pearcleaner/Logic/PKG/PKBundleComponentVersion.h",
"chars": 1501,
"preview": "//\n// Generated by class-dump 3.5 (64 bit) (Debug version compiled May 21 2020 17:01:39).\n//\n// Copyright (C) 1997-"
},
{
"path": "Pearcleaner/Logic/PKG/PKComponent.h",
"chars": 2371,
"preview": "//\n// Generated by class-dump 3.5 (64 bit) (Debug version compiled May 21 2020 17:01:39).\n//\n// Copyright (C) 1997-"
},
{
"path": "Pearcleaner/Logic/PKG/PKGManager.swift",
"chars": 6331,
"preview": "//\n// PKGManager.swift\n// Pearcleaner\n//\n// Wrapper for Apple's private PackageKit framework APIs\n//\n\nimport Foundati"
},
{
"path": "Pearcleaner/Logic/PKG/PKInstallHistory.h",
"chars": 391,
"preview": "//\n// Generated by class-dump 3.5 (64 bit) (Debug version compiled May 21 2020 17:01:39).\n//\n// Copyright (C) 1997-"
},
{
"path": "Pearcleaner/Logic/PKG/PKPackage.h",
"chars": 1906,
"preview": "//\n// Generated by class-dump 3.5 (64 bit) (Debug version compiled May 21 2020 17:01:39).\n//\n// Copyright (C) 1997-"
},
{
"path": "Pearcleaner/Logic/PKG/PKPackageChecker.h",
"chars": 1138,
"preview": "//\n// Generated by class-dump 3.5 (64 bit) (Debug version compiled May 21 2020 17:01:39).\n//\n// Copyright (C) 1997-"
},
{
"path": "Pearcleaner/Logic/PKG/PKPackageInfo.h",
"chars": 4958,
"preview": "//\n// Generated by class-dump 3.5 (64 bit) (Debug version compiled May 21 2020 17:01:39).\n//\n// Copyright (C) 1997-"
},
{
"path": "Pearcleaner/Logic/PKG/PKProductInfo.h",
"chars": 1165,
"preview": "//\n// Generated by class-dump 3.5 (64 bit) (Debug version compiled May 21 2020 17:01:39).\n//\n// Copyright (C) 1997-"
},
{
"path": "Pearcleaner/Logic/PKG/PKReceipt.h",
"chars": 2638,
"preview": "//\n// Generated by class-dump 3.5 (64 bit) (Debug version compiled May 21 2020 17:01:39).\n//\n// Copyright (C) 1997-"
},
{
"path": "Pearcleaner/Logic/PKG/Pearcleaner-Bridging-Header.h",
"chars": 511,
"preview": "//\n// Pearcleaner-Bridging-Header.h\n// Pearcleaner\n//\n// Use this file to import Objective-C headers for Swift.\n//\n\n#"
},
{
"path": "Pearcleaner/Logic/PasswordRequestHandler.swift",
"chars": 2373,
"preview": "//\n// PasswordRequestHandler.swift\n// Pearcleaner\n//\n// Handles password requests from CLI via distributed notificati"
},
{
"path": "Pearcleaner/Logic/ProcessEnv.swift",
"chars": 5425,
"preview": "//\n// ProcessEnv.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/6/25.\n//\n\nimport Foundation\n\npublic extensi"
},
{
"path": "Pearcleaner/Logic/ReversePathsFetch.swift",
"chars": 12485,
"preview": "//\n// ReversePathsFetch.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 3/20/24.\n//\n\nimport Foundation\nimport A"
},
{
"path": "Pearcleaner/Logic/TCC/TCCModels.swift",
"chars": 4812,
"preview": "//\n// TCCModels.swift\n// Pearcleaner\n//\n// TCC (Transparency, Consent, and Control) data models for privacy permissio"
},
{
"path": "Pearcleaner/Logic/TCC/TCCQueryHelper.swift",
"chars": 5402,
"preview": "//\n// TCCQueryHelper.swift\n// Pearcleaner\n//\n// SQLite3 query helper for TCC (Transparency, Consent, and Control) dat"
},
{
"path": "Pearcleaner/Logic/UndoHistoryManager.swift",
"chars": 6815,
"preview": "//\n// UndoHistoryManager.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/10/25.\n//\n\nimport Foundation\nimport"
},
{
"path": "Pearcleaner/Logic/UndoManager.swift",
"chars": 10776,
"preview": "//\n// UndoManager.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 2/24/25.\n//\n\nimport Foundation\nimport SwiftUI"
},
{
"path": "Pearcleaner/Logic/Utilities.swift",
"chars": 32542,
"preview": "//\n// Utilities.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/3/23.\n//\n\nimport Foundation\nimport SwiftUI\ni"
},
{
"path": "Pearcleaner/PearcleanerApp.swift",
"chars": 4004,
"preview": "//\n// PearcleanerApp.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/31/23.\n//\n\nimport SwiftUI\nimport AppKit"
},
{
"path": "Pearcleaner/Resources/Assets.xcassets/AccentColor.colorset/Contents.json",
"chars": 701,
"preview": "{\n \"colors\" : [\n {\n \"color\" : {\n \"color-space\" : \"display-p3\",\n \"components\" : {\n \"alpha"
},
{
"path": "Pearcleaner/Resources/Assets.xcassets/Contents.json",
"chars": 137,
"preview": "{\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n },\n \"properties\" : {\n \"compression-type\" : \"gpu-optimized"
},
{
"path": "Pearcleaner/Resources/Glass.icon/icon.json",
"chars": 1744,
"preview": "{\n \"fill\" : {\n \"linear-gradient\" : [\n \"display-p3:0.93159,0.94081,0.94081,1.00000\",\n \"display-p3:0.75900,0"
},
{
"path": "Pearcleaner/Resources/Info.plist",
"chars": 1277,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "Pearcleaner/Resources/Localizable.xcstrings",
"chars": 2421255,
"preview": "{\n \"sourceLanguage\" : \"en\",\n \"strings\" : {\n \"\" : {\n \"comment\" : \"Do not translate\",\n \"shouldTranslate\" : "
},
{
"path": "Pearcleaner/Resources/Pearcleaner.entitlements",
"chars": 310,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "Pearcleaner/Resources/askpass.sh",
"chars": 128,
"preview": "#!/bin/bash\nSCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" && pwd )\"\nexec \"$SCRIPT_DIR/../MacOS/Pearcleaner\" ask-p"
},
{
"path": "Pearcleaner/Style/ChromeBorder.swift",
"chars": 3485,
"preview": "//\n// ChromeBorder.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 8/8/25.\n//\n\nimport SwiftUI\n\nextension View {"
},
{
"path": "Pearcleaner/Style/CircularProgressView.swift",
"chars": 1731,
"preview": "//\n// CircularProgressView.swift\n// Pearcleaner\n//\n// Circular progress indicator with thick stroke that fills clockw"
},
{
"path": "Pearcleaner/Style/ControlGroupChrome.swift",
"chars": 2623,
"preview": "//\n// ControlGroupChrome.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 8/8/25.\n//\n\nimport SwiftUI\n\npublic enu"
},
{
"path": "Pearcleaner/Style/PearGroupBox.swift",
"chars": 3581,
"preview": "//\n// IceGroupBox.swift\n// Pearcleaner\n//\n// Created by Jordan Baird (Ice), slightly altered for Pearcleaner usage by"
},
{
"path": "Pearcleaner/Style/SparkleProgressBar.swift",
"chars": 4467,
"preview": "//\n// SparkleProgressBar.swift\n// Pearcleaner\n//\n// Animated sparkle progress bar with twinkle effects\n// Created by"
},
{
"path": "Pearcleaner/Style/Styles.swift",
"chars": 38293,
"preview": "//\n// Styles.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/31/23.\n//\n\nimport Foundation\nimport SwiftUI\nimp"
},
{
"path": "Pearcleaner/Style/Theme.swift",
"chars": 22641,
"preview": "import SwiftUI\nimport AlinFoundation\n\n// MARK: - Observable Theme Manager\nclass ThemeManager: ObservableObject {\n sta"
},
{
"path": "Pearcleaner/Views/AppsUpdaterView/AdoptionSheetView.swift",
"chars": 13796,
"preview": "//\n// AdoptionSheetView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/18/25.\n//\n\nimport SwiftUI\n\nenum Adop"
},
{
"path": "Pearcleaner/Views/AppsUpdaterView/AppsUpdaterView.swift",
"chars": 12337,
"preview": "//\n// AppsUpdaterView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/13/25.\n//\n\nimport SwiftUI\nimport AlinF"
},
{
"path": "Pearcleaner/Views/AppsUpdaterView/CaskAdoptionContentView.swift",
"chars": 5072,
"preview": "//\n// CaskAdoptionContentView.swift\n// Pearcleaner\n//\n// Reusable adoption UI content shared between AdoptionSheetVie"
},
{
"path": "Pearcleaner/Views/AppsUpdaterView/ExpandableActionButton.swift",
"chars": 6451,
"preview": "//\n// ExpandableActionButton.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/25/25.\n//\n\nimport SwiftUI\nimpor"
},
{
"path": "Pearcleaner/Views/AppsUpdaterView/UpdateDetailView.swift",
"chars": 49658,
"preview": "//\n// UpdateDetailView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/19/25.\n//\n\nimport SwiftUI\nimport Alin"
},
{
"path": "Pearcleaner/Views/AppsUpdaterView/UpdateRowViewSidebar.swift",
"chars": 7326,
"preview": "//\n// UpdateRowViewSidebar.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/19/25.\n//\n\nimport SwiftUI\nimport "
},
{
"path": "Pearcleaner/Views/AppsUpdaterView/UpdaterDetailsSidebar.swift",
"chars": 21895,
"preview": "//\n// UpdaterHiddenSidebar.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/16/25.\n//\n\nimport Foundation\nimpo"
},
{
"path": "Pearcleaner/Views/AppsView/AppListItems.swift",
"chars": 8763,
"preview": "//\n// AppListDetails.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/10/23.\n//\n\nimport AlinFoundation\nimport"
},
{
"path": "Pearcleaner/Views/AppsView/AppSearchView.swift",
"chars": 7405,
"preview": "//\n// Searchbar.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 4/26/24.\n//\n\nimport AlinFoundation\nimport Swift"
},
{
"path": "Pearcleaner/Views/AppsView/AppsListView.swift",
"chars": 6399,
"preview": "//\n// AppsListView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 3/4/24.\n//\n\nimport Foundation\nimport SwiftUI"
},
{
"path": "Pearcleaner/Views/AppsView/GridAppItem.swift",
"chars": 7461,
"preview": "//\n// GridAppItem.swift\n// Pearcleaner\n//\n// Created for grid layout mode\n//\n\nimport AlinFoundation\nimport Foundation"
},
{
"path": "Pearcleaner/Views/Brew/AutoUpdateSection.swift",
"chars": 24906,
"preview": "//\n// AutoUpdateSection.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/25/25.\n//\n\nimport SwiftUI\nimport Ali"
},
{
"path": "Pearcleaner/Views/Brew/HomebrewView.swift",
"chars": 11503,
"preview": "//\n// HomebrewView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/01/25.\n//\n\nimport SwiftUI\nimport AlinFoun"
},
{
"path": "Pearcleaner/Views/Brew/LogViewerSheet.swift",
"chars": 951,
"preview": "//\n// LogViewerSheet.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/26/25.\n//\n\nimport SwiftUI\n\nstruct LogVi"
},
{
"path": "Pearcleaner/Views/Brew/MaintenanceSection.swift",
"chars": 24223,
"preview": "//\n// MaintenanceSection.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/01/25.\n//\n\nimport SwiftUI\nimport Al"
},
{
"path": "Pearcleaner/Views/Brew/PackageDetailsSidebar.swift",
"chars": 897,
"preview": "//\n// PackageDetailsSidebar.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/07/25.\n//\n\nimport SwiftUI\nimport"
},
{
"path": "Pearcleaner/Views/Brew/SearchInstallSection.swift",
"chars": 131360,
"preview": "//\n// SearchInstallSection.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/01/25.\n//\n\nimport SwiftUI\nimport "
},
{
"path": "Pearcleaner/Views/Brew/TapManagementSection.swift",
"chars": 24053,
"preview": "//\n// TapManagementSection.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/01/25.\n//\n\nimport SwiftUI\nimport "
},
{
"path": "Pearcleaner/Views/Components/BadgeOverlay.swift",
"chars": 19626,
"preview": "//\n// BadgeOverlay.swift\n// Pearcleaner\n//\n// Unified overlay component for all toolbar badge notifications\n// Creat"
},
{
"path": "Pearcleaner/Views/Components/GlobalConsoleView.swift",
"chars": 6844,
"preview": "//\n// GlobalConsoleView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/13/24.\n//\n\nimport SwiftUI\n\nstruct Gl"
},
{
"path": "Pearcleaner/Views/Components/PermissionsSheetView.swift",
"chars": 6963,
"preview": "//\n// PermissionsSheetView.swift\n// Pearcleaner\n//\n// Custom permissions view with ThemeColors integration\n//\n\nimport"
},
{
"path": "Pearcleaner/Views/Components/SidebarDetailView/GenericSidebarListView.swift",
"chars": 11602,
"preview": "//\n// GenericSidebarListView.swift\n// Pearcleaner\n//\n// Created as a reusable component extracted from AppSearchView\n"
},
{
"path": "Pearcleaner/Views/Components/SidebarDetailView/SidebarDetailLayout.swift",
"chars": 769,
"preview": "//\n// SidebarDetailLayout.swift\n// Pearcleaner\n//\n// Created as a reusable layout component extracted from MainWindow"
},
{
"path": "Pearcleaner/Views/Components/StandardSheetView.swift",
"chars": 2671,
"preview": "//\n// StandardSheetView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/26/25.\n//\n\nimport SwiftUI\n\nstruct St"
},
{
"path": "Pearcleaner/Views/Components/TCCPermissionViewer.swift",
"chars": 5809,
"preview": "//\n// TCCPermissionViewer.swift\n// Pearcleaner\n//\n// Sheet view for displaying TCC (privacy) permissions for an appli"
},
{
"path": "Pearcleaner/Views/DaemonView.swift",
"chars": 44124,
"preview": "//\n// DaemonView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 8/10/25.\n//\n\nimport SwiftUI\nimport AlinFoundat"
},
{
"path": "Pearcleaner/Views/DeleteHistoryView.swift",
"chars": 10553,
"preview": "//\n// DeleteHistoryView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/10/25.\n//\n\nimport SwiftUI\nimport Ali"
},
{
"path": "Pearcleaner/Views/DevelopmentView.swift",
"chars": 76053,
"preview": "//\n// DevelopmentView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/15/24.\n//\nimport SwiftUI\nimport AlinFo"
},
{
"path": "Pearcleaner/Views/FileSearchView.swift",
"chars": 49514,
"preview": "//\n// FileSearchView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 09/29/25.\n//\n\nimport SwiftUI\nimport AlinFo"
},
{
"path": "Pearcleaner/Views/FilesView/FileCategory.swift",
"chars": 11436,
"preview": "//\n// FileCategory.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/18/25.\n//\n\nimport Foundation\nimport Swift"
},
{
"path": "Pearcleaner/Views/FilesView/FileListView.swift",
"chars": 16683,
"preview": "//\n// FileListView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 7/31/25.\n//\n\nimport AlinFoundation\nimport Fo"
},
{
"path": "Pearcleaner/Views/FilesView/FilesSidebarView.swift",
"chars": 17077,
"preview": "//\n// SidebarView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 7/31/25.\n//\n\nimport Foundation\nimport SwiftUI"
},
{
"path": "Pearcleaner/Views/FilesView/FilesView.swift",
"chars": 28479,
"preview": "//\n// FilesView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/1/23.\n//\n\nimport AlinFoundation\nimport Found"
},
{
"path": "Pearcleaner/Views/FilesView/TranslationSelectionSheet.swift",
"chars": 8933,
"preview": "//\n// TranslationSelectionSheet.swift\n// Pearcleaner\n//\n// Created by Claude on 10/20/25.\n//\n\nimport SwiftUI\nimport A"
},
{
"path": "Pearcleaner/Views/LipoView/LipoSidebarView.swift",
"chars": 8723,
"preview": "//\n// LipoSidebarView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 8/9/25.\n//\n\nimport AlinFoundation\nimport "
},
{
"path": "Pearcleaner/Views/LipoView/LipoView.swift",
"chars": 29669,
"preview": "//\n// LipoView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 3/20/25.\n//\n\nimport AlinFoundation\nimport SwiftU"
},
{
"path": "Pearcleaner/Views/MainWindow.swift",
"chars": 38089,
"preview": "//\n// AppListH.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/5/23.\n//\n\nimport AlinFoundation\nimport Finder"
},
{
"path": "Pearcleaner/Views/PackageView.swift",
"chars": 66154,
"preview": "//\n// PackageView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 8/10/25.\n//\n\nimport SwiftUI\nimport AlinFounda"
},
{
"path": "Pearcleaner/Views/PluginsView.swift",
"chars": 62928,
"preview": "//\n// PluginsView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 09/29/25.\n//\n\nimport SwiftUI\nimport AlinFound"
},
{
"path": "Pearcleaner/Views/Settings/About.swift",
"chars": 7070,
"preview": "//\n// About.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/5/23.\n//\n\nimport SwiftUI\nimport AlinFoundation\n\n"
},
{
"path": "Pearcleaner/Views/Settings/Folders.swift",
"chars": 24835,
"preview": "//\n// Folders.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 3/20/24.\n//\n\n\nimport Foundation\nimport SwiftUI\nim"
},
{
"path": "Pearcleaner/Views/Settings/General.swift",
"chars": 25929,
"preview": "//\n// General.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/5/23.\n//\n\nimport Foundation\nimport SwiftUI\nimp"
},
{
"path": "Pearcleaner/Views/Settings/Helper.swift",
"chars": 8829,
"preview": "//\n// Helper.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 3/14/25.\n//\n\nimport SwiftUI\nimport Foundation\nimpo"
},
{
"path": "Pearcleaner/Views/Settings/Interface.swift",
"chars": 15586,
"preview": "//\n// Interface.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 3/18/24.\n//\n\n\nimport Foundation\nimport SwiftUI\n"
},
{
"path": "Pearcleaner/Views/Settings/SettingsWindow.swift",
"chars": 10576,
"preview": "//\n// SettingsWindow.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 10/31/23.\n//\n\nimport SwiftUI\nimport AlinFo"
},
{
"path": "Pearcleaner/Views/Settings/Update.swift",
"chars": 2766,
"preview": "//\n// Update.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 11/5/23.\n//\n\n\nimport SwiftUI\nimport Foundation\nimp"
},
{
"path": "Pearcleaner/Views/ZombieView/ZombieSidebarView.swift",
"chars": 8247,
"preview": "//\n// ZombieSidebarView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 8/9/25.\n//\n\nimport Foundation\nimport Sw"
},
{
"path": "Pearcleaner/Views/ZombieView/ZombieView.swift",
"chars": 37338,
"preview": "//\n// ZombieView.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 2/26/24.\n//\n\nimport Foundation\nimport SwiftUI\n"
},
{
"path": "Pearcleaner/announcements.json",
"chars": 2736,
"preview": "{\n \"4.4.0\": \"- Privileged Helper Service: Pearcleaner now supports using a privileged helper service to perform opera"
},
{
"path": "Pearcleaner.xcodeproj/project.pbxproj",
"chars": 60847,
"preview": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 73;\n\tobjects = {\n\n/* Begin PBXBuildFile section *"
},
{
"path": "Pearcleaner.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
"chars": 135,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n version = \"1.0\">\n <FileRef\n location = \"self:\">\n </FileRef"
},
{
"path": "Pearcleaner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
"chars": 238,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "Pearcleaner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved",
"chars": 947,
"preview": "{\n \"originHash\" : \"878acce7c95e9774ee43b058d63510f4ad7a7a3a673f5600c415bcc68aad7c3f\",\n \"pins\" : [\n {\n \"identit"
},
{
"path": "Pearcleaner.xcodeproj/xcshareddata/xcschemes/FinderOpen.xcscheme",
"chars": 3982,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n LastUpgradeVersion = \"2600\"\n wasCreatedForAppExtension = \"YES\"\n ve"
},
{
"path": "Pearcleaner.xcodeproj/xcshareddata/xcschemes/Pearcleaner Debug.xcscheme",
"chars": 3276,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n LastUpgradeVersion = \"2600\"\n version = \"1.7\">\n <BuildAction\n "
},
{
"path": "Pearcleaner.xcodeproj/xcshareddata/xcschemes/Pearcleaner Release.xcscheme",
"chars": 2884,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n LastUpgradeVersion = \"2600\"\n version = \"1.7\">\n <BuildAction\n "
},
{
"path": "Pearcleaner.xcodeproj/xcshareddata/xcschemes/PearcleanerSentinel Release.xcscheme",
"chars": 2917,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n LastUpgradeVersion = \"2600\"\n version = \"1.7\">\n <BuildAction\n "
},
{
"path": "Pearcleaner.xcodeproj/xcshareddata/xcschemes/PearcleanerSentinel.xcscheme",
"chars": 2948,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n LastUpgradeVersion = \"2600\"\n version = \"1.7\">\n <BuildAction\n "
},
{
"path": "PearcleanerHelper/CodesignCheck.swift",
"chars": 4569,
"preview": "//\n// CodesignCheck.swift\n//\n// Created by Erik Berglund on 2018-10-01.\n// Copyright © 2018 Erik Berglund. All rights"
},
{
"path": "PearcleanerHelper/com.alienator88.Pearcleaner.PearcleanerHelper.plist",
"chars": 564,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "PearcleanerHelper/main.swift",
"chars": 3896,
"preview": "//\n// main.swift\n// PearcleanerHelper\n//\n// Created by Alin Lupascu on 3/14/25.\n//\n\nimport Foundation\nimport Objectiv"
},
{
"path": "PearcleanerSentinel/FileWatcher.swift",
"chars": 7122,
"preview": "//\n// FileWatcher.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 9/2/25.\n//\n\nimport Cocoa\nimport Foundation\n\np"
},
{
"path": "PearcleanerSentinel/com.alienator88.PearcleanerSentinel.plist",
"chars": 507,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "PearcleanerSentinel/main.swift",
"chars": 2186,
"preview": "//\n// main.swift\n// PearcleanerSentinel\n//\n// Created by Alin Lupascu on 11/9/23.\n//\n\n\nimport AppKit\n\nmain()\n\nvar glo"
},
{
"path": "README.md",
"chars": 6243,
"preview": "# Pearcleaner\n\n### Project Status: \n> As you may have noticed, there haven't been recent updates made to the app and I w"
},
{
"path": "Shared/AppGroupDefaults.swift",
"chars": 591,
"preview": "//\n// AppGroupDefaults.swift\n// Pearcleaner\n//\n// Created by Alin Lupascu on 9/30/25.\n//\n\nimport Foundation\n\nextensio"
},
{
"path": "announcements.json",
"chars": 3243,
"preview": "{\n \"4.4.0\": {\n \"features\": [\n \"Privileged Helper Service: Pearcleaner now supports using a privileged helper se"
}
]
// ... and 3 more files (download for full content)
About this extraction
This page contains the full source code of the alienator88/Pearcleaner GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 179 files (15.0 MB), approximately 1.1M tokens, and a symbol index with 26 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.