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 } } }