This commit is contained in:
Alin
2024-10-04 11:56:01 -06:00
parent fbcd12d35f
commit bebdce59b8
19 changed files with 666 additions and 275 deletions
+1 -3
View File
@@ -4,9 +4,7 @@
<dict> <dict>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.files.user-selected.read-write</key> <key>com.apple.security.temporary-exception.files.absolute-path.read-only</key>
<true/>
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
<array> <array>
<string>/</string> <string>/</string>
</array> </array>
+5
View File
@@ -42,6 +42,7 @@
C7DD49EE2BAB7F6000CCBA16 /* ReversePathsFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DD49ED2BAB7F6000CCBA16 /* ReversePathsFetch.swift */; }; C7DD49EE2BAB7F6000CCBA16 /* ReversePathsFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DD49ED2BAB7F6000CCBA16 /* ReversePathsFetch.swift */; };
C7DD49F02BABA14400CCBA16 /* Folders.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DD49EF2BABA14400CCBA16 /* Folders.swift */; }; C7DD49F02BABA14400CCBA16 /* Folders.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DD49EF2BABA14400CCBA16 /* Folders.swift */; };
C7DE672B2BA6343D00EB1633 /* MenuBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DE672A2BA6343D00EB1633 /* MenuBarItem.swift */; }; C7DE672B2BA6343D00EB1633 /* MenuBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DE672A2BA6343D00EB1633 /* MenuBarItem.swift */; };
C7DFBDE72CAF5AD30013C825 /* CustomTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DFBDE62CAF5ACB0013C825 /* CustomTabView.swift */; };
C7EA06332C1CBE2000F872FC /* ConditionBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EA06322C1CBE2000F872FC /* ConditionBuilderView.swift */; }; C7EA06332C1CBE2000F872FC /* ConditionBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EA06322C1CBE2000F872FC /* ConditionBuilderView.swift */; };
C7ED86B12BA8A35500F13DE4 /* Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7ED86B02BA8A35500F13DE4 /* Interface.swift */; }; C7ED86B12BA8A35500F13DE4 /* Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7ED86B02BA8A35500F13DE4 /* Interface.swift */; };
C7F539382AF60865007DF1B2 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F539372AF60865007DF1B2 /* Utilities.swift */; }; C7F539382AF60865007DF1B2 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F539372AF60865007DF1B2 /* Utilities.swift */; };
@@ -132,6 +133,7 @@
C7DD49ED2BAB7F6000CCBA16 /* ReversePathsFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReversePathsFetch.swift; sourceTree = "<group>"; }; C7DD49ED2BAB7F6000CCBA16 /* ReversePathsFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReversePathsFetch.swift; sourceTree = "<group>"; };
C7DD49EF2BABA14400CCBA16 /* Folders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folders.swift; sourceTree = "<group>"; }; C7DD49EF2BABA14400CCBA16 /* Folders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folders.swift; sourceTree = "<group>"; };
C7DE672A2BA6343D00EB1633 /* MenuBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarItem.swift; sourceTree = "<group>"; }; C7DE672A2BA6343D00EB1633 /* MenuBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarItem.swift; sourceTree = "<group>"; };
C7DFBDE62CAF5ACB0013C825 /* CustomTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTabView.swift; sourceTree = "<group>"; };
C7EA06322C1CBE2000F872FC /* ConditionBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionBuilderView.swift; sourceTree = "<group>"; }; C7EA06322C1CBE2000F872FC /* ConditionBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionBuilderView.swift; sourceTree = "<group>"; };
C7ED86B02BA8A35500F13DE4 /* Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interface.swift; sourceTree = "<group>"; }; C7ED86B02BA8A35500F13DE4 /* Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interface.swift; sourceTree = "<group>"; };
C7F539372AF60865007DF1B2 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = "<group>"; }; C7F539372AF60865007DF1B2 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = "<group>"; };
@@ -266,6 +268,7 @@
C77B901F2AF1B390009CC655 /* Views */ = { C77B901F2AF1B390009CC655 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C7DFBDE62CAF5ACB0013C825 /* CustomTabView.swift */,
C77B90222AF2D616009CC655 /* FilesView.swift */, C77B90222AF2D616009CC655 /* FilesView.swift */,
C71848432B8D2D600046CB13 /* ZombieView.swift */, C71848432B8D2D600046CB13 /* ZombieView.swift */,
C76D08542AF89CDE00D07867 /* RegularMode.swift */, C76D08542AF89CDE00D07867 /* RegularMode.swift */,
@@ -435,6 +438,7 @@
C764680F2CA7790200DC72DF /* PearGroupBox.swift in Sources */, C764680F2CA7790200DC72DF /* PearGroupBox.swift in Sources */,
C79947962C34A794007279CC /* Tips.swift in Sources */, C79947962C34A794007279CC /* Tips.swift in Sources */,
C76D08482AF83C3F00D07867 /* Update.swift in Sources */, C76D08482AF83C3F00D07867 /* Update.swift in Sources */,
C7DFBDE72CAF5AD30013C825 /* CustomTabView.swift in Sources */,
C7FB173B2B96321300B96F9A /* AppsListView.swift in Sources */, C7FB173B2B96321300B96F9A /* AppsListView.swift in Sources */,
C77B90162AF19377009CC655 /* AppState.swift in Sources */, C77B90162AF19377009CC655 /* AppState.swift in Sources */,
C7DD49EE2BAB7F6000CCBA16 /* ReversePathsFetch.swift in Sources */, C7DD49EE2BAB7F6000CCBA16 /* ReversePathsFetch.swift in Sources */,
@@ -791,6 +795,7 @@
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = BK8443AXLU; "DEVELOPMENT_TEAM[sdk=macosx*]" = BK8443AXLU;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GCC_OPTIMIZATION_LEVEL = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FinderOpen/Info.plist; INFOPLIST_FILE = FinderOpen/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = FinderOpen; INFOPLIST_KEY_CFBundleDisplayName = FinderOpen;
@@ -7,7 +7,7 @@
"location" : "https://github.com/alienator88/AlinFoundation", "location" : "https://github.com/alienator88/AlinFoundation",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "579f5dd4730f085ad0163442a6c81d1c5fb65e90" "revision" : "9e22796895c78f8ecce0b4d8c58917ec36f7c1b0"
} }
}, },
{ {
@@ -71,6 +71,13 @@
ReferencedContainer = "container:Pearcleaner.xcodeproj"> ReferencedContainer = "container:Pearcleaner.xcodeproj">
</BuildableReference> </BuildableReference>
</MacroExpansion> </MacroExpansion>
<AdditionalOptions>
<AdditionalOption
key = "APP_DISTRIBUTOR_ID_OVERRIDE"
value = "com.apple.AppStore"
isEnabled = "YES">
</AdditionalOption>
</AdditionalOptions>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
+9 -9
View File
@@ -37,15 +37,6 @@ struct AppCommands: Commands {
} }
.keyboardShortcut("u", modifiers: .command) .keyboardShortcut("u", modifiers: .command)
Button {
withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) {
reloadAppsList(appState: appState, fsm: fsm)
}
} label: {
Text("Refresh Apps")
}
.keyboardShortcut("r", modifiers: .command)
Button { Button {
appState.triggerUninstallAlert() appState.triggerUninstallAlert()
} label: { } label: {
@@ -93,6 +84,15 @@ struct AppCommands: Commands {
// Tools Menu // Tools Menu
CommandMenu(Text("Tools", comment: "Tools Menu")) { CommandMenu(Text("Tools", comment: "Tools Menu")) {
Button {
withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) {
reloadAppsList(appState: appState, fsm: fsm)
}
} label: {
Text("Refresh Apps")
}
.keyboardShortcut("r", modifiers: .command)
Button Button
{ {
if !appState.appInfo.bundleIdentifier.isEmpty { if !appState.appInfo.bundleIdentifier.isEmpty {
+190 -7
View File
@@ -9,10 +9,78 @@ import Foundation
import SwiftUI import SwiftUI
import AlinFoundation import AlinFoundation
// Metadata-based AppInfo Fetcher Class
class MetadataAppInfoFetcher {
static func getAppInfo(fromMetadata metadata: [String: Any], atPath path: URL) -> AppInfo? {
// Extract metadata attributes for known fields
let displayName = metadata["kMDItemDisplayName"] as? String ?? ""
let fsName = metadata["kMDItemFSName"] as? String ?? path.lastPathComponent
let appName = displayName.isEmpty ? fsName : displayName
let bundleIdentifier = metadata["kMDItemCFBundleIdentifier"] as? String ?? ""
let version = metadata["kMDItemVersion"] as? String ?? ""
// Sizes
let logicalSize = metadata["kMDItemLogicalSize"] as? Int64 ?? 0
let physicalSize = metadata["kMDItemPhysicalSize"] as? Int64 ?? 0
// Check if any of the critical fields are missing or invalid
if appName.isEmpty || bundleIdentifier.isEmpty || version.isEmpty || logicalSize == 0 || physicalSize == 0 {
// print("Metadata is missing critical fields for \(path). Falling back to AppInfoFetcher.")
// Fallback to the regular AppInfoFetcher for this app
return AppInfoFetcher.getAppInfo(atPath: path)
}
// Extract optional date fields
let creationDate = metadata["kMDItemFSCreationDate"] as? Date
let contentChangeDate = metadata["kMDItemFSContentChangeDate"] as? Date
let lastUsedDate = metadata["kMDItemLastUsedDate"] as? Date
// Determine architecture type
let arch = determineArchitecture(from: metadata)
// Use similar helper functions as `AppInfoFetcher` for attributes not found in metadata
let wrapped = AppInfoFetcher.isDirectoryWrapped(path: path)
let appIcon = AppInfoUtils.fetchAppIcon(for: path, wrapped: wrapped, md: true)
let webApp = AppInfoUtils.isWebApp(appPath: path)
let system = !path.path.contains(NSHomeDirectory())
return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName,
appVersion: version, appIcon: appIcon, webApp: webApp, wrapped: wrapped, system: system,
arch: arch, bundleSize: logicalSize, fileSize: [:],
fileSizeLogical: [:], fileIcon: [:], creationDate: creationDate, contentChangeDate: contentChangeDate, lastUsedDate: lastUsedDate)
}
/// Determine the architecture type based on metadata
private static func determineArchitecture(from metadata: [String: Any]) -> Arch {
guard let architectures = metadata["kMDItemExecutableArchitectures"] as? [String] else {
return .empty
}
// Check for ARM and Intel presence
let containsArm = architectures.contains("arm64")
let containsIntel = architectures.contains("x86_64")
// Determine the Arch type based on available architectures
if containsArm && containsIntel {
return .universal
} else if containsArm {
return .arm
} else if containsIntel {
return .intel
} else {
return .empty
}
}
}
class AppInfoFetcher { class AppInfoFetcher {
static let fileManager = FileManager.default static let fileManager = FileManager.default
static func getAppInfo(atPath path: URL, wrapped: Bool = false) -> AppInfo? { public static func getAppInfo(atPath path: URL, wrapped: Bool = false) -> AppInfo? {
if isDirectoryWrapped(path: path) { if isDirectoryWrapped(path: path) {
return handleWrappedDirectory(atPath: path) return handleWrappedDirectory(atPath: path)
} else { } else {
@@ -20,7 +88,7 @@ class AppInfoFetcher {
} }
} }
private static func isDirectoryWrapped(path: URL) -> Bool { public static func isDirectoryWrapped(path: URL) -> Bool {
let wrapperURL = path.appendingPathComponent("Wrapper") let wrapperURL = path.appendingPathComponent("Wrapper")
return fileManager.fileExists(atPath: wrapperURL.path) return fileManager.fileExists(atPath: wrapperURL.path)
} }
@@ -53,17 +121,36 @@ class AppInfoFetcher {
? bundle.infoDictionary?["CFBundleVersion"] as? String ?? "" ? bundle.infoDictionary?["CFBundleVersion"] as? String ?? ""
: bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" : bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let appIcon = fetchAppIcon(for: path, wrapped: wrapped) let appIcon = AppInfoUtils.fetchAppIcon(for: path, wrapped: wrapped)
let webApp = (bundle.infoDictionary?["LSTemplateApplication"] as? Bool ?? false || bundle.infoDictionary?["CFBundleExecutable"] as? String == "app_mode_loader") let webApp = AppInfoUtils.isWebApp(bundle: bundle)
let system = !path.path.contains(NSHomeDirectory()) let system = !path.path.contains(NSHomeDirectory())
return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName, appVersion: appVersion, appIcon: appIcon, return AppInfo(id: UUID(), path: path, bundleIdentifier: bundleIdentifier, appName: appName, appVersion: appVersion, appIcon: appIcon,
webApp: webApp, wrapped: wrapped, system: system, arch: .empty, bundleSize: 0, files: [], fileSize: [:], fileSizeLogical: [:], fileIcon: [:]) webApp: webApp, wrapped: wrapped, system: system, arch: .empty, bundleSize: 0, fileSize: [:], fileSizeLogical: [:], fileIcon: [:], creationDate: nil, contentChangeDate: nil, lastUsedDate: nil)
} }
private static func fetchAppIcon(for path: URL, wrapped: Bool) -> NSImage? { }
let iconPath = wrapped ? path.deletingLastPathComponent().deletingLastPathComponent() : path
class AppInfoUtils {
/// Determines if the app is a web application by directly reading its `Info.plist` using the app path.
static func isWebApp(appPath: URL) -> Bool {
guard let bundle = Bundle(url: appPath) else { return false }
return isWebApp(bundle: bundle)
}
/// Determines if the app is a web application based on its bundle.
static func isWebApp(bundle: Bundle?) -> Bool {
guard let infoDict = bundle?.infoDictionary else { return false }
return (infoDict["LSTemplateApplication"] as? Bool ?? false) ||
(infoDict["CFBundleExecutable"] as? String == "app_mode_loader")
}
/// Fetch app icon.
static func fetchAppIcon(for path: URL, wrapped: Bool, md: Bool = false) -> NSImage? {
let iconPath = wrapped ? (md ? path : path.deletingLastPathComponent().deletingLastPathComponent()) : path
if let appIcon = getIconForFileOrFolderNS(atPath: iconPath) { if let appIcon = getIconForFileOrFolderNS(atPath: iconPath) {
return convertICNSToPNG(icon: appIcon, size: NSSize(width: 100, height: 100)) return convertICNSToPNG(icon: appIcon, size: NSSize(width: 100, height: 100))
} else { } else {
@@ -71,5 +158,101 @@ class AppInfoFetcher {
return nil return nil
} }
} }
}
/// Executes `mdls` with `-plist -` and `-nullMarker ""` options and returns metadata in a structured dictionary.
func getMDLSMetadataAsPlist(for paths: [String]) -> [String: [String: Any]]? {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/mdls")
// Define the required metadata attributes to include in the output
let attributes = [
"kMDItemFSCreationDate",
"kMDItemFSContentChangeDate",
"kMDItemLastUsedDate",
"kMDItemDisplayName",
"kMDItemAppStoreCategory",
"kMDItemCFBundleIdentifier",
"kMDItemExecutableArchitectures",
"kMDItemFSName",
"kMDItemVersion",
"kMDItemLogicalSize",
"kMDItemPhysicalSize"
]
// Construct the `mdls` arguments: paths + -name for each attribute + -plist - + -nullMarker ""
var arguments = paths
for attribute in attributes {
arguments.append("-name")
arguments.append(attribute)
}
arguments.append("-plist") // Use plist format
arguments.append("-") // Output to stdout
arguments.append("-nullMarker") // Replace null attributes
arguments.append("") // Substitute null values with empty string
task.arguments = arguments
// Use Pipe to capture the output
let pipe = Pipe()
let errorPipe = Pipe()
task.standardOutput = pipe
task.standardError = errorPipe // Capture stderr in case there are errors
do {
// Run the task
try task.run()
// Read the data from the pipe
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
if let errorOutput = String(data: errorData, encoding: .utf8), !errorOutput.isEmpty {
print("Error Output from mdls:\n\(errorOutput)\n")
}
// Check if there's any output captured
if data.isEmpty {
print("No output captured from mdls.")
return nil
}
// Attempt to parse the plist output into a Swift array of dictionaries
guard let plistArray = try PropertyListSerialization.propertyList(from: data, format: nil) as? [[String: Any]] else {
print("Failed to parse plist output into expected format.")
return nil
}
// print("Parsed plist array count: \(plistArray.count)")
// Ensure the number of plist items matches the number of paths
if plistArray.count != paths.count {
print("Warning: Number of plist items (\(plistArray.count)) does not match the number of paths (\(paths.count)).")
}
var metadataDictionary = [String: [String: Any]]()
// Map each metadata dictionary to its corresponding path using indices
for (index, appMetadata) in plistArray.enumerated() {
// Match metadata to path using the index
if index < paths.count {
let path = paths[index]
metadataDictionary[path] = appMetadata
// print("Mapped metadata to path: \(path)")
} else {
print("Warning: More metadata entries than paths.")
}
}
return metadataDictionary
} catch {
print("Error running mdls: \(error)")
return nil
}
} }
+26 -5
View File
@@ -19,6 +19,7 @@ class AppState: ObservableObject {
@Published var sortedApps: [AppInfo] = [] @Published var sortedApps: [AppInfo] = []
@Published var selectedItems = Set<URL>() @Published var selectedItems = Set<URL>()
@Published var currentView = CurrentDetailsView.empty @Published var currentView = CurrentDetailsView.empty
@Published var currentPage = CurrentPage.installed
@Published var showAlert: Bool = false @Published var showAlert: Bool = false
@Published var sidebar: Bool = true @Published var sidebar: Bool = true
@Published var reload: Bool = false @Published var reload: Bool = false
@@ -67,10 +68,13 @@ class AppState: ObservableObject {
system: false, system: false,
arch: .empty, arch: .empty,
bundleSize: 0, bundleSize: 0,
files: [], // files: [],
fileSize: [:], fileSize: [:],
fileSizeLogical: [:], fileSizeLogical: [:],
fileIcon: [:] fileIcon: [:],
creationDate: nil,
contentChangeDate: nil,
lastUsedDate: nil
) )
self.zombieFile = ZombieFile( self.zombieFile = ZombieFile(
@@ -137,11 +141,14 @@ struct AppInfo: Identifiable, Equatable, Hashable {
let wrapped: Bool let wrapped: Bool
let system: Bool let system: Bool
var arch: Arch var arch: Arch
var bundleSize: Int64 var bundleSize: Int64 // Only used in the app list view
var files: [URL] // var files: [URL]
var fileSize: [URL:Int64] var fileSize: [URL:Int64]
var fileSizeLogical: [URL:Int64] var fileSizeLogical: [URL:Int64]
var fileIcon: [URL:NSImage?] var fileIcon: [URL:NSImage?]
let creationDate: Date?
let contentChangeDate: Date?
let lastUsedDate: Date?
var totalSize: Int64 var totalSize: Int64
{ {
return fileSize.values.reduce(0, +) return fileSize.values.reduce(0, +)
@@ -152,7 +159,8 @@ struct AppInfo: Identifiable, Equatable, Hashable {
} }
static let empty = AppInfo(id: UUID(), path: URL(fileURLWithPath: ""), bundleIdentifier: "", appName: "", appVersion: "", appIcon: nil, webApp: false, wrapped: false, system: false, arch: .empty, bundleSize: 0, files: [], fileSize: [:], fileSizeLogical: [:], fileIcon: [:])
static let empty = AppInfo(id: UUID(), path: URL(fileURLWithPath: ""), bundleIdentifier: "", appName: "", appVersion: "", appIcon: nil, webApp: false, wrapped: false, system: false, arch: .empty, bundleSize: 0, fileSize: [:], fileSizeLogical: [:], fileIcon: [:], creationDate: nil, contentChangeDate: nil, lastUsedDate: nil)
} }
@@ -185,6 +193,19 @@ enum Arch {
case empty case empty
} }
enum CurrentPage:Int
{
case installed
case uninstalled
var title: String {
switch self {
case .installed: return "Installed"
case .uninstalled: return "Uninstalled"
}
}
}
enum CurrentTabView:Int enum CurrentTabView:Int
{ {
+35 -12
View File
@@ -37,9 +37,40 @@ func getSortedApps(paths: [String]) -> [AppInfo] {
// Collect system applications // Collect system applications
paths.forEach { collectAppPaths(at: $0) } paths.forEach { collectAppPaths(at: $0) }
// let startTime = Date()
// Convert collected paths to string format for metadata query
let combinedPaths = apps.map { $0.path }
// Get metadata for all collected app paths
var metadataDictionary: [String: [String: Any]] = [:]
if let metadata = getMDLSMetadataAsPlist(for: combinedPaths) {
metadataDictionary = metadata
}
// Process each app path and construct AppInfo using metadata first, then fallback if necessary
let appInfos: [AppInfo] = apps.compactMap { appURL in
let appPath = appURL.path
if let appMetadata = metadataDictionary[appPath] {
// Use `MetadataAppInfoFetcher` first
return MetadataAppInfoFetcher.getAppInfo(fromMetadata: appMetadata, atPath: appURL)
} else {
// Fallback to `AppInfoFetcher` if no metadata found
return AppInfoFetcher.getAppInfo(atPath: appURL)
}
}
// Sort apps by display name
let sortedApps = appInfos.sorted { $0.appName.lowercased() < $1.appName.lowercased() }
// Get app info and sort // Get app info and sort
let sortedApps = apps // let sortedApps = apps
.compactMap { AppInfoFetcher.getAppInfo(atPath: $0) } // .compactMap { AppInfoFetcher.getAppInfo(atPath: $0) }
// let elapsedTime = Date().timeIntervalSince(startTime)
// print("Time taken for mdls metadata extraction: \(elapsedTime) seconds")
return sortedApps return sortedApps
} }
@@ -169,7 +200,7 @@ func moveFilesToTrash(appState: AppState, at fileURLs: [URL], completion: @escap
// Stop Sentinel FileWatcher momentarily to ignore .app bundle being sent to Trash // Stop Sentinel FileWatcher momentarily to ignore .app bundle being sent to Trash
sendStopNotificationFW() sendStopNotificationFW()
updateOnBackground { updateOnMain {
let posixFiles = fileURLs.map { item in let posixFiles = fileURLs.map { item in
return "POSIX file \"\(item.path)\"" + (item == fileURLs.last ? "" : ", ")}.joined() return "POSIX file \"\(item.path)\"" + (item == fileURLs.last ? "" : ", ")}.joined()
let scriptSource = """ let scriptSource = """
@@ -182,19 +213,15 @@ func moveFilesToTrash(appState: AppState, at fileURLs: [URL], completion: @escap
// Handle any AppleScript errors // Handle any AppleScript errors
if let error = error { if let error = error {
DispatchQueue.main.async {
printOS("Trash Error: \(error)") printOS("Trash Error: \(error)")
completion(false) // Indicate failure completion(false) // Indicate failure
}
return return
} }
// Check if output is null, indicating the user canceled the operation // Check if output is null, indicating the user canceled the operation
if output.descriptorType == typeNull { if output.descriptorType == typeNull {
DispatchQueue.main.async {
printOS("Trash Error: operation canceled by the user") printOS("Trash Error: operation canceled by the user")
completion(false) // Indicate failure due to cancellation completion(false) // Indicate failure due to cancellation
}
return return
} }
@@ -203,10 +230,8 @@ func moveFilesToTrash(appState: AppState, at fileURLs: [URL], completion: @escap
printOS("Trash: \(outputString)") printOS("Trash: \(outputString)")
} }
} }
DispatchQueue.main.async {
completion(true) // Indicate success completion(true) // Indicate success
} }
}
} }
@@ -265,7 +290,7 @@ func undoTrash(appState: AppState, completion: @escaping () -> Void = {}) {
""" """
var error: NSDictionary? var error: NSDictionary?
updateOnBackground { updateOnMain {
if let scriptObject = NSAppleScript(source: scriptSource) { if let scriptObject = NSAppleScript(source: scriptSource) {
let output: NSAppleEventDescriptor = scriptObject.executeAndReturnError(&error) let output: NSAppleEventDescriptor = scriptObject.executeAndReturnError(&error)
if let error = error { if let error = error {
@@ -274,10 +299,8 @@ func undoTrash(appState: AppState, completion: @escaping () -> Void = {}) {
printOS(outputString) printOS(outputString)
} }
} }
DispatchQueue.main.async {
completion() completion()
} }
}
} }
+18 -1
View File
@@ -303,6 +303,7 @@ func manageSymlink(install: Bool) {
""" """
} }
updateOnMain {
// Execute the AppleScript // Execute the AppleScript
var error: NSDictionary? var error: NSDictionary?
if let scriptObject = NSAppleScript(source: script) { if let scriptObject = NSAppleScript(source: script) {
@@ -320,6 +321,8 @@ func manageSymlink(install: Bool) {
} else { } else {
printOS("Error: Unable to create the AppleScript object.") printOS("Error: Unable to create the AppleScript object.")
} }
}
} }
@@ -361,6 +364,7 @@ func caskCleanup(app: String) {
end tell end tell
""" """
updateOnMain {
var error: NSDictionary? var error: NSDictionary?
if let appleScript = NSAppleScript(source: script) { if let appleScript = NSAppleScript(source: script) {
appleScript.executeAndReturnError(&error) appleScript.executeAndReturnError(&error)
@@ -369,6 +373,8 @@ func caskCleanup(app: String) {
if let error = error { if let error = error {
printOS("AppleScript Error: \(error)") printOS("AppleScript Error: \(error)")
} }
}
} else { } else {
printOS("Brew cleanup: No cask found for \(app).") printOS("Brew cleanup: No cask found for \(app).")
} }
@@ -453,7 +459,7 @@ func caskCleanup2(app: String) {
fi'" in front window fi'" in front window
end tell end tell
""" """
updateOnMain {
var error: NSDictionary? var error: NSDictionary?
if let appleScript = NSAppleScript(source: script) { if let appleScript = NSAppleScript(source: script) {
appleScript.executeAndReturnError(&error) appleScript.executeAndReturnError(&error)
@@ -463,6 +469,8 @@ func caskCleanup2(app: String) {
print("AppleScript Error: \(error)") print("AppleScript Error: \(error)")
} }
} }
}
} }
@@ -796,6 +804,15 @@ func isNested(path: URL) -> Bool {
} }
// Date formatter for metadata
func formattedMDDate(from date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm zzz"
formatter.timeZone = .current // Use the current timezone
return formatter.string(from: date)
}
// --- Extend String to remove periods, spaces and lowercase the string // --- Extend String to remove periods, spaces and lowercase the string
extension String { extension String {
+6 -6
View File
@@ -35,12 +35,12 @@ class WindowSettings: ObservableObject {
func newWindow<V: View>(mini: Bool, withView view: @escaping () -> V) { func newWindow<V: View>(mini: Bool, withView view: @escaping () -> V) {
let frame = self.resetWindowSettings(mini: mini) let frame = self.resetWindowSettings(mini: mini)
if menubarEnabled { // if menubarEnabled {
windowRef = NSWindow( // windowRef = NSWindow(
contentRect: .zero, // contentRect: .zero,
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], // styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false) // backing: .buffered, defer: false)
} // }
// Update the existing windowRef with all desired settings // Update the existing windowRef with all desired settings
windowRef?.contentView = NSHostingView(rootView: view()) windowRef?.contentView = NSHostingView(rootView: view())
windowRef?.setFrame(frame, display: true, animate: true) windowRef?.setFrame(frame, display: true, animate: true)
+28 -18
View File
@@ -98,21 +98,6 @@ struct PearcleanerApp: App {
}) })
.onAppear { .onAppear {
if miniView {
appState.currentView = .apps
} else {
appState.currentView = .empty
}
// Track main window within windowSettings class
windowSettings.trackMainWindow()
// Disable tabbing
NSWindow.allowsAutomaticWindowTabbing = false
// Load apps list on startup
reloadAppsList(appState: appState, fsm: fsm)
// Enable menubar item // Enable menubar item
if menubarEnabled { if menubarEnabled {
MenuBarExtraManager.shared.addMenuBarExtra(withView: { MenuBarExtraManager.shared.addMenuBarExtra(withView: {
@@ -125,12 +110,36 @@ struct PearcleanerApp: App {
.environmentObject(permissionManager) .environmentObject(permissionManager)
.preferredColorScheme(themeManager.displayMode.colorScheme) .preferredColorScheme(themeManager.displayMode.colorScheme)
}) })
findAndHideWindows(named: ["Pearcleaner"])
NSApplication.shared.setActivationPolicy(.accessory) NSApplication.shared.setActivationPolicy(.accessory)
windowSettings.trackMainWindow()
findAndHideWindows(named: ["Pearcleaner", ""])
// Catch windows in case something gets opened from SwiftUI lifecycle
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: {
findAndHideWindows(named: ["Pearcleaner", ""])
})
} }
// Set mini view
if mini && miniView {
appState.currentView = .apps
} else {
appState.currentView = .empty
}
// Disable tabbing
NSWindow.allowsAutomaticWindowTabbing = false
// Load apps list on startup
reloadAppsList(appState: appState, fsm: fsm)
if !menubarEnabled {
Task {
// Track main window within windowSettings class
windowSettings.trackMainWindow()
}
}
} }
} }
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
@@ -197,6 +206,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
} }
} }
} }
+3
View File
@@ -272,10 +272,13 @@ struct GeneralSettingsTab: View {
} }
.onAppear { .onAppear {
Task {
appState.updateExtensionStatus() appState.updateExtensionStatus()
isCLISymlinked = checkCLISymlink() isCLISymlinked = checkCLISymlink()
} }
} }
}
} }
+1 -1
View File
@@ -283,7 +283,7 @@ struct InterfaceSettingsTab: View {
.preferredColorScheme(themeManager.displayMode.colorScheme) .preferredColorScheme(themeManager.displayMode.colorScheme)
}) })
NSApplication.shared.setActivationPolicy(.accessory) NSApplication.shared.setActivationPolicy(.accessory)
findAndHideWindows(named: ["Pearcleaner"]) findAndHideWindows(named: ["Pearcleaner", ""])
} else { } else {
MenuBarExtraManager.shared.removeMenuBarExtra() MenuBarExtraManager.shared.removeMenuBarExtra()
NSApplication.shared.setActivationPolicy(.regular) NSApplication.shared.setActivationPolicy(.regular)
+19 -20
View File
@@ -82,31 +82,29 @@ struct AppListItems: View {
} }
Spacer()
if appInfo.webApp { if appInfo.webApp {
Text("web") Image(systemName: "safari")
.font(.footnote) .resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundStyle(.primary.opacity(0.3)) .foregroundStyle(.primary.opacity(0.3))
.frame(minWidth: 30, minHeight: 15) .symbolRenderingMode(.monochrome)
.padding(2) .help("Web app")
.background(
Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1)
)
} }
if appInfo.wrapped { if appInfo.wrapped {
Text("iOS") Image(systemName: "iphone")
.font(.footnote) .resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundStyle(.primary.opacity(0.3)) .foregroundStyle(.primary.opacity(0.3))
.frame(minWidth: 30, minHeight: 15) .symbolRenderingMode(.monochrome)
.padding(2) .help("iOS app")
.background(
Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1)
)
} }
Spacer()
if minimalEnabled { if minimalEnabled {
Text(bundleSize == 0 ? "v\(appInfo.appVersion)" : (isHovered ? "v\(appInfo.appVersion)" : formatByte(size: bundleSize).human)) Text(appInfo.bundleSize == 0 ? "v\(appInfo.appVersion)" : (isHovered ? "v\(appInfo.appVersion)" : formatByte(size: appInfo.bundleSize).human))
.font(.system(size: (isHovered || isSelected) ? 12 : 10)) .font(.system(size: (isHovered || isSelected) ? 12 : 10))
.foregroundStyle(.primary.opacity(0.5)) .foregroundStyle(.primary.opacity(0.5))
} }
@@ -119,7 +117,7 @@ struct AppListItems: View {
.frame(height: 35) .frame(height: 35)
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 5) .padding(.vertical, 5)
.help(appInfo.appName) // .help(appInfo.appName)
.onHover { hovering in .onHover { hovering in
withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) {
self.isHovered = hovering self.isHovered = hovering
@@ -155,9 +153,10 @@ struct AppListItems: View {
} }
} }
.onAppear { .onAppear {
if self.bundleSize == 0 { if appInfo.bundleSize == 0 {
appState.getBundleSize(for: appInfo) { size in appState.getBundleSize(for: appInfo) { size in
self.bundleSize = size // print("Getting size for: \(appInfo.appName)")
// appInfo.bundleSize = size
} }
} }
} }
+28 -17
View File
@@ -28,23 +28,14 @@ struct AppSearchView: View {
var body: some View { var body: some View {
VStack(alignment: .center, spacing: 0) { VStack(alignment: .center, spacing: 0) {
Spacer() Spacer()
.frame(height: 10) .frame(height: !isMenuBar ? 30 : 10)
.padding(.top, !isMenuBar ? 25 : 0)
if updater.updateAvailable {
UpdateBadge(updater: updater)
.padding(.horizontal)
} else if let _ = permissionManager.results, !permissionManager.allPermissionsGranted {
PermissionsBadge()
.padding(.horizontal)
} else if updater.announcementAvailable {
FeatureBadge(updater: updater)
.padding(.horizontal)
}
AppsListView(search: $search, showPopover: $showPopover, filteredApps: filteredApps) searchBarComponent
.padding(.horizontal, search.isEmpty ? 10 : 5)
.padding(.bottom, 5)
Divider() Divider()
@@ -55,6 +46,29 @@ struct AppSearchView: View {
#endif #endif
AppsListView(search: $search, showPopover: $showPopover, filteredApps: filteredApps)
.padding(.vertical, 4)
if updater.updateAvailable {
Divider()
UpdateBadge(updater: updater)
.padding(8)
} else if let _ = permissionManager.results, !permissionManager.allPermissionsGranted {
Divider()
PermissionsBadge()
.padding(8)
} else if updater.announcementAvailable {
Divider()
FeatureBadge(updater: updater)
.padding(8)
}
}
}
private var searchBarComponent: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
if search.isEmpty { if search.isEmpty {
@@ -148,11 +162,8 @@ struct AppSearchView: View {
} }
.padding(.horizontal, search.isEmpty ? 10 : 5)
.padding(.vertical, 5)
} }
}
private var filteredApps: [AppInfo] { private var filteredApps: [AppInfo] {
let apps: [AppInfo] let apps: [AppInfo]
+2 -2
View File
@@ -18,7 +18,7 @@ struct AppsListView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { VStack(alignment: .leading, spacing: 0) {
let filteredUserApps = filteredApps.filter { !$0.system } let filteredUserApps = filteredApps.filter { !$0.system }
let filteredSystemApps = filteredApps.filter { $0.system } let filteredSystemApps = filteredApps.filter { $0.system }
@@ -48,7 +48,7 @@ struct SectionView: View {
@AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true @AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true
var body: some View { var body: some View {
LazyVStack(spacing: 0) { VStack(spacing: 0) {
Header(title: title, count: count, showPopover: $showPopover) Header(title: title, count: count, showPopover: $showPopover)
.padding(.leading, 5) .padding(.leading, 5)
.onTapGesture { .onTapGesture {
+68
View File
@@ -0,0 +1,68 @@
//
// CustomTabView.swift
// Pearcleaner
//
// Created by Alin Lupascu on 10/3/24.
//
import SwiftUI
public struct CustomTabView: View {
private let titles: [String]
private let icons: [String]
private let tabViews: [AnyView]
@State private var selection = 0
@State private var indexHovered = -1
public init(content: [(title: String, icon: String, view: AnyView)]) {
self.titles = content.map{ $0.title }
self.icons = content.map{ $0.icon }
self.tabViews = content.map{ $0.view }
}
public var tabBar: some View {
HStack {
Spacer()
ForEach(0..<titles.count, id: \.self) { index in
HStack {
Image(systemName: self.icons[index])
.font(.title2)
Text(self.titles[index])
}
// .frame(height: 30)
.padding(5)
.background(Color.gray.opacity(((self.selection == index) || (self.indexHovered == index)) ? 0.3 : 0),
in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.frame(height: 40)
.padding(.horizontal, 0)
.foregroundColor(self.selection == index ? .accent : .primary.opacity(0.8))
.onHover(perform: { hovering in
if hovering {
indexHovered = index
} else {
indexHovered = -1
}
})
.onTapGesture {
self.selection = index
}
}
Spacer()
}
.padding(0)
}
public var body: some View {
VStack(spacing: 0) {
tabBar
tabViews[selection]
.padding(0)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(0)
}
}
+15 -4
View File
@@ -134,15 +134,24 @@ struct FilesView: View {
Text("").foregroundStyle(Color("AccentColor")) Text("").foregroundStyle(Color("AccentColor"))
Text("v\(appState.appInfo.appVersion)").font(.title3) Text("v\(appState.appInfo.appVersion)").font(.title3)
// if appState.appInfo.appName.count < 5 {
// InfoButton(text: "Pearcleaner searches for files via a combination of bundle id and app name. \(appState.appInfo.appName) has a common or short app name so there might be unrelated files found. Please check the list thoroughly before uninstalling.")
// }
} }
Text("\(appState.appInfo.bundleIdentifier)") Text("\(appState.appInfo.bundleIdentifier)")
.lineLimit(1) .lineLimit(1)
.font(.title3) .font(.title3)
.foregroundStyle((.primary.opacity(0.5))) .foregroundStyle((.primary.opacity(0.5)))
if let creationDate = appState.appInfo.creationDate {
Text("Installed: \(formattedMDDate(from: creationDate))")
.font(.footnote)
}
if let modificationDate = appState.appInfo.contentChangeDate {
Text("Modified: \(formattedMDDate(from: modificationDate))")
.font(.footnote)
}
if let lastUsedDate = appState.appInfo.lastUsedDate {
Text("Last Used: \(formattedMDDate(from: lastUsedDate))")
.font(.footnote)
}
} }
.padding(.leading) .padding(.leading)
@@ -153,6 +162,8 @@ struct FilesView: View {
Text("\(displaySizeTotal)").font(.title).fontWeight(.bold).help("Total size on disk") Text("\(displaySizeTotal)").font(.title).fontWeight(.bold).help("Total size on disk")
Text("\(appState.appInfo.fileSize.count == 1 ? "\(appState.selectedItems.count) / \(appState.appInfo.fileSize.count) item" : "\(appState.selectedItems.count) / \(appState.appInfo.fileSize.count) items")") Text("\(appState.appInfo.fileSize.count == 1 ? "\(appState.selectedItems.count) / \(appState.appInfo.fileSize.count) item" : "\(appState.selectedItems.count) / \(appState.appInfo.fileSize.count) items")")
.font(.callout).foregroundStyle((.primary.opacity(0.5))) .font(.callout).foregroundStyle((.primary.opacity(0.5)))
} }
} }
+35
View File
@@ -13,10 +13,12 @@ struct RegularMode: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var themeManager: ThemeManager @EnvironmentObject var themeManager: ThemeManager
@EnvironmentObject var locations: Locations @EnvironmentObject var locations: Locations
@EnvironmentObject var fsm: FolderSettingsManager
@AppStorage("settings.general.glass") private var glass: Bool = false @AppStorage("settings.general.glass") private var glass: Bool = false
@AppStorage("settings.general.sidebarWidth") private var sidebarWidth: Double = 280 @AppStorage("settings.general.sidebarWidth") private var sidebarWidth: Double = 280
@AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false @AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false
@AppStorage("settings.general.mini") private var mini: Bool = false @AppStorage("settings.general.mini") private var mini: Bool = false
@AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true
@Binding var search: String @Binding var search: String
@State private var showSys: Bool = true @State private var showSys: Bool = true
@State private var showUsr: Bool = true @State private var showUsr: Bool = true
@@ -26,6 +28,15 @@ struct RegularMode: View {
var body: some View { var body: some View {
// Main App Window
VStack(spacing: 0) {
// Tab View
CustomTabView(content: [
(
title: "Installed",
icon: "externaldrive",
view: AnyView (
HStack(alignment: .center, spacing: 0) { HStack(alignment: .center, spacing: 0) {
// App List // App List
@@ -47,6 +58,7 @@ struct RegularMode: View {
} else { } else {
AppSearchView(glass: glass, menubarEnabled: menubarEnabled, mini: mini, search: $search, showPopover: $showPopover, isMenuBar: $isMenuBar) AppSearchView(glass: glass, menubarEnabled: menubarEnabled, mini: mini, search: $search, showPopover: $showPopover, isMenuBar: $isMenuBar)
.frame(width: sidebarWidth) .frame(width: sidebarWidth)
} }
} }
@@ -78,6 +90,29 @@ struct RegularMode: View {
.zIndex(2) .zIndex(2)
.background(backgroundView(themeManager: themeManager)) .background(backgroundView(themeManager: themeManager))
} }
)
),
(
title: "Uninstalled",
icon: "externaldrive.badge.minus",
view: AnyView(
ZombieView(showPopover: $showPopover, search: $search)
.background(backgroundView(themeManager: themeManager))
.onAppear {
if appState.zombieFile.fileSize.keys.isEmpty {
appState.showProgress.toggle()
}
withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) {
if appState.zombieFile.fileSize.keys.isEmpty {
reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, fsm: fsm)
}
}
}
)
),
])
}
.frame(minWidth: 900, minHeight: 600) .frame(minWidth: 900, minHeight: 600)
.edgesIgnoringSafeArea(.all) .edgesIgnoringSafeArea(.all)