Skip to content

Surface a refused macOS notification prompt in the desktop app - #1069

Merged
alexeyzimarev merged 2 commits into
mainfrom
capacitor/agent-299b57f55ceb4d
Sep 21, 2026
Merged

alexeyzimarev merged 2 commits into
mainfrom
capacitor/agent-299b57f55ceb4d

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

Closes #1064 — AI-3019

What & why

macOS settles an unanswered notification prompt as denied. Asked from the first notification — which fires only while the app is in the background — the prompt reads as an ordinary banner, times out, and every later notification is refused with nothing in the app saying so. This asks the first time an app window is active (once per run, only while access is undetermined and a preference is on), and the Notifications tab reads the authorization and shows a warning row: Open System Settings when macOS blocks notifications, Allow notifications when it has never been asked.

Where to look

  • Settings re-reads access on window activation — the only signal that the user changed it in System Settings.
  • The request inside Show stays as the fallback for an app that never had an active window.

Verification

  • dotnet run --project test/Capacitor.App.Tests.Unit → 2665 passed, 0 failed; dotnet build Capacitor.slnx → 0 warnings.
  • The new getNotificationSettingsWithCompletionHandler: read ran in a throwaway bundle against the real notification centre: callback fired, access=NotDetermined.
  • Not verified: a Denied/Allowed reading, the first-activation prompt and the System Settings deep link. usernoted refuses to validate an ad-hoc bundle, so these need the signed app.

🤖 Generated with Claude Code

macOS settles an unanswered permission prompt as denied, so the request
moves to the first active window instead of the first background
notification. Settings re-reads access on activation because the user
changes it in System Settings, outside the app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-21T07:44:51.022835Z fd4541b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Request macOS notification access in foreground and surface denials

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Requests macOS notification permission when an app window first becomes active.
• Shows denied or undetermined access in Settings with actionable recovery controls.
• Refreshes authorization after activation and tests native, prompt, and UI behavior.
Diagram

sequenceDiagram
    participant Window as App Window
    participant Prompt as Access Prompt
    participant Sink as Native Sink
    participant OS as macOS Center
    participant Settings as Settings VM
    participant UI as Settings Notice
    Window->>Prompt: First activation
    Prompt->>Sink: Read authorization
    Sink->>OS: Get settings
    OS-->>Sink: Authorization state
    alt Access undetermined
        Prompt->>Sink: Request access
        Sink->>OS: Show permission prompt
        OS-->>Sink: Updated state
    end
    Window->>Settings: Settings activation
    Settings->>Sink: Refresh authorization
    Sink-->>Settings: Current state
    Settings-->>UI: Show or clear warning
    UI->>Sink: Allow or open settings
Loading
High-Level Assessment

The activation-driven approach is appropriate because it presents the first permission prompt while the user is engaged and naturally detects changes made in System Settings. Caching the request result would become stale, while polling authorization would add unnecessary background work; retaining the notification-time request as a fallback covers windowless runs.

Files changed (16) +367 / -12

Enhancement (4) +36 / -1
DesktopNotificationAccess.csDefine platform-neutral notification access states +4/-0

Define platform-neutral notification access states

• Adds authorization states for unknown, undetermined, denied, and allowed notification access.

src/Capacitor.App/Services/Notifications/DesktopNotificationAccess.cs

IDesktopNotificationAccess.csIntroduce desktop notification authorization contract +11/-0

Introduce desktop notification authorization contract

• Defines operations for reading and requesting notification access and opening the platform notification settings.

src/Capacitor.App/Services/Notifications/IDesktopNotificationAccess.cs

NativeDesktopNotificationSink.csForward notification access to supported native backends +7/-1

Forward notification access to supported native backends

• Implements the authorization contract by delegating to capable native backends and returning unknown on unsupported platforms or launch modes.

src/Capacitor.App/Services/Notifications/NativeDesktopNotificationSink.cs

SettingsWindow.axamlAdd actionable notification access warning +14/-0

Add actionable notification access warning

• Adds a warning row to the Notifications tab with dynamic explanatory text and an action button for permission recovery.

src/Capacitor.App/Views/SettingsWindow.axaml

Bug fix (5) +135 / -6
App.axaml.csWire notification access into activation and Settings +12/-2

Wire notification access into activation and Settings

