macOSのメニューバーに常駐する小さなアプリ。起動が速く、邪魔にならず、必要なときだけ呼び出せる——そのUXの良さは一度使うと手放せません。個人開発者として長くiOSアプリを作ってきた私が、次の一手としてmacOSメニューバーアプリに取り組んだのは、iOSとmacOSを同時にカバーすることでユーザー基盤を広げたかったからです。
ところが最初は戸惑いの連続でした。SwiftUIは知っていても、NSStatusItem、NSPopover、MenuBarExtra——macOS固有のAPIは調べても断片的な情報しか見当たりません。Antigravityを使い始めてから状況が変わりました。プロジェクト構成からAppKit/SwiftUIのハイブリッド設計まで、Antigravityは「こう組み合わせればいい」という具体的な判断を一緒にしてくれます。
ここでは私が実際に手を動かして学んだmacOSメニューバーアプリ開発の全工程を、動作するコードとともに共有します。
なぜメニューバーアプリなのか — このUXが選ばれる理由
「常駐型アプリ」というカテゴリには、Dock常駐型とメニューバー常駐型があります。生産性ツールやユーティリティの文脈では、メニューバー型が明確に支持されています。理由は3つです。
起動コストゼロ。Dockアプリは「Dockをクリック→Dockが表示」という視線移動が発生します。メニューバーは視線の最上部にあり、マウスの移動距離も最短です。
ながら作業との相性。タイマー、天気、クリップボード管理、音量コントロール——これらはメインウィンドウを占有せずに使えることに価値があります。フォーカスを妨げない設計が支持される理由です。
Setapp掲載チャンス。SetappはmacOS専門のサブスクリプション型アプリマーケットで、メニューバーアプリの掲載数が特に多く、ユーザーのリテンション率も高いとされています。個人開発者にとって収益化の選択肢として現実的です。
Antigravityとの協働フロー — macOSプロジェクトに適したコンテキスト設定
AntigravityでmacOS開発を始めるとき、最初にやるべきことがあります。プロジェクトルートに PROJECT_CONTEXT.md を置くことです。
# Project Context
## Platform
macOS 13+ (Universal Binary: Apple Silicon + Intel)
Swift 5.10 / SwiftUI + AppKit hybrid
Deployment target: macOS 13.0
## App Type
MenuBar-only app (no Dock icon)
LSUIElement = YES in Info.plist
## Key Frameworks
- SwiftUI (MenuBarExtra API)
- AppKit (NSStatusItem for advanced customization)
- ServiceManagement (LaunchAtLogin)
- AppIntents (Shortcuts integration)
## Architecture
MVVM. ObservableObject for state. No UIKit dependency.このファイルを置いた状態でAntigravityに質問すると、回答が一気に具体的になります。「MenuBarExtraを使うべきかNSStatusItemを直接操作すべきか」のような設計判断も、プロジェクトの要件を踏まえた上で答えてくれます。
私の経験では、Antigravityは「なぜその選択をするのか」まで説明してくれることが多く、単なるコード補完ではなく設計のパートナーとして機能しています。
SwiftUI × MenuBarExtra で最初のメニューバーアプリを動かす
macOS 13から導入されたMenuBarExtraAPIは、SwiftUIネイティブなメニューバーアプリ開発を大幅に簡単にしました。最小構成はこうです。
// MyMenuBarApp.swift
import SwiftUI
@main
struct MyMenuBarApp: App {
var body: some Scene {
MenuBarExtra("MyApp", systemImage: "star.fill") {
// ここに表示するView
ContentView()
}
.menuBarExtraStyle(.window) // または .menu
}
}ポイントが2つあります。.menuBarExtraStyle(.window)は自由なカスタムUIを表示するスタイルで、.menuはNSMenuのような標準メニュー形式になります。ほとんどの本格的なメニューバーアプリは.windowを選びます。
Info.plistの設定を忘れずに。Dockにアイコンを表示させないためにはLSUIElementをYESにする必要があります。
<\!-- Info.plist -->
<key>LSUIElement</key>
<true/>Antigravityでこの設定を忘れた場合、「メニューバーに表示されているがDockにも出てしまう」という症状が出ます。私も最初に見事にハマりました。Antigravityに「Dockアイコンを消したい」と伝えれば、LSUIElementの設定と、それに伴うWindowManagerの調整まで一緒に教えてくれます。
カスタムポップオーバーUIの設計 — SwiftUI + AppKit ハイブリッドの実際
MenuBarExtraの.windowスタイルで表示できるViewには、実はサイズ制約があります。幅は最大で画面幅の半分程度、高さも制限があります。これで足りない場合や、より精密なウィンドウ制御が必要な場合は、NSStatusItem + NSPopoverの組み合わせを検討します。
// StatusBarController.swift
import AppKit
import SwiftUI
class StatusBarController: NSObject {
private var statusItem: NSStatusItem
private var popover: NSPopover
override init() {
// ステータスバーアイテムの作成
statusItem = NSStatusBar.system.statusItem(
withLength: NSStatusItem.squareLength
)
// ポップオーバーの設定
popover = NSPopover()
popover.contentSize = NSSize(width: 320, height: 480)
popover.behavior = .transient // 他の場所クリックで自動的に閉じる
popover.contentViewController = NSHostingController(
rootView: ContentView()
)
super.init()
// アイコンの設定
if let button = statusItem.button {
button.image = NSImage(systemSymbolName: "star.fill",
accessibilityDescription: "MyApp")
button.action = #selector(togglePopover)
button.target = self
}
}
@objc 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?.makeKeyAndOrderFront(nil)
}
}
}このクラスを@mainのAppから使う場合、@NSApplicationDelegateAdaptorを使う方法と、AppDelegateを明示的に使う方法があります。Antigravityに「StatusBarControllerをAppに接続する方法」を聞くと、プロジェクトのアーキテクチャに合わせたアプローチを提案してくれます。
SwiftUIとAppKitのデータ共有
ポップオーバー内のSwiftUI ViewとAppKit側のコントローラーでデータを共有するには、ObservableObjectを@StateObjectまたは@EnvironmentObjectで渡すのが定石です。
// AppState.swift
import Foundation
import Combine
@MainActor
class AppState: ObservableObject {
@Published var isRunning: Bool = false
@Published var message: String = ""
func toggle() {
isRunning.toggle()
message = isRunning ? "実行中" : "停止"
}
}// MyMenuBarApp.swift
@main
struct MyMenuBarApp: App {
@StateObject private var appState = AppState()
var body: some Scene {
MenuBarExtra("MyApp", systemImage: appState.isRunning ? "play.fill" : "stop.fill") {
ContentView()
.environmentObject(appState)
}
.menuBarExtraStyle(.window)
}
}メニューバーアイコン自体を状態に応じて変化させられる点が、MenuBarExtraAPIの便利なところです。appState.isRunningの変化がアイコンにリアルタイムで反映されます。
Apple Shortcuts連携の実装 — ユーザーの既存ワークフローに溶け込む
macOS Monterey以降、アプリはApple Shortcutsと連携できます。ユーザーが「このアプリの機能を自分のワークフローに組み込む」方法として、Shortcuts連携はとても有効です。
実装にはAppIntentsフレームワークを使います。
// Intents/ToggleAppIntent.swift
import AppIntents
struct ToggleRunningIntent: AppIntent {
static var title: LocalizedStringResource = "実行/停止を切り替える"
static var description = IntentDescription("MyAppの実行状態を切り替えます")
// Siri・Shortcutsからパラメータを受け取る例
@Parameter(title: "メッセージ", optionsProvider: nil)
var customMessage: String?
func perform() async throws -> some IntentResult & ProvidesDialog {
// AppStateへのアクセス(シングルトンを使う場合)
await MainActor.run {
AppStateStore.shared.appState.toggle()
if let msg = customMessage {
AppStateStore.shared.appState.message = msg
}
}
let resultMessage = AppStateStore.shared.appState.isRunning
? "MyAppを開始しました"
: "MyAppを停止しました"
return .result(dialog: IntentDialog(stringLiteral: resultMessage))
}
}AppIntentはSwift Concurrencyに対応しており、非同期処理も自然に書けます。Antigravityにこのコードを見せながら「エラーハンドリングを加えたい」「パラメーターを増やしたい」と伝えると、perform()メソッドの拡張やAppShortcutsProviderの追加など、段階的に発展させるコードを提案してくれます。
Shortcutsアプリへの自動登録
// Shortcuts自動登録(App起動時に呼ぶ)
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: ToggleRunningIntent(),
phrases: [
"MyAppを\(.applicationName)で切り替えて",
"Toggle \(.applicationName)"
],
shortTitle: "実行/停止",
systemImageName: "play.pause.fill"
)
}
}AppShortcutsProviderに準拠したstructを定義すると、アプリのインストール直後からShortcutsアプリの「アクション」欄に表示されます。ユーザーが自分でアクションを探す手間が省けます。
LaunchAtLogin・グローバルホットキー・URLスキームの実装
LaunchAtLogin(ログイン時自動起動)
macOS 13以降はServiceManagementフレームワークで実装します。
import ServiceManagement
// ログイン時起動を登録
func enableLaunchAtLogin() throws {
// ヘルパーアプリを使う場合は SMAppService.loginItem(identifier:)
// メインアプリ自体をログイン時起動させる場合:
try SMAppService.mainApp.register()
}
// 登録解除
func disableLaunchAtLogin() throws {
try SMAppService.mainApp.unregister()
}
// 現在の状態確認
var isLaunchAtLoginEnabled: Bool {
SMAppService.mainApp.status == .enabled
}よくあるミスは、SMAppService.mainAppのstatusを確認せずにregister()を呼んでしまうことです。すでに登録済みの場合はエラーになるので、状態を確認してからトグルするパターンが安全です。
URLスキームによる外部連携
// Info.plist にURLスキームを登録後
// myapp://action?param=value の形式で受け取る
// AppDelegate.swift または Scene
func application(_ application: NSApplication,
open urls: [URL]) {
for url in urls {
handleURL(url)
}
}
func handleURL(_ url: URL) {
guard url.scheme == "myapp",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let action = url.host else { return }
switch action {
case "toggle":
AppStateStore.shared.appState.toggle()
case "message":
let msg = components.queryItems?.first(where: { $0.name == "text" })?.value
AppStateStore.shared.appState.message = msg ?? ""
default:
break
}
}URLスキームを実装しておくと、他のアプリ・スクリプト・ShortcutsからURLを叩いてアプリを操作できます。n8nやZapierとの連携も「URLを叩く」だけになります。
よくある落とし穴 — macOSサンドボックス・Notarization・配布の罠
落とし穴1: Sandbox制限とファイルアクセス
Mac App Store経由で配布する場合、アプリはSandbox化が必須です。Sandboxアプリはユーザーの選択なしにファイルにアクセスできません。回避策はNSOpenPanelでユーザーに明示的に選択させ、Security-Scoped Bookmarkを使って次回以降のアクセス権を保持することです。
// Security-Scoped Bookmarkで永続的なアクセス権を確保
func saveBookmark(for url: URL) throws -> Data {
return try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
}
func restoreAccess(from bookmarkData: Data) throws -> URL {
var isStale = false
let url = try URL(
resolvingBookmarkData: bookmarkData,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
if isStale {
// ユーザーに再選択を求める
throw AppError.bookmarkStale
}
url.startAccessingSecurityScopedResource()
return url
}Antigravityにこのパターンを「プロジェクトのファイルアクセス全体に適用して」と伝えると、FileManagerラッパークラスに統合したリファクタリングを提案してくれます。
落とし穴2: Notarizationの手順
Notarizationはコマンドラインで完結できます。XcodeでArchive後、以下のコマンドを順番に実行します。
# エクスポート(xcrunを使う場合)
xcodebuild -exportArchive \
-archivePath MyApp.xcarchive \
-exportPath ./export \
-exportOptionsPlist ExportOptions.plist
# Notarization送信
xcrun notarytool submit ./export/MyApp.zip \
--apple-id "YOUR_APPLE_ID" \
--team-id "YOUR_TEAM_ID" \
--password "YOUR_APP_SPECIFIC_PASSWORD" \
--wait
# Notarization完了後にStaple(証明書を埋め込む)
xcrun stapler staple ./export/MyApp.appExportOptions.plistのsigningStyleをmanualにするかautomaticにするかで挙動が変わります。チームアカウントがある場合はautomaticが楽です。--waitフラグを付けることでNotarizationの完了を待ってから次のコマンドに進めます(付けないと別途ステータス確認が必要になります)。
落とし穴3: ポップオーバーがフォーカスを受け取らない
NSPopoverのbehaviorを.transientにしているとき、テキストフィールドがフォーカスを受け取らないケースがあります。
// ポップオーバー表示後にフォーカスを強制的に付与
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.popover.contentViewController?.view.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}NSApp.activate(ignoringOtherApps: true)は強引に見えますが、メニューバーアプリでは必要な場面が多いです。Antigravityに「ポップオーバーのテキストフィールドに入力できない」と伝えると、このパターンを含む解決策を提案してくれます。
Universal Binaryの作成とリリース戦略
Apple Silicon + Intel 対応Universal Binaryの作り方
XcodeでArchiveするとき、デフォルトの設定でApple Silicon(arm64)とIntel(x86_64)の両方向けにビルドされます。確認するには.appを右クリック→「情報を見る」→アーキテクチャ欄で「Intel + Apple」と表示されていることを確認してください。
Swiftのコードレベルでは基本的にアーキテクチャ依存の書き方をしなければ自動的にユニバーサルになります。ただし、サードパーティのライブラリ(Swift Package Manager経由)が片方のアーキテクチャにしか対応していないケースがあります。
# ライブラリのアーキテクチャを確認
lipo -info /path/to/SomeLibrary.framework/SomeLibraryarm64 x86_64の両方が表示されればOKです。片方しかない場合は、そのライブラリがユニバーサルビルドをサポートしているバージョンに上げるか、代替を探す必要があります。
3つの配布戦略の比較
- Mac App Store: 発見されやすく、Appleの審査によるブランド信頼もあります。デメリットはSandbox必須・30%手数料・審査時間。汎用ツールや生産性アプリに向いています。
- Direct Sales(Gumroad等): 手数料が低く(10%以下)、審査不要で価格設定も自由です。Notarizationは必須ですが集客は自力で行う必要があります。ニッチツール・開発者向けアプリに最適です。
- Setapp: 毎月安定した収入が見込め、高品質なユーザーへの露出機会があります。審査があり1アプリあたりの収益は読みにくいですが、生産性・ユーティリティ系アプリに向いています。
私は最初にDirect Salesから始めて、ユーザーフィードバックを集めてからMac App Storeへ申請するアプローチが合っていると感じています。Direct Salesなら即日リリースできますし、Notarizationさえ済んでいれば技術的な障壁はありません。
Antigravity活用のまとめ — 次の一歩
macOSメニューバーアプリ開発でAntigravityが最も力を発揮するのは、「AppKitとSwiftUIの境界設計」です。どちらを使うべきか、どう橋渡しするか——このグレーゾーンがmacOS開発の難しさの大半を占めています。
今日の次のアクションとして、まずMenuBarExtraの.windowスタイルで最小構成のアプリを動かしてみてください。ContentViewにボタン1つ置いてクリックカウントを表示するだけで構いません。「動いた」という体験があれば、そこからAntigravityに「次に追加したい機能はXです」と伝えながら肉付けしていくのが、最も効率的な進め方です。
メニューバーアプリはiOSアプリと比べてユーザー数は少ないですが、離脱率が低く、コアユーザーがリピートしてくれる特性があります。個人開発者にとって、安定した収益の柱のひとつになり得るカテゴリです。SwiftUIの知識が活かせる分、iOSからの移行コストも意外と低いのが嬉しいポイントでもあります。
macOS開発に興味はあるけれど何から始めればいいかわからなかった方に、この記事が背中を押せれば幸いです。
macOSアプリ開発