This commit is contained in:
Alin
2024-03-21 11:24:56 -06:00
parent 6b71708561
commit 422463e76d
23 changed files with 1110 additions and 696 deletions
+5 -3
View File
@@ -12,10 +12,12 @@ struct AppCommands: Commands {
let appState: AppState
let locations: Locations
let fsm: FolderSettingsManager
init(appState: AppState, locations: Locations) {
init(appState: AppState, locations: Locations, fsm: FolderSettingsManager) {
self.appState = appState
self.locations = locations
self.fsm = fsm
}
var body: some Commands {
@@ -36,7 +38,7 @@ struct AppCommands: Commands {
updateOnMain {
appState.reload.toggle()
}
let sortedApps = getSortedApps()
let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
appState.sortedApps = []
// appState.sortedApps.systemApps = []
@@ -71,7 +73,7 @@ struct AppCommands: Commands {
Button
{
undoTrash(appState: appState) {
let sortedApps = getSortedApps()
let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
appState.sortedApps = sortedApps
// if instantSearch {
+174
View File
@@ -0,0 +1,174 @@
//
// AppInfoFetch.swift
// Pearcleaner
//
// Created by Alin Lupascu on 3/20/24.
//
import Foundation
import SwiftUI
class AppInfoFetcher {
static let fileManager = FileManager.default
static func getAppInfo(atPath path: URL, wrapped: Bool = false) -> AppInfo? {
if isDirectoryWrapped(path: path) {
return handleWrappedDirectory(atPath: path)
} else {
return createAppInfoFromBundle(atPath: path, wrapped: wrapped)
}
}
private static func isDirectoryWrapped(path: URL) -> Bool {
let wrapperURL = path.appendingPathComponent("Wrapper")
return fileManager.fileExists(atPath: wrapperURL.path)
}
private static func handleWrappedDirectory(atPath path: URL) -> 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)
} catch {
printOS("Error reading contents of 'Wrapper' directory: \(error.localizedDescription)\n\(wrapperURL)")
return nil
}
}
private static func createAppInfoFromBundle(atPath path: URL, wrapped: Bool) -> AppInfo? {
guard let bundle = Bundle(url: path), let bundleIdentifier = bundle.bundleIdentifier else {
printOS("Bundle not found or missing bundle identifier at path: \(path)")
return nil
}
let appName = bundle.localizedInfoDictionary?[kCFBundleNameKey as String] as? String
?? bundle.infoDictionary?["CFBundleName"] as? String
?? path.deletingPathExtension().lastPathComponent
let appVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String
?? bundle.infoDictionary?["CFBundleVersion"] as? String
?? ""
let appIcon = fetchAppIcon(for: path, wrapped: wrapped)
let webApp = bundle.infoDictionary?["LSTemplateApplication"] as? Bool ?? false
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: [:])
}
private static func fetchAppIcon(for path: URL, wrapped: Bool) -> NSImage? {
let iconPath = wrapped ? path.deletingLastPathComponent().deletingLastPathComponent() : path
if let appIcon = getIconForFileOrFolderNS(atPath: iconPath) {
return convertICNSToPNG(icon: appIcon, size: NSSize(width: 100, height: 100))
} else {
printOS("App Icon not found for app at path: \(path)")
return nil
}
}
}
// OLD FUNCTION ============================================================
//func getAppInfo(atPath path: URL, wrapped: Bool = false) -> AppInfo? {
// let filemanager = FileManager.default
// let wrapperURL = path.appendingPathComponent("Wrapper")
// if filemanager.fileExists(atPath: wrapperURL.path) {
// do {
// let contents = try filemanager.contentsOfDirectory(at: wrapperURL, includingPropertiesForKeys: nil, options: [])
// let appFiles = contents.filter { $0.pathExtension == "app" }
//
// if let firstAppFile = appFiles.first {
// let fullPath = wrapperURL.appendingPathComponent(firstAppFile.lastPathComponent)
// if let wrappedAppInfo = getAppInfo(atPath: fullPath, wrapped: true) {
// return wrappedAppInfo
// }
// } else {
// printOS("No .app files found in the 'Wrapper' directory: \(wrapperURL)")
// }
// } catch {
// printOS("Error reading contents of 'Wrapper' directory: \(error.localizedDescription)\n\(wrapperURL)")
// }
// } else {
// if let bundle = Bundle(url: path) {
// if let bundleIdentifier = bundle.bundleIdentifier {
// var appVersion: String?
// var appIcon: NSImage?
// var appName: String?
// var webApp: Bool?
// var wrappedApp: Bool?
// var system: Bool?
//
// if let shortVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String, !shortVersion.isEmpty {
// appVersion = shortVersion
// } else {
// if let bundleVersion = bundle.infoDictionary?["CFBundleVersion"] as? String, !bundleVersion.isEmpty {
// appVersion = bundleVersion
// } else {
// printOS("Failed to retrieve bundle version")
// }
// }
//
// if let localizedName = bundle.localizedInfoDictionary?[kCFBundleNameKey as String] as? String {
// appName = localizedName
// } else if let bundleName = bundle.infoDictionary?["CFBundleName"] as? String {
// appName = bundleName
// } else {
// appName = path.deletingPathExtension().lastPathComponent
// }
//
// // Get icon from app
// if path.absoluteString.contains("Wrapper") {
// appIcon = getIconForFileOrFolderNS(atPath: path.deletingLastPathComponent().deletingLastPathComponent())
// } else {
// appIcon = getIconForFileOrFolderNS(atPath: path)
// }
//
// // Convert the icon to a 100x100 PNG image
// if let pngIcon = appIcon.flatMap({ convertICNSToPNG(icon: $0, size: NSSize(width: 100, height: 100)) }) {
// appIcon = pngIcon
// }
//
// if appIcon == nil {
// printOS("App Icon not found for app at path: \(path)")
// }
//
// if bundle.infoDictionary?["LSTemplateApplication"] is Bool {
// webApp = true
// } else {
// webApp = false
// }
//
// if wrapped {
// wrappedApp = true
// } else {
// wrappedApp = false
// }
//
// if !path.path.contains(home) {
// system = true
// } else {
// system = false
// }
//
//
// return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName ?? "", appVersion: appVersion ?? "", appIcon: appIcon, webApp: webApp ?? false, wrapped: wrappedApp ?? false, system: system ?? false, files: [], fileSize: [:], fileIcon: [:])
//
// } else {
// printOS("Bundle identifier not found at path: \(path)")
// }
// } else {
// printOS("Bundle not found at path: \(path)")
// }
// }
// return nil
//}
+226
View File
@@ -0,0 +1,226 @@
//
// AppPathsFetch.swift
// Pearcleaner
//
// Created by Alin Lupascu on 3/20/24.
//
import Foundation
import AppKit
import SwiftUI
class AppPathFinder {
private var appInfo: AppInfo
private var appState: AppState
private var locations: Locations
private var backgroundRun: Bool
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, completion: @escaping () -> Void = {}) {
self.appInfo = appInfo
self.appState = appState
self.locations = locations
self.backgroundRun = backgroundRun
self.completion = completion
}
func findPaths() {
Task(priority: .background) {
self.initialURLProcessing()
let dispatchGroup = DispatchGroup()
for location in self.locations.apps.paths {
dispatchGroup.enter()
DispatchQueue.global(qos: .userInitiated).async {
self.processLocation(location, with: dispatchGroup)
dispatchGroup.leave()
}
}
dispatchGroup.notify(queue: .main) {
self.finalizeCollection()
}
}
}
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
self.collection.append(modifiedUrl)
}
}
private func processLocation(_ location: String, with dispatchGroup: DispatchGroup) {
// Check if the directory exists before attempting to read its contents
if FileManager.default.fileExists(atPath: location) {
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 shouldSkipItem(itemL, at: itemURL) { continue }
if specificCondition(itemL: itemL, itemURL: itemURL) {
collectionAccessQueue.async {
self.collection.append(itemURL)
}
}
}
} catch {
// If an error occurs while trying to read the directory's contents, log or handle it here if needed
printOS("Error processing location: \(location), \(error)")
}
} else {
// The directory does not exist; skip it
}
}
private func shouldSkipItem(_ itemL: String, at itemURL: URL) -> Bool {
var containsItem = false
collectionAccessQueue.sync {
containsItem = self.collection.contains(itemURL)
}
return itemL.hasPrefix("comapple") && !["comappleconfigurator", "comappledt", "comappleiwork"].contains(where: itemL.hasPrefix) || containsItem || !isSupportedFileType(at: itemURL.path)
}
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: "")
if self.appInfo.webApp {
return itemL.contains(bundleIdentifierL)
} else {
if itemL.contains("xcode") && bundleIdentifierL.contains("comappledt") {
return !(itemURL.path.contains("comrobotsandpencilsxcodesapp") || itemURL.path.contains("comoneminutegamesxcodecleaner") || itemURL.path.contains("iohyperappxcodecleaner") || itemURL.path.contains("xcodesjson")) && (itemL.contains(bundle) || itemL.contains(bundleIdentifierL) || (nameL.count < 6 && itemL.contains(nameL)))
} else if itemL.contains("xcodes") && bundleIdentifierL.contains("comrobotsandpencilsxcodesapp") {
return !(itemURL.path.contains("comappledt")) && (itemL.contains(bundle) || itemL.contains(bundleIdentifierL) || (nameL.count > 4 && itemL.contains(nameL)))
} else {
return itemL.contains(bundleIdentifierL) || itemL.contains(bundle) || (nameL.count > 3 && itemL.contains(nameL)) || (nameP.count > 3 && itemL.contains(nameP))
}
}
}
private func filterParentDirectories(in collection: [URL]) -> [URL] {
// Normalize URLs by removing percent encoding and trailing slashes for accurate comparison
var filteredURLs: [URL] = []
let normalizedURLs = collection.map { url -> URL in
let path = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
return URL(fileURLWithPath: path)
}
for url in normalizedURLs {
// Determine if 'url' is a parent of any path in 'filteredURLs'
if !filteredURLs.contains(where: { $0.absoluteString.hasPrefix(url.absoluteString) }) {
filteredURLs.append(url)
}
}
// Remove URLs that are parents of another URL in the list
filteredURLs = filteredURLs.filter { parentUrl in
!filteredURLs.contains { childUrl in
childUrl != parentUrl && childUrl.absoluteString.hasPrefix(parentUrl.absoluteString)
}
}
return filteredURLs
}
private func getGroupContainers(bundleURL: URL) -> [URL] {
// Get group containers
var staticCode: SecStaticCode?
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, [], &staticCode) == errSecSuccess else {
return []
}
var signingInformation: CFDictionary?
let status = SecCodeCopySigningInformation(staticCode!, SecCSFlags(), &signingInformation)
if status != errSecSuccess {
printOS("Failed to copy signing information. Status: \(status)")
return []
}
guard let topDict = signingInformation as? [String: Any],
let entitlementsDict = topDict["entitlements-dict"] as? [String: Any],
let appGroups = entitlementsDict["com.apple.security.application-groups"] as? [String] else {
// printOS("No application groups to extract from entitlements for this app.")
return []
}
let groupContainersPath = appGroups.map { URL(fileURLWithPath: "\(home)/Library/Group Containers/" + $0) }
let existingGroupContainers = groupContainersPath.filter { FileManager.default.fileExists(atPath: $0.path) }
return existingGroupContainers
}
private func finalizeCollection() {
DispatchQueue.global(qos: .userInitiated).async {
let groupContainers = self.getGroupContainers(bundleURL: self.appInfo.path)
var tempCollection: [URL] = []
self.collectionAccessQueue.sync {
tempCollection = self.collection
}
tempCollection.append(contentsOf: groupContainers)
// Apply the filter to remove parent directories
let filteredCollection = self.filterParentDirectories(in: tempCollection)
// Continue with the sorted collection
let sortedCollection = filteredCollection.sorted(by: { $0.absoluteString < $1.absoluteString })
self.handlePostProcessing(sortedCollection: sortedCollection)
}
}
private func handlePostProcessing(sortedCollection: [URL]) {
// Calculate file details (sizes and icons), update app state, and call completion
var fileSize: [URL: Int64] = [:]
var fileIcon: [URL: NSImage?] = [:]
for path in sortedCollection {
fileSize[path] = totalSizeOnDisk(for: path)
fileIcon[path] = getIconForFileOrFolderNS(atPath: path)
}
DispatchQueue.main.async {
var updatedCollection = sortedCollection
if updatedCollection.count == 1, let firstURL = updatedCollection.first, firstURL.path.contains(".Trash") {
updatedCollection.removeAll()
}
// Update appInfo and appState with the new values
self.appInfo.files = updatedCollection
self.appInfo.fileSize = fileSize
self.appInfo.fileIcon = fileIcon
if !self.backgroundRun {
self.appState.appInfo = self.appInfo
self.appState.selectedItems = Set(updatedCollection)
}
self.appState.appInfoStore.append(self.appInfo)
if self.instantSearch {
self.appState.instantProgress += 1
}
self.completion()
}
}
}
//assert(!Thread.isMainThread, "This method should not run on the main thread")
+4
View File
@@ -28,6 +28,8 @@ class AppState: ObservableObject
@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
init() {
@@ -106,6 +108,7 @@ enum CurrentTabView:Int
{
case general
case interface
case folders
case update
case about
@@ -113,6 +116,7 @@ enum CurrentTabView:Int
switch self {
case .general: return "General"
case .interface: return "Interface"
case .folders: return "Folders"
case .update: return "Update"
case .about: return "About"
}
+2 -2
View File
@@ -33,7 +33,7 @@ class DeeplinkManager {
let queryItems = components.queryItems {
if let path = queryItems.first(where: { $0.name == DeepLinkConstants.query })?.value {
let pathURL = URL(fileURLWithPath: path)
let appInfo = getAppInfo(atPath: pathURL)
let appInfo = AppInfoFetcher.getAppInfo(atPath: pathURL)
showAppInFiles(appInfo: appInfo!, appState: appState, locations: locations, showPopover: $showPopover)
} else {
printOS("No path query parameter found in the URL")
@@ -45,7 +45,7 @@ class DeeplinkManager {
func handleAppBundle(url: URL, appState: AppState, locations: Locations) {
let appInfo = getAppInfo(atPath: url)
let appInfo = AppInfoFetcher.getAppInfo(atPath: url)
showAppInFiles(appInfo: appInfo!, appState: appState, locations: locations, showPopover: $showPopover)
}
-2
View File
@@ -14,7 +14,6 @@ class Locations: ObservableObject {
var paths: [String]
}
let home: String
let cacheDir: String
let tempDir: String
@@ -24,7 +23,6 @@ class Locations: ObservableObject {
// var plugins: Category
init() {
self.home = FileManager.default.homeDirectoryForCurrentUser.path
let (cacheDir, tempDir) = darwinCT()
self.cacheDir = cacheDir
self.tempDir = tempDir
+105 -560
View File
@@ -8,16 +8,13 @@
import Foundation
import SwiftUI
// Get all apps from /Applications and ~/Applications
func getSortedApps() -> [AppInfo] {
func getSortedApps(paths: [String], appState: AppState) -> [AppInfo] {
@AppStorage("settings.general.instant") var instantSearch: Bool = true
let fileManager = FileManager.default
// Define the paths for system and user applications
let systemAppsPath = "/Applications"
let userAppsPath = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications").path
var apps: [URL] = []
func collectAppPaths(at directoryPath: String) {
do {
let appURLs = try fileManager.contentsOfDirectory(at: URL(fileURLWithPath: directoryPath), includingPropertiesForKeys: nil, options: [])
@@ -37,214 +34,78 @@ func getSortedApps() -> [AppInfo] {
}
}
// Collect system applications
collectAppPaths(at: systemAppsPath)
// Collect user applications
collectAppPaths(at: userAppsPath)
paths.forEach { collectAppPaths(at: $0) }
// Get app info and sort
let sortedApps = apps
.compactMap { getAppInfo(atPath: $0) }
.compactMap { AppInfoFetcher.getAppInfo(atPath: $0) }
.sorted { $0.appName.replacingOccurrences(of: ".", with: "").lowercased() < $1.appName.replacingOccurrences(of: ".", with: "").lowercased() }
return sortedApps
}
// Get app bundle information from provided path
func getAppInfo(atPath path: URL, wrapped: Bool = false) -> AppInfo? {
let filemanager = FileManager.default
let wrapperURL = path.appendingPathComponent("Wrapper")
if filemanager.fileExists(atPath: wrapperURL.path) {
do {
let contents = try filemanager.contentsOfDirectory(at: wrapperURL, includingPropertiesForKeys: nil, options: [])
let appFiles = contents.filter { $0.pathExtension == "app" }
if let firstAppFile = appFiles.first {
let fullPath = wrapperURL.appendingPathComponent(firstAppFile.lastPathComponent)
if let wrappedAppInfo = getAppInfo(atPath: fullPath, wrapped: true) {
return wrappedAppInfo
}
} else {
printOS("No .app files found in the 'Wrapper' directory: \(wrapperURL)")
}
} catch {
printOS("Error reading contents of 'Wrapper' directory: \(error.localizedDescription)\n\(wrapperURL)")
}
} else {
if let bundle = Bundle(url: path) {
if let bundleIdentifier = bundle.bundleIdentifier {
var appIconFileName = bundle.infoDictionary?["CFBundleIconFile"] as? String ?? ""
var appVersion: String?
var appIcon: NSImage?
var appName: String?
var webApp: Bool?
var wrappedApp: Bool?
var system: Bool?
if let shortVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String, !shortVersion.isEmpty {
appVersion = shortVersion
} else {
if let bundleVersion = bundle.infoDictionary?["CFBundleVersion"] as? String, !bundleVersion.isEmpty {
appVersion = bundleVersion
} else {
printOS("Failed to retrieve bundle version")
}
}
if let localizedName = bundle.localizedInfoDictionary?[kCFBundleNameKey as String] as? String {
appName = localizedName
} else if let bundleName = bundle.infoDictionary?["CFBundleName"] as? String {
appName = bundleName
} else {
appName = path.deletingPathExtension().lastPathComponent
}
if appIconFileName.isEmpty {
if let iconsDict = bundle.infoDictionary?["CFBundleIcons"] as? [String: Any] {
if let primaryIconDict = iconsDict["CFBundlePrimaryIcon"] as? [String: Any],
let iconFiles = primaryIconDict["CFBundleIconFiles"] as? [String],
let primaryIconFile = iconFiles.first {
// Now, you can use the primaryIconFile to construct the path to the icon file
let iconPath = path.appendingPathComponent(primaryIconFile)
appIconFileName = iconPath.path
if let contents = try? FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil) {
// Find the first file that matches the specified prefix
if let foundURL = contents.first(where: { $0.lastPathComponent.hasPrefix(primaryIconFile) }),
let image = NSImage(contentsOfFile: foundURL.path) {
appIcon = image
} else {
printOS("No matching image found for \(primaryIconFile) in wrapped app.")
}
} else {
printOS("Unable to access the directory at \(primaryIconFile) for wrapped app.")
}
}
}
}
// Check if the icon file name has an .icns extension, if not, add it
if !appIconFileName.hasSuffix(".icns") {
appIconFileName += ".icns"
}
// Try to find the icon in the main bundle
if let iconPath = bundle.path(forResource: appIconFileName, ofType: nil),
let icon = NSImage(contentsOfFile: iconPath) {
appIcon = icon
}
// If not found, try to find it in the resources directory
if appIcon == nil,
let resourcesPath = bundle.resourcePath {
let iconURL = URL(fileURLWithPath: resourcesPath).appendingPathComponent(appIconFileName)
if let icon = NSImage(contentsOfFile: iconURL.path) {
appIcon = icon
}
}
// Convert the icon to a 100x100 PNG image
if let pngIcon = appIcon.flatMap({ convertICNSToPNG(icon: $0, size: NSSize(width: 100, height: 100)) }) {
appIcon = pngIcon
}
if appIcon == nil {
printOS("App Icon not found for app at path: \(path)")
}
if bundle.infoDictionary?["LSTemplateApplication"] is Bool {
webApp = true
} else {
webApp = false
}
if wrapped {
wrappedApp = true
} else {
wrappedApp = false
}
if !path.path.contains(home) {
system = true
} else {
system = false
}
return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName ?? "", appVersion: appVersion ?? "", appIcon: appIcon, webApp: webApp ?? false, wrapped: wrappedApp ?? false, system: system ?? false, files: [], fileSize: [:], fileIcon: [:])
} else {
printOS("Bundle identifier not found at path: \(path)")
}
} else {
printOS("Bundle not found at path: \(path)")
if instantSearch {
updateOnMain {
appState.instantTotal = Double(sortedApps.count)
}
}
return nil
return sortedApps
}
// Get directory path for darwin cache and temp directories
func darwinCT() -> (String, String) {
let task = Process()
task.launchPath = "/bin/bash"
task.arguments = ["-c", "getconf DARWIN_USER_CACHE_DIR; getconf DARWIN_USER_TEMP_DIR"]
let command = "echo $(getconf DARWIN_USER_CACHE_DIR) $(getconf DARWIN_USER_TEMP_DIR)"
let process = Process()
process.launchPath = "/bin/bash"
process.arguments = ["-c", command]
let pipe = Pipe()
task.standardOutput = pipe
task.launch()
process.standardOutput = pipe
process.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8) {
let components = output.components(separatedBy: "\n")
if components.count >= 2 {
var cacheDir = components[0].trimmingCharacters(in: .whitespacesAndNewlines)
var tempDir = components[1].trimmingCharacters(in: .whitespacesAndNewlines)
if cacheDir.hasSuffix("/") {
cacheDir.removeLast()
}
if tempDir.hasSuffix("/") {
tempDir.removeLast()
}
return (cacheDir, tempDir)
}
guard let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
printOS("Could not get DARWIN_USER_CACHE_DIR or DARWIN_USER_TEMP_DIR")
return ("", "")
}
printOS("Could not get DARWIN_USER_CACHE_DIR or DARWIN_USER_TEMP_DIR")
return ("", "")
let paths = output.split(separator: " ").map(String.init)
guard paths.count >= 2 else {
printOS("Could not parse DARWIN_USER_CACHE_DIR or DARWIN_USER_TEMP_DIR")
return ("", "")
}
return (paths[0].trimmingCharacters(in: .whitespaces), paths[1].trimmingCharacters(in: .whitespaces))
}
func listAppSupportDirectories() -> [String] {
let home = FileManager.default.homeDirectoryForCurrentUser
let appSupportLocation = home.appendingPathComponent("Library/Application Support/")
let exclusions = ["MobileSync", ".DS_Store", "Xcode", "SyncServices", "networkserviceproxy", "DiskImages", "CallHistoryTransactions", "App Store", "CloudDocs", "icdd", "iCloud", "Instruments", "AddressBook", "FaceTime", "AskPermission", "CallHistoryDB"]
let fileManager = FileManager.default
let home = fileManager.homeDirectoryForCurrentUser
let appSupportLocation = home.appendingPathComponent("Library/Application Support").path
let exclusions = Set(["MobileSync", ".DS_Store", "Xcode", "SyncServices", "networkserviceproxy", "DiskImages", "CallHistoryTransactions", "App Store", "CloudDocs", "icdd", "iCloud", "Instruments", "AddressBook", "FaceTime", "AskPermission", "CallHistoryDB"])
let exclusionRegex = try! NSRegularExpression(pattern: "\\bcom\\.apple\\b", options: [])
do {
let fileManager = FileManager.default
let directoryContents = try fileManager.contentsOfDirectory(at: appSupportLocation, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles)
let directoryContents = try fileManager.contentsOfDirectory(atPath: appSupportLocation)
let filteredDirectories: [String] = directoryContents.compactMap { url in
return directoryContents.compactMap { directoryName in
let fullPath = appSupportLocation.appending("/\(directoryName)")
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
guard fileManager.fileExists(atPath: fullPath, isDirectory: &isDirectory),
isDirectory.boolValue else {
return nil
}
let directoryName = url.lastPathComponent
// Check for exclusions using regex and provided list
let excludeByRegex = exclusionRegex.firstMatch(in: directoryName, options: [], range: NSRange(location: 0, length: directoryName.utf16.count)) != nil
let excludeByList = exclusions.contains(directoryName)
return isDirectory.boolValue && !excludeByRegex && !excludeByList ? directoryName : nil
if exclusions.contains(directoryName) || excludeByRegex {
return nil
}
return directoryName
}
return filteredDirectories
} catch {
printOS("Error listing AppSupport directories: \(error.localizedDescription)")
return []
@@ -253,382 +114,97 @@ func listAppSupportDirectories() -> [String] {
// Check if app is running before deleting app files
func killApp(appId: String, completion: @escaping () -> Void = {}) {
let runningApps = NSWorkspace.shared.runningApplications
for app in runningApps {
if app.bundleIdentifier == appId {
app.terminate()
}
}
completion()
}
// Find all possible paths for an app based on name/bundle id
func findPathsForApp(appInfo: AppInfo = .empty, appState: AppState, locations: Locations, backgroundRun: Bool = false, completion: @escaping () -> Void = {}) {
Task(priority: .high) {
var collection: [URL] = []
if let url = URL(string: appInfo.path.absoluteString) {
if !url.path.contains(".Trash") {
if url.path.contains("Wrapper") {
let modifiedUrl = url.deletingLastPathComponent().deletingLastPathComponent()
collection.insert(modifiedUrl, at: 0)
} else {
collection.insert(url, at: 0)
}
}
}
let fileManager = FileManager.default
let dispatchGroup = DispatchGroup()
var bundleComponents = appInfo.bundleIdentifier.components(separatedBy: ".")
if let lastComponent = bundleComponents.last, let rangeOfDash = lastComponent.range(of: "-") {
let updatedLastComponent = String(lastComponent[..<rangeOfDash.lowerBound])
bundleComponents[bundleComponents.count - 1] = updatedLastComponent
}
var bundle: String = ""
if bundleComponents.count >= 3 { // get last 2 or middle 2 components
bundle = bundleComponents[1...2].joined(separator: "").lowercased()
}
let nameL = appInfo.appName.pearFormat()
let nameP = appInfo.path.lastPathComponent.replacingOccurrences(of: ".app", with: "")
let bundleIdentifierL = appInfo.bundleIdentifier.pearFormat()
for location in locations.apps.paths {
if !fileManager.fileExists(atPath: location) {
continue
}
dispatchGroup.enter() // Enter the dispatch group
do {
let contents = try fileManager.contentsOfDirectory(atPath: location)
for item in contents {
let itemURL = URL(fileURLWithPath: location).appendingPathComponent(item)
let itemL = ("\(item)").replacingOccurrences(of: ".", with: "").replacingOccurrences(of: " ", with: "").lowercased()
let filterItem = "\(location)/\(itemL)"
// Skip directories that start with com.apple. except for a few
if itemL.hasPrefix("comapple") && !itemL.hasPrefix("comappleconfigurator") && !itemL.hasPrefix("comappledt") && !itemL.hasPrefix("comappleiwork") {
continue
}
// Skip adding item if the collection already has it to prevent duplicates
if collection.contains(itemURL) {
continue
}
// Skip if not a regular file, directory or symlink
if !isSupportedFileType(at: itemURL.path) {
continue
}
// Catch web app plist files
if appInfo.webApp {
if itemL.contains(bundleIdentifierL) {
if collection.contains(itemURL) {
continue
}
collection.append(itemURL)
}
} else {
// Catch Xcode files for Xcodes app
if itemL.contains("xcode") && bundleIdentifierL.contains("comappledt") {
if filterItem.contains("comrobotsandpencilsxcodesapp") || filterItem.contains("comoneminutegamesxcodecleaner") || filterItem.contains("iohyperappxcodecleaner") || filterItem.contains("xcodesjson") {
continue
}
if itemL.contains(bundle) || itemL.contains(bundleIdentifierL) || (nameL.count < 6 && itemL.contains(nameL)) {
collection.append(itemURL)
}
} else if itemL.contains("xcodes") && bundleIdentifierL.contains("comrobotsandpencilsxcodesapp") {
if filterItem.contains("comappledt") {
continue
}
if itemL.contains(bundle) || itemL.contains(bundleIdentifierL) || (nameL.count > 4 && itemL.contains(nameL)) {
collection.append(itemURL)
}
}
else {
if itemL.contains(bundleIdentifierL) || itemL.contains(bundle) || (nameL.count > 3 && itemL.contains(nameL) || (nameP.count > 3 && itemL.contains(nameP))) {
collection.append(itemURL)
}
}
}
}
} catch {
printOS("Error processing location:", location, error)
continue
}
dispatchGroup.leave() // Leave the dispatch group
}
// Append group containers
let groupContainers = getGroupContainers(bundleURL: appState.appInfo.path)
collection.append(contentsOf: groupContainers)
var sortedCollection = collection.sorted(by: { $0.absoluteString < $1.absoluteString })
// Calculate file details (sizes and icons)
var fileSize: [URL: Int64] = [:]
var fileIcon: [URL: NSImage?] = [:]
var updatedAppInfo = appInfo
for path in collection {
var size: Int64
var icon: NSImage? = nil
size = totalSizeOnDisk(for: path)
icon = getIconForFileOrFolderNS(atPath: path)
fileSize[path] = size
fileIcon[path] = icon
}
if sortedCollection.count == 1 {
if let firstURL = sortedCollection.first, firstURL.path.contains(".Trash") {
sortedCollection = []
}
}
// Save to appState
dispatchGroup.notify(queue: .main) {
updateOnMain {
updatedAppInfo.files = sortedCollection
updatedAppInfo.fileSize = fileSize
updatedAppInfo.fileIcon = fileIcon
if !backgroundRun {
appState.appInfo = updatedAppInfo
appState.selectedItems = Set(sortedCollection)
}
appState.appInfoStore.append(updatedAppInfo)
}
}
completion()
}
}
// Load app paths on launch
func loadAllPaths(allApps: [AppInfo], appState: AppState, locations: Locations, reverseAddon: Bool = false, completion: @escaping () -> Void = {}) {
let dispatchGroup = DispatchGroup()
let retryLimit = 120
var currentRetryCount = 0
appState.appInfoStore.removeAll()
func checkCompletion() {
if appState.appInfoStore.count == allApps.count {
if reverseAddon {
reversePathsSearch(appState: appState, locations: locations)
}
completion()
} else {
if currentRetryCount < retryLimit {
currentRetryCount += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
checkCompletion()
}
} else {
printOS("loadAllPaths - Retry limit timed out. Unable to load all paths within 1 minute.")
completion()
}
for app in allApps {
dispatchGroup.enter()
DispatchQueue.global(qos: .background).async {
let pathFinder = AppPathFinder(appInfo: app, appState: appState, locations: locations, backgroundRun: true)
pathFinder.findPaths()
dispatchGroup.leave()
}
}
dispatchGroup.enter()
updateOnMain {
appState.appInfoStore.removeAll()
}
dispatchGroup.notify(queue: .main) {
checkCompletion()
}
DispatchQueue.global(qos: .background).async {
for app in allApps {
findPathsForApp(appInfo: app, appState: appState, locations: locations, backgroundRun: true)
_ = dispatchGroup.wait(timeout: .now() + 60)
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()
}
// Reset progress values to 0
appState.instantProgress = 0
appState.instantTotal = 0
completion()
} else if retryCount < maxRetry {
checkAllAppsProcessed(retryCount: retryCount + 1, maxRetry: maxRetry)
} else {
printOS("loadAllPaths - Retry limit reached. Not all paths were loaded.")
completion()
}
}
}
dispatchGroup.leave()
checkAllAppsProcessed()
}
}
// Load item in Files view
func showAppInFiles(appInfo: AppInfo, appState: AppState, locations: Locations, showPopover: Binding<Bool>) {
showPopover.wrappedValue = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
updateOnMain {
appState.appInfo = .empty
appState.selectedItems = []
if let storedAppInfo = appState.appInfoStore.first(where: { $0.path == appInfo.path }) {
appState.appInfo = storedAppInfo
appState.selectedItems = Set(storedAppInfo.files)
withAnimation(Animation.easeIn(duration: 0.4)) {
appState.currentView = .files
showPopover.wrappedValue.toggle()
}
} else {
// Handle the case where the appInfo is not found in the store
withAnimation(Animation.easeIn(duration: 0.4)) {
appState.showProgress = true
appState.currentView = .files
showPopover.wrappedValue.toggle()
}
appState.appInfo = appInfo
findPathsForApp(appInfo: appInfo, appState: appState, locations: locations) {
withAnimation(Animation.easeIn(duration: 0.4)) {
updateOnMain {
appState.showProgress = false
}
}
}
updateOnMain {
appState.appInfo = .empty
appState.selectedItems = []
// Check if the appInfo exists in the appState.appInfoStore
if let storedAppInfo = appState.appInfoStore.first(where: { $0.path == appInfo.path }) {
// Update appState with the stored app info and selected items.
appState.appInfo = storedAppInfo
appState.selectedItems = Set(storedAppInfo.files)
// Trigger the animation for changing views and showing the popover.
withAnimation(Animation.easeIn(duration: 0.4)) {
appState.currentView = .files
showPopover.wrappedValue.toggle()
}
}
}
}
// Get group containers
func getGroupContainers(bundleURL: URL) -> [URL] {
let fileManager = FileManager.default
var staticCode: SecStaticCode?
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, [], &staticCode) == errSecSuccess else {
return []
}
var signingInformation: CFDictionary?
let status = SecCodeCopySigningInformation(staticCode!, SecCSFlags(), &signingInformation)
if status != errSecSuccess {
printOS("Failed to copy signing information. Status: \(status)")
return []
}
guard let topDict = signingInformation as? [String: Any],
let entitlementsDict = topDict["entitlements-dict"] as? [String: Any],
let appGroups = entitlementsDict["com.apple.security.application-groups"] as? [String] else {
// printOS("No application groups to extract from entitlements for this app.")
return []
}
let groupContainersPath = appGroups.map { URL(fileURLWithPath: "\(home)/Library/Group Containers/" + $0) }
let existingGroupContainers = groupContainersPath.filter { fileManager.fileExists(atPath: $0.path) }
return existingGroupContainers
}
// Reverse search for leftover zombie files
func reversePathsSearch(appState: AppState, locations: Locations, completion: @escaping () -> Void = {}) {
Task(priority: .high) {
var collection: [URL] = []
let fileManager = FileManager.default
let dispatchGroup = DispatchGroup()
let allPaths = appState.appInfoStore.flatMap { $0.files.map { $0.path.pearFormat() } }
let allNames = appState.appInfoStore.map { $0.appName.pearFormat() }
let 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"] // Skip system folders
// Skip locations that might not exist
for location in locations.reverse.paths {
if !fileManager.fileExists(atPath: location) {
continue
}
dispatchGroup.enter()
do {
let contents = try fileManager.contentsOfDirectory(atPath: location)
for item in contents {
let itemURL = URL(fileURLWithPath: location).appendingPathComponent(item)
let itemName = ("\(item)").pearFormat()
let itemPath = "\(location)/\(itemName)".pearFormat()
if skipped.contains(where: { itemName.contains($0) }) {
continue
}
if allPaths.contains(where: { $0 == itemPath }) || allNames.contains(where: { $0 == itemName }) {
continue
}
if !isSupportedFileType(at: itemURL.path) {
continue
}
collection.append(itemURL)
}
} catch {
printOS("Error processing location:", location, error)
continue
}
dispatchGroup.leave() // Leave the dispatch group
}
// Calculate file details (sizes and icons)
var fileSize: [URL: Int64] = [:]
var fileIcon: [URL: NSImage?] = [:]
var updatedZombieFile = ZombieFile.empty
for path in collection {
var size: Int64
var icon: NSImage? = nil
size = totalSizeOnDisk(for: path)
icon = getIconForFileOrFolderNS(atPath: path)
fileSize[path] = size
fileIcon[path] = icon
}
// Save to appState
dispatchGroup.notify(queue: .main) {
} else {
// When the appInfo is not found, show progress, and search for paths.
appState.showProgress = true
// Initialize the path finder and execute its search.
let pathFinder = AppPathFinder(appInfo: appInfo, appState: appState, locations: locations) {
updateOnMain {
updatedZombieFile.fileSize = fileSize
updatedZombieFile.fileIcon = fileIcon
appState.zombieFile = updatedZombieFile
// Update the progress indicator on the main thread once the search completes.
appState.showProgress = false
}
}
pathFinder.findPaths()
appState.appInfo = appInfo
completion()
// Animate the view change and popover display.
withAnimation(Animation.easeIn(duration: 0.4)) {
appState.currentView = .files
showPopover.wrappedValue.toggle()
}
}
}
}
// Move files to trash using applescript/Finder so it asks for user password if needed
func moveFilesToTrash(at fileURLs: [URL], completion: @escaping () -> Void = {}) {
@AppStorage("settings.sentinel.enable") var sentinel: Bool = false
@@ -658,37 +234,6 @@ func moveFilesToTrash(at fileURLs: [URL], completion: @escaping () -> Void = {})
}
func isSocketFile(at url: URL) -> Bool {
do {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
if let fileType = attributes[FileAttributeKey.type] as? FileAttributeType {
return fileType == .typeSocket
}
} catch {
print("Error: \(error)")
}
return false
}
func isSupportedFileType(at path: String) -> Bool {
let fileManager = FileManager.default
do {
let attributes = try fileManager.attributesOfItem(atPath: path)
if let fileType = attributes[FileAttributeKey.type] as? FileAttributeType {
switch fileType {
case .typeRegular, .typeDirectory, .typeSymbolicLink:
// The file is a regular file, directory, or symbolic link
return true
default:
// The file is a socket, pipe, or another type not supported
return false
}
}
} catch {
print("Error getting file attributes: \(error)")
}
return false
}
// Undo trash action
+90
View File
@@ -0,0 +1,90 @@
//
// ReversePathsFetch.swift
// Pearcleaner
//
// Created by Alin Lupascu on 3/20/24.
//
import Foundation
import AppKit
class ReversePathsSearcher {
private let appState: AppState
private let locations: Locations
private let fileManager = FileManager.default
private var collection: [URL] = []
private var fileSize: [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()
self.calculateFileDetails()
self.updateAppState()
completion()
}
}
private func processLocations() {
let allPaths = appState.appInfoStore.flatMap { $0.files.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
let itemURL = URL(fileURLWithPath: location).appendingPathComponent(itemName)
processItem(itemName, itemURL: itemURL, allPaths: allPaths, allNames: allNames)
}
} catch {
printOS("Error processing location: \(location), error: \(error)")
}
}
private func processItem(_ itemName: String, itemURL: URL, allPaths: [String], allNames: [String]) {
let formattedItemName = itemName.pearFormat()
let itemPath = itemURL.path.pearFormat()
guard !skipped.contains(where: { formattedItemName.contains($0) }),
!allPaths.contains(itemPath),
!allNames.contains(formattedItemName),
isSupportedFileType(at: itemURL.path) else {
return
}
collection.append(itemURL)
}
private func calculateFileDetails() {
collection.forEach { path in
fileSize[path] = totalSizeOnDisk(for: path)
fileIcon[path] = getIconForFileOrFolderNS(atPath: path)
}
}
private func updateAppState() {
dispatchGroup.notify(queue: .main) {
var updatedZombieFile = ZombieFile.empty
updatedZombieFile.fileSize = self.fileSize
updatedZombieFile.fileIcon = self.fileIcon
self.appState.zombieFile = updatedZombieFile
self.appState.showProgress = false
}
}
}
+48 -5
View File
@@ -308,6 +308,7 @@ struct AnimatedSearchStyle: TextFieldStyle {
@FocusState private var isFocused: Bool
@Binding var text: String
@EnvironmentObject var appState: AppState
@EnvironmentObject var fsm: FolderSettingsManager
func _body(configuration: TextField<Self._Label>) -> some View {
@@ -332,7 +333,7 @@ struct AnimatedSearchStyle: TextFieldStyle {
withAnimation {
// Refresh Apps list
appState.reload.toggle()
let sortedApps = getSortedApps()
let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
appState.sortedApps = sortedApps
appState.reload.toggle()
@@ -435,13 +436,55 @@ struct SimpleSearchStyle: TextFieldStyle {
}
}
.disabled(appState.instantProgress != 0 && appState.instantProgress != appState.instantTotal)
.padding(6)
.overlay(
Group {
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color("mode").opacity(0.2), lineWidth: 1)
.allowsHitTesting(false)
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.gray.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
+103 -81
View File
@@ -312,7 +312,7 @@ func startEnd(_ function: @escaping () -> Void) {
function()
let endTime = Date()
let executionTime = endTime.timeIntervalSince(startTime)
print("Function executed in: \n\(executionTime) seconds")
printOS("Function executed in: \n\(executionTime) seconds")
}
@@ -327,6 +327,17 @@ func relaunchApp(afterDelay seconds: TimeInterval = 0.5) -> Never {
exit(0)
}
// Check if app is running before deleting app files
func killApp(appId: String, completion: @escaping () -> Void = {}) {
let runningApps = NSWorkspace.shared.runningApplications
for app in runningApps {
if app.bundleIdentifier == appId {
app.terminate()
}
}
completion()
}
// Remove app from cache
func removeApp(appState: AppState, withId id: UUID) {
DispatchQueue.main.async {
@@ -379,41 +390,80 @@ extension FileManager {
// --- Extend print command to also output to the Console ---
func printOS(_ items: Any..., separator: String = " ", terminator: String = "\n") {
let message = items.map { "\($0)" }.joined(separator: separator)
let log = OSLog(subsystem: "com.alienator88.Pearcleaner", category: "Application")
os_log("%@", log: log, type: .debug, message)
let log = OSLog(subsystem: "pearcleaner", category: "Application")
os_log("%@", log: log, type: .default, message)
}
//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 {
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
let fileManager = FileManager.default
var totalSize: Int64 = 0
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
for url in paths {
var isDirectory: ObjCBool = false
if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) {
if isDirectory.boolValue {
// It's a directory, recurse into it
if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], 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)
}
} catch {
printOS("Error getting file size for \(fileURL): \(error)")
}
}
}
} else {
// It's a file
do {
let fileAttributes = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
if let fileSize = fileAttributes.totalFileAllocatedSize {
totalSize += Int64(fileSize)
}
} catch {
printOS("Error getting file size for \(url): \(error)")
}
}
}
}
return Int64(totalSize) * 1024 // Convert the size from kilobytes to bytes
return totalSize
}
func totalSizeOnDisk(for path: URL) -> Int64 {
@@ -428,53 +478,25 @@ func formatByte(size: Int64) -> String {
return byteCountFormatter.string(fromByteCount: size)
}
// Get total size of folders and files using straight swift
extension URL {
func totalAllocatedSize(includingSubfolders: Bool = false) throws -> Int? {
return try [self].totalAllocatedSize(includingSubfolders: includingSubfolders)
}
func totalSizeOnDisk(includingSubfolders: Bool = false) throws -> String? {
return try [self].totalSizeOnDisk(includingSubfolders: includingSubfolders)
}
}
extension Array where Element == URL {
func totalAllocatedSize(includingSubfolders: Bool = false) throws -> Int? {
var totalSize = 0
for path in self {
let resourceValues = try path.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey, .totalFileAllocatedSizeKey])
if resourceValues.isDirectory == true || path.pathExtension == "app" {
if includingSubfolders {
let filePaths = FileManager.default.subpaths(atPath: path.path) ?? []
for filePath in filePaths {
let fileUrl = path.appendingPathComponent(filePath)
let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileUrl.path)
let fileSize = fileAttributes[.size] as? Int64 ?? 0
totalSize += Int(fileSize)
}
}
} else {
totalSize += resourceValues.totalFileAllocatedSize ?? 0
}
// Only process supported files
func isSupportedFileType(at path: String) -> Bool {
let fileManager = FileManager.default
do {
let attributes = try fileManager.attributesOfItem(atPath: path)
if let fileType = attributes[FileAttributeKey.type] as? FileAttributeType {
switch fileType {
case .typeRegular, .typeDirectory, .typeSymbolicLink:
// The file is a regular file, directory, or symbolic link
return true
default:
// The file is a socket, pipe, or another type not supported
return false
}
}
return totalSize
}
func totalSizeOnDisk(includingSubfolders: Bool = false) throws -> String? {
if let totalSize = try self.totalAllocatedSize(includingSubfolders: includingSubfolders) {
let byteCountFormatter = ByteCountFormatter()
byteCountFormatter.countStyle = .file
byteCountFormatter.allowedUnits = [.useBytes, .useKB, .useMB, .useTB]
return byteCountFormatter.string(fromByteCount: Int64(totalSize))
}
return nil
} catch {
printOS("Error getting file attributes: \(error)")
}
return false
}
@@ -518,11 +540,13 @@ func uninstallPearcleaner(appState: AppState, locations: Locations) {
launchctl(load: false)
// Get app info for Pearcleaner
let appInfo = getAppInfo(atPath: Bundle.main.bundleURL)
let appInfo = AppInfoFetcher.getAppInfo(atPath: Bundle.main.bundleURL)
// appState.appInfo = appInfo!
// Find application files for Pearcleaner
findPathsForApp(appInfo: appInfo!,appState: appState, locations: locations)
let pathFinder = AppPathFinder(appInfo: appInfo!, appState: appState, locations: locations)
pathFinder.findPaths()
// findPathsForApp(appInfo: appInfo!,appState: appState, locations: locations)
// Kill Pearcleaner and tell Finder to trash the files
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
@@ -587,34 +611,32 @@ func writeLog(string: String) {
// --- Load Plist file with launchctl ---
func launchctl(load: Bool, completion: @escaping () -> Void = {}) {
let cmd = load ? "load" : "unload"
if let plistPath = Bundle.main.path(forResource: "com.alienator88.PearcleanerSentinel", ofType: "plist") {
var plistContent = try! String(contentsOfFile: plistPath)
let executableURL = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/PearcleanerSentinel")
// Replace the placeholder with the actual executable path
plistContent = plistContent.replacingOccurrences(of: "__EXECUTABLE_PATH__", with: executableURL.path)
// Create a temporary plist file with the updated content
let temporaryPlistURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("com.alienator88.PearcleanerSentinel.plist")
do {
try plistContent.write(to: temporaryPlistURL, atomically: false, encoding: .utf8)
try plistContent.write(to: temporaryPlistURL, atomically: true, encoding: .utf8)
} catch {
printOS("Error writing the temporary plist file: \(error)")
return
}
let task = Process()
task.launchPath = "/bin/launchctl"
task.arguments = [cmd, "-w", temporaryPlistURL.path]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
task.launch()
completion()
}
}
+8 -4
View File
@@ -14,6 +14,7 @@ struct PearcleanerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject var appState = AppState()
@StateObject var locations = Locations()
@StateObject var fsm = FolderSettingsManager()
@State private var windowSettings = WindowSettings()
@AppStorage("settings.updater.updateTimeframe") private var updateTimeframe: Int = 1
@AppStorage("settings.permissions.disk") private var diskP: Bool = false
@@ -57,6 +58,7 @@ struct PearcleanerApp: App {
}
.environmentObject(appState)
.environmentObject(locations)
.environmentObject(fsm)
.preferredColorScheme(displayMode.colorScheme)
.handlesExternalEvents(preferring: Set(arrayLiteral: "pear"), allowing: Set(arrayLiteral: "*"))
.onOpenURL(perform: { url in
@@ -96,25 +98,26 @@ struct PearcleanerApp: App {
NSApplication.shared.windows.first?.setFrame(frame, display: true)
// Get Apps
let sortedApps = getSortedApps()
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)
}
if menubarEnabled {
MenuBarExtraManager.shared.addMenuBarExtra(withView: {
MenuBarMiniAppView(search: $search, showPopover: $showPopover)
.environmentObject(locations)
.environmentObject(appState)
.environmentObject(fsm)
.preferredColorScheme(displayMode.colorScheme)
}, icon: selectedMenubarIcon)
}
#if !DEBUG
Task {
@@ -153,7 +156,7 @@ struct PearcleanerApp: App {
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentMinSize)
.commands {
AppCommands(appState: appState, locations: locations)
AppCommands(appState: appState, locations: locations, fsm: fsm)
CommandGroup(replacing: .newItem, addition: { })
}
@@ -165,6 +168,7 @@ struct PearcleanerApp: App {
SettingsView(showPopover: $showPopover, search: $search, showFeature: $showFeature)
.environmentObject(appState)
.environmentObject(locations)
.environmentObject(fsm)
.toolbarBackground(.clear)
.preferredColorScheme(displayMode.colorScheme)
}
+202
View File
@@ -0,0 +1,202 @@
//
// Folders.swift
// Pearcleaner
//
// Created by Alin Lupascu on 3/20/24.
//
//
// General.swift
// Pearcleaner
//
// Created by Alin Lupascu on 11/5/23.
//
import Foundation
import SwiftUI
import AppKit
struct FolderSettingsTab: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var locations: Locations
@EnvironmentObject var fsm: FolderSettingsManager
@State private var isHovered = false
@State private var isHoveredPlus = false
var body: some View {
Form {
VStack {
HStack(spacing: 0) {
Text("Apps").font(.title2)
InfoButton(text: "Locations that will be searched for .app files. Click a non-default path to remove it. Add new folders below or drag/drop a folder over the list.", color: nil, label: "")
Spacer()
}
ScrollView {
VStack(spacing: 5) {
ForEach(fsm.folderPaths.indices, id: \.self) { index in
HStack {
Text(fsm.folderPaths[index])
.font(.callout)
.opacity(fsm.defaultPaths.contains(fsm.folderPaths[index]) ? 0.5 : 1)
.padding(5)
Spacer()
}
.disabled(fsm.defaultPaths.contains(fsm.folderPaths[index]))
.onHover { hovering in
withAnimation(Animation.easeInOut(duration: 0.4)) {
isHovered = hovering
}
if isHovered && !fsm.defaultPaths.contains(fsm.folderPaths[index]) {
NSCursor.disappearingItem.push()
} else {
NSCursor.pop()
}
}
.onTapGesture {
if !fsm.defaultPaths.contains(fsm.folderPaths[index]) {
fsm.removePath(at: index)
}
}
if index != fsm.folderPaths.indices.last {
Divider().opacity(0.5)
}
}
}
}
.scrollIndicators(.automatic)
.padding()
.background(Color("mode").opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 10))
.onDrop(of: ["public.file-url"], isTargeted: nil) { providers -> Bool in
providers.forEach { provider in
provider.loadDataRepresentation(forTypeIdentifier: "public.file-url") { (data, error) in
guard let data = data, error == nil,
let url = URL(dataRepresentation: data, relativeTo: nil),
url.hasDirectoryPath else {
printOS("FSM: Failed to load URL or the item is not a folder")
return
}
updateOnMain {
fsm.addPath(url.path)
}
}
}
return true
}
// === OTHER ================================================================================================
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color("mode").opacity(0.1))
// .strokeBorder(Color("mode").opacity(0.1), lineWidth: 2)
.frame(width: 300, height: 100)
Image(systemName: "plus")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.foregroundStyle(isHoveredPlus ? Color("mode") : Color("mode").opacity(0.5))
}
.padding(.top)
.onTapGesture {
selectFolder()
}
.onHover { hovering in
withAnimation(Animation.easeInOut(duration: 0.4)) {
isHoveredPlus = hovering
}
if isHoveredPlus {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
Spacer()
}
}
.padding(20)
.frame(width: 500, height: 420)
}
private func selectFolder() {
let dialog = NSOpenPanel()
dialog.title = "Choose a folder"
dialog.showsResizeIndicator = false
dialog.showsHiddenFiles = false
dialog.canChooseDirectories = true
dialog.canCreateDirectories = true
dialog.canChooseFiles = false
if dialog.runModal() == NSApplication.ModalResponse.OK {
if let result = dialog.url {
fsm.addPath(result.path)
}
} else {
return
}
}
}
class FolderSettingsManager: ObservableObject {
@Published var folderPaths: [String] = []
private let userDefaultsKey = "settings.folders.apps"
let defaultPaths = ["/Applications", "\(NSHomeDirectory())/Applications"]
init() {
loadDefaultPathsIfNeeded()
}
private func loadDefaultPathsIfNeeded() {
var paths = UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? defaultPaths
if paths.count < 2 {
paths = defaultPaths
}
UserDefaults.standard.set(paths, forKey: userDefaultsKey)
self.folderPaths = paths
}
func addPath(_ path: String) {
if !self.folderPaths.contains(path) {
self.folderPaths.append(path)
UserDefaults.standard.set(self.folderPaths, forKey: userDefaultsKey)
}
}
func removePath(at index: Int) {
guard self.folderPaths.indices.contains(index) else { return }
self.folderPaths.remove(at: index) // Update local state
UserDefaults.standard.set(self.folderPaths, forKey: userDefaultsKey)
}
func refreshPaths() {
self.folderPaths = UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? defaultPaths
}
func getPaths() -> [String] {
return UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? defaultPaths
}
}
+12 -2
View File
@@ -13,6 +13,7 @@ import ServiceManagement
struct InterfaceSettingsTab: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var locations: Locations
@EnvironmentObject var fsm: FolderSettingsManager
@State private var windowSettings = WindowSettings()
@AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false
@AppStorage("settings.general.mini") private var mini: Bool = false
@@ -151,9 +152,12 @@ struct InterfaceSettingsTab: View {
MiniMode(search: $search, showPopover: $showPopover)
.environmentObject(locations)
.environmentObject(appState)
.environmentObject(fsm)
.preferredColorScheme(displayMode.colorScheme)
}
resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner")
updateOnMain(after: 0.1, {
resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner")
})
} else {
if appState.appInfo.appName.isEmpty {
appState.currentView = .empty
@@ -164,9 +168,12 @@ struct InterfaceSettingsTab: View {
RegularMode(search: $search, showPopover: $showPopover)
.environmentObject(locations)
.environmentObject(appState)
.environmentObject(fsm)
.preferredColorScheme(displayMode.colorScheme)
}
resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner")
updateOnMain(after: 0.1, {
resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner")
})
}
@@ -262,6 +269,7 @@ struct InterfaceSettingsTab: View {
MenuBarMiniAppView(search: $search, showPopover: $showPopover)
.environmentObject(locations)
.environmentObject(appState)
.environmentObject(fsm)
.preferredColorScheme(displayMode.colorScheme)
}, icon: selectedMenubarIcon)
NSApplication.shared.setActivationPolicy(.accessory)
@@ -276,6 +284,7 @@ struct InterfaceSettingsTab: View {
MiniMode(search: $search, showPopover: $showPopover)
.environmentObject(locations)
.environmentObject(appState)
.environmentObject(fsm)
.preferredColorScheme(displayMode.colorScheme)
}
resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner")
@@ -284,6 +293,7 @@ struct InterfaceSettingsTab: View {
RegularMode(search: $search, showPopover: $showPopover)
.environmentObject(locations)
.environmentObject(appState)
.environmentObject(fsm)
.preferredColorScheme(displayMode.colorScheme)
}
resizeWindowAuto(windowSettings: windowSettings, title: "Pearcleaner")
@@ -9,6 +9,7 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var fsm: FolderSettingsManager
@Binding var showPopover: Bool
@Binding var search: String
@Binding var showFeature: Bool
@@ -29,6 +30,12 @@ struct SettingsView: View {
}
.tag(CurrentTabView.interface)
FolderSettingsTab()
.tabItem {
Label(CurrentTabView.folders.title, systemImage: "folder")
}
.tag(CurrentTabView.interface)
UpdateSettingsTab(showFeature: $showFeature)
.tabItem {
Label(CurrentTabView.update.title, systemImage: "cloud")
+37 -19
View File
@@ -22,7 +22,9 @@ struct FilesView: View {
@Binding var search: String
var regularWin: Bool
@State private var selectedOption = "Default"
@State private var elapsedTime = 0
@State private var timer: Timer? = nil
var body: some View {
VStack(alignment: .center) {
if appState.showProgress {
@@ -30,14 +32,33 @@ struct FilesView: View {
Spacer()
Text("Searching the file system").font(.title3)
.foregroundStyle((.gray.opacity(0.8)))
ProgressView()
.progressViewStyle(.linear)
.frame(width: 400, height: 10)
HStack {
ProgressView()
.progressViewStyle(.linear)
.frame(width: 400, height: 10)
Image(systemName: "\(elapsedTime).circle")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundStyle((.gray.opacity(0.8)))
.opacity(elapsedTime == 0 ? 0 : 1)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.opacity)
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.elapsedTime += 1
}
}
.onDisappear {
self.timer?.invalidate()
self.timer = nil
self.elapsedTime = 0
}
} else {
// Titlebar
if !regularWin {
@@ -69,8 +90,14 @@ struct FilesView: View {
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.trailing)
// .clipShape(RoundedRectangle(cornerRadius: 8))
// .padding(.trailing)
.padding()
.background{
RoundedRectangle(cornerRadius: 16)
.fill(Color((appState.appInfo.appIcon?.averageColor)!))
}
}
VStack(alignment: .leading, spacing: 5){
@@ -86,6 +113,7 @@ struct FilesView: View {
Text("\(appState.appInfo.bundleIdentifier)").font(.title3)
.foregroundStyle((.gray.opacity(0.8)))
}
.padding(.leading)
Spacer()
@@ -97,6 +125,7 @@ struct FilesView: View {
}
HStack(alignment: .center, spacing: 10) {
Spacer()
@@ -157,7 +186,7 @@ struct FilesView: View {
VStack {
FileDetailsItem(size: fileSize, icon: iconImage, path: path)
if path != appState.appInfo.files.last {
Divider().padding(.leading, 40)
Divider().padding(.leading, 40).opacity(0.5)
}
}
}
@@ -268,17 +297,6 @@ struct FilesView: View {
}
}
func refreshAppList(_ appInfo: AppInfo) {
showPopover = false
let sortedApps = getSortedApps()
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
appState.sortedApps = sortedApps
// appState.sortedApps.systemApps = sortedApps.systemApps
// if instantSearch {
// loadAllPaths(allApps: sortedApps.userApps + sortedApps.systemApps, appState: appState, locations: locations)
// }
}
}
}
@@ -321,7 +339,7 @@ struct FileDetailsItem: View {
.help(path.lastPathComponent)
Text(path.path)
.font(.footnote)
.lineLimit(2)
.lineLimit(1)
.truncationMode(.tail)
.opacity(0.5)
.help(path.path)
+3 -1
View File
@@ -70,7 +70,9 @@ struct MenuBarMiniAppView: View {
appState.showProgress.toggle()
showPopover.toggle()
if instantSearch {
reversePathsSearch(appState: appState, locations: locations)
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)
}
+1
View File
@@ -32,6 +32,7 @@ struct MiniMode: View {
} else {
TopBarMini(search: $search, showPopover: $showPopover)
MiniAppView(search: $search, showPopover: $showPopover)
}
}
.transition(.opacity)
+2 -1
View File
@@ -178,6 +178,7 @@ struct Header: View {
@State private var hovered = false
@EnvironmentObject var appState: AppState
@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
@@ -199,7 +200,7 @@ struct Header: View {
// Refresh Apps list
appState.reload.toggle()
showPopover = false
let sortedApps = getSortedApps()
let sortedApps = getSortedApps(paths: fsm.folderPaths, appState: appState)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
appState.sortedApps = sortedApps
if instantSearch {
+22 -1
View File
@@ -42,6 +42,23 @@ struct TopBar: View {
HStack() {
Spacer()
if appState.currentView == .zombie {
Button("Rescan") {
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)
}
}
}
.buttonStyle(NavButtonBottomBarStyle(image: "arrow.counterclockwise.circle.fill", help: "Rescan files"))
}
if appState.currentView != .zombie {
Button("") {
withAnimation(.easeInOut(duration: 0.5)) {
@@ -53,7 +70,9 @@ struct TopBar: View {
appState.showProgress.toggle()
showPopover.toggle()
if instantSearch {
reversePathsSearch(appState: appState, locations: locations)
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)
}
@@ -81,6 +100,8 @@ struct TopBar: View {
}
}
+7 -5
View File
@@ -37,7 +37,9 @@ struct TopBarMini: View {
appState.showProgress.toggle()
showPopover.toggle()
if instantSearch {
reversePathsSearch(appState: appState, locations: locations)
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)
}
@@ -78,7 +80,9 @@ struct TopBarMini: View {
appState.showProgress.toggle()
showPopover.toggle()
if instantSearch {
reversePathsSearch(appState: appState, locations: locations)
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)
}
@@ -130,10 +134,8 @@ struct SearchBarMiniBottom: View {
var body: some View {
HStack {
TextField(" Search", text: $search)
TextField("\(appState.instantTotal > appState.instantProgress ? " Searching the file system" : " Search")", text: $search)
.textFieldStyle(SimpleSearchStyle(trash: true, text: $search))
}
// .padding(.horizontal)
// .padding(.bottom, 0)
}
}
+31 -6
View File
@@ -22,6 +22,8 @@ struct ZombieView: View {
@State private var searchZ: String = ""
@State private var selectedOption = "Default"
var regularWin: Bool
@State private var elapsedTime = 0
@State private var timer: Timer? = nil
var body: some View {
@@ -49,9 +51,19 @@ struct ZombieView: View {
Text("Searching the file system").font(.title3)
.foregroundStyle((.gray.opacity(0.8)))
ProgressView()
.progressViewStyle(.linear)
.frame(width: 400, height: 10)
HStack {
ProgressView()
.progressViewStyle(.linear)
.frame(width: 400, height: 10)
Image(systemName: "\(elapsedTime).circle")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundStyle((.gray.opacity(0.8)))
.opacity(elapsedTime == 0 ? 0 : 1)
}
Spacer()
}
@@ -59,6 +71,16 @@ struct ZombieView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.elapsedTime += 1
}
}
.onDisappear {
self.timer?.invalidate()
self.timer = nil
self.elapsedTime = 0
}
} else {
// Titlebar
if !regularWin {
@@ -70,7 +92,9 @@ struct ZombieView: View {
appState.zombieFile = .empty
appState.showProgress.toggle()
if instantSearch {
reversePathsSearch(appState: appState, locations: locations)
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)
}
@@ -131,6 +155,7 @@ struct ZombieView: View {
}
SearchBarMiniBottom(search: $searchZ)
.padding(.top)
Divider()
.padding()
@@ -146,7 +171,7 @@ struct ZombieView: View {
.padding(.trailing)
if file != appState.zombieFile.fileSize.keys.sorted(by: { $0.absoluteString < $1.absoluteString }).last {
Divider().padding(.leading, 40)
Divider().padding(.leading, 40).opacity(0.5)
}
}
}
@@ -271,7 +296,7 @@ struct ZombieFileDetailsItem: View {
.help(path.lastPathComponent)
Text(path.path)
.font(.footnote)
.lineLimit(2)
.lineLimit(1)
.truncationMode(.tail)
.opacity(0.5)
// .foregroundStyle(Color("AccentColor"))