Agent Skills › fayazara/macos-app-skills

fayazara/macos-app-skills

GitHub

为原生 macOS 应用集成 Sparkle 自动更新功能。涵盖通过 SPM 添加依赖、创建 UpdaterManager 单例、配置 Info.plist(含 EdDSA 密钥)、以及集成“检查更新”UI 的完整流程,支持 DEBUG 环境隔离和菜单集成。

5 个 Skill 613

安装全部 Skills

npx skills add fayazara/macos-app-skills --all -g -y
更多选项

预览集合内 Skills

npx skills add fayazara/macos-app-skills --list

集合内 Skills (5)

为原生 macOS 应用集成 Sparkle 自动更新功能。涵盖通过 SPM 添加依赖、创建 UpdaterManager 单例、配置 Info.plist(含 EdDSA 密钥)、以及集成“检查更新”UI 的完整流程,支持 DEBUG 环境隔离和菜单集成。
用户希望为 macOS 应用添加自动更新功能 用户提及 Sparkle、appcast、SUFeedURL、EdDSA 签名或更新通知 需要设置 appcast 馈送或集成更新检查逻辑
auto-update/SKILL.md
npx skills add fayazara/macos-app-skills --skill macos-auto-update -g -y
SKILL.md
Frontmatter
{
    "name": "macos-auto-update",
    "description": "Add Sparkle auto-update support to a native macOS app. Use this skill whenever the user wants to add auto-updates, implement Sparkle, set up an appcast feed, add \"Check for Updates\" to their app, or integrate automatic update checking. Also trigger when the user mentions Sparkle, appcast, SUFeedURL, EdDSA signing, or update notifications. This covers the full setup: adding the Sparkle SPM dependency, creating the UpdaterManager singleton, wiring it into the app delegate and UI, configuring Info.plist, and generating the initial appcast.xml."
}

Sparkle Auto-Update for macOS Apps

This skill adds Sparkle auto-update support to a native macOS app. Sparkle is the standard open-source framework for macOS app updates outside the Mac App Store.

Overview

The implementation has 4 parts:

  1. SPM dependency -- add the Sparkle package to the Xcode project
  2. UpdaterManager.swift -- a singleton that wraps SPUStandardUpdaterController
  3. Info.plist keys -- SUFeedURL and SUPublicEDKey
  4. UI integration -- "Check for Updates" button in settings/menu bar

Step 1: Add Sparkle via SPM

In Xcode: File > Add Package Dependencies > enter:

https://github.com/sparkle-project/Sparkle

Use the "Up to Next Major Version" rule with 2.0.0. Add the Sparkle framework to your app target.

Or add it to Package.swift if your project uses one:

.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0")

Step 2: Create UpdaterManager.swift

