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
+9 -9
View File
@@ -36,15 +36,6 @@ struct AppCommands: Commands {
Text("Check for Updates")
}
.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 {
appState.triggerUninstallAlert()
@@ -93,6 +84,15 @@ struct AppCommands: Commands {
// 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
{
if !appState.appInfo.bundleIdentifier.isEmpty {
+190 -7
View File
@@ -9,10 +9,78 @@ import Foundation
import SwiftUI
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 {
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) {
return handleWrappedDirectory(atPath: path)
} 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")
return fileManager.fileExists(atPath: wrapperURL.path)
}
@@ -53,17 +121,36 @@ class AppInfoFetcher {
? bundle.infoDictionary?["CFBundleVersion"] as? String ?? ""
: bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let appIcon = fetchAppIcon(for: path, wrapped: wrapped)
let webApp = (bundle.infoDictionary?["LSTemplateApplication"] as? Bool ?? false || bundle.infoDictionary?["CFBundleExecutable"] as? String == "app_mode_loader")
let appIcon = AppInfoUtils.fetchAppIcon(for: path, wrapped: wrapped)
let webApp = AppInfoUtils.isWebApp(bundle: bundle)
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, 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) {
return convertICNSToPNG(icon: appIcon, size: NSSize(width: 100, height: 100))
} else {
@@ -71,5 +158,101 @@ class AppInfoFetcher {
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
}
}
+27 -6
View File
@@ -19,6 +19,7 @@ class AppState: ObservableObject {
@Published var sortedApps: [AppInfo] = []
@Published var selectedItems = Set<URL>()
@Published var currentView = CurrentDetailsView.empty
@Published var currentPage = CurrentPage.installed
@Published var showAlert: Bool = false
@Published var sidebar: Bool = true
@Published var reload: Bool = false
@@ -67,10 +68,13 @@ class AppState: ObservableObject {
system: false,
arch: .empty,
bundleSize: 0,
files: [],
// files: [],
fileSize: [:],
fileSizeLogical: [:],
fileIcon: [:]
fileIcon: [:],
creationDate: nil,
contentChangeDate: nil,
lastUsedDate: nil
)
self.zombieFile = ZombieFile(
@@ -137,12 +141,15 @@ struct AppInfo: Identifiable, Equatable, Hashable {
let wrapped: Bool
let system: Bool
var arch: Arch
var bundleSize: Int64
var files: [URL]
var bundleSize: Int64 // Only used in the app list view
// var files: [URL]
var fileSize: [URL:Int64]
var fileSizeLogical: [URL:Int64]
var fileIcon: [URL:NSImage?]
var totalSize: Int64
let creationDate: Date?
let contentChangeDate: Date?
let lastUsedDate: Date?
var totalSize: Int64
{
return fileSize.values.reduce(0, +)
}
@@ -150,9 +157,10 @@ struct AppInfo: Identifiable, Equatable, Hashable {
{
return fileSizeLogical.values.reduce(0, +)
}
static let empty = AppInfo(id: UUID(), path: URL(fileURLWithPath: ""), bundleIdentifier: "", appName: "", appVersion: "", appIcon: nil, webApp: false, wrapped: false, system: false, 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
}
enum CurrentPage:Int
{
case installed
case uninstalled
var title: String {
switch self {
case .installed: return "Installed"
case .uninstalled: return "Uninstalled"
}
}
}
enum CurrentTabView:Int
{
+41 -18
View File
@@ -37,9 +37,40 @@ func getSortedApps(paths: [String]) -> [AppInfo] {
// Collect system applications
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
let sortedApps = apps
.compactMap { AppInfoFetcher.getAppInfo(atPath: $0) }
// let sortedApps = apps
// .compactMap { AppInfoFetcher.getAppInfo(atPath: $0) }
// let elapsedTime = Date().timeIntervalSince(startTime)
// print("Time taken for mdls metadata extraction: \(elapsedTime) seconds")
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
sendStopNotificationFW()
updateOnBackground {
updateOnMain {
let posixFiles = fileURLs.map { item in
return "POSIX file \"\(item.path)\"" + (item == fileURLs.last ? "" : ", ")}.joined()
let scriptSource = """
@@ -182,19 +213,15 @@ func moveFilesToTrash(appState: AppState, at fileURLs: [URL], completion: @escap
// Handle any AppleScript errors
if let error = error {
DispatchQueue.main.async {
printOS("Trash Error: \(error)")
completion(false) // Indicate failure
}
printOS("Trash Error: \(error)")
completion(false) // Indicate failure
return
}
// Check if output is null, indicating the user canceled the operation
if output.descriptorType == typeNull {
DispatchQueue.main.async {
printOS("Trash Error: operation canceled by the user")
completion(false) // Indicate failure due to cancellation
}
printOS("Trash Error: operation canceled by the user")
completion(false) // Indicate failure due to cancellation
return
}
@@ -203,9 +230,7 @@ func moveFilesToTrash(appState: AppState, at fileURLs: [URL], completion: @escap
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?
updateOnBackground {
updateOnMain {
if let scriptObject = NSAppleScript(source: scriptSource) {
let output: NSAppleEventDescriptor = scriptObject.executeAndReturnError(&error)
if let error = error {
@@ -274,9 +299,7 @@ func undoTrash(appState: AppState, completion: @escaping () -> Void = {}) {
printOS(outputString)
}
}
DispatchQueue.main.async {
completion()
}
completion()
}
}
+41 -24
View File
@@ -303,23 +303,26 @@ func manageSymlink(install: Bool) {
"""
}
// Execute the AppleScript
var error: NSDictionary?
if let scriptObject = NSAppleScript(source: script) {
scriptObject.executeAndReturnError(&error)
updateOnMain {
// Execute the AppleScript
var error: NSDictionary?
if let scriptObject = NSAppleScript(source: script) {
scriptObject.executeAndReturnError(&error)
if let error = error {
printOS("AppleScript Error: \(error)")
} else {
if install {
printOS("Symlink created successfully at \(symlinkPath).")
if let error = error {
printOS("AppleScript Error: \(error)")
} else {
printOS("Symlink removed successfully from \(symlinkPath).")
if install {
printOS("Symlink created successfully at \(symlinkPath).")
} else {
printOS("Symlink removed successfully from \(symlinkPath).")
}
}
} else {
printOS("Error: Unable to create the AppleScript object.")
}
} else {
printOS("Error: Unable to create the AppleScript object.")
}
}
@@ -361,14 +364,17 @@ func caskCleanup(app: String) {
end tell
"""
var error: NSDictionary?
if let appleScript = NSAppleScript(source: script) {
appleScript.executeAndReturnError(&error)
updateOnMain {
var error: NSDictionary?
if let appleScript = NSAppleScript(source: script) {
appleScript.executeAndReturnError(&error)
}
if let error = error {
printOS("AppleScript Error: \(error)")
}
}
if let error = error {
printOS("AppleScript Error: \(error)")
}
} else {
printOS("Brew cleanup: No cask found for \(app).")
}
@@ -453,15 +459,17 @@ func caskCleanup2(app: String) {
fi'" in front window
end tell
"""
updateOnMain {
var error: NSDictionary?
if let appleScript = NSAppleScript(source: script) {
appleScript.executeAndReturnError(&error)
}
var error: NSDictionary?
if let appleScript = NSAppleScript(source: script) {
appleScript.executeAndReturnError(&error)
if let error = error {
print("AppleScript Error: \(error)")
}
}
if let 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
extension String {
+6 -6
View File
@@ -35,12 +35,12 @@ class WindowSettings: ObservableObject {
func newWindow<V: View>(mini: Bool, withView view: @escaping () -> V) {
let frame = self.resetWindowSettings(mini: mini)
if menubarEnabled {
windowRef = NSWindow(
contentRect: .zero,
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
}
// if menubarEnabled {
// windowRef = NSWindow(
// contentRect: .zero,
// styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
// backing: .buffered, defer: false)
// }
// Update the existing windowRef with all desired settings
windowRef?.contentView = NSHostingView(rootView: view())
windowRef?.setFrame(frame, display: true, animate: true)