Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d8c0ee0c3 |
@@ -13,3 +13,6 @@ launcher/macos/build/
|
|||||||
launcher/macos/.cache/
|
launcher/macos/.cache/
|
||||||
launcher/windows/build/
|
launcher/windows/build/
|
||||||
launcher/windows/.cache/
|
launcher/windows/.cache/
|
||||||
|
|
||||||
|
# the Viewer's build output
|
||||||
|
viewer/macos/build/
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ Modular production dashboard web app for Waters Church, developed in this repo.
|
|||||||
The app lives at the repo root and is built: zero-dependency Node server
|
The app lives at the repo root and is built: zero-dependency Node server
|
||||||
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
||||||
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships
|
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships
|
||||||
none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in
|
none and installs them from the admin page's catalog), a native macOS menu-bar launcher in
|
||||||
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||||
the server and its own Node runtime, supervises them, and owns the macOS
|
the server and its own Node runtime, supervises them, and owns the macOS
|
||||||
permissions modules declare; see its README). Runtime state (module
|
permissions modules declare; see its README), and **ProdDash Viewer** in
|
||||||
|
`viewer/macos/` (Swift, no dependencies — a separate Apple-silicon app that
|
||||||
|
only displays one or more ProdDash installations in WebKit, with an
|
||||||
|
auto-hiding tab bar; it runs no server; see its README). Runtime state (module
|
||||||
config, named layouts) is written to a per-machine data directory outside the
|
config, named layouts) is written to a per-machine data directory outside the
|
||||||
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
|
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
|
||||||
kept") — never add code that writes runtime state into the repo folder.
|
kept") — never add code that writes runtime state into the repo folder.
|
||||||
|
|||||||
@@ -618,6 +618,19 @@ sheet's notes, as ProPresenter does for Vista — point Vista Cues' first
|
|||||||
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
|
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
|
||||||
the [module guide](docs/MODULE-GUIDE.md).
|
the [module guide](docs/MODULE-GUIDE.md).
|
||||||
|
|
||||||
|
## ProdDash Viewer — a Mac that only shows the dashboard
|
||||||
|
|
||||||
|
For a Mac that should show ProdDash and nothing else — a booth display, a
|
||||||
|
second screen watching the other campus — there is **ProdDash Viewer**
|
||||||
|
([viewer/macos](viewer/macos)): one window, the dashboard, and a slim
|
||||||
|
auto-hiding tab bar when it is set up with more than one installation. It
|
||||||
|
runs no server of its own; point it at any ProdDash on the network. Full
|
||||||
|
screen, reopening as it was left, and keeping the display awake are built in.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd viewer/macos && ./build.sh --dmg
|
||||||
|
```
|
||||||
|
|
||||||
## Install as a desktop web app
|
## Install as a desktop web app
|
||||||
|
|
||||||
Open the page in a browser first, then:
|
Open the page in a browser first, then:
|
||||||
@@ -648,6 +661,9 @@ ProdDash/
|
|||||||
├── package.json name + version (what the update check compares)
|
├── package.json name + version (what the update check compares)
|
||||||
├── config/ proddash.json defaults (runtime state lives in the
|
├── config/ proddash.json defaults (runtime state lives in the
|
||||||
│ per-machine data directory — see "Configure")
|
│ per-machine data directory — see "Configure")
|
||||||
|
├── launcher/macos/ the macOS app: menu-bar launcher carrying the server
|
||||||
|
│ and its own Node (launcher/windows: the Windows bundle)
|
||||||
|
├── viewer/macos/ ProdDash Viewer: a Mac app that only shows dashboards
|
||||||
├── docs/MODULE-GUIDE.md how to build and install a module
|
├── docs/MODULE-GUIDE.md how to build and install a module
|
||||||
├── docs/ALERTS.md the alerts feature: levels, channels, admin, API
|
├── docs/ALERTS.md the alerts feature: levels, channels, admin, API
|
||||||
├── tools/prodcom-mock.js mock ProdCom API for development
|
├── tools/prodcom-mock.js mock ProdCom API for development
|
||||||
|
|||||||
@@ -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