This commit is contained in:
Alin
2024-04-17 17:36:04 -06:00
parent b9a60f5977
commit 390e23e31d
24 changed files with 1097 additions and 529 deletions
+16
View File
@@ -16,11 +16,13 @@
C72893052AFD42E600C8C1CD /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72893042AFD42E600C8C1CD /* DeepLink.swift */; };
C72893122AFD51EA00C8C1CD /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72893112AFD51EA00C8C1CD /* main.swift */; };
C74412C72BBF249000DDFCA8 /* AppPathsFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74412C62BBF249000DDFCA8 /* AppPathsFetch.swift */; };
C74FDC632BCD833D00B8960F /* Conditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74FDC622BCD833D00B8960F /* Conditions.swift */; };
C7575D392B0182DE006A600A /* PearcleanerSentinel in CopyFiles */ = {isa = PBXBuildFile; fileRef = C728930F2AFD51EA00C8C1CD /* PearcleanerSentinel */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
C76D08482AF83C3F00D07867 /* Update.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76D08472AF83C3F00D07867 /* Update.swift */; };
C76D084A2AF83C6E00D07867 /* General.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76D08492AF83C6E00D07867 /* General.swift */; };
C76D08512AF850F300D07867 /* About.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76D08502AF850F300D07867 /* About.swift */; };
C76D08552AF89CDE00D07867 /* RegularMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76D08542AF89CDE00D07867 /* RegularMode.swift */; };
C771CB762BD028AF00C33A18 /* SegmentedPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C771CB752BD028AF00C33A18 /* SegmentedPicker.swift */; };
C77B90042AF18E2E009CC655 /* PearcleanerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C77B90032AF18E2E009CC655 /* PearcleanerApp.swift */; };
C77B90082AF18E2F009CC655 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C77B90072AF18E2F009CC655 /* Assets.xcassets */; };
C77B90162AF19377009CC655 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C77B90152AF19377009CC655 /* AppState.swift */; };
@@ -106,10 +108,12 @@
C728930F2AFD51EA00C8C1CD /* PearcleanerSentinel */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = PearcleanerSentinel; sourceTree = BUILT_PRODUCTS_DIR; };
C72893112AFD51EA00C8C1CD /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
C74412C62BBF249000DDFCA8 /* AppPathsFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPathsFetch.swift; sourceTree = "<group>"; };
C74FDC622BCD833D00B8960F /* Conditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conditions.swift; sourceTree = "<group>"; };
C76D08472AF83C3F00D07867 /* Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update.swift; sourceTree = "<group>"; };
C76D08492AF83C6E00D07867 /* General.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = General.swift; sourceTree = "<group>"; };
C76D08502AF850F300D07867 /* About.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = About.swift; sourceTree = "<group>"; };
C76D08542AF89CDE00D07867 /* RegularMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularMode.swift; sourceTree = "<group>"; };
C771CB752BD028AF00C33A18 /* SegmentedPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPicker.swift; sourceTree = "<group>"; };
C77B90002AF18E2E009CC655 /* Pearcleaner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Pearcleaner.app; sourceTree = BUILT_PRODUCTS_DIR; };
C77B90032AF18E2E009CC655 /* PearcleanerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PearcleanerApp.swift; sourceTree = "<group>"; };
C77B90072AF18E2F009CC655 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -197,6 +201,7 @@
C72893042AFD42E600C8C1CD /* DeepLink.swift */,
C77B901B2AF193A3009CC655 /* Styles.swift */,
C7D31D502AFF00F300C7ED9E /* Locations.swift */,
C74FDC622BCD833D00B8960F /* Conditions.swift */,
C7A9CE462B89164700EB6E78 /* Authorization.swift */,
C7ACADE52B92A737000B5845 /* Features.swift */,
C7ED86B22BA8ED8800F13DE4 /* Trash.swift */,
@@ -246,6 +251,14 @@
path = Resources;
sourceTree = "<group>";
};
C771CB742BD028A000C33A18 /* Picker */ = {
isa = PBXGroup;
children = (
C771CB752BD028AF00C33A18 /* SegmentedPicker.swift */,
);
path = Picker;
sourceTree = "<group>";
};
C77B8FF72AF18E2E009CC655 = {
isa = PBXGroup;
children = (
@@ -283,6 +296,7 @@
C77B901F2AF1B390009CC655 /* Views */ = {
isa = PBXGroup;
children = (
C771CB742BD028A000C33A18 /* Picker */,
C77B90222AF2D616009CC655 /* FilesView.swift */,
C71848432B8D2D600046CB13 /* ZombieView.swift */,
C76D08542AF89CDE00D07867 /* RegularMode.swift */,
@@ -471,9 +485,11 @@
C77B90182AF19382009CC655 /* AppCommands.swift in Sources */,
C7D31D4A2AFEB26700C7ED9E /* AppListItems.swift in Sources */,
C77B901E2AF1A9DB009CC655 /* SettingsWindow.swift in Sources */,
C771CB762BD028AF00C33A18 /* SegmentedPicker.swift in Sources */,
C74412C72BBF249000DDFCA8 /* AppPathsFetch.swift in Sources */,
C79D05202BC99EB40083F976 /* ThemeManager.swift in Sources */,
C7D31D482AFEB23700C7ED9E /* TopBar.swift in Sources */,
C74FDC632BCD833D00B8960F /* Conditions.swift in Sources */,
C7ACADE62B92A737000B5845 /* Features.swift in Sources */,
C7F539382AF60865007DF1B2 /* Utilities.swift in Sources */,
C76D08552AF89CDE00D07867 /* RegularMode.swift in Sources */,
+1 -11
View File
@@ -8,7 +8,6 @@
import SwiftUI
struct AppCommands: Commands {
@AppStorage("settings.general.instant") private var instantSearch: Bool = true
let appState: AppState
let locations: Locations
@@ -42,17 +41,9 @@ struct AppCommands: Commands {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
appState.sortedApps = []
appState.sortedApps = sortedApps
if instantSearch {
Task(priority: .high){
loadAllPaths(allApps: sortedApps, appState: appState, locations: locations) {
appState.reload.toggle()
}
}
} else {
appState.reload.toggle()
}
}
}
} label: {
Text("Refresh Apps")
}
@@ -78,8 +69,7 @@ struct AppCommands: Commands {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
appState.sortedApps = sortedApps
for app in appState.trashedFiles {
let pathFinder = AppPathFinder(appInfo: app, appState: appState, locations: locations)
pathFinder.findPaths()
AppPathFinder(appInfo: app, appState: appState, locations: locations).findPaths()
}
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ class AppInfoFetcher {
let system = !path.path.contains(NSHomeDirectory())
return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName, appVersion: appVersion, appIcon: appIcon,
webApp: webApp, wrapped: wrapped, system: system, files: [], fileSize: [:], fileIcon: [:])
webApp: webApp, wrapped: wrapped, system: system, files: [], fileSize: [:], fileSizeLogical: [:], fileIcon: [:])
}
private static func fetchAppIcon(for path: URL, wrapped: Bool) -> NSImage? {
+360 -98
View File
@@ -9,6 +9,7 @@ import Foundation
import AppKit
import SwiftUI
class AppPathFinder {
private var appInfo: AppInfo
private var appState: AppState
@@ -18,7 +19,6 @@ class AppPathFinder {
private var completion: () -> Void = {}
private var collection: [URL] = []
private let collectionAccessQueue = DispatchQueue(label: "com.alienator88.Pearcleaner.appPathFinder.collectionAccess")
@AppStorage("settings.general.instant") var instantSearch: Bool = true
init(appInfo: AppInfo = .empty, appState: AppState, locations: Locations, backgroundRun: Bool = false, reverseAddon: Bool = false, completion: @escaping () -> Void = {}) {
self.appInfo = appInfo
@@ -124,10 +124,21 @@ class AppPathFinder {
collectionAccessQueue.sync {
containsItem = self.collection.contains(itemURL)
}
return itemL.hasPrefix("comapple") && !["comappleconfigurator", "comappledt", "comappleiwork", "comapplesfsymbols", "comappletestflight"].contains(where: itemL.hasPrefix) || containsItem || !isSupportedFileType(at: itemURL.path)
if containsItem || !isSupportedFileType(at: itemURL.path) {
return true
}
for skipCondition in skipConditions {
if itemL.hasPrefix(skipCondition.skipPrefix) {
let isAllowed = skipCondition.allowPrefixes.contains(where: itemL.hasPrefix)
if !isAllowed {
return true // Skip because it starts with a base prefix but is not in the allowed list
}
}
}
return false
}
@@ -138,85 +149,20 @@ class AppPathFinder {
let nameL = self.appInfo.appName.pearFormat()
let nameP = self.appInfo.path.lastPathComponent.replacingOccurrences(of: ".app", with: "")
let exclusions = [
"comappledtxcode": ["comrobotsandpencilsxcodesapp", "comoneminutegamesxcodecleaner", "iohyperappxcodecleaner", "xcodesjson"],
"comrobotsandpencilsxcodesapp": ["comappledtxcode", "comoneminutegamesxcodecleaner", "iohyperappxcodecleaner"],
"iohyperappxcodecleaner": ["comrobotsandpencilsxcodesapp", "comoneminutegamesxcodecleaner", "comappledtxcode", "xcodesjson"]
]
for condition in conditions {
if bundleIdentifierL.contains(condition.bundle_id) {
// Exclude and include keywords
let hasIncludeKeyword = condition.include.contains(where: itemL.contains)
let hasExcludeKeyword = condition.exclude.contains(where: itemL.contains)
if let excludedApps = exclusions[bundleIdentifierL], excludedApps.contains(where: { itemL.contains($0) }) {
if hasExcludeKeyword {
return false
}
if bundleIdentifierL.contains("comappledtxcode") {
// Include items that are part of the Xcode ecosystem
if itemL.contains("comappledt") || itemL.contains("xcode") || itemL.contains("simulator") {
if hasIncludeKeyword {
if !condition.exclude.contains(where: itemL.contains) {
return true
}
}
if bundleIdentifierL.contains("uszoomxos") {
// Include items for Zoom that are not similar to the app name and/or bundle id
if itemL.contains("zoom") {
return true
}
}
if bundleIdentifierL.contains("combravebrowser") {
// Include items for Brave that are not similar to the app name and/or bundle id
if itemL.contains("brave") {
return true
}
}
if bundleIdentifierL.contains("comgooglechrome") {
// Include items for Chrome that are not similar to the app name and/or bundle id
if (itemL.contains("google") && !itemL.contains("iterm")) || (itemL.contains("chrome") && !itemL.contains("chromefeaturestate")) {
return true
}
}
if bundleIdentifierL.contains("commicrosoftedgemac") {
// Include items for Edge that are not similar to the app name and/or bundle id
let exclusions = ["vscode","rdc","appcenter","office","oneauth"]
if itemL.contains("microsoft") && !exclusions.contains(where: itemL.contains) {
return true
}
}
if bundleIdentifierL.contains("orgmozillafirefox") {
// Include items for Firefox that are not similar to the app name and/or bundle id
if itemL.contains("mozilla") {
return true
}
}
if bundleIdentifierL.contains("comlogioptionsplus") {
// Include items for Zoom that are not similar to the app name and/or bundle id
if itemL.contains("logi") && !itemL.contains("login") && !itemL.contains("logic") {
return true
}
}
if bundleIdentifierL.contains("commicrosoftvscode") {
// Include items for vscode
if itemL.contains("vscode") {
return true
}
}
if bundleIdentifierL.contains("comfacebookarchondeveloperid") {
// Include items for FB Messenger
if itemL.contains("archonloginhelper") {
return true
}
}
if bundleIdentifierL.contains("euexelbanstats") {
// Include items for Stats that are not similar to the app name and/or bundle id
if itemL.contains("video") {
return false
}
}
@@ -263,37 +209,35 @@ class AppPathFinder {
private func handleOutliers() -> [URL] {
var outliers: [URL] = []
let bundleIdentifier = self.appInfo.bundleIdentifier.pearFormat()
// Handle VSCode folder
if self.appInfo.bundleIdentifier.pearFormat().contains("commicrosoftvscode") {
let appSupportCodePath = "\(home)/Library/Application Support/Code"
if let codeDirectoryURL = URL(string: appSupportCodePath), FileManager.default.fileExists(atPath: codeDirectoryURL.path) {
outliers.append(codeDirectoryURL)
}
// Find conditions that match the current app's bundle identifier
let matchingConditions = conditions.filter { condition in
bundleIdentifier.contains(condition.bundle_id)
}
// Handle Xcode folder
if self.appInfo.bundleIdentifier.pearFormat().contains("comappledtxcode") {
let simulators = "\(home)/Library/Containers/com.apple.iphonesimulator.ShareExtension"
if let simulatorsURL = URL(string: simulators), FileManager.default.fileExists(atPath: simulatorsURL.path) {
outliers.append(simulatorsURL)
for condition in matchingConditions {
if let forceIncludes = condition.includeForce {
for path in forceIncludes {
if let url = URL(string: path), FileManager.default.fileExists(atPath: url.path) {
outliers.append(url)
}
}
}
}
// Add other outlier cases here as needed
return outliers
}
private func finalizeCollection() {
DispatchQueue.global(qos: .userInitiated).async {
let groupContainers = self.getGroupContainers(bundleURL: self.appInfo.path)
// let groupContainers = self.getGroupContainers(bundleURL: self.appInfo.path)
let outliers = self.handleOutliers()
var tempCollection: [URL] = []
self.collectionAccessQueue.sync {
tempCollection = self.collection
}
tempCollection.append(contentsOf: groupContainers)
// tempCollection.append(contentsOf: groupContainers)
tempCollection.append(contentsOf: outliers)
// Sort and standardize URLs to ensure consistent comparisons
@@ -318,10 +262,13 @@ class AppPathFinder {
private func handlePostProcessing(sortedCollection: [URL]) {
// Calculate file details (sizes and icons), update app state, and call completion
var fileSize: [URL: Int64] = [:]
var fileSizeLogical: [URL: Int64] = [:]
var fileIcon: [URL: NSImage?] = [:]
for path in sortedCollection {
fileSize[path] = totalSizeOnDisk(for: path)
let size = totalSizeOnDisk(for: path)
fileSize[path] = size.real
fileSizeLogical[path] = size.logical
fileIcon[path] = getIconForFileOrFolderNS(atPath: path)
}
@@ -332,8 +279,8 @@ class AppPathFinder {
}
// Update appInfo and appState with the new values
self.appInfo.files = updatedCollection
self.appInfo.fileSize = fileSize
self.appInfo.fileSizeLogical = fileSizeLogical
self.appInfo.fileIcon = fileIcon
if !self.backgroundRun {
@@ -341,12 +288,6 @@ class AppPathFinder {
self.appState.selectedItems = Set(updatedCollection)
}
// Only append object to store if instant search. Same for calculating progress.
if self.instantSearch {
self.appState.appInfoStore.append(self.appInfo)
self.appState.instantProgress += 1
}
// Append object to store if running reverse search with empty store
if self.reverseAddon {
self.appState.appInfoStore.append(self.appInfo)
@@ -356,3 +297,324 @@ class AppPathFinder {
}
}
}
// Async Test
//class AppPathFinder {
// private var appInfo: AppInfo
// private var appState: AppState
// private var locations: Locations
// private var backgroundRun: Bool
// private var reverseAddon: Bool
// private var completion: () -> Void = {}
// private var collection: [URL] = []
// private let collectionAccessQueue = DispatchQueue(label: "com.alienator88.Pearcleaner.appPathFinder.collectionAccess")
// private var state = PathFinderState() // Actor instance
//
// init(appInfo: AppInfo = .empty, appState: AppState, locations: Locations, backgroundRun: Bool = false, reverseAddon: Bool = false, completion: @escaping () -> Void = {}) {
// self.appInfo = appInfo
// self.appState = appState
// self.locations = locations
// self.backgroundRun = backgroundRun
// self.reverseAddon = reverseAddon
// self.completion = completion
// }
//
// func findPaths() async {
// await initialURLProcessing()
// await collectDirectories()
// await collectFiles()
// await finalizeCollection()
// }
//
// private func initialURLProcessing() async {
// if let url = URL(string: self.appInfo.path.absoluteString), !url.path.contains(".Trash") {
// let modifiedUrl = url.path.contains("Wrapper") ? url.deletingLastPathComponent().deletingLastPathComponent() : url
// collectionAccessQueue.sync {
// self.collection.append(modifiedUrl)
// }
// }
// }
//
// private func collectDirectories() async {
// for location in self.locations.apps.paths {
// await processDirectoryLocation(location)
// }
// }
//
// private func processDirectoryLocation(_ location: String) async {
// do {
// let contents = try FileManager.default.contentsOfDirectory(atPath: location)
// for item in contents {
// let itemURL = URL(fileURLWithPath: location).appendingPathComponent(item)
// let itemL = item.replacingOccurrences(of: ".", with: "").replacingOccurrences(of: " ", with: "").lowercased()
//
// var isDirectory: ObjCBool = false
// if FileManager.default.fileExists(atPath: itemURL.path, isDirectory: &isDirectory), isDirectory.boolValue {
// // Perform the check to skip the item if needed
// if shouldSkipItem(itemL, at: itemURL) {
// continue
// }
//
// collectionAccessQueue.sync {
// let alreadyIncluded = self.collection.contains { existingURL in
// itemURL.path.hasPrefix(existingURL.path)
// }
//
// if !alreadyIncluded && specificCondition(itemL: itemL, itemURL: itemURL) {
// self.collection.append(itemURL)
// }
// }
// }
// }
// } catch {
// print("Error processing directory location: \(location), error: \(error)")
// }
// }
//
// private func collectFiles() async {
// for location in self.locations.apps.paths {
// await processFileLocation(location)
// }
// }
//
// private func processFileLocation(_ location: String) async {
// do {
// let contents = try FileManager.default.contentsOfDirectory(atPath: location)
// for item in contents {
// let itemURL = URL(fileURLWithPath: location).appendingPathComponent(item)
// let itemL = item.replacingOccurrences(of: ".", with: "").replacingOccurrences(of: " ", with: "").lowercased()
//
// if FileManager.default.fileExists(atPath: itemURL.path),
// !shouldSkipItem(itemL, at: itemURL),
// specificCondition(itemL: itemL, itemURL: itemURL) {
// collectionAccessQueue.sync {
// self.collection.append(itemURL)
// }
// }
// }
// } catch {
// print("Error processing file location: \(location), error: \(error)")
// }
// }
//
//
//
// private func shouldSkipItem(_ itemL: String, at itemURL: URL) -> Bool {
// var containsItem = false
// collectionAccessQueue.sync {
// containsItem = self.collection.contains(itemURL)
// }
// if containsItem || !isSupportedFileType(at: itemURL.path) {
// return true
// }
//
// for skipCondition in skipConditions {
// if itemL.hasPrefix(skipCondition.skipPrefix) {
// let isAllowed = skipCondition.allowPrefixes.contains(where: itemL.hasPrefix)
// if !isAllowed {
// return true // Skip because it starts with a base prefix but is not in the allowed list
// }
// }
// }
//
// return false
// }
//
//
//
// private func specificCondition(itemL: String, itemURL: URL) -> Bool {
// let bundleIdentifierL = self.appInfo.bundleIdentifier.pearFormat()
// let bundleComponents = self.appInfo.bundleIdentifier.components(separatedBy: ".").compactMap { $0 != "-" ? $0.lowercased() : nil }
// let bundle = bundleComponents.suffix(2).joined()
// let nameL = self.appInfo.appName.pearFormat()
// let nameP = self.appInfo.path.lastPathComponent.replacingOccurrences(of: ".app", with: "")
//
// for condition in conditions {
// if bundleIdentifierL.contains(condition.bundle_id) {
// // Exclude and include keywords
// let hasIncludeKeyword = condition.include.contains(where: itemL.contains)
// let hasExcludeKeyword = condition.exclude.contains(where: itemL.contains)
//
// if hasExcludeKeyword {
// return false
// }
// if hasIncludeKeyword {
// if !condition.exclude.contains(where: itemL.contains) {
// return true
// }
// }
// }
// }
//
//
// if self.appInfo.webApp {
// return itemL.contains(bundleIdentifierL)
// }
//
// return itemL.contains(bundleIdentifierL) || itemL.contains(bundle) || (nameL.count > 3 && itemL.contains(nameL)) || (nameP.count > 3 && itemL.contains(nameP))
//
// }
//
//
// func getGroupContainers(bundleURL: URL) async -> [URL] {
// await withCheckedContinuation { continuation in
// var groupContainers: [URL] = []
// // Assume creating a SecStaticCode is synchronous and quick.
// var staticCode: SecStaticCode?
// let status = SecStaticCodeCreateWithPath(bundleURL as CFURL, [], &staticCode)
//
// if status == errSecSuccess, let staticCode = staticCode {
// var signingInformation: CFDictionary?
//
// // This may involve a blocking call to fetch signing information.
// let status = SecCodeCopySigningInformation(staticCode, SecCSFlags(), &signingInformation)
//
// if status == errSecSuccess, let infoDict = signingInformation as? [String: Any],
// let entitlementsDict = infoDict["entitlements-dict"] as? [String: Any],
// let appGroups = entitlementsDict["com.apple.security.application-groups"] as? [String] {
//
// let fileManager = FileManager.default
// let home = NSHomeDirectory() // Assuming `home` is derived earlier or statically known.
//
// for groupID in appGroups {
// let groupURL = URL(fileURLWithPath: "\(home)/Library/Group Containers/\(groupID)")
// if fileManager.fileExists(atPath: groupURL.path) {
// groupContainers.append(groupURL)
// }
// }
// }
// }
//
// // Continue once all operations are complete.
// continuation.resume(returning: groupContainers)
// }
// }
//
//
// private func handleOutliers() -> [URL] {
// var outliers: [URL] = []
// let bundleIdentifier = self.appInfo.bundleIdentifier.pearFormat()
//
// // Find conditions that match the current app's bundle identifier
// let matchingConditions = conditions.filter { condition in
// bundleIdentifier.contains(condition.bundle_id)
// }
//
// for condition in matchingConditions {
// if let forceIncludes = condition.includeForce {
// for path in forceIncludes {
// if let url = URL(string: path), FileManager.default.fileExists(atPath: url.path) {
// outliers.append(url)
// }
// }
// }
// }
//
// return outliers
// }
//
// private func finalizeCollection() async {
// let groupContainers = await getGroupContainers(bundleURL: self.appInfo.path)
// let outliers = handleOutliers()
// var tempCollection: [URL] = []
//
// collectionAccessQueue.sync {
// tempCollection = self.collection
// }
//
// tempCollection.append(contentsOf: groupContainers)
// tempCollection.append(contentsOf: outliers)
//
// // Sort and standardize URLs to ensure consistent comparisons
// let sortedCollection = tempCollection.map { $0.standardizedFileURL }.sorted(by: { $0.path < $1.path })
// var filteredCollection: [URL] = []
// var previousUrl: URL?
//
// for url in sortedCollection {
// if let previous = previousUrl, url.path.hasPrefix(previous.path + "/") {
// // Current URL is a subdirectory of the previous one, so skip it
// continue
// }
// // This URL is not a subdirectory of the previous one, so keep it and set it as the previous URL
// filteredCollection.append(url)
// previousUrl = url
// }
//
// await handlePostProcessing(sortedCollection: filteredCollection)
// }
//
// private func handlePostProcessing(sortedCollection: [URL]) async {
// for path in sortedCollection {
// let size = totalSizeOnDisk(for: path)
// if let icon = getIconForFileOrFolderNS(atPath: path) { // Retrieve NSImage
// let iconData = serializeImage(icon) // Convert NSImage to Data
// await state.setFileDetails(for: path, size: size, icon: iconData) // Pass Data to actor
// } else {
// await state.setFileDetails(for: path, size: size, icon: nil)
// }
// }
// await updateAppState(with: sortedCollection)
// }
//
// private func updateAppState(with sortedCollection: [URL]) async {
// // Retrieve state from the actor
// let fileSize = await self.state.getFileSize()
// let fileIconData = await self.state.getFileIconData()
// let fileIcons = fileIconData.mapValues { deserializeImage($0) }
//
// // Assume updating of appInfo needs to happen on the main thread
// self.appInfo.fileSize = fileSize
// self.appInfo.fileIcon = fileIcons
//
// // Execute UI related updates on the main thread using MainActor
// await MainActor.run {
// if !self.backgroundRun {
// self.appState.appInfo = self.appInfo
// self.appState.selectedItems = Set(sortedCollection)
// }
//
//
// }
//
// self.completion() // Call the completion handler
// }
//}
//
//actor PathFinderState {
// var fileSize: [URL: Int64] = [:]
// var fileIconData: [URL: Data?] = [:]
//
// func setFileDetails(for path: URL, size: Int64, icon: Data?) {
// fileSize[path] = size
// fileIconData[path] = icon
// }
//
// func getFileSize() -> [URL: Int64] {
// return fileSize
// }
//
// func getFileIconData() -> [URL: Data?] {
// return fileIconData
// }
//}
//
//
//func serializeImage(_ image: NSImage?) -> Data? {
// guard let image = image else { return nil }
// guard let tiffData = image.tiffRepresentation else { return nil }
// let bitmapImage = NSBitmapImageRep(data: tiffData)
// return bitmapImage?.representation(using: .png, properties: [:])
//}
//
//func deserializeImage(_ data: Data?) -> NSImage? {
// guard let data = data else { return nil }
// return NSImage(data: data)
//}
+14 -7
View File
@@ -29,9 +29,6 @@ class AppState: ObservableObject
@Published var progressBar: (String, Double) = ("Ready", 0.0)
@Published var reload: Bool = false
@Published var showProgress: Bool = false
@Published var popCount: Int = 0
@Published var instantProgress: Double = 0.0
@Published var instantTotal: Double = 0.0
@Published var finderExtensionEnabled: Bool = false
@@ -48,12 +45,14 @@ class AppState: ObservableObject
system: false,
files: [],
fileSize: [:],
fileSizeLogical: [:],
fileIcon: [:]
)
self.zombieFile = ZombieFile(
id: UUID(),
fileSize: [:],
fileSizeLogical: [:],
fileIcon: [:]
)
updateExtensionStatus()
@@ -91,14 +90,19 @@ struct AppInfo: Identifiable, Equatable, Hashable {
let system: Bool
var files: [URL]
var fileSize: [URL:Int64]
var fileSizeLogical: [URL:Int64]
var fileIcon: [URL:NSImage?]
var totalSize: Int64
{
return fileSize.values.reduce(0, +)
}
var totalSizeLogical: Int64
{
return fileSizeLogical.values.reduce(0, +)
}
static let empty = AppInfo(id: UUID(), path: URL(fileURLWithPath: ""), bundleIdentifier: "", appName: "", appVersion: "", appIcon: nil, webApp: false, wrapped: false, system: false, files: [], fileSize: [:], fileIcon: [:])
static let empty = AppInfo(id: UUID(), path: URL(fileURLWithPath: ""), bundleIdentifier: "", appName: "", appVersion: "", appIcon: nil, webApp: false, wrapped: false, system: false, files: [], fileSize: [:], fileSizeLogical: [:], fileIcon: [:])
}
@@ -107,14 +111,19 @@ struct AppInfo: Identifiable, Equatable, Hashable {
struct ZombieFile: Identifiable, Equatable, Hashable {
let id: UUID
var fileSize: [URL:Int64]
var fileSizeLogical: [URL:Int64]
var fileIcon: [URL:NSImage?]
var totalSize: Int64
{
return fileSize.values.reduce(0, +)
}
var totalSizeLogical: Int64
{
return fileSizeLogical.values.reduce(0, +)
}
static let empty = ZombieFile(id: UUID(), fileSize: [:], fileIcon: [:])
static let empty = ZombieFile(id: UUID(), fileSize: [:], fileSizeLogical: [:], fileIcon: [:])
}
@@ -201,5 +210,3 @@ enum DisplayMode: Int, CaseIterable {
}
}
}
+149
View File
@@ -0,0 +1,149 @@
//
// Conditions.swift
// Pearcleaner
//
// Created by Alin Lupascu on 4/15/24.
//
import Foundation
struct Condition: Decodable {
var bundle_id: String
var include: [String]
var exclude: [String]
var includeForce: [String]?
}
struct SkipCondition {
var skipPrefix: String
var allowPrefixes: [String]
}
// Conditions for some apps that need to include/exclude certain files/folders when names are more complicated
var conditions: [Condition] = [
Condition(
bundle_id: "comappledtxcode",
include: ["comappledt", "xcode", "simulator"],
exclude: ["comrobotsandpencilsxcodesapp", "comoneminutegamesxcodecleaner", "iohyperappxcodecleaner", "xcodesjson"],
includeForce: ["\(home)/Library/Containers/com.apple.iphonesimulator.ShareExtension"]
),
Condition(
bundle_id: "comrobotsandpencilsxcodesapp",
include: [],
exclude: ["comappledtxcode", "comoneminutegamesxcodecleaner", "iohyperappxcodecleaner"]
),
Condition(
bundle_id: "iohyperappxcodecleaner",
include: [],
exclude: ["comrobotsandpencilsxcodesapp", "comoneminutegamesxcodecleaner", "comappledtxcode", "xcodesjson"]
),
Condition(
bundle_id: "uszoomxos",
include: ["zoom"],
exclude: []
),
Condition(
bundle_id: "combravebrowser",
include: ["brave"],
exclude: []
),
Condition(
bundle_id: "comoktamobile",
include: ["okta"],
exclude: []
),
Condition(
bundle_id: "comgooglechrome",
include: ["google", "chrome"],
exclude: ["iterm", "chromefeaturestate"]
),
Condition(
bundle_id: "commicrosoftedgemac",
include: ["microsoft"],
exclude: ["vscode", "rdc", "appcenter", "office", "oneauth"]
),
Condition(
bundle_id: "orgmozillafirefox",
include: ["mozilla"],
exclude: []
),
Condition(
bundle_id: "comlogioptionsplus",
include: ["logi"],
exclude: ["login", "logic"],
includeForce: []
),
Condition(
bundle_id: "commicrosoftvscode",
include: ["vscode"],
exclude: [],
includeForce: ["\(home)/Library/Application Support/Code"]
),
Condition(
bundle_id: "comfacebookarchondeveloperid",
include: ["archonloginhelper"],
exclude: []
),
Condition(
bundle_id: "euexelbanstats",
include: [],
exclude: ["video"]
),
Condition(
bundle_id: "jetbrains",
include: ["jetbrains", "jcef"],
exclude: []
),
]
// Skip com.apple files/folders since most are system originated, allow some for apps
let skipConditions: [SkipCondition] = [
SkipCondition(
skipPrefix: "comapple",
allowPrefixes: ["comappleconfigurator", "comappledt", "comappleiwork", "comapplesfsymbols", "comappletestflight"]
)
]
// Skip files/folders during reverse file search
let skipReverse = ["apple", "temporary", "btserver", "proapps", "scripteditor", "ilife", "livefsd", "siritoday", "addressbook", "animoji", "appstore", "askpermission", "callhistory", "clouddocs", "diskimages", "dock", "facetime", "fileprovider", "instruments", "knowledge", "mobilesync", "syncservices", "homeenergyd", "icloud", "icdd", "networkserviceproxy", "familycircle", "geoservices", "installation", "passkit", "sharedimagecache", "desktop", "mbuseragent", "swiftpm", "baseband", "coresimulator", "photoslegacyupgrade", "photosupgrade", "siritts", "ipod", "globalpreferences", "apmanalytics", "apmexperiment", "avatarcache", "byhost", "contextstoreagent", "mobilemeaccounts", "intentbuilderc", "loginwindow", "momc", "replayd", "sharedfilelistd", "clang", "audiocomponent", "csexattrcryptoservice", "livetranscriptionagent", "sandboxhelper", "statuskitagent", "betaenrollmentd", "contentlinkingd", "diagnosticextensionsd", "gamed", "heard", "homed", "itunescloudd", "lldb", "mds", "mediaanalysisd", "metrickitd", "mobiletimerd", "proactived", "ptpcamerad", "studentd", "talagent", "watchlistd", "apptranslocation", "xcrun", "ds_store", "caches", "crashreporter", "trash", "pearcleaner", "amsdatamigratortool", "arfilecache", "assistant", "chromium", "cloudkit", "webkit", "databases", "diagnostic", "cache", "gamekit", "homebrew", "logi", "microsoft", "mozilla", "sync", "google", "sentinel", "hexnode", "sentry", "tvappservices"]
// Function to load additional conditions from a GitHub JSON file
func loadConditionsFromGitHub() {
let url = URL(string: "https://api.github.com/repos/alienator88/Pearcleaner/contents/conditions.json")!
var request = URLRequest(url: url)
request.setValue("application/vnd.github.VERSION.raw", forHTTPHeaderField: "Accept")
request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version")
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
do {
// Assuming the JSON structure directly maps to an array of Condition
let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
if let conditionArray = jsonObject as? [[String: Any]] {
let jsonData = try JSONSerialization.data(withJSONObject: conditionArray, options: [])
let additionalConditions = try JSONDecoder().decode([Condition].self, from: jsonData)
if !additionalConditions.isEmpty {
DispatchQueue.main.async {
conditions += additionalConditions
}
}
} else {
printOS("The data format is incorrect or empty for GitHub conditions processing.")
}
} catch {
printOS("Failed to decode conditions JSON: \(error.localizedDescription)")
}
} else {
printOS("Failed to fetch conditions data: \(error?.localizedDescription ?? "Unknown error")")
}
}.resume()
}
-32
View File
@@ -19,8 +19,6 @@ class Locations: ObservableObject {
var apps: Category
var reverse: Category
// var widgets: Category
// var plugins: Category
init() {
let (cacheDir, tempDir) = darwinCT()
@@ -103,35 +101,5 @@ class Locations: ObservableObject {
cacheDir,
tempDir
])
// self.widgets = Category(name: "Widgets", paths: [
// // User
// "~/Library/Widgets",
// // System
// "/Library/Widgets"
// ])
//
// self.plugins = Category(name: "Plugins", paths: [
// // User
// "~/Library/Contextual Menu Items",
// "~/Library/InputManagers",
// "~/Library/Internet Plug-Ins",
// "~/Library/Mail/Bundles",
// "~/Library/PreferencePanes",
// "~/Library/QuickLook",
// "~/Library/QuickTime",
// "~/Library/Screen Savers",
// "~/Library/Spotlight",
// // System
// "/Library/Contextual Menu Items",
// "/Library/InputManagers",
// "/Library/Internet Plug-Ins",
// "/Library/Mail/Bundles",
// "/Library/PreferencePanes",
// "/Library/QuickLook",
// "/Library/QuickTime",
// "/Library/Screen Savers",
// "/Library/Spotlight",
// ])
}
}
+6 -24
View File
@@ -11,7 +11,6 @@ import SwiftUI
// Get all apps from /Applications and ~/Applications
func getSortedApps(paths: [String], appState: AppState) -> [AppInfo] {
@AppStorage("settings.general.instant") var instantSearch: Bool = true
let fileManager = FileManager.default
var apps: [URL] = []
@@ -42,11 +41,6 @@ func getSortedApps(paths: [String], appState: AppState) -> [AppInfo] {
.compactMap { AppInfoFetcher.getAppInfo(atPath: $0) }
.sorted { $0.appName.replacingOccurrences(of: ".", with: "").lowercased() < $1.appName.replacingOccurrences(of: ".", with: "").lowercased() }
if instantSearch {
updateOnMain {
appState.instantTotal = Double(sortedApps.count)
}
}
return sortedApps
}
@@ -116,15 +110,14 @@ func listAppSupportDirectories() -> [String] {
// Load app paths on launch
func loadAllPaths(allApps: [AppInfo], appState: AppState, locations: Locations, reverseAddon: Bool = false, completion: @escaping () -> Void = {}) {
func reversePreloader(allApps: [AppInfo], appState: AppState, locations: Locations, reverseAddon: Bool = false, completion: @escaping () -> Void = {}) {
let dispatchGroup = DispatchGroup()
appState.appInfoStore.removeAll()
for app in allApps {
dispatchGroup.enter()
DispatchQueue.global(qos: .background).async {
let pathFinder = AppPathFinder(appInfo: app, appState: appState, locations: locations, backgroundRun: true, reverseAddon: reverseAddon)
pathFinder.findPaths()
AppPathFinder(appInfo: app, appState: appState, locations: locations, backgroundRun: true, reverseAddon: reverseAddon).findPaths()
dispatchGroup.leave()
}
}
@@ -134,28 +127,17 @@ func loadAllPaths(allApps: [AppInfo], appState: AppState, locations: Locations,
func checkAllAppsProcessed(retryCount: Int = 0, maxRetry: Int = 120) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if appState.appInfoStore.count == allApps.count {
if reverseAddon {
let reverse = ReversePathsSearcher(appState: appState, locations: locations)
reverse.reversePathsSearch() {
ReversePathsSearcher(appState: appState, locations: locations).reversePathsSearch() {
updateOnMain {
appState.showProgress = false
}
}
}
// Reset progress values to 0
updateOnMain {
appState.instantProgress = 0
appState.instantTotal = 0
appState.showProgress = false
}
completion()
} else if retryCount < maxRetry {
checkAllAppsProcessed(retryCount: retryCount + 1, maxRetry: maxRetry)
} else {
// Reset progress values to 0
updateOnMain {
appState.instantProgress = 0
appState.instantTotal = 0
appState.showProgress = false
}
printOS("loadAllPaths - Retry limit reached. Not all paths were loaded.")
@@ -196,13 +178,13 @@ func showAppInFiles(appInfo: AppInfo, appState: AppState, locations: Locations,
appState.showProgress = true
// Initialize the path finder and execute its search.
let pathFinder = AppPathFinder(appInfo: appInfo, appState: appState, locations: locations) {
AppPathFinder(appInfo: appInfo, appState: appState, locations: locations) {
updateOnMain {
// Update the progress indicator on the main thread once the search completes.
appState.showProgress = false
}
}
pathFinder.findPaths()
}.findPaths()
appState.appInfo = appInfo
// Animate the view change and popover display.
+11 -6
View File
@@ -14,16 +14,17 @@ class ReversePathsSearcher {
private let fileManager = FileManager.default
private var collection: [URL] = []
private var fileSize: [URL: Int64] = [:]
private var fileSizeLogical: [URL: Int64] = [:]
private var fileIcon: [URL: NSImage?] = [:]
private var skipped: Set<String>
private let dispatchGroup = DispatchGroup()
init(appState: AppState, locations: Locations) {
self.appState = appState
self.locations = locations
self.skipped = ["apple", "temporary", "btserver", "proapps", "scripteditor", "ilife", "livefsd", "siritoday", "addressbook", "animoji", "appstore", "askpermission", "callhistory", "clouddocs", "diskimages", "dock", "facetime", "fileprovider", "instruments", "knowledge", "mobilesync", "syncservices", "homeenergyd", "icloud", "icdd", "networkserviceproxy", "familycircle", "geoservices", "installation", "passkit", "sharedimagecache", "desktop", "mbuseragent", "swiftpm", "baseband", "coresimulator", "photoslegacyupgrade", "photosupgrade", "siritts", "ipod", "globalpreferences", "apmanalytics", "apmexperiment", "avatarcache", "byhost", "contextstoreagent", "mobilemeaccounts", "intentbuilderc", "loginwindow", "momc", "replayd", "sharedfilelistd", "clang", "audiocomponent", "csexattrcryptoservice", "livetranscriptionagent", "sandboxhelper", "statuskitagent", "betaenrollmentd", "contentlinkingd", "diagnosticextensionsd", "gamed", "heard", "homed", "itunescloudd", "lldb", "mds", "mediaanalysisd", "metrickitd", "mobiletimerd", "proactived", "ptpcamerad", "studentd", "talagent", "watchlistd", "apptranslocation", "xcrun", "ds_store", "caches", "crashreporter", "trash", "pearcleaner", "amsdatamigratortool", "arfilecache", "assistant", "chromium", "cloudkit", "webkit", "databases", "diagnostic", "cache", "gamekit", "homebrew", "logi", "microsoft", "mozilla", "sync", "google", "sentinel", "hexnode", "sentry", "tvappservices"]
}
func reversePathsSearch(completion: @escaping () -> Void = {}) {
Task(priority: .high) {
self.processLocations()
@@ -34,17 +35,19 @@ class ReversePathsSearcher {
}
private func processLocations() {
let allPaths = appState.appInfoStore.flatMap { $0.files.map { $0.path.pearFormat() } }
let allPaths = appState.appInfoStore.flatMap { $0.fileSize.keys.map { $0.path.pearFormat() } }
let allNames = appState.appInfoStore.map { $0.appName.pearFormat() }
for location in locations.reverse.paths where fileManager.fileExists(atPath: location) {
dispatchGroup.enter()
processLocation(location, allPaths: allPaths, allNames: allNames)
dispatchGroup.leave()
}
}
private func processLocation(_ location: String, allPaths: [String], allNames: [String]) {
do {
let contents = try fileManager.contentsOfDirectory(atPath: location)
contents.forEach { itemName in
@@ -61,8 +64,7 @@ class ReversePathsSearcher {
let itemPath = itemURL.path.pearFormat()
let itemLastPathComponent = itemURL.lastPathComponent.pearFormat()
guard !skipped.contains(where: { formattedItemName.contains($0) }),
// !allPaths.contains(itemPath),
guard !skipReverse.contains(where: { formattedItemName.contains($0) }),
!allPaths.contains(where: { $0 == itemPath || $0.hasSuffix("/\(itemLastPathComponent)") }),
!allNames.contains(formattedItemName),
isSupportedFileType(at: itemURL.path) else {
@@ -74,7 +76,9 @@ class ReversePathsSearcher {
private func calculateFileDetails() {
collection.forEach { path in
fileSize[path] = totalSizeOnDisk(for: path)
let size = totalSizeOnDisk(for: path)
fileSize[path] = size.real
fileSizeLogical[path] = size.logical
fileIcon[path] = getIconForFileOrFolderNS(atPath: path)
}
}
@@ -83,6 +87,7 @@ class ReversePathsSearcher {
dispatchGroup.notify(queue: .main) {
var updatedZombieFile = ZombieFile.empty
updatedZombieFile.fileSize = self.fileSize
updatedZombieFile.fileSizeLogical = self.fileSizeLogical
updatedZombieFile.fileIcon = self.fileIcon
self.appState.zombieFile = updatedZombieFile
self.appState.showProgress = false
-45
View File
@@ -441,56 +441,11 @@ struct SimpleSearchStyle: TextFieldStyle {
}
}
.disabled(appState.instantProgress != 0 && appState.instantProgress != appState.instantTotal)
.padding(6)
.overlay(
Group {
GeometryReader { geometry in
ZStack(alignment: .leading) {
if appState.instantProgress != 0 && appState.instantProgress != appState.instantTotal {
let totalWidth = geometry.size.width
let progressFraction = CGFloat(appState.instantProgress / appState.instantTotal)
let progressWidth = totalWidth * progressFraction
RoundedRectangle(cornerRadius: 6)
.fill(Color.accentColor)
.padding(4)
.frame(width: progressWidth)
.animation(.linear, value: progressWidth)
.allowsHitTesting(false)
}
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color("mode").opacity(0.2), lineWidth: 1)
.allowsHitTesting(false)
}
}
// ZStack {
// RoundedRectangle(cornerRadius: 6)
// .strokeBorder(Color("mode").opacity(0.2), lineWidth: 1)
// .allowsHitTesting(false)
//
// if appState.instantTotal > 0 {
// let progressWidth = CGFloat(appState.instantProgress / appState.instantTotal)
// * NSScreen.main.bounds.width // Assuming full width for simplicity; adjust as needed
// RoundedRectangle(cornerRadius: 6)
// .fill(Color.accentColor.opacity(0.5)) // Adjust color and opacity as needed
// .frame(width: progressWidth)
// .animation(.linear, value: progressWidth)
// .allowsHitTesting(false)
// }
//// if appState.instantProgress != 0 && appState.instantProgress != appState.instantTotal {
//// ProgressView("", value: appState.instantProgress, total: appState.instantTotal)
//// .progressViewStyle(.linear)
//// .padding()
//// }
// }
}
)
.onHover { hovering in
withAnimation(Animation.easeInOut(duration: 0.15)) {
+46
View File
@@ -28,10 +28,12 @@ class ThemeSettings: ObservableObject {
if let components = UserDefaults.standard.object(forKey: colorKey) as? [CGFloat], components.count >= 4 {
themeColor = Color(.sRGB, red: components[0], green: components[1], blue: components[2], opacity: components[3])
} else {
themeColor = Color.clear
}
}
func saveThemeColor() {
let nsColor = NSColor(themeColor)
if let components = nsColor.cgColor.components {
@@ -43,17 +45,61 @@ class ThemeSettings: ObservableObject {
themeColor = dark ? Color(.sRGB, red: 0.149, green: 0.149, blue: 0.149, opacity: 1) : Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1)
saveThemeColor()
}
func setPreset(preset: String, colorScheme: DisplayMode) {
themeColor = getColorForPreset(preset: preset, colorScheme: colorScheme)
saveThemeColor()
}
func getColorForPreset(preset: String, colorScheme: DisplayMode) -> Color {
let slate = colorScheme.colorScheme == .light ? Color(.sRGB, red: 0.499549, green: 0.545169, blue: 0.682028, opacity: 1) : Color(.sRGB, red: 0.188143, green: 0.208556, blue: 0.262679, opacity: 1)
let solarized = colorScheme.colorScheme == .light ? Color(.sRGB, red: 0.554372, green: 0.6557, blue: 0.734336, opacity: 1) : Color(.sRGB, red: 0.117257, green: 0.22506, blue: 0.249171, opacity: 1)
let dracula = colorScheme.colorScheme == .light ? Color(.sRGB, red: 0.567094, green: 0.562125, blue: 0.81285, opacity: 1) : Color(.sRGB, red: 0.268614, green: 0.264737, blue: 0.383503, opacity: 1)
switch preset {
case "slate":
return slate
case "solarized":
return solarized
case "dracula":
return dracula
default:
return themeColor
}
}
}
extension Color {
/// Darkens the color by a percentage.
func darker(by percentage: CGFloat = 30.0) -> Color {
var hsb = (hue: CGFloat(0), saturation: CGFloat(0), brightness: CGFloat(0), alpha: CGFloat(0))
NSColor(self).getHue(&hsb.hue, saturation: &hsb.saturation, brightness: &hsb.brightness, alpha: &hsb.alpha)
return Color(hue: hsb.hue, saturation: hsb.saturation, brightness: max(hsb.brightness - percentage/100, 0), opacity: hsb.alpha)
}
/// Lightens the color by a percentage.
func lighter(by percentage: CGFloat = 30.0) -> Color {
var hsb = (hue: CGFloat(0), saturation: CGFloat(0), brightness: CGFloat(0), alpha: CGFloat(0))
NSColor(self).getHue(&hsb.hue, saturation: &hsb.saturation, brightness: &hsb.brightness, alpha: &hsb.alpha)
return Color(hue: hsb.hue, saturation: hsb.saturation, brightness: min(hsb.brightness + percentage / 100, 1.0), opacity: hsb.alpha)
}
}
extension UserDefaults {
func color(forKey key: String) -> Color? {
guard let components = array(forKey: key) as? [CGFloat], components.count == 4 else {
return nil
}
return Color(.sRGB, red: components[0], green: components[1], blue: components[2], opacity: components[3])
}
func setColor(_ color: Color, forKey key: String) {
let nsColor = NSColor(color)
if let components = nsColor.cgColor.components, components.count == 4 {
set(components, forKey: key)
} else {
removeObject(forKey: key)
}
}
}
+48 -64
View File
@@ -174,20 +174,13 @@ func isCurrentUserAdmin(completion: @escaping (Bool) -> Void) {
}
// Check if appearance is dark mode
func isDarkModeEnabled() -> Bool {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-c", "defaults read -g AppleInterfaceStyle"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try? process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if !data.isEmpty{
return true
} else {
return false
func isDarkMode() -> Bool {
return NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
}
// Set app color mode
func setAppearance(mode: NSAppearance.Name) {
NSApp.appearance = NSAppearance(named: mode)
}
// Check if Pearcleaner has any windows open
@@ -216,6 +209,13 @@ func findAndShowWindows(named titles: [String]) {
}
}
// Copy to clipboard
func copyToClipboard(text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
// Check if appearance is dark mode
//func getCasks() -> [String] {
@@ -458,86 +458,73 @@ func printOS(_ items: Any..., separator: String = " ", terminator: String = "\n"
}
//func totalSizeOnDisk2(for paths: [URL]) -> Int64 {
// var totalSize = 0
//
// let process = Process()
// process.launchPath = "/usr/bin/du"
// process.arguments = ["-sk"] + paths.map { (url: URL) -> String in
// return url.path
// }
// let pipe = Pipe()
// process.standardOutput = pipe
// process.standardError = pipe//FileHandle.nullDevice
//
// try? process.run()
// process.waitUntilExit()
//
// let data = pipe.fileHandleForReading.readDataToEndOfFile()
// if let output = String(data: data, encoding: .utf8) {
// let lines = output.components(separatedBy: .newlines)
// for line in lines {
// let components = line.components(separatedBy: "\t")
// if let sizeString = components.first, let size = Int(sizeString) {
// totalSize += size
// }
// }
// }
//
// return Int64(totalSize) * 1024 // Convert the size from kilobytes to bytes
//}
// Get total size of folders and files using DU cli command
func totalSizeOnDisk(for paths: [URL]) -> Int64 {
// Get size of files
func totalSizeOnDisk(for paths: [URL]) -> (real: Int64, logical: Int64) {
let fileManager = FileManager.default
var totalSize: Int64 = 0
var totalAllocatedSize: Int64 = 0
var totalFileSize: Int64 = 0
for url in paths {
var isDirectory: ObjCBool = false
if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) {
let keys: [URLResourceKey] = [.totalFileAllocatedSizeKey, .fileSizeKey]
if isDirectory.boolValue {
// It's a directory, recurse into it
if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], errorHandler: nil) {
if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: keys, errorHandler: nil) {
for case let fileURL as URL in enumerator {
do {
let fileAttributes = try fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
if let fileSize = fileAttributes.totalFileAllocatedSize {
totalSize += Int64(fileSize)
let fileAttributes = try fileURL.resourceValues(forKeys: Set(keys))
if let allocatedSize = fileAttributes.totalFileAllocatedSize {
totalAllocatedSize += Int64(allocatedSize)
}
if let fileSize = fileAttributes.fileSize {
totalFileSize += Int64(fileSize)
}
} catch {
printOS("Error getting file size for \(fileURL): \(error)")
print("Error getting file attributes for \(fileURL): \(error)")
}
}
}
} else {
// It's a file
do {
let fileAttributes = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
if let fileSize = fileAttributes.totalFileAllocatedSize {
totalSize += Int64(fileSize)
let fileAttributes = try url.resourceValues(forKeys: Set(keys))
if let allocatedSize = fileAttributes.totalFileAllocatedSize {
totalAllocatedSize += Int64(allocatedSize)
}
if let fileSize = fileAttributes.fileSize {
totalFileSize += Int64(fileSize)
}
} catch {
printOS("Error getting file size for \(url): \(error)")
print("Error getting file attributes for \(url): \(error)")
}
}
}
}
return totalSize
return (real: totalAllocatedSize, logical: totalFileSize)
}
func totalSizeOnDisk(for path: URL) -> Int64 {
func totalSizeOnDisk(for path: URL) -> (real: Int64, logical: Int64) {
return totalSizeOnDisk(for: [path])
}
// ByteFormatter
func formatByte(size: Int64) -> String {
func formatByte(size: Int64) -> (human: String, byte: String) {
let byteCountFormatter = ByteCountFormatter()
byteCountFormatter.countStyle = .file
byteCountFormatter.allowedUnits = [.useAll]
return byteCountFormatter.string(fromByteCount: size)
let human = byteCountFormatter.string(fromByteCount: size)
let numberformatter = NumberFormatter()
numberformatter.numberStyle = .decimal
let formattedNumber = numberformatter.string(from: NSNumber(value: size)) ?? "\(size)"
let byte = "\(formattedNumber)"
return (human: human, byte: byte)
}
// Only process supported files
@@ -603,12 +590,9 @@ func uninstallPearcleaner(appState: AppState, locations: Locations) {
// Get app info for Pearcleaner
let appInfo = AppInfoFetcher.getAppInfo(atPath: Bundle.main.bundleURL)
// appState.appInfo = appInfo!
// Find application files for Pearcleaner
let pathFinder = AppPathFinder(appInfo: appInfo!, appState: appState, locations: locations)
pathFinder.findPaths()
// findPathsForApp(appInfo: appInfo!,appState: appState, locations: locations)
AppPathFinder(appInfo: appInfo!, appState: appState, locations: locations).findPaths()
// Kill Pearcleaner and tell Finder to trash the files
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+43 -9
View File
@@ -23,7 +23,6 @@ struct PearcleanerApp: App {
@AppStorage("displayMode") var displayMode: DisplayMode = .system
@AppStorage("settings.general.mini") private var mini: Bool = false
@AppStorage("settings.general.miniview") private var miniView: Bool = true
@AppStorage("settings.general.instant") private var instantSearch: Bool = true
@AppStorage("settings.general.features") private var features: String = ""
@AppStorage("settings.general.brew") private var brew: Bool = false
@AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false
@@ -102,13 +101,7 @@ struct PearcleanerApp: App {
let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState)
appState.sortedApps = sortedApps
// Find all app paths/information on load if instantSearch is enabled
if instantSearch {
loadAllPaths(allApps: sortedApps, appState: appState, locations: locations)
}
// Enable menubar item
if menubarEnabled {
MenuBarExtraManager.shared.addMenuBarExtra(withView: {
MenuBarMiniAppView(search: $search, showPopover: $showPopover)
@@ -140,6 +133,9 @@ struct PearcleanerApp: App {
hasLaunched = true
}
// Load extra conditions from GitHub
loadConditionsFromGitHub()
// TIMERS ////////////////////////////////////////////////////////////////////////////////////
@@ -183,6 +179,7 @@ struct PearcleanerApp: App {
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
var observer: NSObjectProtocol?
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
let menubarEnabled = UserDefaults.standard.bool(forKey: "settings.menubar.enabled")
@@ -191,11 +188,49 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
let menubarEnabled = UserDefaults.standard.bool(forKey: "settings.menubar.enabled")
if UserDefaults.standard.object(forKey: "themeColor") == nil {
self.appearanceChanged()
}
if menubarEnabled {
findAndHideWindows(named: ["Pearcleaner"])
NSApplication.shared.setActivationPolicy(.accessory)
}
// Start observing the appearance change
observer = DistributedNotificationCenter.default().addObserver(forName: NSNotification.Name(rawValue: "AppleInterfaceThemeChangedNotification"), object: nil, queue: OperationQueue.main) { [weak self] _ in
let themeMode = UserDefaults.standard.string(forKey: "settings.general.selectedTheme")
if themeMode == "Auto" {
self?.appearanceChanged()
}
}
}
func appearanceChanged() {
let dm = UserDefaults.standard.integer(forKey: "displayMode")
var displayMode = DisplayMode(rawValue: dm)
let dark = isDarkMode()
if dark {
displayMode?.colorScheme = .dark
} else {
displayMode?.colorScheme = .light
}
UserDefaults.standard.set(displayMode?.rawValue, forKey: "displayMode")
ThemeSettings.shared.themeColor = isDarkMode() ? Color(.sRGB, red: 0.149, green: 0.149, blue: 0.149, opacity: 1) : Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1)
ThemeSettings.shared.saveThemeColor()
}
func applicationWillTerminate(_ notification: Notification) {
// Stop observing the appearance change
if let observer = observer {
DistributedNotificationCenter.default().removeObserver(observer)
}
}
@@ -220,4 +255,3 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
}
}
+84 -35
View File
@@ -23,16 +23,15 @@ struct GeneralSettingsTab: View {
@AppStorage("displayMode") var displayMode: DisplayMode = .system
@AppStorage("settings.sentinel.enable") private var sentinel: Bool = false
@AppStorage("settings.general.brew") private var brew: Bool = false
@AppStorage("settings.general.instant") private var instantSearch: Bool = true
@AppStorage("settings.general.selectedTheme") var selectedTheme: String = "Auto"
@AppStorage("settings.general.selectedSort") var selectedSortAlpha: Bool = true
// @State private var finderExtensionEnabled: Bool = false
@AppStorage("settings.general.sizeType") var sizeType: String = "Real"
@State private var diskStatus: Bool = false
@State private var accessStatus: Bool = false
private let themes = ["Auto", "Dark", "Light"]
@Binding var showPopover: Bool
@Binding var search: String
@State var selectedIndex: Int?
var body: some View {
Form {
VStack {
@@ -44,30 +43,6 @@ struct GeneralSettingsTab: View {
.padding(.leading)
HStack(spacing: 0) {
Image(systemName: instantSearch ? "bolt" : "bolt.slash")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.padding(.trailing)
.foregroundStyle(Color("mode").opacity(0.5))
VStack(alignment: .leading, spacing: 5) {
Text("\(instantSearch ? "Instant search is enabled" : "Instant search is disabled")")
.font(.callout)
.foregroundStyle(Color("mode").opacity(0.5))
}
InfoButton(text: "When instant search is enabled, all application files are gathered and cached for later use on startup instead of on each app click. There might be a slight delay of a few seconds when launching Pearcleaner depending on the amount of apps you have installed.", color: nil, label: "")
Spacer()
Toggle(isOn: $instantSearch, label: {
})
.toggleStyle(.switch)
}
.padding(5)
.padding(.leading)
HStack(spacing: 0) {
Image(systemName: brew ? "mug.fill" : "mug")
.resizable()
@@ -104,14 +79,88 @@ struct GeneralSettingsTab: View {
.font(.callout)
.foregroundStyle(Color("mode").opacity(0.5))
}
InfoButton(text: "When searching for app files or leftover files, the list will be sorted based on this choice", color: nil, label: "")
InfoButton(text: "When searching for app files or leftover files, the list will be sorted either alphabetically or by size(large to small)", color: nil, label: "")
Spacer()
Picker("", selection: $selectedSortAlpha) {
Text("Alphabetical").tag(true)
Text("Size").tag(false)
SegmentedPicker(
["Alpha", "Size"],
selectedIndex: Binding(
get: { selectedSortAlpha ? 0 : 1 },
set: { newIndex in
withAnimation(.easeInOut(duration: 0.3)) {
selectedSortAlpha = (newIndex == 0)
}
.pickerStyle(SegmentedPickerStyle())
.frame(width: 200)
}),
selectionAlignment: .bottom,
content: { item, isSelected in
Text(item)
.font(.callout)
.foregroundColor(isSelected ? Color("mode") : Color("mode").opacity(0.5))
.padding(.horizontal)
.padding(.bottom, 5)
.frame(width: 75)
},
selection: {
VStack(spacing: 0) {
Spacer()
Color("pear").frame(height: 1)
}
})
}
.padding(5)
.padding(.leading)
HStack(spacing: 0) {
Image(systemName: "plus.forwardslash.minus")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.padding(.trailing)
.foregroundStyle(Color("mode").opacity(0.5))
VStack(alignment: .leading, spacing: 5) {
Text("File size display")
.font(.callout)
.foregroundStyle(Color("mode").opacity(0.5))
}
InfoButton(text: "Real size type will show how much actual allocated space the file has on disk. Logical type shows the binary size. The filesystem can compress and deduplicate sectors on disk, so real size is sometimes smaller(or bigger) than logical size. Finder size is similar to if you right click > Get Info on a file in Finder, which will show both the logical and real sizes together.", color: nil, label: "")
Spacer()
SegmentedPicker(
["Real", "Logical", "Finder"],
selectedIndex: Binding(
get: {
switch sizeType {
case "Real": return 0
case "Logical": return 1
case "Finder": return 2
default: return 0
}
},
set: { newIndex in
withAnimation(.easeInOut(duration: 0.3)) {
switch newIndex {
case 0: sizeType = "Real"
case 1: sizeType = "Logical"
case 2: sizeType = "Finder"
default: sizeType = "Real"
}
}
}),
selectionAlignment: .bottom,
content: { item, isSelected in
Text(item)
.font(.callout)
.foregroundColor(isSelected ? Color("mode") : Color("mode").opacity(0.5) )
.padding(.horizontal)
.padding(.bottom, 5)
.frame(width: 75)
},
selection: {
VStack(spacing: 0) {
Spacer()
Color("pear").frame(height: 1)
}
})
}
.padding(5)
.padding(.leading)
@@ -275,7 +324,7 @@ struct GeneralSettingsTab: View {
}
.padding(20)
.frame(width: 500, height: 560)
.frame(width: 500, height: 600)
}
+47 -26
View File
@@ -31,18 +31,9 @@ struct InterfaceSettingsTab: View {
@Binding var showPopover: Bool
@Binding var search: String
let icons = ["externaldrive", "trash", "folder", "pear-1", "pear-1.5", "pear-2", "pear-3", "pear-4"]
private let themes = ["Auto", "Dark", "Light"]
// @State private var slate = Color(.sRGB, red: 0.188143, green: 0.208556, blue: 0.262679, opacity: 1)
// @State private var solarized = Color(.sRGB, red: 0.117257, green: 0.22506, blue: 0.249171, opacity: 1)
// @State private var dracula = Color(.sRGB, red: 0.268614, green: 0.264737, blue: 0.383503, opacity: 1)
var body: some View {
let slate = displayMode.colorScheme == .light ? Color(.sRGB, red: 0.499549, green: 0.545169, blue: 0.682028, opacity: 1) : Color(.sRGB, red: 0.188143, green: 0.208556, blue: 0.262679, opacity: 1)
let solarized = displayMode.colorScheme == .light ? Color(.sRGB, red: 0.554372, green: 0.6557, blue: 0.734336, opacity: 1) : Color(.sRGB, red: 0.117257, green: 0.22506, blue: 0.249171, opacity: 1)
let dracula = displayMode.colorScheme == .light ? Color(.sRGB, red: 0.567094, green: 0.562125, blue: 0.81285, opacity: 1) : Color(.sRGB, red: 0.268614, green: 0.264737, blue: 0.383503, opacity: 1)
Form {
VStack {
@@ -92,23 +83,21 @@ struct InterfaceSettingsTab: View {
Spacer()
Button("") {
themeSettings.themeColor = slate
themeSettings.saveThemeColor()
themeSettings.setPreset(preset: "slate", colorScheme: displayMode)
}
.buttonStyle(PresetColor(fillColor: slate, label: "Slate"))
.buttonStyle(PresetColor(fillColor: themeSettings.getColorForPreset(preset: "slate", colorScheme: displayMode), label: "Slate"))
Button("") {
themeSettings.themeColor = dracula
themeSettings.saveThemeColor()
themeSettings.setPreset(preset: "dracula", colorScheme: displayMode)
}
.buttonStyle(PresetColor(fillColor: dracula, label: "Dracula"))
.buttonStyle(PresetColor(fillColor: themeSettings.getColorForPreset(preset: "dracula", colorScheme: displayMode), label: "Dracula"))
.padding(.horizontal)
Button("") {
themeSettings.themeColor = solarized
themeSettings.setPreset(preset: "solarized", colorScheme: displayMode)
themeSettings.saveThemeColor()
}
.buttonStyle(PresetColor(fillColor: solarized, label: "Solarized"))
.buttonStyle(PresetColor(fillColor: themeSettings.getColorForPreset(preset: "solarized", colorScheme: displayMode), label: "Solarized"))
Spacer()
@@ -151,18 +140,50 @@ struct InterfaceSettingsTab: View {
}
InfoButton(text: "Changing the color mode will reset the base color to defaults", color: nil, label: "")
Spacer()
Picker("", selection: $selectedTheme) {
ForEach(themes, id: \.self) { theme in
Text(theme)
SegmentedPicker(
["Auto", "Dark", "Light"],
selectedIndex: Binding(
get: {
switch selectedTheme {
case "Dark": return 1
case "Light": return 2
default: return 0
}
},
set: { newIndex in
withAnimation(.easeInOut(duration: 0.3)) {
switch newIndex {
case 1: selectedTheme = "Dark"
case 2: selectedTheme = "Light"
default: selectedTheme = "Auto"
}
}
.pickerStyle(SegmentedPickerStyle())
.frame(width: 200)
}),
selectionAlignment: .bottom,
content: { item, isSelected in
Text(item)
.font(.callout)
.foregroundColor(isSelected ? Color("mode") : Color("mode").opacity(0.5) )
.padding(.horizontal)
.padding(.bottom, 5)
.frame(width: 65)
},
selection: {
VStack(spacing: 0) {
Spacer()
Color("pear").frame(height: 1)
}
})
.onChange(of: selectedTheme) { newTheme in
switch newTheme {
case "Auto":
displayMode.colorScheme = nil
themeSettings.resetToDefault(dark: isDarkModeEnabled())
if isDarkMode() {
displayMode.colorScheme = .dark
} else {
displayMode.colorScheme = .light
}
// displayMode.colorScheme = nil
themeSettings.resetToDefault(dark: isDarkMode())
// Refresh foreground colors
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.selectedTab = .interface
@@ -172,7 +193,7 @@ struct InterfaceSettingsTab: View {
}
case "Dark":
displayMode.colorScheme = .dark
themeSettings.resetToDefault(dark: true)
// themeSettings.resetToDefault(dark: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.selectedTab = .interface
}
@@ -181,7 +202,7 @@ struct InterfaceSettingsTab: View {
}
case "Light":
displayMode.colorScheme = .light
themeSettings.resetToDefault(dark: false)
// themeSettings.resetToDefault(dark: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.selectedTab = .interface
}
+1 -2
View File
@@ -88,7 +88,7 @@ struct AppListItems: View {
if (isHovered || isSelected) && !mini {
Triangle()
.fill(isSelected ? themeSettings.themeColor : Color("mode").opacity(0.1))
.fill(themeSettings.themeColor)
.frame(width: isSelected ? 16 : 8, height: 30)
.padding(.leading, 5)
.offset(x: 22)
@@ -115,7 +115,6 @@ struct AppListItems: View {
}
}
}
// .grayscale(opacityForItem(appInfo.path))
}
+60 -40
View File
@@ -14,24 +14,36 @@ struct FilesView: View {
@State private var showPop: Bool = false
@AppStorage("settings.general.mini") private var mini: Bool = false
@AppStorage("settings.sentinel.enable") private var sentinel: Bool = false
@AppStorage("settings.general.instant") var instantSearch: Bool = true
@AppStorage("settings.general.brew") private var brew: Bool = false
@AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false
@AppStorage("settings.general.selectedSort") var selectedSortAlpha: Bool = true
@AppStorage("settings.general.sizeType") var sizeType: String = "Real"
@State private var localKey = UUID()
@Environment(\.colorScheme) var colorScheme
@Binding var showPopover: Bool
@Binding var search: String
var regularWin: Bool
// @State private var selectedOption = "Alpha"
@State private var elapsedTime = 0
@State private var timer: Timer? = nil
var body: some View {
let totalSelectedSize = appState.selectedItems.reduce(Int64(0)) { (result, url) in
result + (appState.appInfo.fileSize[url] ?? 0)
var totalSelectedSize: (real: String, logical: String, finder: String) {
var totalReal: Int64 = 0
var totalLogical: Int64 = 0
for url in appState.selectedItems {
let realSize = appState.appInfo.fileSize[url] ?? 0
let logicalSize = appState.appInfo.fileSizeLogical[url] ?? 0
totalReal += realSize
totalLogical += logicalSize
}
return (real: formatByte(size: totalReal).human, logical: formatByte(size: totalLogical).human, finder: "\(formatByte(size: totalLogical).byte) (\(formatByte(size: totalReal).human))")
}
let displaySizeTotal = sizeType == "Real" ? formatByte(size: appState.appInfo.totalSize).human :
sizeType == "Logical" ? formatByte(size: appState.appInfo.totalSizeLogical).human :
"\(formatByte(size: appState.appInfo.totalSizeLogical).byte) (\(formatByte(size: appState.appInfo.totalSize).human))"
VStack(alignment: .center) {
if appState.showProgress {
@@ -73,22 +85,6 @@ struct FilesView: View {
Spacer()
if instantSearch {
Button("Rescan") {
updateOnMain {
appState.showProgress.toggle()
let pathFinder = AppPathFinder(appInfo: appState.appInfo, appState: appState, locations: locations) {
updateOnMain {
appState.showProgress = false
}
}
pathFinder.findPaths()
}
}
.buttonStyle(SimpleButtonStyle(icon: "arrow.counterclockwise.circle.fill", help: "Rescan files"))
}
Button("Close") {
updateOnMain {
appState.appInfo = AppInfo.empty
@@ -144,7 +140,8 @@ struct FilesView: View {
Spacer()
VStack(alignment: .trailing, spacing: 5) {
Text("\(formatByte(size: appState.appInfo.totalSize))").font(.title).fontWeight(.bold)
Text("\(displaySizeTotal)").font(.title).fontWeight(.bold).help("Total size on disk")
Text("\(appState.appInfo.fileSize.count == 1 ? "\(appState.selectedItems.count) / \(appState.appInfo.fileSize.count) item" : "\(appState.selectedItems.count) / \(appState.appInfo.fileSize.count) items")")
.font(.callout).foregroundStyle((Color("mode").opacity(0.5)))
}
@@ -161,10 +158,10 @@ struct FilesView: View {
// Item selection and sorting toolbar
HStack() {
Toggle("", isOn: Binding(
get: { self.appState.selectedItems.count == self.appState.appInfo.files.count },
get: { self.appState.selectedItems.count == self.appState.appInfo.fileSize.keys.count },
set: { newValue in
updateOnMain {
self.appState.selectedItems = newValue ? Set(self.appState.appInfo.files) : []
self.appState.selectedItems = newValue ? Set(self.appState.appInfo.fileSize.keys) : []
}
}
))
@@ -212,7 +209,6 @@ struct FilesView: View {
Button("") {
selectedSortAlpha.toggle()
// selectedOption = selectedOption == "Alpha" ? "Size" : "Alpha"
}
.buttonStyle(SimpleButtonStyle(icon: selectedSortAlpha ? "textformat.abc" : "textformat.123", help: selectedSortAlpha ? "Sorted alphabetically" : "Sorted by size"))
@@ -228,10 +224,9 @@ struct FilesView: View {
ScrollView() {
LazyVStack {
let sortedFilesSize = appState.appInfo.files.sorted(by: { appState.appInfo.fileSize[$0, default: 0] > appState.appInfo.fileSize[$1, default: 0] })
let sortedFilesSize = appState.appInfo.fileSize.keys.sorted(by: { appState.appInfo.fileSize[$0, default: 0] > appState.appInfo.fileSize[$1, default: 0] })
// let sortedFilesAlpha = appState.appInfo.files
let sortedFilesAlpha = appState.appInfo.files.sorted { firstURL, secondURL in
let sortedFilesAlpha = appState.appInfo.fileSize.keys.sorted { firstURL, secondURL in
let isFirstPathApp = firstURL.pathExtension == "app"
let isSecondPathApp = secondURL.pathExtension == "app"
if isFirstPathApp, !isSecondPathApp {
@@ -247,10 +242,10 @@ struct FilesView: View {
let sort = selectedSortAlpha ? sortedFilesAlpha : sortedFilesSize
ForEach(Array(sort.enumerated()), id: \.element) { index, path in
if let fileSize = appState.appInfo.fileSize[path], let fileIcon = appState.appInfo.fileIcon[path] {
if let fileSize = appState.appInfo.fileSize[path], let fileSizeL = appState.appInfo.fileSizeLogical[path], let fileIcon = appState.appInfo.fileIcon[path] {
let iconImage = fileIcon.map(Image.init(nsImage:))
VStack {
FileDetailsItem(size: fileSize, icon: iconImage, path: path)
FileDetailsItem(size: fileSize, sizeL: fileSizeL, icon: iconImage, path: path)
if index < sort.count - 1 {
Divider().padding(.leading, 40).opacity(0.5)
}
@@ -260,7 +255,12 @@ struct FilesView: View {
}
.padding()
.onChange(of: sizeType) { _ in
localKey = UUID()
}
.id(localKey)
}
Spacer()
@@ -269,7 +269,7 @@ struct FilesView: View {
Spacer()
Button("\(formatByte(size: totalSelectedSize))") {
Button("\(sizeType == "Logical" ? totalSelectedSize.logical : sizeType == "Finder" ? totalSelectedSize.finder : totalSelectedSize.real)") {
Task {
updateOnMain {
search = ""
@@ -305,7 +305,6 @@ struct FilesView: View {
removeApp(appState: appState, withId: appState.appInfo.id)
} else {
// Add deleted appInfo object to trashed array
appState.appInfo.files = []
appState.appInfo.fileSize = [:]
appState.trashedFiles.append(appState.appInfo)
@@ -346,7 +345,10 @@ struct FilesView: View {
struct FileDetailsItem: View {
@EnvironmentObject var appState: AppState
@State private var isHovered = false
@AppStorage("settings.general.sizeType") var sizeType: String = "Real"
let size: Int64?
let sizeL: Int64?
let icon: Image?
let path: URL
@@ -372,6 +374,8 @@ struct FileDetailsItem: View {
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.clipShape(RoundedRectangle(cornerRadius: 8))
.brightness(isHovered ? 0.2 : 0)
.shadow(color: Color("mode"), radius: isHovered ? 2 : 0)
}
VStack(alignment: .leading, spacing: 5) {
@@ -396,19 +400,35 @@ struct FileDetailsItem: View {
Spacer()
Text(formatByte(size:size!))
let displaySize = sizeType == "Real" ? formatByte(size: size!).human :
sizeType == "Logical" ? formatByte(size: sizeL!).human :
"\(formatByte(size: sizeL!).byte) (\(formatByte(size: size!).human))"
Text("\(displaySize)")
Button("") {
}
.onHover { hovering in
withAnimation(Animation.easeIn(duration: 0.2)) {
self.isHovered = hovering
}
}
.onTapGesture {
NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: path.deletingLastPathComponent().path)
}
.buttonStyle(SimpleButtonStyle(icon: "folder.fill", help: "Show in Finder"))
.contextMenu {
if path.pathExtension == "app" {
Button("Open \(path.deletingPathExtension().lastPathComponent)") {
NSWorkspace.shared.open(path)
}
Divider()
}
Button("Copy Path") {
copyToClipboard(text: path.path)
}
Button("View in Finder") {
NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: path.deletingLastPathComponent().path)
}
}
}
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.white.opacity(0))
)
}
}
+2 -17
View File
@@ -18,7 +18,6 @@ struct MenuBarMiniAppView: View {
@State private var showSys: Bool = true
@State private var showUsr: Bool = true
@AppStorage("settings.general.mini") private var mini: Bool = false
@AppStorage("settings.general.instant") private var instantSearch: Bool = true
@AppStorage("settings.general.glass") private var glass: Bool = true
@AppStorage("settings.general.popover") private var popoverStay: Bool = true
@Binding var showPopover: Bool
@@ -40,15 +39,7 @@ struct MenuBarMiniAppView: View {
if appState.reload {
VStack {
Spacer()
ProgressView(instantSearch ? "Refreshing app list and caching files" : "Refreshing app list")
if instantSearch {
Image(systemName: "bolt.fill")
.resizable()
.scaledToFit()
.foregroundStyle(Color("mode").opacity(0.5))
.frame(width: 30, height: 30)
.padding()
}
ProgressView("Refreshing app list")
Spacer()
}
.padding(.vertical)
@@ -70,13 +61,7 @@ struct MenuBarMiniAppView: View {
appState.currentView = .zombie
appState.showProgress.toggle()
showPopover.toggle()
if instantSearch {
let reverse = ReversePathsSearcher(appState: appState, locations: locations)
reverse.reversePathsSearch()
// reversePathsSearch(appState: appState, locations: locations)
} else {
loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
}
reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
} else {
appState.currentView = .zombie
showPopover.toggle()
+1 -10
View File
@@ -155,7 +155,6 @@ struct MiniAppView: View {
@State private var showSys: Bool = true
@State private var showUsr: Bool = true
@AppStorage("settings.general.mini") private var mini: Bool = false
@AppStorage("settings.general.instant") private var instantSearch: Bool = true
@AppStorage("settings.general.glass") private var glass: Bool = true
@Binding var showPopover: Bool
@@ -175,15 +174,7 @@ struct MiniAppView: View {
if appState.reload {
VStack {
Spacer()
ProgressView(instantSearch ? "Refreshing app list and caching files" : "Refreshing app list")
if instantSearch {
Image(systemName: "bolt.fill")
.resizable()
.scaledToFit()
.foregroundStyle(Color("mode").opacity(0.5))
.frame(width: 30, height: 30)
.padding()
}
ProgressView("Refreshing app list")
Spacer()
}
.padding(.vertical)
@@ -0,0 +1,109 @@
//
// SegmentedPicker.swift
// Pearcleaner
//
// Created by Alin Lupascu on 4/17/24.
//
import SwiftUI
public struct SegmentedPicker<Element, Content, Selection>: View
where
Content: View,
Selection: View {
public typealias Data = [Element]
@State private var frames: [CGRect]
@Binding private var selectedIndex: Data.Index?
private let data: Data
private let selection: () -> Selection
private let content: (Data.Element, Bool) -> Content
private let selectionAlignment: VerticalAlignment
public init(_ data: Data,
selectedIndex: Binding<Data.Index?>,
selectionAlignment: VerticalAlignment = .center,
@ViewBuilder content: @escaping (Data.Element, Bool) -> Content,
@ViewBuilder selection: @escaping () -> Selection) {
self.data = data
self.content = content
self.selection = selection
self._selectedIndex = selectedIndex
self._frames = State(wrappedValue: Array(repeating: .zero,
count: data.count))
self.selectionAlignment = selectionAlignment
}
public var body: some View {
ZStack(alignment: Alignment(horizontal: .horizontalCenterAlignment,
vertical: selectionAlignment)) {
if let selectedIndex = selectedIndex {
selection()
.frame(width: frames[selectedIndex].width,
height: frames[selectedIndex].height)
.alignmentGuide(.horizontalCenterAlignment) { dimensions in
dimensions[HorizontalAlignment.center]
}
}
HStack(spacing: 0) {
ForEach(data.indices, id: \.self) { index in
Button(action: { selectedIndex = index },
label: { content(data[index], selectedIndex == index) }
)
.buttonStyle(PlainButtonStyle())
.background(GeometryReader { proxy in
Color.clear.onAppear { frames[index] = proxy.frame(in: .global) }
})
.alignmentGuide(.horizontalCenterAlignment,
isActive: selectedIndex == index) { dimensions in
dimensions[HorizontalAlignment.center]
}
}
}
}
}
}
extension HorizontalAlignment {
private enum CenterAlignmentID: AlignmentID {
static func defaultValue(in dimension: ViewDimensions) -> CGFloat {
return dimension[HorizontalAlignment.center]
}
}
static var horizontalCenterAlignment: HorizontalAlignment {
HorizontalAlignment(CenterAlignmentID.self)
}
}
extension View {
@ViewBuilder
@inlinable func alignmentGuide(_ alignment: HorizontalAlignment,
isActive: Bool,
computeValue: @escaping (ViewDimensions) -> CGFloat) -> some View {
if isActive {
alignmentGuide(alignment, computeValue: computeValue)
} else {
self
}
}
@ViewBuilder
@inlinable func alignmentGuide(_ alignment: VerticalAlignment,
isActive: Bool,
computeValue: @escaping (ViewDimensions) -> CGFloat) -> some View {
if isActive {
alignmentGuide(alignment, computeValue: computeValue)
} else {
self
}
}
}
+1 -19
View File
@@ -13,13 +13,10 @@ struct RegularMode: View {
@EnvironmentObject var themeSettings: ThemeSettings
@AppStorage("settings.general.glass") private var glass: Bool = false
@AppStorage("settings.general.sidebarWidth") private var sidebarWidth: Double = 280
@AppStorage("settings.general.instant") private var instantSearch: Bool = true
@Binding var search: String
@State private var showSys: Bool = true
@State private var showUsr: Bool = true
@Binding var showPopover: Bool
// @State private var isHovering = false
// @State private var isHovering2 = false
var filteredApps: [AppInfo] {
@@ -30,7 +27,6 @@ struct RegularMode: View {
}
}
// let slate = Color(.sRGB, red: 0.188143, green: 0.208556, blue: 0.262679, opacity: 1)
var body: some View {
@@ -41,13 +37,7 @@ struct RegularMode: View {
if appState.reload {
VStack {
Spacer()
ProgressView(instantSearch ? "Refreshing app list and caching files" : "Refreshing app list")
if instantSearch {
Image(systemName: "bolt")
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
}
ProgressView("Refreshing app list")
Spacer()
}
.frame(width: sidebarWidth)
@@ -159,7 +149,6 @@ struct Header: View {
@EnvironmentObject var locations: Locations
@EnvironmentObject var fsm: FolderSettingsManager
@Binding var showPopover: Bool
@AppStorage("settings.general.instant") private var instantSearch: Bool = true
@AppStorage("settings.general.glass") private var glass: Bool = true
@@ -198,15 +187,8 @@ struct Header: View {
let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
appState.sortedApps = sortedApps
if instantSearch {
loadAllPaths(allApps: sortedApps, appState: appState, locations: locations) {
appState.reload.toggle()
}
} else {
appState.reload.toggle()
}
}
}
}
.frame(minHeight: 20)
+2 -14
View File
@@ -13,7 +13,6 @@ struct TopBar: View {
@AppStorage("settings.general.glass") private var glass: Bool = false
@AppStorage("settings.sentinel.enable") private var sentinel: Bool = false
@AppStorage("settings.general.mini") private var mini: Bool = false
@AppStorage("settings.general.instant") private var instantSearch: Bool = true
@EnvironmentObject var appState: AppState
@Binding var showPopover: Bool
@EnvironmentObject var locations: Locations
@@ -47,12 +46,7 @@ struct TopBar: View {
updateOnMain {
appState.zombieFile = .empty
appState.showProgress.toggle()
if instantSearch {
let reverse = ReversePathsSearcher(appState: appState, locations: locations)
reverse.reversePathsSearch()
} else {
loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
}
reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
}
}
.buttonStyle(SimpleButtonStyle(icon: "arrow.counterclockwise.circle.fill", help: "Rescan files"))
@@ -68,13 +62,7 @@ struct TopBar: View {
appState.currentView = .zombie
appState.showProgress.toggle()
showPopover.toggle()
if instantSearch {
let reverse = ReversePathsSearcher(appState: appState, locations: locations)
reverse.reversePathsSearch()
// reversePathsSearch(appState: appState, locations: locations)
} else {
loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
}
reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
} else {
appState.currentView = .zombie
showPopover.toggle()
+3 -15
View File
@@ -12,7 +12,6 @@ struct TopBarMini: View {
@AppStorage("displayMode") var displayMode: DisplayMode = .system
@AppStorage("settings.sentinel.enable") private var sentinel: Bool = false
@AppStorage("settings.general.mini") private var mini: Bool = false
@AppStorage("settings.general.instant") private var instantSearch: Bool = true
@Binding var search: String
@Binding var showPopover: Bool
@EnvironmentObject var appState: AppState
@@ -36,13 +35,7 @@ struct TopBarMini: View {
appState.currentView = .zombie
appState.showProgress.toggle()
showPopover.toggle()
if instantSearch {
let reverse = ReversePathsSearcher(appState: appState, locations: locations)
reverse.reversePathsSearch()
// reversePathsSearch(appState: appState, locations: locations)
} else {
loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
}
reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
} else {
appState.currentView = .zombie
showPopover.toggle()
@@ -79,12 +72,7 @@ struct TopBarMini: View {
appState.currentView = .zombie
appState.showProgress.toggle()
showPopover.toggle()
if instantSearch {
let reverse = ReversePathsSearcher(appState: appState, locations: locations)
reverse.reversePathsSearch()
} else {
loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
}
reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
} else {
appState.currentView = .zombie
showPopover.toggle()
@@ -133,7 +121,7 @@ struct SearchBarMiniBottom: View {
var body: some View {
HStack {
TextField("\(appState.instantTotal > appState.instantProgress ? " Searching the file system" : " Search")", text: $search)
TextField(" Search", text: $search)
.textFieldStyle(SimpleSearchStyle(trash: true, text: $search))
}
}
+71 -33
View File
@@ -14,9 +14,10 @@ struct ZombieView: View {
@State private var showPop: Bool = false
@AppStorage("settings.general.mini") private var mini: Bool = false
@AppStorage("settings.sentinel.enable") private var sentinel: Bool = false
@AppStorage("settings.general.instant") private var instantSearch: Bool = true
@AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false
@AppStorage("settings.general.selectedSort") var selectedSortAlpha: Bool = true
@AppStorage("settings.general.sizeType") var sizeType: String = "Real"
@State private var localKey = UUID()
@Environment(\.colorScheme) var colorScheme
@Binding var showPopover: Bool
@Binding var search: String
@@ -27,28 +28,45 @@ struct ZombieView: View {
var body: some View {
let totalSelectedZombieSize = appState.selectedZombieItems.reduce(Int64(0)) { (result, url) in
result + (appState.zombieFile.fileSize[url] ?? 0)
}
let totalSelectedZombieSize: (real: String, logical: String, finder: String) = {
var totalReal: Int64 = 0
var totalLogical: Int64 = 0
let filteredAndSortedFiles: ([URL], Int64) = {
let filteredFiles = appState.zombieFile.fileSize.filter { (url, _) in
for url in appState.selectedZombieItems {
let realSize = appState.zombieFile.fileSize[url] ?? 0
let logicalSize = appState.zombieFile.fileSizeLogical[url] ?? 0
totalReal += realSize
totalLogical += logicalSize
}
return (formatByte(size: totalReal).human, formatByte(size:totalLogical).human, "\(formatByte(size: totalLogical).byte) (\(formatByte(size: totalReal).human))")
}()
let filteredAndSortedFiles: ([URL], Int64, Int64) = {
let fileSizeReal = appState.zombieFile.fileSize
let fileSizeLogical = appState.zombieFile.fileSizeLogical
let filteredFilesReal = fileSizeReal.filter { (url, _) in
searchZ.isEmpty || url.lastPathComponent.localizedCaseInsensitiveContains(searchZ)
}
let sortedFilteredFiles = filteredFiles.sorted(by: {
let filteredFilesLogical = fileSizeLogical.filter { (url, _) in
searchZ.isEmpty || url.lastPathComponent.localizedCaseInsensitiveContains(searchZ)
}
let filesToSort = (sizeType == "Real" || sizeType == "Finder" ? filteredFilesReal : filteredFilesLogical)
let sortedFilteredFiles = filesToSort.sorted(by: {
if selectedSortAlpha {
return $0.key.lastPathComponent.pearFormat() < $1.key.lastPathComponent.pearFormat()
} else {
return $0.value > $1.value
}
}).map { $0.key }
let totalSize = filteredFiles.values.reduce(0, +)
return (sortedFilteredFiles, totalSize)
let totalSize = filteredFilesReal.values.reduce(0, +)
let totalSizeL = filteredFilesLogical.values.reduce(0, +)
return (sortedFilteredFiles, totalSize, totalSizeL)
}()
let displaySizeTotal = sizeType == "Real" ? formatByte(size: filteredAndSortedFiles.1).human :
sizeType == "Logical" ? formatByte(size: filteredAndSortedFiles.2).human :
"\(formatByte(size: filteredAndSortedFiles.2).byte) (\(formatByte(size: filteredAndSortedFiles.1).human))"
VStack(alignment: .center) {
if appState.showProgress {
VStack {
@@ -62,6 +80,7 @@ struct ZombieView: View {
.progressViewStyle(.linear)
.frame(width: 400, height: 10)
Text("\(elapsedTime)")
.font(.title).monospacedDigit()
.foregroundStyle(Color("mode").opacity(0.5))
@@ -97,13 +116,7 @@ struct ZombieView: View {
updateOnMain {
appState.zombieFile = .empty
appState.showProgress.toggle()
if instantSearch {
let reverse = ReversePathsSearcher(appState: appState, locations: locations)
reverse.reversePathsSearch()
// reversePathsSearch(appState: appState, locations: locations)
} else {
loadAllPaths(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
}
reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true)
}
}
.buttonStyle(SimpleButtonStyle(icon: "arrow.counterclockwise.circle.fill", help: "Rescan files"))
@@ -142,14 +155,14 @@ struct ZombieView: View {
Spacer()
}
Text("Files and folders remaining from previously installed applications")
Text("Remaining files and folders from previous installs")
.font(.callout).foregroundStyle(Color("mode").opacity(0.5))
}
Spacer()
VStack(alignment: .trailing, spacing: 5) {
Text("\(formatByte(size: filteredAndSortedFiles.1))").font(.title).fontWeight(.bold)
Text("\(displaySizeTotal)").font(.title).fontWeight(.bold).help("Total size on disk")
Text("\(appState.zombieFile.fileSize.count == 1 ? "\(appState.selectedZombieItems.count) / \(appState.zombieFile.fileSize.count) item" : "\(appState.selectedZombieItems.count) / \(appState.zombieFile.fileSize.count) items")")
.font(.callout).foregroundStyle(Color("mode").opacity(0.5))
@@ -195,10 +208,10 @@ struct ZombieView: View {
ScrollView() {
LazyVStack {
ForEach(Array(filteredAndSortedFiles.0.enumerated()), id: \.element) { index, file in
if let fileSize = appState.zombieFile.fileSize[file], let fileIcon = appState.zombieFile.fileIcon[file] {
if let fileSize = appState.zombieFile.fileSize[file], let fileSizeL = appState.zombieFile.fileSizeLogical[file], let fileIcon = appState.zombieFile.fileIcon[file] {
let iconImage = fileIcon.map(Image.init(nsImage:))
VStack {
ZombieFileDetailsItem(size: fileSize, icon: iconImage, path: file)
ZombieFileDetailsItem(size: fileSize, sizeL: fileSizeL, icon: iconImage, path: file)
.padding(.trailing)
if index < filteredAndSortedFiles.0.count - 1 {
@@ -210,7 +223,12 @@ struct ZombieView: View {
}
.padding()
.onChange(of: sizeType) { _ in
localKey = UUID()
}
.id(localKey)
}
Spacer()
@@ -218,7 +236,7 @@ struct ZombieView: View {
Spacer()
Button("\(formatByte(size: totalSelectedZombieSize))") {
Button("\(sizeType == "Logical" ? totalSelectedZombieSize.logical : sizeType == "Finder" ? totalSelectedZombieSize.finder : totalSelectedZombieSize.real)") {
Task {
updateOnMain {
appState.zombieFile = .empty
@@ -271,7 +289,10 @@ struct ZombieView: View {
struct ZombieFileDetailsItem: View {
@EnvironmentObject var appState: AppState
@State private var isHovered = false
@AppStorage("settings.general.sizeType") var sizeType: String = "Real"
let size: Int64?
let sizeL: Int64?
let icon: Image?
let path: URL
@@ -295,6 +316,8 @@ struct ZombieFileDetailsItem: View {
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.clipShape(RoundedRectangle(cornerRadius: 8))
.brightness(isHovered ? 0.2 : 0)
.shadow(color: Color("mode"), radius: isHovered ? 2 : 0)
}
VStack(alignment: .leading, spacing: 5) {
@@ -313,20 +336,35 @@ struct ZombieFileDetailsItem: View {
Spacer()
Text(formatByte(size:size!))
let displaySize = sizeType == "Real" ? formatByte(size: size!).human :
sizeType == "Logical" ? formatByte(size: sizeL!).human :
"\(formatByte(size: sizeL!).byte) (\(formatByte(size: size!).human))"
Text("\(displaySize)")
Button("") {
}
.onHover { hovering in
withAnimation(Animation.easeIn(duration: 0.2)) {
self.isHovered = hovering
}
}
.onTapGesture {
NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: path.deletingLastPathComponent().path)
}
.contextMenu {
if path.pathExtension == "app" {
Button("Open \(path.deletingPathExtension().lastPathComponent)") {
NSWorkspace.shared.open(path)
}
Divider()
}
Button("Copy Path") {
copyToClipboard(text: path.path)
}
Button("View in Finder") {
NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: path.deletingLastPathComponent().path)
}
.buttonStyle(SimpleButtonStyle(icon: "folder.fill", help: "Show in Finder"))
}
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.white.opacity(0))
)
}
}