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,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user