2026-09-11 10:27:08 -04:00
|
|
|
import AVFoundation
|
|
|
|
|
import SwiftUI
|
|
|
|
|
import UserNotifications
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-11 11:10:22 -04:00
|
|
|
The permissions pane: one line per permission — a status dot, its name, and
|
|
|
|
|
the one thing you can do about it.
|
2026-09-11 10:27:08 -04:00
|
|
|
|
2026-09-11 11:10:22 -04:00
|
|
|
What appears here is what the installed modules declare in their manifests,
|
|
|
|
|
so the list matches what is actually installed. Each row's tooltip carries
|
|
|
|
|
the modules' own reasons; the row itself stays out of the way.
|
2026-09-11 10:27:08 -04:00
|
|
|
*/
|
|
|
|
|
struct PermissionsPane: View {
|
|
|
|
|
|
|
|
|
|
@ObservedObject var server: ServerController
|
|
|
|
|
@ObservedObject var permissions: PermissionsModel
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-09-11 11:10:22 -04:00
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
Form {
|
|
|
|
|
Section {
|
|
|
|
|
if permissions.needs.isEmpty {
|
|
|
|
|
Text("No installed module declares a permission.")
|
2026-09-11 10:27:08 -04:00
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
2026-09-11 11:10:22 -04:00
|
|
|
ForEach(permissions.needs) { need in
|
|
|
|
|
row(for: need)
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
2026-09-11 11:10:22 -04:00
|
|
|
notifications
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-11 11:10:22 -04:00
|
|
|
.formStyle(.grouped)
|
2026-09-11 10:27:08 -04:00
|
|
|
|
2026-09-11 11:10:22 -04:00
|
|
|
Button("Re-check") {
|
|
|
|
|
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
2026-09-11 11:10:22 -04:00
|
|
|
.padding(.bottom, 14)
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
|
|
|
|
.onAppear { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) }
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 11:10:22 -04:00
|
|
|
// MARK: - Rows
|
2026-09-11 10:27:08 -04:00
|
|
|
|
2026-09-11 11:10:22 -04:00
|
|
|
@ViewBuilder
|
|
|
|
|
private func row(for need: ModuleNeed) -> some View {
|
|
|
|
|
switch need.kind {
|
|
|
|
|
case .microphone:
|
|
|
|
|
PermissionRow(title: need.kind.title,
|
|
|
|
|
tone: microphoneTone,
|
|
|
|
|
help: help(for: need),
|
|
|
|
|
actionTitle: microphoneActionTitle,
|
|
|
|
|
action: microphoneActionTitle == nil ? nil : { requestMicrophone() })
|
|
|
|
|
|
|
|
|
|
case .localNetwork:
|
|
|
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
|
|
|
PermissionRow(title: need.kind.title,
|
|
|
|
|
tone: localNetworkTone,
|
|
|
|
|
help: help(for: need),
|
|
|
|
|
busy: permissions.checking,
|
|
|
|
|
actionTitle: "Check Access",
|
|
|
|
|
action: { permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir) })
|
|
|
|
|
if !results.isEmpty { hostResults }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case .other:
|
|
|
|
|
PermissionRow(title: need.kind.title,
|
|
|
|
|
tone: .unknown,
|
|
|
|
|
help: "The launcher doesn't manage this one — grant it in System Settings.\n"
|
|
|
|
|
+ help(for: need),
|
|
|
|
|
actionTitle: "Open System Settings",
|
|
|
|
|
action: { SystemSettings.open(SystemSettings.privacy) })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var notifications: some View {
|
|
|
|
|
PermissionRow(title: "Notifications",
|
|
|
|
|
tone: notificationTone,
|
|
|
|
|
help: "Tells you when ProdDash stops on its own.",
|
|
|
|
|
actionTitle: notificationActionTitle,
|
|
|
|
|
action: notificationActionTitle == nil ? nil : { permissions.requestNotifications() })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// What the modules said, for the tooltip — one line per distinct reason.
|
|
|
|
|
private func help(for need: ModuleNeed) -> String {
|
|
|
|
|
need.claims
|
|
|
|
|
.map { $0.modules.isEmpty ? $0.reason : "\($0.reason) — \($0.askedBy)" }
|
|
|
|
|
.joined(separator: "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Local network results
|
|
|
|
|
|
|
|
|
|
private var results: [Net.Probe] { permissions.hostChecks.compactMap(\.result) }
|
|
|
|
|
|
|
|
|
|
private var hostResults: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
|
|
|
ForEach(permissions.hostChecks) { check in
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(tone(for: check.result).color)
|
|
|
|
|
.frame(width: 6, height: 6)
|
|
|
|
|
Text(check.label)
|
|
|
|
|
.font(.system(.caption, design: .monospaced))
|
|
|
|
|
Spacer()
|
|
|
|
|
Text(describe(check.result))
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !results.isEmpty, !results.contains(where: \.isReachableHost) {
|
|
|
|
|
// Only when everything failed, because that is the shape a
|
|
|
|
|
// withheld permission takes.
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
Text("Nothing answered.")
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
Button("Open System Settings") { SystemSettings.open(SystemSettings.localNetwork) }
|
|
|
|
|
.buttonStyle(.link)
|
|
|
|
|
.controlSize(.small)
|
|
|
|
|
}
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-11 11:10:22 -04:00
|
|
|
.padding(.leading, 17)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func describe(_ result: Net.Probe?) -> String {
|
|
|
|
|
switch result {
|
|
|
|
|
case .none: return "—"
|
|
|
|
|
case .open: return "answered"
|
|
|
|
|
case .refused: return "reachable"
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Status and actions
|
|
|
|
|
|
|
|
|
|
private func requestMicrophone() {
|
|
|
|
|
permissions.requestMicrophone()
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var microphoneTone: Tone {
|
|
|
|
|
switch permissions.microphone {
|
|
|
|
|
case .authorized: return .good
|
|
|
|
|
case .denied, .restricted: return .bad
|
|
|
|
|
default: return .unknown
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 11:10:22 -04:00
|
|
|
private var microphoneActionTitle: String? {
|
|
|
|
|
switch permissions.microphone {
|
|
|
|
|
case .authorized: return nil
|
|
|
|
|
case .notDetermined: return "Allow"
|
|
|
|
|
default: return "Open System Settings"
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 11:10:22 -04:00
|
|
|
/// Anything answering means the permission is there; only a clean sweep of
|
|
|
|
|
/// failures points at macOS withholding it.
|
|
|
|
|
private var localNetworkTone: Tone {
|
|
|
|
|
guard !results.isEmpty else { return .unknown }
|
|
|
|
|
return results.contains(where: \.isReachableHost) ? .good : .bad
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var notificationTone: Tone {
|
|
|
|
|
switch permissions.notifications {
|
|
|
|
|
case .authorized, .provisional, .ephemeral: return .good
|
|
|
|
|
case .denied: return .bad
|
|
|
|
|
default: return .unknown
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-11 11:10:22 -04:00
|
|
|
|
|
|
|
|
private var notificationActionTitle: String? {
|
|
|
|
|
switch permissions.notifications {
|
|
|
|
|
case .authorized, .provisional, .ephemeral: return nil
|
|
|
|
|
case .notDetermined: return "Enable"
|
|
|
|
|
default: return "Open System Settings"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-11 11:10:22 -04:00
|
|
|
// MARK: - Pieces
|
2026-09-11 10:27:08 -04:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 11:10:22 -04:00
|
|
|
/// Said out loud for anyone who can't see the dot.
|
|
|
|
|
var word: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .good: return "allowed"
|
|
|
|
|
case .bad: return "not allowed"
|
|
|
|
|
case .unknown: return "not checked"
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 11:10:22 -04:00
|
|
|
private struct PermissionRow: View {
|
|
|
|
|
let title: String
|
|
|
|
|
let tone: Tone
|
|
|
|
|
let help: String
|
|
|
|
|
var busy = false
|
|
|
|
|
var actionTitle: String?
|
|
|
|
|
var action: (() -> Void)?
|
2026-09-11 10:27:08 -04:00
|
|
|
|
|
|
|
|
var body: some View {
|
2026-09-11 11:10:22 -04:00
|
|
|
HStack(spacing: 9) {
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(tone.color)
|
|
|
|
|
.frame(width: 8, height: 8)
|
|
|
|
|
.accessibilityHidden(true) // the name carries the status instead
|
|
|
|
|
Text(title)
|
|
|
|
|
.accessibilityLabel("\(title): \(tone.word)")
|
|
|
|
|
Spacer()
|
|
|
|
|
if busy { ProgressView().controlSize(.small) }
|
|
|
|
|
if let actionTitle, let action {
|
|
|
|
|
Button(actionTitle, action: action).disabled(busy)
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-11 11:10:22 -04:00
|
|
|
.help(help)
|
2026-09-11 10:27:08 -04:00
|
|
|
}
|
|
|
|
|
}
|