Copy references/UpdaterManager.swift into your project. This is a singleton that:

  • Creates SPUStandardUpdaterController early (before applicationDidFinishLaunching returns)
  • Publishes canCheckForUpdates for UI binding
  • Exposes automaticallyChecksForUpdates toggle
  • Skips all update logic in DEBUG builds (so you don't get update prompts during development)
  • For menu-bar-only apps: temporarily switches to .regular activation policy before showing the update window

The key design decisions in this file:

  • startingUpdater: false in the initializer, then calling start() explicitly in applicationDidFinishLaunching. This gives you control over timing.
  • DEBUG guards on start() and checkForUpdates(). Sparkle should never run in debug builds -- it would try to update your debug app with a release build.
  • ObservableObject with @Published (not @Observable) because we need the Combine publisher(for:) bridge from Sparkle's KVO.

Step 3: Configure Info.plist

Add these keys to your app's Info.plist:

<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/OWNER/REPO/main/appcast.xml</string>

<key>SUPublicEDKey</key>
<string>YOUR_PUBLIC_EDDSA_KEY_HERE</string>

<key>SUEnableInstallerLauncherService</key>
<true/>

Generating EdDSA Keys

Sparkle uses EdDSA (Ed25519) signing. Generate a keypair:

# Find generate_keys in your DerivedData after building the project with Sparkle
find ~/Library/Developer/Xcode/DerivedData -name "generate_keys" -type f 2>/dev/null | head -1

Run it:

/path/to/generate_keys

This prints the public key and stores the private key in your Keychain. Put the public key in SUPublicEDKey in Info.plist. The private key stays in Keychain and is used by sign_update during release.

Step 4: Wire Into App

App Delegate

final class AppDelegate: NSObject, NSApplicationDelegate {
    private let updaterManager = UpdaterManager.shared

    func applicationDidFinishLaunching(_ notification: Notification) {
        updaterManager.start()
    }
}

The UpdaterManager.shared property must be accessed early so the SPUStandardUpdaterController is created before the app finishes launching. Referencing it in the AppDelegate property ensures this.

Settings UI (About Pane)

struct AboutSettingsPane: View {
    @ObservedObject private var updaterManager = UpdaterManager.shared

    var body: some View {
        Form {
            Section("Updates") {
                Toggle(isOn: Binding(
                    get: { updaterManager.automaticallyChecksForUpdates },
                    set: { updaterManager.automaticallyChecksForUpdates = $0 }
                )) {
                    Text("Automatically check for updates")
                }

                Button("Check for Updates...") {
                    updaterManager.checkForUpdates()
                }
                .disabled(!updaterManager.canCheckForUpdates)
            }
        }
        .formStyle(.grouped)
        .scrollContentBackground(.hidden)
    }
}

Menu Bar (optional)

Button {
    updaterManager.checkForUpdates()
} label: {
    Label("Check for Updates...", systemImage: "arrow.down.circle")
}
.disabled(!updaterManager.canCheckForUpdates)

Step 5: Create Initial Appcast

Create an appcast.xml at the root of your repo. It starts empty and gets populated by your release process:

<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>YourApp</title>
    <description>Most recent changes for YourApp.</description>
    <language>en</language>
  </channel>
</rss>

Host this file on GitHub (raw URL) or any static file host. The URL must match SUFeedURL in Info.plist.

Menu-Bar-Only Apps

If your app runs as .accessory (no Dock icon), Sparkle's update window won't appear unless you temporarily switch to .regular. The reference UpdaterManager handles this in checkForUpdates():

func checkForUpdates() {
    NSApp.setActivationPolicy(.regular)
    NSApp.activate(ignoringOtherApps: true)
    controller.checkForUpdates(nil)
}

The app reverts to .accessory when the update window closes (handled by your existing activation policy manager).

Hardened Runtime Entitlements

If your app uses Hardened Runtime (required for notarization), no special Sparkle entitlements are needed. Sparkle 2.x works with the standard hardened runtime configuration.

Appcast Item Format

Each release in the appcast looks like this (for reference when building release tooling):

<item>
  <title>Version 1.2 (Build 5)</title>
  <pubDate>Mon, 26 May 2026 12:00:00 +0000</pubDate>
  <sparkle:version>5</sparkle:version>
  <sparkle:shortVersionString>1.2</sparkle:shortVersionString>
  <sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
  <description><![CDATA[<ul><li>New feature</li><li>Bug fix</li></ul>]]></description>
  <enclosure url="https://github.com/OWNER/REPO/releases/download/v1.2/YourApp.dmg"
             type="application/octet-stream"
             sparkle:edSignature="BASE64_EDDSA_SIGNATURE"
             length="FILE_SIZE_BYTES" />
</item>
  • sparkle:version = CFBundleVersion (build number)
  • sparkle:shortVersionString = CFBundleShortVersionString (marketing version)
  • sparkle:edSignature = output of sign_update YourApp.dmg
  • length = file size in bytes
面向Web开发者的macOS原生开发指南,涵盖菜单栏应用、激活策略、窗口管理等特有模式。旨在纠正Web思维误区(如z-index),提供正确API用法,防止生成错误代码,适用于构建任何原生macOS应用时的参考。
用户询问macOS特定API或功能(如菜单栏、快捷方式、拖放等) 用户尝试将Web开发模式直接套用于macOS开发 构建原生macOS应用需要遵循系统规范时
macos-patterns/SKILL.md
npx skills add fayazara/macos-app-skills --skill macos-patterns -g -y
SKILL.md
Frontmatter
{
    "name": "macos-patterns",
    "description": "Essential native macOS development patterns that web developers don't know about. Use this skill whenever the user is building a macOS app and needs guidance on native patterns, or when they ask about menu bar apps, floating panels, window levels, keyboard shortcuts, file pickers, clipboard, drag and drop, screen capture, activation policy, Quick Look, launch at login, or any macOS-specific API. Also trigger when the user seems to be applying web development patterns to macOS (e.g., using z-index thinking for windows, expecting simple clipboard APIs, or not understanding focus\/activation). This is the \"how things actually work on macOS\" reference that prevents the AI from generating confident but wrong code. Use this skill proactively whenever building any native macOS app."
}

Native macOS Patterns for Web Developers

This is a reference guide for the macOS-specific patterns that have no web equivalent. When building a native macOS app, these are the things that trip up everyone coming from web development. The AI should consult this before generating macOS code to avoid confidently producing patterns that don't work.

Menu Bar Apps

There are two approaches. Use MenuBarExtra for simple menus, NSStatusItem for full control.

SwiftUI MenuBarExtra (simple)

@main
struct MyApp: App {
    var body: some Scene {
        MenuBarExtra("MyApp", image: "MenuBarIcon") {
            MenuBarView()
        }
    }
}

This creates a menu-bar-only app. The menu content is a standard SwiftUI view. For a popover-style menu bar app (richer UI than a plain menu), use the .window style:

MenuBarExtra("MyApp", image: "MenuBarIcon") {
    PopoverContentView()
}
.menuBarExtraStyle(.window)

AppKit NSStatusItem (full control)

For custom menus, dynamic icons, or complex interactions:

let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusItem.button {
    let icon = NSImage(named: "MenuBarIcon")!
    icon.size = NSSize(width: 18, height: 18)
    icon.isTemplate = true  // CRITICAL: adapts to dark/light mode automatically
    button.image = icon
}

let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ","))
statusItem.menu = menu

isTemplate = true is essential. Without it, your icon will be invisible in light mode or look wrong in dark mode.

NSPopover for Rich Menu Bar Content

For a popover attached to the menu bar icon (like Bartender, iStatMenus):

let popover = NSPopover()
popover.contentSize = NSSize(width: 300, height: 400)
popover.behavior = .transient  // auto-closes when clicking outside
popover.contentViewController = NSHostingController(rootView: MyPopoverView())

// Show from the status item button:
if let button = statusItem.button {
    popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}

Activation Policy -- Dock Icon Toggling

A macOS app can dynamically show/hide its Dock icon and Cmd-Tab presence at runtime. Menu-bar-only apps start as .accessory (invisible in Dock) and temporarily become .regular when they open windows like Settings.

// At launch -- hide from Dock:
NSApp.setActivationPolicy(.accessory)

// When opening a window -- show in Dock:
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)

// When closing the last window -- hide again:
NSApp.setActivationPolicy(.accessory)

Use reference counting if multiple windows can be open simultaneously:

@MainActor
enum AppActivationPolicy {
    private static var count = 0

    static func enter() {
        count += 1
        NSApp.setActivationPolicy(.regular)
        NSApp.activate(ignoringOtherApps: true)
    }

    static func leave() {
        count = max(0, count - 1)
        guard count == 0 else { return }
        Task { @MainActor in NSApp.setActivationPolicy(.accessory) }
    }
}

NSPanel vs NSWindow

