396 lines
14 KiB
Swift
396 lines
14 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, 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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|