• Shares the native sink as an authorization service, subscribes to active-window changes for the once-per-run prompt, and injects access management into Settings. The activation subscription is disposed during shutdown.

src/Capacitor.App/App.axaml.cs

DesktopNotificationAccessPrompt.csRequest notification access once during foreground use +17/-0

Request notification access once during foreground use

• Adds a concurrency-safe prompt coordinator that requests access once per run when authorization is undetermined and at least one notification preference is enabled.

src/Capacitor.App/Services/Notifications/DesktopNotificationAccessPrompt.cs

MacOsDesktopNotificationSink.csAdd macOS authorization reads and recovery navigation +55/-2

Add macOS authorization reads and recovery navigation

• Extends the UserNotifications bridge to read authorization status, request access asynchronously, and open the app-specific Notifications settings page. It maps provisional and ephemeral grants to allowed while retaining the existing delivery-time request fallback.

src/Capacitor.App/Services/Notifications/MacOsDesktopNotificationSink.cs

SettingsViewModel.csExpose notification authorization warnings and actions +45/-1

Expose notification authorization warnings and actions

• Reads current authorization, presents state-specific warning text and actions, and either requests permission or opens System Settings. Refreshes safely without publishing results after disposal.

src/Capacitor.App/ViewModels/SettingsViewModel.cs

SettingsWindow.axaml.csRefresh notification access on Settings activation +6/-1

Refresh notification access on Settings activation

• Rereads authorization whenever the Settings window becomes active, including after returning from System Settings.

src/Capacitor.App/Views/SettingsWindow.axaml.cs

Tests (5) +180 / -4
DesktopNotificationAccessPromptTests.csTest foreground prompt eligibility and idempotence +47/-0

Test foreground prompt eligibility and idempotence

• Verifies undetermined access is requested only once, settled states are not requested, and disabled preferences defer prompting until a notification type is enabled.

test/Capacitor.App.Tests.Unit/DesktopNotificationAccessPromptTests.cs

FakeDesktopNotificationAccess.csAdd controllable notification access test double +20/-0

Add controllable notification access test double

• Provides mutable authorization state plus counters for permission requests and System Settings launches.

test/Capacitor.App.Tests.Unit/FakeDesktopNotificationAccess.cs

MacOsDesktopNotificationSinkTests.csTest macOS authorization status mapping +17/-0

Test macOS authorization status mapping

• Covers not-determined, denied, allowed, provisional, ephemeral, and unknown native authorization values.

test/Capacitor.App.Tests.Unit/MacOsDesktopNotificationSinkTests.cs

SettingsViewModelTests.csTest notification access messaging and recovery actions +65/-2

Test notification access messaging and recovery actions

• Verifies denied and undetermined warnings, their respective actions, external authorization refreshes, and suppression for allowed or unsupported access.

test/Capacitor.App.Tests.Unit/SettingsViewModelTests.cs

SettingsWindowSmokeTests.csExercise the blocked-notification warning in the UI +31/-2

Exercise the blocked-notification warning in the UI

• Confirms the warning is visible for denied access, invokes the System Settings action, uses the expected styling, and disappears after access becomes allowed.

test/Capacitor.App.Tests.Unit/SettingsWindowSmokeTests.cs

Documentation (2) +16 / -1
README.mdDocument foreground notification permission behavior +1/-1

Document foreground notification permission behavior

• Explains that macOS requests notification access when the app first becomes active, treats ignored prompts as refusal, and exposes a System Settings link when notifications are disabled.

README.md

CHANGES.mdRecord the notification authorization design +15/-0

Record the notification authorization design

• Documents why permission moved to foreground activation, why the existing delivery-time request remains as fallback, and why Settings rereads operating-system authorization.

docs/CHANGES.md

@qodo-code-review

qodo-code-review Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Prompts can appear in background ✓ Resolved 🐞 Bug ☼ Reliability
Description
AskOnceAsync awaits GetAsync() and then calls RequestAsync() without rechecking whether an app
window is still active. If the window deactivates while the authorization read is pending, the
activation-triggered task still reaches the macOS prompt after the user has left the app.
Code

src/Capacitor.App/Services/Notifications/DesktopNotificationAccessPrompt.cs[14]

+            if (await access.GetAsync() == DesktopNotificationAccess.NotDetermined) await access.RequestAsync();
Relevance

●●● Strong

