Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Tests/StackNudgePanelCoreTests/SettingsRowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 11 additions & 4 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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()
Expand Down
46 changes: 39 additions & 7 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions panel/Permissions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
55 changes: 51 additions & 4 deletions panel/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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()
}
}
Expand Down