diff --git a/.gitignore b/.gitignore index 3b6c710..cdf67fc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ launcher/macos/build/ launcher/macos/.cache/ launcher/windows/build/ launcher/windows/.cache/ + +# the Viewer's build output +viewer/macos/build/ diff --git a/CLAUDE.md b/CLAUDE.md index 117e8b0..2ce5c67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,13 @@ Modular production dashboard web app for Waters Church, developed in this repo. The app lives at the repo root and is built: zero-dependency Node server (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at `/admin`, modules in `modules/` (the repo's copies — a packaged install ships -none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in +none and installs them from the admin page's catalog), a native macOS menu-bar launcher in `launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries the server and its own Node runtime, supervises them, and owns the macOS -permissions modules declare; see its README). Runtime state (module +permissions modules declare; see its README), and **ProdDash Viewer** in +`viewer/macos/` (Swift, no dependencies — a separate Apple-silicon app that +only displays one or more ProdDash installations in WebKit, with an +auto-hiding tab bar; it runs no server; see its README). Runtime state (module config, named layouts) is written to a per-machine data directory outside the checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never add code that writes runtime state into the repo folder. diff --git a/README.md b/README.md index 56bcd19..7b1d3e6 100644 --- a/README.md +++ b/README.md @@ -618,6 +618,19 @@ sheet's notes, as ProPresenter does for Vista — point Vista Cues' first sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of the [module guide](docs/MODULE-GUIDE.md). +## ProdDash Viewer — a Mac that only shows the dashboard + +For a Mac that should show ProdDash and nothing else — a booth display, a +second screen watching the other campus — there is **ProdDash Viewer** +([viewer/macos](viewer/macos)): one window, the dashboard, and a slim +auto-hiding tab bar when it is set up with more than one installation. It +runs no server of its own; point it at any ProdDash on the network. Full +screen, reopening as it was left, and keeping the display awake are built in. + +```bash +cd viewer/macos && ./build.sh --dmg +``` + ## Install as a desktop web app Open the page in a browser first, then: @@ -648,6 +661,9 @@ ProdDash/ ├── package.json name + version (what the update check compares) ├── config/ proddash.json defaults (runtime state lives in the │ per-machine data directory — see "Configure") +├── launcher/macos/ the macOS app: menu-bar launcher carrying the server +│ and its own Node (launcher/windows: the Windows bundle) +├── viewer/macos/ ProdDash Viewer: a Mac app that only shows dashboards ├── docs/MODULE-GUIDE.md how to build and install a module ├── docs/ALERTS.md the alerts feature: levels, channels, admin, API ├── tools/prodcom-mock.js mock ProdCom API for development diff --git a/viewer/macos/README.md b/viewer/macos/README.md new file mode 100644 index 0000000..e00c02b --- /dev/null +++ b/viewer/macos/README.md @@ -0,0 +1,121 @@ +# ProdDash Viewer for macOS + +A Mac app whose only job is to show ProdDash. It opens one installation's +dashboard in its own window — or several, with a slim tab bar to switch +between them — and does nothing else: no address bar, no bookmarks, no +browser to wander off in. It is for the screens that just *show* a dashboard: +a booth display, a stage-manager's laptop, a second Mac watching the other +campus. + +It runs no server. ProdDash itself is installed with the menu-bar app in +[launcher/macos](../../launcher/macos); the Viewer points at that, or at any +other ProdDash on the network. + +Apple silicon, macOS 13 or newer. Native Swift (AppKit with a little SwiftUI, +WebKit for the page), no dependencies, about 1 MB. + +## Build + +```bash +./build.sh # build/ProdDash Viewer.app +./build.sh --dmg # …and build/ProdDash-Viewer--arm64.dmg +./build.sh --install # …and copy it to /Applications +./build.sh --run # …and open it +./build.sh --notarize # with --dmg: notarise and staple (Developer ID) +``` + +Needs Xcode, selected with `xcode-select` — with only the command line +tools, the macOS 27 SDK can't expand SwiftUI's `@State` macro and the +compile fails. Signing and notarising work exactly as +for ProdDash's own app — a Developer ID certificate when one is installed, +ad-hoc otherwise, and the same `proddash` notarytool profile; see +[launcher/macos/README.md](../../launcher/macos/README.md#signing). The +Viewer keeps its own version number (`VERSION` in `build.sh`), since it works +with any ProdDash rather than shipping with one. + +The icon is `Resources/AppIcon.png`, drawn by `Resources/make-icon.swift` — +ProdDash's tile mark on a display, so the two apps tell apart in Finder. +Run `swift Resources/make-icon.swift Resources/AppIcon.png` to redraw it. + +## Using it + +**Settings…** (⌘,) → **Connections** → **+**: a name (what the tab says) and +the address you open ProdDash at in a browser, such as `10.3.11.152:24500`. +Without a port, ProdDash's 24500 is assumed; a path is kept, so an address +can open a particular page. The sheet asks the address whether ProdDash is +answering as you type, but saves either way — the installation may simply be +switched off right now. Double-click a connection to change it, drag to +reorder the tabs. + +With two or more connections the tab bar appears. In a window it floats over +the top of the dashboard, comes out while the pointer is on the title bar and +tucks away after; in full screen it comes down with the menu bar, and shows +for a moment after each switch. ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 switch from the +keyboard. A tab marked ⚠︎ is an installation that isn't answering. + +The menus are short on purpose: **View** has Back (⌘[), Forward (⌘]), Reload +Page (⌘R) and Enter Full Screen; the rest is what every Mac app has. **Edit** +is there because without it Copy and Paste do nothing in the dashboard's and +the admin page's text fields. **Help** opens the guide that ships in the app. + +**Settings → General**: **Open at login**, and **Keep the display awake** +(on by default) — while a dashboard is on screen, the display doesn't dim or +sleep. Minimised, hidden or covered, it lets go. + +## How it behaves + +- **Every dashboard stays live.** Each connection has its own web view, kept + running behind the one on screen, so switching is instant and nothing has + to reconnect. +- **The page never jumps.** In a window the tab bar is an overlay rather than + part of the title bar: anything in the title bar makes WebKit push the + whole page down to clear it, and a dashboard re-flowing every time the bar + came and went would be worse than no bar. In full screen it *is* a title + bar accessory — there the title bar lives in a window of its own, and the + page stays put. +- **It comes back by itself.** An installation that can't be reached shows + why (nothing answering, can't be found, no network — including macOS + withholding Local Network access) and is retried at 2, 4, 8 and then every + 15 seconds. Once a dashboard has loaded, ProdDash's own service worker and + reconnect logic carry it through a server restart. +- **It comes back as it was left.** The window's size and place, the tab that + was showing, and full screen: quit in full screen and it reopens in full + screen — so a booth Mac set to log in and open the Viewer at login returns + to the dashboard on its own after a restart. +- **It stays on ProdDash.** Links to the installation's own pages open in + the window; links anywhere else (a Planning Center item, the release notes) + open in the default browser, as do downloads. The page's `alert`, + `confirm` and `prompt` — the admin page confirms before deleting a layout, + Band Lineup prompts for names — show as sheets titled with the + installation's name. +- **The dashboard's own full-screen button works**, in addition to the + window's: it fills the screen with the dashboard alone; Esc leaves. +- Closing the window quits the app, as with System Settings — there is only + ever the one window. +- Safari's **Develop** menu can inspect a dashboard running in the Viewer. + +## Files + +``` +Sources/ + main.swift NSApplication, nothing else + AppDelegate.swift lifecycle and the menus + ViewerWindowController.swift the window: pages, tab bar overlay/accessory, + full screen, keyboard switching, display awake + DashboardPage.swift one connection's web view, status view, retry, + navigation policy, JavaScript dialogs + TabBar.swift the tabs (SwiftUI), shared by overlay and accessory + Connections.swift Connection, address rules, the store, the probe + ViewerSettings.swift preferences and the login item + SettingsWindowController.swift Settings: Connections and General + HelpWindowController.swift Help → ProdDash Viewer Help +Resources/ + Info.plist bundle id org.waterschurch.proddash.viewer + Help/index.html the guide shown by the Help menu + AppIcon.png, make-icon.swift the icon and what draws it +``` + +Preferences live in `~/Library/Preferences/org.waterschurch.proddash.viewer.plist` +(connections, the selected tab, full screen, keep awake); the web views' +storage — each dashboard's own per-browser settings — in WebKit's default +store for the app. diff --git a/viewer/macos/Resources/AppIcon.png b/viewer/macos/Resources/AppIcon.png new file mode 100644 index 0000000..cd2f457 Binary files /dev/null and b/viewer/macos/Resources/AppIcon.png differ diff --git a/viewer/macos/Resources/Help/index.html b/viewer/macos/Resources/Help/index.html new file mode 100644 index 0000000..7c2f1f9 --- /dev/null +++ b/viewer/macos/Resources/Help/index.html @@ -0,0 +1,78 @@ + + + + + +ProdDash Viewer Help + + + + +