Native permission requests must remain foreground-only; accepted precedents support guarding
asynchronous lifecycle races.

PR-#979
PR-#1055

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new activation subscription fire-and-forgets the asynchronous prompt operation, and the prompt
implementation has an asynchronous boundary before the native request but no foreground-state guard
after that boundary. The interface explicitly documents that requests must occur while the user is
looking at the app.

src/Capacitor.App/App.axaml.cs[700-704]
src/Capacitor.App/Services/Notifications/DesktopNotificationAccessPrompt.cs[9-15]
src/Capacitor.App/Services/Notifications/IDesktopNotificationAccess.cs[6-8]
src/Capacitor.App/Services/Notifications/MacOsDesktopNotificationSink.cs[93-105]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The permission request is initiated by a window activation but can run after that window has deactivated because `AskOnceAsync` awaits the authorization read before requesting access. This recreates the background-banner prompt that this change is intended to prevent.

## Fix Focus Areas
- src/Capacitor.App/App.axaml.cs[700-704]
- src/Capacitor.App/Services/Notifications/DesktopNotificationAccessPrompt.cs[6-15]

## Recommended Fix
Pass a foreground-state predicate into `DesktopNotificationAccessPrompt`. Check it before beginning the read and again immediately before claiming the one-per-run request and calling `RequestAsync`; if no application window remains active, leave the prompt unclaimed so a later activation can request it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Access reads can hang forever ✓ Resolved 🐞 Bug ☼ Reliability
Description
MacOsDesktopNotificationSink.GetAsync performs the native authorizationStatus read inside a
callback without settling its completion source if that read throws.
MacNotificationBlock.Completed catches and logs callback exceptions, so the returned task remains
incomplete and any command or prompt awaiting it never finishes.
Code

src/Capacitor.App/Services/Notifications/MacOsDesktopNotificationSink.cs[R83-84]

+            using var block = MacNotificationBlock.Completion(settings => result.TrySetResult(
+                settings == 0 ? DesktopNotificationAccess.Unknown : Access(Send(settings, Selector("authorizationStatus")))));
Relevance

●●● Strong

An exception escaping an asynchronous callback can permanently hang callers; this is a concrete
reliability defect.

PR-#1055

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The outer try block only covers callback registration because callback execution occurs
asynchronously. The block trampoline catches callback exceptions solely to report them, while the
task completion source is set only by the expression that can throw.

src/Capacitor.App/Services/Notifications/MacOsDesktopNotificationSink.cs[77-90]
src/Capacitor.App/Services/Notifications/MacNotificationBlock.cs[72-76]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An exception while reading `authorizationStatus` is swallowed by the unmanaged callback wrapper without completing the `GetAsync` task.

## Fix Focus Areas
- src/Capacitor.App/Services/Notifications/MacOsDesktopNotificationSink.cs[77-90]
- src/Capacitor.App/Services/Notifications/MacNotificationBlock.cs[72-76]

## Recommended Fix
Catch failures inside the `GetAsync` completion callback and always settle the task, preferably by reporting the exception and returning `DesktopNotificationAccess.Unknown`. Add a test using an injectable failing status reader or equivalent callback seam.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Re-enabled alerts prompt in background ✓ Resolved 🐞 Bug ≡ Correctness
Description
DesktopNotificationAccessPrompt.AskOnceAsync returns when all preferences are disabled, while the
app only retries it on a later window activation rather than when a preference becomes enabled. If a
user enables notifications while the settings window remains active and then backgrounds the app,
the first notification reaches the existing Show fallback and recreates the background-prompt
behavior this change is intended to prevent.
Code

src/Capacitor.App/App.axaml.cs[R701-703]

+            _notificationAccessPrompt = Window.IsActiveProperty.Changed
+                .Where(change => change.NewValue.GetValueOrDefault() && !_shutdownStarted)
+                .Subscribe(change => { _ = accessPrompt.AskOnceAsync(); });
Relevance

●●● Strong

Missing preference-change retry undermines the PR’s stated foreground-prompt intent; similar
notification fixes were accepted recently.

PR-#1055

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
AskOnceAsync exits before claiming its one-shot guard when every preference is off, and the only
app-level calls are the activation subscription and initial active-window check. Preference saves
publish updated settings, but no subscriber invokes the prompt; foreground notifications are
suppressed by the coordinator, leaving the native Show request as the next background fallback.

