Files
ProdDash/viewer/macos/Sources/SettingsWindowController.swift
T
Waters ChurchandClaude Opus 5.5 8d8c0ee0c3 ProdDash Viewer 1.0.0: a Mac app that only shows ProdDash, with a tab per installation
A new Apple-silicon app in viewer/macos (Swift, no dependencies, ~1 MB): one
window showing a ProdDash dashboard in WebKit, and nothing else. It runs no
server; it points at any ProdDash on the network.

- Connections (name + address) in Settings; the add/edit sheet asks
  /api/modules whether ProdDash answers as you type. No port means 24500.
- Two or more connections bring a slim tab bar of their names. In a window
  it floats over the dashboard and comes out while the pointer is on the
  title bar; it is not part of the title bar, because anything there makes
  WebKit push the page down. In full screen it is a titlebar accessory that
  comes down with the menu bar, and it flashes up after each switch.
  ⌃Tab / ⌃⇧Tab and ⌘1–9 switch from the keyboard.
- Full screen, reopening in full screen if it quit there; window size and
  the selected tab are kept too. The dashboard's own full-screen button
  works as well.
- Menus: View (Back, Forward, Reload Page, Enter Full Screen), Settings,
  Edit (copy/paste in the page's text fields), Window, Help (a guide that
  ships in the app).
- Every installation keeps its own live web view. One that can't be
  reached says why and retries at 2, 4, 8, then every 15 s; a redirect off
  the installation is reported, not handed to the browser. JS alert,
  confirm and prompt show as sheets; links to other sites open in the
  browser.
- General settings: open at login (SMAppService), keep the display awake
  while a dashboard is on screen (default on).

build.sh signs with the Developer ID when one is installed and makes a
notarised DMG with --notarize, as the launcher's does. It needs Xcode: with
only the command line tools the macOS 27 SDK can't expand SwiftUI's @State.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:05:14 -04:00

300 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}