App nativa de macOS en la barra de menús con SwiftUI#
Mientras construía Funny Day Calendar — una app de macOS sobre días festivos curiosos y poco comunes — me di cuenta muy rápido de que "solo una ventana con un calendario" no funciona como producto. El festivo del día es algo que quieres ver de un vistazo, no algo por lo que abres una app entera. Eso significa que pertenece a la barra de menús.
Así que el proyecto terminó con tres superficies para el mismo contenido: una ventana completa con el calendario y los detalles del festivo, un icono en la barra de menús (MenuBarExtra) con el festivo de hoy, y un widget de escritorio. Esto es lo que aprendí construyendo todo esto en SwiftUI y publicándolo en la Mac App Store (id 6773287898).
Por qué molestarse con una app de barra de menús#
La barra de menús de macOS no es "una ventana pequeña" — es un modo distinto de existir como app. El usuario no te abre desde el Dock ni cambia a ti con Cmd+Tab; nota el icono con el rabillo del ojo. Eso cambia qué contenido pertenece ahí: exactamente la información que cabe en un solo vistazo, y ni un byte más.
Para Funny Day Calendar eso significó: el icono de la barra de menús muestra el festivo del día en una sola línea, mientras que todo lo demás — historial de festivos, búsqueda, ajustes de temas y fondos — se queda en la ventana principal. Esa separación resultó importar más que cualquier detalle de implementación, pero la implementación tampoco es trivial, así que empecemos por ahí.
MenuBarExtra: del icono al contenido#
Antes de SwiftUI, la única forma de poner un icono en la barra de menús era NSStatusItem de AppKit — funcional, pero verboso, con gestión manual de NSPopover o NSMenu. Desde macOS 13, Apple añadió un tipo de escena declarativo, MenuBarExtra, que elimina la mayor parte de ese trabajo repetitivo.
Una implementación básica cabe en pocas líneas:
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)
}
}El punto clave: MenuBarExtra se puede declarar justo al lado de un WindowGroup normal dentro de la misma App. Son dos tipos de escena independientes que comparten un EnvironmentObject, así que el festivo del día, cargado una sola vez en HolidayStore, está disponible de forma sincronizada tanto en la ventana principal como en la barra de menús — sin duplicar la lógica de carga.
Para la etiqueta de la barra de menús conviene evitar cadenas de texto largas — macOS trunca un elemento de la barra de menús cuando falta espacio, y una combinación de un SF Symbol con un texto corto queda mucho más limpia:
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 vs .menu: cuál elegir#
MenuBarExtra tiene dos estilos de contenido, y la diferencia entre ellos es arquitectónica, no cosmética.
| Criterio | .menu (por defecto) | .window |
|---|---|---|
| Qué renderiza | Un NSMenu real con elementos de menú | Contenido SwiftUI arbitrario en una ventana emergente |
| Interactividad | Solo Button, Toggle, Divider, submenús | Cualquier view: List, sliders, imágenes, layout personalizado |
| Bloquea el run loop | Sí, mientras el menú está abierto — animaciones y temporizadores de la app se pausan | No, la ventana se comporta como una escena SwiftUI normal |
| Auto-ocultado de la barra de menús | Funciona con normalidad | Bugs conocidos con la barra de menús reapareciendo con el popup abierto sobre apps en pantalla completa |
| Cuándo usarlo | Comandos simples: "Actualizar", "Ajustes", "Salir" | Vistas previas ricas: un calendario, una tarjeta de festivo, temas |
Para Funny Day Calendar la elección fue obvia: el festivo del día no es un comando, es una tarjeta con una ilustración y una descripción, así que .window encajaba mejor. El coste: hay que diseñar tú mismo el cierre al hacer clic fuera de la ventana y vigilar el auto-ocultado de la barra de menús en modo pantalla completa — es un tema abierto en los foros de Apple Developer, y en 2026 no hay una solución limpia a nivel de sistema; hay que gestionarlo manualmente con delegados de NSWindow si el comportamiento es crítico para la UX.
Settings y el icono del Dock: detalles del ciclo de vida#
En cuanto tu app tiene un MenuBarExtra, surge la pregunta: ¿debería la app aparecer siquiera en el Dock? Para Funny Day Calendar la respuesta es "normalmente sí", porque no es una utilidad puramente en segundo plano — tiene una ventana real. Pero los usuarios que prefieren el minimalismo esperan poder ocultar el icono del Dock y vivir solo con la barra de menús.
Técnicamente esto se resuelve con NSApplication.ActivationPolicy:
import AppKit
enum DockVisibility {
static func setHidden(_ hidden: Bool) {
NSApp.setActivationPolicy(hidden ? .accessory : .regular)
}
}.accessory elimina el icono del Dock y la entrada del selector de apps, igual que la clave LSUIElement en Info.plist, pero lo hace de forma programática — así que un interruptor "mostrar en el Dock" puede vivir en la UI de tu app en lugar de fijarse para siempre en tiempo de compilación.
La escena Settings es otro punto de fricción. SwiftUI ofrece una API declarativa:
Settings {
SettingsView()
.environmentObject(holidayStore)
}y un botón del sistema, SettingsLink, para abrirla. En la práctica, SettingsLink invocado desde una ventana de MenuBarExtra mientras la app corre como .accessory no siempre trae la ventana de ajustes al frente — la barra de menús se queda activa, y la ventana de ajustes puede abrirse detrás de otras apps. Una solución que funciona es activar explícitamente la app antes de abrir los ajustes:
Button("Ajustes…") {
NSApp.activate(ignoringOtherApps: true)
NSApp.sendAction(
Selector(("showSettingsWindow:")),
to: nil,
from: nil
)
}No es el código más elegante que he escrito, pero se ha mantenido estable en todas las versiones de macOS en las que probé Funny Day Calendar.
Un widget de escritorio: WidgetKit sin una segunda app#
Desde macOS Sonoma, los widgets de WidgetKit pueden vivir directamente en el escritorio, no solo en el Centro de notificaciones, y macOS Tahoe les dio un fondo Liquid Glass que se adapta al fondo de pantalla. Para Funny Day Calendar eso significó reutilizar el mismo widget target que en iOS: el mismo WidgetKind, TimelineProvider y layout SwiftUI para la tarjeta del festivo — solo con disponibilidad en escritorio añadida encima.
El verdadero reto de ingeniería no es el renderizado — es la entrega de datos. Una extensión de widget es un proceso separado, sin acceso al HolidayStore de la app principal. Los datos cruzan ese límite a través de un 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)
// Actualiza en la próxima medianoche — el festivo del día cambia una vez al día
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)
}
}Cuando la app principal actualiza los datos — por ejemplo, tras un cambio de tema o fondo en los ajustes — tiene que pedirle explícitamente al sistema que reconstruya la timeline:
import WidgetKit
WidgetCenter.shared.reloadAllTimelines()Sin esa llamada, el widget sigue mostrando una instantánea desactualizada hasta su próxima actualización programada — el presupuesto de actualizaciones de WidgetKit es intencionalmente limitado para ahorrar batería, así que no puedes confiar en que "se actualizará solo".
App Sandbox y el camino hacia la Mac App Store#
Vale la pena aclarar una confusión frecuente: la notarización (notarytool) es un proceso para apps distribuidas fuera de la Mac App Store, vía Developer ID. Una app publicada en la Mac App Store no pasa por la notarización como paso independiente — en su lugar, obligatoriamente pasa por App Sandbox y por App Review.
App Sandbox resultó ser bastante permisivo para Funny Day Calendar: la app no necesita acceso a red (los datos de festivos son locales), así que los entitlements necesarios fueron mínimos:
<?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>La entrada de App Group aquí es esencial — es el canal por el que la app principal y la extensión de widget intercambian datos dentro del sandbox, donde el acceso directo a los archivos del otro está bloqueado.
Algo que merece la pena revisar dos veces: tanto el target principal como la extensión de widget deben pertenecer al mismo App Group y al mismo Team ID — de lo contrario UserDefaults(suiteName:) devuelve nil en silencio, y el widget queda en blanco sin una sola línea en la consola que explique por qué.
App Review para una app de barra de menús: lo que realmente importó#
Desde el punto de vista del App Review de la Mac App Store, una app con MenuBarExtra no es ninguna categoría especial, pero un par de matices aparecieron precisamente porque parte de la funcionalidad vive fuera de la ventana principal.
- Funcionalidad mínima (Guideline 4.2). Si Funny Day Calendar consistiera solo en un icono de barra de menús sin una ventana principal sustancial, eso parecería un candidato de manual para el rechazo — "funcionalidad insuficiente para justificar una app independiente". Una ventana completa con un calendario, historial de festivos y ajustes no es solo una decisión de UX — es un seguro frente a posibles objeciones del review.
- Las capturas de pantalla siguen siendo sobre la ventana principal. Las capturas de marketing para la ficha de la Mac App Store deben mostrar la experiencia principal en los tamaños de pantalla requeridos por el sistema — el icono de la barra de menús por sí solo no sustituye a capturas reales de la interfaz.
- Las notas para el revisor nunca están de más. Para la funcionalidad que no aparece en la ventana principal (el icono de la barra de menús, el widget de escritorio), conviene describirla explícitamente en las notas para el revisor, para que el experto de Apple no tenga que buscar algo que no es obvio al primer arranque.
Ninguno de estos detalles es un truco para saltarse las reglas — es más bien cuidar que el revisor vea exactamente la misma imagen del producto que ve un usuario normal.
Conclusión#
MenuBarExtra en SwiftUI elimina la mayor parte del trabajo repetitivo que antes exigía trabajo manual con AppKit, pero no elimina las decisiones de arquitectura: qué estilo elegir, cómo sincronizar los datos entre la ventana principal, la barra de menús y el widget, cómo gestionar el icono del Dock y los ajustes. Para Funny Day Calendar, esta combinación — ventana, barra de menús, widget — no terminó siendo una lista de funciones por cumplir, sino tres formas distintas de mostrar el mismo hecho simple: qué festivo es hoy.
Si quieres ver cómo quedó, la app se llama Funny Day Calendar, y está disponible en la Mac App Store.



