Launcher: strip the window back to what's worth reading
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1dbd55aa24
commit
e1bceb3e60
@@ -237,8 +237,8 @@ it and ask for it before a service rather than during one:
|
|||||||
| `microphone` | The module reads an audio input (`ltc-capture` and anything like it). |
|
| `microphone` | The module reads an audio input (`ltc-capture` and anything like it). |
|
||||||
| `local-network` | The module talks to gear on this building's network. Not needed for services out on the internet — Planning Center, say. |
|
| `local-network` | The module talks to gear on this building's network. Not needed for services out on the internet — Planning Center, say. |
|
||||||
|
|
||||||
`reason` is one line, shown to the operator as-is; modules giving the same
|
`reason` is one line, shown to the operator as-is when they hover the
|
||||||
reason are listed together. A bare `"permissions": ["microphone"]` works when
|
permission; modules giving the same reason are listed together. A bare `"permissions": ["microphone"]` works when
|
||||||
the standing explanation is enough. A `kind` the launcher doesn't manage is
|
the standing explanation is enough. A `kind` the launcher doesn't manage is
|
||||||
still listed, so nothing goes unmentioned — the shell itself ignores the key
|
still listed, so nothing goes unmentioned — the shell itself ignores the key
|
||||||
entirely, and other platforms ignore it too.
|
entirely, and other platforms ignore it too.
|
||||||
|
|||||||
@@ -78,9 +78,11 @@ it while the server runs restarts it.
|
|||||||
|
|
||||||
### Permissions
|
### Permissions
|
||||||
|
|
||||||
The pane lists what the installed modules declare in their manifests
|
One line per permission: a status dot, its name, and the one thing you can do
|
||||||
(`"permissions": [ … ]` — see [docs/MODULE-GUIDE.md](../../docs/MODULE-GUIDE.md)),
|
about it. What appears is what the installed modules declare in their
|
||||||
with the module's own reason:
|
manifests (`"permissions": [ … ]` — see
|
||||||
|
[docs/MODULE-GUIDE.md](../../docs/MODULE-GUIDE.md)); hover a row to see which
|
||||||
|
module asked, and why.
|
||||||
|
|
||||||
- **Microphone** — asked for directly, with the status macOS reports.
|
- **Microphone** — asked for directly, with the status macOS reports.
|
||||||
- **Local Network** — macOS has no API that reports whether this was granted,
|
- **Local Network** — macOS has no API that reports whether this was granted,
|
||||||
@@ -88,8 +90,9 @@ with the module's own reason:
|
|||||||
services (which is what raises the prompt) and tries every upstream the
|
services (which is what raises the prompt) and tries every upstream the
|
||||||
admin page is pointed at, then says what the result means. Nothing
|
admin page is pointed at, then says what the result means. Nothing
|
||||||
answering, when the gear is on, means macOS is withholding access.
|
answering, when the gear is on, means macOS is withholding access.
|
||||||
- **Notifications** — the launcher's own, for a server that stops while
|
- **Notifications** — the launcher's own, so a server that stops while nobody
|
||||||
nobody is watching.
|
is watching still reaches someone. Turn them off for ProdDash in System
|
||||||
|
Settings if you'd rather not have them.
|
||||||
|
|
||||||
On first run the launcher asks for what the modules declare, while someone is
|
On first run the launcher asks for what the modules declare, while someone is
|
||||||
still at the keyboard, rather than halfway through a service.
|
still at the keyboard, rather than halfway through a service.
|
||||||
@@ -121,7 +124,7 @@ still at the keyboard, rather than halfway through a service.
|
|||||||
| What you see | What it means |
|
| What you see | What it means |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| "Can't find the ProdDash folder" | Point it at the folder with `server.js` in it, under Settings. |
|
| "Can't find the ProdDash folder" | Point it at the folder with `server.js` in it, under Settings. |
|
||||||
| "Node.js 18 or newer isn't installed" | Install Node, or set the path under Settings. A GUI app inherits almost no `PATH`, so a node in an unusual place has to be named. |
|
| "Node.js 18 or newer isn't installed" | Install Node. A GUI app inherits almost no `PATH`, so one in an unusual place may not be found — Settings shows which one is in use. |
|
||||||
| "ProdDash stopped 4 times in two minutes" | The server is failing on startup — the Log tab has its own error. |
|
| "ProdDash stopped 4 times in two minutes" | The server is failing on startup — the Log tab has its own error. |
|
||||||
| LTC shows no signal | Permissions tab → Microphone. If it says granted and the input is still silent, the server may be an older copy started outside the launcher — Stop, then Start. |
|
| LTC shows no signal | Permissions tab → Microphone. If it says granted and the input is still silent, the server may be an older copy started outside the launcher — Stop, then Start. |
|
||||||
| Nothing on the network answers | Permissions tab → **Check Access**. |
|
| Nothing on the network answers | Permissions tab → **Check Access**. |
|
||||||
|
|||||||
@@ -299,9 +299,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|||||||
/// Ask for what the installed modules actually declare they need.
|
/// Ask for what the installed modules actually declare they need.
|
||||||
func requestDeclaredPermissions() {
|
func requestDeclaredPermissions() {
|
||||||
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
|
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
|
||||||
// Notifications are the launcher's own ask, and they are on by default,
|
|
||||||
// so get the prompt over with now rather than at the first fault.
|
|
||||||
if settings.notifyOnProblem { permissions.requestNotifications() }
|
|
||||||
for need in permissions.needs {
|
for need in permissions.needs {
|
||||||
switch need.kind {
|
switch need.kind {
|
||||||
case .microphone:
|
case .microphone:
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ final class LauncherSettings: ObservableObject {
|
|||||||
/// Bring the server back if it dies on its own. Backs off, and gives up
|
/// Bring the server back if it dies on its own. Backs off, and gives up
|
||||||
/// after a burst of fast crashes rather than looping forever.
|
/// after a burst of fast crashes rather than looping forever.
|
||||||
@Published var restartOnCrash: Bool { didSet { defaults.set(restartOnCrash, forKey: "restartOnCrash") } }
|
@Published var restartOnCrash: Bool { didSet { defaults.set(restartOnCrash, forKey: "restartOnCrash") } }
|
||||||
/// Notify when the server stops unexpectedly — the point of a booth machine
|
|
||||||
/// nobody is watching.
|
|
||||||
@Published var notifyOnProblem: Bool { didSet { defaults.set(notifyOnProblem, forKey: "notifyOnProblem") } }
|
|
||||||
/// Open the dashboard in a browser whenever the server comes up.
|
/// Open the dashboard in a browser whenever the server comes up.
|
||||||
@Published var openDashboardOnStart: Bool { didSet { defaults.set(openDashboardOnStart, forKey: "openDashboardOnStart") } }
|
@Published var openDashboardOnStart: Bool { didSet { defaults.set(openDashboardOnStart, forKey: "openDashboardOnStart") } }
|
||||||
/// Show this window at launch. Off means the launcher starts straight into
|
/// Show this window at launch. Off means the launcher starts straight into
|
||||||
@@ -28,7 +25,6 @@ final class LauncherSettings: ObservableObject {
|
|||||||
defaults.register(defaults: [
|
defaults.register(defaults: [
|
||||||
"startServerAtLaunch": true,
|
"startServerAtLaunch": true,
|
||||||
"restartOnCrash": true,
|
"restartOnCrash": true,
|
||||||
"notifyOnProblem": true,
|
|
||||||
"openDashboardOnStart": false,
|
"openDashboardOnStart": false,
|
||||||
"showWindowAtLaunch": true,
|
"showWindowAtLaunch": true,
|
||||||
])
|
])
|
||||||
@@ -36,7 +32,6 @@ final class LauncherSettings: ObservableObject {
|
|||||||
nodePath = defaults.string(forKey: "nodePath") ?? ""
|
nodePath = defaults.string(forKey: "nodePath") ?? ""
|
||||||
startServerAtLaunch = defaults.bool(forKey: "startServerAtLaunch")
|
startServerAtLaunch = defaults.bool(forKey: "startServerAtLaunch")
|
||||||
restartOnCrash = defaults.bool(forKey: "restartOnCrash")
|
restartOnCrash = defaults.bool(forKey: "restartOnCrash")
|
||||||
notifyOnProblem = defaults.bool(forKey: "notifyOnProblem")
|
|
||||||
openDashboardOnStart = defaults.bool(forKey: "openDashboardOnStart")
|
openDashboardOnStart = defaults.bool(forKey: "openDashboardOnStart")
|
||||||
showWindowAtLaunch = defaults.bool(forKey: "showWindowAtLaunch")
|
showWindowAtLaunch = defaults.bool(forKey: "showWindowAtLaunch")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ struct LauncherView: View {
|
|||||||
case .settings:
|
case .settings:
|
||||||
SettingsPane(server: server, settings: settings)
|
SettingsPane(server: server, settings: settings)
|
||||||
case .permissions:
|
case .permissions:
|
||||||
PermissionsPane(server: server, settings: settings, permissions: permissions)
|
PermissionsPane(server: server, permissions: permissions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -86,7 +86,6 @@ struct LauncherView: View {
|
|||||||
private var subtitle: String {
|
private var subtitle: String {
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
if !server.shellVersion.isEmpty { parts.append("v\(server.shellVersion)") }
|
if !server.shellVersion.isEmpty { parts.append("v\(server.shellVersion)") }
|
||||||
parts.append("port \(server.port)")
|
|
||||||
if !server.nodeVersion.isEmpty { parts.append("node \(server.nodeVersion)") }
|
if !server.nodeVersion.isEmpty { parts.append("node \(server.nodeVersion)") }
|
||||||
return parts.joined(separator: " · ")
|
return parts.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
@@ -101,12 +100,6 @@ struct LauncherView: View {
|
|||||||
ForEach(server.networkURLs, id: \.self) { url in
|
ForEach(server.networkURLs, id: \.self) { url in
|
||||||
AddressRow(label: "Network", url: url)
|
AddressRow(label: "Network", url: url)
|
||||||
}
|
}
|
||||||
if !server.modulesSummary.isEmpty {
|
|
||||||
Text("Modules: \(server.modulesSummary)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(2)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Text("Not serving. ProdDash will come up on port " + String(server.port) + ".")
|
Text("Not serving. ProdDash will come up on port " + String(server.port) + ".")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
@@ -268,7 +261,7 @@ private struct SettingsPane: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section("ProdDash") {
|
Section("Network") {
|
||||||
HStack {
|
HStack {
|
||||||
TextField("Port", text: $portText)
|
TextField("Port", text: $portText)
|
||||||
.frame(width: 90)
|
.frame(width: 90)
|
||||||
@@ -277,13 +270,9 @@ private struct SettingsPane: View {
|
|||||||
.disabled(Int(portText) == server.port || Int(portText) == nil)
|
.disabled(Int(portText) == server.port || Int(portText) == nil)
|
||||||
Spacer()
|
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") {
|
Section("Startup") {
|
||||||
Toggle("Open ProdDash at login", isOn: Binding(
|
Toggle("Open ProdDash at login", isOn: Binding(
|
||||||
get: { loginItemOn },
|
get: { loginItemOn },
|
||||||
set: { setLoginItem($0) }))
|
set: { setLoginItem($0) }))
|
||||||
@@ -309,9 +298,7 @@ private struct SettingsPane: View {
|
|||||||
}
|
}
|
||||||
PathRow(title: "Node.js",
|
PathRow(title: "Node.js",
|
||||||
value: server.nodeURL.map { "\($0.path) \(server.nodeVersion)" } ?? "Not found",
|
value: server.nodeURL.map { "\($0.path) \(server.nodeVersion)" } ?? "Not found",
|
||||||
missing: server.nodeURL == nil) {
|
missing: server.nodeURL == nil, action: nil)
|
||||||
chooseNode()
|
|
||||||
}
|
|
||||||
PathRow(title: "Settings", value: server.settingsDir.isEmpty
|
PathRow(title: "Settings", value: server.settingsDir.isEmpty
|
||||||
? server.dataDir.path : server.settingsDir, missing: false, action: nil)
|
? server.dataDir.path : server.settingsDir, missing: false, action: nil)
|
||||||
}
|
}
|
||||||
@@ -356,17 +343,6 @@ private struct SettingsPane: View {
|
|||||||
server.refreshEnvironment()
|
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 {
|
private struct PathRow: View {
|
||||||
|
|||||||
@@ -105,8 +105,6 @@ final class PermissionsModel: ObservableObject {
|
|||||||
@Published private(set) var notifications: UNAuthorizationStatus = .notDetermined
|
@Published private(set) var notifications: UNAuthorizationStatus = .notDetermined
|
||||||
@Published private(set) var hostChecks: [HostCheck] = []
|
@Published private(set) var hostChecks: [HostCheck] = []
|
||||||
@Published private(set) var checking = false
|
@Published private(set) var checking = false
|
||||||
/// Plain-language read on local network access, since macOS has no API for it.
|
|
||||||
@Published private(set) var localNetworkVerdict = ""
|
|
||||||
|
|
||||||
private var browser: NWBrowser?
|
private var browser: NWBrowser?
|
||||||
|
|
||||||
@@ -160,7 +158,6 @@ final class PermissionsModel: ObservableObject {
|
|||||||
guard !checking else { return }
|
guard !checking else { return }
|
||||||
var checks = Self.configuredLocalHosts(root: root, dataDir: dataDir)
|
var checks = Self.configuredLocalHosts(root: root, dataDir: dataDir)
|
||||||
checking = true
|
checking = true
|
||||||
localNetworkVerdict = ""
|
|
||||||
for index in checks.indices { checks[index].result = nil }
|
for index in checks.indices { checks[index].result = nil }
|
||||||
hostChecks = checks
|
hostChecks = checks
|
||||||
startBonjourBrowse()
|
startBonjourBrowse()
|
||||||
@@ -169,8 +166,7 @@ final class PermissionsModel: ObservableObject {
|
|||||||
// Nothing configured yet: the browse alone still raises the prompt.
|
// Nothing configured yet: the browse alone still raises the prompt.
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
|
||||||
self?.checking = false
|
self?.checking = false
|
||||||
self?.localNetworkVerdict = "No local gear is configured yet. Set up ProdCom or ProPresenter in the "
|
LogStore.shared.launcher("local network check: nothing configured to try yet")
|
||||||
+ "admin page, then check again — approve the prompt if macOS asks."
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -189,16 +185,15 @@ final class PermissionsModel: ObservableObject {
|
|||||||
self.stopBonjourBrowse()
|
self.stopBonjourBrowse()
|
||||||
let results = self.hostChecks.compactMap(\.result)
|
let results = self.hostChecks.compactMap(\.result)
|
||||||
let answered = results.filter(\.isReachableHost).count
|
let answered = results.filter(\.isReachableHost).count
|
||||||
|
let verdict: String
|
||||||
if answered == results.count {
|
if answered == results.count {
|
||||||
self.localNetworkVerdict = "Local network access is working — every configured host answered."
|
verdict = "every configured host answered"
|
||||||
} else if answered == 0 {
|
} else if answered == 0 {
|
||||||
self.localNetworkVerdict = "Nothing on this network answered. If the gear is powered on, macOS is "
|
verdict = "nothing answered — if the gear is on, macOS is withholding local network access"
|
||||||
+ "withholding local network access from ProdDash — turn it on in System Settings."
|
|
||||||
} else {
|
} else {
|
||||||
self.localNetworkVerdict = "\(answered) of \(results.count) hosts answered. The rest are probably "
|
verdict = "\(answered) of \(results.count) hosts answered"
|
||||||
+ "off or on another network; a blanket failure would point at the permission."
|
|
||||||
}
|
}
|
||||||
LogStore.shared.launcher("local network check: \(self.localNetworkVerdict)")
|
LogStore.shared.launcher("local network check: \(verdict)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,164 +3,130 @@ import SwiftUI
|
|||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The permissions pane.
|
The permissions pane: one line per permission — a status dot, its name, and
|
||||||
|
the one thing you can do about it.
|
||||||
|
|
||||||
This is the reason the launcher exists as much as the start button is: macOS
|
What appears here is what the installed modules declare in their manifests,
|
||||||
grants privacy permissions to the *app* that owns the process tree, so when
|
so the list matches what is actually installed. Each row's tooltip carries
|
||||||
ProdDash runs as this app's child, the prompts are asked in ProdDash's name
|
the modules' own reasons; the row itself stays out of the way.
|
||||||
and stay granted. What each module needs is read from its own manifest, so
|
|
||||||
this list matches what is actually installed.
|
|
||||||
*/
|
*/
|
||||||
struct PermissionsPane: View {
|
struct PermissionsPane: View {
|
||||||
|
|
||||||
@ObservedObject var server: ServerController
|
@ObservedObject var server: ServerController
|
||||||
@ObservedObject var settings: LauncherSettings
|
|
||||||
@ObservedObject var permissions: PermissionsModel
|
@ObservedObject var permissions: PermissionsModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
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 {
|
if permissions.needs.isEmpty {
|
||||||
Section("Modules") {
|
|
||||||
Text("No installed module declares a permission.")
|
Text("No installed module declares a permission.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(permissions.needs) { need in
|
ForEach(permissions.needs) { need in
|
||||||
Section(need.kind.title) {
|
row(for: need)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
notifications
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
|
|
||||||
|
Button("Re-check") {
|
||||||
|
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
}
|
||||||
.onAppear { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) }
|
.onAppear { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Microphone
|
// MARK: - Rows
|
||||||
|
|
||||||
private var microphoneControls: some View {
|
@ViewBuilder
|
||||||
HStack {
|
private func row(for need: ModuleNeed) -> some View {
|
||||||
StatusText(text: permissions.microphone.description, tone: microphoneTone)
|
switch need.kind {
|
||||||
Spacer()
|
case .microphone:
|
||||||
switch permissions.microphone {
|
PermissionRow(title: need.kind.title,
|
||||||
case .notDetermined:
|
tone: microphoneTone,
|
||||||
Button("Allow…") { permissions.requestMicrophone() }
|
help: help(for: need),
|
||||||
case .denied, .restricted:
|
actionTitle: microphoneActionTitle,
|
||||||
Button("Open System Settings") { SystemSettings.open(SystemSettings.microphone) }
|
action: microphoneActionTitle == nil ? nil : { requestMicrophone() })
|
||||||
default:
|
|
||||||
Button("Open System Settings") { SystemSettings.open(SystemSettings.microphone) }
|
case .localNetwork:
|
||||||
.controlSize(.small)
|
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 microphoneTone: Tone {
|
private var notifications: some View {
|
||||||
switch permissions.microphone {
|
PermissionRow(title: "Notifications",
|
||||||
case .authorized: return .good
|
tone: notificationTone,
|
||||||
case .denied, .restricted: return .bad
|
help: "Tells you when ProdDash stops on its own.",
|
||||||
default: return .unknown
|
actionTitle: notificationActionTitle,
|
||||||
}
|
action: notificationActionTitle == nil ? nil : { permissions.requestNotifications() })
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Local network
|
/// What the modules said, for the tooltip — one line per distinct reason.
|
||||||
|
private func help(for need: ModuleNeed) -> String {
|
||||||
private var localNetworkControls: some View {
|
need.claims
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
.map { $0.modules.isEmpty ? $0.reason : "\($0.reason) — \($0.askedBy)" }
|
||||||
Text("macOS never reports whether this was granted, so the check below asks the network directly: "
|
.joined(separator: "\n")
|
||||||
+ "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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
ForEach(permissions.hostChecks) { check in
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(tone(for: check.result).color)
|
.fill(tone(for: check.result).color)
|
||||||
.frame(width: 7, height: 7)
|
.frame(width: 6, height: 6)
|
||||||
Text(check.label)
|
Text(check.label)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
Text(check.module)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(describe(check.result))
|
Text(describe(check.result))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !results.isEmpty, !results.contains(where: \.isReachableHost) {
|
||||||
if !permissions.localNetworkVerdict.isEmpty {
|
// Only when everything failed, because that is the shape a
|
||||||
Text(permissions.localNetworkVerdict)
|
// withheld permission takes.
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("Nothing answered.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(permissions.localNetworkVerdict.contains("working") ? .secondary : .primary)
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Open System Settings") { SystemSettings.open(SystemSettings.localNetwork) }
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.controlSize(.small)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.leading, 17)
|
||||||
|
}
|
||||||
|
|
||||||
private func describe(_ result: Net.Probe?) -> String {
|
private func describe(_ result: Net.Probe?) -> String {
|
||||||
switch result {
|
switch result {
|
||||||
case .none: return "—"
|
case .none: return "—"
|
||||||
case .open: return "answered"
|
case .open: return "answered"
|
||||||
case .refused: return "reachable, nothing on that port"
|
case .refused: return "reachable"
|
||||||
case .unreachable: return "no route"
|
case .unreachable: return "no route"
|
||||||
case .timedOut: return "no answer"
|
case .timedOut: return "no answer"
|
||||||
case .failed(let why): return why
|
case .failed(let why): return why
|
||||||
@@ -172,13 +138,33 @@ struct PermissionsPane: View {
|
|||||||
return result.isReachableHost ? .good : .bad
|
return result.isReachableHost ? .good : .bad
|
||||||
}
|
}
|
||||||
|
|
||||||
private var notificationLabel: String {
|
// MARK: - Status and actions
|
||||||
switch permissions.notifications {
|
|
||||||
case .authorized, .provisional, .ephemeral: return "granted"
|
private func requestMicrophone() {
|
||||||
case .denied: return "denied"
|
permissions.requestMicrophone()
|
||||||
case .notDetermined: return "not asked yet"
|
|
||||||
@unknown default: return "unknown"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var microphoneTone: Tone {
|
||||||
|
switch permissions.microphone {
|
||||||
|
case .authorized: return .good
|
||||||
|
case .denied, .restricted: return .bad
|
||||||
|
default: return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var microphoneActionTitle: String? {
|
||||||
|
switch permissions.microphone {
|
||||||
|
case .authorized: return nil
|
||||||
|
case .notDetermined: return "Allow"
|
||||||
|
default: return "Open System Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
private var notificationTone: Tone {
|
private var notificationTone: Tone {
|
||||||
@@ -188,9 +174,17 @@ struct PermissionsPane: View {
|
|||||||
default: return .unknown
|
default: return .unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var notificationActionTitle: String? {
|
||||||
|
switch permissions.notifications {
|
||||||
|
case .authorized, .provisional, .ephemeral: return nil
|
||||||
|
case .notDetermined: return "Enable"
|
||||||
|
default: return "Open System Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Small pieces
|
// MARK: - Pieces
|
||||||
|
|
||||||
enum Tone {
|
enum Tone {
|
||||||
case good, bad, unknown
|
case good, bad, unknown
|
||||||
@@ -202,33 +196,39 @@ enum Tone {
|
|||||||
case .unknown: return .secondary
|
case .unknown: return .secondary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct StatusText: View {
|
private struct PermissionRow: View {
|
||||||
let text: String
|
let title: String
|
||||||
let tone: Tone
|
let tone: Tone
|
||||||
|
let help: String
|
||||||
|
var busy = false
|
||||||
|
var actionTitle: String?
|
||||||
|
var action: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 9) {
|
||||||
Circle().fill(tone.color).frame(width: 7, height: 7)
|
Circle()
|
||||||
Text(text).font(.callout)
|
.fill(tone.color)
|
||||||
}
|
.frame(width: 8, height: 8)
|
||||||
}
|
.accessibilityHidden(true) // the name carries the status instead
|
||||||
}
|
Text(title)
|
||||||
|
.accessibilityLabel("\(title): \(tone.word)")
|
||||||
private struct NeedHeader: View {
|
Spacer()
|
||||||
let need: ModuleNeed
|
if busy { ProgressView().controlSize(.small) }
|
||||||
|
if let actionTitle, let action {
|
||||||
var body: some View {
|
Button(actionTitle, action: action).disabled(busy)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.help(help)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ final class ServerController: ObservableObject {
|
|||||||
@Published private(set) var shellVersion = ""
|
@Published private(set) var shellVersion = ""
|
||||||
@Published private(set) var localURL = ""
|
@Published private(set) var localURL = ""
|
||||||
@Published private(set) var networkURLs: [String] = []
|
@Published private(set) var networkURLs: [String] = []
|
||||||
@Published private(set) var modulesSummary = ""
|
|
||||||
@Published private(set) var settingsDir = ""
|
@Published private(set) var settingsDir = ""
|
||||||
/// The server answered /api/modules — listening, not merely alive.
|
/// The server answered /api/modules — listening, not merely alive.
|
||||||
@Published private(set) var healthy = false
|
@Published private(set) var healthy = false
|
||||||
@@ -125,7 +124,6 @@ final class ServerController: ObservableObject {
|
|||||||
message = ""
|
message = ""
|
||||||
localURL = ""
|
localURL = ""
|
||||||
networkURLs = []
|
networkURLs = []
|
||||||
modulesSummary = ""
|
|
||||||
healthy = false
|
healthy = false
|
||||||
healthMisses = 0
|
healthMisses = 0
|
||||||
openedDashboardThisRun = false
|
openedDashboardThisRun = false
|
||||||
@@ -283,7 +281,6 @@ final class ServerController: ObservableObject {
|
|||||||
switch field {
|
switch field {
|
||||||
case "Local": localURL = value
|
case "Local": localURL = value
|
||||||
case "Network": if !networkURLs.contains(value) { networkURLs.append(value) }
|
case "Network": if !networkURLs.contains(value) { networkURLs.append(value) }
|
||||||
case "Modules": modulesSummary = value
|
|
||||||
case "Settings": settingsDir = value
|
case "Settings": settingsDir = value
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
@@ -330,16 +327,14 @@ final class ServerController: ObservableObject {
|
|||||||
: "ProdDash \(how)."
|
: "ProdDash \(how)."
|
||||||
let last = lastProblemLine.isEmpty ? "" : " Last message: \(lastProblemLine)"
|
let last = lastProblemLine.isEmpty ? "" : " Last message: \(lastProblemLine)"
|
||||||
fail(why + last)
|
fail(why + last)
|
||||||
Notifier.shared.post(title: "ProdDash stopped", body: why, if: settings.notifyOnProblem)
|
Notifier.shared.post(title: "ProdDash stopped", body: why)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let delay = [1.0, 2.0, 5.0, 10.0][min(crashTimes.count - 1, 3)]
|
let delay = [1.0, 2.0, 5.0, 10.0][min(crashTimes.count - 1, 3)]
|
||||||
state = .stopped
|
state = .stopped
|
||||||
message = "ProdDash \(how) — restarting…"
|
message = "ProdDash \(how) — restarting…"
|
||||||
log.launcher("ProdDash \(how)\(detail); restarting in \(Int(delay))s")
|
log.launcher("ProdDash \(how)\(detail); restarting in \(Int(delay))s")
|
||||||
Notifier.shared.post(title: "ProdDash stopped",
|
Notifier.shared.post(title: "ProdDash stopped", body: "It \(how)\(detail). Restarting…")
|
||||||
body: "It \(how)\(detail). Restarting…",
|
|
||||||
if: settings.notifyOnProblem)
|
|
||||||
scheduleRestart(after: delay)
|
scheduleRestart(after: delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ final class Notifier {
|
|||||||
|
|
||||||
/// Posting never asks for permission: a prompt that appears because
|
/// Posting never asks for permission: a prompt that appears because
|
||||||
/// something just failed, unattended, is the worst possible moment for it.
|
/// something just failed, unattended, is the worst possible moment for it.
|
||||||
/// Authorization is asked for on first run and when the setting is turned
|
/// It is asked for from the Permissions tab; until then this does nothing.
|
||||||
/// on; until then this quietly does nothing.
|
func post(title: String, body: String) {
|
||||||
func post(title: String, body: String, if enabled: Bool) {
|
guard available, authorized else { return }
|
||||||
guard enabled, available, authorized else { return }
|
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = title
|
content.title = title
|
||||||
content.body = body
|
content.body = body
|
||||||
@@ -92,4 +91,5 @@ enum SystemSettings {
|
|||||||
static let microphone = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
|
static let microphone = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
|
||||||
static let localNetwork = "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork"
|
static let localNetwork = "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork"
|
||||||
static let notifications = "x-apple.systempreferences:com.apple.Notifications-Settings.extension"
|
static let notifications = "x-apple.systempreferences:com.apple.Notifications-Settings.extension"
|
||||||
|
static let privacy = "x-apple.systempreferences:com.apple.preference.security?Privacy"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user