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:
co-authored by
Claude Opus 5.5
parent
c043834d1e
commit
8d8c0ee0c3
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user