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>
This commit is contained in:
Waters Church
2026-10-25 13:05:14 -04:00
co-authored by Claude Opus 5.5
parent c043834d1e
commit 8d8c0ee0c3
18 changed files with 2229 additions and 2 deletions
+110
View File
@@ -0,0 +1,110 @@
import AppKit
/**
ProdDash Viewer: one window showing a ProdDash dashboard, a tab per
installation when there's more than one, and Settings to say which.
The menus are deliberately short — Back, Forward, Reload Page and Settings,
plus what every Mac app has: the app menu, Edit (without which Copy and
Paste do nothing in the dashboard's text fields), Window and Help.
*/
final class AppDelegate: NSObject, NSApplicationDelegate {
static var version: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
}
private var viewer: ViewerWindowController!
private lazy var settingsWindow = SettingsWindowController()
private lazy var helpWindow = HelpWindowController()
func applicationWillFinishLaunching(_ notification: Notification) {
NSWindow.allowsAutomaticWindowTabbing = false
NSApp.mainMenu = makeMainMenu()
}
func applicationDidFinishLaunching(_ notification: Notification) {
viewer = ViewerWindowController()
viewer.onAddConnection = { [weak self] in self?.settingsWindow.show(addingConnection: true) }
viewer.showWindow(nil)
if ViewerSettings.shared.reopenInFullScreen {
DispatchQueue.main.async { self.viewer.window?.toggleFullScreen(nil) }
}
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if !flag { viewer.showWindow(nil) }
return true
}
func applicationWillTerminate(_ notification: Notification) {
viewer?.rememberFullScreen()
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
@objc func showSettings(_ sender: Any?) { settingsWindow.show() }
@objc func showViewerHelp(_ sender: Any?) { helpWindow.show() }
// MARK: - Menus
private func makeMainMenu() -> NSMenu {
let name = "ProdDash Viewer"
let main = NSMenu()
let app = submenu(of: main, title: name)
app.addItem(withTitle: "About \(name)",
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
app.addItem(.separator())
app.addItem(withTitle: "Settings…", action: #selector(showSettings(_:)), keyEquivalent: ",")
app.addItem(.separator())
let services = submenu(of: app, title: "Services")
NSApp.servicesMenu = services
app.addItem(.separator())
app.addItem(withTitle: "Hide \(name)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
app.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)),
keyEquivalent: "h").keyEquivalentModifierMask = [.command, .option]
app.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
app.addItem(.separator())
app.addItem(withTitle: "Quit \(name)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
let edit = submenu(of: main, title: "Edit")
edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
edit.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
edit.addItem(.separator())
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
let view = submenu(of: main, title: "View")
view.addItem(withTitle: "Back", action: #selector(ViewerWindowController.goBackInPage(_:)), keyEquivalent: "[")
view.addItem(withTitle: "Forward", action: #selector(ViewerWindowController.goForwardInPage(_:)), keyEquivalent: "]")
view.addItem(withTitle: "Reload Page", action: #selector(ViewerWindowController.reloadPage(_:)), keyEquivalent: "r")
view.addItem(.separator())
// AppKit retitles this "Exit Full Screen" while the window is in full screen.
view.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)),
keyEquivalent: "f").keyEquivalentModifierMask = [.command, .control]
let window = submenu(of: main, title: "Window")
window.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
window.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
window.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
window.addItem(.separator())
window.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")
NSApp.windowsMenu = window
let help = submenu(of: main, title: "Help")
help.addItem(withTitle: "\(name) Help", action: #selector(showViewerHelp(_:)), keyEquivalent: "?")
NSApp.helpMenu = help
return main
}
@discardableResult
private func submenu(of parent: NSMenu, title: String) -> NSMenu {
let menu = NSMenu(title: title)
parent.addItem(withTitle: title, action: nil, keyEquivalent: "").submenu = menu
return menu
}
}
+169
View File
@@ -0,0 +1,169 @@
import Foundation
import SwiftUI
/// One ProdDash installation the viewer shows: the name its tab carries and
/// the address it answers at.
struct Connection: Codable, Identifiable, Equatable {
var id = UUID()
var name: String
/// Always a full http(s) URL — see `Address.normalize`.
var address: String
var url: URL? { URL(string: address) }
/// The name, or the host when nobody gave it one.
var displayName: String {
let trimmed = name.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? (url?.host ?? address) : trimmed
}
var displayAddress: String { url.map(Address.display) ?? address }
}
enum Address {
/// ProdDash's port unless someone changed it (config/proddash.json).
static let defaultPort = 24500
/// What someone typed, as the URL ProdDash answers at: `10.3.11.152` and
/// `10.3.11.152:24500` both become `http://10.3.11.152:24500/`. A typed
/// scheme is taken at its word — `http://host` means port 80. A path is
/// kept, so an address can open a particular page.
static func normalize(_ text: String) -> URL? {
var typed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !typed.isEmpty else { return nil }
let hasScheme = typed.contains("://")
if !hasScheme { typed = "http://" + typed }
guard var parts = URLComponents(string: typed),
let scheme = parts.scheme?.lowercased(), scheme == "http" || scheme == "https",
let host = parts.host, !host.isEmpty else { return nil }
parts.scheme = scheme
if !hasScheme && parts.port == nil { parts.port = defaultPort }
if parts.path.isEmpty { parts.path = "/" }
return parts.url
}
/// Scheme, host and port — what decides whether a link stays in the viewer.
static func origin(_ url: URL) -> String? {
guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else { return nil }
let port = url.port ?? (scheme == "https" ? 443 : 80)
return "\(scheme)://\(host):\(port)"
}
/// For people to read: no trailing slash on a bare address.
static func display(_ url: URL) -> String {
var text = url.absoluteString
if url.path == "/" && url.query == nil && url.fragment == nil && text.hasSuffix("/") { text.removeLast() }
return text
}
/// Just the scheme, host and port, for people: `https://host`.
static func originDisplay(_ url: URL) -> String {
var parts = URLComponents()
parts.scheme = url.scheme
parts.host = url.host
parts.port = url.port
return parts.string ?? url.absoluteString
}
/// `host:port`, the way the error messages name a place.
static func place(_ url: URL?) -> String {
guard let url, let host = url.host else { return "that address" }
return url.port.map { "\(host):\($0)" } ?? host
}
}
/// The connections, in tab order, kept in the viewer's preferences.
final class ConnectionStore: ObservableObject {
static let shared = ConnectionStore()
private static let key = "connections"
@Published private(set) var connections: [Connection] {
didSet { save() }
}
private init() {
let saved = UserDefaults.standard.data(forKey: Self.key)
connections = saved.flatMap { try? JSONDecoder().decode([Connection].self, from: $0) } ?? []
}
func add(_ connection: Connection) { connections.append(connection) }
func update(_ connection: Connection) {
guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return }
connections[index] = connection
}
func remove(_ id: UUID) { connections.removeAll { $0.id == id } }
func move(from source: IndexSet, to destination: Int) {
connections.move(fromOffsets: source, toOffset: destination)
}
private func save() {
guard let data = try? JSONEncoder().encode(connections) else { return }
UserDefaults.standard.set(data, forKey: Self.key)
}
}
/// Network failures in words an operator can act on.
enum NetworkProblem {
static func describe(_ error: Error, url: URL?) -> String {
let ns = error as NSError
let place = Address.place(url)
guard ns.domain == NSURLErrorDomain else { return ns.localizedDescription }
switch ns.code {
case NSURLErrorCannotConnectToHost:
return "Nothing is answering at \(place). ProdDash may not be running there, or it may use another port."
case NSURLErrorTimedOut:
return "\(place) didn't answer in time."
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
return "\(url?.host ?? place) can't be found on the network."
case NSURLErrorNotConnectedToInternet:
return "This Mac isn't on a network — or macOS is keeping ProdDash Viewer off it. Check System Settings → Privacy & Security → Local Network."
case NSURLErrorNetworkConnectionLost:
return "The connection to \(place) dropped."
case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateNotYetValid:
return "\(place) offered a secure connection this Mac doesn't trust."
default:
return ns.localizedDescription
}
}
}
/// Asks an address whether ProdDash is there, for the connection sheet.
enum Probe {
enum Answer: Equatable {
case prodDash
case somethingElse
case noAnswer(String)
}
private static let session: URLSession = {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 4
config.timeoutIntervalForResource = 6
return URLSession(configuration: config)
}()
/// `/api/modules` is the first thing every dashboard asks for, and it
/// answers without a PIN — so `{"modules": …}` there means ProdDash.
static func ask(_ url: URL) async -> Answer {
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return .somethingElse }
parts.path = "/api/modules"
parts.query = nil
parts.fragment = nil
guard let target = parts.url else { return .somethingElse }
do {
let (data, response) = try await session.data(from: target)
let ok = (response as? HTTPURLResponse)?.statusCode == 200
let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return ok && body?["modules"] != nil ? .prodDash : .somethingElse
} catch {
return .noAnswer(NetworkProblem.describe(error, url: url))
}
}
}
+425
View File
@@ -0,0 +1,425 @@
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
}
}
}
@@ -0,0 +1,42 @@
import AppKit
import WebKit
/// Help → ProdDash Viewer Help: the page that ships inside the app, so it
/// opens with no network and always matches this version.
final class HelpWindowController: NSWindowController, WKNavigationDelegate {
convenience init() {
let web = WKWebView()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 720),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
window.title = "ProdDash Viewer Help"
window.minSize = NSSize(width: 420, height: 360)
window.contentView = web
window.tabbingMode = .disallowed
window.isRestorable = false
window.center()
window.setFrameAutosaveName("ProdDashViewerHelp")
self.init(window: window)
web.navigationDelegate = self
if let page = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "Help") {
web.loadFileURL(page, allowingReadAccessTo: page.deletingLastPathComponent())
}
}
func show() {
showWindow(nil)
window?.makeKeyAndOrderFront(nil)
}
/// The help page itself stays here; its links to the web open in the browser.
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = action.request.url, !url.isFileURL, url.scheme != "about" else {
return decisionHandler(.allow)
}
NSWorkspace.shared.open(url)
decisionHandler(.cancel)
}
}
@@ -0,0 +1,299 @@
import AppKit
import SwiftUI
/// Settings (⌘,): a standard toolbar-tabbed settings window with two panes,
/// Connections and General. Every change applies as it's made.
final class SettingsWindowController: NSWindowController {
private var model: SettingsModel!
private var tabs: NSTabViewController!
convenience init() {
let model = SettingsModel()
let tabs = NSTabViewController()
tabs.tabStyle = .toolbar
tabs.addTabViewItem(Self.pane("Connections", symbol: "network",
ConnectionsPane(store: .shared, model: model)))
tabs.addTabViewItem(Self.pane("General", symbol: "gearshape",
GeneralPane(settings: .shared)))
let window = NSWindow(contentViewController: tabs)
window.styleMask = [.titled, .closable]
window.toolbarStyle = .preference
window.isRestorable = false
self.init(window: window)
self.model = model
self.tabs = tabs
window.center()
}
private static func pane<Content: View>(_ label: String, symbol: String, _ content: Content) -> NSTabViewItem {
let controller = NSHostingController(rootView: content)
controller.sizingOptions = .preferredContentSize
controller.title = label // the window takes its title from the pane
let item = NSTabViewItem(viewController: controller)
item.label = label
item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)
return item
}
/// Show Settings; `addingConnection` opens it on the add-a-connection sheet.
func show(addingConnection: Bool = false) {
if addingConnection {
tabs.selectedTabViewItemIndex = 0
model.pendingAdd = true
}
showWindow(nil)
window?.makeKeyAndOrderFront(nil)
}
}
final class SettingsModel: ObservableObject {
/// Set to open the add sheet as soon as the Connections pane can.
@Published var pendingAdd = false
}
// MARK: - Connections
private struct ConnectionDraft: Identifiable {
var id: UUID
var name: String
var address: String
var isNew: Bool
}
private struct ConnectionsPane: View {
@ObservedObject var store: ConnectionStore
@ObservedObject var model: SettingsModel
@State private var selection: UUID?
@State private var editing: ConnectionDraft?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Each connection is a ProdDash installation on your network. With more than one, a tab bar at the top of the window switches between them — drag them into the order you want.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
List(selection: $selection) {
ForEach(store.connections) { connection in
VStack(alignment: .leading, spacing: 2) {
Text(connection.displayName)
Text(connection.displayAddress)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 3)
.tag(connection.id)
}
.onMove { store.move(from: $0, to: $1) }
}
.listStyle(.bordered(alternatesRowBackgrounds: true))
.contextMenu(forSelectionType: UUID.self) { ids in
Button("Edit…") { edit(ids.first) }
.disabled(ids.count != 1)
Button("Remove") {
ids.forEach(store.remove)
if let selected = selection, ids.contains(selected) { selection = nil }
}
} primaryAction: { ids in
edit(ids.first)
}
.overlay {
if store.connections.isEmpty {
Text("No Connections").foregroundStyle(.tertiary)
}
}
.frame(height: 210)
HStack(spacing: 6) {
Button(action: add) {
Image(systemName: "plus").frame(width: 14)
}
.help("Add a connection")
.accessibilityLabel("Add")
Button(action: removeSelected) {
Image(systemName: "minus").frame(width: 14)
}
.help("Remove the selected connection")
.accessibilityLabel("Remove")
.disabled(selection == nil)
Spacer()
Button("Edit…") { edit(selection) }
.disabled(selection == nil)
}
}
.padding(20)
.frame(width: 520)
.sheet(item: $editing) { draft in
ConnectionEditor(draft: draft, onSave: save)
}
.onReceive(model.$pendingAdd) { pending in
guard pending else { return }
DispatchQueue.main.async {
model.pendingAdd = false
add()
}
}
}
private func add() {
editing = ConnectionDraft(id: UUID(), name: "", address: "", isNew: true)
}
private func edit(_ id: UUID?) {
guard let id, let connection = store.connections.first(where: { $0.id == id }) else { return }
editing = ConnectionDraft(id: id, name: connection.name, address: connection.displayAddress, isNew: false)
}
private func removeSelected() {
guard let id = selection else { return }
store.remove(id)
selection = nil
}
private func save(_ draft: ConnectionDraft) {
guard let url = Address.normalize(draft.address) else { return }
let connection = Connection(id: draft.id,
name: draft.name.trimmingCharacters(in: .whitespaces),
address: url.absoluteString)
if draft.isNew { store.add(connection) } else { store.update(connection) }
selection = connection.id
}
}
/// The add / edit sheet. It asks the address whether ProdDash is there as
/// you type, so a mistyped address shows up here rather than on the booth
/// display — but saving never depends on it: the installation may simply be
/// switched off right now.
private struct ConnectionEditor: View {
@State var draft: ConnectionDraft
let onSave: (ConnectionDraft) -> Void
@Environment(\.dismiss) private var dismiss
private enum Check: Equatable { case idle, checking, answer(Probe.Answer) }
@State private var check: Check = .idle
private var url: URL? { Address.normalize(draft.address) }
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Text(draft.isNew ? "Add Connection" : "Edit Connection")
.font(.headline)
Form {
TextField("Name:", text: $draft.name, prompt: Text(url?.host ?? "AB Booth"))
TextField("Address:", text: $draft.address, prompt: Text("10.3.11.152:24500"))
LabeledContent {
status
.font(.caption)
.frame(maxWidth: .infinity, minHeight: 30, alignment: .topLeading)
} label: {
EmptyView()
}
}
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Button(draft.isNew ? "Add" : "Save") {
onSave(draft)
dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(url == nil)
}
}
.padding(20)
.frame(width: 460)
.task(id: draft.address) {
guard let url else { return check = .idle }
check = .checking
try? await Task.sleep(for: .milliseconds(500)) // let the typing finish
guard !Task.isCancelled else { return }
let answer = await Probe.ask(url)
guard !Task.isCancelled else { return }
check = .answer(answer)
}
}
@ViewBuilder private var status: some View {
switch check {
case .idle where url == nil:
Text(draft.address.isEmpty
? "The address you open ProdDash at in a browser. Without a port, ProdDash’s \(String(Address.defaultPort)) is used."
: "That isn’t an address.")
.foregroundStyle(.secondary)
case .idle, .checking: // idle with an address: the check is about to start
HStack(spacing: 6) {
ProgressView().controlSize(.mini)
Text("Checking \(url.map(Address.display) ?? "")…").foregroundStyle(.secondary)
}
case .answer(.prodDash):
Label("ProdDash is answering at \(url.map(Address.display) ?? "").", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
case .answer(.somethingElse):
Label("Something answers at \(url.map(Address.display) ?? "") — but it isn’t ProdDash.", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
case .answer(.noAnswer(let why)):
Label(why, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
}
// MARK: - General
private struct GeneralPane: View {
@ObservedObject var settings: ViewerSettings
@State private var openAtLogin = LoginItem.isEnabled || LoginItem.needsApproval
@State private var needsApproval = LoginItem.needsApproval
@State private var loginProblem: String?
var body: some View {
Form {
Section {
Toggle("Open at login", isOn: Binding(
get: { openAtLogin },
set: { wanted in
loginProblem = LoginItem.set(wanted)
needsApproval = LoginItem.needsApproval
openAtLogin = LoginItem.isEnabled || needsApproval
}))
if needsApproval {
HStack {
Text("Waiting for your approval in System Settings.")
.foregroundStyle(.secondary)
Spacer()
Button("Open Login Items…") { LoginItem.openSystemSettings() }
}
}
if let loginProblem {
Text(loginProblem).foregroundStyle(.red)
}
}
Section {
Toggle(isOn: $settings.keepDisplayAwake) {
Text("Keep the display awake")
Text("While a dashboard is on screen, the display doesn’t dim or sleep.")
}
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.frame(width: 520, height: needsApproval || loginProblem != nil ? 200 : 152)
.onAppear(perform: refreshLoginItem)
// Approving it in System Settings happens elsewhere; catch up on return.
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in
refreshLoginItem()
}
}
private func refreshLoginItem() {
needsApproval = LoginItem.needsApproval
openAtLogin = LoginItem.isEnabled || needsApproval
}
}
+77
View File
@@ -0,0 +1,77 @@
import AppKit
import SwiftUI
/// What the tab bar shows: one tab per connection, in Settings' order.
final class TabBarModel: ObservableObject {
struct Tab: Identifiable, Equatable {
let id: UUID
var title: String
var address: String
var unreachable: Bool
}
@Published var tabs: [Tab] = []
@Published var selection: UUID?
var onSelect: (UUID) -> Void = { _ in }
}
/// Hosts the tabs. A click on a tab works even while another app is in
/// front, rather than only bringing the viewer forward.
final class TabBarHostingView: NSHostingView<TabBar> {
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
}
/// The slim bar of installation names. It sits in the window's title bar
/// area, so it takes the title bar's material in light and dark alike.
struct TabBar: View {
@ObservedObject var model: TabBarModel
var body: some View {
HStack(spacing: 4) {
ForEach(model.tabs) { tab in
TabButton(tab: tab, selected: tab.id == model.selection) { model.onSelect(tab.id) }
}
}
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct TabButton: View {
let tab: TabBarModel.Tab
let selected: Bool
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
HStack(spacing: 5) {
if tab.unreachable {
Image(systemName: "exclamationmark.triangle.fill")
.imageScale(.small)
.foregroundStyle(.orange)
}
Text(tab.title)
.lineLimit(1)
.truncationMode(.tail)
}
.font(.system(size: 12, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? .primary : .secondary)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, minHeight: 24)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(selected ? 0.12 : hovering ? 0.06 : 0))
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.focusable(false) // the keyboard belongs to the dashboard; ⌃Tab switches
.onHover { hovering = $0 }
.help(tab.unreachable ? "\(tab.address) — not answering" : tab.address)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
+61
View File
@@ -0,0 +1,61 @@
import Foundation
import ServiceManagement
/// The viewer's own preferences. The connections themselves are in
/// ConnectionStore.
final class ViewerSettings: ObservableObject {
static let shared = ViewerSettings()
private let defaults = UserDefaults.standard
/// Keep the display on while a dashboard is on screen — a booth display
/// that dims halfway through a service is the failure this is here for.
@Published var keepDisplayAwake: Bool {
didSet { defaults.set(keepDisplayAwake, forKey: "keepDisplayAwake") }
}
private init() {
defaults.register(defaults: ["keepDisplayAwake": true])
keepDisplayAwake = defaults.bool(forKey: "keepDisplayAwake")
}
/// The window was in full screen when the viewer last quit, so it opens in
/// full screen again — a booth Mac that restarts comes back as it was left.
var reopenInFullScreen: Bool {
get { defaults.bool(forKey: "reopenInFullScreen") }
set { defaults.set(newValue, forKey: "reopenInFullScreen") }
}
/// The tab that was showing, so the viewer reopens on it.
var selectedConnection: UUID? {
get { defaults.string(forKey: "selectedConnection").flatMap(UUID.init(uuidString:)) }
set { defaults.set(newValue?.uuidString, forKey: "selectedConnection") }
}
}
/// "Open at login", through the macOS 13 login-item API. The operator may
/// still have to approve it in System Settings — hence `needsApproval`, which
/// Settings shows rather than failing silently.
enum LoginItem {
static var isEnabled: Bool { SMAppService.mainApp.status == .enabled }
static var needsApproval: Bool { SMAppService.mainApp.status == .requiresApproval }
/// Nil when it worked; otherwise what went wrong, for Settings to show.
static func set(_ enabled: Bool) -> String? {
do {
if enabled {
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
} else {
if SMAppService.mainApp.status != .notRegistered { try SMAppService.mainApp.unregister() }
}
return nil
} catch {
return error.localizedDescription
}
}
static func openSystemSettings() {
SMAppService.openSystemSettingsLoginItems()
}
}
@@ -0,0 +1,544 @@
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))
}
}
+8
View File
@@ -0,0 +1,8 @@
import AppKit
// ProdDash Viewer — a window onto ProdDash, and nothing else.
let delegate = AppDelegate()
let application = NSApplication.shared
application.delegate = delegate
application.setActivationPolicy(.regular)
application.run()