Files
ProdDash/viewer/macos/Sources/ViewerWindowController.swift
T
Waters ChurchandClaude Opus 5.5 8d8c0ee0c3 ProdDash Viewer 1.0.0: a Mac app that only shows ProdDash, with a tab per installation
A new Apple-silicon app in viewer/macos (Swift, no dependencies, ~1 MB): one
window showing a ProdDash dashboard in WebKit, and nothing else. It runs no
server; it points at any ProdDash on the network.

- Connections (name + address) in Settings; the add/edit sheet asks
  /api/modules whether ProdDash answers as you type. No port means 24500.
- Two or more connections bring a slim tab bar of their names. In a window
  it floats over the dashboard and comes out while the pointer is on the
  title bar; it is not part of the title bar, because anything there makes
  WebKit push the page down. In full screen it is a titlebar accessory that
  comes down with the menu bar, and it flashes up after each switch.
  ⌃Tab / ⌃⇧Tab and ⌘1–9 switch from the keyboard.
- Full screen, reopening in full screen if it quit there; window size and
  the selected tab are kept too. The dashboard's own full-screen button
  works as well.
- Menus: View (Back, Forward, Reload Page, Enter Full Screen), Settings,
  Edit (copy/paste in the page's text fields), Window, Help (a guide that
  ships in the app).
- Every installation keeps its own live web view. One that can't be
  reached says why and retries at 2, 4, 8, then every 15 s; a redirect off
  the installation is reported, not handed to the browser. JS alert,
  confirm and prompt show as sheets; links to other sites open in the
  browser.
- General settings: open at login (SMAppService), keep the display awake
  while a dashboard is on screen (default on).

build.sh signs with the Developer ID when one is installed and makes a
notarised DMG with --notarize, as the launcher's does. It needs Xcode: with
only the command line tools the macOS 27 SDK can't expand SwiftUI's @State.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:05:14 -04:00

545 lines
22 KiB
Swift

