mirror of
https://github.com/exituser/Pearcleaner.git
synced 2026-09-17 09:38:51 +00:00
v3.3.0
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
//}
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ struct MiniMode: View {
|
||||
} else {
|
||||
TopBarMini(search: $search, showPopover: $showPopover)
|
||||
MiniAppView(search: $search, showPopover: $showPopover)
|
||||
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user