diff --git a/Tests/StackNudgePanelCoreTests/SettingsRowTests.swift b/Tests/StackNudgePanelCoreTests/SettingsRowTests.swift index 5125b47..acb486f 100644 --- a/Tests/StackNudgePanelCoreTests/SettingsRowTests.swift +++ b/Tests/StackNudgePanelCoreTests/SettingsRowTests.swift @@ -53,6 +53,23 @@ final class SettingsRowTests: XCTestCase { XCTAssertEqual(nav.index(of: .update), 0) } + func test_permissionsRow_prependedWhenMissing() { + let nav = PanelNav() + XCTAssertFalse(nav.settingsRows.contains(.permissions)) + nav.missingPermissions = [.accessibility] + XCTAssertEqual(nav.settingsRows.first, .permissions) + XCTAssertEqual(nav.index(of: .permissions), 0) + } + + func test_permissionsRow_sitsAboveUpdate_whenBothPresent() { + let nav = PanelNav() + nav.missingPermissions = [.notifications] + nav.updateAvailable = "1.2.3" + XCTAssertEqual(nav.settingsRows.first, .permissions) + XCTAssertEqual(nav.index(of: .permissions), 0) + XCTAssertEqual(nav.index(of: .update), 1) + } + func test_indexRowRoundTrip() { let nav = PanelNav() for row in nav.settingsRows { diff --git a/panel/Panel.swift b/panel/Panel.swift index a05218c..afc272c 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -165,7 +165,10 @@ struct PanelContentView: View { tab(.sessions, label: "Sessions", count: sessions.sessions.filter { $0.status == .active }.count) tab(.usage, label: "Usage", count: 0) tab(.outcomes, label: "Tickets", count: ticketGroupCount) - tab(.settings, label: "Settings", count: 0, dot: nav.updateAvailable != nil) + tab(.settings, label: "Settings", count: 0, + dotColor: !nav.missingPermissions.isEmpty ? .orange + : nav.updateAvailable != nil ? .accentColor + : nil) Spacer() @@ -181,7 +184,7 @@ struct PanelContentView: View { .padding(.vertical, 8) } - private func tab(_ mode: PanelMode, label: String, count: Int, dot: Bool = false) -> some View { + private func tab(_ mode: PanelMode, label: String, count: Int, dotColor: Color? = nil) -> some View { let isActive = nav.mode == mode return Button { nav.mode = mode @@ -198,9 +201,9 @@ struct PanelContentView: View { Capsule().fill(Color.primary.opacity(isActive ? 0.18 : 0.10)) ) } - if dot { + if let dotColor { Circle() - .fill(Color.accentColor) + .fill(dotColor) .frame(width: 6, height: 6) } } @@ -705,6 +708,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // reconciliation). Surfaces a "Set up X" banner in Settings when // any detected agent lacks our notify.sh hook. nav.refreshUnwiredAgents() + // Probe runtime grants so the Settings tab dot + "Permissions needed" + // banner reflect reality from first launch, not just after the user + // opens Settings. + nav.refreshPermissions() updateChecker = UpdateChecker(nav: nav) updateChecker?.start() diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 12d12c1..9759af4 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -134,7 +134,7 @@ enum GithubSignIn: Equatable { // change with no renumbering — and the applyCycle switch over this is // exhaustive, so the compiler flags any row left unhandled. enum SettingsRow: Hashable { - case update, hotkey + case permissions, update, hotkey case banner, muteWhenFocused, pinPanel, keepOpenWhenEmpty, launchAtLogin case widget, widgetCorner, widgetOpacity, widgetContent, mascot, theme case soundEnabled, agentDoneSound, permissionSound @@ -203,6 +203,14 @@ final class PanelNav: ObservableObject { // tab dot badge and the conditional "Update available" row at the top // of the Settings list. Populated by UpdateChecker. @Published var updateAvailable: String? + // Runtime permissions (Accessibility / Automation / Notifications) that + // aren't granted yet — empty means all set. Drives the orange dot on the + // Settings tab and the "Permissions needed" banner pinned above the + // Settings sections. Both denied and not-yet-determined count as missing: + // the app can't fully function without the grant either way. Refreshed on + // launch and every Settings.onAppear (see refreshPermissions) so granting + // a permission and coming back clears it. Populated by refreshPermissions. + @Published var missingPermissions: [SettingsPane] = [] // Release notes body (markdown) for the available update — shown in the // confirmation step. nil before notes have loaded or when fetch failed // (e.g. private repo without auth). @@ -600,17 +608,17 @@ final class PanelNav: ObservableObject { "I'd love your input on this.", ] - // +1 when an update is available and the "Update to vX.Y.Z" row is - // pinned at the top of the Settings list. All other indices shift down - // when the offset is 1. // Settings rows in render order — the single source of truth for both the // view (Settings.swift looks up indices from this) and keyboard nav // (selectedRow indexes into it). The conditionals keep render + dispatch in - // sync automatically: the update row appears only when an update is - // pending, and Voice collapses to a single Download row until the model is - // cached — no hand-maintained indices, no off-by-one to chase. + // sync automatically: the permissions row appears only when a grant is + // missing and the update row only when an update is pending (both pin to the + // top, permissions first), and Voice collapses to a single Download row + // until the model is cached — no hand-maintained indices, no off-by-one to + // chase. var settingsRows: [SettingsRow] { var rows: [SettingsRow] = [] + if !missingPermissions.isEmpty { rows.append(.permissions) } if updateAvailable != nil { rows.append(.update) } rows += [.hotkey, .banner, .muteWhenFocused, .pinPanel, .keepOpenWhenEmpty, .launchAtLogin, @@ -789,6 +797,26 @@ final class PanelNav: ObservableObject { try? data.write(to: url, options: [.atomic]) } + // MARK: - Permissions + + // Probe the three runtime grants and record which aren't set yet. + // Notifications is async, so the ordered list is assembled in its callback + // (same order the Permissions window renders them). denied and + // not-yet-determined both count as missing — the panel can't fully + // function without the grant either way. + func refreshPermissions() { + let accessibility = Permissions.accessibility() + let automation = Permissions.automation() + Permissions.notifications { [weak self] notifications in + guard let self else { return } + var missing: [SettingsPane] = [] + if notifications != .granted { missing.append(.notifications) } + if accessibility != .granted { missing.append(.accessibility) } + if automation != .granted { missing.append(.automation) } + if missing != self.missingPermissions { self.missingPermissions = missing } + } + } + func refreshVoiceModelCached() { voiceModelCached = Speaker.voiceModelCached() } @@ -888,6 +916,7 @@ final class PanelNav: ObservableObject { // row enters record mode. func activate() { switch selectedRow { + case .permissions: actions?.checkPermissions() case .update: actions?.beginUpdate() case .hotkey: startRecordingHotkey() case .downloadVoiceModel: @@ -952,6 +981,9 @@ final class PanelNav: ObservableObject { private func applyCycle(forward: Bool) { switch selectedRow { + case .permissions: + // Nothing to cycle — arrows open the Permissions window, like Enter. + actions?.checkPermissions() case .update: // Nothing to cycle — arrows behave like Enter. actions?.beginUpdate() diff --git a/panel/Permissions.swift b/panel/Permissions.swift index 67aeee4..e76d95e 100644 --- a/panel/Permissions.swift +++ b/panel/Permissions.swift @@ -146,6 +146,15 @@ enum SettingsPane { case .notifications: return nil // notifications aren't a TCC service } } + + // Short human label for the Settings "Permissions needed" banner. + var title: String { + switch self { + case .accessibility: return "Accessibility" + case .automation: return "Automation" + case .notifications: return "Notifications" + } + } } struct PermissionsView: View { diff --git a/panel/Settings.swift b/panel/Settings.swift index 19b3a87..c266e84 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -25,8 +25,14 @@ struct SettingsView: View { } else if !nav.recentlyWiredAgents.isEmpty { wiredConfirmationRow(nav.recentlyWiredAgents) } - // Shown only when an update is pending; nav.settingsRows - // puts it first (index 0) so selection lines up. + // Runtime-permission nudge. nav.settingsRows puts + // .permissions ahead of .update, so it renders (and + // keyboard-indexes) above the update row when both are + // present. + if !nav.missingPermissions.isEmpty { + permissionsRow(nav.missingPermissions) + } + // Shown only when an update is pending. if let version = nav.updateAvailable { updateRow(version: version) } @@ -148,6 +154,10 @@ struct SettingsView: View { // a hook file; old install lacks events added in a recent // StackNudge release). nav.refreshUnwiredAgents() + // Re-probe grants on every open so the banner/dot clear right + // after the user grants a permission in System Settings and + // returns to the panel. + nav.refreshPermissions() } } @@ -375,9 +385,46 @@ struct SettingsView: View { .fill(Color.accentColor.opacity(selected ? 0.32 : 0.18)) ) .contentShape(Rectangle()) - .id(0) + .id(nav.index(of: .update)) + .onTapGesture { + nav.selectedSettingIndex = nav.index(of: .update) + nav.activate() + } + } + + // Runtime-permission nudge pinned above the settings sections when one or + // more grants (Accessibility / Automation / Notifications) aren't set. + // Keyboard-navigable like the update row — selecting + Enter, or a click, + // opens the Permissions window. Orange (not accent) to read as "needs + // attention" rather than the update affordance. + private func permissionsRow(_ missing: [SettingsPane]) -> some View { + let selected = nav.selectedSettingIndex == nav.index(of: .permissions) + return HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.body) + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 1) { + Text(missing.count == 1 ? "Permission needed" : "Permissions needed") + .font(.subheadline.weight(.medium)) + Text(missing.map(\.title).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.orange.opacity(selected ? 0.32 : 0.18)) + ) + .contentShape(Rectangle()) + .id(nav.index(of: .permissions)) .onTapGesture { - nav.selectedSettingIndex = 0 + nav.selectedSettingIndex = nav.index(of: .permissions) nav.activate() } }