NSPanel is a subclass of NSWindow for auxiliary content that should not steal focus. Use it for:

  • Floating overlays (preview cards, recording indicators)
  • Palettes and tool windows
  • Inspectors
  • Anything that should stay visible while the user works in another app

Key configuration:

let panel = NSPanel(
    contentRect: .zero,
    styleMask: [.borderless, .nonactivatingPanel],  // Does NOT steal focus
    backing: .buffered,
    defer: false
)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
panel.hidesOnDeactivate = false     // Stay visible when app loses focus
panel.isFloatingPanel = true        // Float above normal windows
panel.ignoresMouseEvents = true     // Clicks pass through to windows below
panel.collectionBehavior = [
    .canJoinAllSpaces,              // Visible on all virtual desktops
    .fullScreenAuxiliary,           // Visible over fullscreen apps
]

To host SwiftUI content in the panel:

let hostingView = NSHostingView(rootView: MySwiftUIView())
panel.contentView = hostingView

Override focus behavior when needed:

class MyPanel: NSPanel {
    override var canBecomeKey: Bool { true }     // Can receive keyboard input
    override var canBecomeMain: Bool { false }   // Never becomes the "main" window
}

Screen Capture Exclusion

If your app shows floating UI that shouldn't appear in screenshots/recordings:

panel.sharingType = .none  // Invisible to screen capture APIs

Window Levels

macOS has a multi-tier window level system that controls where windows appear relative to the entire OS (not just your app):

normal (0)           -- standard app windows
floating (3)         -- tool palettes, floating panels
modalPanel (8)       -- modal dialogs
mainMenu (24)        -- menu bar
screenSaver (1000)   -- above everything except...
CGShieldingWindowLevel() -- above absolutely everything (kiosk mode)

Set via:

window.level = .floating
// or for extreme cases:
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))

Collection Behaviors

Control how windows interact with Spaces, fullscreen, and Cmd-Tab:

window.collectionBehavior = [
    .canJoinAllSpaces,       // Visible on all virtual desktops
    .fullScreenAuxiliary,    // Visible over fullscreen apps
    .stationary,             // Doesn't move with Space transitions
    .ignoresCycle,           // Hidden from Cmd-` window cycling
    .transient,              // Removed when app is hidden
]

Screen Geometry

macOS uses bottom-left origin coordinates. The Y axis is flipped compared to the web.

let screen = NSScreen.main!

// Full screen rectangle (includes menu bar and Dock area):
screen.frame  // e.g., (0, 0, 1728, 1117)

// Usable area (excludes menu bar and Dock):
screen.visibleFrame  // e.g., (0, 0, 1728, 1055)

// Notch detection (MacBook with notch has top safe area):
screen.safeAreaInsets.top > 0  // true on notch Macs

Use frame when positioning over the menu bar (notch overlays). Use visibleFrame for normal window placement.

Quartz vs AppKit Y-Axis

Core Graphics / Quartz uses top-left origin. AppKit uses bottom-left origin. When converting between the two:

let desktopFrame = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }
let appKitY = desktopFrame.maxY - quartzY - height  // Quartz → AppKit
let quartzY = desktopFrame.maxY - appKitY - height  // AppKit → Quartz

Multi-Monitor

Never assume a single screen. Always handle the case where NSScreen.main is not the only display:

// Find the screen containing the mouse pointer:
let mouseLocation = NSEvent.mouseLocation
let screen = NSScreen.screens.first { $0.frame.contains(mouseLocation) }

// Find the screen containing a specific window:
let screen = window.screen

Keyboard Shortcuts

There are 3 tiers, each for different use cases.

Tier 1: SwiftUI keyboard shortcuts (in-app, when focused)

Button("Settings") { openSettings() }
    .keyboardShortcut(",", modifiers: [.command])  // Cmd+,

Tier 2: NSEvent monitors (app-wide or global)

// Local: fires only when YOUR app is active
let monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
    if event.keyCode == 53 { /* Escape */ return nil /* consume */ }
    return event  // pass through
}

// Global: fires even when ANOTHER app is active
let monitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
    // Cannot consume the event -- read-only
}

// CRITICAL: Always remove monitors when done
NSEvent.removeMonitor(monitor)

Returning nil from a local monitor consumes the event (stops propagation). Global monitors cannot consume events.

Tier 3: Carbon hotkeys (system-wide, works when app is not focused)

The only way to register true global keyboard shortcuts. Uses the Carbon API (1990s-era, still not deprecated):

import Carbon.HIToolbox

var hotKeyRef: EventHotKeyRef?
let hotKeyID = EventHotKeyID(signature: OSType(0x4D594150), id: 1) // "MYAP"

RegisterEventHotKey(
    UInt32(kVK_ANSI_1),      // Key code
    UInt32(optionKey),         // Modifiers
    hotKeyID,
    GetApplicationEventTarget(),
    0,
    &hotKeyRef
)

The callback runs on an arbitrary thread -- always dispatch to main:

DispatchQueue.main.async { self.handleHotKey() }

File Pickers

Open (select files/directories)

let panel = NSOpenPanel()
panel.allowedContentTypes = [.image, .movie]  // UTType filter
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.canCreateDirectories = true
panel.title = "Choose Images"

// Modal (blocks the thread):
if panel.runModal() == .OK {
    let urls = panel.urls
}

// Or sheet-modal (attached to a window, async):
panel.beginSheetModal(for: window) { response in
    guard response == .OK else { return }
    let urls = panel.urls
}

Save

let panel = NSSavePanel()
panel.allowedContentTypes = [.png]
panel.nameFieldStringValue = "screenshot.png"
panel.canCreateDirectories = true

panel.begin { response in
    guard response == .OK, let url = panel.url else { return }
    // write file to url
}

Directory picker

let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.directoryURL = URL(fileURLWithPath: NSHomeDirectory())

Clipboard / Pasteboard