ProdDash Viewer

+

A window onto ProdDash. It shows one installation’s dashboard — or several, with a tab for each — and nothing else.

+ +

Add an installation

+ + +

Switch between installations

+

With two or more connections, a slim tab bar of their names sits under the title bar. It tucks itself away so the dashboard has the whole window: point at the title bar and it slides back out.

+ + +

Full screen

+

Click the green button, or choose View → Enter Full Screen (fn F or ⌃⌘F). If the Viewer was in full screen when it quit, it opens in full screen again — so a booth Mac that restarts comes back as it was left.

+

The dashboard’s own full-screen button works too: it fills the screen with the dashboard alone, tab bar and all put away. Press Esc to leave it.

+ +

Moving around

+ + +

When an installation can’t be reached

+

The Viewer says why — nothing answering, the address can’t be found, no network — and keeps trying by itself, so a dashboard comes back on its own after ProdDash restarts or its Mac reboots. Try Again asks right away.

+

If macOS asked whether ProdDash Viewer may find devices on your local network and the answer was no, it can’t reach anything: allow it in System Settings → Privacy & Security → Local Network.

+ +

General settings

+ + +

Keyboard shortcuts

+ + + + + + + + +
⌘,Settings
⌘[   ⌘]Back, Forward
⌘RReload Page
fn F   ⌃⌘FEnter or leave full screen
⌃Tab   ⌃⇧TabNext, previous installation
⌘1 … ⌘9An installation by its place in the tab bar
⌘WClose the window, which quits the Viewer
+ + + diff --git a/viewer/macos/Resources/Info.plist b/viewer/macos/Resources/Info.plist new file mode 100644 index 0000000..eefb3c6 --- /dev/null +++ b/viewer/macos/Resources/Info.plist @@ -0,0 +1,44 @@ + + + + + CFBundleName + ProdDash Viewer + CFBundleDisplayName + ProdDash Viewer + CFBundleExecutable + ProdDashViewer + CFBundleIdentifier + org.waterschurch.proddash.viewer + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1.0.0 + CFBundleIconFile + AppIcon + NSHumanReadableCopyright + © 2026 Waters Church + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 13.0 + LSArchitecturePriority + + arm64 + + NSHighResolutionCapable + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + NSLocalNetworkUsageDescription + ProdDash Viewer shows the dashboards of the ProdDash installations you add in Settings, which run on this network. + + diff --git a/viewer/macos/Resources/make-icon.swift b/viewer/macos/Resources/make-icon.swift new file mode 100644 index 0000000..2d49fd7 --- /dev/null +++ b/viewer/macos/Resources/make-icon.swift @@ -0,0 +1,78 @@ +// Draws AppIcon.png, the Viewer's icon: ProdDash's 2×2 tile mark on a +// display, so the Viewer and ProdDash itself tell apart in Finder and the +// Dock. Run it again only to change the icon; build.sh uses the PNG. +// +// swift Resources/make-icon.swift Resources/AppIcon.png +import AppKit + +let side = 1024 +let output = CommandLine.arguments.dropFirst().first ?? "AppIcon.png" + +guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side, bitsPerSample: 8, + samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0), + let context = NSGraphicsContext(bitmapImageRep: bitmap) else { fatalError("no bitmap") } +NSGraphicsContext.current = context +let g = context.cgContext +// Top-left origin, as the measurements below are written. +g.translateBy(x: 0, y: CGFloat(side)) +g.scaleBy(x: 1, y: -1) + +func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor { + CGColor(srgbRed: CGFloat((hex >> 16) & 0xff) / 255, green: CGFloat((hex >> 8) & 0xff) / 255, + blue: CGFloat(hex & 0xff) / 255, alpha: alpha) +} +func rounded(_ rect: CGRect, _ radius: CGFloat) -> CGPath { + CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) +} +func fill(_ path: CGPath, _ fillColor: CGColor) { + g.addPath(path); g.setFillColor(fillColor); g.fillPath() +} + +// The body: Apple's icon grid — an 824-point rounded square, centred, on the +// soft shadow macOS icons sit on. ProdDash's own near-black. +let body = CGRect(x: 100, y: 100, width: 824, height: 824) +let bodyPath = rounded(body, 185) +g.saveGState() +g.setShadow(offset: CGSize(width: 0, height: 12), blur: 28, color: color(0x000000, 0.35)) +fill(bodyPath, color(0x0b0e12)) +g.restoreGState() +g.saveGState() +g.addPath(bodyPath); g.clip() +let shade = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), + colors: [color(0x1b222b), color(0x0a0d11)] as CFArray, locations: [0, 1])! +g.drawLinearGradient(shade, start: CGPoint(x: 0, y: body.minY), end: CGPoint(x: 0, y: body.maxY), options: []) +g.restoreGState() +g.addPath(rounded(body.insetBy(dx: 1.5, dy: 1.5), 183.5)) +g.setStrokeColor(color(0xffffff, 0.08)); g.setLineWidth(3); g.strokePath() + +// The display: bezel, screen, neck and foot. +let bezel = CGRect(x: 212, y: 250, width: 600, height: 410) +let metal = color(0x3a434f) +fill(rounded(CGRect(x: 474, y: 650, width: 76, height: 84), 8), metal) +fill(rounded(CGRect(x: 372, y: 724, width: 280, height: 36), 18), color(0x4a5462)) +fill(rounded(bezel, 44), metal) +let screen = bezel.insetBy(dx: 22, dy: 22) +fill(rounded(screen, 26), color(0x0c1015)) + +// ProdDash's mark on the screen: three outlined tiles and the lit one. +let padding: CGFloat = 46, gap: CGFloat = 30 +let tileW = (screen.width - padding * 2 - gap) / 2 +let tileH = (screen.height - padding * 2 - gap) / 2 +for (column, row) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let tile = CGRect(x: screen.minX + padding + CGFloat(column) * (tileW + gap), + y: screen.minY + padding + CGFloat(row) * (tileH + gap), + width: tileW, height: tileH) + if column == 1 && row == 1 { + fill(rounded(tile, 22), color(0x2fe39a)) + } else { + let inset = tile.insetBy(dx: 5, dy: 5) + fill(rounded(inset, 18), color(0x121820)) + g.addPath(rounded(inset, 18)); g.setStrokeColor(color(0x3d4652)); g.setLineWidth(10); g.strokePath() + } +} + +guard let png = bitmap.representation(using: .png, properties: [:]) else { fatalError("no png") } +try png.write(to: URL(fileURLWithPath: output)) +print("wrote \(output)") diff --git a/viewer/macos/Sources/AppDelegate.swift b/viewer/macos/Sources/AppDelegate.swift new file mode 100644 index 0000000..a659e51 --- /dev/null +++ b/viewer/macos/Sources/AppDelegate.swift @@ -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 + } +} diff --git a/viewer/macos/Sources/Connections.swift b/viewer/macos/Sources/Connections.swift new file mode 100644 index 0000000..6153350 --- /dev/null +++ b/viewer/macos/Sources/Connections.swift @@ -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)) + } + } +} diff --git a/viewer/macos/Sources/DashboardPage.swift b/viewer/macos/Sources/DashboardPage.swift new file mode 100644 index 0000000..9719995 --- /dev/null +++ b/viewer/macos/Sources/DashboardPage.swift @@ -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 + } + } +} diff --git a/viewer/macos/Sources/HelpWindowController.swift b/viewer/macos/Sources/HelpWindowController.swift new file mode 100644 index 0000000..f20ca8f --- /dev/null +++ b/viewer/macos/Sources/HelpWindowController.swift @@ -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) + } +} diff --git a/viewer/macos/Sources/SettingsWindowController.swift b/viewer/macos/Sources/SettingsWindowController.swift new file mode 100644 index 0000000..7af69a9 --- /dev/null +++ b/viewer/macos/Sources/SettingsWindowController.swift @@ -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(_ 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 + } +} diff --git a/viewer/macos/Sources/TabBar.swift b/viewer/macos/Sources/TabBar.swift new file mode 100644 index 0000000..5114c4e --- /dev/null +++ b/viewer/macos/Sources/TabBar.swift @@ -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 { + 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 : []) + } +} diff --git a/viewer/macos/Sources/ViewerSettings.swift b/viewer/macos/Sources/ViewerSettings.swift new file mode 100644 index 0000000..1cdf8c9 --- /dev/null +++ b/viewer/macos/Sources/ViewerSettings.swift @@ -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() + } +} diff --git a/viewer/macos/Sources/ViewerWindowController.swift b/viewer/macos/Sources/ViewerWindowController.swift new file mode 100644 index 0000000..403b96f --- /dev/null +++ b/viewer/macos/Sources/ViewerWindowController.swift @@ -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() + + 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)) + } +} diff --git a/viewer/macos/Sources/main.swift b/viewer/macos/Sources/main.swift new file mode 100644 index 0000000..5e8f441 --- /dev/null +++ b/viewer/macos/Sources/main.swift @@ -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() diff --git a/viewer/macos/build.sh b/viewer/macos/build.sh new file mode 100755 index 0000000..ceb038a --- /dev/null +++ b/viewer/macos/build.sh @@ -0,0 +1,149 @@ +#!/bin/zsh +# Build ProdDash Viewer.app — a window onto ProdDash installations, for Apple +# silicon Macs. Native Swift, no dependencies. +# +# ./build.sh build/ProdDash Viewer.app +# ./build.sh --dmg …and build/ProdDash-Viewer--arm64.dmg +# ./build.sh --install …and copy it to /Applications +# ./build.sh --run …and launch it +# ./build.sh --notarize with --dmg: notarise and staple (needs a +# Developer ID and a notarytool keychain profile) +# +# Signing works as ProdDash's own app does (launcher/macos/README.md): a +# "Developer ID Application" certificate when one is installed (override with +# CODESIGN_IDENTITY), ad-hoc when none is — fine on your own Macs, but another +# Mac asks before it opens an ad-hoc app. +# +# Needs Xcode (xcode-select pointing at it): with the command line tools +# alone, the macOS 27 SDK can't expand SwiftUI's @State macro. +set -e -u +SCRIPT="${0:A}" +cd "${SCRIPT:h}" + +APP_NAME="ProdDash Viewer" +EXECUTABLE="ProdDashViewer" +BUNDLE_ID="org.waterschurch.proddash.viewer" +VERSION="1.0.0" +ARCH="arm64" # Apple silicon +DEPLOYMENT_TARGET="13.0" + +OUT_DIR="build" +APP="$OUT_DIR/$APP_NAME.app" +CONTENTS="$APP/Contents" + +INSTALL=0 +RUN=0 +MAKE_DMG=0 +NOTARIZE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --install) INSTALL=1 ;; + --run) RUN=1 ;; + --dmg) MAKE_DMG=1 ;; + --notarize) NOTARIZE=1; MAKE_DMG=1 ;; + -h|--help) sed -n '2,18p' "$SCRIPT"; exit 0 ;; + *) echo "build.sh: unknown option $1" >&2; exit 2 ;; + esac + shift +done + +command -v swiftc >/dev/null || { + echo "build.sh: swiftc not found — install Xcode, then: sudo xcode-select -s /Applications/Xcode.app" >&2 + exit 1 +} + +echo "$APP_NAME $VERSION — building for $ARCH" + +rm -rf "$OUT_DIR" +mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" + +echo " compiling" +swiftc -O -whole-module-optimization \ + -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" \ + -module-name ProdDashViewer \ + -o "$CONTENTS/MacOS/$EXECUTABLE" \ + Sources/*.swift + +cp Resources/Info.plist "$CONTENTS/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist" >/dev/null +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$CONTENTS/Info.plist" >/dev/null +printf 'APPL????' > "$CONTENTS/PkgInfo" + +cp -R Resources/Help "$CONTENTS/Resources/Help" + +# Resources/AppIcon.png is drawn by Resources/make-icon.swift — ProdDash's +# tile mark on a display, so the Viewer and ProdDash itself tell apart. +echo " building the icon" +ICONSET="$OUT_DIR/AppIcon.iconset" +mkdir -p "$ICONSET" +for size in 16 32 128 256 512; do + sips -z $size $size Resources/AppIcon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null + double=$((size * 2)) + sips -z $double $double Resources/AppIcon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns" +rm -rf "$ICONSET" +find "$APP" -name '.DS_Store' -delete + +# ── signing ────────────────────────────────────────────────────────────── +IDENTITY="${CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" ]]; then + IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F'"' '/Developer ID Application/ {print $2; exit}') +fi +if [[ -n "$IDENTITY" ]]; then + echo " signing as $IDENTITY" + SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp) +else + echo " signing ad-hoc (no Developer ID installed — other Macs will ask before opening it)" + IDENTITY="-" + SIGN=(--force --sign -) +fi +codesign "${SIGN[@]}" --identifier "$BUNDLE_ID" "$APP" +codesign --verify --deep --strict "$APP" # stops the build if it doesn't +echo " signature verifies" + +echo "built $APP ($(du -sh "$APP" | awk '{print $1}'))" + +# ── disk image ─────────────────────────────────────────────────────────── +if [[ $MAKE_DMG -eq 1 ]]; then + DMG="$OUT_DIR/ProdDash-Viewer-$VERSION-$ARCH.dmg" + STAGE="$OUT_DIR/dmg" + rm -rf "$STAGE" + mkdir -p "$STAGE" + cp -R "$APP" "$STAGE/" + ln -s /Applications "$STAGE/Applications" # drag-to-install, the usual way + hdiutil create -volname "$APP_NAME $VERSION" -srcfolder "$STAGE" \ + -ov -format UDZO -quiet "$DMG" + rm -rf "$STAGE" + [[ "$IDENTITY" == "-" ]] || codesign --force --sign "$IDENTITY" --timestamp "$DMG" + echo "built $DMG ($(du -sh "$DMG" | awk '{print $1}'))" + + if [[ $NOTARIZE -eq 1 ]]; then + if [[ "$IDENTITY" == "-" ]]; then + echo "build.sh: can't notarise an ad-hoc signed app — a Developer ID is required" >&2 + exit 1 + fi + PROFILE="${NOTARY_PROFILE:-proddash}" + echo " notarising as keychain profile \"$PROFILE\" (a few minutes)" + xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait + xcrun stapler staple "$DMG" + echo " notarised and stapled — this opens anywhere with no warning" + fi +fi + +if [[ $INSTALL -eq 1 ]]; then + DEST="/Applications/$APP_NAME.app" + if pgrep -x "$EXECUTABLE" >/dev/null; then + osascript -e "tell application id \"$BUNDLE_ID\" to quit" >/dev/null 2>&1 || true + sleep 1 + fi + rm -rf "$DEST" + cp -R "$APP" "$DEST" + echo "installed $DEST" + APP="$DEST" +fi + +[[ $RUN -eq 1 ]] && open "$APP" +exit 0