mcpField
M
macos-patterns

macos-patterns

github.com/fayazara开发685682 次安装22 周前更新
代码仓库

关于 macos-patterns

为从 Web 开发转向原生 macOS 开发的人员提供模式参考,帮助 AI 生成可行的 macOS 代码。内容涵盖窗口、菜单栏、输入和系统集成等 Web 中没有直接对应物的 API。

主要功能

  1. 01菜单栏应用与弹出面板模式
  2. 02Dock 图标和窗口激活策略
  3. 03窗口层级、焦点与屏幕行为
  4. 04快捷键、文件选择与剪贴板
  5. 05拖放、导航和系统集成

适用场景

  1. 01实现菜单栏应用及其设置窗口
  2. 02创建不抢焦点的浮动面板
  3. 03处理多显示器上的窗口定位
  4. 04为原生应用添加全局快捷键

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/fayazara/macos-app-skills --skill macos-patterns

安装后在 Claude Code 中输入/macos-patterns确认已加载。

详细介绍

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

这是一份参考指南,介绍了没有 Web 对应方案的 macOS 特定模式。构建 macOS 原生应用时,这些内容会让所有从事 Web 开发的人感到棘手。AI 在生成 macOS 代码前应先查阅本指南,以免自信满满地生成无法运行的模式。

菜单栏应用

有两种方案。简单菜单使用 MenuBarExtra,需要完全控制时使用 NSStatusItem。

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

这会创建一个仅显示在菜单栏中的应用。菜单内容是标准的 SwiftUI 视图。若要创建弹出窗口样式的菜单栏应用(比普通菜单拥有更丰富的 UI),请使用 .window 样式:

MenuBarExtra("MyApp", image: "MenuBarIcon") {
    PopoverContentView()
}
.menuBarExtraStyle(.window)
AppKit NSStatusItem(完全控制)

用于自定义菜单、动态图标或复杂交互:

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 至关重要。否则,图标在浅色模式下会不可见,或在深色模式下显示异常。

用于丰富菜单栏内容的 NSPopover

若要创建附加到菜单栏图标的弹出窗口(类似 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)
}

激活策略——切换 Dock 图标

macOS 应用可以在运行时动态显示或隐藏 Dock 图标和 Cmd-Tab 列表项。仅菜单栏应用以 .accessory 启动(在 Dock 中不可见),打开“设置”等窗口时则会临时变为 .regular。

// 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)

如果可能同时打开多个窗口,请使用引用计数:

@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 与 NSWindow

NSPanel 是 NSWindow 的子类,适用于不应抢占焦点的辅助内容。可用于:

  • 浮动覆盖层(预览卡片、录制指示器)
  • 调色板和工具窗口
  • 检查器
  • 用户在其他应用中工作时仍应保持可见的任何内容

关键配置:

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
]

要在面板中托管 SwiftUI 内容:

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

需要时覆盖焦点行为:

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

如果应用显示的浮动 UI 不应出现在屏幕截图或录制中:

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

窗口层级

macOS 使用多层级的窗口层级系统,用于控制窗口相对于整个操作系统(而不仅是你的应用)的位置:

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)

通过以下方式设置:

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

控制窗口如何与 Spaces、全屏模式和 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
]

屏幕几何

macOS 使用左下角原点坐标。与 Web 相比,Y 轴方向相反。

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

在菜单栏上方定位时(刘海覆盖层)使用 frame。正常放置窗口时使用 visibleFrame。

Quartz 与 AppKit 的 Y 轴

Core Graphics / Quartz 使用左上角原点。AppKit 使用左下角原点。在两者之间转换时:

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
多显示器

绝不要假设只有一个屏幕。始终处理 NSScreen.main 不是唯一显示器的情况:

// 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

键盘快捷键

共有 3 个层级,分别适用于不同场景。

层级 1:SwiftUI 键盘快捷键(应用内,获得焦点时)
Button("Settings") { openSettings() }
    .keyboardShortcut(",", modifiers: [.command])  // Cmd+,
层级 2:NSEvent 监视器(应用级或全局)
// 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)

从本地监视器返回 nil 会拦截事件(停止传播)。全局监视器无法拦截事件。

层级 3:Carbon 热键(系统级,应用未获得焦点时也有效)

注册真正全局键盘快捷键的唯一方式。使用 Carbon API(源自 1990 年代,至今仍未弃用):

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
)

回调会在任意线程上运行——务必派发到主线程:

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

文件选择器

打开(选择文件/目录)
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
}
保存
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
}
目录选择器
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.directoryURL = URL(fileURLWithPath: NSHomeDirectory())

剪贴板 / Pasteboard

macOS 的 pasteboard 与 Web 的 navigator.clipboard 有本质区别。它是一个支持多项目、多类型的容器。

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) { ... }

单个 pasteboard 项可以声明多种类型。读取时,请按优先级顺序检查类型。

拖放

SwiftUI(简单)
// 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(完全控制)

注册为拖放目标:

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 布局

侧边栏 + 详情 + 检查器布局的原生 macOS 模式:

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() 修饰符会创建一个原生右侧面板,可滑入和滑出,自动管理布局,并与窗口工具栏集成。

登录时启动

使用 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()

requiresApproval 状态是 macOS 独有的——应用已请求在登录时启动,但用户必须在“系统设置”>“通用”>“登录项”中手动批准。

在调试版本中始终禁用此功能,以免开发期间污染登录项列表。

Quick Look 预览

显示适用于任何文件(图像、PDF、视频、文档)的系统 Quick Look 面板:

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 —— 操作系统集成

// 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

以编程方式访问
// 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")
响应式 SwiftUI 绑定
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 会自动从 UserDefaults 读取和写入数据,并在值发生变化时触发 SwiftUI 视图更新。

ScreenCaptureKit

以原生 Retina 分辨率捕获屏幕内容:

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)

从点到像素的换算至关重要。ScreenCaptureKit 使用点,但 Retina 分辨率的输出尺寸必须以像素表示。

Web 开发者常犯的错误

他们尝试的做法失败原因应改用的做法
使用 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 并找到正确的显示器
所有动画都使用 CSSmacOS 提供弹簧物理效果、减少动态效果选项和逐窗口动画使用 SwiftUI .animation(.spring(...)),并检查 accessibilityDisplayShouldReduceMotion

评论

支持 Markdown