macos-native について
AppKit、SwiftUI、Catalystを使ったmacOSネイティブアプリ開発者向けガイドです。アプリ構成、システム連携、サンドボックス、公開前の確認事項を扱います。
主な機能
- 01AppKit、SwiftUI、Catalystの比較
- 02ウィンドウ、メニューバー、書類アプリの解説
- 03サービス、ドラッグ&ドロップ、通知の紹介
- 04サンドボックスとセキュリティスコープブックマークの説明
- 05Mac App Store提出チェックリスト
活用シーン
- 01macOSアプリのフレームワーク選定
- 02メニューバーアプリや書類アプリの構築
- 03ドラッグ&ドロップ、サービス、通知の実装
- 04サンドボックス設定とストア提出要件の確認
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ travisjneuman/ .claude --skill macos-nativeインストール後、Claude Code で/macos-nativeを入力して読み込まれたか確認します。
詳細
macOS ネイティブ開発
AppKit と最新の macOS API を使用して、ネイティブ macOS アプリケーションを構築するための包括的なガイドです。
フレームワークの概要
| フレームワーク | 用途 | 備考 |
|---|---|---|
| AppKit | 従来型の Mac アプリ | 柔軟な制御、成熟したフレームワーク |
| SwiftUI | 最新の Mac アプリ | クロスプラットフォーム、宣言型 |
| Catalyst | Mac 上の iPad アプリ | 簡単に移植可能、制約あり |
| AppKit + SwiftUI | ハイブリッド方式 | 両方の利点を活用 |
AppKit の基礎
アプリケーションの構造
// AppDelegate.swift
import Cocoa
@main
class AppDelegate: NSObject, NSApplicationDelegate {
var mainWindow: NSWindow?
func applicationDidFinishLaunching(_ notification: Notification) {
setupMainWindow()
setupMainMenu()
}
func applicationWillTerminate(_ notification: Notification) {
// Cleanup
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
private func setupMainWindow() {
let contentRect = NSRect(x: 0, y: 0, width: 800, height: 600)
let styleMask: NSWindow.StyleMask = [
.titled, .closable, .miniaturizable, .resizable
]
mainWindow = NSWindow(
contentRect: contentRect,
styleMask: styleMask,
backing: .buffered,
defer: false
)
mainWindow?.title = "My Mac App"
mainWindow?.contentViewController = MainViewController()
mainWindow?.center()
mainWindow?.makeKeyAndOrderFront(nil)
}
}
ビューコントローラー
import Cocoa
class MainViewController: NSViewController {
private let tableView = NSTableView()
private let scrollView = NSScrollView()
private var items: [String] = []
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600))
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
loadData()
}
private func setupUI() {
// Setup scroll view
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.hasVerticalScroller = true
scrollView.documentView = tableView
view.addSubview(scrollView)
// Setup table view
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("main"))
column.title = "Items"
column.width = 200
tableView.addTableColumn(column)
tableView.delegate = self
tableView.dataSource = self
// Constraints
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20),
])
}
private func loadData() {
items = ["Item 1", "Item 2", "Item 3"]
tableView.reloadData()
}
}
extension MainViewController: NSTableViewDataSource, NSTableViewDelegate {
func numberOfRows(in tableView: NSTableView) -> Int {
return items.count
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("cell")
var cell = tableView.makeView(withIdentifier: identifier, owner: nil) as? NSTextField
if cell == nil {
cell = NSTextField(labelWithString: "")
cell?.identifier = identifier
}
cell?.stringValue = items[row]
return cell
}
}
メニューバーアプリ
ステータス項目
import Cocoa
class StatusBarController {
private var statusItem: NSStatusItem?
private var popover: NSPopover?
init() {
setupStatusItem()
setupPopover()
}
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "App")
button.action = #selector(togglePopover)
button.target = self
}
}
private func setupPopover() {
popover = NSPopover()
popover?.contentViewController = PopoverViewController()
popover?.behavior = .transient
}
@objc private func togglePopover() {
guard let button = statusItem?.button, let popover = popover else { return }
if popover.isShown {
popover.performClose(nil)
} else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true)
}
}
}
class PopoverViewController: NSViewController {
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 200))
}
override func viewDidLoad() {
super.viewDidLoad()
let label = NSTextField(labelWithString: "Menu Bar App Content")
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
}
メニューの構築
func setupMainMenu() {
let mainMenu = NSMenu()
// App Menu
let appMenu = NSMenu()
let appMenuItem = NSMenuItem()
appMenuItem.submenu = appMenu
appMenu.addItem(withTitle: "About My App", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(withTitle: "Preferences...", action: #selector(showPreferences), keyEquivalent: ",")
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(withTitle: "Quit My App", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
mainMenu.addItem(appMenuItem)
// File Menu
let fileMenu = NSMenu(title: "File")
let fileMenuItem = NSMenuItem()
fileMenuItem.submenu = fileMenu
fileMenu.addItem(withTitle: "New", action: #selector(newDocument), keyEquivalent: "n")
fileMenu.addItem(withTitle: "Open...", action: #selector(openDocument), keyEquivalent: "o")
fileMenu.addItem(NSMenuItem.separator())
fileMenu.addItem(withTitle: "Save", action: #selector(saveDocument), keyEquivalent: "s")
mainMenu.addItem(fileMenuItem)
// Edit Menu
let editMenu = NSMenu(title: "Edit")
let editMenuItem = NSMenuItem()
editMenuItem.submenu = editMenu
editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
editMenu.addItem(NSMenuItem.separator())
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
mainMenu.addItem(editMenuItem)
NSApp.mainMenu = mainMenu
}
ドキュメントベースのアプリ
ドキュメントコントローラー
import Cocoa
import UniformTypeIdentifiers
class MyDocument: NSDocument {
var content: String = ""
override class var autosavesInPlace: Bool { true }
override func makeWindowControllers() {
let storyboard = NSStoryboard(name: "Main", bundle: nil)
let windowController = storyboard.instantiateController(
withIdentifier: "Document Window Controller"
) as! NSWindowController
addWindowController(windowController)
}
override func data(ofType typeName: String) throws -> Data {
guard let data = content.data(using: .utf8) else {
throw NSError(domain: NSOSStatusErrorDomain, code: unimpErr)
}
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: unimpErr)
}
self.content = content
}
}
// Info.plist Document Types
/*
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>My Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.example.mydocument</string>
</array>
</dict>
</array>
*/
システムとの統合
サービス
// Providing a service
class ServiceProvider: NSObject {
@objc func processText(_ pboard: NSPasteboard, userData: String, error: AutoreleasingUnsafeMutablePointer<NSString?>) {
guard let text = pboard.string(forType: .string) else { return }
let processed = text.uppercased()
pboard.clearContents()
pboard.setString(processed, forType: .string)
}
}
// Register in Info.plist
/*
<key>NSServices</key>
<array>
<dict>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Process with My App</string>
</dict>
<key>NSMessage</key>
<string>processText</string>
<key>NSPortName</key>
<string>MyApp</string>
<key>NSSendTypes</key>
<array>
<string>NSStringPboardType</string>
</array>
<key>NSReturnTypes</key>
<array>
<string>NSStringPboardType</string>
</array>
</dict>
</array>
*/
ドラッグ&ドロップ
class DropView: NSView {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
registerForDraggedTypes([.fileURL, .string])
}
required init?(coder: NSCoder) {
super.init(coder: coder)
registerForDraggedTypes([.fileURL, .string])
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
return .copy
}
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
let pasteboard = sender.draggingPasteboard
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] {
for url in urls {
handleDroppedFile(url)
}
return true
}
if let strings = pasteboard.readObjects(forClasses: [NSString.self]) as? [String] {
for string in strings {
handleDroppedText(string)
}
return true
}
return false
}
private func handleDroppedFile(_ url: URL) {
print("Dropped file: \(url)")
}
private func handleDroppedText(_ text: String) {
print("Dropped text: \(text)")
}
}
通知
import UserNotifications
class NotificationManager {
static let shared = NotificationManager()
func requestAuthorization() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted {
print("Notification permission granted")
}
}
}
func scheduleNotification(title: String, body: String, delay: TimeInterval = 5) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request)
}
}
サンドボックス化とエンタイトルメント
よく使われるエンタイトルメント
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
セキュリティスコープ付きブックマーク
class BookmarkManager {
private let bookmarksKey = "securityScopedBookmarks"
func saveBookmark(for url: URL) throws {
let bookmarkData = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
var bookmarks = UserDefaults.standard.dictionary(forKey: bookmarksKey) ?? [:]
bookmarks[url.path] = bookmarkData
UserDefaults.standard.set(bookmarks, forKey: bookmarksKey)
}
func resolveBookmark(for path: String) -> URL? {
guard let bookmarks = UserDefaults.standard.dictionary(forKey: bookmarksKey),
let bookmarkData = bookmarks[path] as? Data else {
return nil
}
var isStale = false
guard let url = try? URL(
resolvingBookmarkData: bookmarkData,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
) else {
return nil
}
if isStale {
try? saveBookmark(for: url)
}
return url
}
func accessSecurityScopedResource(_ url: URL, action: (URL) throws -> Void) rethrows {
guard url.startAccessingSecurityScopedResource() else {
return
}
defer { url.stopAccessingSecurityScopedResource() }
try action(url)
}
}
チェックリスト
Mac App Store への提出
- App Sandbox を有効化
- Hardened Runtime を有効化
- すべてのエンタイトルメントの必要性を説明
- Info.plist にプライバシーに関する説明を記載
- アプリアイコン(すべてのサイズ)
- App Store 用のスクリーンショット
- 非公開 API を使用していない
ベストプラクティス
- キーボード操作に対応
- システムの外観(ダーク/ライト)に準拠
- フルスクリーンに対応
- ウィンドウの復元に対応
- 適切な取り消し/やり直しを実装
- 標準的なキーボードショートカットに対応
コメント
Markdown 対応