A pass for less prose and fewer boxes. - Header shows the version and node; the port is already in the addresses below it, and the module list belonged in the log. - Settings: "Network" over the port (no explanation under it), "Startup" instead of "Starting up", and no node chooser — the path stays visible, since which node runs is the first thing worth knowing when it won't. - Permissions: one line per permission — a status dot, its name, and the one thing you can do about it. The standing explanation is gone and the modules' own reasons moved to each row's tooltip. Local network results still appear under their row after a check, with a short line and a link only when nothing answered at all. Re-check sits centred at the bottom, outside the group. The app-level "notify me" switch goes with it: macOS's own per-app notification setting already does that job, so notifications now follow the permission alone, asked for by the row's Enable button rather than on first run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
372 lines
13 KiB
Swift
372 lines
13 KiB
Swift
import AVFoundation
|
|
import AppKit
|
|
import SwiftUI
|
|
import UserNotifications
|
|
|
|
/**
|
|
The launcher window: what ProdDash is doing at the top, and the three things
|
|
an operator ever needs underneath — what it is saying (Log), how it runs
|
|
(Settings), and what macOS is letting it do (Permissions).
|
|
*/
|
|
struct LauncherView: View {
|
|
|
|
@ObservedObject var server: ServerController
|
|
@ObservedObject var settings: LauncherSettings
|
|
@ObservedObject var permissions: PermissionsModel
|
|
let onHide: () -> Void
|
|
let onQuit: () -> Void
|
|
|
|
private enum Tab: String, CaseIterable, Identifiable {
|
|
case log = "Log", settings = "Settings", permissions = "Permissions"
|
|
var id: String { rawValue }
|
|
}
|
|
@State private var tab: Tab = .log
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
addresses
|
|
Divider()
|
|
Picker("", selection: $tab) {
|
|
ForEach(Tab.allCases) { Text($0.rawValue).tag($0) }
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
|
|
Group {
|
|
switch tab {
|
|
case .log:
|
|
LogPane()
|
|
case .settings:
|
|
SettingsPane(server: server, settings: settings)
|
|
case .permissions:
|
|
PermissionsPane(server: server, permissions: permissions)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
Divider()
|
|
footer
|
|
}
|
|
.frame(minWidth: 540, idealWidth: 580, maxWidth: .infinity,
|
|
minHeight: 520, idealHeight: 740, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 14) {
|
|
Image(nsImage: NSApp.applicationIconImage)
|
|
.resizable()
|
|
.frame(width: 46, height: 46)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("ProdDash").font(.system(size: 19, weight: .semibold))
|
|
Text(subtitle).font(.callout).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing, spacing: 5) {
|
|
StatusPill(state: server.state, healthy: server.healthy)
|
|
if !server.message.isEmpty {
|
|
Text(server.message)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(maxWidth: 260, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 22)
|
|
.padding(.bottom, 14)
|
|
}
|
|
|
|
private var subtitle: String {
|
|
var parts: [String] = []
|
|
if !server.shellVersion.isEmpty { parts.append("v\(server.shellVersion)") }
|
|
if !server.nodeVersion.isEmpty { parts.append("node \(server.nodeVersion)") }
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
// MARK: - Addresses and the controls that matter
|
|
|
|
private var addresses: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
if server.state == .running {
|
|
AddressRow(label: "This Mac", url: server.localURL.isEmpty
|
|
? "http://localhost:\(server.port)" : server.localURL)
|
|
ForEach(server.networkURLs, id: \.self) { url in
|
|
AddressRow(label: "Network", url: url)
|
|
}
|
|
} else {
|
|
Text("Not serving. ProdDash will come up on port " + String(server.port) + ".")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
switch server.state {
|
|
case .running, .starting:
|
|
Button("Stop") { server.stop() }
|
|
case .stopping:
|
|
Button("Stopping…") {}.disabled(true)
|
|
case .stopped, .failed:
|
|
Button("Start") { server.start() }.keyboardShortcut(.defaultAction)
|
|
}
|
|
Button("Restart") { server.restart() }
|
|
.disabled(server.state != .running && server.state != .starting)
|
|
Spacer()
|
|
Button("Open Dashboard") { server.openDashboard() }
|
|
.disabled(server.state != .running)
|
|
Button("Admin") { server.openAdmin() }
|
|
.disabled(server.state != .running)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
}
|
|
|
|
// MARK: - Footer
|
|
|
|
private var footer: some View {
|
|
HStack {
|
|
Button("Hide") { onHide() }
|
|
.help("Closes this window; ProdDash keeps running in the menu bar.")
|
|
Spacer()
|
|
Text("Launcher \(AppDelegate.version)")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
Spacer()
|
|
Button("Quit ProdDash") { onQuit() }
|
|
.help("Stops the server and quits the launcher.")
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
}
|
|
|
|
// MARK: - Pieces
|
|
|
|
private struct StatusPill: View {
|
|
let state: ServerState
|
|
let healthy: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: 7) {
|
|
Circle()
|
|
.fill(Color(nsColor: StatusIcon.color(for: state, healthy: healthy)))
|
|
.frame(width: 9, height: 9)
|
|
Text(state == .running && !healthy ? "Running (not answering)" : state.label)
|
|
.font(.system(size: 13, weight: .medium))
|
|
}
|
|
.padding(.horizontal, 11)
|
|
.padding(.vertical, 5)
|
|
.background(Color(nsColor: .quaternaryLabelColor).opacity(0.5), in: Capsule())
|
|
}
|
|
}
|
|
|
|
private struct AddressRow: View {
|
|
let label: String
|
|
let url: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 58, alignment: .leading)
|
|
Text(url)
|
|
.font(.system(.callout, design: .monospaced))
|
|
.textSelection(.enabled)
|
|
Button {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(url, forType: .string)
|
|
} label: {
|
|
Image(systemName: "doc.on.doc")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("Copy this address")
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Log
|
|
|
|
private struct LogPane: View {
|
|
@ObservedObject private var log = LogStore.shared
|
|
@State private var follow = true
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 2) {
|
|
ForEach(log.lines) { line in
|
|
Text(line.text)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(color(for: line.kind))
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.id(line.id)
|
|
}
|
|
Color.clear.frame(height: 1).id("bottom")
|
|
}
|
|
.padding(10)
|
|
}
|
|
.background(Color(nsColor: .textBackgroundColor))
|
|
.onChange(of: log.lines.count) { _ in
|
|
guard follow else { return }
|
|
withAnimation(.none) { proxy.scrollTo("bottom", anchor: .bottom) }
|
|
}
|
|
.onAppear { proxy.scrollTo("bottom", anchor: .bottom) }
|
|
}
|
|
HStack(spacing: 8) {
|
|
Toggle("Follow", isOn: $follow).toggleStyle(.checkbox)
|
|
Spacer()
|
|
Button("Copy") {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(log.plainText, forType: .string)
|
|
}
|
|
Button("Reveal Log File") {
|
|
NSWorkspace.shared.activateFileViewerSelecting([log.currentFile])
|
|
}
|
|
Button("Clear") { log.clear() }
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
}
|
|
|
|
private func color(for kind: LogStore.Kind) -> Color {
|
|
switch kind {
|
|
case .launcher: return .accentColor
|
|
case .err: return .orange
|
|
case .out: return .primary
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Settings
|
|
|
|
private struct SettingsPane: View {
|
|
@ObservedObject var server: ServerController
|
|
@ObservedObject var settings: LauncherSettings
|
|
|
|
@State private var portText = ""
|
|
@State private var loginItemOn = LoginItem.isEnabled
|
|
@State private var loginNeedsApproval = LoginItem.needsApproval
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section("Network") {
|
|
HStack {
|
|
TextField("Port", text: $portText)
|
|
.frame(width: 90)
|
|
.onSubmit(applyPort)
|
|
Button("Apply", action: applyPort)
|
|
.disabled(Int(portText) == server.port || Int(portText) == nil)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
Section("Startup") {
|
|
Toggle("Open ProdDash at login", isOn: Binding(
|
|
get: { loginItemOn },
|
|
set: { setLoginItem($0) }))
|
|
if loginNeedsApproval {
|
|
HStack(spacing: 8) {
|
|
Text("macOS is holding this until you allow it.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Button("Open Login Items") { LoginItem.openSystemSettings() }
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
Toggle("Start the server when the launcher opens", isOn: $settings.startServerAtLaunch)
|
|
Toggle("Restart the server if it stops unexpectedly", isOn: $settings.restartOnCrash)
|
|
Toggle("Open the dashboard in a browser when the server starts", isOn: $settings.openDashboardOnStart)
|
|
Toggle("Show this window at launch", isOn: $settings.showWindowAtLaunch)
|
|
}
|
|
|
|
Section("Where things are") {
|
|
PathRow(title: "ProdDash folder",
|
|
value: server.rootURL?.path ?? "Not found — choose it",
|
|
missing: server.rootURL == nil) {
|
|
chooseFolder()
|
|
}
|
|
PathRow(title: "Node.js",
|
|
value: server.nodeURL.map { "\($0.path) \(server.nodeVersion)" } ?? "Not found",
|
|
missing: server.nodeURL == nil, action: nil)
|
|
PathRow(title: "Settings", value: server.settingsDir.isEmpty
|
|
? server.dataDir.path : server.settingsDir, missing: false, action: nil)
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.onAppear { portText = String(server.port) }
|
|
.onChange(of: server.port) { newValue in portText = String(newValue) }
|
|
}
|
|
|
|
private func applyPort() {
|
|
guard let port = Int(portText) else {
|
|
portText = String(server.port)
|
|
return
|
|
}
|
|
server.applyPort(port)
|
|
portText = String(server.port)
|
|
}
|
|
|
|
private func setLoginItem(_ on: Bool) {
|
|
if let error = LoginItem.set(on) {
|
|
LogStore.shared.launcher("couldn't change the login item: \(error)")
|
|
}
|
|
loginItemOn = LoginItem.isEnabled
|
|
loginNeedsApproval = LoginItem.needsApproval
|
|
}
|
|
|
|
private func chooseFolder() {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseDirectories = true
|
|
panel.canChooseFiles = false
|
|
panel.message = "Choose the ProdDash folder — the one with server.js in it."
|
|
panel.prompt = "Choose"
|
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
|
guard ProdDash.isRoot(url) else {
|
|
let alert = NSAlert()
|
|
alert.messageText = "That isn't a ProdDash folder"
|
|
alert.informativeText = "Pick the folder that contains server.js and public/."
|
|
alert.runModal()
|
|
return
|
|
}
|
|
settings.rootPath = url.path
|
|
server.refreshEnvironment()
|
|
}
|
|
|
|
}
|
|
|
|
private struct PathRow: View {
|
|
let title: String
|
|
let value: String
|
|
let missing: Bool
|
|
var action: (() -> Void)?
|
|
|
|
var body: some View {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
Text(value)
|
|
.font(.caption)
|
|
.foregroundStyle(missing ? Color.orange : Color.secondary)
|
|
.textSelection(.enabled)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
}
|
|
Spacer()
|
|
if let action {
|
|
Button("Choose…", action: action).controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
}
|