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