macOS's pasteboard is fundamentally different from the web's navigator.clipboard. It is a multi-item, multi-type container.

let pasteboard = NSPasteboard.general

// CRITICAL: You MUST clear before writing. Forgetting this is a common bug.
pasteboard.clearContents()

// Write text:
pasteboard.setString("hello", forType: .string)

// Write image data:
let pngData = try Data(contentsOf: imageURL)
pasteboard.setData(pngData, forType: .png)

// Write a file URL (for Finder paste):
pasteboard.writeObjects([url as NSURL])

// Read (check what types are available):
if let string = pasteboard.string(forType: .string) { ... }
if let data = pasteboard.data(forType: .png) { ... }

A single pasteboard item can advertise multiple types. When reading, check types in priority order.

Drag and Drop

SwiftUI (simple)

// Make something draggable:
Image(nsImage: image)
    .draggable(fileURL) {
        // Custom drag preview
        Image(nsImage: image).frame(width: 100, height: 75)
    }

// Accept drops:
.dropDestination(for: URL.self) { urls, location in
    handleDroppedFiles(urls)
    return true
}

AppKit (full control)

Register as a drop target:

class MyView: NSView {
    override init(frame: NSRect) {
        super.init(frame: frame)
        registerForDraggedTypes([.fileURL, .png, .tiff])
    }

    override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
        .copy  // Show the green + cursor
    }

    override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
        guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else {
            return false
        }
        handleFiles(urls)
        return true
    }
}

NavigationSplitView + Inspector Layout

The native macOS pattern for a sidebar + detail + inspector layout:

struct ContentView: View {
    @State private var selection: Item?
    @State private var showInspector = true

    var body: some View {
        NavigationSplitView {
            SidebarView(selection: $selection)
                .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 300)
        } detail: {
            DetailView(item: selection)
        }
        .inspector(isPresented: $showInspector) {
            InspectorView(item: selection)
                .inspectorColumnWidth(min: 250, ideal: 280, max: 400)
        }
        .toolbar {
            ToolbarItem {
                Button { showInspector.toggle() } label: {
                    Image(systemName: "sidebar.right")
                }
            }
        }
    }
}

The .inspector() modifier creates a native right-side panel that slides in/out, automatically manages layout, and integrates with the window's toolbar.

Launch at Login

Use SMAppService (macOS 13+):

import ServiceManagement

// Check status:
switch SMAppService.mainApp.status {
case .enabled: /* running at login */
case .requiresApproval: /* registered but user must approve in System Settings */
case .notRegistered, .notFound: /* not registered */
}

// Register:
try SMAppService.mainApp.register()

// Unregister:
try SMAppService.mainApp.unregister()

The requiresApproval state is unique to macOS -- the app has asked to launch at login, but the user must manually approve it in System Settings > General > Login Items.

Always disable in debug builds to avoid polluting the login item list during development.

Quick Look Preview

Show a system Quick Look panel for any file (images, PDFs, videos, documents):

import QuickLookUI

class PreviewPresenter: NSObject, QLPreviewPanelDataSource {
    var url: URL?

    func show(url: URL) {
        self.url = url
        guard let panel = QLPreviewPanel.shared() else { return }
        NSApp.activate()  // MUST activate first or panel opens behind other windows
        panel.dataSource = self
        panel.reloadData()
        panel.makeKeyAndOrderFront(nil)
    }

    func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { url != nil ? 1 : 0 }

    func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! {
        url as? NSURL
    }
}

NSWorkspace -- OS Integration

// Open URL in default browser:
NSWorkspace.shared.open(URL(string: "https://example.com")!)

// Reveal file in Finder (selects it):
NSWorkspace.shared.activateFileViewerSelecting([fileURL])

// Get the frontmost application:
let app = NSWorkspace.shared.frontmostApplication
let name = app?.localizedName  // e.g., "Safari"

// Check accessibility preferences:
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion  // Respect "Reduce Motion"
NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency

UserDefaults + @AppStorage

Programmatic access

// Write:
UserDefaults.standard.set(true, forKey: "autoSave")
UserDefaults.standard.set(0.8, forKey: "quality")

// Read (returns false/0/nil if key doesn't exist):
let autoSave = UserDefaults.standard.bool(forKey: "autoSave")
let quality = UserDefaults.standard.double(forKey: "quality")

Reactive SwiftUI binding

struct SettingsView: View {
    @AppStorage("autoSave") private var autoSave = false
    @AppStorage("quality") private var quality = 0.8

    var body: some View {
        Toggle("Auto Save", isOn: $autoSave)      // Auto-persisted
        Slider(value: $quality, in: 0...1)         // Auto-persisted
    }
}

@AppStorage automatically reads from and writes to UserDefaults, and triggers SwiftUI view updates when the value changes.

ScreenCaptureKit

Capture screen content at native Retina resolution:

import ScreenCaptureKit

let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
guard let display = content.displays.first else { return }

let filter = SCContentFilter(display: display, excludingWindows: [])
let config = SCStreamConfiguration()
let scale = CGFloat(filter.pointPixelScale)
config.width = Int(CGFloat(display.width) * scale)   // Points → pixels
config.height = Int(CGFloat(display.height) * scale)
config.showsCursor = false

let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)

The points-to-pixels conversion is critical. ScreenCaptureKit works in points, but output dimensions must be in pixels for Retina resolution.

Common Mistakes Web Devs Make

