Нативное macOS-приложение со строкой меню на SwiftUI#
Когда я делал Funny Day Calendar — macOS-приложение о необычных праздниках — у меня довольно быстро сложилось ощущение, что «просто окно с календарём» не работает как продукт. Праздник дня — это то, что хочется увидеть мельком, не открывая приложение целиком. Значит, ему место в строке меню.
Так в проекте появились сразу три поверхности одного и того же контента: полноценное окно с календарём и подробностями праздника, иконка в строке меню (MenuBarExtra) с праздником дня и виджет рабочего стола. Ниже — то, что я узнал, пока собирал это на SwiftUI и доводил до публикации в Mac App Store (id 6773287898).
Зачем вообще ещё одно приложение в строке меню#
Строка меню на macOS — это не «маленькое окно», а отдельный режим существования приложения. Пользователь не запускает вас из Dock и не переключается на вас через Cmd+Tab — он замечает иконку боковым зрением. Это меняет требования к контенту: там должно быть ровно столько информации, сколько помещается в одном взгляде, и ни байтом больше.
Для Funny Day Calendar это означало: иконка в строке меню показывает праздник дня одной строкой, а всё остальное — история праздников, поиск, настройки тем и фонов — остаётся в главном окне. Это разделение оказалось важнее, чем любые технические детали реализации, но и реализация не тривиальна, так что начнём с неё.
MenuBarExtra: от иконки до содержимого#
До SwiftUI единственным способом сделать иконку в строке меню был NSStatusItem из AppKit — рабочий, но многословный подход с ручным управлением NSPopover или NSMenu. С macOS 13 Apple добавила декларативный сценический тип MenuBarExtra, который снимает большую часть этой рутины.
Базовая реализация укладывается в несколько строк:
import SwiftUI
@main
struct FunnyDayCalendarApp: App {
@StateObject private var holidayStore = HolidayStore()
var body: some Scene {
WindowGroup {
CalendarWindowView()
.environmentObject(holidayStore)
}
MenuBarExtra {
MenuBarContentView()
.environmentObject(holidayStore)
} label: {
MenuBarLabelView(holiday: holidayStore.todayHoliday)
}
.menuBarExtraStyle(.window)
}
}Ключевой момент — MenuBarExtra можно объявить рядом с обычной WindowGroup в одном и том же App. Это два независимых сценических типа, которые делят один EnvironmentObject, поэтому праздник дня, загруженный один раз в HolidayStore, синхронно доступен и в главном окне, и в строке меню — без дублирования логики загрузки данных.
Для лейбла в строке меню стоит избегать длинных строк текста — macOS обрезает пункт строки меню при нехватке места, и опрятнее выглядит комбинация SF Symbol и короткого текста:
struct MenuBarLabelView: View {
let holiday: Holiday?
var body: some View {
if let holiday {
Label(holiday.shortTitle, systemImage: "calendar.badge.clock")
} else {
Image(systemName: "calendar")
}
}
}.window против .menu: что выбрать#
У MenuBarExtra два стиля отображения содержимого, и разница между ними — не косметическая, а архитектурная.
| Критерий | .menu (по умолчанию) | .window |
|---|---|---|
| Что рендерит | Настоящее NSMenu, пункты меню | Произвольный SwiftUI-контент в окне-попапе |
| Интерактивность | Только Button, Toggle, Divider, вложенные меню | Любые view: List, слайдеры, изображения, кастомная разметка |
| Блокировка runloop | Да, пока меню открыто — анимации и таймеры внутри приложения приостанавливаются | Нет, окно ведёт себя как обычная SwiftUI-сцена |
| Автоскрытие панели меню (auto-hide menu bar) | Работает штатно | Есть известные баги с показом строки меню при открытом окне поверх полноэкранных приложений |
| Когда уместен | Простые команды: «Обновить», «Настройки», «Выход» | Богатый предпросмотр: календарь, карточка праздника, темы |
Для Funny Day Calendar выбор был очевиден: праздник дня — это не команда, а мини-карточка с иллюстрацией и описанием, так что .window подошёл лучше. Плата за это — нужно самому проектировать закрытие окна по клику вне его области и следить за автоскрытием системной строки меню в полноэкранном режиме: это открытая тема на форумах Apple Developer, и однозначного системного решения на 2026 год нет, приходится обрабатывать вручную через NSWindow-делегаты, если поведение критично для UX.
Настройки и иконка в Dock: тонкости жизненного цикла#
Как только в приложении появляется MenuBarExtra, встаёт вопрос: приложение вообще должно быть в Dock? Для Funny Day Calendar ответ — «да, обычно», потому что это не чисто утилитарный фоновый процесс, а приложение с полноценным окном. Но пользователи, которые предпочитают минимализм, ожидают возможность спрятать иконку из Dock и жить только со строкой меню.
Технически это решается через NSApplication.ActivationPolicy:
import AppKit
enum DockVisibility {
static func setHidden(_ hidden: Bool) {
NSApp.setActivationPolicy(hidden ? .accessory : .regular)
}
}.accessory убирает иконку из Dock и переключателя приложений так же, как ключ LSUIElement в Info.plist, но делает это программно — то есть настройку «показывать в Dock» можно вынести в UI приложения, а не фиксировать раз и навсегда на этапе сборки.
Отдельная боль — сцена Settings. SwiftUI предлагает декларативный API:
Settings {
SettingsView()
.environmentObject(holidayStore)
}и системную кнопку SettingsLink для его открытия. На практике SettingsLink, вызванный из окна MenuBarExtra в режиме .accessory, не всегда переводит окно настроек на передний план — активной остаётся строка меню, а окно настроек может открыться позади других приложений. Рабочий обходной путь — принудительно активировать приложение перед открытием настроек:
Button("Настройки…") {
NSApp.activate(ignoringOtherApps: true)
NSApp.sendAction(
Selector(("showSettingsWindow:")),
to: nil,
from: nil
)
}Это не самый элегантный код, который я писал, но он стабильно работает во всех версиях macOS, где я тестировал Funny Day Calendar.
Виджет рабочего стола: WidgetKit без второго приложения#
С macOS Sonoma виджеты WidgetKit можно размещать прямо на рабочем столе, а не только в Notification Center, а macOS Tahoe добавил им стеклянный (Liquid Glass) фон, который подстраивается под обои. Для Funny Day Calendar это означало ровно тот же виджет-таргет, что и на iOS: один и тот же WidgetKind, TimelineProvider и SwiftUI-разметка карточки праздника — просто с доступностью на десктопе.
Главная инженерная задача — не рендеринг, а доставка данных. Widget-экстеншен — отдельный процесс, у него нет доступа к HolidayStore основного приложения. Данные передаются через App Group:
struct HolidayProvider: TimelineProvider {
func getTimeline(
in context: Context,
completion: @escaping (Timeline<HolidayEntry>) -> Void
) {
let holiday = SharedHolidayStore.shared.todayHoliday()
let entry = HolidayEntry(date: .now, holiday: holiday)
// Обновляем на следующую полночь — праздник дня меняется раз в сутки
let midnight = Calendar.current.startOfDay(
for: .now.addingTimeInterval(86_400)
)
completion(Timeline(entries: [entry], policy: .after(midnight)))
}
func placeholder(in context: Context) -> HolidayEntry { .placeholder }
func getSnapshot(
in context: Context,
completion: @escaping (HolidayEntry) -> Void
) {
completion(.placeholder)
}
}Когда основное приложение обновляет данные (например, после смены темы или фона в настройках), оно обязано явно попросить систему перестроить таймлайн:
import WidgetKit
WidgetCenter.shared.reloadAllTimelines()Без этого вызова виджет продолжит показывать устаревший снимок до следующего планового обновления — а бюджет обновлений у WidgetKit ограничен намеренно, ради экономии заряда, так что полагаться на «само обновится» нельзя.
App Sandbox и путь до Mac App Store#
Тут стоит развеять одну частую путаницу: нотаризация (notarytool) — это процедура для приложений, которые распространяются вне Mac App Store, через Developer ID. Приложение, которое идёт в Mac App Store, нотаризацию как отдельный шаг не проходит — вместо этого оно обязательно попадает под App Sandbox и проходит App Review.
App Sandbox для Funny Day Calendar оказался довольно щадящим: приложению не нужен сетевой доступ (данные о праздниках — локальные), поэтому в entitlements хватило самого базового набора:
<?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.application-groups</key>
<array>
<string>group.pro.dodecaidr.funnydaycalendar</string>
</array>
</dict>
</plist>Группа приложений (application-groups) здесь обязательна — это тот самый канал, через который основное приложение и виджет-расширение обмениваются данными в песочнице, где прямой доступ к файлам друг друга закрыт.
Отдельно стоит проверить: и главный таргет, и виджет-экстеншен должны входить в один App Group и использовать один Team ID — иначе UserDefaults(suiteName:) тихо вернёт nil, и виджет останется пустым без единой строчки в консоли, объясняющей почему.
App Review для приложения со строкой меню: что оказалось важным#
С точки зрения ревью Mac App Store, MenuBarExtra-приложение — не какая-то отдельная категория, но пара нюансов проявилась именно потому, что часть функциональности живёт вне главного окна.
- Минимальная функциональность (Guideline 4.2). Если бы Funny Day Calendar состоял из одной иконки в строке меню без содержательного главного окна, это выглядело бы как классическая заявка на отклонение — «недостаточно функциональности для отдельного приложения». Полноценное окно с календарём, историей праздников и настройками — не только UX-решение, но и подстраховка на ревью.
- Скриншоты остаются про главное окно. Маркетинговые скриншоты для карточки в Mac App Store должны показывать основной пользовательский опыт в требуемых системой разрешениях экрана — строка меню на них не заменяет полноценные кадры интерфейса.
- Notes for Review не бывают лишними. Для функциональности, которая проявляется не в главном окне (иконка в строке меню, виджет рабочего стола), полезно явно описать её в заметках для ревьюера — так эксперт Apple не тратит время на поиск того, что не сразу очевидно при первом запуске.
Ни одна из этих деталей не является хаком в обход правил — это, скорее, забота о том, чтобы ревьюер увидел ровно ту же картину продукта, что видит обычный пользователь.
Итог#
MenuBarExtra в SwiftUI закрывает большую часть рутины, которая раньше требовала ручной работы с AppKit, но не отменяет архитектурных решений: какой стиль выбрать, как синхронизировать данные между главным окном, строкой меню и виджетом, как вести себя с Dock-иконкой и настройками. Для Funny Day Calendar эта комбинация — окно, строка меню, виджет — оказалась не набором фич для галочки, а тремя разными способами показать один и тот же простой факт: какой сегодня праздник.
Если интересно посмотреть, как это выглядит в готовом виде — приложение называется Funny Day Calendar, и оно доступно в Mac App Store.



