macos-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Native macOS Patterns for Web Developers

面向Web开发者的macOS原生开发模式

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.
这是一份针对macOS专属模式的参考指南,这些模式在Web开发中没有对应概念。在构建macOS原生应用时,这些正是所有Web开发者容易踩坑的点。AI在生成macOS代码前应参考此文档,避免生成看似合理但实际无法运行的代码模式。

Menu Bar Apps

菜单栏应用

There are two approaches. Use
MenuBarExtra
for simple menus,
NSStatusItem
for full control.
有两种实现方式:简单菜单使用
MenuBarExtra
,需要完全控制时使用
NSStatusItem

SwiftUI MenuBarExtra (simple)

SwiftUI MenuBarExtra(简单版)

swift
@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:
swift
MenuBarExtra("MyApp", image: "MenuBarIcon") {
    PopoverContentView()
}
.menuBarExtraStyle(.window)
swift
@main
struct MyApp: App {
    var body: some Scene {
        MenuBarExtra("MyApp", image: "MenuBarIcon") {
            MenuBarView()
        }
    }
}
这会创建一个仅显示在菜单栏的应用。菜单内容是标准SwiftUI视图。如果需要弹出式菜单栏应用(比普通菜单更丰富的UI),使用
.window
样式:
swift
MenuBarExtra("MyApp", image: "MenuBarIcon") {
    PopoverContentView()
}
.menuBarExtraStyle(.window)

AppKit NSStatusItem (full control)

AppKit NSStatusItem(完全控制版)

For custom menus, dynamic icons, or complex interactions:
swift
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.
适用于自定义菜单、动态图标或复杂交互场景:
swift
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  // 关键:自动适配深色/浅色模式
    button.image = icon
}

let menu = NSMenu()
menu.addItem(NSMenuItem(title: "设置...", action: #selector(openSettings), keyEquivalent: ","))
statusItem.menu = menu
isTemplate = true
至关重要。如果不设置,图标在浅色模式下会不可见,或在深色模式下显示异常。

NSPopover for Rich Menu Bar Content

NSPopover实现丰富菜单栏内容

For a popover attached to the menu bar icon (like Bartender, iStatMenus):
swift
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)
}
用于实现附在菜单栏图标上的弹出面板(类似Bartender、iStatMenus):
swift
let popover = NSPopover()
popover.contentSize = NSSize(width: 300, height: 400)
popover.behavior = .transient  // 点击外部自动关闭
popover.contentViewController = NSHostingController(rootView: MyPopoverView())

// 从状态栏按钮显示:
if let button = statusItem.button {
    popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}

Activation Policy -- Dock Icon Toggling

激活策略——Dock图标切换

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.
swift
// 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:
swift
@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) }
    }
}
macOS应用可在运行时动态显示/隐藏Dock图标和Cmd-Tab列表项。仅菜单栏应用启动时为
.accessory
(Dock中不可见),当打开设置等窗口时临时切换为
.regular
swift
// 启动时——隐藏Dock图标:
NSApp.setActivationPolicy(.accessory)

// 打开窗口时——显示Dock图标:
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)

// 关闭最后一个窗口时——再次隐藏:
NSApp.setActivationPolicy(.accessory)
如果有多个窗口可同时打开,使用引用计数:
swift
@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 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:
swift
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:
swift
let hostingView = NSHostingView(rootView: MySwiftUIView())
panel.contentView = hostingView
Override focus behavior when needed:
swift
class MyPanel: NSPanel {
    override var canBecomeKey: Bool { true }     // Can receive keyboard input
    override var canBecomeMain: Bool { false }   // Never becomes the "main" window
}
NSPanel
NSWindow
的子类,用于不应抢夺焦点的辅助内容。适用于:
  • 浮动覆盖层(预览卡片、录制指示器)
  • 调色板和工具窗口
  • 检查器
  • 任何用户在其他应用工作时仍需保持可见的内容
