Compare commits

...
1 Commits
Author SHA1 Message Date
Waters ChurchandClaude Opus 5.5 8d8c0ee0c3 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>
2026-10-25 13:05:14 -04:00
18 changed files with 2229 additions and 2 deletions
+3
View File
@@ -13,3 +13,6 @@ launcher/macos/build/
launcher/macos/.cache/ launcher/macos/.cache/
launcher/windows/build/ launcher/windows/build/
launcher/windows/.cache/ launcher/windows/.cache/
# the Viewer's build output
viewer/macos/build/
+5 -2
View File
@@ -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 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 (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships `/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 `launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
the server and its own Node runtime, supervises them, and owns the macOS 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 config, named layouts) is written to a per-machine data directory outside the
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
kept") — never add code that writes runtime state into the repo folder. kept") — never add code that writes runtime state into the repo folder.
+16
View File
@@ -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 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). 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 ## Install as a desktop web app
Open the page in a browser first, then: Open the page in a browser first, then:
@@ -648,6 +661,9 @@ ProdDash/
├── package.json name + version (what the update check compares) ├── package.json name + version (what the update check compares)
├── config/ proddash.json defaults (runtime state lives in the ├── config/ proddash.json defaults (runtime state lives in the
│ per-machine data directory — see "Configure") │ 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/MODULE-GUIDE.md how to build and install a module
├── docs/ALERTS.md the alerts feature: levels, channels, admin, API ├── docs/ALERTS.md the alerts feature: levels, channels, admin, API
├── tools/prodcom-mock.js mock ProdCom API for development ├── tools/prodcom-mock.js mock ProdCom API for development
+121
View File
@@ -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-<version>-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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ProdDash Viewer Help</title>
<style>
:root { color-scheme: light dark; --muted: #6e6e73; --rule: #d2d2d7; --key: #f2f2f7; }
@media (prefers-color-scheme: dark) { :root { --muted: #98989d; --rule: #3a3a3c; --key: #2c2c2e; } }
body { font: 13px/1.55 -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 28px 36px 48px; max-width: 640px; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 28px 0 6px; padding-top: 18px; border-top: 1px solid var(--rule); }
p, li { margin: 6px 0; }
ul { padding-left: 20px; }
.lead { color: var(--muted); margin-bottom: 18px; }
kbd { font: 12px -apple-system, sans-serif; background: var(--key); border: 1px solid var(--rule); border-radius: 4px; padding: 0 5px; }
code { font: 12px ui-monospace, Menlo, monospace; }
table { border-collapse: collapse; width: 100%; margin-top: 8px; }
td { padding: 5px 0; border-bottom: 1px solid var(--rule); vertical-align: top; }
td:first-child { width: 42%; white-space: nowrap; }
</style>
</head>
<body>
<h1>ProdDash Viewer</h1>
<p class="lead">A window onto ProdDash. It shows one installation’s dashboard — or several, with a tab for each — and nothing else.</p>
<h2>Add an installation</h2>
<ul>
<li>Choose <b>ProdDash Viewer → Settings…</b> (<kbd>⌘,</kbd>), then <b>Connections</b>, then <b>+</b>.</li>
<li><b>Name</b> is what its tab says — “AB Booth”, “NA Ingest”.</li>
<li><b>Address</b> is the one you open ProdDash at in a browser, such as <code>10.3.11.152:24500</code>. Leave the port off and ProdDash’s own, 24500, is used. The sheet tells you as you type whether ProdDash is answering there.</li>
<li>Double-click a connection to change it; drag connections to change the order of the tabs.</li>
</ul>
<h2>Switch between installations</h2>
<p>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.</p>
<ul>
<li>In full screen, move the pointer to the top of the screen — the tab bar comes down with the menu bar. It also shows for a moment after each switch, so you can see where you are.</li>
<li><kbd>⌃Tab</kbd> and <kbd>⌃⇧Tab</kbd> go to the next and previous installation; <kbd>⌘1</kbd>–<kbd>⌘8</kbd> go straight to one, <kbd>⌘9</kbd> to the last.</li>
<li>Every dashboard stays live in the background, so switching is instant.</li>
<li>A tab with <b>⚠︎</b> is an installation that isn’t answering.</li>
</ul>
<h2>Full screen</h2>
<p>Click the green button, or choose <b>View → Enter Full Screen</b> (<kbd>fn F</kbd> or <kbd>⌃⌘F</kbd>). 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.</p>
<p>The dashboard’s own full-screen button works too: it fills the screen with the dashboard alone, tab bar and all put away. Press <kbd>Esc</kbd> to leave it.</p>
<h2>Moving around</h2>
<ul>
<li><b>View → Back</b> (<kbd>⌘[</kbd>), <b>Forward</b> (<kbd>⌘]</kbd>) — after opening the admin page from the dashboard, for example. Swiping with two fingers works as in Safari.</li>
<li><b>View → Reload Page</b> (<kbd>⌘R</kbd>).</li>
<li>Links to anywhere outside the installation open in your web browser.</li>
</ul>
<h2>When an installation can’t be reached</h2>
<p>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. <b>Try Again</b> asks right away.</p>
<p>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 <b>System Settings → Privacy &amp; Security → Local Network</b>.</p>
<h2>General settings</h2>
<ul>
<li><b>Open at login</b> — start the Viewer when this Mac’s user logs in. macOS may ask you to approve it in System Settings first.</li>
<li><b>Keep the display awake</b> — while a dashboard is on screen, the display doesn’t dim or sleep. On unless you turn it off.</li>
</ul>
<h2>Keyboard shortcuts</h2>
<table>
<tr><td><kbd>⌘,</kbd></td><td>Settings</td></tr>
<tr><td><kbd>⌘[</kbd> &nbsp; <kbd>⌘]</kbd></td><td>Back, Forward</td></tr>
<tr><td><kbd>⌘R</kbd></td><td>Reload Page</td></tr>
<tr><td><kbd>fn F</kbd> &nbsp; <kbd>⌃⌘F</kbd></td><td>Enter or leave full screen</td></tr>
<tr><td><kbd>⌃Tab</kbd> &nbsp; <kbd>⌃⇧Tab</kbd></td><td>Next, previous installation</td></tr>
<tr><td><kbd>⌘1</kbd> … <kbd>⌘9</kbd></td><td>An installation by its place in the tab bar</td></tr>
<tr><td><kbd>⌘W</kbd></td><td>Close the window, which quits the Viewer</td></tr>
</table>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>ProdDash Viewer</string>
<key>CFBundleDisplayName</key>
<string>ProdDash Viewer</string>
<key>CFBundleExecutable</key>
<string>ProdDashViewer</string>
<key>CFBundleIdentifier</key>
<string>org.waterschurch.proddash.viewer</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026 Waters Church</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSArchitecturePriority</key>
<array>
<string>arm64</string>
</array>
<key>NSHighResolutionCapable</key>
<true/>
<!-- ProdDash is served over plain http on the local network, and the
connection sheet asks each address whether ProdDash is there. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- macOS shows this sentence verbatim when it asks. -->
<key>NSLocalNetworkUsageDescription</key>
<string>ProdDash Viewer shows the dashboards of the ProdDash installations you add in Settings, which run on this network.</string>
</dict>
</plist>
+78
View File
@@ -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)")
+110
View File
@@ -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
}
}
+169
View File
@@ -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))
}
}
}
+425
View File
@@ -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
}
}
}
@@ -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)
}
}
@@ -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<Content: View>(_ 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
}
}
+77
View File
@@ -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<TabBar> {
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 : [])
}
}
+61
View File
@@ -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()
}
}
@@ -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<AnyCancellable>()
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))
}
}
+8
View File
@@ -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()
+149
View File
@@ -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-<version>-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