What they try Why it fails What to do instead
SwiftUI Window scene for floating UI Steals focus, shows in Dock, no transparency Use NSPanel with .nonactivatingPanel
z-index thinking for window ordering macOS uses discrete window levels, not a flat stack Set window.level to .floating, .screenSaver, etc.
window.innerHeight for positioning Doesn't account for menu bar, Dock, or notch Use NSScreen.visibleFrame or .frame depending on context
navigator.clipboard.writeText() macOS requires clearContents() first Always call pasteboard.clearContents() before writing
addEventListener('keydown') for global shortcuts Only works when app is focused Use Carbon RegisterEventHotKey for system-wide
<input type="file"> mental model macOS has modal, sheet-modal, and async file pickers Use NSOpenPanel with the right presentation mode
Single-monitor assumptions macOS users commonly have 2-3 displays Always use NSScreen.screens and find the right one
CSS animation for everything macOS has spring physics, reduced motion, per-window animation Use SwiftUI .animation(.spring(...)) and check accessibilityDisplayShouldReduceMotion
为 macOS 应用添加类似 Dynamic Island 的凹槽 UI。通过 NSPanel 和自定义 SwiftUI Shape 创建透明浮动面板,精准定位并贴合硬件凹槽区域,支持弹簧动画与状态内容展示。
用户希望创建凹槽覆盖层、Dynamic Island 风格界面或凹槽指示器 提及 'notch shape', 'notch window', 'recording indicator near the notch' 等关键词 需要显示从硬件凹槽区域浮现的状态或内容
notch-ui/SKILL.md
npx skills add fayazara/macos-app-skills --skill macos-notch-ui -g -y
SKILL.md
Frontmatter
{
    "name": "macos-notch-ui",
    "description": "Add a Dynamic Island-style notch UI to a macOS app. Use this skill whenever the user wants to create a notch overlay, notch extender, Dynamic Island for Mac, notch indicator, or any UI that extends from the MacBook notch area. Also trigger when the user mentions \"notch shape\", \"notch window\", \"notch cutout\", \"notch panel\", \"recording indicator near the notch\", \"Dynamic Island style\", or wants to show status\/content that appears to emerge from the hardware notch. This covers the NSPanel setup, the NotchShape with concave Bezier ear curves, screen positioning math, spring animations, and show\/hide choreography."
}

macOS Notch UI (Dynamic Island Style)

This skill creates a Dynamic Island-style overlay that extends from the MacBook's hardware notch. The overlay is a transparent floating panel positioned flush against the top of the screen, using a custom shape with concave "ear" curves that blend seamlessly with the physical notch cutout.

Architecture

The implementation has 3 parts:

  1. NotchWindow (NSPanel subclass) -- a borderless, transparent, click-through panel at CGShieldingWindowLevel that sits above everything, including the menu bar
  2. NotchShape (SwiftUI Shape) -- draws the Dynamic Island silhouette with concave quadratic Bezier curves at the top corners and convex rounded corners at the bottom
  3. Your content view -- whatever you want to show inside the notch (status indicators, waveforms, text, icons)

How It Works

The MacBook notch is a black rectangle at the top-center of the screen. By placing a black-filled NotchShape at that exact position at the highest window level, it visually extends the notch area. Content inside the shape appears to "emerge" from the hardware notch.

The key positioning math:

// Use full screen frame (not visibleFrame) to include the menu bar / notch area
let screen = NSScreen.main!
let x = screen.frame.origin.x + (screen.frame.width - totalWidth) / 2
let y = screen.frame.origin.y + screen.frame.height - totalHeight

Using screen.frame (not screen.visibleFrame) is critical -- visibleFrame excludes the menu bar area where the notch lives.

Reference Files

  • references/NotchWindow.swift -- Drop-in NSPanel subclass with show/hide and positioning
  • references/NotchShape.swift -- The Dynamic Island shape with animatable corner radii

Step-by-Step Integration

1. Add the NotchShape

Copy references/NotchShape.swift. This is a SwiftUI Shape with two configurable corner radii:

  • topCornerRadius (default 10) -- the concave "ear" curves at the top that mimic the hardware notch's inverse corners
  • bottomCornerRadius (default 16) -- the standard convex rounded corners at the bottom

Both are animatable via animatableData, so SwiftUI can smoothly interpolate shape changes.

2. Create Your Content View

Build whatever you want to show inside the notch. The content should be clipped to NotchShape and filled with black background:

struct NotchContentView: View {
    var isVisible: Bool

    var body: some View {
        HStack {
            Image(systemName: "mic.fill")
                .foregroundStyle(.red)
            Text("Recording")
                .font(.system(size: 13, weight: .medium))
                .foregroundStyle(.white)
        }
        .frame(width: isVisible ? 200 : 0)
        .frame(height: 32)
        .background(NotchShape().fill(.black))
        .clipShape(NotchShape())
        .animation(.spring(response: 0.35, dampingFraction: 0.75), value: isVisible)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
    }
}

3. Add the NotchWindow

Copy references/NotchWindow.swift. This is a generic NSPanel that:

  • Creates a borderless, transparent, non-activating panel
  • Positions at CGShieldingWindowLevel (above everything)
  • Centers at the top of the screen, flush with the top edge
  • Ignores mouse events (fully click-through)
  • Joins all spaces and survives fullscreen

4. Show and Hide

// Create once and reuse
let notchWindow = NotchWindow()

// Show with your content
let content = NotchContentView(isVisible: true)
notchWindow.showNotch(content: content)

// Hide with spring collapse animation
notchWindow.hideNotch()

Spring Animation Choreography

The Dynamic Island effect comes from a specific animation sequence:

Show:

  1. Window appears instantly (orderFront)
  2. On the next runloop tick, isVisible toggles to true
  3. The width animates from 0 to the target width with .spring(response: 0.35, dampingFraction: 0.75)

This two-step approach (instant window, then animated content) is necessary because SwiftUI needs the view to be in the hierarchy before it can animate.

Hide (3-step choreography):

  1. Clear any expanded content (text, details) -- collapses to compact shape
  2. After 0.25s, set isVisible = false -- triggers the spring width collapse to 0
  3. After 0.65s, remove the window (orderOut)

The delays are tuned so each animation completes before the next starts. This creates the smooth "shrink into the notch" effect.