关键配置:
swift
let panel = NSPanel(
    contentRect: .zero,
    styleMask: [.borderless, .nonactivatingPanel],  // 不会抢夺焦点
    backing: .buffered,
    defer: false
)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
panel.hidesOnDeactivate = false     // 应用失去焦点时仍保持可见
panel.isFloatingPanel = true        // 悬浮在普通窗口上方
panel.ignoresMouseEvents = true     // 点击事件穿透到下方窗口
panel.collectionBehavior = [
    .canJoinAllSpaces,              // 在所有虚拟桌面可见
    .fullScreenAuxiliary,           // 在全屏应用上方可见
]
在面板中托管SwiftUI内容:
swift
let hostingView = NSHostingView(rootView: MySwiftUIView())
panel.contentView = hostingView
必要时覆盖焦点行为:
swift
class MyPanel: NSPanel {
    override var canBecomeKey: Bool { true }     // 可接收键盘输入
    override var canBecomeMain: Bool { false }   // 永远不会成为「主」窗口
}

Screen Capture Exclusion

屏幕捕获排除

If your app shows floating UI that shouldn't appear in screenshots/recordings:
swift
panel.sharingType = .none  // Invisible to screen capture APIs
如果你的应用显示的浮动UI不应出现在截图/录屏中:
swift
panel.sharingType = .none  // 对屏幕捕获API不可见

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:
swift
window.level = .floating
// or for extreme cases:
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
macOS有多层级窗口系统,控制窗口相对于整个系统(而非仅你的应用)的显示位置:
normal (0)           -- 标准应用窗口
floating (3)         -- 工具调色板、浮动面板
modalPanel (8)       -- 模态对话框
mainMenu (24)        -- 菜单栏
screenSaver (1000)   -- 位于所有内容上方,除了...
CGShieldingWindowLevel() -- 绝对顶层(信息亭模式)
设置方式:
swift
window.level = .floating
// 极端场景下:
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))

Collection Behaviors

集合行为

Control how windows interact with Spaces, fullscreen, and Cmd-Tab:
swift
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
]
控制窗口与Spaces、全屏、Cmd-Tab的交互:
swift
window.collectionBehavior = [
    .canJoinAllSpaces,       // 在所有虚拟桌面可见
    .fullScreenAuxiliary,    // 在全屏应用上方可见
    .stationary,             // 切换Space时不移动
    .ignoresCycle,           // 不在Cmd-`窗口切换列表中显示
    .transient,              // 应用隐藏时自动移除
]

Screen Geometry

屏幕几何

macOS uses bottom-left origin coordinates. The Y axis is flipped compared to the web.
swift
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.
macOS使用左下角原点坐标系统,Y轴与Web开发相反。
swift
let screen = NSScreen.main!

// 全屏矩形(包含菜单栏和Dock区域):
screen.frame  // 示例:(0, 0, 1728, 1117)

// 可用区域(排除菜单栏和Dock):
screen.visibleFrame  // 示例:(0, 0, 1728, 1055)

// 刘海检测(带刘海的MacBook有顶部安全区域):
screen.safeAreaInsets.top > 0  // 带刘海的Mac上返回true
在菜单栏(刘海覆盖区域)上方定位时使用
frame
,普通窗口定位时使用
visibleFrame

Quartz vs AppKit Y-Axis

Quartz vs AppKit Y轴

Core Graphics / Quartz uses top-left origin. AppKit uses bottom-left origin. When converting between the two:
swift
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
Core Graphics / Quartz使用左上角原点,AppKit使用左下角原点。两者转换方式:
swift
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:
swift
// 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
永远不要假设只有一个屏幕。务必处理
NSScreen.main
不是唯一显示器的情况:
swift
// 查找包含鼠标指针的屏幕:
let mouseLocation = NSEvent.mouseLocation
let screen = NSScreen.screens.first { $0.frame.contains(mouseLocation) }

// 查找包含指定窗口的屏幕:
let screen = window.screen

Keyboard Shortcuts

键盘快捷键

There are 3 tiers, each for different use cases.
分为3个层级,适用于不同场景。

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

层级1:SwiftUI键盘快捷键(应用内,聚焦时生效)

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

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

层级2:NSEvent监视器(应用内或全局)

swift
// 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.
swift
// 本地:仅你的应用激活时触发
let monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
    if event.keyCode == 53 { /* 退出键 */ return nil /* 拦截事件 */ }
    return event  // 传递事件
}

// 全局:其他应用激活时也触发
let monitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
    // 无法拦截事件——仅可读
}

// 关键:使用完毕后务必移除监视器
NSEvent.removeMonitor(monitor)
从本地监视器返回
nil
拦截事件(停止传播)。全局监视器无法拦截事件。

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

层级3:Carbon热键(系统级,应用未聚焦时也生效)

