Initial commit
This commit is contained in:
632
skills/expertise/macos-apps/references/app-architecture.md
Normal file
632
skills/expertise/macos-apps/references/app-architecture.md
Normal file
@@ -0,0 +1,632 @@
|
||||
<overview>
|
||||
State management, dependency injection, and app structure patterns for macOS apps. Use @Observable for shared state, environment for dependency injection, and structured async/await patterns for concurrency.
|
||||
</overview>
|
||||
|
||||
<recommended_structure>
|
||||
```
|
||||
MyApp/
|
||||
├── App/
|
||||
│ ├── MyApp.swift # @main entry point
|
||||
│ ├── AppState.swift # App-wide observable state
|
||||
│ └── AppCommands.swift # Menu bar commands
|
||||
├── Models/
|
||||
│ ├── Item.swift # Data models
|
||||
│ └── ItemStore.swift # Data access layer
|
||||
├── Views/
|
||||
│ ├── ContentView.swift # Main view
|
||||
│ ├── Sidebar/
|
||||
│ │ └── SidebarView.swift
|
||||
│ ├── Detail/
|
||||
│ │ └── DetailView.swift
|
||||
│ └── Settings/
|
||||
│ └── SettingsView.swift
|
||||
├── Services/
|
||||
│ ├── NetworkService.swift # API calls
|
||||
│ ├── StorageService.swift # Persistence
|
||||
│ └── NotificationService.swift
|
||||
├── Utilities/
|
||||
│ └── Extensions.swift
|
||||
└── Resources/
|
||||
└── Assets.xcassets
|
||||
```
|
||||
</recommended_structure>
|
||||
|
||||
<state_management>
|
||||
<observable_pattern>
|
||||
Use `@Observable` (macOS 14+) for shared state:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
class AppState {
|
||||
// Published properties - UI updates automatically
|
||||
var items: [Item] = []
|
||||
var selectedItemID: UUID?
|
||||
var isLoading = false
|
||||
var error: AppError?
|
||||
|
||||
// Computed properties
|
||||
var selectedItem: Item? {
|
||||
items.first { $0.id == selectedItemID }
|
||||
}
|
||||
|
||||
var hasSelection: Bool {
|
||||
selectedItemID != nil
|
||||
}
|
||||
|
||||
// Actions
|
||||
func selectItem(_ id: UUID?) {
|
||||
selectedItemID = id
|
||||
}
|
||||
|
||||
func addItem(_ item: Item) {
|
||||
items.append(item)
|
||||
selectedItemID = item.id
|
||||
}
|
||||
|
||||
func deleteSelected() {
|
||||
guard let id = selectedItemID else { return }
|
||||
items.removeAll { $0.id == id }
|
||||
selectedItemID = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
</observable_pattern>
|
||||
|
||||
<environment_injection>
|
||||
Inject state at app level:
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var appState = AppState()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Access in any view
|
||||
struct SidebarView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
List(appState.items, id: \.id) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</environment_injection>
|
||||
|
||||
<bindable_for_mutations>
|
||||
Use `@Bindable` for two-way bindings:
|
||||
|
||||
```swift
|
||||
struct DetailView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
if let item = appState.selectedItem {
|
||||
TextField("Name", text: Binding(
|
||||
get: { item.name },
|
||||
set: { newValue in
|
||||
if let index = appState.items.firstIndex(where: { $0.id == item.id }) {
|
||||
appState.items[index].name = newValue
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Or for direct observable property binding
|
||||
struct SettingsView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
Toggle("Show Hidden", isOn: $appState.showHidden)
|
||||
}
|
||||
}
|
||||
```
|
||||
</bindable_for_mutations>
|
||||
|
||||
<multiple_state_objects>
|
||||
Split state by domain:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
class UIState {
|
||||
var sidebarWidth: CGFloat = 250
|
||||
var inspectorVisible = true
|
||||
var selectedTab: Tab = .library
|
||||
}
|
||||
|
||||
@Observable
|
||||
class DataState {
|
||||
var items: [Item] = []
|
||||
var isLoading = false
|
||||
}
|
||||
|
||||
@Observable
|
||||
class NetworkState {
|
||||
var isConnected = true
|
||||
var lastSync: Date?
|
||||
}
|
||||
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var uiState = UIState()
|
||||
@State private var dataState = DataState()
|
||||
@State private var networkState = NetworkState()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(uiState)
|
||||
.environment(dataState)
|
||||
.environment(networkState)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</multiple_state_objects>
|
||||
</state_management>
|
||||
|
||||
<dependency_injection>
|
||||
<environment_keys>
|
||||
Define custom environment keys for services:
|
||||
|
||||
```swift
|
||||
// Define protocol
|
||||
protocol DataStoreProtocol {
|
||||
func fetchAll() async throws -> [Item]
|
||||
func save(_ item: Item) async throws
|
||||
func delete(_ id: UUID) async throws
|
||||
}
|
||||
|
||||
// Live implementation
|
||||
class LiveDataStore: DataStoreProtocol {
|
||||
func fetchAll() async throws -> [Item] {
|
||||
// Real implementation
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
// Environment key
|
||||
struct DataStoreKey: EnvironmentKey {
|
||||
static let defaultValue: DataStoreProtocol = LiveDataStore()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var dataStore: DataStoreProtocol {
|
||||
get { self[DataStoreKey.self] }
|
||||
set { self[DataStoreKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// Inject
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.dataStore, LiveDataStore())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use
|
||||
struct ItemListView: View {
|
||||
@Environment(\.dataStore) private var dataStore
|
||||
@State private var items: [Item] = []
|
||||
|
||||
var body: some View {
|
||||
List(items) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
.task {
|
||||
items = try? await dataStore.fetchAll() ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</environment_keys>
|
||||
|
||||
<testing_with_mocks>
|
||||
```swift
|
||||
// Mock for testing
|
||||
class MockDataStore: DataStoreProtocol {
|
||||
var itemsToReturn: [Item] = []
|
||||
var shouldThrow = false
|
||||
|
||||
func fetchAll() async throws -> [Item] {
|
||||
if shouldThrow { throw TestError.mockError }
|
||||
return itemsToReturn
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
// In preview or test
|
||||
#Preview {
|
||||
let mockStore = MockDataStore()
|
||||
mockStore.itemsToReturn = [
|
||||
Item(name: "Test 1"),
|
||||
Item(name: "Test 2")
|
||||
]
|
||||
|
||||
return ItemListView()
|
||||
.environment(\.dataStore, mockStore)
|
||||
}
|
||||
```
|
||||
</testing_with_mocks>
|
||||
|
||||
<service_container>
|
||||
For apps with many services:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
class ServiceContainer {
|
||||
let dataStore: DataStoreProtocol
|
||||
let networkService: NetworkServiceProtocol
|
||||
let authService: AuthServiceProtocol
|
||||
|
||||
init(
|
||||
dataStore: DataStoreProtocol = LiveDataStore(),
|
||||
networkService: NetworkServiceProtocol = LiveNetworkService(),
|
||||
authService: AuthServiceProtocol = LiveAuthService()
|
||||
) {
|
||||
self.dataStore = dataStore
|
||||
self.networkService = networkService
|
||||
self.authService = authService
|
||||
}
|
||||
|
||||
// Convenience for testing
|
||||
static func mock() -> ServiceContainer {
|
||||
ServiceContainer(
|
||||
dataStore: MockDataStore(),
|
||||
networkService: MockNetworkService(),
|
||||
authService: MockAuthService()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject container
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var services = ServiceContainer()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(services)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</service_container>
|
||||
</dependency_injection>
|
||||
|
||||
<app_lifecycle>
|
||||
<app_delegate>
|
||||
Use AppDelegate for lifecycle events:
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
// Setup logging, register defaults, etc.
|
||||
registerDefaults()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// Cleanup, save state
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
// Return true for utility apps
|
||||
return false
|
||||
}
|
||||
|
||||
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
|
||||
// Custom dock menu
|
||||
return createDockMenu()
|
||||
}
|
||||
|
||||
private func registerDefaults() {
|
||||
UserDefaults.standard.register(defaults: [
|
||||
"defaultName": "Untitled",
|
||||
"showWelcome": true
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
</app_delegate>
|
||||
|
||||
<scene_phase>
|
||||
React to app state changes:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
MainContent()
|
||||
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||
switch newPhase {
|
||||
case .active:
|
||||
// App became active
|
||||
Task { await appState.refresh() }
|
||||
case .inactive:
|
||||
// App going to background
|
||||
appState.saveState()
|
||||
case .background:
|
||||
// App in background
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</scene_phase>
|
||||
</app_lifecycle>
|
||||
|
||||
<coordinator_pattern>
|
||||
For complex navigation flows:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
class AppCoordinator {
|
||||
enum Route: Hashable {
|
||||
case home
|
||||
case detail(Item)
|
||||
case settings
|
||||
case onboarding
|
||||
}
|
||||
|
||||
var path = NavigationPath()
|
||||
var sheet: Route?
|
||||
var alert: AlertState?
|
||||
|
||||
func navigate(to route: Route) {
|
||||
path.append(route)
|
||||
}
|
||||
|
||||
func present(_ route: Route) {
|
||||
sheet = route
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
sheet = nil
|
||||
}
|
||||
|
||||
func popToRoot() {
|
||||
path = NavigationPath()
|
||||
}
|
||||
|
||||
func showError(_ error: Error) {
|
||||
alert = AlertState(
|
||||
title: "Error",
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
var body: some View {
|
||||
@Bindable var coordinator = coordinator
|
||||
|
||||
NavigationStack(path: $coordinator.path) {
|
||||
HomeView()
|
||||
.navigationDestination(for: AppCoordinator.Route.self) { route in
|
||||
switch route {
|
||||
case .home:
|
||||
HomeView()
|
||||
case .detail(let item):
|
||||
DetailView(item: item)
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .onboarding:
|
||||
OnboardingView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $coordinator.sheet) { route in
|
||||
// Sheet content
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</coordinator_pattern>
|
||||
|
||||
<error_handling>
|
||||
<error_types>
|
||||
Define domain-specific errors:
|
||||
|
||||
```swift
|
||||
enum AppError: LocalizedError {
|
||||
case networkError(underlying: Error)
|
||||
case dataCorrupted
|
||||
case unauthorized
|
||||
case notFound(String)
|
||||
case validationFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .networkError(let error):
|
||||
return "Network error: \(error.localizedDescription)"
|
||||
case .dataCorrupted:
|
||||
return "Data is corrupted and cannot be loaded"
|
||||
case .unauthorized:
|
||||
return "You are not authorized to perform this action"
|
||||
case .notFound(let item):
|
||||
return "\(item) not found"
|
||||
case .validationFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .networkError:
|
||||
return "Check your internet connection and try again"
|
||||
case .dataCorrupted:
|
||||
return "Try restarting the app or contact support"
|
||||
case .unauthorized:
|
||||
return "Please sign in again"
|
||||
case .notFound:
|
||||
return nil
|
||||
case .validationFailed:
|
||||
return "Please correct the issue and try again"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</error_types>
|
||||
|
||||
<error_presentation>
|
||||
Present errors to user:
|
||||
|
||||
```swift
|
||||
struct ErrorAlert: ViewModifier {
|
||||
@Binding var error: AppError?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert(
|
||||
"Error",
|
||||
isPresented: Binding(
|
||||
get: { error != nil },
|
||||
set: { if !$0 { error = nil } }
|
||||
),
|
||||
presenting: error
|
||||
) { _ in
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: { error in
|
||||
VStack {
|
||||
Text(error.localizedDescription)
|
||||
if let recovery = error.recoverySuggestion {
|
||||
Text(recovery)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func errorAlert(_ error: Binding<AppError?>) -> some View {
|
||||
modifier(ErrorAlert(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
struct ContentView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
MainContent()
|
||||
.errorAlert($appState.error)
|
||||
}
|
||||
}
|
||||
```
|
||||
</error_presentation>
|
||||
</error_handling>
|
||||
|
||||
<async_patterns>
|
||||
<task_management>
|
||||
```swift
|
||||
struct ItemListView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var loadTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
List(appState.items) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
.task {
|
||||
await loadItems()
|
||||
}
|
||||
.refreshable {
|
||||
await loadItems()
|
||||
}
|
||||
.onDisappear {
|
||||
loadTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadItems() async {
|
||||
loadTask?.cancel()
|
||||
loadTask = Task {
|
||||
await appState.loadItems()
|
||||
}
|
||||
await loadTask?.value
|
||||
}
|
||||
}
|
||||
```
|
||||
</task_management>
|
||||
|
||||
<async_sequences>
|
||||
```swift
|
||||
@Observable
|
||||
class NotificationListener {
|
||||
var notifications: [AppNotification] = []
|
||||
|
||||
func startListening() async {
|
||||
for await notification in NotificationCenter.default.notifications(named: .dataChanged) {
|
||||
guard !Task.isCancelled else { break }
|
||||
|
||||
if let userInfo = notification.userInfo,
|
||||
let appNotification = AppNotification(userInfo: userInfo) {
|
||||
await MainActor.run {
|
||||
notifications.append(appNotification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</async_sequences>
|
||||
</async_patterns>
|
||||
|
||||
<best_practices>
|
||||
<do>
|
||||
- Use `@Observable` for shared state (macOS 14+)
|
||||
- Inject dependencies through environment
|
||||
- Keep views focused - they ARE the view model in SwiftUI
|
||||
- Use protocols for testability
|
||||
- Handle errors at appropriate levels
|
||||
- Cancel tasks when views disappear
|
||||
</do>
|
||||
|
||||
<avoid>
|
||||
- Massive centralized state objects
|
||||
- Passing state through init parameters (use environment)
|
||||
- Business logic in views (use services)
|
||||
- Ignoring task cancellation
|
||||
- Retaining strong references to self in async closures
|
||||
</avoid>
|
||||
</best_practices>
|
||||
484
skills/expertise/macos-apps/references/app-extensions.md
Normal file
484
skills/expertise/macos-apps/references/app-extensions.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# App Extensions
|
||||
|
||||
Share extensions, widgets, Quick Look previews, and Shortcuts for macOS.
|
||||
|
||||
<share_extension>
|
||||
<setup>
|
||||
1. File > New > Target > Share Extension
|
||||
2. Configure activation rules in Info.plist
|
||||
3. Implement share view controller
|
||||
|
||||
**Info.plist activation rules**:
|
||||
```xml
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>10</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
```
|
||||
</setup>
|
||||
|
||||
<share_view_controller>
|
||||
```swift
|
||||
import Cocoa
|
||||
import Social
|
||||
|
||||
class ShareViewController: SLComposeServiceViewController {
|
||||
override func loadView() {
|
||||
super.loadView()
|
||||
// Customize title
|
||||
title = "Save to MyApp"
|
||||
}
|
||||
|
||||
override func didSelectPost() {
|
||||
// Get shared items
|
||||
guard let extensionContext = extensionContext else { return }
|
||||
|
||||
for item in extensionContext.inputItems as? [NSExtensionItem] ?? [] {
|
||||
for provider in item.attachments ?? [] {
|
||||
if provider.hasItemConformingToTypeIdentifier("public.url") {
|
||||
provider.loadItem(forTypeIdentifier: "public.url") { [weak self] url, error in
|
||||
if let url = url as? URL {
|
||||
self?.saveURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if provider.hasItemConformingToTypeIdentifier("public.image") {
|
||||
provider.loadItem(forTypeIdentifier: "public.image") { [weak self] image, error in
|
||||
if let image = image as? NSImage {
|
||||
self?.saveImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extensionContext.completeRequest(returningItems: nil)
|
||||
}
|
||||
|
||||
override func isContentValid() -> Bool {
|
||||
// Validate content before allowing post
|
||||
return !contentText.isEmpty
|
||||
}
|
||||
|
||||
override func didSelectCancel() {
|
||||
extensionContext?.cancelRequest(withError: NSError(domain: "ShareExtension", code: 0))
|
||||
}
|
||||
|
||||
private func saveURL(_ url: URL) {
|
||||
// Save to shared container
|
||||
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.myapp")
|
||||
var urls = sharedDefaults?.array(forKey: "savedURLs") as? [String] ?? []
|
||||
urls.append(url.absoluteString)
|
||||
sharedDefaults?.set(urls, forKey: "savedURLs")
|
||||
}
|
||||
|
||||
private func saveImage(_ image: NSImage) {
|
||||
// Save to shared container
|
||||
guard let data = image.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: data),
|
||||
let pngData = rep.representation(using: .png, properties: [:]) else { return }
|
||||
|
||||
let containerURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: "group.com.yourcompany.myapp"
|
||||
)!
|
||||
let imageURL = containerURL.appendingPathComponent(UUID().uuidString + ".png")
|
||||
try? pngData.write(to: imageURL)
|
||||
}
|
||||
}
|
||||
```
|
||||
</share_view_controller>
|
||||
|
||||
<app_groups>
|
||||
Share data between app and extension:
|
||||
|
||||
```xml
|
||||
<!-- Entitlements for both app and extension -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.yourcompany.myapp</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
```swift
|
||||
// Shared UserDefaults
|
||||
let shared = UserDefaults(suiteName: "group.com.yourcompany.myapp")
|
||||
|
||||
// Shared container
|
||||
let containerURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: "group.com.yourcompany.myapp"
|
||||
)
|
||||
```
|
||||
</app_groups>
|
||||
</share_extension>
|
||||
|
||||
<widgets>
|
||||
<widget_extension>
|
||||
1. File > New > Target > Widget Extension
|
||||
2. Define timeline provider
|
||||
3. Create widget view
|
||||
|
||||
```swift
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// Timeline entry
|
||||
struct ItemEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let items: [Item]
|
||||
}
|
||||
|
||||
// Timeline provider
|
||||
struct ItemProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> ItemEntry {
|
||||
ItemEntry(date: Date(), items: [.placeholder])
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (ItemEntry) -> Void) {
|
||||
let entry = ItemEntry(date: Date(), items: loadItems())
|
||||
completion(entry)
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<ItemEntry>) -> Void) {
|
||||
let items = loadItems()
|
||||
let entry = ItemEntry(date: Date(), items: items)
|
||||
|
||||
// Refresh every hour
|
||||
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())!
|
||||
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
|
||||
|
||||
completion(timeline)
|
||||
}
|
||||
|
||||
private func loadItems() -> [Item] {
|
||||
// Load from shared container
|
||||
let shared = UserDefaults(suiteName: "group.com.yourcompany.myapp")
|
||||
// ... deserialize items
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Widget view
|
||||
struct ItemWidgetView: View {
|
||||
var entry: ItemEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Recent Items")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(entry.items.prefix(3)) { item in
|
||||
HStack {
|
||||
Image(systemName: item.icon)
|
||||
Text(item.name)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// Widget configuration
|
||||
@main
|
||||
struct ItemWidget: Widget {
|
||||
let kind = "ItemWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: ItemProvider()) { entry in
|
||||
ItemWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Recent Items")
|
||||
.description("Shows your most recent items")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
```
|
||||
</widget_extension>
|
||||
|
||||
<widget_deep_links>
|
||||
```swift
|
||||
struct ItemWidgetView: View {
|
||||
var entry: ItemEntry
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ForEach(entry.items) { item in
|
||||
Link(destination: URL(string: "myapp://item/\(item.id)")!) {
|
||||
Text(item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
.widgetURL(URL(string: "myapp://widget"))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle in main app
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleURL(_ url: URL) {
|
||||
// Parse myapp://item/123
|
||||
if url.host == "item", let id = url.pathComponents.last {
|
||||
// Navigate to item
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</widget_deep_links>
|
||||
|
||||
<update_widget>
|
||||
```swift
|
||||
// From main app, tell widget to refresh
|
||||
import WidgetKit
|
||||
|
||||
func itemsChanged() {
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "ItemWidget")
|
||||
}
|
||||
|
||||
// Reload all widgets
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
```
|
||||
</update_widget>
|
||||
</widgets>
|
||||
|
||||
<quick_look>
|
||||
<preview_extension>
|
||||
1. File > New > Target > Quick Look Preview Extension
|
||||
2. Implement preview view controller
|
||||
|
||||
```swift
|
||||
import Cocoa
|
||||
import Quartz
|
||||
|
||||
class PreviewViewController: NSViewController, QLPreviewingController {
|
||||
@IBOutlet var textView: NSTextView!
|
||||
|
||||
func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) {
|
||||
do {
|
||||
let content = try loadDocument(at: url)
|
||||
textView.string = content.text
|
||||
handler(nil)
|
||||
} catch {
|
||||
handler(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadDocument(at url: URL) throws -> DocumentContent {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode(DocumentContent.self, from: data)
|
||||
}
|
||||
}
|
||||
```
|
||||
</preview_extension>
|
||||
|
||||
<thumbnail_extension>
|
||||
1. File > New > Target > Thumbnail Extension
|
||||
|
||||
```swift
|
||||
import QuickLookThumbnailing
|
||||
|
||||
class ThumbnailProvider: QLThumbnailProvider {
|
||||
override func provideThumbnail(
|
||||
for request: QLFileThumbnailRequest,
|
||||
_ handler: @escaping (QLThumbnailReply?, Error?) -> Void
|
||||
) {
|
||||
let size = request.maximumSize
|
||||
|
||||
handler(QLThumbnailReply(contextSize: size) { context -> Bool in
|
||||
// Draw thumbnail
|
||||
let content = self.loadContent(at: request.fileURL)
|
||||
self.drawThumbnail(content, in: context, size: size)
|
||||
return true
|
||||
}, nil)
|
||||
}
|
||||
|
||||
private func drawThumbnail(_ content: DocumentContent, in context: CGContext, size: CGSize) {
|
||||
// Draw background
|
||||
context.setFillColor(NSColor.white.cgColor)
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
|
||||
// Draw content preview
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
</thumbnail_extension>
|
||||
</quick_look>
|
||||
|
||||
<shortcuts>
|
||||
<app_intents>
|
||||
```swift
|
||||
import AppIntents
|
||||
|
||||
// Define intent
|
||||
struct CreateItemIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Create Item"
|
||||
static var description = IntentDescription("Creates a new item in MyApp")
|
||||
|
||||
@Parameter(title: "Name")
|
||||
var name: String
|
||||
|
||||
@Parameter(title: "Folder", optionsProvider: FolderOptionsProvider())
|
||||
var folder: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let item = Item(name: name)
|
||||
if let folderName = folder {
|
||||
item.folder = findFolder(named: folderName)
|
||||
}
|
||||
|
||||
try await DataService.shared.save(item)
|
||||
|
||||
return .result(dialog: "Created \(name)")
|
||||
}
|
||||
}
|
||||
|
||||
// Options provider
|
||||
struct FolderOptionsProvider: DynamicOptionsProvider {
|
||||
func results() async throws -> [String] {
|
||||
let folders = try await DataService.shared.fetchFolders()
|
||||
return folders.map { $0.name }
|
||||
}
|
||||
}
|
||||
|
||||
// Register shortcuts
|
||||
struct MyAppShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: CreateItemIntent(),
|
||||
phrases: [
|
||||
"Create item in \(.applicationName)",
|
||||
"New \(.applicationName) item"
|
||||
],
|
||||
shortTitle: "Create Item",
|
||||
systemImageName: "plus.circle"
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
</app_intents>
|
||||
|
||||
<entity_queries>
|
||||
```swift
|
||||
// Define entity
|
||||
struct ItemEntity: AppEntity {
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Item")
|
||||
|
||||
var id: UUID
|
||||
var name: String
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: "\(name)")
|
||||
}
|
||||
|
||||
static var defaultQuery = ItemQuery()
|
||||
}
|
||||
|
||||
// Define query
|
||||
struct ItemQuery: EntityQuery {
|
||||
func entities(for identifiers: [UUID]) async throws -> [ItemEntity] {
|
||||
let items = try await DataService.shared.fetchItems(ids: identifiers)
|
||||
return items.map { ItemEntity(id: $0.id, name: $0.name) }
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [ItemEntity] {
|
||||
let items = try await DataService.shared.recentItems(limit: 10)
|
||||
return items.map { ItemEntity(id: $0.id, name: $0.name) }
|
||||
}
|
||||
}
|
||||
|
||||
// Use in intent
|
||||
struct OpenItemIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open Item"
|
||||
|
||||
@Parameter(title: "Item")
|
||||
var item: ItemEntity
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
// Open item in app
|
||||
NotificationCenter.default.post(
|
||||
name: .openItem,
|
||||
object: nil,
|
||||
userInfo: ["id": item.id]
|
||||
)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
```
|
||||
</entity_queries>
|
||||
</shortcuts>
|
||||
|
||||
<action_extension>
|
||||
```swift
|
||||
import Cocoa
|
||||
|
||||
class ActionViewController: NSViewController {
|
||||
@IBOutlet var textView: NSTextView!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Get input items
|
||||
for item in extensionContext?.inputItems as? [NSExtensionItem] ?? [] {
|
||||
for provider in item.attachments ?? [] {
|
||||
if provider.hasItemConformingToTypeIdentifier("public.text") {
|
||||
provider.loadItem(forTypeIdentifier: "public.text") { [weak self] text, _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.textView.string = text as? String ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func done(_ sender: Any) {
|
||||
// Return modified content
|
||||
let outputItem = NSExtensionItem()
|
||||
outputItem.attachments = [
|
||||
NSItemProvider(item: textView.string as NSString, typeIdentifier: "public.text")
|
||||
]
|
||||
|
||||
extensionContext?.completeRequest(returningItems: [outputItem])
|
||||
}
|
||||
|
||||
@IBAction func cancel(_ sender: Any) {
|
||||
extensionContext?.cancelRequest(withError: NSError(domain: "ActionExtension", code: 0))
|
||||
}
|
||||
}
|
||||
```
|
||||
</action_extension>
|
||||
|
||||
<extension_best_practices>
|
||||
- Share data via App Groups
|
||||
- Keep extensions lightweight (memory limits)
|
||||
- Handle errors gracefully
|
||||
- Test in all contexts (Finder, Safari, etc.)
|
||||
- Update Info.plist activation rules carefully
|
||||
- Use WidgetCenter.shared.reloadTimelines() to update widgets
|
||||
- Define clear App Intents with good phrases
|
||||
</extension_best_practices>
|
||||
485
skills/expertise/macos-apps/references/appkit-integration.md
Normal file
485
skills/expertise/macos-apps/references/appkit-integration.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# AppKit Integration
|
||||
|
||||
When and how to use AppKit alongside SwiftUI for advanced functionality.
|
||||
|
||||
<when_to_use_appkit>
|
||||
Use AppKit (not SwiftUI) when you need:
|
||||
- Custom drawing with `NSView.draw(_:)`
|
||||
- Complex text editing (`NSTextView`)
|
||||
- Drag and drop with custom behaviors
|
||||
- Low-level event handling
|
||||
- Popovers with specific positioning
|
||||
- Custom window chrome
|
||||
- Backward compatibility (< macOS 13)
|
||||
|
||||
**Anti-pattern: Using AppKit to "fix" SwiftUI**
|
||||
|
||||
Before reaching for AppKit as a workaround:
|
||||
1. Search your SwiftUI code for what's declaratively controlling the behavior
|
||||
2. SwiftUI wrappers (NSHostingView, NSViewRepresentable) manage their wrapped AppKit objects
|
||||
3. Your AppKit code may run but be overridden by SwiftUI's declarative layer
|
||||
4. Example: Setting `NSWindow.minSize` is ignored if content view has `.frame(minWidth:)`
|
||||
|
||||
**Debugging mindset:**
|
||||
- SwiftUI's declarative layer = policy
|
||||
- AppKit's imperative APIs = implementation details
|
||||
- Policy wins. Check policy first.
|
||||
|
||||
Prefer SwiftUI for everything else.
|
||||
</when_to_use_appkit>
|
||||
|
||||
<nsviewrepresentable>
|
||||
<basic_pattern>
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
struct CustomCanvasView: NSViewRepresentable {
|
||||
@Binding var drawing: Drawing
|
||||
|
||||
func makeNSView(context: Context) -> CanvasNSView {
|
||||
let view = CanvasNSView()
|
||||
view.delegate = context.coordinator
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: CanvasNSView, context: Context) {
|
||||
nsView.drawing = drawing
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, CanvasDelegate {
|
||||
var parent: CustomCanvasView
|
||||
|
||||
init(_ parent: CustomCanvasView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func canvasDidUpdate(_ drawing: Drawing) {
|
||||
parent.drawing = drawing
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</basic_pattern>
|
||||
|
||||
<with_sizeThatFits>
|
||||
```swift
|
||||
struct IntrinsicSizeView: NSViewRepresentable {
|
||||
let text: String
|
||||
|
||||
func makeNSView(context: Context) -> NSTextField {
|
||||
let field = NSTextField(labelWithString: text)
|
||||
field.setContentHuggingPriority(.required, for: .horizontal)
|
||||
return field
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSTextField, context: Context) {
|
||||
nsView.stringValue = text
|
||||
}
|
||||
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextField, context: Context) -> CGSize? {
|
||||
nsView.fittingSize
|
||||
}
|
||||
}
|
||||
```
|
||||
</with_sizeThatFits>
|
||||
</nsviewrepresentable>
|
||||
|
||||
<custom_nsview>
|
||||
<drawing_view>
|
||||
```swift
|
||||
import AppKit
|
||||
|
||||
class CanvasNSView: NSView {
|
||||
var drawing: Drawing = Drawing() {
|
||||
didSet { needsDisplay = true }
|
||||
}
|
||||
|
||||
weak var delegate: CanvasDelegate?
|
||||
|
||||
override var isFlipped: Bool { true } // Use top-left origin
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
||||
|
||||
// Background
|
||||
NSColor.windowBackgroundColor.setFill()
|
||||
context.fill(bounds)
|
||||
|
||||
// Draw content
|
||||
for path in drawing.paths {
|
||||
context.setStrokeColor(path.color.cgColor)
|
||||
context.setLineWidth(path.lineWidth)
|
||||
context.addPath(path.cgPath)
|
||||
context.strokePath()
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse handling
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
drawing.startPath(at: point)
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
drawing.addPoint(point)
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
drawing.endPath()
|
||||
delegate?.canvasDidUpdate(drawing)
|
||||
}
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
}
|
||||
|
||||
protocol CanvasDelegate: AnyObject {
|
||||
func canvasDidUpdate(_ drawing: Drawing)
|
||||
}
|
||||
```
|
||||
</drawing_view>
|
||||
|
||||
<keyboard_handling>
|
||||
```swift
|
||||
class KeyHandlingView: NSView {
|
||||
var onKeyPress: ((NSEvent) -> Bool)?
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
if let handler = onKeyPress, handler(event) {
|
||||
return // Event handled
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
override func flagsChanged(with event: NSEvent) {
|
||||
// Handle modifier key changes
|
||||
if event.modifierFlags.contains(.shift) {
|
||||
// Shift pressed
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</keyboard_handling>
|
||||
</custom_nsview>
|
||||
|
||||
<nstextview_integration>
|
||||
<rich_text_editor>
|
||||
```swift
|
||||
struct RichTextEditor: NSViewRepresentable {
|
||||
@Binding var attributedText: NSAttributedString
|
||||
var isEditable: Bool = true
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scrollView = NSTextView.scrollableTextView()
|
||||
let textView = scrollView.documentView as! NSTextView
|
||||
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = isEditable
|
||||
textView.isRichText = true
|
||||
textView.allowsUndo = true
|
||||
textView.usesFontPanel = true
|
||||
textView.usesRuler = true
|
||||
textView.isRulerVisible = true
|
||||
|
||||
// Typography
|
||||
textView.textContainerInset = NSSize(width: 20, height: 20)
|
||||
textView.font = .systemFont(ofSize: 14)
|
||||
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSScrollView, context: Context) {
|
||||
let textView = nsView.documentView as! NSTextView
|
||||
|
||||
if textView.attributedString() != attributedText {
|
||||
textView.textStorage?.setAttributedString(attributedText)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, NSTextViewDelegate {
|
||||
var parent: RichTextEditor
|
||||
|
||||
init(_ parent: RichTextEditor) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let textView = notification.object as? NSTextView else { return }
|
||||
parent.attributedText = textView.attributedString()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</rich_text_editor>
|
||||
</nstextview_integration>
|
||||
|
||||
<nshostingview>
|
||||
Use SwiftUI views in AppKit:
|
||||
|
||||
```swift
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
class MyWindowController: NSWindowController {
|
||||
convenience init() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
// SwiftUI content in AppKit window
|
||||
let hostingView = NSHostingView(
|
||||
rootView: ContentView()
|
||||
.environment(appState)
|
||||
)
|
||||
window.contentView = hostingView
|
||||
|
||||
self.init(window: window)
|
||||
}
|
||||
}
|
||||
|
||||
// In toolbar item
|
||||
class ToolbarItemController: NSToolbarItem {
|
||||
override init(itemIdentifier: NSToolbarItem.Identifier) {
|
||||
super.init(itemIdentifier: itemIdentifier)
|
||||
|
||||
let hostingView = NSHostingView(rootView: ToolbarButton())
|
||||
view = hostingView
|
||||
}
|
||||
}
|
||||
```
|
||||
</nshostingview>
|
||||
|
||||
<drag_and_drop>
|
||||
<dragging_source>
|
||||
```swift
|
||||
class DraggableView: NSView, NSDraggingSource {
|
||||
var item: Item?
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard let item = item else { return }
|
||||
|
||||
let pasteboardItem = NSPasteboardItem()
|
||||
pasteboardItem.setString(item.id.uuidString, forType: .string)
|
||||
|
||||
let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem)
|
||||
draggingItem.setDraggingFrame(bounds, contents: snapshot())
|
||||
|
||||
beginDraggingSession(with: [draggingItem], event: event, source: self)
|
||||
}
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
|
||||
context == .withinApplication ? .move : .copy
|
||||
}
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
|
||||
if operation == .move {
|
||||
// Remove from source
|
||||
}
|
||||
}
|
||||
|
||||
private func snapshot() -> NSImage {
|
||||
let image = NSImage(size: bounds.size)
|
||||
image.lockFocus()
|
||||
draw(bounds)
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
}
|
||||
```
|
||||
</dragging_source>
|
||||
|
||||
<dragging_destination>
|
||||
```swift
|
||||
class DropTargetView: NSView {
|
||||
var onDrop: (([String]) -> Bool)?
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
registerForDraggedTypes([.string, .fileURL])
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
.copy
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
let pasteboard = sender.draggingPasteboard
|
||||
|
||||
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] {
|
||||
return onDrop?(urls.map { $0.path }) ?? false
|
||||
}
|
||||
|
||||
if let strings = pasteboard.readObjects(forClasses: [NSString.self]) as? [String] {
|
||||
return onDrop?(strings) ?? false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
</dragging_destination>
|
||||
</drag_and_drop>
|
||||
|
||||
<window_customization>
|
||||
<custom_titlebar>
|
||||
```swift
|
||||
class CustomWindow: NSWindow {
|
||||
override init(
|
||||
contentRect: NSRect,
|
||||
styleMask style: NSWindow.StyleMask,
|
||||
backing backingStoreType: NSWindow.BackingStoreType,
|
||||
defer flag: Bool
|
||||
) {
|
||||
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||
|
||||
// Transparent titlebar
|
||||
titlebarAppearsTransparent = true
|
||||
titleVisibility = .hidden
|
||||
|
||||
// Full-size content
|
||||
styleMask.insert(.fullSizeContentView)
|
||||
|
||||
// Custom background
|
||||
backgroundColor = .windowBackgroundColor
|
||||
isOpaque = false
|
||||
}
|
||||
}
|
||||
```
|
||||
</custom_titlebar>
|
||||
|
||||
<access_window_from_swiftui>
|
||||
```swift
|
||||
struct WindowAccessor: NSViewRepresentable {
|
||||
var callback: (NSWindow?) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
callback(view.window)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
|
||||
// Usage
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
MainContent()
|
||||
.background(WindowAccessor { window in
|
||||
window?.titlebarAppearsTransparent = true
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
</access_window_from_swiftui>
|
||||
</window_customization>
|
||||
|
||||
<popover>
|
||||
```swift
|
||||
class PopoverController {
|
||||
private var popover: NSPopover?
|
||||
|
||||
func show(from view: NSView, content: some View) {
|
||||
let popover = NSPopover()
|
||||
popover.contentViewController = NSHostingController(rootView: content)
|
||||
popover.behavior = .transient
|
||||
|
||||
popover.show(
|
||||
relativeTo: view.bounds,
|
||||
of: view,
|
||||
preferredEdge: .minY
|
||||
)
|
||||
|
||||
self.popover = popover
|
||||
}
|
||||
|
||||
func close() {
|
||||
popover?.close()
|
||||
popover = nil
|
||||
}
|
||||
}
|
||||
|
||||
// SwiftUI wrapper
|
||||
struct PopoverButton<Content: View>: NSViewRepresentable {
|
||||
@Binding var isPresented: Bool
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
func makeNSView(context: Context) -> NSButton {
|
||||
let button = NSButton(title: "Show", target: context.coordinator, action: #selector(Coordinator.showPopover))
|
||||
return button
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSButton, context: Context) {
|
||||
context.coordinator.isPresented = isPresented
|
||||
context.coordinator.content = AnyView(content())
|
||||
|
||||
if !isPresented {
|
||||
context.coordinator.popover?.close()
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, NSPopoverDelegate {
|
||||
var parent: PopoverButton
|
||||
var popover: NSPopover?
|
||||
var isPresented: Bool = false
|
||||
var content: AnyView = AnyView(EmptyView())
|
||||
|
||||
init(_ parent: PopoverButton) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
@objc func showPopover(_ sender: NSButton) {
|
||||
let popover = NSPopover()
|
||||
popover.contentViewController = NSHostingController(rootView: content)
|
||||
popover.behavior = .transient
|
||||
popover.delegate = self
|
||||
|
||||
popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
|
||||
self.popover = popover
|
||||
parent.isPresented = true
|
||||
}
|
||||
|
||||
func popoverDidClose(_ notification: Notification) {
|
||||
parent.isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</popover>
|
||||
|
||||
<best_practices>
|
||||
<do>
|
||||
- Use NSViewRepresentable for custom views
|
||||
- Use Coordinator for delegate callbacks
|
||||
- Clean up resources in NSViewRepresentable
|
||||
- Use NSHostingView to embed SwiftUI in AppKit
|
||||
</do>
|
||||
|
||||
<avoid>
|
||||
- Using AppKit when SwiftUI suffices
|
||||
- Forgetting to set acceptsFirstResponder for keyboard input
|
||||
- Not handling coordinate system (isFlipped)
|
||||
- Memory leaks from strong delegate references
|
||||
</avoid>
|
||||
</best_practices>
|
||||
379
skills/expertise/macos-apps/references/cli-observability.md
Normal file
379
skills/expertise/macos-apps/references/cli-observability.md
Normal file
@@ -0,0 +1,379 @@
|
||||
# CLI Observability
|
||||
|
||||
Complete debugging and monitoring without opening Xcode. Claude has full visibility into build errors, runtime logs, crashes, memory issues, and network traffic.
|
||||
|
||||
<prerequisites>
|
||||
```bash
|
||||
# Install observability tools (one-time)
|
||||
brew tap ldomaradzki/xcsift && brew install xcsift
|
||||
brew install mitmproxy xcbeautify
|
||||
```
|
||||
</prerequisites>
|
||||
|
||||
<build_output>
|
||||
## Build Error Parsing
|
||||
|
||||
**xcsift** converts verbose xcodebuild output to token-efficient JSON for AI agents:
|
||||
|
||||
```bash
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp build 2>&1 | xcsift
|
||||
```
|
||||
|
||||
Output includes structured errors with file paths and line numbers:
|
||||
```json
|
||||
{
|
||||
"status": "failed",
|
||||
"errors": [
|
||||
{"file": "/path/File.swift", "line": 42, "message": "Type mismatch..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative** (human-readable):
|
||||
```bash
|
||||
xcodebuild build 2>&1 | xcbeautify
|
||||
```
|
||||
</build_output>
|
||||
|
||||
<runtime_logging>
|
||||
## Runtime Logs
|
||||
|
||||
### In-App Logging Pattern
|
||||
|
||||
Add to all apps:
|
||||
```swift
|
||||
import os
|
||||
|
||||
extension Logger {
|
||||
static let app = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "App")
|
||||
static let network = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Network")
|
||||
static let data = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Data")
|
||||
}
|
||||
|
||||
// Usage
|
||||
Logger.network.debug("Request: \(url)")
|
||||
Logger.data.error("Save failed: \(error)")
|
||||
```
|
||||
|
||||
### Stream Logs from Running App
|
||||
|
||||
```bash
|
||||
# All logs from your app
|
||||
log stream --level debug --predicate 'subsystem == "com.yourcompany.MyApp"'
|
||||
|
||||
# Filter by category
|
||||
log stream --level debug \
|
||||
--predicate 'subsystem == "com.yourcompany.MyApp" AND category == "Network"'
|
||||
|
||||
# Errors only
|
||||
log stream --predicate 'subsystem == "com.yourcompany.MyApp" AND messageType == error'
|
||||
|
||||
# JSON output for parsing
|
||||
log stream --level debug --style json \
|
||||
--predicate 'subsystem == "com.yourcompany.MyApp"'
|
||||
```
|
||||
|
||||
### Search Historical Logs
|
||||
|
||||
```bash
|
||||
# Last hour
|
||||
log show --predicate 'subsystem == "com.yourcompany.MyApp"' --last 1h
|
||||
|
||||
# Export to file
|
||||
log show --predicate 'subsystem == "com.yourcompany.MyApp"' --last 1h > logs.txt
|
||||
```
|
||||
</runtime_logging>
|
||||
|
||||
<crash_analysis>
|
||||
## Crash Logs
|
||||
|
||||
### Find Crashes
|
||||
|
||||
```bash
|
||||
# List crash reports
|
||||
ls ~/Library/Logs/DiagnosticReports/ | grep MyApp
|
||||
|
||||
# View latest crash
|
||||
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -200
|
||||
```
|
||||
|
||||
### Symbolicate with atos
|
||||
|
||||
```bash
|
||||
# Get load address from "Binary Images:" section of crash report
|
||||
xcrun atos -arch arm64 \
|
||||
-o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
|
||||
-l 0x104600000 \
|
||||
0x104605ca4
|
||||
|
||||
# Verify dSYM matches
|
||||
xcrun dwarfdump --uuid MyApp.app.dSYM
|
||||
```
|
||||
|
||||
### Symbolicate with LLDB
|
||||
|
||||
```bash
|
||||
xcrun lldb
|
||||
(lldb) command script import lldb.macosx.crashlog
|
||||
(lldb) crashlog /path/to/crash.ips
|
||||
```
|
||||
</crash_analysis>
|
||||
|
||||
<debugger>
|
||||
## LLDB Debugging
|
||||
|
||||
### Attach to Running App
|
||||
|
||||
```bash
|
||||
# By name
|
||||
lldb -n MyApp
|
||||
|
||||
# By PID
|
||||
lldb -p $(pgrep MyApp)
|
||||
```
|
||||
|
||||
### Launch and Debug
|
||||
|
||||
```bash
|
||||
lldb ./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
|
||||
(lldb) run
|
||||
```
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Breakpoints
|
||||
(lldb) breakpoint set --file ContentView.swift --line 42
|
||||
(lldb) breakpoint set --name "AppState.addItem"
|
||||
(lldb) breakpoint set --name saveItem --condition 'item.name == "Test"'
|
||||
|
||||
# Watchpoints (break when value changes)
|
||||
(lldb) watchpoint set variable self.items.count
|
||||
|
||||
# Execution
|
||||
(lldb) continue # or 'c'
|
||||
(lldb) next # step over
|
||||
(lldb) step # step into
|
||||
(lldb) finish # step out
|
||||
|
||||
# Inspection
|
||||
(lldb) p variable
|
||||
(lldb) po object
|
||||
(lldb) frame variable # all local vars
|
||||
(lldb) bt # backtrace
|
||||
(lldb) bt all # all threads
|
||||
|
||||
# Evaluate expressions
|
||||
(lldb) expr self.items.count
|
||||
(lldb) expr self.items.append(newItem)
|
||||
```
|
||||
</debugger>
|
||||
|
||||
<memory_debugging>
|
||||
## Memory Debugging
|
||||
|
||||
### Leak Detection
|
||||
|
||||
```bash
|
||||
# Check running process for leaks
|
||||
leaks MyApp
|
||||
|
||||
# Run with leak check at exit
|
||||
leaks --atExit -- ./MyApp
|
||||
|
||||
# With stack traces (shows where leak originated)
|
||||
MallocStackLogging=1 ./MyApp &
|
||||
leaks MyApp
|
||||
```
|
||||
|
||||
### Heap Analysis
|
||||
|
||||
```bash
|
||||
# Show heap summary
|
||||
heap MyApp
|
||||
|
||||
# Show allocations of specific class
|
||||
heap MyApp -class NSString
|
||||
|
||||
# Virtual memory regions
|
||||
vmmap --summary MyApp
|
||||
```
|
||||
|
||||
### Profiling with xctrace
|
||||
|
||||
```bash
|
||||
# List templates
|
||||
xcrun xctrace list templates
|
||||
|
||||
# Time Profiler
|
||||
xcrun xctrace record \
|
||||
--template 'Time Profiler' \
|
||||
--time-limit 30s \
|
||||
--output profile.trace \
|
||||
--launch -- ./MyApp.app/Contents/MacOS/MyApp
|
||||
|
||||
# Leaks
|
||||
xcrun xctrace record \
|
||||
--template 'Leaks' \
|
||||
--time-limit 5m \
|
||||
--attach $(pgrep MyApp) \
|
||||
--output leaks.trace
|
||||
|
||||
# Export data
|
||||
xcrun xctrace export --input profile.trace --toc
|
||||
```
|
||||
</memory_debugging>
|
||||
|
||||
<sanitizers>
|
||||
## Sanitizers
|
||||
|
||||
Enable via xcodebuild flags:
|
||||
|
||||
```bash
|
||||
# Address Sanitizer (memory errors, buffer overflows)
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-enableAddressSanitizer YES
|
||||
|
||||
# Thread Sanitizer (race conditions)
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-enableThreadSanitizer YES
|
||||
|
||||
# Undefined Behavior Sanitizer
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-enableUndefinedBehaviorSanitizer YES
|
||||
```
|
||||
|
||||
**Note:** ASAN and TSAN cannot run simultaneously.
|
||||
</sanitizers>
|
||||
|
||||
<network_inspection>
|
||||
## Network Traffic Inspection
|
||||
|
||||
### mitmproxy Setup
|
||||
|
||||
```bash
|
||||
# Run proxy (defaults to localhost:8080)
|
||||
mitmproxy # TUI
|
||||
mitmdump # CLI output only
|
||||
```
|
||||
|
||||
### Configure macOS Proxy
|
||||
|
||||
```bash
|
||||
# Enable
|
||||
networksetup -setwebproxy "Wi-Fi" 127.0.0.1 8080
|
||||
networksetup -setsecurewebproxy "Wi-Fi" 127.0.0.1 8080
|
||||
|
||||
# Disable when done
|
||||
networksetup -setwebproxystate "Wi-Fi" off
|
||||
networksetup -setsecurewebproxystate "Wi-Fi" off
|
||||
```
|
||||
|
||||
### Log Traffic
|
||||
|
||||
```bash
|
||||
# Log all requests
|
||||
mitmdump -w traffic.log
|
||||
|
||||
# Filter by domain
|
||||
mitmdump --filter "~d api.example.com"
|
||||
|
||||
# Verbose (show bodies)
|
||||
mitmdump -v
|
||||
```
|
||||
</network_inspection>
|
||||
|
||||
<test_results>
|
||||
## Test Result Parsing
|
||||
|
||||
```bash
|
||||
# Run tests with result bundle
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-resultBundlePath TestResults.xcresult
|
||||
|
||||
# Get summary
|
||||
xcrun xcresulttool get test-results summary --path TestResults.xcresult
|
||||
|
||||
# Export as JSON
|
||||
xcrun xcresulttool get --path TestResults.xcresult --format json > results.json
|
||||
|
||||
# Coverage report
|
||||
xcrun xccov view --report TestResults.xcresult
|
||||
|
||||
# Coverage as JSON
|
||||
xcrun xccov view --report --json TestResults.xcresult > coverage.json
|
||||
```
|
||||
</test_results>
|
||||
|
||||
<swiftui_debugging>
|
||||
## SwiftUI Debugging
|
||||
|
||||
### Track View Re-evaluation
|
||||
|
||||
```swift
|
||||
var body: some View {
|
||||
let _ = Self._printChanges() // Logs what caused re-render
|
||||
VStack {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dump Objects
|
||||
|
||||
```swift
|
||||
let _ = dump(someObject) // Full object hierarchy to console
|
||||
```
|
||||
|
||||
**Note:** No CLI equivalent for Xcode's visual view hierarchy inspector. Use logging extensively.
|
||||
</swiftui_debugging>
|
||||
|
||||
<standard_debug_workflow>
|
||||
## Standard Debug Workflow
|
||||
|
||||
```bash
|
||||
# 1. Build with error parsing
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp build 2>&1 | xcsift
|
||||
|
||||
# 2. Run with log streaming (background terminal)
|
||||
log stream --level debug --predicate 'subsystem == "com.yourcompany.MyApp"' &
|
||||
|
||||
# 3. Launch app
|
||||
open ./build/Build/Products/Debug/MyApp.app
|
||||
|
||||
# 4. If crash occurs
|
||||
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -100
|
||||
|
||||
# 5. Memory check
|
||||
leaks MyApp
|
||||
|
||||
# 6. Deep debugging
|
||||
lldb -n MyApp
|
||||
```
|
||||
</standard_debug_workflow>
|
||||
|
||||
<cli_vs_xcode>
|
||||
## What CLI Can and Cannot Do
|
||||
|
||||
| Task | CLI | Tool |
|
||||
|------|-----|------|
|
||||
| Build errors | ✓ | xcsift |
|
||||
| Runtime logs | ✓ | log stream |
|
||||
| Crash symbolication | ✓ | atos, lldb |
|
||||
| Breakpoints/debugging | ✓ | lldb |
|
||||
| Memory leaks | ✓ | leaks, xctrace |
|
||||
| CPU profiling | ✓ | xctrace |
|
||||
| Network inspection | ✓ | mitmproxy |
|
||||
| Test results | ✓ | xcresulttool |
|
||||
| Sanitizers | ✓ | xcodebuild flags |
|
||||
| View hierarchy | ⚠️ | _printChanges() only |
|
||||
| GPU debugging | ✗ | Requires Xcode |
|
||||
</cli_vs_xcode>
|
||||
615
skills/expertise/macos-apps/references/cli-workflow.md
Normal file
615
skills/expertise/macos-apps/references/cli-workflow.md
Normal file
@@ -0,0 +1,615 @@
|
||||
# CLI-Only Workflow
|
||||
|
||||
Build, run, debug, and monitor macOS apps entirely from command line without opening Xcode.
|
||||
|
||||
<prerequisites>
|
||||
```bash
|
||||
# Ensure Xcode is installed and selected
|
||||
xcode-select -p
|
||||
# Should show: /Applications/Xcode.app/Contents/Developer
|
||||
|
||||
# If not, run:
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
|
||||
# Install XcodeGen for project creation
|
||||
brew install xcodegen
|
||||
|
||||
# Optional: prettier build output
|
||||
brew install xcbeautify
|
||||
```
|
||||
</prerequisites>
|
||||
|
||||
<create_project>
|
||||
**Create a new project entirely from CLI**:
|
||||
|
||||
```bash
|
||||
# Create directory structure
|
||||
mkdir MyApp && cd MyApp
|
||||
mkdir -p Sources Tests Resources
|
||||
|
||||
# Create project.yml (Claude generates this)
|
||||
cat > project.yml << 'EOF'
|
||||
name: MyApp
|
||||
options:
|
||||
bundleIdPrefix: com.yourcompany
|
||||
deploymentTarget:
|
||||
macOS: "14.0"
|
||||
targets:
|
||||
MyApp:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources: [Sources]
|
||||
settings:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
|
||||
DEVELOPMENT_TEAM: YOURTEAMID
|
||||
EOF
|
||||
|
||||
# Create app entry point
|
||||
cat > Sources/MyApp.swift << 'EOF'
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Text("Hello, World!")
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Generate .xcodeproj
|
||||
xcodegen generate
|
||||
|
||||
# Verify
|
||||
xcodebuild -list -project MyApp.xcodeproj
|
||||
|
||||
# Build
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp build
|
||||
```
|
||||
|
||||
See [project-scaffolding.md](project-scaffolding.md) for complete project.yml templates.
|
||||
</create_project>
|
||||
|
||||
<build>
|
||||
<list_schemes>
|
||||
```bash
|
||||
# See available schemes and targets
|
||||
xcodebuild -list -project MyApp.xcodeproj
|
||||
```
|
||||
</list_schemes>
|
||||
|
||||
<build_debug>
|
||||
```bash
|
||||
# Build debug configuration
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-configuration Debug \
|
||||
-derivedDataPath ./build \
|
||||
build
|
||||
|
||||
# Output location
|
||||
ls ./build/Build/Products/Debug/MyApp.app
|
||||
```
|
||||
</build_debug>
|
||||
|
||||
<build_release>
|
||||
```bash
|
||||
# Build release configuration
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-configuration Release \
|
||||
-derivedDataPath ./build \
|
||||
build
|
||||
```
|
||||
</build_release>
|
||||
|
||||
<build_with_signing>
|
||||
```bash
|
||||
# Build with code signing for distribution
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-configuration Release \
|
||||
-derivedDataPath ./build \
|
||||
CODE_SIGN_IDENTITY="Developer ID Application: Your Name" \
|
||||
DEVELOPMENT_TEAM=YOURTEAMID \
|
||||
build
|
||||
```
|
||||
</build_with_signing>
|
||||
|
||||
<clean>
|
||||
```bash
|
||||
# Clean build artifacts
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
clean
|
||||
|
||||
# Remove derived data
|
||||
rm -rf ./build
|
||||
```
|
||||
</clean>
|
||||
|
||||
<build_errors>
|
||||
Build output goes to stdout. Filter for errors:
|
||||
|
||||
```bash
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp build 2>&1 | grep -E "error:|warning:"
|
||||
```
|
||||
|
||||
For prettier output, use xcpretty (install with `gem install xcpretty`):
|
||||
|
||||
```bash
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp build | xcpretty
|
||||
```
|
||||
</build_errors>
|
||||
</build>
|
||||
|
||||
<run>
|
||||
<launch_app>
|
||||
```bash
|
||||
# Run the built app
|
||||
open ./build/Build/Products/Debug/MyApp.app
|
||||
|
||||
# Or run directly (shows stdout in terminal)
|
||||
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
|
||||
```
|
||||
</launch_app>
|
||||
|
||||
<run_with_arguments>
|
||||
```bash
|
||||
# Pass command line arguments
|
||||
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp --debug-mode
|
||||
|
||||
# Pass environment variables
|
||||
MYAPP_DEBUG=1 ./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
|
||||
```
|
||||
</run_with_arguments>
|
||||
|
||||
<background>
|
||||
```bash
|
||||
# Run in background (don't bring to front)
|
||||
open -g ./build/Build/Products/Debug/MyApp.app
|
||||
|
||||
# Run hidden (no dock icon)
|
||||
open -j ./build/Build/Products/Debug/MyApp.app
|
||||
```
|
||||
</background>
|
||||
</run>
|
||||
|
||||
<logging>
|
||||
<os_log_in_code>
|
||||
Add logging to your Swift code:
|
||||
|
||||
```swift
|
||||
import os
|
||||
|
||||
class DataService {
|
||||
private let logger = Logger(subsystem: "com.yourcompany.MyApp", category: "Data")
|
||||
|
||||
func loadItems() async throws -> [Item] {
|
||||
logger.info("Loading items...")
|
||||
|
||||
do {
|
||||
let items = try await fetchItems()
|
||||
logger.info("Loaded \(items.count) items")
|
||||
return items
|
||||
} catch {
|
||||
logger.error("Failed to load items: \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func saveItem(_ item: Item) {
|
||||
logger.debug("Saving item: \(item.id)")
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Log levels**:
|
||||
- `.debug` - Verbose development info
|
||||
- `.info` - General informational
|
||||
- `.notice` - Notable conditions
|
||||
- `.error` - Errors
|
||||
- `.fault` - Critical failures
|
||||
</os_log_in_code>
|
||||
|
||||
<stream_logs>
|
||||
```bash
|
||||
# Stream logs from your app (run while app is running)
|
||||
log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level info
|
||||
|
||||
# Filter by category
|
||||
log stream --predicate 'subsystem == "com.yourcompany.MyApp" and category == "Data"'
|
||||
|
||||
# Filter by process name
|
||||
log stream --predicate 'process == "MyApp"' --level debug
|
||||
|
||||
# Include debug messages
|
||||
log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level debug
|
||||
|
||||
# Show only errors
|
||||
log stream --predicate 'subsystem == "com.yourcompany.MyApp" and messageType == error'
|
||||
```
|
||||
</stream_logs>
|
||||
|
||||
<search_past_logs>
|
||||
```bash
|
||||
# Search recent logs (last hour)
|
||||
log show --predicate 'subsystem == "com.yourcompany.MyApp"' --last 1h
|
||||
|
||||
# Search specific time range
|
||||
log show --predicate 'subsystem == "com.yourcompany.MyApp"' \
|
||||
--start "2024-01-15 10:00:00" \
|
||||
--end "2024-01-15 11:00:00"
|
||||
|
||||
# Export to file
|
||||
log show --predicate 'subsystem == "com.yourcompany.MyApp"' --last 1h > app_logs.txt
|
||||
```
|
||||
</search_past_logs>
|
||||
|
||||
<system_logs>
|
||||
```bash
|
||||
# See app lifecycle events
|
||||
log stream --predicate 'process == "MyApp" or (sender == "lsd" and message contains "MyApp")'
|
||||
|
||||
# Network activity (if using NSURLSession)
|
||||
log stream --predicate 'subsystem == "com.apple.network" and process == "MyApp"'
|
||||
|
||||
# Core Data / SwiftData activity
|
||||
log stream --predicate 'subsystem == "com.apple.coredata"'
|
||||
```
|
||||
</system_logs>
|
||||
</logging>
|
||||
|
||||
<debugging>
|
||||
<lldb_attach>
|
||||
```bash
|
||||
# Start app, then attach lldb
|
||||
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp &
|
||||
|
||||
# Attach by process name
|
||||
lldb -n MyApp
|
||||
|
||||
# Or attach by PID
|
||||
lldb -p $(pgrep MyApp)
|
||||
```
|
||||
</lldb_attach>
|
||||
|
||||
<lldb_launch>
|
||||
```bash
|
||||
# Launch app under lldb directly
|
||||
lldb ./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
|
||||
|
||||
# In lldb:
|
||||
(lldb) run
|
||||
```
|
||||
</lldb_launch>
|
||||
|
||||
<common_lldb_commands>
|
||||
```bash
|
||||
# In lldb session:
|
||||
|
||||
# Set breakpoint by function name
|
||||
(lldb) breakpoint set --name saveItem
|
||||
(lldb) b DataService.swift:42
|
||||
|
||||
# Set conditional breakpoint
|
||||
(lldb) breakpoint set --name saveItem --condition 'item.name == "Test"'
|
||||
|
||||
# Continue execution
|
||||
(lldb) continue
|
||||
(lldb) c
|
||||
|
||||
# Step over/into/out
|
||||
(lldb) next
|
||||
(lldb) step
|
||||
(lldb) finish
|
||||
|
||||
# Print variable
|
||||
(lldb) p item
|
||||
(lldb) po self.items
|
||||
|
||||
# Print with format
|
||||
(lldb) p/x pointer # hex
|
||||
(lldb) p/t flags # binary
|
||||
|
||||
# Backtrace
|
||||
(lldb) bt
|
||||
(lldb) bt all # all threads
|
||||
|
||||
# List threads
|
||||
(lldb) thread list
|
||||
|
||||
# Switch thread
|
||||
(lldb) thread select 2
|
||||
|
||||
# Frame info
|
||||
(lldb) frame info
|
||||
(lldb) frame variable # all local variables
|
||||
|
||||
# Watchpoint (break when value changes)
|
||||
(lldb) watchpoint set variable self.items.count
|
||||
|
||||
# Expression evaluation
|
||||
(lldb) expr self.items.append(newItem)
|
||||
```
|
||||
</common_lldb_commands>
|
||||
|
||||
<debug_entitlement>
|
||||
For lldb to attach, your app needs the `get-task-allow` entitlement (included in Debug builds by default):
|
||||
|
||||
```xml
|
||||
<key>com.apple.security.get-task-allow</key>
|
||||
<true/>
|
||||
```
|
||||
|
||||
If you have attachment issues:
|
||||
```bash
|
||||
# Check entitlements
|
||||
codesign -d --entitlements - ./build/Build/Products/Debug/MyApp.app
|
||||
```
|
||||
</debug_entitlement>
|
||||
</debugging>
|
||||
|
||||
<crash_logs>
|
||||
<locations>
|
||||
```bash
|
||||
# User crash logs
|
||||
ls ~/Library/Logs/DiagnosticReports/
|
||||
|
||||
# System crash logs (requires sudo)
|
||||
ls /Library/Logs/DiagnosticReports/
|
||||
|
||||
# Find your app's crashes
|
||||
ls ~/Library/Logs/DiagnosticReports/ | grep MyApp
|
||||
```
|
||||
</locations>
|
||||
|
||||
<read_crash>
|
||||
```bash
|
||||
# View latest crash
|
||||
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -200
|
||||
|
||||
# Symbolicate (if you have dSYM)
|
||||
atos -arch arm64 -o ./build/Build/Products/Debug/MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x100001234
|
||||
```
|
||||
</read_crash>
|
||||
|
||||
<monitor_crashes>
|
||||
```bash
|
||||
# Watch for new crashes
|
||||
fswatch ~/Library/Logs/DiagnosticReports/ | grep MyApp
|
||||
```
|
||||
</monitor_crashes>
|
||||
</crash_logs>
|
||||
|
||||
<profiling>
|
||||
<instruments_cli>
|
||||
```bash
|
||||
# List available templates
|
||||
instruments -s templates
|
||||
|
||||
# Profile CPU usage
|
||||
instruments -t "Time Profiler" -D trace.trace ./build/Build/Products/Debug/MyApp.app
|
||||
|
||||
# Profile memory
|
||||
instruments -t "Allocations" -D memory.trace ./build/Build/Products/Debug/MyApp.app
|
||||
|
||||
# Profile leaks
|
||||
instruments -t "Leaks" -D leaks.trace ./build/Build/Products/Debug/MyApp.app
|
||||
```
|
||||
</instruments_cli>
|
||||
|
||||
<signposts>
|
||||
Add signposts for custom profiling:
|
||||
|
||||
```swift
|
||||
import os
|
||||
|
||||
class DataService {
|
||||
private let signposter = OSSignposter(subsystem: "com.yourcompany.MyApp", category: "Performance")
|
||||
|
||||
func loadItems() async throws -> [Item] {
|
||||
let signpostID = signposter.makeSignpostID()
|
||||
let state = signposter.beginInterval("Load Items", id: signpostID)
|
||||
|
||||
defer {
|
||||
signposter.endInterval("Load Items", state)
|
||||
}
|
||||
|
||||
return try await fetchItems()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
View in Instruments with "os_signpost" instrument.
|
||||
</signposts>
|
||||
</profiling>
|
||||
|
||||
<code_signing>
|
||||
<check_signature>
|
||||
```bash
|
||||
# Verify signature
|
||||
codesign -v ./build/Build/Products/Release/MyApp.app
|
||||
|
||||
# Show signature details
|
||||
codesign -dv --verbose=4 ./build/Build/Products/Release/MyApp.app
|
||||
|
||||
# Show entitlements
|
||||
codesign -d --entitlements - ./build/Build/Products/Release/MyApp.app
|
||||
```
|
||||
</check_signature>
|
||||
|
||||
<sign_manually>
|
||||
```bash
|
||||
# Sign with Developer ID (for distribution outside App Store)
|
||||
codesign --force --sign "Developer ID Application: Your Name (TEAMID)" \
|
||||
--entitlements MyApp/MyApp.entitlements \
|
||||
--options runtime \
|
||||
./build/Build/Products/Release/MyApp.app
|
||||
```
|
||||
</sign_manually>
|
||||
|
||||
<notarize>
|
||||
```bash
|
||||
# Create ZIP for notarization
|
||||
ditto -c -k --keepParent ./build/Build/Products/Release/MyApp.app MyApp.zip
|
||||
|
||||
# Submit for notarization
|
||||
xcrun notarytool submit MyApp.zip \
|
||||
--apple-id your@email.com \
|
||||
--team-id YOURTEAMID \
|
||||
--password @keychain:AC_PASSWORD \
|
||||
--wait
|
||||
|
||||
# Staple ticket to app
|
||||
xcrun stapler staple ./build/Build/Products/Release/MyApp.app
|
||||
```
|
||||
|
||||
**Store password in keychain**:
|
||||
```bash
|
||||
xcrun notarytool store-credentials --apple-id your@email.com --team-id TEAMID
|
||||
```
|
||||
</notarize>
|
||||
</code_signing>
|
||||
|
||||
<testing>
|
||||
<run_tests>
|
||||
```bash
|
||||
# Run all tests
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-derivedDataPath ./build \
|
||||
test
|
||||
|
||||
# Run specific test class
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-only-testing:MyAppTests/DataServiceTests \
|
||||
test
|
||||
|
||||
# Run specific test method
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-only-testing:MyAppTests/DataServiceTests/testLoadItems \
|
||||
test
|
||||
```
|
||||
</run_tests>
|
||||
|
||||
<test_output>
|
||||
```bash
|
||||
# Pretty test output
|
||||
xcodebuild test -project MyApp.xcodeproj -scheme MyApp | xcpretty --test
|
||||
|
||||
# Generate test report
|
||||
xcodebuild test -project MyApp.xcodeproj -scheme MyApp \
|
||||
-resultBundlePath ./TestResults.xcresult
|
||||
|
||||
# View result bundle
|
||||
xcrun xcresulttool get --path ./TestResults.xcresult --format json
|
||||
```
|
||||
</test_output>
|
||||
|
||||
<test_coverage>
|
||||
```bash
|
||||
# Build with coverage
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-enableCodeCoverage YES \
|
||||
-derivedDataPath ./build \
|
||||
test
|
||||
|
||||
# Generate coverage report
|
||||
xcrun llvm-cov report \
|
||||
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp \
|
||||
-instr-profile=./build/Build/ProfileData/*/Coverage.profdata
|
||||
```
|
||||
</test_coverage>
|
||||
</testing>
|
||||
|
||||
<complete_workflow>
|
||||
Typical development cycle without opening Xcode:
|
||||
|
||||
```bash
|
||||
# 1. Edit code (in your editor of choice)
|
||||
# Claude Code, vim, VS Code, etc.
|
||||
|
||||
# 2. Build
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp -configuration Debug -derivedDataPath ./build build 2>&1 | grep -E "error:|warning:" || echo "Build succeeded"
|
||||
|
||||
# 3. Run
|
||||
open ./build/Build/Products/Debug/MyApp.app
|
||||
|
||||
# 4. Monitor logs (in separate terminal)
|
||||
log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level debug
|
||||
|
||||
# 5. If crash, check logs
|
||||
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -100
|
||||
|
||||
# 6. Debug if needed
|
||||
lldb -n MyApp
|
||||
|
||||
# 7. Run tests
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp test
|
||||
|
||||
# 8. Build release
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp -configuration Release -derivedDataPath ./build build
|
||||
```
|
||||
</complete_workflow>
|
||||
|
||||
<helper_script>
|
||||
Create a build script for convenience:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# build.sh
|
||||
|
||||
PROJECT="MyApp.xcodeproj"
|
||||
SCHEME="MyApp"
|
||||
CONFIG="${1:-Debug}"
|
||||
|
||||
echo "Building $SCHEME ($CONFIG)..."
|
||||
|
||||
xcodebuild -project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration "$CONFIG" \
|
||||
-derivedDataPath ./build \
|
||||
build 2>&1 | tee build.log | grep -E "error:|warning:|BUILD"
|
||||
|
||||
if [ ${PIPESTATUS[0]} -eq 0 ]; then
|
||||
echo "✓ Build succeeded"
|
||||
echo "App: ./build/Build/Products/$CONFIG/$SCHEME.app"
|
||||
else
|
||||
echo "✗ Build failed - see build.log"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
```bash
|
||||
chmod +x build.sh
|
||||
./build.sh # Debug build
|
||||
./build.sh Release # Release build
|
||||
```
|
||||
</helper_script>
|
||||
|
||||
<useful_aliases>
|
||||
Add to ~/.zshrc or ~/.bashrc:
|
||||
|
||||
```bash
|
||||
# Build current project
|
||||
alias xb='xcodebuild -project *.xcodeproj -scheme $(basename *.xcodeproj .xcodeproj) -derivedDataPath ./build build'
|
||||
|
||||
# Build and run
|
||||
alias xbr='xb && open ./build/Build/Products/Debug/*.app'
|
||||
|
||||
# Run tests
|
||||
alias xt='xcodebuild -project *.xcodeproj -scheme $(basename *.xcodeproj .xcodeproj) test'
|
||||
|
||||
# Stream logs for current project
|
||||
alias xl='log stream --predicate "subsystem contains \"$(defaults read ./build/Build/Products/Debug/*.app/Contents/Info.plist CFBundleIdentifier)\"" --level debug'
|
||||
|
||||
# Clean
|
||||
alias xc='xcodebuild -project *.xcodeproj -scheme $(basename *.xcodeproj .xcodeproj) clean && rm -rf ./build'
|
||||
```
|
||||
</useful_aliases>
|
||||
538
skills/expertise/macos-apps/references/concurrency-patterns.md
Normal file
538
skills/expertise/macos-apps/references/concurrency-patterns.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# Concurrency Patterns
|
||||
|
||||
Modern Swift concurrency for responsive, safe macOS apps.
|
||||
|
||||
<async_await_basics>
|
||||
<simple_async>
|
||||
```swift
|
||||
// Basic async function
|
||||
func fetchData() async throws -> [Item] {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
return try JSONDecoder().decode([Item].self, from: data)
|
||||
}
|
||||
|
||||
// Call from view
|
||||
struct ContentView: View {
|
||||
@State private var items: [Item] = []
|
||||
|
||||
var body: some View {
|
||||
List(items) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
.task {
|
||||
do {
|
||||
items = try await fetchData()
|
||||
} catch {
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</simple_async>
|
||||
|
||||
<task_modifier>
|
||||
```swift
|
||||
struct ItemListView: View {
|
||||
@State private var items: [Item] = []
|
||||
let category: Category
|
||||
|
||||
var body: some View {
|
||||
List(items) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
// .task runs when view appears, cancels when disappears
|
||||
.task {
|
||||
await loadItems()
|
||||
}
|
||||
// .task(id:) re-runs when id changes
|
||||
.task(id: category) {
|
||||
await loadItems(for: category)
|
||||
}
|
||||
}
|
||||
|
||||
func loadItems(for category: Category? = nil) async {
|
||||
// Automatically cancelled if view disappears
|
||||
items = await dataService.fetchItems(category: category)
|
||||
}
|
||||
}
|
||||
```
|
||||
</task_modifier>
|
||||
</async_await_basics>
|
||||
|
||||
<actors>
|
||||
<basic_actor>
|
||||
```swift
|
||||
// Actor for thread-safe state
|
||||
actor DataCache {
|
||||
private var cache: [String: Data] = [:]
|
||||
|
||||
func get(_ key: String) -> Data? {
|
||||
cache[key]
|
||||
}
|
||||
|
||||
func set(_ key: String, data: Data) {
|
||||
cache[key] = data
|
||||
}
|
||||
|
||||
func clear() {
|
||||
cache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// Usage (must await)
|
||||
let cache = DataCache()
|
||||
await cache.set("key", data: data)
|
||||
let cached = await cache.get("key")
|
||||
```
|
||||
</basic_actor>
|
||||
|
||||
<service_actor>
|
||||
```swift
|
||||
actor NetworkService {
|
||||
private let session: URLSession
|
||||
private var pendingRequests: [URL: Task<Data, Error>] = [:]
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func fetch(_ url: URL) async throws -> Data {
|
||||
// Deduplicate concurrent requests for same URL
|
||||
if let existing = pendingRequests[url] {
|
||||
return try await existing.value
|
||||
}
|
||||
|
||||
let task = Task {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
return data
|
||||
}
|
||||
|
||||
pendingRequests[url] = task
|
||||
|
||||
defer {
|
||||
pendingRequests[url] = nil
|
||||
}
|
||||
|
||||
return try await task.value
|
||||
}
|
||||
}
|
||||
```
|
||||
</service_actor>
|
||||
|
||||
<nonisolated>
|
||||
```swift
|
||||
actor ImageProcessor {
|
||||
private var processedCount = 0
|
||||
|
||||
// Synchronous access for non-isolated properties
|
||||
nonisolated let maxConcurrent = 4
|
||||
|
||||
// Computed property that doesn't need isolation
|
||||
nonisolated var identifier: String {
|
||||
"ImageProcessor-\(ObjectIdentifier(self))"
|
||||
}
|
||||
|
||||
func process(_ image: NSImage) async -> NSImage {
|
||||
processedCount += 1
|
||||
// Process image...
|
||||
return processedImage
|
||||
}
|
||||
|
||||
func getCount() -> Int {
|
||||
processedCount
|
||||
}
|
||||
}
|
||||
```
|
||||
</nonisolated>
|
||||
</actors>
|
||||
|
||||
<main_actor>
|
||||
<ui_updates>
|
||||
```swift
|
||||
// Mark entire class as @MainActor
|
||||
@MainActor
|
||||
@Observable
|
||||
class AppState {
|
||||
var items: [Item] = []
|
||||
var isLoading = false
|
||||
var error: AppError?
|
||||
|
||||
func loadItems() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
// This call might be on background, result delivered on main
|
||||
items = try await dataService.fetchAll()
|
||||
} catch {
|
||||
self.error = .loadFailed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Or mark specific functions
|
||||
class DataProcessor {
|
||||
@MainActor
|
||||
func updateUI(with result: ProcessResult) {
|
||||
// Safe to update UI here
|
||||
}
|
||||
|
||||
func processInBackground() async -> ProcessResult {
|
||||
// Heavy work here
|
||||
let result = await heavyComputation()
|
||||
|
||||
// Update UI on main actor
|
||||
await updateUI(with: result)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
</ui_updates>
|
||||
|
||||
<main_actor_dispatch>
|
||||
```swift
|
||||
// From async context
|
||||
await MainActor.run {
|
||||
self.items = newItems
|
||||
}
|
||||
|
||||
// Assume main actor (when you know you're on main)
|
||||
MainActor.assumeIsolated {
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
|
||||
// Task on main actor
|
||||
Task { @MainActor in
|
||||
self.progress = 0.5
|
||||
}
|
||||
```
|
||||
</main_actor_dispatch>
|
||||
</main_actor>
|
||||
|
||||
<structured_concurrency>
|
||||
<task_groups>
|
||||
```swift
|
||||
// Parallel execution with results
|
||||
func loadAllCategories() async throws -> [Category: [Item]] {
|
||||
let categories = try await fetchCategories()
|
||||
|
||||
return try await withThrowingTaskGroup(of: (Category, [Item]).self) { group in
|
||||
for category in categories {
|
||||
group.addTask {
|
||||
let items = try await self.fetchItems(for: category)
|
||||
return (category, items)
|
||||
}
|
||||
}
|
||||
|
||||
var results: [Category: [Item]] = [:]
|
||||
for try await (category, items) in group {
|
||||
results[category] = items
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
```
|
||||
</task_groups>
|
||||
|
||||
<limited_concurrency>
|
||||
```swift
|
||||
// Process with limited parallelism
|
||||
func processImages(_ urls: [URL], maxConcurrent: Int = 4) async throws -> [ProcessedImage] {
|
||||
var results: [ProcessedImage] = []
|
||||
|
||||
try await withThrowingTaskGroup(of: ProcessedImage.self) { group in
|
||||
var iterator = urls.makeIterator()
|
||||
|
||||
// Start initial batch
|
||||
for _ in 0..<min(maxConcurrent, urls.count) {
|
||||
if let url = iterator.next() {
|
||||
group.addTask {
|
||||
try await self.processImage(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// As each completes, add another
|
||||
for try await result in group {
|
||||
results.append(result)
|
||||
|
||||
if let url = iterator.next() {
|
||||
group.addTask {
|
||||
try await self.processImage(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
</limited_concurrency>
|
||||
|
||||
<async_let>
|
||||
```swift
|
||||
// Concurrent bindings
|
||||
func loadDashboard() async throws -> Dashboard {
|
||||
async let user = fetchUser()
|
||||
async let projects = fetchProjects()
|
||||
async let notifications = fetchNotifications()
|
||||
|
||||
// All three run concurrently, await results together
|
||||
return try await Dashboard(
|
||||
user: user,
|
||||
projects: projects,
|
||||
notifications: notifications
|
||||
)
|
||||
}
|
||||
```
|
||||
</async_let>
|
||||
</structured_concurrency>
|
||||
|
||||
<async_sequences>
|
||||
<for_await>
|
||||
```swift
|
||||
// Iterate async sequence
|
||||
func monitorChanges() async {
|
||||
for await change in fileMonitor.changes {
|
||||
await processChange(change)
|
||||
}
|
||||
}
|
||||
|
||||
// With notifications
|
||||
func observeNotifications() async {
|
||||
let notifications = NotificationCenter.default.notifications(named: .dataChanged)
|
||||
|
||||
for await notification in notifications {
|
||||
guard !Task.isCancelled else { break }
|
||||
await handleNotification(notification)
|
||||
}
|
||||
}
|
||||
```
|
||||
</for_await>
|
||||
|
||||
<custom_async_sequence>
|
||||
```swift
|
||||
struct CountdownSequence: AsyncSequence {
|
||||
typealias Element = Int
|
||||
let start: Int
|
||||
|
||||
struct AsyncIterator: AsyncIteratorProtocol {
|
||||
var current: Int
|
||||
|
||||
mutating func next() async -> Int? {
|
||||
guard current > 0 else { return nil }
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
defer { current -= 1 }
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
func makeAsyncIterator() -> AsyncIterator {
|
||||
AsyncIterator(current: start)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
for await count in CountdownSequence(start: 10) {
|
||||
print(count)
|
||||
}
|
||||
```
|
||||
</custom_async_sequence>
|
||||
|
||||
<async_stream>
|
||||
```swift
|
||||
// Bridge callback-based API
|
||||
func fileChanges(at path: String) -> AsyncStream<FileChange> {
|
||||
AsyncStream { continuation in
|
||||
let monitor = FileMonitor(path: path) { change in
|
||||
continuation.yield(change)
|
||||
}
|
||||
|
||||
monitor.start()
|
||||
|
||||
continuation.onTermination = { _ in
|
||||
monitor.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Throwing version
|
||||
func networkEvents() -> AsyncThrowingStream<NetworkEvent, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let connection = NetworkConnection()
|
||||
|
||||
connection.onEvent = { event in
|
||||
continuation.yield(event)
|
||||
}
|
||||
|
||||
connection.onError = { error in
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
|
||||
connection.onComplete = {
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
connection.start()
|
||||
|
||||
continuation.onTermination = { _ in
|
||||
connection.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</async_stream>
|
||||
</async_sequences>
|
||||
|
||||
<cancellation>
|
||||
<checking_cancellation>
|
||||
```swift
|
||||
func processLargeDataset(_ items: [Item]) async throws -> [Result] {
|
||||
var results: [Result] = []
|
||||
|
||||
for item in items {
|
||||
// Check for cancellation
|
||||
try Task.checkCancellation()
|
||||
|
||||
// Or check without throwing
|
||||
if Task.isCancelled {
|
||||
break
|
||||
}
|
||||
|
||||
let result = await process(item)
|
||||
results.append(result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
</checking_cancellation>
|
||||
|
||||
<cancellation_handlers>
|
||||
```swift
|
||||
func downloadFile(_ url: URL) async throws -> Data {
|
||||
let task = URLSession.shared.dataTask(with: url)
|
||||
|
||||
return try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
task.completionHandler = { data, _, error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let data = data {
|
||||
continuation.resume(returning: data)
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
} onCancel: {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
```
|
||||
</cancellation_handlers>
|
||||
|
||||
<task_cancellation>
|
||||
```swift
|
||||
class ViewModel {
|
||||
private var loadTask: Task<Void, Never>?
|
||||
|
||||
func load() {
|
||||
// Cancel previous load
|
||||
loadTask?.cancel()
|
||||
|
||||
loadTask = Task {
|
||||
await performLoad()
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
loadTask?.cancel()
|
||||
loadTask = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
}
|
||||
}
|
||||
```
|
||||
</task_cancellation>
|
||||
</cancellation>
|
||||
|
||||
<sendable>
|
||||
<sendable_types>
|
||||
```swift
|
||||
// Value types are Sendable by default if all properties are Sendable
|
||||
struct Item: Sendable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let count: Int
|
||||
}
|
||||
|
||||
// Classes must be explicitly Sendable
|
||||
final class ImmutableConfig: Sendable {
|
||||
let apiKey: String
|
||||
let baseURL: URL
|
||||
|
||||
init(apiKey: String, baseURL: URL) {
|
||||
self.apiKey = apiKey
|
||||
self.baseURL = baseURL
|
||||
}
|
||||
}
|
||||
|
||||
// Actors are automatically Sendable
|
||||
actor Counter: Sendable {
|
||||
var count = 0
|
||||
}
|
||||
|
||||
// Mark as @unchecked Sendable when you manage thread safety yourself
|
||||
final class ThreadSafeCache: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage: [String: Data] = [:]
|
||||
|
||||
func get(_ key: String) -> Data? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage[key]
|
||||
}
|
||||
}
|
||||
```
|
||||
</sendable_types>
|
||||
|
||||
<sending_closures>
|
||||
```swift
|
||||
// Closures that cross actor boundaries must be @Sendable
|
||||
func processInBackground(work: @Sendable @escaping () async -> Void) {
|
||||
Task.detached {
|
||||
await work()
|
||||
}
|
||||
}
|
||||
|
||||
// Capture only Sendable values
|
||||
let items = items // Must be Sendable
|
||||
Task {
|
||||
await process(items)
|
||||
}
|
||||
```
|
||||
</sending_closures>
|
||||
</sendable>
|
||||
|
||||
<best_practices>
|
||||
<do>
|
||||
- Use `.task` modifier for view-related async work
|
||||
- Use actors for shared mutable state
|
||||
- Mark UI-updating code with `@MainActor`
|
||||
- Check `Task.isCancelled` in long operations
|
||||
- Use structured concurrency (task groups, async let) over unstructured
|
||||
- Cancel tasks when no longer needed
|
||||
</do>
|
||||
|
||||
<avoid>
|
||||
- Creating detached tasks unnecessarily (loses structured concurrency benefits)
|
||||
- Blocking actors with synchronous work
|
||||
- Ignoring cancellation in long-running operations
|
||||
- Passing non-Sendable types across actor boundaries
|
||||
- Using `DispatchQueue` when async/await works
|
||||
</avoid>
|
||||
</best_practices>
|
||||
700
skills/expertise/macos-apps/references/data-persistence.md
Normal file
700
skills/expertise/macos-apps/references/data-persistence.md
Normal file
@@ -0,0 +1,700 @@
|
||||
# Data Persistence
|
||||
|
||||
Patterns for persisting data in macOS apps using SwiftData, Core Data, and file-based storage.
|
||||
|
||||
<choosing_persistence>
|
||||
**SwiftData** (macOS 14+): Best for new apps
|
||||
- Declarative schema in code
|
||||
- Tight SwiftUI integration
|
||||
- Automatic iCloud sync
|
||||
- Less boilerplate
|
||||
|
||||
**Core Data**: Best for complex needs or backward compatibility
|
||||
- Visual schema editor
|
||||
- Fine-grained migration control
|
||||
- More mature ecosystem
|
||||
- Works on older macOS
|
||||
|
||||
**File-based (Codable)**: Best for documents or simple data
|
||||
- JSON/plist storage
|
||||
- No database overhead
|
||||
- Portable data
|
||||
- Good for document-based apps
|
||||
|
||||
**UserDefaults**: Preferences and small state only
|
||||
- Not for app data
|
||||
|
||||
**Keychain**: Sensitive data only
|
||||
- Passwords, tokens, keys
|
||||
</choosing_persistence>
|
||||
|
||||
<swiftdata>
|
||||
<model_definition>
|
||||
```swift
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
class Project {
|
||||
var name: String
|
||||
var createdAt: Date
|
||||
var isArchived: Bool
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Task.project)
|
||||
var tasks: [Task]
|
||||
|
||||
@Attribute(.externalStorage)
|
||||
var thumbnail: Data?
|
||||
|
||||
// Computed properties are fine
|
||||
var activeTasks: [Task] {
|
||||
tasks.filter { !$0.isComplete }
|
||||
}
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
self.createdAt = Date()
|
||||
self.isArchived = false
|
||||
self.tasks = []
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
class Task {
|
||||
var title: String
|
||||
var isComplete: Bool
|
||||
var dueDate: Date?
|
||||
var priority: Priority
|
||||
|
||||
var project: Project?
|
||||
|
||||
enum Priority: Int, Codable {
|
||||
case low = 0
|
||||
case medium = 1
|
||||
case high = 2
|
||||
}
|
||||
|
||||
init(title: String, priority: Priority = .medium) {
|
||||
self.title = title
|
||||
self.isComplete = false
|
||||
self.priority = priority
|
||||
}
|
||||
}
|
||||
```
|
||||
</model_definition>
|
||||
|
||||
<container_setup>
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(for: Project.self)
|
||||
}
|
||||
}
|
||||
|
||||
// Custom configuration
|
||||
@main
|
||||
struct MyApp: App {
|
||||
let container: ModelContainer
|
||||
|
||||
init() {
|
||||
let schema = Schema([Project.self, Task.self])
|
||||
let config = ModelConfiguration(
|
||||
"MyApp",
|
||||
schema: schema,
|
||||
isStoredInMemoryOnly: false,
|
||||
cloudKitDatabase: .automatic
|
||||
)
|
||||
|
||||
do {
|
||||
container = try ModelContainer(for: schema, configurations: config)
|
||||
} catch {
|
||||
fatalError("Failed to create container: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(container)
|
||||
}
|
||||
}
|
||||
```
|
||||
</container_setup>
|
||||
|
||||
<querying>
|
||||
```swift
|
||||
struct ProjectListView: View {
|
||||
// Basic query
|
||||
@Query private var projects: [Project]
|
||||
|
||||
// Filtered and sorted
|
||||
@Query(
|
||||
filter: #Predicate<Project> { !$0.isArchived },
|
||||
sort: \Project.createdAt,
|
||||
order: .reverse
|
||||
) private var activeProjects: [Project]
|
||||
|
||||
// Dynamic filter
|
||||
@Query private var allProjects: [Project]
|
||||
|
||||
var filteredProjects: [Project] {
|
||||
if searchText.isEmpty {
|
||||
return allProjects
|
||||
}
|
||||
return allProjects.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
@State private var searchText = ""
|
||||
|
||||
var body: some View {
|
||||
List(filteredProjects) { project in
|
||||
Text(project.name)
|
||||
}
|
||||
.searchable(text: $searchText)
|
||||
}
|
||||
}
|
||||
```
|
||||
</querying>
|
||||
|
||||
<relationship_patterns>
|
||||
<critical_rule>
|
||||
**When adding items to relationships, set the inverse relationship property, then insert into context.** Don't manually append to arrays.
|
||||
</critical_rule>
|
||||
|
||||
<adding_to_relationships>
|
||||
```swift
|
||||
// CORRECT: Set inverse, then insert
|
||||
func addCard(to column: Column, title: String) {
|
||||
let card = Card(title: title, position: 1.0)
|
||||
card.column = column // Set the inverse relationship
|
||||
modelContext.insert(card) // Insert into context
|
||||
// SwiftData automatically updates column.cards
|
||||
}
|
||||
|
||||
// WRONG: Don't manually append to arrays
|
||||
func addCardWrong(to column: Column, title: String) {
|
||||
let card = Card(title: title, position: 1.0)
|
||||
column.cards.append(card) // This can cause issues
|
||||
modelContext.insert(card)
|
||||
}
|
||||
```
|
||||
</adding_to_relationships>
|
||||
|
||||
<when_to_insert>
|
||||
**Always call `modelContext.insert()` for new objects.** SwiftData needs this to track the object.
|
||||
|
||||
```swift
|
||||
// Creating a new item - MUST insert
|
||||
let card = Card(title: "New")
|
||||
card.column = column
|
||||
modelContext.insert(card) // Required!
|
||||
|
||||
// Modifying existing item - no insert needed
|
||||
existingCard.title = "Updated" // SwiftData tracks this automatically
|
||||
|
||||
// Moving item between parents
|
||||
card.column = newColumn // Just update the relationship
|
||||
// No insert needed for existing objects
|
||||
```
|
||||
</when_to_insert>
|
||||
|
||||
<relationship_definition>
|
||||
```swift
|
||||
@Model
|
||||
class Column {
|
||||
var name: String
|
||||
var position: Double
|
||||
|
||||
// Define relationship with inverse
|
||||
@Relationship(deleteRule: .cascade, inverse: \Card.column)
|
||||
var cards: [Card] = []
|
||||
|
||||
init(name: String, position: Double) {
|
||||
self.name = name
|
||||
self.position = position
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
class Card {
|
||||
var title: String
|
||||
var position: Double
|
||||
|
||||
// The inverse side - this is what you SET when adding
|
||||
var column: Column?
|
||||
|
||||
init(title: String, position: Double) {
|
||||
self.title = title
|
||||
self.position = position
|
||||
}
|
||||
}
|
||||
```
|
||||
</relationship_definition>
|
||||
|
||||
<common_pitfalls>
|
||||
**Pitfall 1: Not setting inverse relationship**
|
||||
```swift
|
||||
// WRONG - card won't appear in column.cards
|
||||
let card = Card(title: "New", position: 1.0)
|
||||
modelContext.insert(card) // Missing: card.column = column
|
||||
```
|
||||
|
||||
**Pitfall 2: Manually managing both sides**
|
||||
```swift
|
||||
// WRONG - redundant and can cause issues
|
||||
card.column = column
|
||||
column.cards.append(card) // Don't do this
|
||||
modelContext.insert(card)
|
||||
```
|
||||
|
||||
**Pitfall 3: Forgetting to insert**
|
||||
```swift
|
||||
// WRONG - object won't persist
|
||||
let card = Card(title: "New", position: 1.0)
|
||||
card.column = column
|
||||
// Missing: modelContext.insert(card)
|
||||
```
|
||||
</common_pitfalls>
|
||||
|
||||
<reordering_items>
|
||||
```swift
|
||||
// For drag-and-drop reordering within same parent
|
||||
func moveCard(_ card: Card, to newPosition: Double) {
|
||||
card.position = newPosition
|
||||
// SwiftData tracks the change automatically
|
||||
}
|
||||
|
||||
// Moving between parents (e.g., column to column)
|
||||
func moveCard(_ card: Card, to newColumn: Column, position: Double) {
|
||||
card.column = newColumn
|
||||
card.position = position
|
||||
// No insert needed - card already exists
|
||||
}
|
||||
```
|
||||
</reordering_items>
|
||||
</relationship_patterns>
|
||||
|
||||
<crud_operations>
|
||||
```swift
|
||||
struct ProjectListView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query private var projects: [Project]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(projects) { project in
|
||||
Text(project.name)
|
||||
}
|
||||
.onDelete(perform: deleteProjects)
|
||||
}
|
||||
.toolbar {
|
||||
Button("Add") {
|
||||
addProject()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addProject() {
|
||||
let project = Project(name: "New Project")
|
||||
context.insert(project)
|
||||
// Auto-saves
|
||||
}
|
||||
|
||||
private func deleteProjects(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
context.delete(projects[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In a service
|
||||
actor DataService {
|
||||
private let context: ModelContext
|
||||
|
||||
init(container: ModelContainer) {
|
||||
self.context = ModelContext(container)
|
||||
}
|
||||
|
||||
func fetchProjects() throws -> [Project] {
|
||||
let descriptor = FetchDescriptor<Project>(
|
||||
predicate: #Predicate { !$0.isArchived },
|
||||
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
|
||||
)
|
||||
return try context.fetch(descriptor)
|
||||
}
|
||||
|
||||
func save(_ project: Project) throws {
|
||||
context.insert(project)
|
||||
try context.save()
|
||||
}
|
||||
}
|
||||
```
|
||||
</crud_operations>
|
||||
|
||||
<icloud_sync>
|
||||
```swift
|
||||
// Enable in ModelConfiguration
|
||||
let config = ModelConfiguration(
|
||||
cloudKitDatabase: .automatic // or .private("containerID")
|
||||
)
|
||||
|
||||
// Handle sync status
|
||||
struct SyncStatusView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
var body: some View {
|
||||
// SwiftData handles sync automatically
|
||||
// Monitor with NotificationCenter for CKAccountChanged
|
||||
Text("Syncing...")
|
||||
}
|
||||
}
|
||||
```
|
||||
</icloud_sync>
|
||||
</swiftdata>
|
||||
|
||||
<core_data>
|
||||
<stack_setup>
|
||||
```swift
|
||||
class PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
|
||||
let container: NSPersistentContainer
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
container = NSPersistentContainer(name: "MyApp")
|
||||
|
||||
if inMemory {
|
||||
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
|
||||
}
|
||||
|
||||
container.loadPersistentStores { description, error in
|
||||
if let error = error {
|
||||
fatalError("Failed to load store: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
}
|
||||
|
||||
var viewContext: NSManagedObjectContext {
|
||||
container.viewContext
|
||||
}
|
||||
|
||||
func newBackgroundContext() -> NSManagedObjectContext {
|
||||
container.newBackgroundContext()
|
||||
}
|
||||
}
|
||||
```
|
||||
</stack_setup>
|
||||
|
||||
<fetch_request>
|
||||
```swift
|
||||
struct ProjectListView: View {
|
||||
@Environment(\.managedObjectContext) private var context
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \CDProject.createdAt, ascending: false)],
|
||||
predicate: NSPredicate(format: "isArchived == NO")
|
||||
)
|
||||
private var projects: FetchedResults<CDProject>
|
||||
|
||||
var body: some View {
|
||||
List(projects) { project in
|
||||
Text(project.name ?? "Untitled")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</fetch_request>
|
||||
|
||||
<crud_operations_coredata>
|
||||
```swift
|
||||
// Create
|
||||
func createProject(name: String) {
|
||||
let project = CDProject(context: context)
|
||||
project.id = UUID()
|
||||
project.name = name
|
||||
project.createdAt = Date()
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
}
|
||||
}
|
||||
|
||||
// Update
|
||||
func updateProject(_ project: CDProject, name: String) {
|
||||
project.name = name
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
// Delete
|
||||
func deleteProject(_ project: CDProject) {
|
||||
context.delete(project)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
// Background operations
|
||||
func importProjects(_ data: [ProjectData]) async throws {
|
||||
let context = PersistenceController.shared.newBackgroundContext()
|
||||
|
||||
try await context.perform {
|
||||
for item in data {
|
||||
let project = CDProject(context: context)
|
||||
project.id = UUID()
|
||||
project.name = item.name
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
}
|
||||
```
|
||||
</crud_operations_coredata>
|
||||
</core_data>
|
||||
|
||||
<file_based>
|
||||
<codable_storage>
|
||||
```swift
|
||||
struct AppData: Codable {
|
||||
var items: [Item]
|
||||
var lastModified: Date
|
||||
}
|
||||
|
||||
class FileStorage {
|
||||
private let fileURL: URL
|
||||
|
||||
init() {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let appFolder = appSupport.appendingPathComponent("MyApp", isDirectory: true)
|
||||
|
||||
// Create directory if needed
|
||||
try? FileManager.default.createDirectory(at: appFolder, withIntermediateDirectories: true)
|
||||
|
||||
fileURL = appFolder.appendingPathComponent("data.json")
|
||||
}
|
||||
|
||||
func load() throws -> AppData {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
return try JSONDecoder().decode(AppData.self, from: data)
|
||||
}
|
||||
|
||||
func save(_ appData: AppData) throws {
|
||||
let data = try JSONEncoder().encode(appData)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
```
|
||||
</codable_storage>
|
||||
|
||||
<document_storage>
|
||||
For document-based apps, see [document-apps.md](document-apps.md).
|
||||
|
||||
```swift
|
||||
struct ProjectDocument: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [.json] }
|
||||
|
||||
var project: Project
|
||||
|
||||
init(project: Project = Project()) {
|
||||
self.project = project
|
||||
}
|
||||
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
guard let data = configuration.file.regularFileContents else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
project = try JSONDecoder().decode(Project.self, from: data)
|
||||
}
|
||||
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
let data = try JSONEncoder().encode(project)
|
||||
return FileWrapper(regularFileWithContents: data)
|
||||
}
|
||||
}
|
||||
```
|
||||
</document_storage>
|
||||
</file_based>
|
||||
|
||||
<keychain>
|
||||
```swift
|
||||
import Security
|
||||
|
||||
class KeychainService {
|
||||
static let shared = KeychainService()
|
||||
|
||||
func save(key: String, data: Data) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.saveFailed(status)
|
||||
}
|
||||
}
|
||||
|
||||
func load(key: String) throws -> Data {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw KeychainError.loadFailed(status)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func delete(key: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainError.deleteFailed(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainError: Error {
|
||||
case saveFailed(OSStatus)
|
||||
case loadFailed(OSStatus)
|
||||
case deleteFailed(OSStatus)
|
||||
}
|
||||
|
||||
// Usage
|
||||
let token = "secret-token".data(using: .utf8)!
|
||||
try KeychainService.shared.save(key: "api-token", data: token)
|
||||
```
|
||||
</keychain>
|
||||
|
||||
<user_defaults>
|
||||
```swift
|
||||
// Using @AppStorage
|
||||
struct SettingsView: View {
|
||||
@AppStorage("theme") private var theme = "system"
|
||||
@AppStorage("fontSize") private var fontSize = 14.0
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Picker("Theme", selection: $theme) {
|
||||
Text("System").tag("system")
|
||||
Text("Light").tag("light")
|
||||
Text("Dark").tag("dark")
|
||||
}
|
||||
|
||||
Slider(value: $fontSize, in: 10...24) {
|
||||
Text("Font Size: \(Int(fontSize))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Type-safe wrapper
|
||||
extension UserDefaults {
|
||||
enum Keys {
|
||||
static let theme = "theme"
|
||||
static let recentFiles = "recentFiles"
|
||||
}
|
||||
|
||||
var theme: String {
|
||||
get { string(forKey: Keys.theme) ?? "system" }
|
||||
set { set(newValue, forKey: Keys.theme) }
|
||||
}
|
||||
|
||||
var recentFiles: [URL] {
|
||||
get {
|
||||
guard let data = data(forKey: Keys.recentFiles),
|
||||
let urls = try? JSONDecoder().decode([URL].self, from: data)
|
||||
else { return [] }
|
||||
return urls
|
||||
}
|
||||
set {
|
||||
let data = try? JSONEncoder().encode(newValue)
|
||||
set(data, forKey: Keys.recentFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</user_defaults>
|
||||
|
||||
<migration>
|
||||
<swiftdata_migration>
|
||||
```swift
|
||||
// SwiftData handles lightweight migrations automatically
|
||||
// For complex migrations, use VersionedSchema
|
||||
|
||||
enum MyAppSchemaV1: VersionedSchema {
|
||||
static var versionIdentifier = Schema.Version(1, 0, 0)
|
||||
static var models: [any PersistentModel.Type] {
|
||||
[Project.self]
|
||||
}
|
||||
|
||||
@Model
|
||||
class Project {
|
||||
var name: String
|
||||
init(name: String) { self.name = name }
|
||||
}
|
||||
}
|
||||
|
||||
enum MyAppSchemaV2: VersionedSchema {
|
||||
static var versionIdentifier = Schema.Version(2, 0, 0)
|
||||
static var models: [any PersistentModel.Type] {
|
||||
[Project.self]
|
||||
}
|
||||
|
||||
@Model
|
||||
class Project {
|
||||
var name: String
|
||||
var createdAt: Date // New property
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
self.createdAt = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MyAppMigrationPlan: SchemaMigrationPlan {
|
||||
static var schemas: [any VersionedSchema.Type] {
|
||||
[MyAppSchemaV1.self, MyAppSchemaV2.self]
|
||||
}
|
||||
|
||||
static var stages: [MigrationStage] {
|
||||
[migrateV1toV2]
|
||||
}
|
||||
|
||||
static let migrateV1toV2 = MigrationStage.lightweight(
|
||||
fromVersion: MyAppSchemaV1.self,
|
||||
toVersion: MyAppSchemaV2.self
|
||||
)
|
||||
}
|
||||
```
|
||||
</swiftdata_migration>
|
||||
</migration>
|
||||
|
||||
<best_practices>
|
||||
- Use SwiftData for new apps targeting macOS 14+
|
||||
- Use background contexts for heavy operations
|
||||
- Handle migration explicitly for production apps
|
||||
- Don't store large blobs in database (use @Attribute(.externalStorage))
|
||||
- Use transactions for multiple related changes
|
||||
- Test persistence with in-memory stores
|
||||
</best_practices>
|
||||
420
skills/expertise/macos-apps/references/design-system.md
Normal file
420
skills/expertise/macos-apps/references/design-system.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Design System
|
||||
|
||||
Colors, typography, spacing, and visual patterns for professional macOS apps.
|
||||
|
||||
<semantic_colors>
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
// Use semantic colors that adapt to light/dark mode
|
||||
static let background = Color(NSColor.windowBackgroundColor)
|
||||
static let secondaryBackground = Color(NSColor.controlBackgroundColor)
|
||||
static let tertiaryBackground = Color(NSColor.underPageBackgroundColor)
|
||||
|
||||
// Text
|
||||
static let primaryText = Color(NSColor.labelColor)
|
||||
static let secondaryText = Color(NSColor.secondaryLabelColor)
|
||||
static let tertiaryText = Color(NSColor.tertiaryLabelColor)
|
||||
static let quaternaryText = Color(NSColor.quaternaryLabelColor)
|
||||
|
||||
// Controls
|
||||
static let controlAccent = Color.accentColor
|
||||
static let controlBackground = Color(NSColor.controlColor)
|
||||
static let selectedContent = Color(NSColor.selectedContentBackgroundColor)
|
||||
|
||||
// Separators
|
||||
static let separator = Color(NSColor.separatorColor)
|
||||
static let gridLine = Color(NSColor.gridColor)
|
||||
}
|
||||
|
||||
// Usage
|
||||
Text("Hello")
|
||||
.foregroundStyle(.primaryText)
|
||||
.background(.background)
|
||||
```
|
||||
</semantic_colors>
|
||||
|
||||
<custom_colors>
|
||||
```swift
|
||||
extension Color {
|
||||
// Define once, use everywhere
|
||||
static let appPrimary = Color("AppPrimary") // From asset catalog
|
||||
static let appSecondary = Color("AppSecondary")
|
||||
|
||||
// Or programmatic
|
||||
static let success = Color(red: 0.2, green: 0.8, blue: 0.4)
|
||||
static let warning = Color(red: 1.0, green: 0.8, blue: 0.0)
|
||||
static let error = Color(red: 0.9, green: 0.3, blue: 0.3)
|
||||
}
|
||||
|
||||
// Asset catalog with light/dark variants
|
||||
// Assets.xcassets/AppPrimary.colorset/Contents.json:
|
||||
/*
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : { "color-space" : "srgb", "components" : { "red" : "0.2", "green" : "0.5", "blue" : "1.0" } },
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ],
|
||||
"color" : { "color-space" : "srgb", "components" : { "red" : "0.4", "green" : "0.7", "blue" : "1.0" } },
|
||||
"idiom" : "universal"
|
||||
}
|
||||
]
|
||||
}
|
||||
*/
|
||||
```
|
||||
</custom_colors>
|
||||
|
||||
<typography>
|
||||
```swift
|
||||
extension Font {
|
||||
// System fonts
|
||||
static let displayLarge = Font.system(size: 34, weight: .bold, design: .default)
|
||||
static let displayMedium = Font.system(size: 28, weight: .semibold)
|
||||
static let displaySmall = Font.system(size: 22, weight: .semibold)
|
||||
|
||||
static let headlineLarge = Font.system(size: 17, weight: .semibold)
|
||||
static let headlineMedium = Font.system(size: 15, weight: .semibold)
|
||||
static let headlineSmall = Font.system(size: 13, weight: .semibold)
|
||||
|
||||
static let bodyLarge = Font.system(size: 15, weight: .regular)
|
||||
static let bodyMedium = Font.system(size: 13, weight: .regular)
|
||||
static let bodySmall = Font.system(size: 11, weight: .regular)
|
||||
|
||||
// Monospace for code
|
||||
static let codeLarge = Font.system(size: 14, weight: .regular, design: .monospaced)
|
||||
static let codeMedium = Font.system(size: 12, weight: .regular, design: .monospaced)
|
||||
static let codeSmall = Font.system(size: 10, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
// Usage
|
||||
Text("Title")
|
||||
.font(.displayMedium)
|
||||
|
||||
Text("Body text")
|
||||
.font(.bodyMedium)
|
||||
|
||||
Text("let x = 42")
|
||||
.font(.codeMedium)
|
||||
```
|
||||
</typography>
|
||||
|
||||
<spacing>
|
||||
```swift
|
||||
enum Spacing {
|
||||
static let xxxs: CGFloat = 2
|
||||
static let xxs: CGFloat = 4
|
||||
static let xs: CGFloat = 8
|
||||
static let sm: CGFloat = 12
|
||||
static let md: CGFloat = 16
|
||||
static let lg: CGFloat = 24
|
||||
static let xl: CGFloat = 32
|
||||
static let xxl: CGFloat = 48
|
||||
static let xxxl: CGFloat = 64
|
||||
}
|
||||
|
||||
// Usage
|
||||
VStack(spacing: Spacing.md) {
|
||||
Text("Title")
|
||||
Text("Subtitle")
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "star")
|
||||
Text("Favorite")
|
||||
}
|
||||
```
|
||||
</spacing>
|
||||
|
||||
<corner_radius>
|
||||
```swift
|
||||
enum CornerRadius {
|
||||
static let small: CGFloat = 4
|
||||
static let medium: CGFloat = 8
|
||||
static let large: CGFloat = 12
|
||||
static let xlarge: CGFloat = 16
|
||||
}
|
||||
|
||||
// Usage
|
||||
RoundedRectangle(cornerRadius: CornerRadius.medium)
|
||||
.fill(.secondaryBackground)
|
||||
|
||||
Text("Tag")
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, Spacing.xxs)
|
||||
.background(.controlBackground, in: RoundedRectangle(cornerRadius: CornerRadius.small))
|
||||
```
|
||||
</corner_radius>
|
||||
|
||||
<shadows>
|
||||
```swift
|
||||
extension View {
|
||||
func cardShadow() -> some View {
|
||||
shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
|
||||
func elevatedShadow() -> some View {
|
||||
shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
|
||||
}
|
||||
|
||||
func subtleShadow() -> some View {
|
||||
shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
CardView()
|
||||
.cardShadow()
|
||||
```
|
||||
</shadows>
|
||||
|
||||
<component_styles>
|
||||
<buttons>
|
||||
```swift
|
||||
struct PrimaryButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.headlineMedium)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: CornerRadius.medium)
|
||||
.fill(Color.accentColor)
|
||||
)
|
||||
.opacity(configuration.isPressed ? 0.8 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct SecondaryButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.headlineMedium)
|
||||
.foregroundStyle(.accentColor)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: CornerRadius.medium)
|
||||
.stroke(Color.accentColor, lineWidth: 1)
|
||||
)
|
||||
.opacity(configuration.isPressed ? 0.8 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
Button("Save") { save() }
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
|
||||
Button("Cancel") { cancel() }
|
||||
.buttonStyle(SecondaryButtonStyle())
|
||||
```
|
||||
</buttons>
|
||||
|
||||
<cards>
|
||||
```swift
|
||||
struct CardStyle: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.padding(Spacing.md)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: CornerRadius.large)
|
||||
.fill(.secondaryBackground)
|
||||
)
|
||||
.cardShadow()
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cardStyle() -> some View {
|
||||
modifier(CardStyle())
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
VStack {
|
||||
Text("Card Title")
|
||||
Text("Card content")
|
||||
}
|
||||
.cardStyle()
|
||||
```
|
||||
</cards>
|
||||
|
||||
<list_rows>
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: item.icon)
|
||||
.foregroundStyle(isSelected ? .white : .secondaryText)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(item.name)
|
||||
.font(.headlineSmall)
|
||||
.foregroundStyle(isSelected ? .white : .primaryText)
|
||||
|
||||
Text(item.subtitle)
|
||||
.font(.bodySmall)
|
||||
.foregroundStyle(isSelected ? .white.opacity(0.8) : .secondaryText)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(item.date.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.bodySmall)
|
||||
.foregroundStyle(isSelected ? .white.opacity(0.8) : .tertiaryText)
|
||||
}
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: CornerRadius.small)
|
||||
.fill(isSelected ? Color.accentColor : .clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
</list_rows>
|
||||
|
||||
<text_fields>
|
||||
```swift
|
||||
struct StyledTextField: View {
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
TextField(placeholder, text: $text)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bodyMedium)
|
||||
.padding(Spacing.sm)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: CornerRadius.medium)
|
||||
.fill(.controlBackground)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CornerRadius.medium)
|
||||
.stroke(.separator, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
</text_fields>
|
||||
</component_styles>
|
||||
|
||||
<icons>
|
||||
```swift
|
||||
// Use SF Symbols
|
||||
Image(systemName: "doc.text")
|
||||
Image(systemName: "folder.fill")
|
||||
Image(systemName: "gear")
|
||||
|
||||
// Consistent sizing
|
||||
Image(systemName: "star")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
|
||||
// With colors
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.green)
|
||||
|
||||
// Multicolor
|
||||
Image(systemName: "externaldrive.badge.checkmark")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
```
|
||||
</icons>
|
||||
|
||||
<animations>
|
||||
```swift
|
||||
// Standard durations
|
||||
enum AnimationDuration {
|
||||
static let fast: Double = 0.15
|
||||
static let normal: Double = 0.25
|
||||
static let slow: Double = 0.4
|
||||
}
|
||||
|
||||
// Common animations
|
||||
extension Animation {
|
||||
static let defaultSpring = Animation.spring(response: 0.3, dampingFraction: 0.7)
|
||||
static let quickSpring = Animation.spring(response: 0.2, dampingFraction: 0.8)
|
||||
static let gentleSpring = Animation.spring(response: 0.5, dampingFraction: 0.7)
|
||||
|
||||
static let easeOut = Animation.easeOut(duration: AnimationDuration.normal)
|
||||
static let easeIn = Animation.easeIn(duration: AnimationDuration.normal)
|
||||
}
|
||||
|
||||
// Usage
|
||||
withAnimation(.defaultSpring) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
|
||||
// Respect reduce motion
|
||||
struct AnimationSettings {
|
||||
static var prefersReducedMotion: Bool {
|
||||
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
||||
}
|
||||
|
||||
static func animation(_ animation: Animation) -> Animation? {
|
||||
prefersReducedMotion ? nil : animation
|
||||
}
|
||||
}
|
||||
```
|
||||
</animations>
|
||||
|
||||
<dark_mode>
|
||||
```swift
|
||||
// Automatic adaptation
|
||||
struct ContentView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// Semantic colors adapt automatically
|
||||
Text("Title")
|
||||
.foregroundStyle(.primaryText)
|
||||
.background(.background)
|
||||
|
||||
// Manual override when needed
|
||||
Image("logo")
|
||||
.colorInvert() // Only if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force scheme for preview
|
||||
#Preview("Dark Mode") {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
```
|
||||
</dark_mode>
|
||||
|
||||
<accessibility>
|
||||
```swift
|
||||
// Dynamic type support
|
||||
Text("Title")
|
||||
.font(.headline) // Scales with user settings
|
||||
|
||||
// Custom fonts with scaling
|
||||
@ScaledMetric(relativeTo: .body) var customSize: CGFloat = 14
|
||||
Text("Custom")
|
||||
.font(.system(size: customSize))
|
||||
|
||||
// Contrast
|
||||
Button("Action") { }
|
||||
.foregroundStyle(.white)
|
||||
.background(.accentColor) // Ensure contrast ratio >= 4.5:1
|
||||
|
||||
// Reduce transparency
|
||||
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
|
||||
|
||||
VStack {
|
||||
// content
|
||||
}
|
||||
.background(reduceTransparency ? .background : .background.opacity(0.8))
|
||||
```
|
||||
</accessibility>
|
||||
445
skills/expertise/macos-apps/references/document-apps.md
Normal file
445
skills/expertise/macos-apps/references/document-apps.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# Document-Based Apps
|
||||
|
||||
Apps where users create, open, and save discrete files (like TextEdit, Pages, Xcode).
|
||||
|
||||
<when_to_use>
|
||||
Use document-based architecture when:
|
||||
- Users explicitly create/open/save files
|
||||
- Multiple documents open simultaneously
|
||||
- Files shared with other apps
|
||||
- Standard document behaviors expected (Recent Documents, autosave, versions)
|
||||
|
||||
Do NOT use when:
|
||||
- Single internal database (use shoebox pattern)
|
||||
- No user-facing files
|
||||
</when_to_use>
|
||||
|
||||
<swiftui_document_group>
|
||||
<basic_setup>
|
||||
```swift
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@main
|
||||
struct MyDocumentApp: App {
|
||||
var body: some Scene {
|
||||
DocumentGroup(newDocument: MyDocument()) { file in
|
||||
DocumentView(document: file.$document)
|
||||
}
|
||||
.commands {
|
||||
DocumentCommands()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MyDocument: FileDocument {
|
||||
// Supported types
|
||||
static var readableContentTypes: [UTType] { [.myDocument] }
|
||||
static var writableContentTypes: [UTType] { [.myDocument] }
|
||||
|
||||
// Document data
|
||||
var content: DocumentContent
|
||||
|
||||
// New document
|
||||
init() {
|
||||
content = DocumentContent()
|
||||
}
|
||||
|
||||
// Load from file
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
guard let data = configuration.file.regularFileContents else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
content = try JSONDecoder().decode(DocumentContent.self, from: data)
|
||||
}
|
||||
|
||||
// Save to file
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
let data = try JSONEncoder().encode(content)
|
||||
return FileWrapper(regularFileWithContents: data)
|
||||
}
|
||||
}
|
||||
|
||||
// Custom UTType
|
||||
extension UTType {
|
||||
static var myDocument: UTType {
|
||||
UTType(exportedAs: "com.yourcompany.myapp.document")
|
||||
}
|
||||
}
|
||||
```
|
||||
</basic_setup>
|
||||
|
||||
<document_view>
|
||||
```swift
|
||||
struct DocumentView: View {
|
||||
@Binding var document: MyDocument
|
||||
@FocusedBinding(\.document) private var focusedDocument
|
||||
|
||||
var body: some View {
|
||||
TextEditor(text: $document.content.text)
|
||||
.focusedSceneValue(\.document, $document)
|
||||
}
|
||||
}
|
||||
|
||||
// Focused values for commands
|
||||
struct DocumentFocusedValueKey: FocusedValueKey {
|
||||
typealias Value = Binding<MyDocument>
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var document: Binding<MyDocument>? {
|
||||
get { self[DocumentFocusedValueKey.self] }
|
||||
set { self[DocumentFocusedValueKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
```
|
||||
</document_view>
|
||||
|
||||
<document_commands>
|
||||
```swift
|
||||
struct DocumentCommands: Commands {
|
||||
@FocusedBinding(\.document) private var document
|
||||
|
||||
var body: some Commands {
|
||||
CommandMenu("Format") {
|
||||
Button("Bold") {
|
||||
document?.wrappedValue.content.toggleBold()
|
||||
}
|
||||
.keyboardShortcut("b", modifiers: .command)
|
||||
.disabled(document == nil)
|
||||
|
||||
Button("Italic") {
|
||||
document?.wrappedValue.content.toggleItalic()
|
||||
}
|
||||
.keyboardShortcut("i", modifiers: .command)
|
||||
.disabled(document == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</document_commands>
|
||||
|
||||
<reference_file_document>
|
||||
For documents referencing external files:
|
||||
|
||||
```swift
|
||||
struct ProjectDocument: ReferenceFileDocument {
|
||||
static var readableContentTypes: [UTType] { [.myProject] }
|
||||
|
||||
var project: Project
|
||||
|
||||
init() {
|
||||
project = Project()
|
||||
}
|
||||
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
guard let data = configuration.file.regularFileContents else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
project = try JSONDecoder().decode(Project.self, from: data)
|
||||
}
|
||||
|
||||
func snapshot(contentType: UTType) throws -> Project {
|
||||
project
|
||||
}
|
||||
|
||||
func fileWrapper(snapshot: Project, configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
return FileWrapper(regularFileWithContents: data)
|
||||
}
|
||||
}
|
||||
```
|
||||
</reference_file_document>
|
||||
</swiftui_document_group>
|
||||
|
||||
<info_plist_document_types>
|
||||
Configure document types in Info.plist:
|
||||
|
||||
```xml
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>My Document</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.yourcompany.myapp.document</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.yourcompany.myapp.document</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>My Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.content</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>mydoc</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
</info_plist_document_types>
|
||||
|
||||
<nsdocument_appkit>
|
||||
For more control, use NSDocument:
|
||||
|
||||
<nsdocument_subclass>
|
||||
```swift
|
||||
import AppKit
|
||||
|
||||
class Document: NSDocument {
|
||||
var content = DocumentContent()
|
||||
|
||||
override class var autosavesInPlace: Bool { true }
|
||||
|
||||
override func makeWindowControllers() {
|
||||
let contentView = DocumentView(document: self)
|
||||
let hostingController = NSHostingController(rootView: contentView)
|
||||
|
||||
let window = NSWindow(contentViewController: hostingController)
|
||||
window.setContentSize(NSSize(width: 800, height: 600))
|
||||
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||
|
||||
let windowController = NSWindowController(window: window)
|
||||
addWindowController(windowController)
|
||||
}
|
||||
|
||||
override func data(ofType typeName: String) throws -> Data {
|
||||
try JSONEncoder().encode(content)
|
||||
}
|
||||
|
||||
override func read(from data: Data, ofType typeName: String) throws {
|
||||
content = try JSONDecoder().decode(DocumentContent.self, from: data)
|
||||
}
|
||||
}
|
||||
```
|
||||
</nsdocument_subclass>
|
||||
|
||||
<undo_support>
|
||||
```swift
|
||||
class Document: NSDocument {
|
||||
var content = DocumentContent() {
|
||||
didSet {
|
||||
updateChangeCount(.changeDone)
|
||||
}
|
||||
}
|
||||
|
||||
func updateContent(_ newContent: DocumentContent) {
|
||||
let oldContent = content
|
||||
|
||||
undoManager?.registerUndo(withTarget: self) { document in
|
||||
document.updateContent(oldContent)
|
||||
}
|
||||
undoManager?.setActionName("Update Content")
|
||||
|
||||
content = newContent
|
||||
}
|
||||
}
|
||||
```
|
||||
</undo_support>
|
||||
|
||||
<nsdocument_lifecycle>
|
||||
```swift
|
||||
class Document: NSDocument {
|
||||
// Called when document is first opened
|
||||
override func windowControllerDidLoadNib(_ windowController: NSWindowController) {
|
||||
super.windowControllerDidLoadNib(windowController)
|
||||
// Setup UI
|
||||
}
|
||||
|
||||
// Called before saving
|
||||
override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
|
||||
savePanel.allowedContentTypes = [.myDocument]
|
||||
savePanel.allowsOtherFileTypes = false
|
||||
return true
|
||||
}
|
||||
|
||||
// Called after saving
|
||||
override func save(to url: URL, ofType typeName: String, for saveOperation: NSDocument.SaveOperationType, completionHandler: @escaping (Error?) -> Void) {
|
||||
super.save(to: url, ofType: typeName, for: saveOperation) { error in
|
||||
if error == nil {
|
||||
// Post-save actions
|
||||
}
|
||||
completionHandler(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle close with unsaved changes
|
||||
override func canClose(withDelegate delegate: Any, shouldClose shouldCloseSelector: Selector?, contextInfo: UnsafeMutableRawPointer?) {
|
||||
// Custom save confirmation
|
||||
super.canClose(withDelegate: delegate, shouldClose: shouldCloseSelector, contextInfo: contextInfo)
|
||||
}
|
||||
}
|
||||
```
|
||||
</nsdocument_lifecycle>
|
||||
</nsdocument_appkit>
|
||||
|
||||
<package_documents>
|
||||
For documents containing multiple files (like .pages):
|
||||
|
||||
```swift
|
||||
struct PackageDocument: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [.myPackage] }
|
||||
|
||||
var mainContent: MainContent
|
||||
var assets: [String: Data]
|
||||
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
guard let directory = configuration.file.fileWrappers else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
|
||||
// Read main content
|
||||
guard let mainData = directory["content.json"]?.regularFileContents else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
mainContent = try JSONDecoder().decode(MainContent.self, from: mainData)
|
||||
|
||||
// Read assets
|
||||
assets = [:]
|
||||
if let assetsDir = directory["Assets"]?.fileWrappers {
|
||||
for (name, wrapper) in assetsDir {
|
||||
if let data = wrapper.regularFileContents {
|
||||
assets[name] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
let directory = FileWrapper(directoryWithFileWrappers: [:])
|
||||
|
||||
// Write main content
|
||||
let mainData = try JSONEncoder().encode(mainContent)
|
||||
directory.addRegularFile(withContents: mainData, preferredFilename: "content.json")
|
||||
|
||||
// Write assets
|
||||
let assetsDir = FileWrapper(directoryWithFileWrappers: [:])
|
||||
for (name, data) in assets {
|
||||
assetsDir.addRegularFile(withContents: data, preferredFilename: name)
|
||||
}
|
||||
directory.addFileWrapper(assetsDir)
|
||||
assetsDir.preferredFilename = "Assets"
|
||||
|
||||
return directory
|
||||
}
|
||||
}
|
||||
|
||||
// UTType for package
|
||||
extension UTType {
|
||||
static var myPackage: UTType {
|
||||
UTType(exportedAs: "com.yourcompany.myapp.package", conformingTo: .package)
|
||||
}
|
||||
}
|
||||
```
|
||||
</package_documents>
|
||||
|
||||
<recent_documents>
|
||||
```swift
|
||||
// NSDocumentController manages Recent Documents automatically
|
||||
|
||||
// Custom recent documents menu
|
||||
struct AppCommands: Commands {
|
||||
var body: some Commands {
|
||||
CommandGroup(after: .newItem) {
|
||||
Menu("Open Recent") {
|
||||
ForEach(recentDocuments, id: \.self) { url in
|
||||
Button(url.lastPathComponent) {
|
||||
NSDocumentController.shared.openDocument(
|
||||
withContentsOf: url,
|
||||
display: true
|
||||
) { _, _, _ in }
|
||||
}
|
||||
}
|
||||
|
||||
if !recentDocuments.isEmpty {
|
||||
Divider()
|
||||
Button("Clear Menu") {
|
||||
NSDocumentController.shared.clearRecentDocuments(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var recentDocuments: [URL] {
|
||||
NSDocumentController.shared.recentDocumentURLs
|
||||
}
|
||||
}
|
||||
```
|
||||
</recent_documents>
|
||||
|
||||
<export_import>
|
||||
```swift
|
||||
struct DocumentView: View {
|
||||
@Binding var document: MyDocument
|
||||
@State private var showingExporter = false
|
||||
@State private var showingImporter = false
|
||||
|
||||
var body: some View {
|
||||
MainContent(document: $document)
|
||||
.toolbar {
|
||||
Button("Export") { showingExporter = true }
|
||||
Button("Import") { showingImporter = true }
|
||||
}
|
||||
.fileExporter(
|
||||
isPresented: $showingExporter,
|
||||
document: document,
|
||||
contentType: .pdf,
|
||||
defaultFilename: "Export"
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
print("Exported to \(url)")
|
||||
case .failure(let error):
|
||||
print("Export failed: \(error)")
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showingImporter,
|
||||
allowedContentTypes: [.plainText, .json],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
importFile(urls.first!)
|
||||
case .failure(let error):
|
||||
print("Import failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export to different format
|
||||
extension MyDocument {
|
||||
func exportAsPDF() -> Data {
|
||||
// Generate PDF from content
|
||||
let renderer = ImageRenderer(content: ContentPreview(content: content))
|
||||
return renderer.render { size, render in
|
||||
var box = CGRect(origin: .zero, size: size)
|
||||
guard let context = CGContext(consumer: CGDataConsumer(data: NSMutableData() as CFMutableData)!, mediaBox: &box, nil) else { return }
|
||||
context.beginPDFPage(nil)
|
||||
render(context)
|
||||
context.endPDFPage()
|
||||
context.closePDF()
|
||||
} ?? Data()
|
||||
}
|
||||
}
|
||||
```
|
||||
</export_import>
|
||||
555
skills/expertise/macos-apps/references/macos-polish.md
Normal file
555
skills/expertise/macos-apps/references/macos-polish.md
Normal file
@@ -0,0 +1,555 @@
|
||||
# macOS Polish
|
||||
|
||||
Details that make apps feel native and professional.
|
||||
|
||||
<keyboard_shortcuts>
|
||||
<standard_shortcuts>
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
struct AppCommands: Commands {
|
||||
var body: some Commands {
|
||||
// File operations
|
||||
CommandGroup(replacing: .saveItem) {
|
||||
Button("Save") { save() }
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
|
||||
Button("Save As...") { saveAs() }
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||
}
|
||||
|
||||
// Edit operations (usually automatic)
|
||||
// ⌘Z Undo, ⌘X Cut, ⌘C Copy, ⌘V Paste, ⌘A Select All
|
||||
|
||||
// View menu
|
||||
CommandMenu("View") {
|
||||
Button("Zoom In") { zoomIn() }
|
||||
.keyboardShortcut("+", modifiers: .command)
|
||||
|
||||
Button("Zoom Out") { zoomOut() }
|
||||
.keyboardShortcut("-", modifiers: .command)
|
||||
|
||||
Button("Actual Size") { resetZoom() }
|
||||
.keyboardShortcut("0", modifiers: .command)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Toggle Sidebar") { toggleSidebar() }
|
||||
.keyboardShortcut("s", modifiers: [.command, .control])
|
||||
|
||||
Button("Toggle Inspector") { toggleInspector() }
|
||||
.keyboardShortcut("i", modifiers: [.command, .option])
|
||||
}
|
||||
|
||||
// Custom menu
|
||||
CommandMenu("Actions") {
|
||||
Button("Run") { run() }
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
|
||||
Button("Build") { build() }
|
||||
.keyboardShortcut("b", modifiers: .command)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</standard_shortcuts>
|
||||
|
||||
<view_shortcuts>
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
MainContent()
|
||||
.onKeyPress(.space) {
|
||||
togglePlay()
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.delete) {
|
||||
deleteSelected()
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.escape) {
|
||||
clearSelection()
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress("f", modifiers: .command) {
|
||||
focusSearch()
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</view_shortcuts>
|
||||
</keyboard_shortcuts>
|
||||
|
||||
<menu_bar>
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.commands {
|
||||
// Replace standard items
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Project") { newProject() }
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
|
||||
Button("New from Template...") { newFromTemplate() }
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
}
|
||||
|
||||
// Add after existing group
|
||||
CommandGroup(after: .importExport) {
|
||||
Button("Import...") { importFile() }
|
||||
.keyboardShortcut("i", modifiers: [.command, .shift])
|
||||
|
||||
Button("Export...") { exportFile() }
|
||||
.keyboardShortcut("e", modifiers: [.command, .shift])
|
||||
}
|
||||
|
||||
// Add entire menu
|
||||
CommandMenu("Project") {
|
||||
Button("Build") { build() }
|
||||
.keyboardShortcut("b", modifiers: .command)
|
||||
|
||||
Button("Run") { run() }
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Clean") { clean() }
|
||||
.keyboardShortcut("k", modifiers: [.command, .shift])
|
||||
}
|
||||
|
||||
// Add to Help menu
|
||||
CommandGroup(after: .help) {
|
||||
Button("Keyboard Shortcuts") { showShortcuts() }
|
||||
.keyboardShortcut("/", modifiers: .command)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</menu_bar>
|
||||
|
||||
<context_menus>
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
|
||||
var body: some View {
|
||||
Text(item.name)
|
||||
.contextMenu {
|
||||
Button("Open") { open(item) }
|
||||
|
||||
Button("Open in New Window") { openInNewWindow(item) }
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Duplicate") { duplicate(item) }
|
||||
.keyboardShortcut("d", modifiers: .command)
|
||||
|
||||
Button("Rename") { rename(item) }
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Delete", role: .destructive) { delete(item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</context_menus>
|
||||
|
||||
<window_management>
|
||||
<multiple_windows>
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
// Main document window
|
||||
DocumentGroup(newDocument: MyDocument()) { file in
|
||||
DocumentView(document: file.$document)
|
||||
}
|
||||
|
||||
// Auxiliary windows
|
||||
Window("Inspector", id: "inspector") {
|
||||
InspectorView()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultPosition(.trailing)
|
||||
.keyboardShortcut("i", modifiers: [.command, .option])
|
||||
|
||||
// Floating utility
|
||||
Window("Quick Entry", id: "quick-entry") {
|
||||
QuickEntryView()
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open window from view
|
||||
struct ContentView: View {
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
Button("Show Inspector") {
|
||||
openWindow(id: "inspector")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</multiple_windows>
|
||||
|
||||
<window_state>
|
||||
```swift
|
||||
// Save and restore window state
|
||||
class WindowStateManager {
|
||||
static func save(_ window: NSWindow, key: String) {
|
||||
let frame = window.frame
|
||||
UserDefaults.standard.set(NSStringFromRect(frame), forKey: "window.\(key).frame")
|
||||
}
|
||||
|
||||
static func restore(_ window: NSWindow, key: String) {
|
||||
guard let frameString = UserDefaults.standard.string(forKey: "window.\(key).frame"),
|
||||
let frame = NSRectFromString(frameString) as NSRect? else { return }
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Window delegate
|
||||
class WindowDelegate: NSObject, NSWindowDelegate {
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
WindowStateManager.save(window, key: "main")
|
||||
}
|
||||
}
|
||||
```
|
||||
</window_state>
|
||||
</window_management>
|
||||
|
||||
<dock_menu>
|
||||
```swift
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
|
||||
let menu = NSMenu()
|
||||
|
||||
menu.addItem(NSMenuItem(
|
||||
title: "New Project",
|
||||
action: #selector(newProject),
|
||||
keyEquivalent: ""
|
||||
))
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
// Recent items
|
||||
let recentProjects = RecentProjectsManager.shared.projects
|
||||
for project in recentProjects.prefix(5) {
|
||||
let item = NSMenuItem(
|
||||
title: project.name,
|
||||
action: #selector(openRecent(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
item.representedObject = project.url
|
||||
menu.addItem(item)
|
||||
}
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
@objc private func newProject() {
|
||||
NSDocumentController.shared.newDocument(nil)
|
||||
}
|
||||
|
||||
@objc private func openRecent(_ sender: NSMenuItem) {
|
||||
guard let url = sender.representedObject as? URL else { return }
|
||||
NSDocumentController.shared.openDocument(
|
||||
withContentsOf: url,
|
||||
display: true
|
||||
) { _, _, _ in }
|
||||
}
|
||||
}
|
||||
```
|
||||
</dock_menu>
|
||||
|
||||
<accessibility>
|
||||
<voiceover>
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: item.icon)
|
||||
VStack(alignment: .leading) {
|
||||
Text(item.name)
|
||||
Text(item.date.formatted())
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(item.name), \(item.date.formatted())")
|
||||
.accessibilityHint("Double-tap to open")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
}
|
||||
```
|
||||
</voiceover>
|
||||
|
||||
<custom_rotors>
|
||||
```swift
|
||||
struct NoteListView: View {
|
||||
let notes: [Note]
|
||||
@State private var selectedNote: Note?
|
||||
|
||||
var body: some View {
|
||||
List(notes, selection: $selectedNote) { note in
|
||||
NoteRow(note: note)
|
||||
}
|
||||
.accessibilityRotor("Pinned Notes") {
|
||||
ForEach(notes.filter { $0.isPinned }) { note in
|
||||
AccessibilityRotorEntry(note.title, id: note.id) {
|
||||
selectedNote = note
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityRotor("Recent Notes") {
|
||||
ForEach(notes.sorted { $0.modifiedAt > $1.modifiedAt }.prefix(10)) { note in
|
||||
AccessibilityRotorEntry("\(note.title), modified \(note.modifiedAt.formatted())", id: note.id) {
|
||||
selectedNote = note
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</custom_rotors>
|
||||
|
||||
<reduced_motion>
|
||||
```swift
|
||||
struct AnimationHelper {
|
||||
static var prefersReducedMotion: Bool {
|
||||
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
||||
}
|
||||
|
||||
static func animation(_ animation: Animation) -> Animation? {
|
||||
prefersReducedMotion ? nil : animation
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
withAnimation(AnimationHelper.animation(.spring())) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
```
|
||||
</reduced_motion>
|
||||
</accessibility>
|
||||
|
||||
<user_defaults>
|
||||
```swift
|
||||
extension UserDefaults {
|
||||
enum Keys {
|
||||
static let theme = "theme"
|
||||
static let fontSize = "fontSize"
|
||||
static let recentFiles = "recentFiles"
|
||||
static let windowFrame = "windowFrame"
|
||||
}
|
||||
|
||||
var theme: String {
|
||||
get { string(forKey: Keys.theme) ?? "system" }
|
||||
set { set(newValue, forKey: Keys.theme) }
|
||||
}
|
||||
|
||||
var fontSize: Double {
|
||||
get { double(forKey: Keys.fontSize).nonZero ?? 14.0 }
|
||||
set { set(newValue, forKey: Keys.fontSize) }
|
||||
}
|
||||
|
||||
var recentFiles: [URL] {
|
||||
get {
|
||||
guard let data = data(forKey: Keys.recentFiles),
|
||||
let urls = try? JSONDecoder().decode([URL].self, from: data)
|
||||
else { return [] }
|
||||
return urls
|
||||
}
|
||||
set {
|
||||
let data = try? JSONEncoder().encode(newValue)
|
||||
set(data, forKey: Keys.recentFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Double {
|
||||
var nonZero: Double? { self == 0 ? nil : self }
|
||||
}
|
||||
|
||||
// Register defaults at launch
|
||||
func registerDefaults() {
|
||||
UserDefaults.standard.register(defaults: [
|
||||
UserDefaults.Keys.theme: "system",
|
||||
UserDefaults.Keys.fontSize: 14.0
|
||||
])
|
||||
}
|
||||
```
|
||||
</user_defaults>
|
||||
|
||||
<error_presentation>
|
||||
```swift
|
||||
struct ErrorPresenter: ViewModifier {
|
||||
@Binding var error: AppError?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert(
|
||||
"Error",
|
||||
isPresented: Binding(
|
||||
get: { error != nil },
|
||||
set: { if !$0 { error = nil } }
|
||||
),
|
||||
presenting: error
|
||||
) { _ in
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func errorAlert(_ error: Binding<AppError?>) -> some View {
|
||||
modifier(ErrorPresenter(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
ContentView()
|
||||
.errorAlert($appState.error)
|
||||
```
|
||||
</error_presentation>
|
||||
|
||||
<onboarding>
|
||||
```swift
|
||||
struct OnboardingView: View {
|
||||
@AppStorage("hasSeenOnboarding") private var hasSeenOnboarding = false
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.accentColor)
|
||||
|
||||
Text("Welcome to MyApp")
|
||||
.font(.largeTitle)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
FeatureRow(icon: "doc.text", title: "Create Documents", description: "Organize your work in documents")
|
||||
FeatureRow(icon: "folder", title: "Stay Organized", description: "Use folders and tags")
|
||||
FeatureRow(icon: "cloud", title: "Sync Everywhere", description: "Access on all your devices")
|
||||
}
|
||||
|
||||
Button("Get Started") {
|
||||
hasSeenOnboarding = true
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(40)
|
||||
.frame(width: 500)
|
||||
}
|
||||
}
|
||||
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.title2)
|
||||
.frame(width: 40)
|
||||
.foregroundStyle(.accentColor)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(title).fontWeight(.medium)
|
||||
Text(description).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</onboarding>
|
||||
|
||||
<sparkle_updates>
|
||||
```swift
|
||||
// Add Sparkle package for auto-updates
|
||||
// https://github.com/sparkle-project/Sparkle
|
||||
|
||||
import Sparkle
|
||||
|
||||
class UpdaterManager {
|
||||
private var updater: SPUUpdater?
|
||||
|
||||
func setup() {
|
||||
let controller = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
updater = controller.updater
|
||||
}
|
||||
|
||||
func checkForUpdates() {
|
||||
updater?.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
// In commands
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates...") {
|
||||
updaterManager.checkForUpdates()
|
||||
}
|
||||
}
|
||||
```
|
||||
</sparkle_updates>
|
||||
|
||||
<app_lifecycle>
|
||||
```swift
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
// Register defaults
|
||||
registerDefaults()
|
||||
|
||||
// Setup services
|
||||
setupServices()
|
||||
|
||||
// Check for updates
|
||||
checkForUpdates()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// Save state
|
||||
saveApplicationState()
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
// Return false for document-based or menu bar apps
|
||||
return false
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
if !flag {
|
||||
// Reopen main window
|
||||
NSDocumentController.shared.newDocument(nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
```
|
||||
</app_lifecycle>
|
||||
424
skills/expertise/macos-apps/references/menu-bar-apps.md
Normal file
424
skills/expertise/macos-apps/references/menu-bar-apps.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# Menu Bar Apps
|
||||
|
||||
Status bar utilities with quick access and minimal UI.
|
||||
|
||||
<when_to_use>
|
||||
Use menu bar pattern when:
|
||||
- Quick actions or status display
|
||||
- Background functionality
|
||||
- Minimal persistent UI
|
||||
- System-level utilities
|
||||
|
||||
Examples: Rectangle, Bartender, system utilities
|
||||
</when_to_use>
|
||||
|
||||
<basic_setup>
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MenuBarApp: App {
|
||||
var body: some Scene {
|
||||
MenuBarExtra("MyApp", systemImage: "star.fill") {
|
||||
MenuContent()
|
||||
}
|
||||
.menuBarExtraStyle(.window) // or .menu
|
||||
|
||||
// Optional settings window
|
||||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuContent: View {
|
||||
@AppStorage("isEnabled") private var isEnabled = true
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Enabled", isOn: $isEnabled)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Settings...") {
|
||||
openSettings()
|
||||
}
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
|
||||
Button("Quit") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.keyboardShortcut("q", modifiers: .command)
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 200)
|
||||
}
|
||||
}
|
||||
```
|
||||
</basic_setup>
|
||||
|
||||
<menu_styles>
|
||||
<window_style>
|
||||
Rich UI with any SwiftUI content:
|
||||
|
||||
```swift
|
||||
MenuBarExtra("MyApp", systemImage: "star.fill") {
|
||||
WindowStyleContent()
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
|
||||
struct WindowStyleContent: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Header
|
||||
HStack {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.title)
|
||||
Text("MyApp")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Content
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
|
||||
// Actions
|
||||
HStack {
|
||||
Button("Action 1") { }
|
||||
Button("Action 2") { }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 300)
|
||||
}
|
||||
}
|
||||
```
|
||||
</window_style>
|
||||
|
||||
<menu_style>
|
||||
Standard menu appearance:
|
||||
|
||||
```swift
|
||||
MenuBarExtra("MyApp", systemImage: "star.fill") {
|
||||
Button("Action 1") { performAction1() }
|
||||
.keyboardShortcut("1")
|
||||
|
||||
Button("Action 2") { performAction2() }
|
||||
.keyboardShortcut("2")
|
||||
|
||||
Divider()
|
||||
|
||||
Menu("Submenu") {
|
||||
Button("Sub-action 1") { }
|
||||
Button("Sub-action 2") { }
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Quit") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.keyboardShortcut("q", modifiers: .command)
|
||||
}
|
||||
.menuBarExtraStyle(.menu)
|
||||
```
|
||||
</menu_style>
|
||||
</menu_styles>
|
||||
|
||||
<dynamic_icon>
|
||||
```swift
|
||||
@main
|
||||
struct MenuBarApp: App {
|
||||
@State private var status: AppStatus = .idle
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
MenuContent(status: $status)
|
||||
} label: {
|
||||
switch status {
|
||||
case .idle:
|
||||
Image(systemName: "circle")
|
||||
case .active:
|
||||
Image(systemName: "circle.fill")
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppStatus {
|
||||
case idle, active, error
|
||||
}
|
||||
|
||||
// Or with text
|
||||
MenuBarExtra {
|
||||
Content()
|
||||
} label: {
|
||||
Label("\(count)", systemImage: "bell.fill")
|
||||
}
|
||||
```
|
||||
</dynamic_icon>
|
||||
|
||||
<background_only>
|
||||
App without dock icon (menu bar only):
|
||||
|
||||
```swift
|
||||
// Info.plist
|
||||
// <key>LSUIElement</key>
|
||||
// <true/>
|
||||
|
||||
@main
|
||||
struct MenuBarApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("MyApp", systemImage: "star.fill") {
|
||||
MenuContent()
|
||||
}
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
// Clicking dock icon (if visible) shows settings
|
||||
if !flag {
|
||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
```
|
||||
</background_only>
|
||||
|
||||
<global_shortcuts>
|
||||
```swift
|
||||
import Carbon
|
||||
|
||||
class ShortcutManager {
|
||||
static let shared = ShortcutManager()
|
||||
|
||||
private var hotKeyRef: EventHotKeyRef?
|
||||
private var callback: (() -> Void)?
|
||||
|
||||
func register(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) {
|
||||
self.callback = action
|
||||
|
||||
var hotKeyID = EventHotKeyID()
|
||||
hotKeyID.signature = OSType("MYAP".fourCharCodeValue)
|
||||
hotKeyID.id = 1
|
||||
|
||||
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
|
||||
|
||||
InstallEventHandler(GetApplicationEventTarget(), { _, event, userData -> OSStatus in
|
||||
guard let userData = userData else { return OSStatus(eventNotHandledErr) }
|
||||
let manager = Unmanaged<ShortcutManager>.fromOpaque(userData).takeUnretainedValue()
|
||||
manager.callback?()
|
||||
return noErr
|
||||
}, 1, &eventType, Unmanaged.passUnretained(self).toOpaque(), nil)
|
||||
|
||||
RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef)
|
||||
}
|
||||
|
||||
func unregister() {
|
||||
if let ref = hotKeyRef {
|
||||
UnregisterEventHotKey(ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var fourCharCodeValue: FourCharCode {
|
||||
var result: FourCharCode = 0
|
||||
for char in utf8.prefix(4) {
|
||||
result = (result << 8) + FourCharCode(char)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
ShortcutManager.shared.register(
|
||||
keyCode: UInt32(kVK_ANSI_M),
|
||||
modifiers: UInt32(cmdKey | optionKey)
|
||||
) {
|
||||
// Toggle menu bar app
|
||||
}
|
||||
```
|
||||
</global_shortcuts>
|
||||
|
||||
<with_main_window>
|
||||
Menu bar app with optional main window:
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MenuBarApp: App {
|
||||
@State private var showMainWindow = false
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("MyApp", systemImage: "star.fill") {
|
||||
MenuContent(showMainWindow: $showMainWindow)
|
||||
}
|
||||
|
||||
Window("MyApp", id: "main") {
|
||||
MainWindowContent()
|
||||
}
|
||||
.defaultSize(width: 600, height: 400)
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuContent: View {
|
||||
@Binding var showMainWindow: Bool
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Button("Show Window") {
|
||||
openWindow(id: "main")
|
||||
}
|
||||
|
||||
// Quick actions...
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
```
|
||||
</with_main_window>
|
||||
|
||||
<persistent_state>
|
||||
```swift
|
||||
struct MenuContent: View {
|
||||
@AppStorage("isEnabled") private var isEnabled = true
|
||||
@AppStorage("checkInterval") private var checkInterval = 60
|
||||
@AppStorage("notificationsEnabled") private var notifications = true
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Toggle("Enabled", isOn: $isEnabled)
|
||||
|
||||
Picker("Check every", selection: $checkInterval) {
|
||||
Text("1 min").tag(60)
|
||||
Text("5 min").tag(300)
|
||||
Text("15 min").tag(900)
|
||||
}
|
||||
|
||||
Toggle("Notifications", isOn: $notifications)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
```
|
||||
</persistent_state>
|
||||
|
||||
<popover_from_menu_bar>
|
||||
Custom popover positioning:
|
||||
|
||||
```swift
|
||||
class PopoverManager: NSObject {
|
||||
private var statusItem: NSStatusItem?
|
||||
private var popover = NSPopover()
|
||||
|
||||
func setup() {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
|
||||
if let button = statusItem?.button {
|
||||
button.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "MyApp")
|
||||
button.action = #selector(togglePopover)
|
||||
button.target = self
|
||||
}
|
||||
|
||||
popover.contentViewController = NSHostingController(rootView: PopoverContent())
|
||||
popover.behavior = .transient
|
||||
}
|
||||
|
||||
@objc func togglePopover() {
|
||||
if popover.isShown {
|
||||
popover.close()
|
||||
} else if let button = statusItem?.button {
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</popover_from_menu_bar>
|
||||
|
||||
<timer_background_task>
|
||||
```swift
|
||||
@Observable
|
||||
class BackgroundService {
|
||||
private var timer: Timer?
|
||||
var lastCheck: Date?
|
||||
var status: String = "Idle"
|
||||
|
||||
func start() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
|
||||
Task {
|
||||
await self?.performCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func performCheck() async {
|
||||
status = "Checking..."
|
||||
// Do work
|
||||
await Task.sleep(for: .seconds(2))
|
||||
lastCheck = Date()
|
||||
status = "OK"
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuContent: View {
|
||||
@State private var service = BackgroundService()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Status: \(service.status)")
|
||||
|
||||
if let lastCheck = service.lastCheck {
|
||||
Text("Last: \(lastCheck.formatted())")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Button("Check Now") {
|
||||
Task { await service.performCheck() }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.onAppear {
|
||||
service.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</timer_background_task>
|
||||
|
||||
<best_practices>
|
||||
- Keep menu content minimal and fast
|
||||
- Use .window style for rich UI, .menu for simple actions
|
||||
- Provide keyboard shortcuts for common actions
|
||||
- Save state with @AppStorage
|
||||
- Include "Quit" option always
|
||||
- Use background-only (LSUIElement) when appropriate
|
||||
- Provide settings window for configuration
|
||||
- Show status in icon when possible (dynamic icon)
|
||||
</best_practices>
|
||||
549
skills/expertise/macos-apps/references/networking.md
Normal file
549
skills/expertise/macos-apps/references/networking.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# Networking
|
||||
|
||||
URLSession patterns for API calls, authentication, caching, and offline support.
|
||||
|
||||
<basic_requests>
|
||||
<async_await>
|
||||
```swift
|
||||
actor NetworkService {
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
self.decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
}
|
||||
|
||||
func fetch<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
guard 200..<300 ~= httpResponse.statusCode else {
|
||||
throw NetworkError.httpError(httpResponse.statusCode, data)
|
||||
}
|
||||
|
||||
return try decoder.decode(T.self, from: data)
|
||||
}
|
||||
|
||||
func fetchData(_ request: URLRequest) async throws -> Data {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
200..<300 ~= httpResponse.statusCode else {
|
||||
throw NetworkError.requestFailed
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkError: Error {
|
||||
case invalidResponse
|
||||
case httpError(Int, Data)
|
||||
case requestFailed
|
||||
case decodingError(Error)
|
||||
}
|
||||
```
|
||||
</async_await>
|
||||
|
||||
<request_building>
|
||||
```swift
|
||||
struct Endpoint {
|
||||
let path: String
|
||||
let method: HTTPMethod
|
||||
let queryItems: [URLQueryItem]?
|
||||
let body: Data?
|
||||
let headers: [String: String]?
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case put = "PUT"
|
||||
case patch = "PATCH"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
|
||||
var request: URLRequest {
|
||||
var components = URLComponents()
|
||||
components.scheme = "https"
|
||||
components.host = "api.example.com"
|
||||
components.path = path
|
||||
components.queryItems = queryItems
|
||||
|
||||
var request = URLRequest(url: components.url!)
|
||||
request.httpMethod = method.rawValue
|
||||
request.httpBody = body
|
||||
|
||||
// Default headers
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
// Custom headers
|
||||
headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) }
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
extension Endpoint {
|
||||
static func projects() -> Endpoint {
|
||||
Endpoint(path: "/v1/projects", method: .get, queryItems: nil, body: nil, headers: nil)
|
||||
}
|
||||
|
||||
static func project(id: UUID) -> Endpoint {
|
||||
Endpoint(path: "/v1/projects/\(id)", method: .get, queryItems: nil, body: nil, headers: nil)
|
||||
}
|
||||
|
||||
static func createProject(_ project: CreateProjectRequest) -> Endpoint {
|
||||
let body = try? JSONEncoder().encode(project)
|
||||
return Endpoint(path: "/v1/projects", method: .post, queryItems: nil, body: body, headers: nil)
|
||||
}
|
||||
}
|
||||
```
|
||||
</request_building>
|
||||
</basic_requests>
|
||||
|
||||
<authentication>
|
||||
<bearer_token>
|
||||
```swift
|
||||
actor AuthenticatedNetworkService {
|
||||
private let session: URLSession
|
||||
private var token: String?
|
||||
|
||||
init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpAdditionalHeaders = [
|
||||
"User-Agent": "MyApp/1.0"
|
||||
]
|
||||
self.session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
func setToken(_ token: String) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
var request = endpoint.request
|
||||
|
||||
if let token = token {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 {
|
||||
throw NetworkError.unauthorized
|
||||
}
|
||||
|
||||
guard 200..<300 ~= httpResponse.statusCode else {
|
||||
throw NetworkError.httpError(httpResponse.statusCode, data)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
```
|
||||
</bearer_token>
|
||||
|
||||
<oauth_refresh>
|
||||
```swift
|
||||
actor OAuthService {
|
||||
private var accessToken: String?
|
||||
private var refreshToken: String?
|
||||
private var tokenExpiry: Date?
|
||||
private var isRefreshing = false
|
||||
|
||||
func validToken() async throws -> String {
|
||||
// Return existing valid token
|
||||
if let token = accessToken,
|
||||
let expiry = tokenExpiry,
|
||||
expiry > Date().addingTimeInterval(60) {
|
||||
return token
|
||||
}
|
||||
|
||||
// Refresh if needed
|
||||
return try await refreshAccessToken()
|
||||
}
|
||||
|
||||
private func refreshAccessToken() async throws -> String {
|
||||
guard !isRefreshing else {
|
||||
// Wait for in-progress refresh
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
return try await validToken()
|
||||
}
|
||||
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
|
||||
guard let refresh = refreshToken else {
|
||||
throw AuthError.noRefreshToken
|
||||
}
|
||||
|
||||
let request = Endpoint.refreshToken(refresh).request
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
let response = try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||
|
||||
accessToken = response.accessToken
|
||||
refreshToken = response.refreshToken
|
||||
tokenExpiry = Date().addingTimeInterval(TimeInterval(response.expiresIn))
|
||||
|
||||
// Save to keychain
|
||||
try saveTokens()
|
||||
|
||||
return response.accessToken
|
||||
}
|
||||
}
|
||||
```
|
||||
</oauth_refresh>
|
||||
</authentication>
|
||||
|
||||
<caching>
|
||||
<urlcache>
|
||||
```swift
|
||||
// Configure cache in URLSession
|
||||
let config = URLSessionConfiguration.default
|
||||
config.urlCache = URLCache(
|
||||
memoryCapacity: 50 * 1024 * 1024, // 50 MB memory
|
||||
diskCapacity: 100 * 1024 * 1024, // 100 MB disk
|
||||
diskPath: "network_cache"
|
||||
)
|
||||
config.requestCachePolicy = .returnCacheDataElseLoad
|
||||
|
||||
let session = URLSession(configuration: config)
|
||||
```
|
||||
</urlcache>
|
||||
|
||||
<custom_cache>
|
||||
```swift
|
||||
actor ResponseCache {
|
||||
private var cache: [String: CachedResponse] = [:]
|
||||
private let maxAge: TimeInterval
|
||||
|
||||
init(maxAge: TimeInterval = 300) { // 5 minutes default
|
||||
self.maxAge = maxAge
|
||||
}
|
||||
|
||||
func get<T: Decodable>(_ key: String) -> T? {
|
||||
guard let cached = cache[key],
|
||||
Date().timeIntervalSince(cached.timestamp) < maxAge else {
|
||||
cache[key] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
return try? JSONDecoder().decode(T.self, from: cached.data)
|
||||
}
|
||||
|
||||
func set<T: Encodable>(_ value: T, for key: String) {
|
||||
guard let data = try? JSONEncoder().encode(value) else { return }
|
||||
cache[key] = CachedResponse(data: data, timestamp: Date())
|
||||
}
|
||||
|
||||
func invalidate(_ key: String) {
|
||||
cache[key] = nil
|
||||
}
|
||||
|
||||
func clear() {
|
||||
cache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
struct CachedResponse {
|
||||
let data: Data
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
// Usage
|
||||
actor CachedNetworkService {
|
||||
private let network: NetworkService
|
||||
private let cache = ResponseCache()
|
||||
|
||||
func fetchProjects(forceRefresh: Bool = false) async throws -> [Project] {
|
||||
let cacheKey = "projects"
|
||||
|
||||
if !forceRefresh, let cached: [Project] = await cache.get(cacheKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let projects: [Project] = try await network.fetch(Endpoint.projects().request)
|
||||
await cache.set(projects, for: cacheKey)
|
||||
|
||||
return projects
|
||||
}
|
||||
}
|
||||
```
|
||||
</custom_cache>
|
||||
</caching>
|
||||
|
||||
<offline_support>
|
||||
```swift
|
||||
@Observable
|
||||
class OfflineAwareService {
|
||||
private let network: NetworkService
|
||||
private let storage: LocalStorage
|
||||
var isOnline = true
|
||||
|
||||
init(network: NetworkService, storage: LocalStorage) {
|
||||
self.network = network
|
||||
self.storage = storage
|
||||
monitorConnectivity()
|
||||
}
|
||||
|
||||
func fetchProjects() async throws -> [Project] {
|
||||
if isOnline {
|
||||
do {
|
||||
let projects = try await network.fetch(Endpoint.projects().request)
|
||||
try storage.save(projects, for: "projects")
|
||||
return projects
|
||||
} catch {
|
||||
// Fall back to cache on network error
|
||||
if let cached = try? storage.load("projects") as [Project] {
|
||||
return cached
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
// Offline: use cache
|
||||
guard let cached = try? storage.load("projects") as [Project] else {
|
||||
throw NetworkError.offline
|
||||
}
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
private func monitorConnectivity() {
|
||||
let monitor = NWPathMonitor()
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor in
|
||||
self?.isOnline = path.status == .satisfied
|
||||
}
|
||||
}
|
||||
monitor.start(queue: .global())
|
||||
}
|
||||
}
|
||||
```
|
||||
</offline_support>
|
||||
|
||||
<upload_download>
|
||||
<file_upload>
|
||||
```swift
|
||||
actor UploadService {
|
||||
func upload(file: URL, to endpoint: Endpoint) async throws -> UploadResponse {
|
||||
var request = endpoint.request
|
||||
|
||||
let boundary = UUID().uuidString
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let data = try Data(contentsOf: file)
|
||||
let body = createMultipartBody(
|
||||
data: data,
|
||||
filename: file.lastPathComponent,
|
||||
boundary: boundary
|
||||
)
|
||||
request.httpBody = body
|
||||
|
||||
let (responseData, _) = try await URLSession.shared.data(for: request)
|
||||
return try JSONDecoder().decode(UploadResponse.self, from: responseData)
|
||||
}
|
||||
|
||||
private func createMultipartBody(data: Data, filename: String, boundary: String) -> Data {
|
||||
var body = Data()
|
||||
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(data)
|
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
return body
|
||||
}
|
||||
}
|
||||
```
|
||||
</file_upload>
|
||||
|
||||
<file_download>
|
||||
```swift
|
||||
actor DownloadService {
|
||||
func download(from url: URL, to destination: URL) async throws {
|
||||
let (tempURL, response) = try await URLSession.shared.download(from: url)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
200..<300 ~= httpResponse.statusCode else {
|
||||
throw NetworkError.downloadFailed
|
||||
}
|
||||
|
||||
// Move to destination
|
||||
let fileManager = FileManager.default
|
||||
if fileManager.fileExists(atPath: destination.path) {
|
||||
try fileManager.removeItem(at: destination)
|
||||
}
|
||||
try fileManager.moveItem(at: tempURL, to: destination)
|
||||
}
|
||||
|
||||
func downloadWithProgress(from url: URL) -> AsyncThrowingStream<DownloadProgress, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = URLSession.shared.downloadTask(with: url) { tempURL, response, error in
|
||||
if let error = error {
|
||||
continuation.finish(throwing: error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let tempURL = tempURL else {
|
||||
continuation.finish(throwing: NetworkError.downloadFailed)
|
||||
return
|
||||
}
|
||||
|
||||
continuation.yield(.completed(tempURL))
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
// Observe progress
|
||||
let observation = task.progress.observe(\.fractionCompleted) { progress, _ in
|
||||
continuation.yield(.progress(progress.fractionCompleted))
|
||||
}
|
||||
|
||||
continuation.onTermination = { _ in
|
||||
observation.invalidate()
|
||||
task.cancel()
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DownloadProgress {
|
||||
case progress(Double)
|
||||
case completed(URL)
|
||||
}
|
||||
```
|
||||
</file_download>
|
||||
</upload_download>
|
||||
|
||||
<error_handling>
|
||||
```swift
|
||||
enum NetworkError: LocalizedError {
|
||||
case invalidResponse
|
||||
case httpError(Int, Data)
|
||||
case unauthorized
|
||||
case offline
|
||||
case timeout
|
||||
case decodingError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
return "Invalid server response"
|
||||
case .httpError(let code, _):
|
||||
return "Server error: \(code)"
|
||||
case .unauthorized:
|
||||
return "Authentication required"
|
||||
case .offline:
|
||||
return "No internet connection"
|
||||
case .timeout:
|
||||
return "Request timed out"
|
||||
case .decodingError(let error):
|
||||
return "Data error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
var isRetryable: Bool {
|
||||
switch self {
|
||||
case .httpError(let code, _):
|
||||
return code >= 500
|
||||
case .timeout, .offline:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retry logic
|
||||
func fetchWithRetry<T: Decodable>(
|
||||
_ request: URLRequest,
|
||||
maxAttempts: Int = 3
|
||||
) async throws -> T {
|
||||
var lastError: Error?
|
||||
|
||||
for attempt in 1...maxAttempts {
|
||||
do {
|
||||
return try await network.fetch(request)
|
||||
} catch let error as NetworkError where error.isRetryable {
|
||||
lastError = error
|
||||
let delay = pow(2.0, Double(attempt - 1)) // Exponential backoff
|
||||
try await Task.sleep(for: .seconds(delay))
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? NetworkError.requestFailed
|
||||
}
|
||||
```
|
||||
</error_handling>
|
||||
|
||||
<testing>
|
||||
```swift
|
||||
// Mock URLProtocol for testing
|
||||
class MockURLProtocol: URLProtocol {
|
||||
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
guard let handler = MockURLProtocol.requestHandler else {
|
||||
fatalError("Handler not set")
|
||||
}
|
||||
|
||||
do {
|
||||
let (response, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
// Test setup
|
||||
func testFetchProjects() async throws {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [MockURLProtocol.self]
|
||||
let session = URLSession(configuration: config)
|
||||
|
||||
MockURLProtocol.requestHandler = { request in
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
let data = try JSONEncoder().encode([Project(name: "Test")])
|
||||
return (response, data)
|
||||
}
|
||||
|
||||
let service = NetworkService(session: session)
|
||||
let projects: [Project] = try await service.fetch(Endpoint.projects().request)
|
||||
|
||||
XCTAssertEqual(projects.count, 1)
|
||||
}
|
||||
```
|
||||
</testing>
|
||||
585
skills/expertise/macos-apps/references/project-scaffolding.md
Normal file
585
skills/expertise/macos-apps/references/project-scaffolding.md
Normal file
@@ -0,0 +1,585 @@
|
||||
# Project Scaffolding
|
||||
|
||||
Complete setup for new macOS Swift apps with all necessary files and configurations.
|
||||
|
||||
<new_project_checklist>
|
||||
1. Create project.yml for XcodeGen
|
||||
2. Create Swift source files
|
||||
3. Run `xcodegen generate`
|
||||
4. Configure signing (DEVELOPMENT_TEAM)
|
||||
5. Build and verify with `xcodebuild`
|
||||
</new_project_checklist>
|
||||
|
||||
<xcodegen_setup>
|
||||
**Install XcodeGen** (one-time):
|
||||
```bash
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
**Create a new macOS app**:
|
||||
```bash
|
||||
mkdir MyApp && cd MyApp
|
||||
mkdir -p Sources Tests Resources
|
||||
# Create project.yml (see template below)
|
||||
# Create Swift files
|
||||
xcodegen generate
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp build
|
||||
```
|
||||
</xcodegen_setup>
|
||||
|
||||
<project_yml_template>
|
||||
**project.yml** - Complete macOS SwiftUI app template:
|
||||
|
||||
```yaml
|
||||
name: MyApp
|
||||
options:
|
||||
bundleIdPrefix: com.yourcompany
|
||||
deploymentTarget:
|
||||
macOS: "14.0"
|
||||
xcodeVersion: "15.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
configs:
|
||||
Debug: debug
|
||||
Release: release
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
|
||||
targets:
|
||||
MyApp:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
- Sources
|
||||
resources:
|
||||
- Resources
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
||||
CFBundleName: $(PRODUCT_NAME)
|
||||
CFBundleIdentifier: $(PRODUCT_BUNDLE_IDENTIFIER)
|
||||
CFBundleShortVersionString: "1.0"
|
||||
CFBundleVersion: "1"
|
||||
LSApplicationCategoryType: public.app-category.utilities
|
||||
NSPrincipalClass: NSApplication
|
||||
NSHighResolutionCapable: true
|
||||
entitlements:
|
||||
path: Sources/MyApp.entitlements
|
||||
properties:
|
||||
com.apple.security.app-sandbox: true
|
||||
com.apple.security.network.client: true
|
||||
com.apple.security.files.user-selected.read-write: true
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
|
||||
PRODUCT_NAME: MyApp
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: YOURTEAMID
|
||||
configs:
|
||||
Debug:
|
||||
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||
SWIFT_OPTIMIZATION_LEVEL: -Onone
|
||||
CODE_SIGN_ENTITLEMENTS: Sources/MyApp.entitlements
|
||||
Release:
|
||||
SWIFT_OPTIMIZATION_LEVEL: -Osize
|
||||
|
||||
MyAppTests:
|
||||
type: bundle.unit-test
|
||||
platform: macOS
|
||||
sources:
|
||||
- Tests
|
||||
dependencies:
|
||||
- target: MyApp
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp.tests
|
||||
|
||||
schemes:
|
||||
MyApp:
|
||||
build:
|
||||
targets:
|
||||
MyApp: all
|
||||
MyAppTests: [test]
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
config: Debug
|
||||
gatherCoverageData: true
|
||||
targets:
|
||||
- MyAppTests
|
||||
profile:
|
||||
config: Release
|
||||
archive:
|
||||
config: Release
|
||||
```
|
||||
</project_yml_template>
|
||||
|
||||
<project_yml_swiftdata>
|
||||
**project.yml with SwiftData**:
|
||||
|
||||
Add to target settings:
|
||||
```yaml
|
||||
settings:
|
||||
base:
|
||||
# ... existing settings ...
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) SWIFT_DATA"
|
||||
dependencies:
|
||||
- sdk: SwiftData.framework
|
||||
```
|
||||
</project_yml_swiftdata>
|
||||
|
||||
<project_yml_packages>
|
||||
**Adding Swift Package dependencies**:
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
Alamofire:
|
||||
url: https://github.com/Alamofire/Alamofire
|
||||
from: 5.8.0
|
||||
KeychainAccess:
|
||||
url: https://github.com/kishikawakatsumi/KeychainAccess
|
||||
from: 4.2.0
|
||||
|
||||
targets:
|
||||
MyApp:
|
||||
# ... other config ...
|
||||
dependencies:
|
||||
- package: Alamofire
|
||||
- package: KeychainAccess
|
||||
```
|
||||
</project_yml_packages>
|
||||
|
||||
<alternative_xcode_template>
|
||||
**Alternative: Xcode GUI method**
|
||||
|
||||
For users who prefer Xcode:
|
||||
1. File > New > Project > macOS > App
|
||||
2. Settings: SwiftUI, Swift, SwiftData (optional)
|
||||
3. Save to desired location
|
||||
</alternative_xcode_template>
|
||||
|
||||
<minimal_file_structure>
|
||||
```
|
||||
MyApp/
|
||||
├── MyApp.xcodeproj/
|
||||
│ └── project.pbxproj
|
||||
├── MyApp/
|
||||
│ ├── MyApp.swift # App entry point
|
||||
│ ├── ContentView.swift # Main view
|
||||
│ ├── Info.plist
|
||||
│ ├── MyApp.entitlements
|
||||
│ └── Assets.xcassets/
|
||||
│ ├── Contents.json
|
||||
│ ├── AppIcon.appiconset/
|
||||
│ │ └── Contents.json
|
||||
│ └── AccentColor.colorset/
|
||||
│ └── Contents.json
|
||||
└── MyAppTests/
|
||||
└── MyAppTests.swift
|
||||
```
|
||||
</minimal_file_structure>
|
||||
|
||||
<starter_code>
|
||||
<app_entry_point>
|
||||
**MyApp.swift**:
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var appState = AppState()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(appState)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) { } // Remove default New
|
||||
}
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</app_entry_point>
|
||||
|
||||
<app_state>
|
||||
**AppState.swift**:
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
class AppState {
|
||||
var items: [Item] = []
|
||||
var selectedItemID: UUID?
|
||||
var searchText = ""
|
||||
|
||||
var selectedItem: Item? {
|
||||
items.first { $0.id == selectedItemID }
|
||||
}
|
||||
|
||||
var filteredItems: [Item] {
|
||||
if searchText.isEmpty {
|
||||
return items
|
||||
}
|
||||
return items.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
func addItem(_ name: String) {
|
||||
let item = Item(name: name)
|
||||
items.append(item)
|
||||
selectedItemID = item.id
|
||||
}
|
||||
|
||||
func deleteItem(_ item: Item) {
|
||||
items.removeAll { $0.id == item.id }
|
||||
if selectedItemID == item.id {
|
||||
selectedItemID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Item: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
var createdAt = Date()
|
||||
}
|
||||
```
|
||||
</app_state>
|
||||
|
||||
<content_view>
|
||||
**ContentView.swift**:
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
} detail: {
|
||||
DetailView()
|
||||
}
|
||||
.searchable(text: $appState.searchText)
|
||||
.navigationTitle("MyApp")
|
||||
}
|
||||
}
|
||||
|
||||
struct SidebarView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
List(appState.filteredItems, selection: $appState.selectedItemID) { item in
|
||||
Text(item.name)
|
||||
.tag(item.id)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
appState.addItem("New Item")
|
||||
}
|
||||
}
|
||||
|
||||
struct DetailView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
if let item = appState.selectedItem {
|
||||
VStack {
|
||||
Text(item.name)
|
||||
.font(.title)
|
||||
Text(item.createdAt.formatted())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
ContentUnavailableView("No Selection", systemImage: "sidebar.left")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</content_view>
|
||||
|
||||
<settings_view>
|
||||
**SettingsView.swift**:
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
GeneralSettingsView()
|
||||
.tabItem {
|
||||
Label("General", systemImage: "gear")
|
||||
}
|
||||
|
||||
AdvancedSettingsView()
|
||||
.tabItem {
|
||||
Label("Advanced", systemImage: "slider.horizontal.3")
|
||||
}
|
||||
}
|
||||
.frame(width: 450, height: 250)
|
||||
}
|
||||
}
|
||||
|
||||
struct GeneralSettingsView: View {
|
||||
@AppStorage("showWelcome") private var showWelcome = true
|
||||
@AppStorage("defaultName") private var defaultName = "Untitled"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Toggle("Show welcome screen on launch", isOn: $showWelcome)
|
||||
TextField("Default item name", text: $defaultName)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct AdvancedSettingsView: View {
|
||||
@AppStorage("enableLogging") private var enableLogging = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Toggle("Enable debug logging", isOn: $enableLogging)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
```
|
||||
</settings_view>
|
||||
</starter_code>
|
||||
|
||||
<info_plist>
|
||||
**Info.plist** (complete template):
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>MyApp</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2024 Your Name. All rights reserved.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
**Common category types**:
|
||||
- `public.app-category.productivity`
|
||||
- `public.app-category.developer-tools`
|
||||
- `public.app-category.utilities`
|
||||
- `public.app-category.music`
|
||||
- `public.app-category.graphics-design`
|
||||
</info_plist>
|
||||
|
||||
<entitlements>
|
||||
**MyApp.entitlements** (sandbox with network):
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
**Debug entitlements** (add for debug builds):
|
||||
```xml
|
||||
<key>com.apple.security.get-task-allow</key>
|
||||
<true/>
|
||||
```
|
||||
</entitlements>
|
||||
|
||||
<assets_catalog>
|
||||
**Assets.xcassets/Contents.json**:
|
||||
```json
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Assets.xcassets/AppIcon.appiconset/Contents.json**:
|
||||
```json
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Assets.xcassets/AccentColor.colorset/Contents.json**:
|
||||
```json
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
```
|
||||
</assets_catalog>
|
||||
|
||||
<swift_packages>
|
||||
Add dependencies via Package.swift or Xcode:
|
||||
|
||||
**Common packages**:
|
||||
```swift
|
||||
// In Xcode: File > Add Package Dependencies
|
||||
|
||||
// Networking
|
||||
.package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0")
|
||||
|
||||
// Logging
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0")
|
||||
|
||||
// Keychain
|
||||
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", from: "4.2.0")
|
||||
|
||||
// Syntax highlighting
|
||||
.package(url: "https://github.com/raspu/Highlightr.git", from: "2.1.0")
|
||||
```
|
||||
|
||||
**Add via CLI**:
|
||||
```bash
|
||||
# Edit project to add package dependency
|
||||
# (Easier to do once in Xcode, then clone for future projects)
|
||||
```
|
||||
</swift_packages>
|
||||
|
||||
<verify_setup>
|
||||
```bash
|
||||
# Verify project configuration
|
||||
xcodebuild -list -project MyApp.xcodeproj
|
||||
|
||||
# Build
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-configuration Debug \
|
||||
-derivedDataPath ./build \
|
||||
build
|
||||
|
||||
# Run
|
||||
open ./build/Build/Products/Debug/MyApp.app
|
||||
|
||||
# Check signing
|
||||
codesign -dv ./build/Build/Products/Debug/MyApp.app
|
||||
```
|
||||
</verify_setup>
|
||||
|
||||
<next_steps>
|
||||
After scaffolding:
|
||||
|
||||
1. **Define your data model**: Create models in Models/ folder
|
||||
2. **Choose persistence**: SwiftData, Core Data, or file-based
|
||||
3. **Design main UI**: Sidebar + detail or single-window layout
|
||||
4. **Add menu commands**: Edit AppCommands.swift
|
||||
5. **Configure logging**: Set up os.Logger with appropriate subsystem
|
||||
6. **Write tests**: Unit tests for models, integration tests for services
|
||||
|
||||
See [cli-workflow.md](cli-workflow.md) for build/run/debug workflow.
|
||||
</next_steps>
|
||||
524
skills/expertise/macos-apps/references/security-code-signing.md
Normal file
524
skills/expertise/macos-apps/references/security-code-signing.md
Normal file
@@ -0,0 +1,524 @@
|
||||
# Security & Code Signing
|
||||
|
||||
Secure coding, keychain, code signing, and notarization for macOS apps.
|
||||
|
||||
<keychain>
|
||||
<save_retrieve>
|
||||
```swift
|
||||
import Security
|
||||
|
||||
class KeychainService {
|
||||
enum KeychainError: Error {
|
||||
case itemNotFound
|
||||
case duplicateItem
|
||||
case unexpectedStatus(OSStatus)
|
||||
}
|
||||
|
||||
static let shared = KeychainService()
|
||||
private let service = Bundle.main.bundleIdentifier!
|
||||
|
||||
// Save data
|
||||
func save(key: String, data: Data) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
|
||||
// Delete existing item first
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve data
|
||||
func load(key: String) throws -> Data {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else {
|
||||
if status == errSecItemNotFound {
|
||||
throw KeychainError.itemNotFound
|
||||
}
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
|
||||
guard let data = result as? Data else {
|
||||
throw KeychainError.itemNotFound
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Delete item
|
||||
func delete(key: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
// Update existing item
|
||||
func update(key: String, data: Data) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data
|
||||
]
|
||||
|
||||
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods for strings
|
||||
extension KeychainService {
|
||||
func saveString(_ string: String, for key: String) throws {
|
||||
guard let data = string.data(using: .utf8) else { return }
|
||||
try save(key: key, data: data)
|
||||
}
|
||||
|
||||
func loadString(for key: String) throws -> String {
|
||||
let data = try load(key: key)
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
throw KeychainError.itemNotFound
|
||||
}
|
||||
return string
|
||||
}
|
||||
}
|
||||
```
|
||||
</save_retrieve>
|
||||
|
||||
<keychain_access_groups>
|
||||
Share keychain items between apps:
|
||||
|
||||
```swift
|
||||
// In entitlements
|
||||
/*
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.yourcompany.shared</string>
|
||||
</array>
|
||||
*/
|
||||
|
||||
// When saving
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecAttrAccessGroup as String: "TEAMID.com.yourcompany.shared",
|
||||
kSecValueData as String: data
|
||||
]
|
||||
```
|
||||
</keychain_access_groups>
|
||||
|
||||
<keychain_access_control>
|
||||
```swift
|
||||
// Require user presence (Touch ID / password)
|
||||
func saveSecure(key: String, data: Data) throws {
|
||||
let access = SecAccessControlCreateWithFlags(
|
||||
nil,
|
||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
.userPresence,
|
||||
nil
|
||||
)
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessControl as String: access as Any
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
```
|
||||
</keychain_access_control>
|
||||
</keychain>
|
||||
|
||||
<secure_coding>
|
||||
<input_validation>
|
||||
```swift
|
||||
// Validate user input
|
||||
func validateUsername(_ username: String) throws -> String {
|
||||
// Check length
|
||||
guard username.count >= 3, username.count <= 50 else {
|
||||
throw ValidationError.invalidLength
|
||||
}
|
||||
|
||||
// Check characters
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_-"))
|
||||
guard username.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
|
||||
throw ValidationError.invalidCharacters
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
// Sanitize for display
|
||||
func sanitizeHTML(_ input: String) -> String {
|
||||
input
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
```
|
||||
</input_validation>
|
||||
|
||||
<secure_random>
|
||||
```swift
|
||||
import Security
|
||||
|
||||
// Generate secure random bytes
|
||||
func secureRandomBytes(count: Int) -> Data? {
|
||||
var bytes = [UInt8](repeating: 0, count: count)
|
||||
let result = SecRandomCopyBytes(kSecRandomDefault, count, &bytes)
|
||||
guard result == errSecSuccess else { return nil }
|
||||
return Data(bytes)
|
||||
}
|
||||
|
||||
// Generate secure token
|
||||
func generateToken(length: Int = 32) -> String? {
|
||||
guard let data = secureRandomBytes(count: length) else { return nil }
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
```
|
||||
</secure_random>
|
||||
|
||||
<cryptography>
|
||||
```swift
|
||||
import CryptoKit
|
||||
|
||||
// Hash data
|
||||
func hash(_ data: Data) -> String {
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
// Encrypt with symmetric key
|
||||
func encrypt(_ data: Data, key: SymmetricKey) throws -> Data {
|
||||
try AES.GCM.seal(data, using: key).combined!
|
||||
}
|
||||
|
||||
func decrypt(_ data: Data, key: SymmetricKey) throws -> Data {
|
||||
let box = try AES.GCM.SealedBox(combined: data)
|
||||
return try AES.GCM.open(box, using: key)
|
||||
}
|
||||
|
||||
// Generate key from password
|
||||
func deriveKey(from password: String, salt: Data) -> SymmetricKey {
|
||||
let passwordData = Data(password.utf8)
|
||||
let key = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: passwordData),
|
||||
salt: salt,
|
||||
info: Data("MyApp".utf8),
|
||||
outputByteCount: 32
|
||||
)
|
||||
return key
|
||||
}
|
||||
```
|
||||
</cryptography>
|
||||
|
||||
<secure_file_storage>
|
||||
```swift
|
||||
// Store sensitive files with data protection
|
||||
func saveSecureFile(_ data: Data, to url: URL) throws {
|
||||
try data.write(to: url, options: [.atomic, .completeFileProtection])
|
||||
}
|
||||
|
||||
// Read with security scope
|
||||
func readSecureFile(at url: URL) throws -> Data {
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
```
|
||||
</secure_file_storage>
|
||||
</secure_coding>
|
||||
|
||||
<app_sandbox>
|
||||
<entitlements>
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Enable sandbox -->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
|
||||
<!-- Network -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
|
||||
<!-- File access -->
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
|
||||
<!-- Hardware -->
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
|
||||
<!-- Inter-app -->
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
|
||||
<!-- Temporary exception (avoid if possible) -->
|
||||
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
|
||||
<array>
|
||||
<string>/Library/Application Support/MyApp/</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
</entitlements>
|
||||
|
||||
<request_permission>
|
||||
```swift
|
||||
// Request camera permission
|
||||
import AVFoundation
|
||||
|
||||
func requestCameraAccess() async -> Bool {
|
||||
await AVCaptureDevice.requestAccess(for: .video)
|
||||
}
|
||||
|
||||
// Request microphone permission
|
||||
func requestMicrophoneAccess() async -> Bool {
|
||||
await AVCaptureDevice.requestAccess(for: .audio)
|
||||
}
|
||||
|
||||
// Check status
|
||||
func checkCameraAuthorization() -> AVAuthorizationStatus {
|
||||
AVCaptureDevice.authorizationStatus(for: .video)
|
||||
}
|
||||
```
|
||||
</request_permission>
|
||||
</app_sandbox>
|
||||
|
||||
<code_signing>
|
||||
<signing_identity>
|
||||
```bash
|
||||
# List available signing identities
|
||||
security find-identity -v -p codesigning
|
||||
|
||||
# Sign app with Developer ID
|
||||
codesign --force --options runtime \
|
||||
--sign "Developer ID Application: Your Name (TEAMID)" \
|
||||
--entitlements MyApp/MyApp.entitlements \
|
||||
MyApp.app
|
||||
|
||||
# Verify signature
|
||||
codesign --verify --verbose=4 MyApp.app
|
||||
|
||||
# Display signature info
|
||||
codesign -dv --verbose=4 MyApp.app
|
||||
|
||||
# Show entitlements
|
||||
codesign -d --entitlements - MyApp.app
|
||||
```
|
||||
</signing_identity>
|
||||
|
||||
<hardened_runtime>
|
||||
```xml
|
||||
<!-- Required for notarization -->
|
||||
<!-- Hardened runtime entitlements -->
|
||||
|
||||
<!-- Allow JIT (for JavaScript engines) -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
|
||||
<!-- Allow unsigned executable memory (rare) -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
|
||||
<!-- Disable library validation (for plugins) -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
|
||||
<!-- Allow DYLD environment variables -->
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
```
|
||||
</hardened_runtime>
|
||||
</code_signing>
|
||||
|
||||
<notarization>
|
||||
<notarize_app>
|
||||
```bash
|
||||
# Create ZIP for notarization
|
||||
ditto -c -k --keepParent MyApp.app MyApp.zip
|
||||
|
||||
# Submit for notarization
|
||||
xcrun notarytool submit MyApp.zip \
|
||||
--apple-id your@email.com \
|
||||
--team-id YOURTEAMID \
|
||||
--password @keychain:AC_PASSWORD \
|
||||
--wait
|
||||
|
||||
# Check status
|
||||
xcrun notarytool info <submission-id> \
|
||||
--apple-id your@email.com \
|
||||
--team-id YOURTEAMID \
|
||||
--password @keychain:AC_PASSWORD
|
||||
|
||||
# View log
|
||||
xcrun notarytool log <submission-id> \
|
||||
--apple-id your@email.com \
|
||||
--team-id YOURTEAMID \
|
||||
--password @keychain:AC_PASSWORD
|
||||
|
||||
# Staple ticket
|
||||
xcrun stapler staple MyApp.app
|
||||
|
||||
# Verify notarization
|
||||
spctl --assess --verbose=4 --type execute MyApp.app
|
||||
```
|
||||
</notarize_app>
|
||||
|
||||
<store_credentials>
|
||||
```bash
|
||||
# Store notarization credentials in keychain
|
||||
xcrun notarytool store-credentials "AC_PASSWORD" \
|
||||
--apple-id your@email.com \
|
||||
--team-id YOURTEAMID \
|
||||
--password <app-specific-password>
|
||||
|
||||
# Use stored credentials
|
||||
xcrun notarytool submit MyApp.zip \
|
||||
--keychain-profile "AC_PASSWORD" \
|
||||
--wait
|
||||
```
|
||||
</store_credentials>
|
||||
|
||||
<dmg_notarization>
|
||||
```bash
|
||||
# Create DMG
|
||||
hdiutil create -volname "MyApp" -srcfolder MyApp.app -ov -format UDZO MyApp.dmg
|
||||
|
||||
# Sign DMG
|
||||
codesign --force --sign "Developer ID Application: Your Name (TEAMID)" MyApp.dmg
|
||||
|
||||
# Notarize DMG
|
||||
xcrun notarytool submit MyApp.dmg \
|
||||
--keychain-profile "AC_PASSWORD" \
|
||||
--wait
|
||||
|
||||
# Staple DMG
|
||||
xcrun stapler staple MyApp.dmg
|
||||
```
|
||||
</dmg_notarization>
|
||||
</notarization>
|
||||
|
||||
<transport_security>
|
||||
```swift
|
||||
// HTTPS only (default in iOS 9+ / macOS 10.11+)
|
||||
// Add exceptions in Info.plist if needed
|
||||
|
||||
/*
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
*/
|
||||
|
||||
// Certificate pinning
|
||||
class PinnedSessionDelegate: NSObject, URLSessionDelegate {
|
||||
let pinnedCertificates: [Data]
|
||||
|
||||
init(certificates: [Data]) {
|
||||
self.pinnedCertificates = certificates
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let serverTrust = challenge.protectionSpace.serverTrust,
|
||||
let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let serverCertData = SecCertificateCopyData(certificate) as Data
|
||||
|
||||
if pinnedCertificates.contains(serverCertData) {
|
||||
completionHandler(.useCredential, URLCredential(trust: serverTrust))
|
||||
} else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</transport_security>
|
||||
|
||||
<best_practices>
|
||||
<security_checklist>
|
||||
- Store secrets in Keychain, never in UserDefaults or files
|
||||
- Use App Transport Security (HTTPS only)
|
||||
- Validate all user input
|
||||
- Use secure random for tokens/keys
|
||||
- Enable hardened runtime
|
||||
- Sign and notarize for distribution
|
||||
- Request only necessary entitlements
|
||||
- Clear sensitive data from memory when done
|
||||
</security_checklist>
|
||||
|
||||
<common_mistakes>
|
||||
- Storing API keys in code (use Keychain or secure config)
|
||||
- Logging sensitive data
|
||||
- Using `print()` for sensitive values in production
|
||||
- Not validating server certificates
|
||||
- Weak password hashing (use bcrypt/scrypt/Argon2)
|
||||
- Storing passwords instead of hashes
|
||||
</common_mistakes>
|
||||
</best_practices>
|
||||
522
skills/expertise/macos-apps/references/shoebox-apps.md
Normal file
522
skills/expertise/macos-apps/references/shoebox-apps.md
Normal file
@@ -0,0 +1,522 @@
|
||||
# Shoebox/Library Apps
|
||||
|
||||
Apps with internal database and sidebar navigation (like Notes, Photos, Music).
|
||||
|
||||
<when_to_use>
|
||||
Use shoebox pattern when:
|
||||
- Single library of items (not separate files)
|
||||
- No explicit save (auto-save everything)
|
||||
- Import/export rather than open/save
|
||||
- Sidebar navigation (folders, tags, smart folders)
|
||||
- iCloud sync across devices
|
||||
|
||||
Do NOT use when:
|
||||
- Users need to manage individual files
|
||||
- Files shared with other apps directly
|
||||
</when_to_use>
|
||||
|
||||
<basic_structure>
|
||||
```swift
|
||||
@main
|
||||
struct LibraryApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(for: [Note.self, Folder.self, Tag.self])
|
||||
.commands {
|
||||
LibraryCommands()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var selectedFolder: Folder?
|
||||
@State private var selectedNote: Note?
|
||||
@State private var searchText = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView(selection: $selectedFolder)
|
||||
} content: {
|
||||
NoteListView(folder: selectedFolder, selection: $selectedNote)
|
||||
} detail: {
|
||||
if let note = selectedNote {
|
||||
NoteEditorView(note: note)
|
||||
} else {
|
||||
ContentUnavailableView("Select a Note", systemImage: "note.text")
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText)
|
||||
}
|
||||
}
|
||||
```
|
||||
</basic_structure>
|
||||
|
||||
<data_model>
|
||||
```swift
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
class Note {
|
||||
var title: String
|
||||
var content: String
|
||||
var createdAt: Date
|
||||
var modifiedAt: Date
|
||||
var isPinned: Bool
|
||||
|
||||
@Relationship(inverse: \Folder.notes)
|
||||
var folder: Folder?
|
||||
|
||||
@Relationship
|
||||
var tags: [Tag]
|
||||
|
||||
init(title: String = "New Note") {
|
||||
self.title = title
|
||||
self.content = ""
|
||||
self.createdAt = Date()
|
||||
self.modifiedAt = Date()
|
||||
self.isPinned = false
|
||||
self.tags = []
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
class Folder {
|
||||
var name: String
|
||||
var icon: String
|
||||
var sortOrder: Int
|
||||
|
||||
@Relationship(deleteRule: .cascade)
|
||||
var notes: [Note]
|
||||
|
||||
var isSmartFolder: Bool
|
||||
var predicate: String? // For smart folders
|
||||
|
||||
init(name: String, icon: String = "folder") {
|
||||
self.name = name
|
||||
self.icon = icon
|
||||
self.sortOrder = 0
|
||||
self.notes = []
|
||||
self.isSmartFolder = false
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
class Tag {
|
||||
var name: String
|
||||
var color: String
|
||||
|
||||
@Relationship(inverse: \Note.tags)
|
||||
var notes: [Note]
|
||||
|
||||
init(name: String, color: String = "blue") {
|
||||
self.name = name
|
||||
self.color = color
|
||||
self.notes = []
|
||||
}
|
||||
}
|
||||
```
|
||||
</data_model>
|
||||
|
||||
<sidebar>
|
||||
```swift
|
||||
struct SidebarView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query(sort: \Folder.sortOrder) private var folders: [Folder]
|
||||
@Binding var selection: Folder?
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selection) {
|
||||
Section("Library") {
|
||||
Label("All Notes", systemImage: "note.text")
|
||||
.tag(nil as Folder?)
|
||||
|
||||
Label("Recently Deleted", systemImage: "trash")
|
||||
}
|
||||
|
||||
Section("Folders") {
|
||||
ForEach(folders.filter { !$0.isSmartFolder }) { folder in
|
||||
Label(folder.name, systemImage: folder.icon)
|
||||
.tag(folder as Folder?)
|
||||
.contextMenu {
|
||||
Button("Rename") { renameFolder(folder) }
|
||||
Button("Delete", role: .destructive) { deleteFolder(folder) }
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveFolders)
|
||||
}
|
||||
|
||||
Section("Smart Folders") {
|
||||
ForEach(folders.filter { $0.isSmartFolder }) { folder in
|
||||
Label(folder.name, systemImage: "folder.badge.gearshape")
|
||||
.tag(folder as Folder?)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Tags") {
|
||||
TagsSection()
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button(action: addFolder) {
|
||||
Label("New Folder", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addFolder() {
|
||||
let folder = Folder(name: "New Folder")
|
||||
folder.sortOrder = folders.count
|
||||
context.insert(folder)
|
||||
}
|
||||
|
||||
private func deleteFolder(_ folder: Folder) {
|
||||
context.delete(folder)
|
||||
}
|
||||
|
||||
private func moveFolders(from source: IndexSet, to destination: Int) {
|
||||
var reordered = folders.filter { !$0.isSmartFolder }
|
||||
reordered.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, folder) in reordered.enumerated() {
|
||||
folder.sortOrder = index
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</sidebar>
|
||||
|
||||
<note_list>
|
||||
```swift
|
||||
struct NoteListView: View {
|
||||
let folder: Folder?
|
||||
@Binding var selection: Note?
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query private var allNotes: [Note]
|
||||
|
||||
var filteredNotes: [Note] {
|
||||
let sorted = allNotes.sorted {
|
||||
if $0.isPinned != $1.isPinned {
|
||||
return $0.isPinned
|
||||
}
|
||||
return $0.modifiedAt > $1.modifiedAt
|
||||
}
|
||||
|
||||
if let folder = folder {
|
||||
return sorted.filter { $0.folder == folder }
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List(filteredNotes, selection: $selection) { note in
|
||||
NoteRow(note: note)
|
||||
.tag(note)
|
||||
.contextMenu {
|
||||
Button(note.isPinned ? "Unpin" : "Pin") {
|
||||
note.isPinned.toggle()
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) {
|
||||
context.delete(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button(action: addNote) {
|
||||
Label("New Note", systemImage: "square.and.pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addNote() {
|
||||
let note = Note()
|
||||
note.folder = folder
|
||||
context.insert(note)
|
||||
selection = note
|
||||
}
|
||||
}
|
||||
|
||||
struct NoteRow: View {
|
||||
let note: Note
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
if note.isPinned {
|
||||
Image(systemName: "pin.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(note.title.isEmpty ? "New Note" : note.title)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
|
||||
Text(note.modifiedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(note.content.prefix(100))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
```
|
||||
</note_list>
|
||||
|
||||
<editor>
|
||||
```swift
|
||||
struct NoteEditorView: View {
|
||||
@Bindable var note: Note
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Title
|
||||
TextField("Title", text: $note.title)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.title)
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
// Content
|
||||
TextEditor(text: $note.content)
|
||||
.font(.body)
|
||||
.focused($isFocused)
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: note.title) { _, _ in
|
||||
note.modifiedAt = Date()
|
||||
}
|
||||
.onChange(of: note.content) { _, _ in
|
||||
note.modifiedAt = Date()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Menu {
|
||||
TagPickerMenu(note: note)
|
||||
} label: {
|
||||
Label("Tags", systemImage: "tag")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
ShareLink(item: note.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</editor>
|
||||
|
||||
<smart_folders>
|
||||
```swift
|
||||
struct SmartFolderSetup {
|
||||
static func createDefaultSmartFolders(context: ModelContext) {
|
||||
// Today
|
||||
let today = Folder(name: "Today", icon: "calendar")
|
||||
today.isSmartFolder = true
|
||||
today.predicate = "modifiedAt >= startOfToday"
|
||||
context.insert(today)
|
||||
|
||||
// This Week
|
||||
let week = Folder(name: "This Week", icon: "calendar.badge.clock")
|
||||
week.isSmartFolder = true
|
||||
week.predicate = "modifiedAt >= startOfWeek"
|
||||
context.insert(week)
|
||||
|
||||
// Pinned
|
||||
let pinned = Folder(name: "Pinned", icon: "pin")
|
||||
pinned.isSmartFolder = true
|
||||
pinned.predicate = "isPinned == true"
|
||||
context.insert(pinned)
|
||||
}
|
||||
}
|
||||
|
||||
// Query based on smart folder predicate
|
||||
func notesForSmartFolder(_ folder: Folder) -> [Note] {
|
||||
switch folder.predicate {
|
||||
case "isPinned == true":
|
||||
return allNotes.filter { $0.isPinned }
|
||||
case "modifiedAt >= startOfToday":
|
||||
let start = Calendar.current.startOfDay(for: Date())
|
||||
return allNotes.filter { $0.modifiedAt >= start }
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
</smart_folders>
|
||||
|
||||
<import_export>
|
||||
```swift
|
||||
struct LibraryCommands: Commands {
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
var body: some Commands {
|
||||
CommandGroup(after: .importExport) {
|
||||
Button("Import Notes...") {
|
||||
importNotes()
|
||||
}
|
||||
.keyboardShortcut("i", modifiers: [.command, .shift])
|
||||
|
||||
Button("Export All Notes...") {
|
||||
exportNotes()
|
||||
}
|
||||
.keyboardShortcut("e", modifiers: [.command, .shift])
|
||||
}
|
||||
}
|
||||
|
||||
private func importNotes() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.json, .plainText]
|
||||
panel.allowsMultipleSelection = true
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
for url in panel.urls {
|
||||
importFile(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportNotes() {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.json]
|
||||
panel.nameFieldStringValue = "Notes Export.json"
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
let descriptor = FetchDescriptor<Note>()
|
||||
if let notes = try? context.fetch(descriptor) {
|
||||
let exportData = notes.map { NoteExport(note: $0) }
|
||||
if let data = try? JSONEncoder().encode(exportData) {
|
||||
try? data.write(to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NoteExport: Codable {
|
||||
let title: String
|
||||
let content: String
|
||||
let createdAt: Date
|
||||
let modifiedAt: Date
|
||||
|
||||
init(note: Note) {
|
||||
self.title = note.title
|
||||
self.content = note.content
|
||||
self.createdAt = note.createdAt
|
||||
self.modifiedAt = note.modifiedAt
|
||||
}
|
||||
}
|
||||
```
|
||||
</import_export>
|
||||
|
||||
<search>
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var searchText = ""
|
||||
@Query private var allNotes: [Note]
|
||||
|
||||
var searchResults: [Note] {
|
||||
if searchText.isEmpty {
|
||||
return []
|
||||
}
|
||||
return allNotes.filter { note in
|
||||
note.title.localizedCaseInsensitiveContains(searchText) ||
|
||||
note.content.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
// ...
|
||||
}
|
||||
.searchable(text: $searchText, placement: .toolbar)
|
||||
.searchSuggestions {
|
||||
if !searchText.isEmpty {
|
||||
ForEach(searchResults.prefix(5)) { note in
|
||||
Button {
|
||||
selectedNote = note
|
||||
} label: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(note.title)
|
||||
Text(note.modifiedAt.formatted())
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</search>
|
||||
|
||||
<icloud_sync>
|
||||
```swift
|
||||
// Configure container for iCloud
|
||||
@main
|
||||
struct LibraryApp: App {
|
||||
let container: ModelContainer
|
||||
|
||||
init() {
|
||||
let schema = Schema([Note.self, Folder.self, Tag.self])
|
||||
let config = ModelConfiguration(
|
||||
"Library",
|
||||
schema: schema,
|
||||
cloudKitDatabase: .automatic
|
||||
)
|
||||
|
||||
do {
|
||||
container = try ModelContainer(for: schema, configurations: config)
|
||||
} catch {
|
||||
fatalError("Failed to create container: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(container)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle sync status
|
||||
struct SyncStatusIndicator: View {
|
||||
@State private var isSyncing = false
|
||||
|
||||
var body: some View {
|
||||
if isSyncing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
} else {
|
||||
Image(systemName: "checkmark.icloud")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</icloud_sync>
|
||||
|
||||
<best_practices>
|
||||
- Auto-save on every change (no explicit save)
|
||||
- Provide import/export for data portability
|
||||
- Use sidebar for navigation (folders, tags, smart folders)
|
||||
- Support search across all content
|
||||
- Show modification dates, not explicit "save"
|
||||
- Use SwiftData with iCloud for seamless sync
|
||||
- Provide trash/restore instead of permanent delete
|
||||
</best_practices>
|
||||
905
skills/expertise/macos-apps/references/swiftui-patterns.md
Normal file
905
skills/expertise/macos-apps/references/swiftui-patterns.md
Normal file
@@ -0,0 +1,905 @@
|
||||
<overview>
|
||||
Modern SwiftUI patterns for macOS apps. Covers @Bindable usage, navigation (NavigationSplitView, NavigationStack), windows, toolbars, menus, lists/tables, forms, sheets/alerts, drag & drop, focus management, and keyboard shortcuts.
|
||||
</overview>
|
||||
|
||||
<sections>
|
||||
Reference sections:
|
||||
- observation_rules - @Bindable, @Observable, environment patterns
|
||||
- navigation - NavigationSplitView, NavigationStack, drill-down
|
||||
- windows - WindowGroup, Settings, auxiliary windows
|
||||
- toolbar - Toolbar items, customizable toolbars
|
||||
- menus - App commands, context menus
|
||||
- lists_and_tables - List selection, Table, OutlineGroup
|
||||
- forms - Settings forms, validation
|
||||
- sheets_and_alerts - Sheets, confirmation dialogs, file dialogs
|
||||
- drag_and_drop - Draggable items, drop targets, reorderable lists
|
||||
- focus_and_keyboard - Focus state, keyboard shortcuts
|
||||
- previews - Preview patterns
|
||||
</sections>
|
||||
|
||||
<observation_rules>
|
||||
<passing_model_objects>
|
||||
**Critical rule for SwiftData @Model objects**: Use `@Bindable` when the child view needs to observe property changes or create bindings. Use `let` only for static display.
|
||||
|
||||
```swift
|
||||
// CORRECT: Use @Bindable when observing changes or binding
|
||||
struct CardView: View {
|
||||
@Bindable var card: Card // Use this for @Model objects
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
TextField("Title", text: $card.title) // Binding works
|
||||
Text(card.description) // Observes changes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WRONG: Using let breaks observation
|
||||
struct CardViewBroken: View {
|
||||
let card: Card // Won't observe property changes!
|
||||
|
||||
var body: some View {
|
||||
Text(card.title) // May not update when card.title changes
|
||||
}
|
||||
}
|
||||
```
|
||||
</passing_model_objects>
|
||||
|
||||
<when_to_use_bindable>
|
||||
**Use `@Bindable` when:**
|
||||
- Passing @Model objects to child views that observe changes
|
||||
- Creating bindings to model properties ($model.property)
|
||||
- The view should update when model properties change
|
||||
|
||||
**Use `let` when:**
|
||||
- Passing simple value types (structs, enums)
|
||||
- The view only needs the value at the moment of creation
|
||||
- You explicitly don't want reactivity
|
||||
|
||||
```swift
|
||||
// @Model objects - use @Bindable
|
||||
struct ColumnView: View {
|
||||
@Bindable var column: Column // SwiftData model
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(column.name) // Updates when column.name changes
|
||||
ForEach(column.cards) { card in
|
||||
CardView(card: card) // Pass model, use @Bindable in CardView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Value types - use let
|
||||
struct BadgeView: View {
|
||||
let count: Int // Value type, let is fine
|
||||
|
||||
var body: some View {
|
||||
Text("\(count)")
|
||||
}
|
||||
}
|
||||
```
|
||||
</when_to_use_bindable>
|
||||
|
||||
<environment_to_bindable>
|
||||
When accessing @Observable from environment, create local @Bindable for bindings:
|
||||
|
||||
```swift
|
||||
struct SidebarView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
// Create local @Bindable for bindings
|
||||
@Bindable var appState = appState
|
||||
|
||||
List(appState.items, selection: $appState.selectedID) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</environment_to_bindable>
|
||||
</observation_rules>
|
||||
|
||||
<navigation>
|
||||
<navigation_split_view>
|
||||
Standard three-column layout:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var selectedFolder: Folder?
|
||||
@State private var selectedItem: Item?
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
// Sidebar
|
||||
SidebarView(selection: $selectedFolder)
|
||||
} content: {
|
||||
// Content list
|
||||
if let folder = selectedFolder {
|
||||
ItemListView(folder: folder, selection: $selectedItem)
|
||||
} else {
|
||||
ContentUnavailableView("Select a Folder", systemImage: "folder")
|
||||
}
|
||||
} detail: {
|
||||
// Detail
|
||||
if let item = selectedItem {
|
||||
DetailView(item: item)
|
||||
} else {
|
||||
ContentUnavailableView("Select an Item", systemImage: "doc")
|
||||
}
|
||||
}
|
||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 300)
|
||||
}
|
||||
}
|
||||
```
|
||||
</navigation_split_view>
|
||||
|
||||
<two_column_layout>
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var selectedItem: Item?
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView(selection: $selectedItem)
|
||||
.navigationSplitViewColumnWidth(min: 200, ideal: 250)
|
||||
} detail: {
|
||||
if let item = selectedItem {
|
||||
DetailView(item: item)
|
||||
} else {
|
||||
ContentUnavailableView("No Selection", systemImage: "sidebar.left")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</two_column_layout>
|
||||
|
||||
<navigation_stack>
|
||||
For drill-down navigation:
|
||||
|
||||
```swift
|
||||
struct BrowseView: View {
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
CategoryListView()
|
||||
.navigationDestination(for: Category.self) { category in
|
||||
ItemListView(category: category)
|
||||
}
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
DetailView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</navigation_stack>
|
||||
</navigation>
|
||||
|
||||
<windows>
|
||||
<multiple_window_types>
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
// Main window
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.commands {
|
||||
AppCommands()
|
||||
}
|
||||
|
||||
// Auxiliary window
|
||||
Window("Inspector", id: "inspector") {
|
||||
InspectorView()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultPosition(.trailing)
|
||||
.keyboardShortcut("i", modifiers: [.command, .option])
|
||||
|
||||
// Utility window
|
||||
Window("Quick Entry", id: "quick-entry") {
|
||||
QuickEntryView()
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
// Settings
|
||||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</multiple_window_types>
|
||||
|
||||
<window_control>
|
||||
Open windows programmatically:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
Button("Show Inspector") {
|
||||
openWindow(id: "inspector")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</window_control>
|
||||
|
||||
<document_group>
|
||||
For document-based apps:
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
DocumentGroup(newDocument: MyDocument()) { file in
|
||||
DocumentView(document: file.$document)
|
||||
}
|
||||
.commands {
|
||||
DocumentCommands()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</document_group>
|
||||
|
||||
<debugging_swiftui_appkit>
|
||||
**Meta-principle: Declarative overrides Imperative**
|
||||
|
||||
When SwiftUI wraps AppKit (via NSHostingView, NSViewRepresentable, etc.), SwiftUI's declarative layer manages the AppKit objects underneath. Your AppKit code may be "correct" but irrelevant if SwiftUI is controlling that concern.
|
||||
|
||||
**Debugging pattern:**
|
||||
1. Issue occurs (e.g., window won't respect constraints, focus not working, layout broken)
|
||||
2. ❌ **Wrong approach:** Jump to AppKit APIs to "fix" it imperatively
|
||||
3. ✅ **Right approach:** Check SwiftUI layer first - what's declaratively controlling this?
|
||||
4. **Why:** The wrapper controls the wrapped. Higher abstraction wins.
|
||||
|
||||
**Example scenario - Window sizing:**
|
||||
- Symptom: `NSWindow.minSize` code runs but window still resizes smaller
|
||||
- Wrong: Add more AppKit code, observers, notifications to "force" it
|
||||
- Right: Search codebase for `.frame(minWidth:)` on content view - that's what's actually controlling it
|
||||
- Lesson: NSHostingView manages window constraints based on SwiftUI content
|
||||
|
||||
**This pattern applies broadly:**
|
||||
- Window sizing → Check `.frame()`, `.windowResizability()` before `NSWindow` properties
|
||||
- Focus management → Check `@FocusState`, `.focused()` before `NSResponder` chain
|
||||
- Layout constraints → Check SwiftUI layout modifiers before Auto Layout
|
||||
- Toolbar → Check `.toolbar {}` before `NSToolbar` setup
|
||||
|
||||
**When to actually use AppKit:**
|
||||
Only when SwiftUI doesn't provide the capability (custom drawing, specialized controls, backward compatibility). Not as a workaround when SwiftUI "doesn't work" - you probably haven't found SwiftUI's way yet.
|
||||
</debugging_swiftui_appkit>
|
||||
</windows>
|
||||
|
||||
<toolbar>
|
||||
<toolbar_content>
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var searchText = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
} detail: {
|
||||
DetailView()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button(action: addItem) {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
|
||||
Button(action: deleteItem) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button(action: toggleSidebar) {
|
||||
Label("Toggle Sidebar", systemImage: "sidebar.left")
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, placement: .toolbar)
|
||||
}
|
||||
|
||||
private func toggleSidebar() {
|
||||
NSApp.keyWindow?.firstResponder?.tryToPerform(
|
||||
#selector(NSSplitViewController.toggleSidebar(_:)),
|
||||
with: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
</toolbar_content>
|
||||
|
||||
<customizable_toolbar>
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
MainContent()
|
||||
.toolbar(id: "main") {
|
||||
ToolbarItem(id: "add", placement: .primaryAction) {
|
||||
Button(action: add) {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(id: "share", placement: .secondaryAction) {
|
||||
ShareLink(item: currentItem)
|
||||
}
|
||||
|
||||
ToolbarItem(id: "spacer", placement: .automatic) {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.toolbarRole(.editor)
|
||||
}
|
||||
}
|
||||
```
|
||||
</customizable_toolbar>
|
||||
</toolbar>
|
||||
|
||||
<menus>
|
||||
<app_commands>
|
||||
```swift
|
||||
struct AppCommands: Commands {
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some Commands {
|
||||
// Replace standard menu items
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Project") {
|
||||
// Create new project
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
}
|
||||
|
||||
// Add new menu
|
||||
CommandMenu("View") {
|
||||
Button("Show Inspector") {
|
||||
openWindow(id: "inspector")
|
||||
}
|
||||
.keyboardShortcut("i", modifiers: [.command, .option])
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Zoom In") {
|
||||
// Zoom in
|
||||
}
|
||||
.keyboardShortcut("+", modifiers: .command)
|
||||
|
||||
Button("Zoom Out") {
|
||||
// Zoom out
|
||||
}
|
||||
.keyboardShortcut("-", modifiers: .command)
|
||||
}
|
||||
|
||||
// Add to existing menu
|
||||
CommandGroup(after: .sidebar) {
|
||||
Button("Toggle Inspector") {
|
||||
// Toggle
|
||||
}
|
||||
.keyboardShortcut("i", modifiers: .command)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</app_commands>
|
||||
|
||||
<context_menus>
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
let onDelete: () -> Void
|
||||
let onDuplicate: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(item.name)
|
||||
Spacer()
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Duplicate") {
|
||||
onDuplicate()
|
||||
}
|
||||
|
||||
Button("Delete", role: .destructive) {
|
||||
onDelete()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Menu("Move to") {
|
||||
ForEach(folders) { folder in
|
||||
Button(folder.name) {
|
||||
move(to: folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</context_menus>
|
||||
</menus>
|
||||
|
||||
<lists_and_tables>
|
||||
<list_selection>
|
||||
```swift
|
||||
struct SidebarView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
List(appState.items, selection: $appState.selectedItemID) { item in
|
||||
Label(item.name, systemImage: item.icon)
|
||||
.tag(item.id)
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
}
|
||||
```
|
||||
</list_selection>
|
||||
|
||||
<table>
|
||||
```swift
|
||||
struct ItemTableView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var sortOrder = [KeyPathComparator(\Item.name)]
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
Table(appState.items, selection: $appState.selectedItemIDs, sortOrder: $sortOrder) {
|
||||
TableColumn("Name", value: \.name) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
|
||||
TableColumn("Date", value: \.createdAt) { item in
|
||||
Text(item.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
.width(min: 100, ideal: 150)
|
||||
|
||||
TableColumn("Size", value: \.size) { item in
|
||||
Text(ByteCountFormatter.string(fromByteCount: item.size, countStyle: .file))
|
||||
}
|
||||
.width(80)
|
||||
}
|
||||
.onChange(of: sortOrder) {
|
||||
appState.items.sort(using: sortOrder)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</table>
|
||||
|
||||
<outline_group>
|
||||
For hierarchical data:
|
||||
|
||||
```swift
|
||||
struct OutlineView: View {
|
||||
let rootItems: [TreeItem]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
OutlineGroup(rootItems, children: \.children) { item in
|
||||
Label(item.name, systemImage: item.icon)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TreeItem: Identifiable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
var icon: String
|
||||
var children: [TreeItem]?
|
||||
}
|
||||
```
|
||||
</outline_group>
|
||||
</lists_and_tables>
|
||||
|
||||
<forms>
|
||||
<settings_form>
|
||||
```swift
|
||||
struct SettingsView: View {
|
||||
@AppStorage("autoSave") private var autoSave = true
|
||||
@AppStorage("saveInterval") private var saveInterval = 5
|
||||
@AppStorage("theme") private var theme = "system"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("General") {
|
||||
Toggle("Auto-save documents", isOn: $autoSave)
|
||||
|
||||
if autoSave {
|
||||
Stepper("Save every \(saveInterval) minutes", value: $saveInterval, in: 1...60)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Appearance") {
|
||||
Picker("Theme", selection: $theme) {
|
||||
Text("System").tag("system")
|
||||
Text("Light").tag("light")
|
||||
Text("Dark").tag("dark")
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 400)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
```
|
||||
</settings_form>
|
||||
|
||||
<validation>
|
||||
```swift
|
||||
struct EditItemView: View {
|
||||
@Binding var item: Item
|
||||
@State private var isValid = true
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
TextField("Name", text: $item.name)
|
||||
.onChange(of: item.name) {
|
||||
isValid = !item.name.isEmpty
|
||||
}
|
||||
|
||||
if !isValid {
|
||||
Text("Name is required")
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</validation>
|
||||
</forms>
|
||||
|
||||
<sheets_and_alerts>
|
||||
<sheet>
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var showingSheet = false
|
||||
@State private var itemToEdit: Item?
|
||||
|
||||
var body: some View {
|
||||
MainContent()
|
||||
.sheet(isPresented: $showingSheet) {
|
||||
SheetContent()
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
EditItemView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</sheet>
|
||||
|
||||
<confirmation_dialog>
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
@State private var showingDeleteConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Text(item.name)
|
||||
.confirmationDialog(
|
||||
"Delete \(item.name)?",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
deleteItem()
|
||||
}
|
||||
} message: {
|
||||
Text("This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</confirmation_dialog>
|
||||
|
||||
<file_dialogs>
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var showingImporter = false
|
||||
@State private var showingExporter = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Button("Import") {
|
||||
showingImporter = true
|
||||
}
|
||||
Button("Export") {
|
||||
showingExporter = true
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showingImporter,
|
||||
allowedContentTypes: [.json, .plainText],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
importFiles(urls)
|
||||
case .failure(let error):
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
.fileExporter(
|
||||
isPresented: $showingExporter,
|
||||
document: exportDocument,
|
||||
contentType: .json,
|
||||
defaultFilename: "export.json"
|
||||
) { result in
|
||||
// Handle result
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</file_dialogs>
|
||||
</sheets_and_alerts>
|
||||
|
||||
<drag_and_drop>
|
||||
<draggable>
|
||||
```swift
|
||||
struct DraggableItem: View {
|
||||
let item: Item
|
||||
|
||||
var body: some View {
|
||||
Text(item.name)
|
||||
.draggable(item.id.uuidString) {
|
||||
// Preview
|
||||
Label(item.name, systemImage: item.icon)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</draggable>
|
||||
|
||||
<drop_target>
|
||||
```swift
|
||||
struct DropTargetView: View {
|
||||
@State private var isTargeted = false
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(isTargeted ? Color.accentColor.opacity(0.3) : Color.clear)
|
||||
.dropDestination(for: String.self) { items, location in
|
||||
for itemID in items {
|
||||
handleDrop(itemID)
|
||||
}
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
isTargeted = targeted
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</drop_target>
|
||||
|
||||
<reorderable_list>
|
||||
```swift
|
||||
struct ReorderableList: View {
|
||||
@State private var items = ["A", "B", "C", "D"]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(items, id: \.self) { item in
|
||||
Text(item)
|
||||
}
|
||||
.onMove { from, to in
|
||||
items.move(fromOffsets: from, toOffset: to)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</reorderable_list>
|
||||
</drag_and_drop>
|
||||
|
||||
<focus_and_keyboard>
|
||||
<focus_state>
|
||||
```swift
|
||||
struct EditForm: View {
|
||||
@State private var name = ""
|
||||
@State private var description = ""
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
enum Field {
|
||||
case name, description
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
TextField("Name", text: $name)
|
||||
.focused($focusedField, equals: .name)
|
||||
|
||||
TextField("Description", text: $description)
|
||||
.focused($focusedField, equals: .description)
|
||||
}
|
||||
.onSubmit {
|
||||
switch focusedField {
|
||||
case .name:
|
||||
focusedField = .description
|
||||
case .description:
|
||||
save()
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
focusedField = .name
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</focus_state>
|
||||
|
||||
<keyboard_shortcuts>
|
||||
**CRITICAL: Menu commands required for reliable keyboard shortcuts**
|
||||
|
||||
`.onKeyPress()` handlers ALONE are unreliable in SwiftUI. You MUST define menu commands with `.keyboardShortcut()` for keyboard shortcuts to work properly.
|
||||
|
||||
<correct_pattern>
|
||||
**Step 1: Define menu command in App or WindowGroup:**
|
||||
|
||||
```swift
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.commands {
|
||||
CommandMenu("Edit") {
|
||||
EditLoopButton()
|
||||
Divider()
|
||||
DeleteButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Menu command buttons with keyboard shortcuts
|
||||
struct EditLoopButton: View {
|
||||
@FocusedValue(\.selectedItem) private var selectedItem
|
||||
|
||||
var body: some View {
|
||||
Button("Edit Item") {
|
||||
// Perform action
|
||||
}
|
||||
.keyboardShortcut("e", modifiers: [])
|
||||
.disabled(selectedItem == nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct DeleteButton: View {
|
||||
@FocusedValue(\.selectedItem) private var selectedItem
|
||||
|
||||
var body: some View {
|
||||
Button("Delete Item") {
|
||||
// Perform deletion
|
||||
}
|
||||
.keyboardShortcut(.delete, modifiers: [])
|
||||
.disabled(selectedItem == nil)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Expose state via FocusedValues:**
|
||||
|
||||
```swift
|
||||
// Define focused value keys
|
||||
struct SelectedItemKey: FocusedValueKey {
|
||||
typealias Value = Binding<Item?>
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var selectedItem: Binding<Item?>? {
|
||||
get { self[SelectedItemKey.self] }
|
||||
set { self[SelectedItemKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// In your view, expose the state
|
||||
struct ContentView: View {
|
||||
@State private var selectedItem: Item?
|
||||
|
||||
var body: some View {
|
||||
ItemList(selection: $selectedItem)
|
||||
.focusedSceneValue(\.selectedItem, $selectedItem)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why menu commands are required:**
|
||||
- `.keyboardShortcut()` on menu buttons registers shortcuts at the system level
|
||||
- `.onKeyPress()` alone only works when the view hierarchy receives events
|
||||
- System menus (Edit, View, etc.) can intercept keys before `.onKeyPress()` fires
|
||||
- Menu commands show shortcuts in the menu bar for discoverability
|
||||
|
||||
</correct_pattern>
|
||||
|
||||
<onKeyPress_usage>
|
||||
**When to use `.onKeyPress()`:**
|
||||
|
||||
Use for keyboard **input** (typing, arrow keys for navigation):
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
MainContent()
|
||||
.onKeyPress(.upArrow) {
|
||||
guard !isInputFocused else { return .ignored }
|
||||
selectPrevious()
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.downArrow) {
|
||||
guard !isInputFocused else { return .ignored }
|
||||
selectNext()
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(characters: .alphanumerics) { press in
|
||||
guard !isInputFocused else { return .ignored }
|
||||
handleTypeahead(press.characters)
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Always check focus state** to prevent interfering with text input.
|
||||
</onKeyPress_usage>
|
||||
</keyboard_shortcuts>
|
||||
</focus_and_keyboard>
|
||||
|
||||
<previews>
|
||||
```swift
|
||||
#Preview("Default") {
|
||||
ContentView()
|
||||
.environment(AppState())
|
||||
}
|
||||
|
||||
#Preview("With Data") {
|
||||
let state = AppState()
|
||||
state.items = [
|
||||
Item(name: "First"),
|
||||
Item(name: "Second")
|
||||
]
|
||||
|
||||
return ContentView()
|
||||
.environment(state)
|
||||
}
|
||||
|
||||
#Preview("Dark Mode") {
|
||||
ContentView()
|
||||
.environment(AppState())
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview(traits: .fixedLayout(width: 800, height: 600)) {
|
||||
ContentView()
|
||||
.environment(AppState())
|
||||
}
|
||||
```
|
||||
</previews>
|
||||
532
skills/expertise/macos-apps/references/system-apis.md
Normal file
532
skills/expertise/macos-apps/references/system-apis.md
Normal file
@@ -0,0 +1,532 @@
|
||||
# System APIs
|
||||
|
||||
macOS system integration: file system, notifications, services, and automation.
|
||||
|
||||
<file_system>
|
||||
<standard_directories>
|
||||
```swift
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// App Support (persistent app data)
|
||||
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let appFolder = appSupport.appendingPathComponent("MyApp", isDirectory: true)
|
||||
|
||||
// Documents (user documents)
|
||||
let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
|
||||
// Caches (temporary, can be deleted)
|
||||
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||
|
||||
// Temporary (short-lived)
|
||||
let temp = fileManager.temporaryDirectory
|
||||
|
||||
// Create directories
|
||||
try? fileManager.createDirectory(at: appFolder, withIntermediateDirectories: true)
|
||||
```
|
||||
</standard_directories>
|
||||
|
||||
<file_operations>
|
||||
```swift
|
||||
// Read
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
let string = try String(contentsOf: fileURL)
|
||||
|
||||
// Write
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
try string.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
|
||||
// Copy/Move
|
||||
try fileManager.copyItem(at: source, to: destination)
|
||||
try fileManager.moveItem(at: source, to: destination)
|
||||
|
||||
// Delete
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
|
||||
// Check existence
|
||||
let exists = fileManager.fileExists(atPath: path)
|
||||
|
||||
// List directory
|
||||
let contents = try fileManager.contentsOfDirectory(
|
||||
at: folderURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)
|
||||
```
|
||||
</file_operations>
|
||||
|
||||
<file_monitoring>
|
||||
```swift
|
||||
import CoreServices
|
||||
|
||||
class FileWatcher {
|
||||
private var stream: FSEventStreamRef?
|
||||
private var callback: () -> Void
|
||||
|
||||
init(path: String, onChange: @escaping () -> Void) {
|
||||
self.callback = onChange
|
||||
|
||||
var context = FSEventStreamContext()
|
||||
context.info = Unmanaged.passUnretained(self).toOpaque()
|
||||
|
||||
let paths = [path] as CFArray
|
||||
stream = FSEventStreamCreate(
|
||||
nil,
|
||||
{ _, info, numEvents, eventPaths, _, _ in
|
||||
guard let info = info else { return }
|
||||
let watcher = Unmanaged<FileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
DispatchQueue.main.async {
|
||||
watcher.callback()
|
||||
}
|
||||
},
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.5, // Latency in seconds
|
||||
FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents)
|
||||
)
|
||||
|
||||
FSEventStreamSetDispatchQueue(stream!, DispatchQueue.global())
|
||||
FSEventStreamStart(stream!)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let stream = stream {
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
let watcher = FileWatcher(path: "/path/to/watch") {
|
||||
print("Files changed!")
|
||||
}
|
||||
```
|
||||
</file_monitoring>
|
||||
|
||||
<security_scoped_bookmarks>
|
||||
For sandboxed apps to retain file access:
|
||||
|
||||
```swift
|
||||
class BookmarkManager {
|
||||
func saveBookmark(for url: URL) throws -> Data {
|
||||
// User selected this file via NSOpenPanel
|
||||
let bookmark = try url.bookmarkData(
|
||||
options: .withSecurityScope,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
return bookmark
|
||||
}
|
||||
|
||||
func resolveBookmark(_ data: Data) throws -> URL {
|
||||
var isStale = false
|
||||
let url = try URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: .withSecurityScope,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
)
|
||||
|
||||
// Start accessing
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
throw BookmarkError.accessDenied
|
||||
}
|
||||
|
||||
// Remember to call stopAccessingSecurityScopedResource() when done
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
```
|
||||
</security_scoped_bookmarks>
|
||||
</file_system>
|
||||
|
||||
<notifications>
|
||||
<local_notifications>
|
||||
```swift
|
||||
import UserNotifications
|
||||
|
||||
class NotificationService {
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
do {
|
||||
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
at date: Date,
|
||||
identifier: String
|
||||
) async throws {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
|
||||
let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date)
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
try await center.add(request)
|
||||
}
|
||||
|
||||
func scheduleImmediateNotification(title: String, body: String) async throws {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
|
||||
|
||||
try await center.add(request)
|
||||
}
|
||||
|
||||
func cancelNotification(identifier: String) {
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
}
|
||||
}
|
||||
```
|
||||
</local_notifications>
|
||||
|
||||
<notification_handling>
|
||||
```swift
|
||||
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
// Called when notification arrives while app is in foreground
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
[.banner, .sound]
|
||||
}
|
||||
|
||||
// Called when user interacts with notification
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
let identifier = response.notification.request.identifier
|
||||
// Handle the notification tap
|
||||
handleNotificationAction(identifier)
|
||||
}
|
||||
}
|
||||
```
|
||||
</notification_handling>
|
||||
</notifications>
|
||||
|
||||
<launch_at_login>
|
||||
```swift
|
||||
import ServiceManagement
|
||||
|
||||
class LaunchAtLoginManager {
|
||||
var isEnabled: Bool {
|
||||
get {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
set {
|
||||
do {
|
||||
if newValue {
|
||||
try SMAppService.mainApp.register()
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to update launch at login: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SwiftUI binding
|
||||
struct SettingsView: View {
|
||||
@State private var launchAtLogin = LaunchAtLoginManager()
|
||||
|
||||
var body: some View {
|
||||
Toggle("Launch at Login", isOn: Binding(
|
||||
get: { launchAtLogin.isEnabled },
|
||||
set: { launchAtLogin.isEnabled = $0 }
|
||||
))
|
||||
}
|
||||
}
|
||||
```
|
||||
</launch_at_login>
|
||||
|
||||
<nsworkspace>
|
||||
```swift
|
||||
import AppKit
|
||||
|
||||
let workspace = NSWorkspace.shared
|
||||
|
||||
// Open URL in browser
|
||||
workspace.open(URL(string: "https://example.com")!)
|
||||
|
||||
// Open file with default app
|
||||
workspace.open(fileURL)
|
||||
|
||||
// Open file with specific app
|
||||
workspace.open(
|
||||
[fileURL],
|
||||
withApplicationAt: appURL,
|
||||
configuration: NSWorkspace.OpenConfiguration()
|
||||
)
|
||||
|
||||
// Reveal in Finder
|
||||
workspace.activateFileViewerSelecting([fileURL])
|
||||
|
||||
// Get app for file type
|
||||
if let appURL = workspace.urlForApplication(toOpen: fileURL) {
|
||||
print("Default app: \(appURL)")
|
||||
}
|
||||
|
||||
// Get running apps
|
||||
let runningApps = workspace.runningApplications
|
||||
for app in runningApps {
|
||||
print("\(app.localizedName ?? "Unknown"): \(app.bundleIdentifier ?? "")")
|
||||
}
|
||||
|
||||
// Get frontmost app
|
||||
if let frontmost = workspace.frontmostApplication {
|
||||
print("Frontmost: \(frontmost.localizedName ?? "")")
|
||||
}
|
||||
|
||||
// Observe app launches
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSWorkspace.didLaunchApplicationNotification,
|
||||
object: workspace,
|
||||
queue: .main
|
||||
) { notification in
|
||||
if let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication {
|
||||
print("Launched: \(app.localizedName ?? "")")
|
||||
}
|
||||
}
|
||||
```
|
||||
</nsworkspace>
|
||||
|
||||
<process_management>
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
// Run shell command
|
||||
func runCommand(_ command: String) async throws -> String {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
process.arguments = ["-c", command]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
// Launch app
|
||||
func launchApp(bundleIdentifier: String) {
|
||||
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) {
|
||||
NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration())
|
||||
}
|
||||
}
|
||||
|
||||
// Check if app is running
|
||||
func isAppRunning(bundleIdentifier: String) -> Bool {
|
||||
NSWorkspace.shared.runningApplications.contains {
|
||||
$0.bundleIdentifier == bundleIdentifier
|
||||
}
|
||||
}
|
||||
```
|
||||
</process_management>
|
||||
|
||||
<clipboard>
|
||||
```swift
|
||||
import AppKit
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
|
||||
// Write text
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString("Hello", forType: .string)
|
||||
|
||||
// Read text
|
||||
if let string = pasteboard.string(forType: .string) {
|
||||
print(string)
|
||||
}
|
||||
|
||||
// Write URL
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([url as NSURL])
|
||||
|
||||
// Read URLs
|
||||
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] {
|
||||
print(urls)
|
||||
}
|
||||
|
||||
// Write image
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([image])
|
||||
|
||||
// Monitor clipboard
|
||||
class ClipboardMonitor {
|
||||
private var timer: Timer?
|
||||
private var lastChangeCount = 0
|
||||
|
||||
func start(onChange: @escaping (String?) -> Void) {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||||
let changeCount = NSPasteboard.general.changeCount
|
||||
if changeCount != self.lastChangeCount {
|
||||
self.lastChangeCount = changeCount
|
||||
onChange(NSPasteboard.general.string(forType: .string))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
}
|
||||
}
|
||||
```
|
||||
</clipboard>
|
||||
|
||||
<apple_events>
|
||||
```swift
|
||||
import AppKit
|
||||
|
||||
// Tell another app to do something (requires com.apple.security.automation.apple-events)
|
||||
func tellFinderToEmptyTrash() {
|
||||
let script = """
|
||||
tell application "Finder"
|
||||
empty trash
|
||||
end tell
|
||||
"""
|
||||
|
||||
var error: NSDictionary?
|
||||
if let scriptObject = NSAppleScript(source: script) {
|
||||
scriptObject.executeAndReturnError(&error)
|
||||
if let error = error {
|
||||
print("AppleScript error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get data from another app
|
||||
func getFinderSelection() -> [URL] {
|
||||
let script = """
|
||||
tell application "Finder"
|
||||
set selectedItems to selection
|
||||
set itemPaths to {}
|
||||
repeat with anItem in selectedItems
|
||||
set end of itemPaths to POSIX path of (anItem as text)
|
||||
end repeat
|
||||
return itemPaths
|
||||
end tell
|
||||
"""
|
||||
|
||||
var error: NSDictionary?
|
||||
if let scriptObject = NSAppleScript(source: script),
|
||||
let result = scriptObject.executeAndReturnError(&error).coerce(toDescriptorType: typeAEList) {
|
||||
var urls: [URL] = []
|
||||
for i in 1...result.numberOfItems {
|
||||
if let path = result.atIndex(i)?.stringValue {
|
||||
urls.append(URL(fileURLWithPath: path))
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
return []
|
||||
}
|
||||
```
|
||||
</apple_events>
|
||||
|
||||
<services>
|
||||
<providing_services>
|
||||
```swift
|
||||
// Info.plist
|
||||
/*
|
||||
<key>NSServices</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSMessage</key>
|
||||
<string>processText</string>
|
||||
<key>NSPortName</key>
|
||||
<string>MyApp</string>
|
||||
<key>NSSendTypes</key>
|
||||
<array>
|
||||
<string>public.plain-text</string>
|
||||
</array>
|
||||
<key>NSReturnTypes</key>
|
||||
<array>
|
||||
<string>public.plain-text</string>
|
||||
</array>
|
||||
<key>NSMenuItem</key>
|
||||
<dict>
|
||||
<key>default</key>
|
||||
<string>Process with MyApp</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
*/
|
||||
|
||||
class ServiceProvider: NSObject {
|
||||
@objc func processText(
|
||||
_ pboard: NSPasteboard,
|
||||
userData: String,
|
||||
error: AutoreleasingUnsafeMutablePointer<NSString?>
|
||||
) {
|
||||
guard let string = pboard.string(forType: .string) else {
|
||||
error.pointee = "No text found" as NSString
|
||||
return
|
||||
}
|
||||
|
||||
// Process the text
|
||||
let processed = string.uppercased()
|
||||
|
||||
// Return result
|
||||
pboard.clearContents()
|
||||
pboard.setString(processed, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
// Register in AppDelegate
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.servicesProvider = ServiceProvider()
|
||||
NSUpdateDynamicServices()
|
||||
}
|
||||
```
|
||||
</providing_services>
|
||||
</services>
|
||||
|
||||
<accessibility>
|
||||
```swift
|
||||
import AppKit
|
||||
|
||||
// Check if app has accessibility permissions
|
||||
func hasAccessibilityPermission() -> Bool {
|
||||
AXIsProcessTrusted()
|
||||
}
|
||||
|
||||
// Request permission
|
||||
func requestAccessibilityPermission() {
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue(): true] as CFDictionary
|
||||
AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
// Check display settings
|
||||
let workspace = NSWorkspace.shared
|
||||
let reduceMotion = workspace.accessibilityDisplayShouldReduceMotion
|
||||
let reduceTransparency = workspace.accessibilityDisplayShouldReduceTransparency
|
||||
let increaseContrast = workspace.accessibilityDisplayShouldIncreaseContrast
|
||||
```
|
||||
</accessibility>
|
||||
612
skills/expertise/macos-apps/references/testing-debugging.md
Normal file
612
skills/expertise/macos-apps/references/testing-debugging.md
Normal file
@@ -0,0 +1,612 @@
|
||||
# Testing and Debugging
|
||||
|
||||
Patterns for unit testing, UI testing, and debugging macOS apps.
|
||||
|
||||
<unit_testing>
|
||||
<basic_test>
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import MyApp
|
||||
|
||||
final class DataServiceTests: XCTestCase {
|
||||
var sut: DataService!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
sut = DataService()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testAddItem() {
|
||||
// Given
|
||||
let item = Item(name: "Test")
|
||||
|
||||
// When
|
||||
sut.addItem(item)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(sut.items.count, 1)
|
||||
XCTAssertEqual(sut.items.first?.name, "Test")
|
||||
}
|
||||
|
||||
func testDeleteItem() {
|
||||
// Given
|
||||
let item = Item(name: "Test")
|
||||
sut.addItem(item)
|
||||
|
||||
// When
|
||||
sut.deleteItem(item.id)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(sut.items.isEmpty)
|
||||
}
|
||||
}
|
||||
```
|
||||
</basic_test>
|
||||
|
||||
<async_testing>
|
||||
```swift
|
||||
final class NetworkServiceTests: XCTestCase {
|
||||
var sut: NetworkService!
|
||||
var mockSession: MockURLSession!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
mockSession = MockURLSession()
|
||||
sut = NetworkService(session: mockSession)
|
||||
}
|
||||
|
||||
func testFetchProjects() async throws {
|
||||
// Given
|
||||
let expectedProjects = [Project(name: "Test")]
|
||||
mockSession.data = try JSONEncoder().encode(expectedProjects)
|
||||
mockSession.response = HTTPURLResponse(
|
||||
url: URL(string: "https://api.example.com")!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)
|
||||
|
||||
// When
|
||||
let projects: [Project] = try await sut.fetch(Endpoint.projects().request)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(projects.count, 1)
|
||||
XCTAssertEqual(projects.first?.name, "Test")
|
||||
}
|
||||
|
||||
func testFetchError() async {
|
||||
// Given
|
||||
mockSession.error = NetworkError.timeout
|
||||
|
||||
// When/Then
|
||||
do {
|
||||
let _: [Project] = try await sut.fetch(Endpoint.projects().request)
|
||||
XCTFail("Expected error")
|
||||
} catch {
|
||||
XCTAssertTrue(error is NetworkError)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</async_testing>
|
||||
|
||||
<testing_observables>
|
||||
```swift
|
||||
final class AppStateTests: XCTestCase {
|
||||
func testAddItem() {
|
||||
// Given
|
||||
let sut = AppState()
|
||||
|
||||
// When
|
||||
sut.addItem(Item(name: "Test"))
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(sut.items.count, 1)
|
||||
}
|
||||
|
||||
func testSelectedItem() {
|
||||
// Given
|
||||
let sut = AppState()
|
||||
let item = Item(name: "Test")
|
||||
sut.items = [item]
|
||||
|
||||
// When
|
||||
sut.selectedItemID = item.id
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(sut.selectedItem?.name, "Test")
|
||||
}
|
||||
}
|
||||
```
|
||||
</testing_observables>
|
||||
|
||||
<mock_dependencies>
|
||||
```swift
|
||||
// Protocol for testability
|
||||
protocol DataStoreProtocol {
|
||||
func fetchAll() async throws -> [Item]
|
||||
func save(_ item: Item) async throws
|
||||
}
|
||||
|
||||
// Mock implementation
|
||||
class MockDataStore: DataStoreProtocol {
|
||||
var itemsToReturn: [Item] = []
|
||||
var savedItems: [Item] = []
|
||||
var shouldThrow = false
|
||||
|
||||
func fetchAll() async throws -> [Item] {
|
||||
if shouldThrow { throw TestError.mock }
|
||||
return itemsToReturn
|
||||
}
|
||||
|
||||
func save(_ item: Item) async throws {
|
||||
if shouldThrow { throw TestError.mock }
|
||||
savedItems.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
enum TestError: Error {
|
||||
case mock
|
||||
}
|
||||
|
||||
// Test using mock
|
||||
final class ViewModelTests: XCTestCase {
|
||||
func testLoadItems() async throws {
|
||||
// Given
|
||||
let mockStore = MockDataStore()
|
||||
mockStore.itemsToReturn = [Item(name: "Test")]
|
||||
let sut = ViewModel(dataStore: mockStore)
|
||||
|
||||
// When
|
||||
await sut.loadItems()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(sut.items.count, 1)
|
||||
}
|
||||
}
|
||||
```
|
||||
</mock_dependencies>
|
||||
|
||||
<testing_swiftdata>
|
||||
```swift
|
||||
final class SwiftDataTests: XCTestCase {
|
||||
var container: ModelContainer!
|
||||
var context: ModelContext!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
let schema = Schema([Project.self, Task.self])
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
container = try! ModelContainer(for: schema, configurations: config)
|
||||
context = ModelContext(container)
|
||||
}
|
||||
|
||||
func testCreateProject() throws {
|
||||
// Given
|
||||
let project = Project(name: "Test")
|
||||
|
||||
// When
|
||||
context.insert(project)
|
||||
try context.save()
|
||||
|
||||
// Then
|
||||
let descriptor = FetchDescriptor<Project>()
|
||||
let projects = try context.fetch(descriptor)
|
||||
XCTAssertEqual(projects.count, 1)
|
||||
XCTAssertEqual(projects.first?.name, "Test")
|
||||
}
|
||||
|
||||
func testCascadeDelete() throws {
|
||||
// Given
|
||||
let project = Project(name: "Test")
|
||||
let task = Task(title: "Task")
|
||||
task.project = project
|
||||
context.insert(project)
|
||||
context.insert(task)
|
||||
try context.save()
|
||||
|
||||
// When
|
||||
context.delete(project)
|
||||
try context.save()
|
||||
|
||||
// Then
|
||||
let tasks = try context.fetch(FetchDescriptor<Task>())
|
||||
XCTAssertTrue(tasks.isEmpty)
|
||||
}
|
||||
}
|
||||
```
|
||||
</testing_swiftdata>
|
||||
</unit_testing>
|
||||
|
||||
<swiftdata_debugging>
|
||||
<verify_relationships>
|
||||
When SwiftData items aren't appearing or relationships seem broken:
|
||||
|
||||
```swift
|
||||
// Debug print to verify relationships
|
||||
func debugRelationships(for column: Column) {
|
||||
print("=== Column: \(column.name) ===")
|
||||
print("Cards count: \(column.cards.count)")
|
||||
for card in column.cards {
|
||||
print(" - Card: \(card.title)")
|
||||
print(" Card's column: \(card.column?.name ?? "NIL")")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify inverse relationships are set
|
||||
func verifyCard(_ card: Card) {
|
||||
if card.column == nil {
|
||||
print("⚠️ Card '\(card.title)' has no column set!")
|
||||
} else {
|
||||
let inParentArray = card.column!.cards.contains { $0.id == card.id }
|
||||
print("Card in column.cards: \(inParentArray)")
|
||||
}
|
||||
}
|
||||
```
|
||||
</verify_relationships>
|
||||
|
||||
<common_swiftdata_issues>
|
||||
**Issue: Items not appearing in list**
|
||||
|
||||
Symptoms: Added items don't show, count is 0
|
||||
|
||||
Debug steps:
|
||||
```swift
|
||||
// 1. Check modelContext has the item
|
||||
let descriptor = FetchDescriptor<Card>()
|
||||
let allCards = try? modelContext.fetch(descriptor)
|
||||
print("Total cards in context: \(allCards?.count ?? 0)")
|
||||
|
||||
// 2. Check relationship is set
|
||||
if let card = allCards?.first {
|
||||
print("Card column: \(card.column?.name ?? "NIL")")
|
||||
}
|
||||
|
||||
// 3. Check parent's array
|
||||
print("Column.cards count: \(column.cards.count)")
|
||||
```
|
||||
|
||||
Common causes:
|
||||
- Forgot `modelContext.insert(item)` for new objects
|
||||
- Didn't set inverse relationship (`card.column = column`)
|
||||
- Using wrong modelContext (view context vs background context)
|
||||
</common_swiftdata_issues>
|
||||
|
||||
<inspect_database>
|
||||
```swift
|
||||
// Print database location
|
||||
func printDatabaseLocation() {
|
||||
let url = URL.applicationSupportDirectory
|
||||
.appendingPathComponent("default.store")
|
||||
print("Database: \(url.path)")
|
||||
}
|
||||
|
||||
// Dump all items of a type
|
||||
func dumpAllItems<T: PersistentModel>(_ type: T.Type, context: ModelContext) {
|
||||
let descriptor = FetchDescriptor<T>()
|
||||
if let items = try? context.fetch(descriptor) {
|
||||
print("=== \(String(describing: T.self)) (\(items.count)) ===")
|
||||
for item in items {
|
||||
print(" \(item)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
dumpAllItems(Column.self, context: modelContext)
|
||||
dumpAllItems(Card.self, context: modelContext)
|
||||
```
|
||||
</inspect_database>
|
||||
|
||||
<logging_swiftdata_operations>
|
||||
```swift
|
||||
import os
|
||||
|
||||
let dataLogger = Logger(subsystem: "com.yourapp", category: "SwiftData")
|
||||
|
||||
// Log when adding items
|
||||
func addCard(to column: Column, title: String) {
|
||||
let card = Card(title: title, position: 1.0)
|
||||
card.column = column
|
||||
modelContext.insert(card)
|
||||
|
||||
dataLogger.debug("Added card '\(title)' to column '\(column.name)'")
|
||||
dataLogger.debug("Column now has \(column.cards.count) cards")
|
||||
}
|
||||
|
||||
// Log when relationships change
|
||||
func moveCard(_ card: Card, to newColumn: Column) {
|
||||
let oldColumn = card.column?.name ?? "none"
|
||||
card.column = newColumn
|
||||
|
||||
dataLogger.debug("Moved '\(card.title)' from '\(oldColumn)' to '\(newColumn.name)'")
|
||||
}
|
||||
|
||||
// View logs in Console.app or:
|
||||
// log stream --predicate 'subsystem == "com.yourapp" AND category == "SwiftData"' --level debug
|
||||
```
|
||||
</logging_swiftdata_operations>
|
||||
|
||||
<symptom_cause_table>
|
||||
**Quick reference for common SwiftData symptoms:**
|
||||
|
||||
| Symptom | Likely Cause | Fix |
|
||||
|---------|--------------|-----|
|
||||
| Items don't appear | Missing `insert()` | Call `modelContext.insert(item)` |
|
||||
| Items appear once then disappear | Inverse relationship not set | Set `child.parent = parent` before insert |
|
||||
| Changes don't persist | Wrong context | Use same modelContext throughout |
|
||||
| @Query returns empty | Schema mismatch | Verify @Model matches container schema |
|
||||
| Cascade delete fails | Missing deleteRule | Add `@Relationship(deleteRule: .cascade)` |
|
||||
| Relationship array always empty | Not using inverse | Set inverse on child, not append on parent |
|
||||
</symptom_cause_table>
|
||||
</swiftdata_debugging>
|
||||
|
||||
<ui_testing>
|
||||
```swift
|
||||
import XCTest
|
||||
|
||||
final class MyAppUITests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testAddItem() {
|
||||
// Tap add button
|
||||
app.buttons["Add"].click()
|
||||
|
||||
// Verify item appears in list
|
||||
XCTAssertTrue(app.staticTexts["New Item"].exists)
|
||||
}
|
||||
|
||||
func testRenameItem() {
|
||||
// Add item first
|
||||
app.buttons["Add"].click()
|
||||
|
||||
// Select and rename
|
||||
app.staticTexts["New Item"].click()
|
||||
let textField = app.textFields["Name"]
|
||||
textField.click()
|
||||
textField.typeText("Renamed Item")
|
||||
|
||||
// Verify
|
||||
XCTAssertTrue(app.staticTexts["Renamed Item"].exists)
|
||||
}
|
||||
|
||||
func testDeleteItem() {
|
||||
// Add item
|
||||
app.buttons["Add"].click()
|
||||
|
||||
// Right-click and delete
|
||||
app.staticTexts["New Item"].rightClick()
|
||||
app.menuItems["Delete"].click()
|
||||
|
||||
// Verify deleted
|
||||
XCTAssertFalse(app.staticTexts["New Item"].exists)
|
||||
}
|
||||
}
|
||||
```
|
||||
</ui_testing>
|
||||
|
||||
<debugging>
|
||||
<os_log>
|
||||
```swift
|
||||
import os
|
||||
|
||||
let logger = Logger(subsystem: "com.yourcompany.MyApp", category: "General")
|
||||
|
||||
// Log levels
|
||||
logger.debug("Debug info")
|
||||
logger.info("General info")
|
||||
logger.notice("Notable event")
|
||||
logger.error("Error occurred")
|
||||
logger.fault("Critical failure")
|
||||
|
||||
// With interpolation
|
||||
logger.info("Loaded \(items.count) items")
|
||||
|
||||
// Privacy for sensitive data
|
||||
logger.info("User: \(username, privacy: .private)")
|
||||
|
||||
// In console
|
||||
// log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level debug
|
||||
```
|
||||
</os_log>
|
||||
|
||||
<signposts>
|
||||
```swift
|
||||
import os
|
||||
|
||||
let signposter = OSSignposter(subsystem: "com.yourcompany.MyApp", category: "Performance")
|
||||
|
||||
func loadData() async {
|
||||
let signpostID = signposter.makeSignpostID()
|
||||
let state = signposter.beginInterval("Load Data", id: signpostID)
|
||||
|
||||
// Work
|
||||
await fetchFromNetwork()
|
||||
|
||||
signposter.endInterval("Load Data", state)
|
||||
}
|
||||
|
||||
// Interval with metadata
|
||||
func processItem(_ item: Item) {
|
||||
let state = signposter.beginInterval("Process Item", id: signposter.makeSignpostID())
|
||||
|
||||
// Work
|
||||
process(item)
|
||||
|
||||
signposter.endInterval("Process Item", state, "Processed \(item.name)")
|
||||
}
|
||||
```
|
||||
</signposts>
|
||||
|
||||
<breakpoint_actions>
|
||||
```swift
|
||||
// Symbolic breakpoints in Xcode:
|
||||
// - Symbol: `-[NSException raise]` to catch all exceptions
|
||||
// - Symbol: `UIViewAlertForUnsatisfiableConstraints` for layout issues
|
||||
|
||||
// In code, trigger debugger
|
||||
func criticalFunction() {
|
||||
guard condition else {
|
||||
#if DEBUG
|
||||
raise(SIGINT) // Triggers breakpoint
|
||||
#endif
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
</breakpoint_actions>
|
||||
|
||||
<memory_debugging>
|
||||
```swift
|
||||
// Check for leaks with weak references
|
||||
class DebugHelper {
|
||||
static func trackDeallocation<T: AnyObject>(_ object: T, name: String) {
|
||||
let observer = DeallocObserver(name: name)
|
||||
objc_setAssociatedObject(object, "deallocObserver", observer, .OBJC_ASSOCIATION_RETAIN)
|
||||
}
|
||||
}
|
||||
|
||||
class DeallocObserver {
|
||||
let name: String
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
deinit {
|
||||
print("✓ \(name) deallocated")
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in tests
|
||||
func testNoMemoryLeak() {
|
||||
weak var weakRef: ViewModel?
|
||||
|
||||
autoreleasepool {
|
||||
let vm = ViewModel()
|
||||
weakRef = vm
|
||||
DebugHelper.trackDeallocation(vm, name: "ViewModel")
|
||||
}
|
||||
|
||||
XCTAssertNil(weakRef, "ViewModel should be deallocated")
|
||||
}
|
||||
```
|
||||
</memory_debugging>
|
||||
</debugging>
|
||||
|
||||
<common_issues>
|
||||
<memory_leaks>
|
||||
**Symptom**: Memory grows over time, objects not deallocated
|
||||
|
||||
**Common causes**:
|
||||
- Strong reference cycles in closures
|
||||
- Delegate not weak
|
||||
- NotificationCenter observers not removed
|
||||
|
||||
**Fix**:
|
||||
```swift
|
||||
// Use [weak self]
|
||||
someService.fetch { [weak self] result in
|
||||
self?.handle(result)
|
||||
}
|
||||
|
||||
// Weak delegates
|
||||
weak var delegate: MyDelegate?
|
||||
|
||||
// Remove observers
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
```
|
||||
</memory_leaks>
|
||||
|
||||
<main_thread_violations>
|
||||
**Symptom**: Purple warnings, UI not updating, crashes
|
||||
|
||||
**Fix**:
|
||||
```swift
|
||||
// Ensure UI updates on main thread
|
||||
Task { @MainActor in
|
||||
self.items = fetchedItems
|
||||
}
|
||||
|
||||
// Or use DispatchQueue
|
||||
DispatchQueue.main.async {
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
```
|
||||
</main_thread_violations>
|
||||
|
||||
<swiftui_not_updating>
|
||||
**Symptom**: View doesn't reflect state changes
|
||||
|
||||
**Common causes**:
|
||||
- Missing @Observable
|
||||
- Property not being tracked
|
||||
- Binding not connected
|
||||
|
||||
**Fix**:
|
||||
```swift
|
||||
// Ensure class is @Observable
|
||||
@Observable
|
||||
class AppState {
|
||||
var items: [Item] = [] // This will be tracked
|
||||
}
|
||||
|
||||
// Use @Bindable for mutations
|
||||
@Bindable var appState = appState
|
||||
TextField("Name", text: $appState.name)
|
||||
```
|
||||
</swiftui_not_updating>
|
||||
</common_issues>
|
||||
|
||||
<test_coverage>
|
||||
```bash
|
||||
# Build with coverage
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-enableCodeCoverage YES \
|
||||
-derivedDataPath ./build \
|
||||
test
|
||||
|
||||
# View coverage report
|
||||
xcrun xccov view --report ./build/Logs/Test/*.xcresult
|
||||
```
|
||||
</test_coverage>
|
||||
|
||||
<performance_testing>
|
||||
```swift
|
||||
func testPerformanceLoadLargeDataset() {
|
||||
measure {
|
||||
let items = (0..<10000).map { Item(name: "Item \($0)") }
|
||||
sut.items = items
|
||||
}
|
||||
}
|
||||
|
||||
// With options
|
||||
func testPerformanceWithMetrics() {
|
||||
let metrics: [XCTMetric] = [
|
||||
XCTClockMetric(),
|
||||
XCTMemoryMetric(),
|
||||
XCTCPUMetric()
|
||||
]
|
||||
|
||||
measure(metrics: metrics) {
|
||||
performHeavyOperation()
|
||||
}
|
||||
}
|
||||
```
|
||||
</performance_testing>
|
||||
222
skills/expertise/macos-apps/references/testing-tdd.md
Normal file
222
skills/expertise/macos-apps/references/testing-tdd.md
Normal file
@@ -0,0 +1,222 @@
|
||||
<overview>
|
||||
Test-Driven Development patterns for macOS apps. Write tests first, implement minimal code to pass, refactor while keeping tests green. Covers SwiftData testing, network mocking, @Observable state testing, and UI testing patterns.
|
||||
</overview>
|
||||
|
||||
<tdd_workflow>
|
||||
Test-Driven Development cycle for macOS apps:
|
||||
|
||||
1. **Write failing test** - Specify expected behavior
|
||||
2. **Run test** - Verify RED (fails as expected)
|
||||
3. **Implement** - Minimal code to pass
|
||||
4. **Run test** - Verify GREEN (passes)
|
||||
5. **Refactor** - Clean up while keeping green
|
||||
6. **Run suite** - Ensure no regressions
|
||||
|
||||
Repeat for each feature. Keep tests running fast.
|
||||
</tdd_workflow>
|
||||
|
||||
<test_organization>
|
||||
```
|
||||
MyApp/
|
||||
├── MyApp/
|
||||
│ └── ... (production code)
|
||||
└── MyAppTests/
|
||||
├── ModelTests/
|
||||
│ ├── ItemTests.swift
|
||||
│ └── ItemStoreTests.swift
|
||||
├── ServiceTests/
|
||||
│ ├── NetworkServiceTests.swift
|
||||
│ └── StorageServiceTests.swift
|
||||
└── ViewModelTests/
|
||||
└── AppStateTests.swift
|
||||
```
|
||||
|
||||
Group tests by layer. One test file per production file/class.
|
||||
</test_organization>
|
||||
|
||||
<testing_swiftdata>
|
||||
SwiftData requires ModelContainer. Create in-memory container for tests:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class ItemTests: XCTestCase {
|
||||
var container: ModelContainer!
|
||||
var context: ModelContext!
|
||||
|
||||
override func setUp() async throws {
|
||||
// In-memory container (doesn't persist)
|
||||
let schema = Schema([Item.self, Tag.self])
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
container = try ModelContainer(for: schema, configurations: config)
|
||||
context = ModelContext(container)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
container = nil
|
||||
context = nil
|
||||
}
|
||||
|
||||
func testCreateItem() throws {
|
||||
let item = Item(name: "Test")
|
||||
context.insert(item)
|
||||
try context.save()
|
||||
|
||||
let fetched = try context.fetch(FetchDescriptor<Item>())
|
||||
XCTAssertEqual(fetched.count, 1)
|
||||
XCTAssertEqual(fetched.first?.name, "Test")
|
||||
}
|
||||
}
|
||||
```
|
||||
</testing_swiftdata>
|
||||
|
||||
<testing_relationships>
|
||||
Critical: Test relationship behavior with in-memory container:
|
||||
|
||||
```swift
|
||||
func testDeletingParentCascadesToChildren() throws {
|
||||
let parent = Parent(name: "Parent")
|
||||
let child1 = Child(name: "Child1")
|
||||
let child2 = Child(name: "Child2")
|
||||
|
||||
child1.parent = parent
|
||||
child2.parent = parent
|
||||
|
||||
context.insert(parent)
|
||||
context.insert(child1)
|
||||
context.insert(child2)
|
||||
try context.save()
|
||||
|
||||
context.delete(parent)
|
||||
try context.save()
|
||||
|
||||
let children = try context.fetch(FetchDescriptor<Child>())
|
||||
XCTAssertEqual(children.count, 0) // Cascade delete worked
|
||||
}
|
||||
```
|
||||
</testing_relationships>
|
||||
|
||||
<mocking_network>
|
||||
```swift
|
||||
protocol NetworkSession {
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse)
|
||||
}
|
||||
|
||||
extension URLSession: NetworkSession {}
|
||||
|
||||
class MockNetworkSession: NetworkSession {
|
||||
var mockData: Data?
|
||||
var mockResponse: URLResponse?
|
||||
var mockError: Error?
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
if let error = mockError { throw error }
|
||||
return (mockData ?? Data(), mockResponse ?? URLResponse())
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
func testFetchItems() async throws {
|
||||
let json = """
|
||||
[{"id": 1, "name": "Test"}]
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let mock = MockNetworkSession()
|
||||
mock.mockData = json
|
||||
mock.mockResponse = HTTPURLResponse(url: URL(string: "https://api.example.com")!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: nil)
|
||||
|
||||
let service = NetworkService(session: mock)
|
||||
let items = try await service.fetchItems()
|
||||
|
||||
XCTAssertEqual(items.count, 1)
|
||||
XCTAssertEqual(items.first?.name, "Test")
|
||||
}
|
||||
```
|
||||
</mocking_network>
|
||||
|
||||
<testing_observable>
|
||||
Test @Observable state changes:
|
||||
|
||||
```swift
|
||||
func testAppStateUpdatesOnAdd() {
|
||||
let appState = AppState()
|
||||
|
||||
XCTAssertEqual(appState.items.count, 0)
|
||||
|
||||
appState.addItem(Item(name: "Test"))
|
||||
|
||||
XCTAssertEqual(appState.items.count, 1)
|
||||
XCTAssertEqual(appState.items.first?.name, "Test")
|
||||
}
|
||||
|
||||
func testSelectionChanges() {
|
||||
let appState = AppState()
|
||||
let item = Item(name: "Test")
|
||||
appState.addItem(item)
|
||||
|
||||
appState.selectedItemID = item.id
|
||||
|
||||
XCTAssertEqual(appState.selectedItem?.id, item.id)
|
||||
}
|
||||
```
|
||||
</testing_observable>
|
||||
|
||||
<ui_testing>
|
||||
Use XCUITest for critical user flows:
|
||||
|
||||
```swift
|
||||
class MyAppUITests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUp() {
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testAddItemFlow() {
|
||||
app.buttons["Add"].click()
|
||||
|
||||
let nameField = app.textFields["Name"]
|
||||
nameField.click()
|
||||
nameField.typeText("New Item")
|
||||
|
||||
app.buttons["Save"].click()
|
||||
|
||||
XCTAssertTrue(app.staticTexts["New Item"].exists)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep UI tests minimal (slow, brittle). Test critical flows only.
|
||||
</ui_testing>
|
||||
|
||||
<what_not_to_test>
|
||||
Don't test:
|
||||
- SwiftUI framework itself
|
||||
- URLSession (Apple's code)
|
||||
- File system (use mocks)
|
||||
|
||||
Do test:
|
||||
- Your business logic
|
||||
- State management
|
||||
- Data transformations
|
||||
- Service layer with mocks
|
||||
</what_not_to_test>
|
||||
|
||||
<running_tests>
|
||||
```bash
|
||||
# Run all tests
|
||||
xcodebuild test -scheme MyApp -destination 'platform=macOS'
|
||||
|
||||
# Run unit tests only (fast)
|
||||
xcodebuild test -scheme MyApp -destination 'platform=macOS' -only-testing:MyAppTests
|
||||
|
||||
# Run UI tests only (slow)
|
||||
xcodebuild test -scheme MyApp -destination 'platform=macOS' -only-testing:MyAppUITests
|
||||
|
||||
# Watch mode
|
||||
find . -name "*.swift" | entr xcodebuild test -scheme MyApp -destination 'platform=macOS' -only-testing:MyAppTests
|
||||
```
|
||||
</running_tests>
|
||||
Reference in New Issue
Block a user