关于 macos-patterns
为从 Web 开发转向原生 macOS 开发的人员提供模式参考,帮助 AI 生成可行的 macOS 代码。内容涵盖窗口、菜单栏、输入和系统集成等 Web 中没有直接对应物的 API。
主要功能
- 01菜单栏应用与弹出面板模式
- 02Dock 图标和窗口激活策略
- 03窗口层级、焦点与屏幕行为
- 04快捷键、文件选择与剪贴板
- 05拖放、导航和系统集成
适用场景
- 01实现菜单栏应用及其设置窗口
- 02创建不抢焦点的浮动面板
- 03处理多显示器上的窗口定位
- 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 并找到正确的显示器 |
| 所有动画都使用 CSS | macOS 提供弹簧物理效果、减少动态效果选项和逐窗口动画 | 使用 SwiftUI .animation(.spring(...)),并检查 accessibilityDisplayShouldReduceMotion |