Files
ProdDash/viewer/macos/Sources/DashboardPage.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

426 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
import WebKit
/**
One installation's dashboard: its web view, and what shows instead while the
installation can't be reached. Every page stays alive while the viewer runs,
so switching tabs is instant and the dashboards behind the one on screen stay
live.
*/
final class DashboardPage: NSObject, WKNavigationDelegate, WKUIDelegate {
final class Status: ObservableObject {
enum Phase: Equatable {
case connecting
case showing
case unreachable(String)
}
@Published var phase: Phase = .connecting
@Published var name = ""
@Published var address = ""
}
private(set) var connection: Connection
/// What the window shows: the web view, with the status view over it.
let view = NSView()
private(set) var webView: WKWebView
let status = Status()
/// Something the tab bar or the menus show has changed.
var onChange: () -> Void = {}
private let window: () -> NSWindow?
private var statusView: NSView!
private var retryTimer: Timer?
private var retryDelay: TimeInterval = 2
private var revealTimer: Timer?
private var lastAttempt: URL?
var isUnreachable: Bool {
if case .unreachable = status.phase { return true }
return false
}
init(connection: Connection, window: @escaping () -> NSWindow?) {
self.connection = connection
self.window = window
self.webView = Self.makeWebView()
super.init()
status.name = connection.displayName
status.address = connection.displayAddress
install(webView)
let overlay = NSHostingView(rootView: PageStatusView(status: status) { [weak self] in self?.retry() })
overlay.sizingOptions = [] // sized by the window, never the other way round
overlay.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlay)
pin(overlay)
statusView = overlay
}
private static func makeWebView() -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .default()
// The dashboard's own full-screen button uses the Fullscreen API.
config.preferences.isElementFullscreenEnabled = true
// An alert may need to sound on a dashboard nobody has touched.
config.mediaTypesRequiringUserActionForPlayback = []
config.applicationNameForUserAgent = "ProdDashViewer/\(AppDelegate.version)"
let web = WKWebView(frame: .zero, configuration: config)
web.allowsBackForwardNavigationGestures = true
// Safari's Develop menu can inspect a dashboard running in here.
if #available(macOS 13.3, *) { web.isInspectable = true }
return web
}
private func install(_ web: WKWebView) {
web.navigationDelegate = self
web.uiDelegate = self
// Hidden until it has drawn something: no white flash on a dark booth
// display while the first load is in flight.
web.isHidden = true
web.translatesAutoresizingMaskIntoConstraints = false
if let statusView {
view.addSubview(web, positioned: .below, relativeTo: statusView)
} else {
view.addSubview(web)
}
pin(web)
}
private func pin(_ subview: NSView) {
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: view.leadingAnchor),
subview.trailingAnchor.constraint(equalTo: view.trailingAnchor),
subview.topAnchor.constraint(equalTo: view.topAnchor),
subview.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
// MARK: - Loading
func load() { load(connection.url) }
private func load(_ url: URL?) {
guard let url else {
return show(.unreachable("“\(connection.address)” isn't an address ProdDash Viewer can open. Change it in Settings."))
}
lastAttempt = url
webView.load(URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 15))
}
/// Reload, or — while the installation can't be reached — try again now.
func reload() {
if isUnreachable || webView.url == nil { return retry() }
webView.reload()
}
func goBack() { webView.goBack() }
func goForward() { webView.goForward() }
func retry() {
retryTimer?.invalidate()
load(lastAttempt ?? connection.url)
}
/// An installation that isn't answering is usually one restarting — an
/// update, a reboot — so keep asking, less often the longer it takes.
private func scheduleRetry() {
retryTimer?.invalidate()
retryTimer = Timer.scheduledTimer(withTimeInterval: retryDelay, repeats: false) { [weak self] _ in
self?.retry()
}
retryDelay = min(retryDelay * 2, 15)
}
/// Settings changed this connection. A new address is a different
/// installation, so it gets a fresh web view — Back can't wander into the
/// old one.
func update(_ new: Connection) {
let moved = new.address != connection.address
connection = new
status.name = new.displayName
status.address = new.displayAddress
guard moved else { return onChange() }
retire(webView)
webView = Self.makeWebView()
install(webView)
retryTimer?.invalidate()
revealTimer?.invalidate()
retryDelay = 2
show(.connecting)
load()
}
/// The connection was removed.
func close() {
retryTimer?.invalidate()
revealTimer?.invalidate()
retire(webView)
view.removeFromSuperview()
}
private func retire(_ web: WKWebView) {
web.stopLoading()
web.navigationDelegate = nil
web.uiDelegate = nil
web.removeFromSuperview()
}
private func show(_ phase: Status.Phase) {
status.phase = phase
statusView.isHidden = phase == .showing
onChange()
}
private func isHome(_ url: URL) -> Bool {
guard let home = connection.url.flatMap(Address.origin) else { return false }
return Address.origin(url) == home
}
// MARK: - Navigation
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard webView === self.webView else { return }
reveal()
}
/// Normally the load finishes within moments of this; but a tile's frame
/// that never answers holds the load event back, and the dashboard
/// shouldn't sit behind "Connecting…" waiting for it.
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
guard webView === self.webView, status.phase != .showing else { return }
revealTimer?.invalidate()
revealTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self, weak webView] _ in
guard let self, let webView, webView === self.webView else { return }
self.reveal()
}
}
private func reveal() {
revealTimer?.invalidate()
retryTimer?.invalidate()
retryDelay = 2
webView.isHidden = false
if status.phase != .showing { show(.showing) }
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
failed(webView, error)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
failed(webView, error)
}
private func failed(_ web: WKWebView, _ error: Error) {
guard web === webView else { return }
let ns = error as NSError
// Not failures: a load replaced by another, or one handed to the browser.
if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled { return }
if ns.domain == "WebKitErrorDomain" && (ns.code == 102 || ns.code == 204) { return }
if let failing = ns.userInfo[NSURLErrorFailingURLErrorKey] as? URL, isHome(failing) {
lastAttempt = failing
}
show(.unreachable(NetworkProblem.describe(error, url: lastAttempt)))
scheduleRetry()
}
/// The page's process died (memory pressure, a WebKit crash). A booth
/// display is never left blank: bring it back — after the usual retry
/// delay, so a page that takes its process down as it loads can't spin.
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
guard webView === self.webView else { return }
if let url = webView.url, isHome(url) { lastAttempt = url }
scheduleRetry()
}
/// The viewer shows ProdDash and nothing else: the installation's own
/// pages open here, any other site opens in the browser.
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = action.request.url else { return decisionHandler(.allow) }
// A tile may embed another site in a frame; only the page itself is policed.
guard action.targetFrame?.isMainFrame ?? true else { return decisionHandler(.allow) }
switch url.scheme?.lowercased() {
case "http", "https":
if !isHome(url) {
let followed = action.targetFrame == nil || status.phase == .showing
|| action.navigationType == .linkActivated || action.navigationType == .formSubmitted
if followed {
NSWorkspace.shared.open(url) // someone followed a link off ProdDash
} else {
redirected(to: url)
}
return decisionHandler(.cancel)
}
if action.targetFrame == nil {
// target="_blank" or window.open on ProdDash's own page: one
// window, so it opens here.
let request = action.request
DispatchQueue.main.async { webView.load(request) }
return decisionHandler(.cancel)
}
decisionHandler(.allow)
case "about", "blob", "data":
decisionHandler(.allow)
case "file":
// A file dropped somewhere the page didn't take it.
decisionHandler(.cancel)
default:
NSWorkspace.shared.open(url) // mailto: and the like
decisionHandler(.cancel)
}
}
/// Something the page can't show is a download: the browser does those.
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if response.isForMainFrame, !response.canShowMIMEType, let url = response.response.url {
NSWorkspace.shared.open(url)
return decisionHandler(.cancel)
}
decisionHandler(.allow)
}
/// The address itself sends the viewer somewhere else — a proxy, http
/// moving to https. Handing that to the browser would leave the page
/// stuck connecting, and do it again on every retry.
private func redirected(to url: URL) {
show(.unreachable("\(Address.place(connection.url)) sends ProdDash Viewer on to \(Address.originDisplay(url)). If that's where ProdDash answers, change the address in Settings to it."))
scheduleRetry()
}
/// window.open(), which doesn't pass through decidePolicyFor: ProdDash's
/// own pages open here, other sites in the browser. The viewer never
/// opens a second window.
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
guard let url = action.request.url else { return nil }
if isHome(url) {
webView.load(action.request)
} else if url.scheme == "http" || url.scheme == "https" {
NSWorkspace.shared.open(url)
}
return nil
}
// MARK: - JavaScript dialogs and file pickers
// A WKWebView answers alert, confirm and prompt with "no" unless the app
// shows them — and the admin page confirms before deleting a layout, Band
// Lineup prompts for names.
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
let alert = dialog(message)
alert.addButton(withTitle: "OK")
present(alert) { _ in completionHandler() }
}
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
let alert = dialog(message)
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
present(alert) { completionHandler($0 == .alertFirstButtonReturn) }
}
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String,
defaultText: String?, initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (String?) -> Void) {
let alert = dialog(prompt)
let field = NSTextField(string: defaultText ?? "")
field.frame = NSRect(x: 0, y: 0, width: 280, height: 22)
alert.accessoryView = field
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = field
present(alert) { completionHandler($0 == .alertFirstButtonReturn ? field.stringValue : nil) }
}
func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = parameters.allowsDirectories
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
if let window = window(), window.attachedSheet == nil {
panel.beginSheetModal(for: window) { completionHandler($0 == .OK ? panel.urls : nil) }
} else {
completionHandler(panel.runModal() == .OK ? panel.urls : nil)
}
}
/// Titled with the installation's name, so it's clear which one is asking.
private func dialog(_ message: String) -> NSAlert {
let alert = NSAlert()
alert.messageText = connection.displayName
alert.informativeText = message
return alert
}
private func present(_ alert: NSAlert, then done: @escaping (NSApplication.ModalResponse) -> Void) {
if let window = window(), window.attachedSheet == nil {
alert.beginSheetModal(for: window, completionHandler: done)
} else {
done(alert.runModal())
}
}
}
/// Over the web view while there is nothing to show yet, or while the
/// installation can't be reached.
struct PageStatusView: View {
@ObservedObject var status: DashboardPage.Status
let retry: () -> Void
/// A fast load shouldn't flash a spinner.
@State private var patient = false
var body: some View {
ZStack {
Color(nsColor: .windowBackgroundColor)
switch status.phase {
case .showing:
EmptyView()
case .connecting:
if patient {
VStack(spacing: 12) {
ProgressView()
Text("Connecting to \(status.name)…").foregroundStyle(.secondary)
}
}
case .unreachable(let why):
VStack(spacing: 10) {
Image(systemName: "network.slash")
.font(.system(size: 44, weight: .light))
.foregroundStyle(.secondary)
.padding(.bottom, 4)
Text("Can’t Reach \(status.name)")
.font(.title2.weight(.semibold))
Text(why)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 420)
.fixedSize(horizontal: false, vertical: true)
Text(status.address)
.font(.callout.monospaced())
.foregroundStyle(.tertiary)
.textSelection(.enabled)
Button("Try Again", action: retry)
.controlSize(.large)
.padding(.top, 8)
Text("ProdDash Viewer keeps trying by itself.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(40)
}
}
.task {
try? await Task.sleep(for: .milliseconds(800))
patient = true
}
}
}