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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c043834d1e
commit
8d8c0ee0c3
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user