swift-macos

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Swift macOS Skill

Swift macOS 技能

Native macOS application development with AppKit, system integration, and distribution.
基于AppKit的原生macOS应用开发,包含系统集成与分发。

Prerequisites

前置要求

  • Xcode 15+ on macOS Sonoma+
  • Apple Developer account (for distribution)
  • Understanding of AppKit fundamentals
  • macOS Sonoma+ 系统上安装Xcode 15+
  • Apple Developer 账号(用于分发)
  • 了解AppKit基础概念

Parameters

参数

yaml
parameters:
  distribution_method:
    type: string
    enum: [app_store, developer_id, none]
    default: developer_id
  sandbox_enabled:
    type: boolean
    default: true
  min_macos_version:
    type: string
    default: "13.0"
  app_type:
    type: string
    enum: [document_based, single_window, menu_bar, agent]
    default: single_window
yaml
parameters:
  distribution_method:
    type: string
    enum: [app_store, developer_id, none]
    default: developer_id
  sandbox_enabled:
    type: boolean
    default: true
  min_macos_version:
    type: string
    default: "13.0"
  app_type:
    type: string
    enum: [document_based, single_window, menu_bar, agent]
    default: single_window

Topics Covered

涵盖主题

AppKit Components

AppKit 组件

ComponentPurpose
NSWindowWindow management
NSViewControllerView controller
NSViewBase view class
NSMenuMenu bar and context menus
NSStatusItemMenu bar icon
NSAlertDialog boxes
组件用途
NSWindow窗口管理
NSViewController视图控制器
NSView基础视图类
NSMenu菜单栏与上下文菜单
NSStatusItem菜单栏图标
NSAlert对话框

Window Types

窗口类型

TypeUse Case
NSWindowStandard window
NSPanelUtility/floating window
NSPopoverAttached popover
NSSavePanel/NSOpenPanelFile dialogs
类型使用场景
NSWindow标准窗口
NSPanel工具/浮动窗口
NSPopover依附式弹出框
NSSavePanel/NSOpenPanel文件对话框

Distribution Methods

分发方式

MethodRequirements
Mac App StoreSandbox, App Review
Developer IDNotarization
DirectNone (Gatekeeper blocks)
方式要求
Mac App Store沙箱机制、应用审核
Developer ID公证
直接分发无(但会被Gatekeeper拦截)

Code Examples

代码示例

Menu Bar Application

菜单栏应用

swift
import AppKit
import SwiftUI

@main
struct MenuBarApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        Settings {
            SettingsView()
        }
    }
}

final class AppDelegate: NSObject, NSApplicationDelegate {
    private var statusItem: NSStatusItem!
    private var popover: NSPopover!

    func applicationDidFinishLaunching(_ notification: Notification) {
        setupStatusItem()
        setupPopover()

        // Hide dock icon for menu bar only app
        NSApp.setActivationPolicy(.accessory)
    }

    private func setupStatusItem() {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)

        if let button = statusItem.button {
            button.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "App")
            button.action = #selector(togglePopover)
        }
    }

    private func setupPopover() {
        popover = NSPopover()
        popover.contentSize = NSSize(width: 300, height: 400)
        popover.behavior = .transient
        popover.contentViewController = NSHostingController(rootView: PopoverView())
    }

    @objc private func togglePopover() {
        guard let button = statusItem.button else { return }

        if popover.isShown {
            popover.performClose(nil)
        } else {
            popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
            popover.contentViewController?.view.window?.makeKey()
        }
    }
}

struct PopoverView: View {
    @Environment(\.openSettings) private var openSettings

    var body: some View {
        VStack(spacing: 16) {
            Text("Menu Bar App")
                .font(.headline)

            Button("Settings") {
                openSettings()
            }

            Button("Quit") {
                NSApp.terminate(nil)
            }
        }
        .padding()
    }
}
swift
import AppKit
import SwiftUI

@main
struct MenuBarApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        Settings {
            SettingsView()
        }
    }
}

final class AppDelegate: NSObject, NSApplicationDelegate {
    private var statusItem: NSStatusItem!
    private var popover: NSPopover!

    func applicationDidFinishLaunching(_ notification: Notification) {
        setupStatusItem()
        setupPopover()

        // 仅菜单栏应用隐藏dock图标
        NSApp.setActivationPolicy(.accessory)
    }

    private func setupStatusItem() {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)

        if let button = statusItem.button {
            button.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "App")
            button.action = #selector(togglePopover)
        }
    }

    private func setupPopover() {
        popover = NSPopover()
        popover.contentSize = NSSize(width: 300, height: 400)
        popover.behavior = .transient
        popover.contentViewController = NSHostingController(rootView: PopoverView())
    }

    @objc private func togglePopover() {
        guard let button = statusItem.button else { return }

        if popover.isShown {
            popover.performClose(nil)
        } else {
            popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
            popover.contentViewController?.view.window?.makeKey()
        }
    }
}

struct PopoverView: View {
    @Environment(\.openSettings) private var openSettings