src/Capacitor.App/Services/Notifications/DesktopNotificationAccessPrompt.cs[9-15]
src/Capacitor.App/App.axaml.cs[700-704]
src/Capacitor.App/Services/NotificationSettingsService.cs[30-38]
src/Capacitor.App/Services/Notifications/DesktopNotificationCoordinator.cs[105-118]
src/Capacitor.App/Services/Notifications/MacOsDesktopNotificationSink.cs[51-70]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The foreground notification-access prompt is retried only on window activation, so enabling a notification preference while a window remains active leaves authorization undetermined until a background notification or later activation.

## Fix Focus Areas
- src/Capacitor.App/App.axaml.cs[700-704]
- src/Capacitor.App/Services/Notifications/DesktopNotificationAccessPrompt.cs[9-15]

## Recommended Fix
Subscribe to notification preference changes as well as window activation, and invoke `AskOnceAsync` when preferences transition to any-enabled while an application window is active. Dispose both subscriptions through the existing prompt-registration lifetime.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Settings can show stale access ✓ Resolved 🐞 Bug ≡ Correctness
Description
ReadNotificationAccessAsync lets untracked constructor, activation, and command-triggered
GetAsync() and RequestAsync() operations assign _access solely in completion order, without
serialization or a latest-operation check. When an activation read started before an Allow request
finishes afterward with its earlier status, it overwrites the newer authorization state and restores
the wrong warning and action in the notification notice.
Code

src/Capacitor.App/ViewModels/SettingsViewModel.cs[R300-307]

+            access = await (request ? _notificationAccess.RequestAsync() : _notificationAccess.GetAsync());
+        } catch {
+            return;
+        }
+        if (_lifetime.IsCancellationRequested || access == _access) return;
+        _access = access;
+        this.RaisePropertyChanged(nameof(NotificationAccessText));
+        this.RaisePropertyChanged(nameof(NotificationAccessAction));
Relevance

●● Moderate

The completion-order race is plausible, but history provides no close SettingsViewModel
authorization precedent.

PR-#766
PR-#979

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The constructor starts a fire-and-forget read, every settings-window activation starts another
unawaited refresh, and the action command can independently start an authorization request. These
paths converge on code that writes _access after awaiting, with no cancellation, serialization,
generation, or synchronization check to prevent an older operation that completes last from
replacing a newer result.

src/Capacitor.App/Views/SettingsWindow.axaml.cs[7-10]
src/Capacitor.App/ViewModels/SettingsViewModel.cs[288-307]
src/Capacitor.App/Services/Notifications/MacOsDesktopNotificationSink.cs[77-105]
src/Capacitor.App/ViewModels/SettingsViewModel.cs[79-94]
src/Capacitor.App/ViewModels/SettingsViewModel.cs[288-308]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Notification authorization reads and requests can overlap, and every completion currently updates the displayed state regardless of whether a newer operation has already completed. A delayed passive read can therefore overwrite the latest authorization status and restore an obsolete warning and action after the user has allowed notifications.

## Fix Focus Areas
- src/Capacitor.App/ViewModels/SettingsViewModel.cs[288-308]
- src/Capacitor.App/Views/SettingsWindow.axaml.cs[7-10]

## Recommended Fix
Serialize notification-access operations or assign each operation a monotonically increasing generation when it starts, then apply its returned access value only if it is still the newest generation. Ensure an explicit command-triggered authorization request supersedes all outstanding constructor- or activation-triggered passive refreshes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 64 rules
✅ Cross-repo context — repo relationships
Review mode: 🧠 Deep: This is a behavior-changing macOS notification permission flow spanning native interop, app lifecycle, settings UI, commands, and multiple independent code paths, with substantial potential for subtle defects.

Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/Capacitor.App/App.axaml.cs Outdated
Comment thread src/Capacitor.App/Services/Notifications/MacOsDesktopNotificationSink.cs Outdated
Comment thread src/Capacitor.App/Services/Notifications/DesktopNotificationAccessPrompt.cs Outdated
Comment thread src/Capacitor.App/ViewModels/SettingsViewModel.cs Outdated
An Allow request can stay open across many window activations, so the
view model follows it with a read of its own instead of applying its
result: ordering overlapping reads by start would otherwise drop the
answer the user just gave.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop: a missed macOS notification prompt silently disables notifications

1 participant