The only way to register true global keyboard shortcuts. Uses the Carbon API (1990s-era, still not deprecated):
swift
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:
swift
DispatchQueue.main.async { self.handleHotKey() }
这是注册真正全局键盘快捷键的唯一方式,使用Carbon API(诞生于1990年代,至今未被弃用):
swift
import Carbon.HIToolbox

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

RegisterEventHotKey(
    UInt32(kVK_ANSI_1),      // 键码
    UInt32(optionKey),         // 修饰键
    hotKeyID,
    GetApplicationEventTarget(),
    0,
    &hotKeyRef
)
回调会在任意线程执行——务必调度到主线程:
swift
DispatchQueue.main.async { self.handleHotKey() }

File Pickers

文件选择器

Open (select files/directories)

打开(选择文件/目录)

swift
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
}
swift
let panel = NSOpenPanel()
panel.allowedContentTypes = [.image, .movie]  // UTType过滤
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.canCreateDirectories = true
panel.title = "选择图片"

// 模态(阻塞线程):
if panel.runModal() == .OK {
    let urls = panel.urls
}

// 或表单模态(附加到窗口,异步):
panel.beginSheetModal(for: window) { response in
    guard response == .OK else { return }
    let urls = panel.urls
}

Save

保存

swift
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
}
swift
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 }
    // 将文件写入url
}

Directory picker

目录选择器

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

Clipboard / Pasteboard

剪贴板 / Pasteboard

macOS's pasteboard is fundamentally different from the web's
navigator.clipboard
. It is a multi-item, multi-type container.
swift
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.
macOS的剪贴板与Web的
navigator.clipboard
有本质区别,它是一个多项目、多类型的容器。
swift
let pasteboard = NSPasteboard.general

// 关键:写入前必须清空。忘记这一步是常见错误。
pasteboard.clearContents()

// 写入文本:
pasteboard.setString("hello", forType: .string)

// 写入图片数据:
let pngData = try Data(contentsOf: imageURL)
pasteboard.setData(pngData, forType: .png)

// 写入文件URL(用于Finder粘贴):
pasteboard.writeObjects([url as NSURL])

// 读取(检查可用类型):
if let string = pasteboard.string(forType: .string) { ... }
if let data = pasteboard.data(forType: .png) { ... }
单个剪贴板项目可声明多种类型。读取时按优先级顺序检查类型。

Drag and Drop

拖放

SwiftUI (simple)

SwiftUI(简单版)

swift
// 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
}
swift
// 使元素可拖拽:
Image(nsImage: image)
    .draggable(fileURL) {
        // 自定义拖拽预览
        Image(nsImage: image).frame(width: 100, height: 75)
    }

// 接受拖拽:
.dropDestination(for: URL.self) { urls, location in
    handleDroppedFiles(urls)
    return true
}

AppKit (full control)

AppKit(完全控制版)

Register as a drop target:
swift
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
    }
}
注册为拖放目标:
swift
class MyView: NSView {
    override init(frame: NSRect) {
        super.init(frame: frame)
        registerForDraggedTypes([.fileURL, .png, .tiff])
    }

    override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
        .copy  // 显示绿色+光标
    }

    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

NavigationSplitView + 检查器布局

The native macOS pattern for a sidebar + detail + inspector layout:
swift
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.
macOS原生的侧边栏+详情+检查器布局模式:
swift
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")
                }
            }
        }
    }
}
.inspector()
修饰符会创建一个原生右侧面板,可滑入/滑出,自动管理布局,并与窗口工具栏集成。

Launch at Login

开机自启

Use
SMAppService
(macOS 13+):
swift
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.
使用
SMAppService
(macOS 13+):
swift
import ServiceManagement

// 检查状态:
switch SMAppService.mainApp.status {
case .enabled: /* 开机自启已启用 */
case .requiresApproval: /* 已注册但用户需在系统设置中批准 */
case .notRegistered, .notFound: /* 未注册 */
}

// 注册:
try SMAppService.mainApp.register()

// 取消注册:
try SMAppService.mainApp.unregister()
requiresApproval
状态是macOS独有的——应用已申请开机自启,但用户必须在「系统设置」>「通用」>「登录项」中手动批准。
开发调试时务必禁用此功能,避免污染登录项列表。

Quick Look Preview

Quick Look预览