// Step 1: collapse content
state.isExpanded = false

// Step 2: shrink width
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
    state.isVisible = false
}

// Step 3: remove window
DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) {
    window.orderOut(nil)
}

Screen Geometry Details

Property Value Why
Window level CGShieldingWindowLevel() Above everything including menu bar
Position origin NSScreen.main.frame (not visibleFrame) Must include the notch/menu bar area
Horizontal Centered: (screen.width - totalWidth) / 2 Aligned with the hardware notch
Vertical Flush top: screen.height - totalHeight Top edge touches the screen edge
Collection behavior .stationary, .canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle Doesn't move with Spaces, survives fullscreen, hidden from Cmd+Tab

Fallback for Non-Notch Macs

Not all Macs have a notch (e.g., external displays, older MacBooks). You can detect this:

var hasNotch: Bool {
    guard let screen = NSScreen.main else { return false }
    // Notch Macs have a safe area inset at the top
    return screen.safeAreaInsets.top > 0
}

For non-notch displays, fall back to a floating pill at the bottom of the screen using screen.visibleFrame and standard .floating window level.

Design Guidelines

  • Fill the shape with solid black -- this is what makes it blend with the hardware notch
  • Use white or colored text/icons on the black background for contrast
  • Keep content compact -- the notch area is small. A single row with an icon + short text works best
  • Use red for recording indicators -- matches iOS convention
  • Animate width, not opacity -- the Dynamic Island effect is about the shape growing/shrinking, not fading
用于将原生 macOS 应用发布至 GitHub,涵盖版本升级、归档、公证、DMG 打包、Sparkle EdDSA 签名及 appcast 更新的全流程自动化。
用户希望发布新版本或创建 GitHub Release 涉及 DMG 创建、Sparkle 签名、代码公证或应用分发 需要更新 Sparkle appcast.xml 以支持自动更新
release/SKILL.md
npx skills add fayazara/macos-app-skills --skill macos-release -g -y
SKILL.md
Frontmatter
{
    "name": "macos-release",
    "description": "Release a native macOS app to GitHub with DMG packaging and Sparkle appcast updates. Use this skill whenever the user wants to publish a new version, create a release, ship an update, push a release to GitHub, or update the appcast. Also trigger when the user mentions DMG creation, Sparkle signing, notarization, archiving, or anything related to distributing a new version of their macOS app. This covers the full release pipeline: archive, notarize, export, create DMG, sign with Sparkle EdDSA, update appcast.xml, git push, and GitHub release creation."
}

Release macOS App

This skill covers the full release pipeline for distributing a native macOS app outside the Mac App Store via GitHub Releases with Sparkle auto-update support.

Release Pipeline Overview

Bump version → Archive → Notarize → Export → Create DMG → Sign DMG → Update appcast.xml → Git push → GitHub Release

Prerequisites

The user needs these tools installed:

Tool Install Purpose
create-dmg brew install create-dmg Creates the DMG installer
gh brew install gh Creates GitHub releases
git Built-in Pushes appcast changes
Sparkle sign_update Built automatically when the project is built with Sparkle EdDSA-signs the DMG

The sign_update binary lives in DerivedData after building the project in Xcode:

find ~/Library/Developer/Xcode/DerivedData -name "sign_update" -type f 2>/dev/null | head -1

Step-by-Step Release Guide

1. Bump Version Numbers

In Xcode, update:

  • MARKETING_VERSION (e.g., 1.2) -- the user-facing version
  • CURRENT_PROJECT_VERSION (e.g., 5) -- the build number (must be unique per release)

Or via command line:

# Check current values
grep -E "MARKETING_VERSION|CURRENT_PROJECT_VERSION" YourApp.xcodeproj/project.pbxproj | head -4

2. Archive in Xcode

Product > Archive. This creates a release build with the proper signing identity.

3. Notarize and Export

In the Archives organizer:

  1. Select the archive > Distribute App
  2. Choose "Direct Distribution" (or "Developer ID" for notarization)
  3. Wait for notarization to complete
  4. Export to ~/Downloads/YourApp.app

4. Create DMG

create-dmg \
  --volname "YourApp" \
  --window-pos 200 120 \
  --window-size 660 400 \
  --icon-size 160 \
  --icon "YourApp.app" 180 170 \
  --app-drop-link 480 170 \
  --hide-extension "YourApp.app" \
  ~/Downloads/YourApp.dmg \
  ~/Downloads/YourApp.app

5. Sign DMG with Sparkle

/path/to/sign_update ~/Downloads/YourApp.dmg

This outputs the EdDSA signature and file length:

sparkle:edSignature="BASE64..." length="12345"

Save both values for the appcast.

6. Update appcast.xml

Add a new <item> at the top of the <channel> in your appcast.xml:

<item>
  <title>Version 1.2 (Build 5)</title>
  <pubDate>Mon, 26 May 2026 12:00:00 +0000</pubDate>
  <sparkle:version>5</sparkle:version>
  <sparkle:shortVersionString>1.2</sparkle:shortVersionString>
  <sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
  <description><![CDATA[<ul><li>Feature one</li><li>Bug fix two</li></ul>]]></description>
  <enclosure url="https://github.com/OWNER/REPO/releases/download/v1.2/YourApp.dmg"
             type="application/octet-stream"
             sparkle:edSignature="THE_SIGNATURE_FROM_STEP_5"
             length="THE_LENGTH_FROM_STEP_5" />
</item>

The pubDate should be RFC 2822 format. Generate it:

date -R

7. Commit and Push Appcast

git add appcast.xml
git commit -m "Release v1.2 appcast"
git push origin main

8. Create GitHub Release

gh release create v1.2 \
  ~/Downloads/YourApp.dmg \
  --title "v1.2" \
  --notes "- Feature one
- Bug fix two"

