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>
300 lines
11 KiB
Swift
300 lines
11 KiB
Swift
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
|
||
}
|
||
}
|