Mini Timer Window & Tray Icon

Mac Catalyst only. Plan and decision record: 916-plan-pip-mini-timer.

A compact always-on-top companion window showing the running timers, plus a menu bar (tray) icon with a play symbol. Both are triggers for the same window.

Mini timer window

A second UIWindowScene backed by WindowGroup(id: "miniTimers") (MinutaApp.swift), showing MiniTimerView — one compact row per running timer (tag color dot, tag name, ticking elapsed, a drop button that discards the timer, and a stop button that saves it), plus a play button that starts an untagged timer. Tapping a row opens the full RecordEditor in place (narrow, iPhone-like layout) with a Back button; a “close pip” button (pip.exit) in the header closes the window — there are no traffic lights. The scene shares AppState with the main window, so timers started/stopped anywhere appear everywhere.

Opening and routing

MiniWindowManager.toggle():

  • open: requestSceneSessionActivation with an NSUserActivity of type tools.minuta.app.miniTimers; the mini WindowGroup’s handlesExternalEvents(matching:) routes the new scene to it
  • close: requestSceneSessionDestruction of the mini scene’s session

SceneDelegate.willConnectTo detects the mini scene by the activity (first open) or by the minutaSceneRole stamp in session.userInfo (state restoration) and skips all main-window setup (toolbar, test-window sizing). Requires UIApplicationSupportsMultipleScenes in Info.plist.

Window configuration and sizing

Sizing is fully declarative: the mini WindowGroup uses .windowResizability(.contentSize) and MiniTimerView declares a fixed 360pt width with its natural height, so the window always fits the content (empty state, N rows, editor). Never resize this window imperatively — mutating sizeRestrictions or the AppKit frame on the visible window leaves newly inserted SwiftUI content permanently blank on macOS 15 Catalyst (hittable but unpainted; cost a long debugging session).

Two rendering quirks and their fixes, both in place:

  • The window frame can lag a beat behind a content shrink, and the hosting view bottom-aligns its ideal-height content in the taller frame — a white strip appears at the top. Fix in MiniTimerView: a trailing Spacer(minLength: 0) in the root VStack (contributes nothing to the ideal height, absorbs the slack so the gray background covers the whole frame).
  • The titlebar strip is a separate AppKit layer with a white backdrop. Fix in MiniWindowManager.configureNSWindow: titlebarAppearsTransparent + fullSizeContentView style bit + the NSWindow’s own backgroundColor set to the content gray (systemGroupedBackground resolved and rebuilt as NSColor through the class-method IMP), plus separatorStyle = .none on the UIKit titlebar.

AppKit side otherwise (via NSWindowBridge, the same _hostWindowForUIWindow: KVC bridge the pin button uses): floating level, canJoinAllSpaces, resizable style bit stripped, all three traffic lights hidden.

The window always floats — independent of the main window’s pin state.

Origin persistence

Manually persisted origin (miniWindowOrigin UserDefault, AppKit screen coordinates) — never AppKit frame autosave, which the main window already owns. Saved on scene resign-active/disconnect/app termination; restored on connect, clamped to the screen’s visible frame. When opened from the tray icon the window is instead placed under the status item once. Both save and restore are disabled in UI-test runs (tests share the real app’s UserDefaults).

Multi-scene side effects

  • TimerCommands uses CommandGroup(replacing: .newItem): with multiple scenes enabled SwiftUI contributes its own “New Window” Cmd+N, which collides with Start Timer’s Cmd+N and crashes menu building at launch (NSInternalInconsistencyException). Replacing the group drops New Window.
  • WindowPinManager resolves the main scene by role (UIApplication.mainWindowScene), never connectedScenes.first — with two scenes connected, .first on the unordered set could hand the pin the wrong window.
  • In test mode a restored mini scene is destroyed on connect so a previous run’s window cannot leak into the next test; a restored second main scene is destroyed too (sessions persist across test launches and duplicate main windows make single-element queries ambiguous).

Tray icon

NSStatusBar is AppKit-only, out of a Catalyst app’s reach, so the icon lives in MinutaTray.bundle — a small AppKit plugin (Minuta/Tray/, own XcodeGen target, embedded under Contents/PlugIns/, Catalyst builds only via platformFilter). The app loads it at runtime through @objc protocols in TrayBridge.swift, the only file both targets compile; nothing else crosses the boundary.

  • Icon: SF Symbol play (outline) when idle, play.fill while any timer runs; template image, so it adapts to menu bar appearance.
  • Left click: summon/hide the mini window, positioned under the icon on first open.
  • Right click: menu with Start Timer, Stop All Timers (disabled when idle), Open Minuta.
  • The plugin holds no timer data and no logic — TrayIconManager (app side) loads the bundle, observes appState.runningTimers via withObservationTracking, pushes a running-or-not boolean, and implements the delegate actions against AppState and MiniWindowManager.

Settings > Menu bar > “Show menu bar icon” (default on) installs/removes the status item live. The toggle binds through @AppStorage — a manual Binding into the non-observable manager would not re-render the control when clicked.

Testing

  • MiniTimerTests (UI, Catalyst-only): open/close via pip button, start/stop from the mini window with cross-window assertions, main-window timer appearing in the mini window, Settings toggle round-trip.
  • TrayIconManagerTests (unit, Catalyst-only): real plugin-bundle load path (the test host is the app), enabled-flag default and persistence.
  • The status item itself is unreachable from XCUITest (system menu bar) — click behavior is verified manually.