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>
426 lines
17 KiB
Swift
426 lines
17 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|