From ccb10e21edce542bedad8f46add9a5820a749f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:33:15 +0000 Subject: [PATCH 1/2] fix(mac): show the menu bar item with an AppKit status item SwiftUI's MenuBarExtra scene kept the app's scene graph changing at launch, so the main window rebuilt its toolbar endlessly and the app hung. MenuBarController now owns an NSStatusItem with a popover that hosts the existing MenuBarContent; the label follows the calendar and the due cards through observation tracking. Refs #64 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- App/MenuBarController.swift | 124 ++++++++++++++++++++++ App/StudyAppApp.swift | 37 +------ Presentation/MenuBar/MenuBarContent.swift | 34 +++--- 3 files changed, 142 insertions(+), 53 deletions(-) create mode 100644 App/MenuBarController.swift diff --git a/App/MenuBarController.swift b/App/MenuBarController.swift new file mode 100644 index 0000000..83e689a --- /dev/null +++ b/App/MenuBarController.swift @@ -0,0 +1,124 @@ +import AppKit +import Observation +import SwiftUI + +/// The StudyApp item in the menu bar, built with AppKit (`NSStatusItem` and a popover). +/// +/// SwiftUI's `MenuBarExtra` scene kept the app's scene graph changing at launch, so the main +/// window rebuilt its toolbar endlessly and the app hung. A status item owned by the app avoids +/// the extra scene; the popover still shows the SwiftUI `MenuBarContent`. +@MainActor +final class MenuBarController: NSObject { + private let calendar: StudyCalendarViewModel + private let today: TodayViewModel + private let navigation: AppNavigation + private let defaults: UserDefaults + private var statusItem: NSStatusItem? + private var defaultsObserver: NSObjectProtocol? + private lazy var popover: NSPopover = makePopover() + + init( + calendar: StudyCalendarViewModel, + today: TodayViewModel, + navigation: AppNavigation, + defaults: UserDefaults = .standard + ) { + self.calendar = calendar + self.today = today + self.navigation = navigation + self.defaults = defaults + } + + /// Shows the item when the setting is on and follows later changes of the setting. + func install() { + guard defaultsObserver == nil else { return } + defaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, object: defaults, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.applyVisibility() } + } + applyVisibility() + } + + // MARK: Visibility + + private var isEnabled: Bool { + defaults.object(forKey: ViewPreferences.showsMenuBarItemKey) as? Bool ?? true + } + + private func applyVisibility() { + if isEnabled, statusItem == nil { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.target = self + item.button?.action = #selector(togglePopover(_:)) + item.button?.imagePosition = .imageLeading + statusItem = item + observeSummary() + } else if !isEnabled, let item = statusItem { + popover.performClose(nil) + NSStatusBar.system.removeStatusItem(item) + statusItem = nil + } + } + + // MARK: Label + + /// Updates the label now and again whenever the lecture or the due cards change. + private func observeSummary() { + guard statusItem != nil else { return } + let summary = withObservationTracking { + MenuBarSummary(calendar: calendar, dueCards: today.totalDue) + } onChange: { [weak self] in + Task { @MainActor in self?.observeSummary() } + } + updateLabel(summary) + } + + private func updateLabel(_ summary: MenuBarSummary) { + guard let button = statusItem?.button else { return } + let symbol = summary.title.isEmpty ? "graduationcap" : "record.circle" + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: "StudyApp") + button.title = summary.title.isEmpty ? "" : " \(summary.title)" + } + + // MARK: Popover + + @objc private func togglePopover(_ sender: NSStatusBarButton) { + if popover.isShown { + popover.performClose(sender) + } else { + popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .transient + popover.contentViewController = NSHostingController(rootView: MenuBarPopoverView( + calendar: calendar, + today: today, + onOpenLecture: { [weak self] moduleID in + self?.openMainWindow() + self?.navigation.show(module: moduleID, section: .slides, intent: .resumeSlides) + }, + onReviewCards: { [weak self] in + guard let self else { return } + openMainWindow() + // Starts with the module that has the most due cards, like „Lernen starten“ on Today. + if let moduleID = today.dueCounts.max(by: { $0.value < $1.value })?.key { + navigation.show(module: moduleID, section: .flashcards) + } + }, + onOpenApp: { [weak self] in self?.openMainWindow() } + )) + return popover + } + + /// Closes the popover and brings the main window to the front. + private func openMainWindow() { + popover.performClose(nil) + NSApplication.shared.activate() + NSApplication.shared.windows.first { $0.canBecomeMain }?.makeKeyAndOrderFront(nil) + } +} diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index 4416918..5cba309 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -8,6 +8,7 @@ struct StudyAppApp: App { private let autopilot: LectureAutopilot /// Installed in `init` so a click that launches the app is not lost. private let notificationResponder: NotificationResponder + private let menuBar: MenuBarController @State private var modulesViewModel: ModulesViewModel @State private var calendarViewModel: StudyCalendarViewModel @State private var todayViewModel: TodayViewModel @@ -22,7 +23,6 @@ struct StudyAppApp: App { @State private var mensaViewModel: MensaViewModel @State private var updater = AppUpdater() @AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard - @AppStorage(ViewPreferences.showsMenuBarItemKey) private var showsMenuBarItem = true init() { do { @@ -33,6 +33,7 @@ struct StudyAppApp: App { let assignments = container.makeAssignmentsViewModel() let exams = container.makeExamsViewModel() let navigation = AppNavigation() + menuBar = MenuBarController(calendar: calendar, today: today, navigation: navigation) let studIP = container.makeStudIPViewModel() let announcements = container.makeAnnouncementsViewModel() studIP.onAnnouncementsSynced = { announcements.load() } @@ -155,33 +156,6 @@ struct StudyAppApp: App { BackupCommands(viewModel: backupViewModel) } - MenuBarExtra(isInserted: $showsMenuBarItem) { - MenuBarSummaryReader(calendar: calendarViewModel, today: todayViewModel) { summary in - MenuBarContent( - summary: summary, - onOpenLecture: { moduleID in - Self.bringToFront() - navigation.show(module: moduleID, section: .slides, intent: .resumeSlides) - }, - onReviewCards: { - Self.bringToFront() - // Starts with the module that has the most due cards, like „Lernen starten“ on Today. - if let moduleID = todayViewModel.dueCounts.max(by: { $0.value < $1.value })?.key { - navigation.show(module: moduleID, section: .flashcards) - } - }, - onOpenApp: { Self.bringToFront() } - ) - .tint(Theme.accent) - .preferredColorScheme(appearance.colorScheme) - } - } label: { - MenuBarSummaryReader(calendar: calendarViewModel, today: todayViewModel) { summary in - MenuBarLabel(summary: summary) - } - } - .menuBarExtraStyle(.window) - Settings { SettingsView( settings: container.preferences, @@ -210,6 +184,7 @@ struct StudyAppApp: App { if navigation.destination == nil { navigation.showToday() } + menuBar.install() autopilot.run(isLaunch: true) // Abonnements im Hintergrund auffrischen; eine dabei neu erkannte Vorlesung wird ebenfalls geöffnet. @@ -223,12 +198,6 @@ struct StudyAppApp: App { await notificationsViewModel.refresh() } - /// Shows the main window, e.g. after a click in the menu bar. - private static func bringToFront() { - NSApplication.shared.activate() - NSApplication.shared.windows.first { $0.canBecomeMain }?.makeKeyAndOrderFront(nil) - } - /// Keeps Spotlight in line with notes and study sheets; failures only cost search results. private func reindexSearch() async { try? await container.makeReindexSearch()() diff --git a/Presentation/MenuBar/MenuBarContent.swift b/Presentation/MenuBar/MenuBarContent.swift index 6896019..6a3982f 100644 --- a/Presentation/MenuBar/MenuBarContent.swift +++ b/Presentation/MenuBar/MenuBarContent.swift @@ -1,29 +1,25 @@ import AppKit import SwiftUI -/// Builds the summary inside a view, so only the menu bar item observes the calendar and the due cards. -/// Reading them in the App body would rebuild every scene on each change, already while the app launches. -struct MenuBarSummaryReader: View { +/// Content of the menu bar popover: builds the summary inside the view, so it follows the calendar +/// and the due cards, and applies the chosen appearance. +struct MenuBarPopoverView: View { let calendar: StudyCalendarViewModel let today: TodayViewModel - @ViewBuilder let content: (MenuBarSummary) -> Content - - var body: some View { - content(MenuBarSummary(calendar: calendar, dueCards: today.totalDue)) - } -} - -/// The menu bar item's label: an icon, plus the remaining minutes while a lecture runs. -struct MenuBarLabel: View { - let summary: MenuBarSummary + let onOpenLecture: (UUID) -> Void + let onReviewCards: () -> Void + let onOpenApp: () -> Void + @AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard var body: some View { - if summary.title.isEmpty { - Image(systemName: "graduationcap") - } else { - Label(summary.title, systemImage: "record.circle") - .labelStyle(.titleAndIcon) - } + MenuBarContent( + summary: MenuBarSummary(calendar: calendar, dueCards: today.totalDue), + onOpenLecture: onOpenLecture, + onReviewCards: onReviewCards, + onOpenApp: onOpenApp + ) + .tint(Theme.accent) + .preferredColorScheme(appearance.colorScheme) } } From fb9e5efe4175c85e7852569f97c7797d23ad3376 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:33:15 +0000 Subject: [PATCH 2/2] docs: describe the AppKit menu bar item Closes #64 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- docs/ARCHITECTURE.md | 2 +- docs/FEATURES.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0ce1b73..d561a86 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,7 +34,7 @@ Dependencies point **inward**: Presentation and Data both depend on Domain, Doma `AppContainer(inMemory: true)` swaps in in-memory persistence and preferences for previews and tests. -`AppLauncher` is the entry point: under XCTest it starts an empty host app instead of `StudyAppApp`, so launch work (database, sync, menu bar item) never runs during unit tests. On CI runners the menu bar scene kept the test host from finishing its launch. +`AppLauncher` is the entry point: under XCTest it starts an empty host app instead of `StudyAppApp`, so launch work (database, sync, menu bar item) never runs during unit tests. This keeps the unit tests independent of the window, the menu bar item and the network. ## Data flow example: calendar subscription → module diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e411c4b..902d000 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -84,7 +84,9 @@ Code: `Presentation/StudIP/`, `Domain/UseCases/StudIPUseCases.swift`, `Data/Stud The menu bar item shows the remaining minutes while a lecture runs. Its window lists the running or next lecture (with „Folien öffnen“) and the due flashcards, and opens the main window. It can be hidden in Settings → Allgemein. -Code: `Presentation/MenuBar/` +The item is an AppKit `NSStatusItem` with a popover (`App/MenuBarController.swift`), not a SwiftUI `MenuBarExtra` scene: that scene kept the scene graph changing at launch and the app hung. + +Code: `App/MenuBarController.swift`, `Presentation/MenuBar/` ## Shortcuts and Siri