diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2d6a29a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# Agent Guidelines for FuckAppleLaunchpad Project + +## Quick Facts +- **Type**: macOS app (SwiftUI, Swift 6.2+) +- **Target**: macOS 16.0+ (expanded from 26.0 for broader compatibility) +- **Requires**: Xcode 26+ (currently 26.4.1) +- **Structure**: View/, ViewModel/, Models.swift, FuckAppleLaunchpadApp.swift + +## Critical Commands + +```bash +# Debug build (fastest) +xcodebuild -scheme FuckAppleLaunchpad -configuration Debug build + +# Run tests (uses Testing framework, async syntax) +xcodebuild -scheme FuckAppleLaunchpad -destination 'platform=macOS' test + +# Single test +xcodebuild -scheme FuckAppleLaunchpadTests -destination 'platform=macOS' \ + -only-testing:FuckAppleLaunchpadTests/FuckAppleLaunchpadTests/example test + +# Release build +xcodebuild -scheme FuckAppleLaunchpad -configuration Release build +``` + +## No CI/CD Workflows +No GitHub Actions or pre-commit hooks configured. Manual testing is expected before PR. + +## Code Style Guidelines + +### File Organization +- Group related functionality in View, ViewModel, and Model folders +- Keep files focused on a single responsibility (< 200 lines when possible) + +### Import Statements +- Group imports: System frameworks (SwiftUI, AppKit, Combine) first, then local imports +- Each import on its own line; no unused imports + +### Specific Patterns Used in This Project +- ViewModels use `@Published` properties with `ObservableObject` +- Views observe ViewModels with `@StateObject` or `@ObservedObject` +- Data models are simple structs conforming to `Identifiable` (defined in Models.swift) +- Image loading uses `NSImage` with fallback placeholders +- App termination: `NSApplication.shared.terminate(nil)` +- Groups: Click folder (with >2 apps) opens modal grid like real macOS Launchpad (GroupDetailView) + +### Testing Guidelines +- Unit tests in `FuckAppleLaunchpadTests` target +- UI tests in `FuckAppleLaunchpadUITests` target (present but minimal) +- Use `Testing` framework with `@Test` and `#expect()` +- Tests are async-first (`async throws`) \ No newline at end of file diff --git a/FuckAppleLaunchpad.xcodeproj/project.pbxproj b/FuckAppleLaunchpad.xcodeproj/project.pbxproj index 4b43fb6..4786ab7 100644 --- a/FuckAppleLaunchpad.xcodeproj/project.pbxproj +++ b/FuckAppleLaunchpad.xcodeproj/project.pbxproj @@ -323,7 +323,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -381,7 +381,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -410,7 +410,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.davinci.FuckAppleLaunchpad; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -419,7 +419,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 6.0; + SWIFT_VERSION = 6.2; }; name = Debug; }; @@ -444,7 +444,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.davinci.FuckAppleLaunchpad; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -453,7 +453,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 6.0; + SWIFT_VERSION = 6.2; }; name = Release; }; @@ -465,14 +465,14 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 92A277ZDK2; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.davinci.FuckAppleLaunchpadTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FuckAppleLaunchpad.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FuckAppleLaunchpad"; }; name = Debug; @@ -485,14 +485,14 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 92A277ZDK2; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.davinci.FuckAppleLaunchpadTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FuckAppleLaunchpad.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FuckAppleLaunchpad"; }; name = Release; @@ -510,7 +510,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; TEST_TARGET_NAME = FuckAppleLaunchpad; }; name = Debug; @@ -528,7 +528,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; TEST_TARGET_NAME = FuckAppleLaunchpad; }; name = Release; diff --git a/FuckAppleLaunchpad/FuckAppleLaunchpadApp.swift b/FuckAppleLaunchpad/FuckAppleLaunchpadApp.swift index 1c9f8b3..2826505 100644 --- a/FuckAppleLaunchpad/FuckAppleLaunchpadApp.swift +++ b/FuckAppleLaunchpad/FuckAppleLaunchpadApp.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import ServiceManagement @main struct FuckAppleLaunchpadApp: App { @@ -8,16 +9,83 @@ struct FuckAppleLaunchpadApp: App { var body: some Scene { WindowGroup { LaunchpadView() - .frame(minWidth: NSScreen.main?.visibleFrame.width ?? 1280, minHeight: NSScreen.main?.visibleFrame.height ?? 800) // Minimum size for readability + .ignoresSafeArea() } - .defaultSize(width: NSScreen.main?.visibleFrame.width ?? 1280, height: NSScreen.main?.visibleFrame.height ?? 800) .windowStyle(.hiddenTitleBar) - .windowResizability(.contentSize) } } class AppDelegate: NSObject, NSApplicationDelegate { + private let launchAtLoginKey = "launchAtLogin" + private var statusItem: NSStatusItem? + func applicationDidFinishLaunching(_ notification: Notification) { - NSApplication.shared.presentationOptions = [.autoHideMenuBar] + setupStatusBar() + + // Register for launch at login on first run + if !UserDefaults.standard.bool(forKey: launchAtLoginKey) { + setLaunchAtLogin(enabled: true) + UserDefaults.standard.set(true, forKey: launchAtLoginKey) + } + + // Show menu bar and make window fullscreen + NSApplication.shared.presentationOptions = [] + + // Make window fullscreen + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if let window = NSApplication.shared.windows.first { + window.toggleFullScreen(nil) + } + } + } + + private func setupStatusBar() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem?.button { + button.image = NSImage(systemSymbolName: "square.grid.3x3", accessibilityDescription: "Launchpad") + } + + let menu = NSMenu() + menu.addItem(withTitle: "Open Launchpad", action: #selector(openLaunchpad), keyEquivalent: "") + menu.addItem(NSMenuItem.separator()) + + let launchItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "") + launchItem.state = UserDefaults.standard.bool(forKey: launchAtLoginKey) ? .on : .off + menu.addItem(launchItem) + + menu.addItem(NSMenuItem.separator()) + menu.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + + statusItem?.menu = menu + } + + @objc private func openLaunchpad() { + if let window = NSApplication.shared.windows.first { + window.makeKeyAndOrderFront(nil) + if !window.styleMask.contains(.fullScreen) { + window.toggleFullScreen(nil) + } + } + } + + @objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) { + let enabled = sender.state == .off + setLaunchAtLogin(enabled: enabled) + sender.state = enabled ? .on : .off + UserDefaults.standard.set(enabled, forKey: launchAtLoginKey) + } + + private func setLaunchAtLogin(enabled: Bool) { + do { + if enabled { + try SMAppService.mainApp.register() + print("Enabled launch at login") + } else { + try SMAppService.mainApp.unregister() + print("Disabled launch at login") + } + } catch { + print("Failed to update launch at login setting: \(error.localizedDescription)") + } } } diff --git a/FuckAppleLaunchpad/Models.swift b/FuckAppleLaunchpad/Models.swift new file mode 100644 index 0000000..3edd8c9 --- /dev/null +++ b/FuckAppleLaunchpad/Models.swift @@ -0,0 +1,92 @@ +import SwiftUI +import AppKit + +struct Application: Identifiable, Hashable, Codable { + let id: UUID + let name: String + let path: URL + let icon: NSImage? + var lastOpenedTime: Date + var openCount: Int + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Application, rhs: Application) -> Bool { + lhs.id == rhs.id + } + + enum CodingKeys: String, CodingKey { + case id, name, path, lastOpenedTime, openCount + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(path, forKey: .path) + try container.encode(lastOpenedTime, forKey: .lastOpenedTime) + try container.encode(openCount, forKey: .openCount) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + path = try container.decode(URL.self, forKey: .path) + lastOpenedTime = try container.decode(Date.self, forKey: .lastOpenedTime) + openCount = try container.decode(Int.self, forKey: .openCount) + icon = NSWorkspace.shared.icon(forFile: path.path) + } + + init(name: String, path: URL, icon: NSImage?) { + self.id = UUID() + self.name = name + self.path = path + self.icon = icon + self.lastOpenedTime = Date() + self.openCount = 0 + } +} + +struct Group: Identifiable, Hashable, Codable { + let id: UUID + var name: String + var apps: [Application] + var lastOpenedTime: Date + var openCount: Int + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Group, rhs: Group) -> Bool { + lhs.id == rhs.id + } + + init(name: String, apps: [Application]) { + self.id = UUID() + self.name = name + self.apps = apps + self.lastOpenedTime = Date() + self.openCount = 0 + } +} + +enum LaunchpadItem: Identifiable, Hashable { + case app(Application) + case group(Group) + + var id: UUID { + switch self { + case .app(let app): return app.id + case .group(let group): return group.id + } + } +} + +struct ApplicationPage: Identifiable { + let id = UUID() + let items: [LaunchpadItem] +} \ No newline at end of file diff --git a/FuckAppleLaunchpad/View/AppGridView.swift b/FuckAppleLaunchpad/View/AppGridView.swift index 42b8cc8..0591e5b 100644 --- a/FuckAppleLaunchpad/View/AppGridView.swift +++ b/FuckAppleLaunchpad/View/AppGridView.swift @@ -1,44 +1,46 @@ import SwiftUI struct AppGridView: View { - let apps: [Application] - @State private var appItemViewHeight: CGFloat = 0 - @State private var appItemViewWidth: CGFloat = 0 - @State private var appGridViewVerticalPadding: CGFloat = 120.0 - @State private var appGridViewHorizontalPadding: CGFloat = 420.0 + let items: [LaunchpadItem] + let onGroupTap: (Group) -> Void + let onAppTap: (Application) -> Void + @State private var itemWidth: CGFloat = 0 + @State private var itemHeight: CGFloat = 0 + @State private var verticalPadding: CGFloat = 120.0 + @State private var horizontalPadding: CGFloat = 420.0 var body: some View { Grid(horizontalSpacing: 2, verticalSpacing: 2) { ForEach(0..<5) { rowIndex in GridRow { ForEach(0..<7) { columnIndex in - if rowIndex * 7 + columnIndex < apps.count { - AppItemView(app: apps[rowIndex * 7 + columnIndex]) - .frame(width: appItemViewWidth, height: appItemViewHeight) + let index = rowIndex * 7 + columnIndex + if index < items.count { + LaunchpadItemView(item: items[index], onGroupTap: onGroupTap, onAppTap: onAppTap) + .frame(width: itemWidth, height: itemHeight) } else { Color.clear - .frame(width: appItemViewWidth, height: appItemViewHeight) + .frame(width: itemWidth, height: itemHeight) } } } } } - .onAppear(perform: { - updateAppItemViewSize() - }) + .onAppear { + updateItemSize() + } } - func updateAppItemViewSize() { + func updateItemSize() { guard let screenHeight = NSScreen.main?.visibleFrame.height, let screenWidth = NSScreen.main?.visibleFrame.width else { return } if screenWidth <= 3456.0 / 2.0 { - appGridViewHorizontalPadding = 100.0 - appGridViewVerticalPadding = 40.0 + horizontalPadding = 100.0 + verticalPadding = 40.0 } - print("screenHeight", screenHeight, "screenWidth", screenWidth) - appItemViewWidth = (screenWidth - appGridViewHorizontalPadding * 2 ) / 7.0 - appItemViewHeight = (screenHeight - appGridViewVerticalPadding * 2 ) / 5.0 + itemWidth = (screenWidth - horizontalPadding * 2) / 7.0 + itemHeight = (screenHeight - verticalPadding * 2) / 5.0 } } diff --git a/FuckAppleLaunchpad/View/AppItemView.swift b/FuckAppleLaunchpad/View/AppItemView.swift index da2295c..321acff 100644 --- a/FuckAppleLaunchpad/View/AppItemView.swift +++ b/FuckAppleLaunchpad/View/AppItemView.swift @@ -3,6 +3,7 @@ import AppKit struct AppItemView: View { let app: Application + var onTap: ((Application) -> Void)? = nil var body: some View { VStack(spacing: 12) { @@ -29,6 +30,7 @@ struct AppItemView: View { } .contentShape(Rectangle()) .onTapGesture { + onTap?(app) NSWorkspace.shared.open(app.path) NSApplication.shared.terminate(nil) } diff --git a/FuckAppleLaunchpad/View/GroupItemView.swift b/FuckAppleLaunchpad/View/GroupItemView.swift new file mode 100644 index 0000000..1a3f2a9 --- /dev/null +++ b/FuckAppleLaunchpad/View/GroupItemView.swift @@ -0,0 +1,30 @@ +import SwiftUI +import AppKit + +struct GroupItemView: View { + let group: Group + let onTap: () -> Void + + var body: some View { + VStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(0.5)) + .frame(width: 104, height: 104) + Image(systemName: "folder.fill") + .resizable() + .frame(width: 60, height: 48) + .foregroundColor(.white) + } + Text(group.name) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .lineLimit(2) + .multilineTextAlignment(.center) + } + .contentShape(Rectangle()) + .onTapGesture { + onTap() + } + } +} \ No newline at end of file diff --git a/FuckAppleLaunchpad/View/LaunchpadItemView.swift b/FuckAppleLaunchpad/View/LaunchpadItemView.swift new file mode 100644 index 0000000..8928c32 --- /dev/null +++ b/FuckAppleLaunchpad/View/LaunchpadItemView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct LaunchpadItemView: View { + let item: LaunchpadItem + let onGroupTap: (Group) -> Void + let onAppTap: (Application) -> Void + + var body: some View { + switch item { + case .app(let app): + AppItemView(app: app, onTap: onAppTap) + case .group(let group): + GroupItemView(group: group) { + onGroupTap(group) + } + } + } +} \ No newline at end of file diff --git a/FuckAppleLaunchpad/View/PageView.swift b/FuckAppleLaunchpad/View/PageView.swift index 5393d03..8077dae 100644 --- a/FuckAppleLaunchpad/View/PageView.swift +++ b/FuckAppleLaunchpad/View/PageView.swift @@ -3,6 +3,7 @@ import SwiftUI struct PageView: View { @State private var currentPage: Int? = 0 @StateObject private var viewModel = LaunchpadViewViewModel() + @State private var openedGroup: Group? = nil @State private var appGridViewVerticalPadding: CGFloat = 120.0 var body: some View { @@ -10,13 +11,20 @@ struct PageView: View { VStack(spacing: 0) { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 0) { - ForEach(viewModel.applicationPages.indices, id: \.self) { index in - AppGridView(apps: viewModel.applicationPages[index].apps) - .frame(width: geometry.size.width, - height: geometry.size.height - appGridViewVerticalPadding * 2) - .containerRelativeFrame(.horizontal) - .id(index) - } + ForEach(viewModel.applicationPages.indices, id: \.self) { index in + AppGridView(items: viewModel.applicationPages[index].items, + onGroupTap: { group in + openedGroup = group + viewModel.recordGroupOpen(group) + }, + onAppTap: { app in + viewModel.recordAppOpen(app) + }) + .frame(width: geometry.size.width, + height: geometry.size.height - appGridViewVerticalPadding * 2) + .containerRelativeFrame(.horizontal) + .id(index) + } } .scrollTargetLayout() } @@ -30,9 +38,15 @@ struct PageView: View { .frame(width: 9, height: 9) } } + .padding(.bottom, 60) } .padding(.top, appGridViewVerticalPadding) } + .overlay { + if let group = openedGroup { + GroupDetailView(group: group, onClose: { openedGroup = nil }, viewModel: viewModel) + } + } .onAppear { viewModel.loadApplications() updateAppItemViewSize() @@ -49,6 +63,60 @@ struct PageView: View { } } +struct GroupDetailView: View { + let group: Group + let onClose: () -> Void + @ObservedObject var viewModel: LaunchpadViewViewModel + + var body: some View { + ZStack { + Color.black.opacity(0.7) + .ignoresSafeArea() + .onTapGesture { + onClose() + } + + VStack(spacing: 24) { + Text(group.name) + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.white) + + ScrollView { + LazyVGrid(columns: Array(repeating: GridItem(.fixed(110), spacing: 16), count: 6), spacing: 24) { + ForEach(group.apps) { app in + AppItemView(app: app) { tappedApp in + viewModel.recordAppOpen(tappedApp) + } + .onTapGesture { + if let path = app.path.path(percentEncoded: false) as? String { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + onClose() + NSApplication.shared.terminate(nil) + } + } + } + } + .padding(40) + } + + Text("Click outside or press ESC to close") + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.6)) + } + .frame(maxWidth: 900, maxHeight: 600) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.ultraThinMaterial) + ) + .shadow(radius: 30) + } + .onAppear { + // Focus the window so ESC key works + NSApp.activate(ignoringOtherApps: true) + } + } +} + #Preview { PageView() .frame(width: 3024/2, height: 1964/2) diff --git a/FuckAppleLaunchpad/ViewModel/ApplicationsViewModel.swift b/FuckAppleLaunchpad/ViewModel/ApplicationsViewModel.swift index 9d9b96e..c2e8bb2 100644 --- a/FuckAppleLaunchpad/ViewModel/ApplicationsViewModel.swift +++ b/FuckAppleLaunchpad/ViewModel/ApplicationsViewModel.swift @@ -2,57 +2,146 @@ import SwiftUI import AppKit import Combine -struct Application: Identifiable { - let id = UUID() - let name: String - let path: URL - let icon: NSImage? -} - -struct ApplicationPage: Identifiable { - let id = UUID() - let apps: [Application] +struct AppStatistics: Codable { + let openCount: Int + let lastOpenedTime: Date } class LaunchpadViewViewModel: ObservableObject { @Published var applications: [Application] = [] + @Published var groups: [Group] = [] + + private let appStatsKey = "com.launchpad.appStats" + private let groupStatsKey = "com.launchpad.groupStats" + private let fileManager = FileManager.default + + var launchpadItems: [LaunchpadItem] { + var items: [LaunchpadItem] = groups.map { .group($0) } + items.append(contentsOf: applications.map { .app($0) }) + return items + } + var applicationPages: [ApplicationPage] { - stride(from: 0, to: applications.count, by: 35).map { index in - ApplicationPage(apps: Array(applications[index.. app2.openCount + } + return app1.lastOpenedTime > app2.lastOpenedTime + } + + statsGroups.sort { group1, group2 in + if group1.openCount != group2.openCount { + return group1.openCount > group2.openCount + } + return group1.lastOpenedTime > group2.lastOpenedTime + } + + self.applications = statsApps + self.groups = statsGroups + print("total apps loaded:", applications.count, "groups:", groups.count) } } - // TODO: Fix issue on Error accessing User Applications - private func getUserApplicationsPath() -> String? { - let userHomePath = FileManager.default.homeDirectoryForCurrentUser.path - let components = userHomePath.components(separatedBy: "/") - if components.count >= 3, - components[1] == "Users" { - let homePath = "/\(components[1])/\(components[2])/Applications" - return homePath - } else { - return nil + private func saveAppStats(for app: Application) { + var stats = loadAllAppStats() + stats[app.id.uuidString] = AppStatistics(openCount: app.openCount, lastOpenedTime: app.lastOpenedTime) + UserDefaults.standard.set(try? JSONEncoder().encode(stats), forKey: appStatsKey) + } + + private func saveGroupStats(for group: Group) { + var stats = loadAllGroupStats() + stats[group.id.uuidString] = AppStatistics(openCount: group.openCount, lastOpenedTime: group.lastOpenedTime) + UserDefaults.standard.set(try? JSONEncoder().encode(stats), forKey: groupStatsKey) + } + + private func loadAppStats(for app: Application) -> Application? { + let stats = loadAllAppStats() + guard let stat = stats[app.id.uuidString] else { return nil } + var updated = app + updated.openCount = stat.openCount + updated.lastOpenedTime = stat.lastOpenedTime + return updated + } + + private func loadGroupStats(for group: Group) -> Group? { + let stats = loadAllGroupStats() + guard let stat = stats[group.id.uuidString] else { return nil } + var updated = group + updated.openCount = stat.openCount + updated.lastOpenedTime = stat.lastOpenedTime + return updated + } + + private func loadAllAppStats() -> [String: AppStatistics] { + guard let data = UserDefaults.standard.data(forKey: appStatsKey), + let stats = try? JSONDecoder().decode([String: AppStatistics].self, from: data) else { + return [:] + } + return stats + } + + private func loadAllGroupStats() -> [String: AppStatistics] { + guard let data = UserDefaults.standard.data(forKey: groupStatsKey), + let stats = try? JSONDecoder().decode([String: AppStatistics].self, from: data) else { + return [:] } + return stats } + - private func loadApplications(from folderUrl: URL, isLoadingDirectory: Bool) -> [Application] { + private func loadApplicationsAndFolders(from folderUrl: URL) -> (apps: [Application], groups: [Group]) { let fileManager = FileManager.default var applications: [Application] = [] + var groups: [Group] = [] + do { let resourceKeys: [URLResourceKey] = [.isApplicationKey, .isDirectoryKey] guard let enumerator = fileManager.enumerator(at: folderUrl, @@ -62,24 +151,76 @@ class LaunchpadViewViewModel: ObservableObject { print("Error accessing \(url): \(error)") return true }) else { - return [] + return ([], []) } + while let url = enumerator.nextObject() as? URL { - if url.lastPathComponent.contains("app") { + let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys)) + + // Check if it's an app bundle + if url.lastPathComponent.contains(".app") { let name = url.deletingPathExtension().lastPathComponent let icon = NSWorkspace.shared.icon(forFile: url.path) applications.append(Application(name: name, path: url, icon: icon)) continue } - guard isLoadingDirectory else { continue } - let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys)) - if resourceValues.isDirectory == true { - applications.append(contentsOf: loadApplications(from: url, isLoadingDirectory: false)) + + // Check if it's a folder (not an app bundle) + if resourceValues.isDirectory == true && !url.lastPathComponent.contains(".app") { + let folderName = url.lastPathComponent + let appsInFolder = loadApplicationsInFolder(from: url) + + // Only create a group if folder has more than 2 apps + if appsInFolder.count > 2 { + let group = Group(name: folderName, apps: appsInFolder) + groups.append(group) + } } } } catch let error { - print("Error loading applications: \(error)") + print("Error loading applications and folders: \(error)") + } + + return (applications, groups) + } + + private func loadApplicationsInFolder(from folderUrl: URL) -> [Application] { + let fileManager = FileManager.default + var applications: [Application] = [] + + do { + let resourceKeys: [URLResourceKey] = [.isApplicationKey, .isDirectoryKey] + guard let enumerator = fileManager.enumerator(at: folderUrl, + includingPropertiesForKeys: resourceKeys, + options: [.skipsHiddenFiles], + errorHandler: { _, _ in true }) else { + return [] + } + + while let url = enumerator.nextObject() as? URL { + if url.lastPathComponent.contains(".app") { + let name = url.deletingPathExtension().lastPathComponent + let icon = NSWorkspace.shared.icon(forFile: url.path) + applications.append(Application(name: name, path: url, icon: icon)) + } + } + } catch { + print("Error loading applications in folder: \(error)") } + return applications } + + // TODO: Fix issue on Error accessing User Applications + private func getUserApplicationsPath() -> String? { + let userHomePath = FileManager.default.homeDirectoryForCurrentUser.path + let components = userHomePath.components(separatedBy: "/") + if components.count >= 3, + components[1] == "Users" { + let homePath = "/\(components[1])/\(components[2])/Applications" + return homePath + } else { + return nil + } + } } diff --git a/README.md b/README.md index e0b80d3..7f39505 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - Download the app and move it into "/Applications" folder. You all set. ## Requirements -- macOS 26.0 Tahoe +- macOS 16.0 or later - Swift 6.2 or later ### Version Compatibility @@ -28,6 +28,7 @@ |---------------------|---------------------|----------------------------------------| | v1.0.0 | macOS 26.0 | swiftlang-6.2.0.19.9 clang-1700.3.19.1 | | v1.0.1 | macOS 26.0 | swiftlang-6.2.0.19.9 clang-1700.3.19.1 | +| v1.1.0+ | macOS 16.0+ | swiftlang-6.2.0.19.9 clang-1700.3.19.1 | ## App Snapshot ![App Snapshot](https://github.com/sweetmans/FuckAppleLaunchpad/blob/develop/raw/develop/screenshot@macbook.png) diff --git a/raw/develop/screenshot@macbook.png b/raw/develop/screenshot@macbook.png new file mode 100644 index 0000000..9a4cafe Binary files /dev/null and b/raw/develop/screenshot@macbook.png differ