    var body: some View {
        VStack(spacing: 16) {
            Text("Menu Bar App")
                .font(.headline)

            Button("Settings") {
                openSettings()
            }

            Button("Quit") {
                NSApp.terminate(nil)
            }
        }
        .padding()
    }
}

NSDocument-Based App

NSDocument 文档型应用

swift
import AppKit

final class Document: NSDocument {
    var content: String = ""

    override class var autosavesInPlace: Bool { true }

    override func makeWindowControllers() {
        let contentVC = ContentViewController(document: self)
        let window = NSWindow(contentViewController: contentVC)
        window.setContentSize(NSSize(width: 800, height: 600))

        let windowController = NSWindowController(window: window)
        addWindowController(windowController)
    }

    override func data(ofType typeName: String) throws -> Data {
        guard let data = content.data(using: .utf8) else {
            throw NSError(domain: NSOSStatusErrorDomain, code: writErr)
        }
        return data
    }

    override func read(from data: Data, ofType typeName: String) throws {
        guard let content = String(data: data, encoding: .utf8) else {
            throw NSError(domain: NSOSStatusErrorDomain, code: readErr)
        }
        self.content = content
    }
}

final class ContentViewController: NSViewController {
    private let document: Document

    init(document: Document) {
        self.document = document
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) not implemented")
    }

    override func loadView() {
        let scrollView = NSScrollView()
        let textView = NSTextView()

        textView.isEditable = true
        textView.isRichText = false
        textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
        textView.string = document.content

        scrollView.documentView = textView
        scrollView.hasVerticalScroller = true

        view = scrollView
    }
}
swift
import AppKit

final class Document: NSDocument {
    var content: String = ""

    override class var autosavesInPlace: Bool { true }

    override func makeWindowControllers() {
        let contentVC = ContentViewController(document: self)
        let window = NSWindow(contentViewController: contentVC)
        window.setContentSize(NSSize(width: 800, height: 600))

        let windowController = NSWindowController(window: window)
        addWindowController(windowController)
    }

    override func data(ofType typeName: String) throws -> Data {
        guard let data = content.data(using: .utf8) else {
            throw NSError(domain: NSOSStatusErrorDomain, code: writErr)
        }
        return data
    }

    override func read(from data: Data, ofType typeName: String) throws {
        guard let content = String(data: data, encoding: .utf8) else {
            throw NSError(domain: NSOSStatusErrorDomain, code: readErr)
        }
        self.content = content
    }
}

final class ContentViewController: NSViewController {
    private let document: Document

    init(document: Document) {
        self.document = document
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) not implemented")
    }

    override func loadView() {
        let scrollView = NSScrollView()
        let textView = NSTextView()

        textView.isEditable = true
        textView.isRichText = false
        textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
        textView.string = document.content

        scrollView.documentView = textView
        scrollView.hasVerticalScroller = true

        view = scrollView
    }
}

Sandboxing with Security-Scoped Bookmarks

基于安全范围书签的沙箱机制

swift
final class SandboxedFileAccess {
    private static let bookmarksKey = "securityScopedBookmarks"

    static func requestAccess(to url: URL) throws -> URL {
        let bookmark = try url.bookmarkData(
            options: .withSecurityScope,
            includingResourceValuesForKeys: nil,
            relativeTo: nil
        )

        var bookmarks = UserDefaults.standard.dictionary(forKey: bookmarksKey) as? [String: Data] ?? [:]
        bookmarks[url.path] = bookmark
        UserDefaults.standard.set(bookmarks, forKey: bookmarksKey)

        return url
    }

    static func accessBookmarkedURL(_ path: String) throws -> URL {
        guard let bookmarks = UserDefaults.standard.dictionary(forKey: bookmarksKey) as? [String: Data],
              let bookmarkData = bookmarks[path] else {
            throw SandboxError.noBookmark
        }

        var isStale = false
        let url = try URL(
            resolvingBookmarkData: bookmarkData,
            options: .withSecurityScope,
            relativeTo: nil,
            bookmarkDataIsStale: &isStale
        )

        if isStale {
            _ = try requestAccess(to: url)
        }

        return url
    }

    static func withAccess<T>(to url: URL, perform: (URL) throws -> T) throws -> T {
        guard url.startAccessingSecurityScopedResource() else {
            throw SandboxError.accessDenied
        }
        defer { url.stopAccessingSecurityScopedResource() }
        return try perform(url)
    }
}

enum SandboxError: Error {
    case noBookmark
    case accessDenied
}
swift
final class SandboxedFileAccess {
    private static let bookmarksKey = "securityScopedBookmarks"

    static func requestAccess(to url: URL) throws -> URL {
        let bookmark = try url.bookmarkData(
            options: .withSecurityScope,
            includingResourceValuesForKeys: nil,
            relativeTo: nil
        )

        var bookmarks = UserDefaults.standard.dictionary(forKey: bookmarksKey) as? [String: Data] ?? [:]
        bookmarks[url.path] = bookmark
        UserDefaults.standard.set(bookmarks, forKey: bookmarksKey)

        return url
    }

