Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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`)
24 changes: 12 additions & 12 deletions FuckAppleLaunchpad.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)";
Expand All @@ -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;
};
Expand All @@ -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)";
Expand All @@ -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;
};
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
76 changes: 72 additions & 4 deletions FuckAppleLaunchpad/FuckAppleLaunchpadApp.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import AppKit
import ServiceManagement
@main

struct FuckAppleLaunchpadApp: App {
Expand All @@ -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)")
}
}
}
92 changes: 92 additions & 0 deletions FuckAppleLaunchpad/Models.swift
Original file line number Diff line number Diff line change
@@ -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]
}
Loading