import AVFoundation import SwiftUI import UserNotifications /** The permissions pane. This is the reason the launcher exists as much as the start button is: macOS grants privacy permissions to the *app* that owns the process tree, so when ProdDash runs as this app's child, the prompts are asked in ProdDash's name and stay granted. What each module needs is read from its own manifest, so this list matches what is actually installed. */ struct PermissionsPane: View { @ObservedObject var server: ServerController @ObservedObject var settings: LauncherSettings @ObservedObject var permissions: PermissionsModel var body: some View { Form { Section { Text("ProdDash's modules ask macOS for these through the launcher. Start the server any other way " + "— over SSH, from a launchd job — and the permissions belong to that instead, which is why " + "LTC goes quietly silent when it isn't launched from here.") .font(.caption) .foregroundStyle(.secondary) } if permissions.needs.isEmpty { Section("Modules") { Text("No installed module declares a permission.") .foregroundStyle(.secondary) } } ForEach(permissions.needs) { need in Section(need.kind.title) { NeedHeader(need: need) switch need.kind { case .microphone: microphoneControls case .localNetwork: localNetworkControls case .other: Text("The launcher doesn't manage this one — grant it in System Settings → Privacy & Security.") .font(.caption) .foregroundStyle(.secondary) } } } Section("Notifications") { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 3) { Text("Tell me when ProdDash stops") Text("The launcher's own — so a server that stops on a booth machine nobody is watching " + "still reaches someone.") .font(.caption) .foregroundStyle(.secondary) } Spacer() StatusText(text: notificationLabel, tone: notificationTone) } Toggle("Notify me", isOn: Binding( get: { settings.notifyOnProblem }, set: { on in settings.notifyOnProblem = on if on { permissions.requestNotifications() } })) if permissions.notifications == .denied { Button("Open Notification Settings") { SystemSettings.open(SystemSettings.notifications) } .controlSize(.small) } } Section { Button("Re-check") { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) } } } .formStyle(.grouped) .onAppear { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) } } // MARK: - Microphone private var microphoneControls: some View { HStack { StatusText(text: permissions.microphone.description, tone: microphoneTone) Spacer() switch permissions.microphone { case .notDetermined: Button("Allow…") { permissions.requestMicrophone() } case .denied, .restricted: Button("Open System Settings") { SystemSettings.open(SystemSettings.microphone) } default: Button("Open System Settings") { SystemSettings.open(SystemSettings.microphone) } .controlSize(.small) } } } private var microphoneTone: Tone { switch permissions.microphone { case .authorized: return .good case .denied, .restricted: return .bad default: return .unknown } } // MARK: - Local network private var localNetworkControls: some View { VStack(alignment: .leading, spacing: 8) { Text("macOS never reports whether this was granted, so the check below asks the network directly: " + "it browses for services (which is what raises the prompt) and tries every upstream the admin " + "page is pointed at.") .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 8) { Button(permissions.checking ? "Checking…" : "Check Access") { permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir) } .disabled(permissions.checking) if permissions.checking { ProgressView().controlSize(.small) } Spacer() Button("Open System Settings") { SystemSettings.open(SystemSettings.localNetwork) } .controlSize(.small) } ForEach(permissions.hostChecks) { check in HStack(spacing: 8) { Circle() .fill(tone(for: check.result).color) .frame(width: 7, height: 7) Text(check.label) .font(.system(.caption, design: .monospaced)) Text(check.module) .font(.caption) .foregroundStyle(.tertiary) Spacer() Text(describe(check.result)) .font(.caption) .foregroundStyle(.secondary) } } if !permissions.localNetworkVerdict.isEmpty { Text(permissions.localNetworkVerdict) .font(.caption) .foregroundStyle(permissions.localNetworkVerdict.contains("working") ? .secondary : .primary) } } } private func describe(_ result: Net.Probe?) -> String { switch result { case .none: return "—" case .open: return "answered" case .refused: return "reachable, nothing on that port" case .unreachable: return "no route" case .timedOut: return "no answer" case .failed(let why): return why } } private func tone(for result: Net.Probe?) -> Tone { guard let result else { return .unknown } return result.isReachableHost ? .good : .bad } private var notificationLabel: String { switch permissions.notifications { case .authorized, .provisional, .ephemeral: return "granted" case .denied: return "denied" case .notDetermined: return "not asked yet" @unknown default: return "unknown" } } private var notificationTone: Tone { switch permissions.notifications { case .authorized, .provisional, .ephemeral: return .good case .denied: return .bad default: return .unknown } } } // MARK: - Small pieces enum Tone { case good, bad, unknown var color: Color { switch self { case .good: return Color(nsColor: StatusIcon.accent) case .bad: return Color(nsColor: StatusIcon.danger) case .unknown: return .secondary } } } private struct StatusText: View { let text: String let tone: Tone var body: some View { HStack(spacing: 6) { Circle().fill(tone.color).frame(width: 7, height: 7) Text(text).font(.callout) } } } private struct NeedHeader: View { let need: ModuleNeed var body: some View { VStack(alignment: .leading, spacing: 8) { ForEach(need.claims) { claim in VStack(alignment: .leading, spacing: 3) { Text(claim.reason) Text("Needed by \(claim.askedBy)") .font(.caption) .foregroundStyle(.secondary) } } } } }