Show a system Quick Look panel for any file (images, PDFs, videos, documents):
swift
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
    }
}
为任意文件(图片、PDF、视频、文档)显示系统Quick Look面板:
swift
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()  // 必须先激活应用,否则面板会在其他窗口后方打开
        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

NSWorkspace——系统集成

swift
// 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
swift
// 在默认浏览器中打开URL:
NSWorkspace.shared.open(URL(string: "https://example.com")!)

// 在Finder中显示文件(选中该文件):
NSWorkspace.shared.activateFileViewerSelecting([fileURL])

// 获取前台应用:
let app = NSWorkspace.shared.frontmostApplication
let name = app?.localizedName  // 示例:"Safari"

// 检查辅助功能偏好设置:
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion  // 遵循「减少动态效果」
NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency

UserDefaults + @AppStorage

UserDefaults + @AppStorage

Programmatic access

程序化访问

swift
// 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")
swift
// 写入:
UserDefaults.standard.set(true, forKey: "autoSave")
UserDefaults.standard.set(0.8, forKey: "quality")

// 读取(键不存在时返回false/0/nil):
let autoSave = UserDefaults.standard.bool(forKey: "autoSave")
let quality = UserDefaults.standard.double(forKey: "quality")

Reactive SwiftUI binding

响应式SwiftUI绑定

swift
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.
swift
struct SettingsView: View {
    @AppStorage("autoSave") private var autoSave = false
    @AppStorage("quality") private var quality = 0.8

    var body: some View {
        Toggle("自动保存", isOn: $autoSave)      // 自动持久化
        Slider(value: $quality, in: 0...1)         // 自动持久化
    }
}
@AppStorage
会自动从
UserDefaults
读取数据并写入,且值变化时会触发SwiftUI视图更新。

ScreenCaptureKit

ScreenCaptureKit

Capture screen content at native Retina resolution:
swift
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.
以原生Retina分辨率捕获屏幕内容:
swift
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)   // 点 → 像素
config.height = Int(CGFloat(display.height) * scale)
config.showsCursor = false

let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
点到像素的转换至关重要。ScreenCaptureKit以点为单位工作,但输出尺寸必须以像素为单位才能获得Retina分辨率。

Common Mistakes Web Devs Make

Web开发者常犯的错误

What they tryWhy it failsWhat to do instead
SwiftUI
Window
scene for floating UI
Steals focus, shows in Dock, no transparencyUse
NSPanel
with
.nonactivatingPanel
z-index
thinking for window ordering
macOS uses discrete window levels, not a flat stackSet
window.level
to
.floating
,
.screenSaver
, etc.
window.innerHeight
for positioning
Doesn't account for menu bar, Dock, or notchUse
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 focusedUse Carbon
RegisterEventHotKey
for system-wide
<input type="file">
mental model
macOS has modal, sheet-modal, and async file pickersUse
NSOpenPanel
with the right presentation mode
Single-monitor assumptionsmacOS users commonly have 2-3 displaysAlways use
NSScreen.screens
and find the right one
CSS animation for everythingmacOS has spring physics, reduced motion, per-window animationUse SwiftUI
.animation(.spring(...))
and check
accessibilityDisplayShouldReduceMotion
尝试的操作失败原因正确做法
使用SwiftUI
Window
场景实现浮动UI
抢夺焦点、显示在Dock中、不支持透明使用带
.nonactivatingPanel
NSPanel
z-index
思路处理窗口层级
macOS使用离散窗口层级,而非扁平堆叠
window.level
设置为
.floating
.screenSaver
window.innerHeight
定位
未考虑菜单栏、Dock或刘海根据场景使用
NSScreen.visibleFrame
.frame
navigator.clipboard.writeText()
思路
macOS要求先调用
clearContents()
写入前务必调用
pasteboard.clearContents()
addEventListener('keydown')
实现全局快捷键
仅应用聚焦时生效使用Carbon的
RegisterEventHotKey
实现系统级快捷键
套用
<input type="file">
的思维模型
macOS有模态、表单模态和异步文件选择器使用对应展示模式的
NSOpenPanel
假设单显示器macOS用户通常有2-3台显示器始终使用
NSScreen.screens
并找到对应屏幕
所有动画都用CSS思路macOS有弹簧物理、减少动态效果、窗口级动画使用SwiftUI
.animation(.spring(...))
并检查
accessibilityDisplayShouldReduceMotion