    static func accessBookmarkedURL(_ path: String) throws -> URL {
        guard let bookmarks = UserDefaults.standard.dictionary(forKey: bookmarksKey) as? [String: Data],
              let bookmarkData = bookmarks[path] else {
            throw SandboxError.noBookmark
        }

        var isStale = false
        let url = try URL(
            resolvingBookmarkData: bookmarkData,
            options: .withSecurityScope,
            relativeTo: nil,
            bookmarkDataIsStale: &isStale
        )

        if isStale {
            _ = try requestAccess(to: url)
        }

        return url
    }

    static func withAccess<T>(to url: URL, perform: (URL) throws -> T) throws -> T {
        guard url.startAccessingSecurityScopedResource() else {
            throw SandboxError.accessDenied
        }
        defer { url.stopAccessingSecurityScopedResource() }
        return try perform(url)
    }
}

enum SandboxError: Error {
    case noBookmark
    case accessDenied
}

Notarization Script

公证脚本

bash
#!/bin/bash
set -e

APP_PATH="$1"
DEVELOPER_ID="Developer ID Application: Your Name (TEAMID)"
KEYCHAIN_PROFILE="notary-profile"

echo "Signing..."
codesign --force --options runtime --sign "$DEVELOPER_ID" \
    --deep --strict "$APP_PATH"

echo "Creating ZIP..."
ditto -c -k --keepParent "$APP_PATH" "app.zip"

echo "Submitting for notarization..."
xcrun notarytool submit "app.zip" \
    --keychain-profile "$KEYCHAIN_PROFILE" \
    --wait

echo "Stapling..."
xcrun stapler staple "$APP_PATH"

echo "Verifying..."
spctl --assess --type execute --verbose=2 "$APP_PATH"

echo "Done!"
rm "app.zip"
bash
#!/bin/bash
set -e

APP_PATH="$1"
DEVELOPER_ID="Developer ID Application: Your Name (TEAMID)"
KEYCHAIN_PROFILE="notary-profile"

echo "Signing..."
codesign --force --options runtime --sign "$DEVELOPER_ID" \
    --deep --strict "$APP_PATH"

echo "Creating ZIP..."
ditto -c -k --keepParent "$APP_PATH" "app.zip"

echo "Submitting for notarization..."
xcrun notarytool submit "app.zip" \
    --keychain-profile "$KEYCHAIN_PROFILE" \
    --wait

echo "Stapling..."
xcrun stapler staple "$APP_PATH"

echo "Verifying..."
spctl --assess --type execute --verbose=2 "$APP_PATH"

echo "Done!"
rm "app.zip"

Troubleshooting

故障排除

Common Issues

常见问题

IssueCauseSolution
"App is damaged"Not notarizedNotarize before distribution
Sandbox violationMissing entitlementAdd required entitlement
Window not appearingWrong activation policyCheck NSApp.activationPolicy
Menu not respondingMissing target/actionVerify menu item connections
Sparkle updates failCode signing issueAdd exception entitlement
问题原因解决方案
"应用已损坏"未公证分发前进行公证
沙箱违规缺少权限配置添加所需权限
窗口不显示激活策略错误检查NSApp.activationPolicy
菜单无响应缺少目标/动作验证菜单项连接
Sparkle 更新失败代码签名问题添加例外权限

Debug Tips

调试技巧

bash
undefined
bash
undefined

Check code signing

检查代码签名

codesign -dv --verbose=4 App.app
codesign -dv --verbose=4 App.app

Check entitlements

检查权限配置

codesign -d --entitlements :- App.app
codesign -d --entitlements :- App.app

Check notarization

检查公证状态

spctl --assess --type execute App.app
spctl --assess --type execute App.app

View sandbox violations

查看沙箱违规日志

log show --predicate 'subsystem == "com.apple.sandbox"' --last 1h
undefined
log show --predicate 'subsystem == "com.apple.sandbox"' --last 1h
undefined

Validation Rules

验证规则

yaml
validation:
  - rule: hardened_runtime
    severity: error
    check: Enable hardened runtime for notarization
  - rule: sandbox_entitlements
    severity: warning
    check: Only request necessary sandbox entitlements
  - rule: code_signing
    severity: error
    check: Sign with Developer ID for distribution
yaml
validation:
  - rule: hardened_runtime
    severity: error
    check: Enable hardened runtime for notarization
  - rule: sandbox_entitlements
    severity: warning
    check: Only request necessary sandbox entitlements
  - rule: code_signing
    severity: error
    check: Sign with Developer ID for distribution

Usage

使用方法

Skill("swift-macos")
Skill("swift-macos")

Related Skills

相关技能

  • swift-swiftui
    - SwiftUI on macOS
  • swift-architecture
    - App architecture
  • swift-testing
    - Testing macOS apps
  • swift-swiftui
    - macOS 上的 SwiftUI 开发
  • swift-architecture
    - 应用架构
  • swift-testing
    - macOS 应用测试