Files
ProdDash/launcher/macos/Sources/LauncherView.swift
T

396 lines
14 KiB
Swift
Raw Normal View History

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, settings: settings, 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)") }
parts.append("port \(server.port)")
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)
}
if !server.modulesSummary.isEmpty {
Text("Modules: \(server.modulesSummary)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
} 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("ProdDash") {
HStack {
TextField("Port", text: $portText)
.frame(width: 90)
.onSubmit(applyPort)
Button("Apply", action: applyPort)
.disabled(Int(portText) == server.port || Int(portText) == nil)
Spacer()
}
Text("Saved to ProdDash's own settings, so it keeps this port however it is started. "
+ "Changing it while the server runs restarts it.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Starting up") {
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) {
chooseNode()
}
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 func chooseNode() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.message = "Choose the node binary ProdDash should run on."
panel.prompt = "Choose"
panel.directoryURL = URL(fileURLWithPath: "/usr/local/bin")
guard panel.runModal() == .OK, let url = panel.url else { return }
settings.nodePath = 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)
}
}
}
}