Automating the Pipeline

For frequent releases, build a CLI tool that automates steps 4-8. See references/release-pipeline.md for a template Go CLI that handles DMG creation, signing, appcast updates, and GitHub release creation in one command.

The CLI should:

  • Find sign_update in DerivedData automatically
  • Read version/build from the exported app's Info.plist via plutil
  • Parse and update appcast.xml (preserving existing entries)
  • Interactively collect release notes
  • Show a summary and ask for confirmation before proceeding
  • Create the GitHub release with the DMG attached

Release CLI Tool

This repo includes a Go CLI that automates steps 4-8 (DMG creation through GitHub release) in a single command. It reads configuration from a release.json file in your project root.

Setup

  1. Create a release.json in your project root (see cli/release.example.json):
{
  "app_name": "MyApp",
  "github_repo": "owner/myapp"
}

Only app_name and github_repo are required. Everything else has sensible defaults:

Field Default Description
bundle_name {app_name}.app The .app bundle filename
dmg_name {app_name}.dmg Output DMG filename
git_branch main Branch to push appcast to
min_system_version 14.0 Sparkle minimum macOS version
appcast_file appcast.xml Appcast filename in repo root
derived_data_prefixes ["{app_name}-"] DerivedData prefixes to search for sign_update
  1. Run the CLI from your project directory:
go run github.com/fayazara/macos-app-skills/release/cli@latest

Or clone this repo and run locally:

go run ./release/cli

The CLI is interactive -- it prompts for release notes and asks for confirmation before proceeding. It must be run in a terminal the user can interact with.

What the CLI Does

  1. Finds release.json by walking up from the current directory
  2. Checks that create-dmg, gh, git, and Sparkle's sign_update are available
  3. Validates the exported app in ~/Downloads/ (reads version, build, Sparkle keys from Info.plist)
  4. Warns if the build number already exists in the appcast
  5. Collects release notes interactively (one bullet per line, empty line to finish)
  6. Shows a release summary and asks for confirmation
  7. Creates the DMG via create-dmg
  8. Signs the DMG with Sparkle's sign_update (EdDSA)
  9. Updates appcast.xml with the new release entry
  10. Commits and pushes the appcast
  11. Creates a GitHub release with the DMG attached

Common Issues

Problem Solution
sign_update not found Build the project in Xcode first so DerivedData has the Sparkle artifacts
gh auth failure Run gh auth login
Duplicate build number in appcast Bump CURRENT_PROJECT_VERSION before archiving
Notarization fails Check signing identity, entitlements, and hardened runtime settings
DMG is too large Check for debug symbols or unnecessary frameworks in the export
App won't update Verify SUFeedURL points to the raw appcast URL, not the GitHub page URL

Appcast Hosting

The simplest hosting: commit appcast.xml to your GitHub repo and use the raw URL:

https://raw.githubusercontent.com/OWNER/REPO/main/appcast.xml

This must match SUFeedURL in your app's Info.plist.

为 macOS 26 应用构建原生设置窗口,支持液态玻璃效果。通过 NSWindowController 实现全尺寸内容视图与圆角边框,使用 NavigationSplitView 构建侧边栏与详情面板,确保分组表单背景透明及滚动边缘模糊效果。
创建 macOS 设置窗口或首选项界面 提及液态玻璃、NavigationSplitView 设置布局 修复或重建 SwiftUI 原生应用的设置屏幕
settings-ui/SKILL.md
npx skills add fayazara/macos-app-skills --skill macos-settings-ui -g -y
SKILL.md
Frontmatter
{
    "name": "macos-settings-ui",
    "description": "Build a proper macOS settings\/preferences window with liquid glass support for macOS 26 (Tahoe). Use this skill whenever the user asks to create a settings window, preferences UI, settings view, or preferences pane for a macOS app. Also trigger when the user mentions \"liquid glass settings\", \"NavigationSplitView settings\", \"grouped Form settings\", \"macOS settings layout\", or wants to add\/fix\/rebuild a settings screen in any native macOS SwiftUI app. This includes phrases like \"add settings to my app\", \"create a preferences window\", \"my settings look wrong\", \"fix my settings UI\", or even just \"I need a settings view\". If the user is building a macOS app and mentions settings or preferences in any context, use this skill."
}

macOS Settings UI with Liquid Glass

This skill produces a native macOS settings window that follows Apple's macOS 26 design guidelines. The result is a sidebar + detail NavigationSplitView with liquid glass window chrome, back/forward toolbar navigation, grouped Form sections with transparent backgrounds, and proper scroll edge effects.

Architecture Overview

The settings UI is composed of 3 layers:

  1. Window Controller (SettingsWindowController.swift) — An NSWindowController that creates the NSWindow programmatically with .fullSizeContentView. This is what gives the window rounded liquid glass corners. You cannot get this effect from a SwiftUI Window scene.

  2. Root View (SettingsView.swift) — A NavigationSplitView with a sidebar list and detail pane. Includes back/forward navigation history in the toolbar, which also forces the creation of an NSToolbar (required for the liquid glass title bar treatment).

  3. Detail Panes (one file per tab) — Each pane uses Form { Section(...) { ... } }.formStyle(.grouped).scrollContentBackground(.hidden).

Why NSWindowController Instead of SwiftUI Window Scene

SwiftUI's declarative Window scene does not expose the NSWindow style mask. The .fullSizeContentView flag must be set at window creation time for macOS 26 to render the liquid glass chrome (rounded corners, translucent sidebar, blurred title bar). Trying to inject it later via NSViewRepresentable is unreliable because SwiftUI resets the window's configuration.

The NSWindowController approach also lets you control the toolbar style, frame autosave, minimum size, and delegate lifecycle directly.

Critical Modifiers

Every detail pane MUST have these three modifiers on its Form:

.formStyle(.grouped)
.scrollContentBackground(.hidden)
.contentMargins(.top, 8, for: .scrollContent)
  • .formStyle(.grouped) — gives the native inset rounded-rect section appearance
  • .scrollContentBackground(.hidden) — makes the form background transparent so the liquid glass window chrome shows through. Without this, you get an opaque white/dark background that breaks the glass effect.
  • .contentMargins(.top, 8) — adds breathing room between the toolbar and the first section

The sidebar List MUST have:

.listStyle(.sidebar)
.scrollEdgeEffectStyleSoftIfAvailable()  // macOS 26 progressive blur at scroll edges
.navigationTitle("Settings")

The NavigationSplitView MUST have:

NavigationSplitView(columnVisibility: .constant(.all))  // sidebar always visible
// ...
.navigationTitle("Settings")
.navigationSplitViewStyle(.balanced)

Reference Implementation

Read the reference files for complete, working code:

  • references/SettingsWindowController.swift — The window controller (copy as-is, adapt the activation policy calls to your app)
  • references/SettingsView.swift — The root view with sidebar, detail routing, and navigation history
  • references/ExampleDetailPane.swift — A template detail pane showing common control patterns (Toggle, Picker, Slider, LabeledContent)

Step-by-Step: Adding Settings to a New App

1. Create the Tab Enum

Define your settings categories. Each case needs a title and SF Symbol icon:

enum SettingsTab: String, CaseIterable, Identifiable {
    case general
    case appearance
    case about

    var id: Self { self }

    var title: String {
        switch self {
        case .general: "General"
        case .appearance: "Appearance"
        case .about: "About"
        }
    }

    var systemImage: String {
        switch self {
        case .general: "gearshape"
        case .appearance: "paintbrush"
        case .about: "info.circle"
        }
    }
}

2. Create the Window Controller

Copy references/SettingsWindowController.swift into your project. Adapt:

  • The initial contentRect size (default 700x540 is good for most apps)
  • The minSize (default 620x460)
  • The activation policy calls (AppActivationPolicy.enter()/leave()) — if your app isn't a menu-bar-only app, remove these

3. Create the Root Settings View

Copy references/SettingsView.swift. Adapt:

  • The SettingsTab enum cases to match your categories
  • The SettingsDetailView switch to return your panes

4. Create Detail Panes

For each tab, create a pane file. Use this template:

import SwiftUI

struct GeneralSettingsPane: View {
    @AppStorage("someKey") private var someValue = false

    var body: some View {
        Form {
            Section("Section Name") {
                Toggle("Toggle label", isOn: $someValue)
            }
        }
        .formStyle(.grouped)
        .scrollContentBackground(.hidden)
        .contentMargins(.top, 8, for: .scrollContent)
    }
}

5. Open Settings from Your App

From a menu bar, button, or anywhere:

SettingsWindowController.show(tab: .general)

Do NOT use a SwiftUI Window scene. Do NOT use openWindow(id:). The window controller handles everything.

6. Remove Any SwiftUI Window Scene

If you previously had a Window("Settings", id: "SETTINGS") scene in your App struct, remove it entirely. The SettingsWindowController replaces it.

Common Control Patterns Inside Form Sections

Toggle with description:

Toggle(isOn: $value) {
    VStack(alignment: .leading, spacing: 2) {
        Text("Primary label")
        Text("Description text explaining what this does.")
            .font(.caption)
            .foregroundStyle(.secondary)
    }
}
.toggleStyle(.switch)

Picker (dropdown):

Picker("Label", selection: $value) {
    Text("Option A").tag(OptionEnum.a)
    Text("Option B").tag(OptionEnum.b)
}
.pickerStyle(.menu)

Picker (segmented):

Picker("Label", selection: $value) {
    ForEach(SomeEnum.allCases) { item in
        Text(item.title).tag(item)
    }
}
.pickerStyle(.segmented)

Slider with value label:

LabeledContent("Size") {
    HStack(spacing: 12) {
        Slider(value: $size, in: 24...96, step: 2)
            .frame(width: 180)
        Text("\(Int(size)) pt")
            .monospacedDigit()
            .foregroundStyle(.secondary)
            .frame(width: 46, alignment: .trailing)
    }
}

Button row:

HStack(spacing: 8) {
    Button("Action") { doSomething() }
        .controlSize(.small)
    Button("Reset") { reset() }
        .controlSize(.small)
        .disabled(isDefault)
}

Menu-Bar-Only Apps

If your app uses NSApp.setActivationPolicy(.accessory) (no Dock icon), you need activation policy management so the settings window brings the app to the foreground. The reference SettingsWindowController calls AppActivationPolicy.enter() on show and .leave() on close. Implement this as a simple reference-counting wrapper:

@MainActor
enum AppActivationPolicy {
    private static var count = 0

    static func enter() {
        count += 1
        NSApp.setActivationPolicy(.regular)
        NSApp.activate(ignoringOtherApps: true)
    }

    static func leave() {
        count = max(0, count - 1)
        guard count == 0 else { return }
        Task { @MainActor in
            NSApp.setActivationPolicy(.accessory)
        }
    }
}

If your app always shows in the Dock, remove the AppActivationPolicy calls from the window controller and just use NSApp.activate(ignoringOtherApps: true) in showWindow.

macOS Version Compatibility

The scrollEdgeEffectStyle(.soft) API is macOS 26+ only. Always wrap it in an availability check:

private extension View {
    @ViewBuilder
    func scrollEdgeEffectStyleSoftIfAvailable() -> some View {
        if #available(macOS 26.0, *) {
            scrollEdgeEffectStyle(.soft, for: .all)
        } else {
            self
        }
    }
}

The rest of the pattern (NSWindowController with .fullSizeContentView, grouped Form, NavigationSplitView) works on macOS 14+.

首页 - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-07-13 22:59
浙ICP备14020137号-1 $访客地图$