import AppKit
import Combine
import SwiftUI
/**
The viewer's one window: the selected installation's dashboard and — when
there is more than one — a slim tab bar of their names.
In a window the bar floats over the top of the dashboard, just under the
title bar: it comes out while the pointer is on the title bar and tucks away
after. It is deliberately not part of the title bar — anything there makes
WebKit push the whole page down to clear it, and a dashboard jumping every
time the bar came and went would be worse than no bar at all.
In full screen the bar *is* part of the title bar (a titlebar accessory), so
it comes down with the menu bar like any Mac app's toolbar; there the title
bar lives in a window of its own and the page stays put.
*/
final class ViewerWindowController: NSWindowController, NSWindowDelegate, NSMenuItemValidation {
static let tabBarHeight: CGFloat = 32
/// The standard title bar's height: where the dashboard starts.
private static let titlebarHeight: CGFloat = {
let probe = NSRect(x: 0, y: 0, width: 100, height: 100)
return NSWindow.frameRect(forContentRect: probe, styleMask: [.titled]).height - probe.height
}()
/// Empty state's "Add Connection…".
var onAddConnection: () -> Void = {}
private let store = ConnectionStore.shared
private let settings = ViewerSettings.shared
private var pages: [UUID: DashboardPage] = [:]
private var order: [UUID] = []
private var selection: UUID?
private let content = ViewerContentView()
private let pageArea = NSView()
private var pageAreaTop: NSLayoutConstraint!
private var emptyState: NSView!
private let tabModel = TabBarModel()
private var tabOverlay: TabBarOverlay!
private var tabOverlayTop: NSLayoutConstraint!
private let tabAccessory = NSTitlebarAccessoryViewController()
private var accessoryAttached = false
private var tabBarShown = false
private var hideTimer: Timer?
private var inFullScreen = false
private var leaving = false
private var awakeActivity: NSObjectProtocol?
private var keyMonitor: Any?
private var cancellables = Set<AnyCancellable>()
private var currentPage: DashboardPage? { selection.flatMap { pages[$0] } }
private var hasTabs: Bool { order.count > 1 }
convenience init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 800),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.title = "ProdDash Viewer"
window.minSize = NSSize(width: 640, height: 420)
window.tabbingMode = .disallowed // the tab bar here is ProdDash's, not AppKit's
window.collectionBehavior.insert(.fullScreenPrimary)
window.isRestorable = false // frame and full screen are kept below instead
window.center()
window.setFrameAutosaveName("ProdDashViewerWindow")
self.init(window: window)
window.delegate = self
build(in: window)
}
private func build(in window: NSWindow) {
// The content runs under the title bar so the pointer can be watched
// there; the dashboard itself starts below it.
window.contentView = content
pageArea.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(pageArea)
pageAreaTop = pageArea.topAnchor.constraint(equalTo: content.topAnchor, constant: Self.titlebarHeight)
let empty = NSHostingView(rootView: EmptyStateView { [weak self] in self?.onAddConnection() })
empty.sizingOptions = [] // sized by the window, never the other way round
empty.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(empty)
emptyState = empty
tabOverlay = TabBarOverlay(model: tabModel)
tabOverlay.translatesAutoresizingMaskIntoConstraints = false
tabOverlay.isHidden = true
tabOverlay.alphaValue = 0
content.addSubview(tabOverlay)
tabOverlayTop = tabOverlay.topAnchor.constraint(equalTo: content.topAnchor, constant: hiddenOverlayTop)
NSLayoutConstraint.activate([
pageAreaTop,
pageArea.leadingAnchor.constraint(equalTo: content.leadingAnchor),
pageArea.trailingAnchor.constraint(equalTo: content.trailingAnchor),
pageArea.bottomAnchor.constraint(equalTo: content.bottomAnchor),
empty.topAnchor.constraint(equalTo: pageArea.topAnchor),
empty.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
empty.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
empty.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
tabOverlayTop,
tabOverlay.leadingAnchor.constraint(equalTo: content.leadingAnchor),
tabOverlay.trailingAnchor.constraint(equalTo: content.trailingAnchor),
tabOverlay.heightAnchor.constraint(equalToConstant: Self.tabBarHeight),
])
let bar = TabBarHostingView(rootView: TabBar(model: tabModel))
bar.sizingOptions = []
bar.frame = NSRect(x: 0, y: 0, width: 800, height: Self.tabBarHeight)
tabAccessory.view = bar
tabAccessory.layoutAttribute = .bottom
tabAccessory.fullScreenMinHeight = 0 // out of sight until the menu bar comes down
tabModel.onSelect = { [weak self] id in self?.select(id) }
content.topZoneHeight = { [weak self] in self?.revealZoneHeight ?? 0 }
content.onPointerInTopZone = { [weak self] inside in self?.pointerInTopZone(inside) }
selection = settings.selectedConnection
// @Published sends before it stores, so use the value it sends.
store.$connections
.sink { [weak self] connections in self?.sync(connections) }
.store(in: &cancellables)
settings.$keepDisplayAwake
.sink { [weak self] keep in self?.updateDisplayAwake(keep) }
.store(in: &cancellables)
installKeyboardSwitching()
}
// MARK: - Connections and tabs
private func sync(_ connections: [Connection]) {
let ids = Set(connections.map(\.id))
let hadTabs = hasTabs
for (id, page) in pages where !ids.contains(id) {
page.close()
pages[id] = nil
}
for connection in connections {
if let page = pages[connection.id] {
page.update(connection)
} else {
let page = DashboardPage(connection: connection) { [weak self] in self?.window }
page.onChange = { [weak self, weak page] in
if let page { self?.pageChanged(page) }
}
pages[connection.id] = page
// A dashboard behind the one on screen still lays itself out
// at the window's size, not at nothing.
page.view.frame = NSRect(x: 0, y: 0, width: content.bounds.width,
height: max(content.bounds.height - pageAreaTop.constant, 0))
page.view.layoutSubtreeIfNeeded()
page.load()
}
}
order = connections.map(\.id)
if selection.map(ids.contains) != true { selection = order.first }
showSelection()
refreshTabs()
placeTabBar()
if hasTabs && !hadTabs { flashTabBar(for: 3) } // out long enough to be noticed
updateDisplayAwake(settings.keepDisplayAwake)
}
private func pageChanged(_ page: DashboardPage) {
refreshTabs()
if page === currentPage {
updateTitle()
// A new address means a new web view: put it on screen.
if page.view.superview !== pageArea { showSelection() }
// The web view can't take the keyboard until it's shown something.
if page.status.phase == .showing, (window?.firstResponder as? NSView)?.isDescendant(of: page.webView) != true {
window?.makeFirstResponder(page.webView)
}
}
}
private func showSelection() {
let page = currentPage
for view in pageArea.subviews where view !== page?.view { view.removeFromSuperview() }
if let page, page.view.superview !== pageArea {
page.view.translatesAutoresizingMaskIntoConstraints = false
pageArea.addSubview(page.view)
NSLayoutConstraint.activate([
page.view.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
page.view.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
page.view.topAnchor.constraint(equalTo: pageArea.topAnchor),
page.view.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
])
}
emptyState.isHidden = page != nil
if let page { window?.makeFirstResponder(page.webView) }
settings.selectedConnection = selection
updateTitle()
}
private func updateTitle() {
window?.title = currentPage?.connection.displayName ?? "ProdDash Viewer"
}
private func refreshTabs() {
tabModel.tabs = order.compactMap { id in
pages[id].map {
TabBarModel.Tab(id: id, title: $0.connection.displayName,
address: $0.connection.displayAddress, unreachable: $0.isUnreachable)
}
}
tabModel.selection = selection
}
func select(_ id: UUID) {
guard pages[id] != nil else { return }
if id != selection {
selection = id
showSelection()
refreshTabs()
}
flashTabBar(for: 2)
}
private func select(index: Int) {
guard order.indices.contains(index) else { return }
select(order[index])
}
private func step(_ delta: Int) {
guard !order.isEmpty else { return }
let index = selection.flatMap { order.firstIndex(of: $0) } ?? 0
select(index: (index + delta + order.count) % order.count)
}
/// ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 (⌘9 the last), as in Safari. Not menu items:
/// the menus stay as short as the viewer is.
private func installKeyboardSwitching() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self, event.window === self.window, self.hasTabs else { return event }
// Only the keys that are pressed together — Caps Lock doesn't count.
let mods = event.modifierFlags.intersection([.command, .control, .option, .shift])
if event.keyCode == 48, mods.subtracting(.shift) == .control { // Tab
self.step(mods.contains(.shift) ? -1 : 1)
return nil
}
if mods == .command, let digit = event.charactersIgnoringModifiers.flatMap(Int.init), (1...9).contains(digit) {
self.select(index: digit == 9 ? self.order.count - 1 : digit - 1)
return nil
}
return event
}
}
// MARK: - The tab bar coming and going
/// Overlay in a window, titlebar accessory in full screen — and neither
/// with only one connection. The accessory stays attached whenever there
/// are tabs, so it is already in place when AppKit builds the full-screen
/// title bar; in a window it is hidden, and hidden it takes no room, so
/// the page stays put.
private func placeTabBar() {
tabAccessory.isHidden = !inFullScreen
if hasTabs != accessoryAttached {
accessoryAttached = hasTabs
if hasTabs {
window?.addTitlebarAccessoryViewController(tabAccessory)
} else {
tabAccessory.removeFromParent()
}
}
if !hasTabs {
hideTimer?.invalidate()
setTabBar(shown: false, animated: false)
}
content.updateTrackingAreas()
}
/// Under the title bar in a window; the top of the screen in full screen,
/// where it only flashes up after a switch.
private var shownOverlayTop: CGFloat { inFullScreen ? 0 : Self.titlebarHeight }
/// Slid up by a few points while hidden, so it slides down as it fades in.
private var hiddenOverlayTop: CGFloat { shownOverlayTop - 8 }
/// The strip along the top that brings the bar out: the title bar, a
/// sliver of the dashboard below it, and the bar itself while it's out.
private var revealZoneHeight: CGFloat {
guard hasTabs, !inFullScreen else { return 0 }
return Self.titlebarHeight + (tabBarShown ? Self.tabBarHeight : 4)
}
private func pointerInTopZone(_ inside: Bool) {
if inside {
hideTimer?.invalidate()
setTabBar(shown: true)
} else {
scheduleHide(after: 0.8)
}
}
/// Bring the bar out for a moment — at launch, and after switching, so
/// it's clear what changed. In full screen too, where nothing else says
/// which installation is on screen.
private func flashTabBar(for seconds: TimeInterval) {
guard hasTabs else { return }
setTabBar(shown: true)
if !content.pointerInTopZone { scheduleHide(after: seconds) }
}
private func scheduleHide(after delay: TimeInterval) {
hideTimer?.invalidate()
hideTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
guard let self, !self.content.pointerInTopZone else { return }
self.setTabBar(shown: false)
}
}
private func setTabBar(shown: Bool, animated: Bool = true) {
let shown = shown && hasTabs
guard shown != tabBarShown else { return }
tabBarShown = shown
if shown { tabOverlay.isHidden = false }
content.layoutSubtreeIfNeeded()
NSAnimationContext.runAnimationGroup({ context in
context.duration = animated ? 0.18 : 0
context.allowsImplicitAnimation = true
tabOverlay.animator().alphaValue = shown ? 1 : 0
tabOverlayTop.animator().constant = shown ? shownOverlayTop : hiddenOverlayTop
content.layoutSubtreeIfNeeded()
}, completionHandler: { [weak self] in
guard let self, !self.tabBarShown else { return }
self.tabOverlay.isHidden = true
})
content.updateTrackingAreas()
}
// MARK: - Full screen
func windowWillEnterFullScreen(_ notification: Notification) {
settleOverlay()
inFullScreen = true
pageAreaTop.constant = 0
placeTabBar()
tabOverlayTop.constant = hiddenOverlayTop
}
func windowDidEnterFullScreen(_ notification: Notification) {
if !leaving { settings.reopenInFullScreen = true }
}
/// The accessory hides before the window is a window again — showing in
/// a window's title bar, it would push the dashboard down.
func windowWillExitFullScreen(_ notification: Notification) {
settleOverlay()
inFullScreen = false
pageAreaTop.constant = Self.titlebarHeight
placeTabBar()
tabOverlayTop.constant = hiddenOverlayTop
}
/// No overlay riding through the full-screen animation.
private func settleOverlay() {
hideTimer?.invalidate()
setTabBar(shown: false, animated: false)
}
func windowDidExitFullScreen(_ notification: Notification) {
if !leaving { settings.reopenInFullScreen = false }
content.updateTrackingAreas()
}
/// Called on the way out, before AppKit takes the window out of full
/// screen on its own account.
func rememberFullScreen() {
guard !leaving, let window else { return }
leaving = true
settings.reopenInFullScreen = window.styleMask.contains(.fullScreen)
}
// MARK: - Window
/// Before AppKit takes a full-screen window out of full screen to close it.
func windowShouldClose(_ sender: NSWindow) -> Bool {
rememberFullScreen()
return true
}
/// One window is the whole app: closing it quits, as in System Settings.
func windowWillClose(_ notification: Notification) {
rememberFullScreen()
DispatchQueue.main.async { NSApp.terminate(nil) }
}
func windowDidChangeOcclusionState(_ notification: Notification) {
updateDisplayAwake(settings.keepDisplayAwake)
}
/// Hold the display awake while a dashboard is actually on screen — not
/// while the window is minimised, hidden or covered.
private func updateDisplayAwake(_ keep: Bool) {
let visible = window?.occlusionState.contains(.visible) == true
let wanted = keep && visible && !order.isEmpty
if wanted, awakeActivity == nil {
awakeActivity = ProcessInfo.processInfo.beginActivity(
options: [.idleDisplaySleepDisabled], reason: "Showing a ProdDash dashboard")
} else if !wanted, let activity = awakeActivity {
ProcessInfo.processInfo.endActivity(activity)
awakeActivity = nil
}
}
// MARK: - View menu
@objc func goBackInPage(_ sender: Any?) { currentPage?.goBack() }
@objc func goForwardInPage(_ sender: Any?) { currentPage?.goForward() }
@objc func reloadPage(_ sender: Any?) { currentPage?.reload() }
func validateMenuItem(_ item: NSMenuItem) -> Bool {
switch item.action {
case #selector(goBackInPage(_:)): return currentPage?.webView.canGoBack == true
case #selector(goForwardInPage(_:)): return currentPage?.webView.canGoForward == true
case #selector(reloadPage(_:)): return currentPage != nil
default: return true
}
}
}
/// The tab bar as it shows in a window: the title bar's material, the tabs,
/// and a hairline under them, floating over the top of the dashboard.
final class TabBarOverlay: NSView {
init(model: TabBarModel) {
super.init(frame: .zero)
let material = NSVisualEffectView()
material.material = .titlebar
material.blendingMode = .withinWindow
material.state = .followsWindowActiveState
let tabs = TabBarHostingView(rootView: TabBar(model: model))
tabs.sizingOptions = []
let rule = Hairline()
for view in [material, tabs, rule] {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
}
NSLayoutConstraint.activate([
material.leadingAnchor.constraint(equalTo: leadingAnchor),
material.trailingAnchor.constraint(equalTo: trailingAnchor),
material.topAnchor.constraint(equalTo: topAnchor),
material.bottomAnchor.constraint(equalTo: bottomAnchor),
tabs.leadingAnchor.constraint(equalTo: leadingAnchor),
tabs.trailingAnchor.constraint(equalTo: trailingAnchor),
tabs.topAnchor.constraint(equalTo: topAnchor),
tabs.bottomAnchor.constraint(equalTo: rule.topAnchor),
rule.leadingAnchor.constraint(equalTo: leadingAnchor),
rule.trailingAnchor.constraint(equalTo: trailingAnchor),
rule.bottomAnchor.constraint(equalTo: bottomAnchor),
rule.heightAnchor.constraint(equalToConstant: 1),
])
}
required init?(coder: NSCoder) { fatalError("not used") }
/// The title bar's separator line. (An NSBox separator would do, but one
/// laid out from a zero frame decides it's vertical and pins the window
/// to a point wide.)
private final class Hairline: NSView {
override func draw(_ dirtyRect: NSRect) {
NSColor.separatorColor.setFill()
bounds.fill()
}
}
}
/// The window's content view. It watches the strip along the top of the
/// window — which, with the content drawn under the title bar, includes the
/// title bar — and says when the pointer comes and goes. Tracking stays on
/// while another app is in front, so the bar answers the pointer even then.
final class ViewerContentView: NSView {
var topZoneHeight: () -> CGFloat = { 0 }
var onPointerInTopZone: (Bool) -> Void = { _ in }
private var zone: NSTrackingArea?
private var zoneRect: NSRect {
let height = min(topZoneHeight(), bounds.height)
return NSRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
}
var pointerInTopZone: Bool {
guard let window else { return false }
let rect = zoneRect
return rect.height > 0 && rect.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let zone { removeTrackingArea(zone) }
zone = nil
let rect = zoneRect
guard rect.height > 0 else { return }
let area = NSTrackingArea(rect: rect, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
addTrackingArea(area)
zone = area
}
override func mouseEntered(with event: NSEvent) {
if event.trackingArea === zone { onPointerInTopZone(true) }
}
override func mouseExited(with event: NSEvent) {
if event.trackingArea === zone { onPointerInTopZone(false) }
}
}
/// The window before any connection exists.
struct EmptyStateView: View {
let add: () -> Void
var body: some View {
VStack(spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 96, height: 96)
Text("No ProdDash Connections")
.font(.title2.weight(.semibold))
Text("Add the address of a ProdDash installation and its dashboard opens here. Add more than one to switch between them.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 380)
.fixedSize(horizontal: false, vertical: true)
Button("Add Connection…", action: add)
.controlSize(.large)
.keyboardShortcut(.defaultAction)
.padding(.top, 6)
}
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
}