Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d8c0ee0c3 |
@@ -13,3 +13,6 @@ launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
launcher/windows/build/
|
||||
launcher/windows/.cache/
|
||||
|
||||
# the Viewer's build output
|
||||
viewer/macos/build/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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 & 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> <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> <kbd>⌃⌘F</kbd></td><td>Enter or leave full screen</td></tr>
|
||||
<tr><td><kbd>⌃Tab</kbd> <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>
|
||||
@@ -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>
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 : [])
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Executable
+149
@@ -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
|
||||
Reference in New Issue
Block a user