onboarding: seamless macOS notification permission step with live state detection (#7684)

* feat(onboarding): state-aware macOS notification permission step

The Set up notifications step showed a one-size-fits-all 'Open Mac
Settings' button that simultaneously fired the macOS permission prompt
and opened System Settings — two competing system UIs, with System
Settings unnecessary for the common fresh-install case.

Electron exposes no API to read macOS notification authorization, but
scheduling outcomes do reveal it: a silent probe notification's 'show'
event means permission is granted, 'failed' means delivery is blocked.
A new notifications:probeDelivery IPC runs that probe (cached via
passive delivery evidence and a persisted confirmation flag), and the
onboarding card now renders the real state:

- fresh install: the probe itself pops the native Allow dialog the
  moment the step opens; the card flips to 'Notifications are enabled'
  automatically when the user clicks Allow (silent 2.5s re-probes)
- blocked: amber card with an Open System Settings deep-link, which
  also self-heals once the user flips the toggle
- granted: green confirmation card

The test-notification button now feeds the same card instead of the
ambiguous 'if no banner appeared…' toast during onboarding.

Co-authored-by: Orca <help@stably.ai>

* fix: don't log expected probe rejections while polling for permission

Co-authored-by: Orca <help@stably.ai>

* fix: amber warning styling + single stable dev bundle id for notifications

- Blocked card now uses the app's shipped amber idiom (tinted surface with
  amber title/body) instead of white-on-amber-wash, which read muddy in
  dark mode; macOS permission card split into its own module to stay under
  the max-lines budget.
- Dev instances previously minted a unique macOS bundle id per
  branch x Electron version, registering a new Notification Settings entry
  every time ('Orca: <branch>' rows piling up forever) and pointing the
  settings deep-link at ids System Settings can't resolve. All dev
  instances now share com.stablyai.orca.dev: one Notification Center
  entry, one permission grant covering every dev build.

Co-authored-by: Orca <help@stably.ai>

* fix: tighten macOS permission card copy

Body copy was one long sentence; now a single short instruction with
'Updates automatically.' as a separate dimmer line. Also repairs locale
catalog parity for keys introduced by commits rebased into this branch.

Co-authored-by: Orca <help@stably.ai>

* fix: drop 'Updates automatically.' line; ad-hoc sign dev app copies

The extra line read as confusing filler — the cards now carry one short
instruction each.

Dev Electron copies had broken code signatures (the Info.plist identity
edits invalidate the ad-hoc seal), which macOS punishes by refusing
Notification Center registration outright: every dev notification failed
with UNErrorDomain error 1, the app never appeared in System Settings >
Notifications, and the settings deep-link had nothing to land on. The dev
runner now ad-hoc re-signs the copied bundle after the plist edits
(bundleLayoutVersion bumped so stale unsigned copies are recreated).
Verified end-to-end: runner-built copy passes codesign --verify --deep,
probe delivery returns delivered, the onboarding card flips green in dev,
and the deep link opens the dev app's own notifications pane.

Co-authored-by: Orca <help@stably.ai>

* fix: drop confusing copy line; session-only permission evidence

Removes the 'Updates automatically.' line from both permission cards.

Also drops the persisted notificationDeliveryConfirmed flag: OS-level
permission changes between sessions, and a stale positive rendered a
false green card. Delivery evidence is now session-scoped only.

Documented detection ceiling (verified empirically on macOS 26): while
the permission dialog is unanswered — and when notifications are toggled
off in System Settings after being authorized — macOS accepts requests
and silently swallows them, with no public API (Notification Center
delivered-history and legacy ncprefs both included) able to distinguish
that from real delivery. 'failed' remains definitive for unsigned builds
and dialog-level denials.

Co-authored-by: Orca <help@stably.ai>

* feat: real macOS notification permission readout via native helper

Electron has no API for UNUserNotificationCenter authorization, and every
observable fallback lies: scheduling succeeds (and getHistory lists the
notification) even while macOS silently swallows display because the
permission dialog is unanswered or notifications were toggled off in
System Settings. The onboarding card therefore showed 'enabled' after the
user disabled notifications.

Adds native/notification-status-macos: a tiny Swift binary that prints
the app's real authorization status. It runs from inside the app bundle
(NSBundle resolves the bundle by walking up from the executable) and
embeds the app's CFBundleIdentifier in a __TEXT,__info_plist section so
every codesign --force pass — electron-builder's signing or the dev
runner's ad-hoc deep sign — derives the identifier macOS keys
notification records to. Spawning it from the app returns authorized /
denied / not-determined exactly matching System Settings.

notifications:probeDelivery now prefers this readout (authoritative,
silent), firing at most one dialog-trigger probe per session while the
decision is pending, and falls back to the previous delivery-probe
heuristics when the helper is unavailable. The card polls the readout
silently in every state, so toggling Allow notifications in System
Settings flips the card within a poll — both directions, verified live.
Test notifications also consult the readout so 'delivered' is no longer
claimed for swallowed notifications.

Packaged builds ship the helper via extraResources and sign it in
afterPack like the computer-use helper; dev copies compile it on demand
(swiftc, non-fatal when missing) with the shared dev bundle id.

Co-authored-by: Orca <help@stably.ai>

* feat: in-app fallback for swallowed notifications + permission card in Settings

- Dispatch now consults the authorization readout before creating a
  native notification: when macOS would silently swallow it (denied or
  prompt unanswered) it returns reason 'blocked-by-system' instead of
  piling invisible notifications into Notification Center. The terminal
  notification path surfaces that as a once-per-session in-app toast
  with an Open System Settings action. Mobile fan-out is unaffected.
- Settings > Notifications now shows the same live permission card as
  onboarding (moved to components/notifications/), polling the readout
  so System Settings changes reflect within seconds, and the test
  button updates it inline.
- Test sends that are blocked at the OS level now show the
  settings-pointing failure toast instead of a generic error.

Co-authored-by: Orca <help@stably.ai>

* fix: hide macOS permission card while Orca notifications are disabled

A green 'Notifications are enabled' card next to a disabled Enable
Notifications toggle read as a contradiction — the card now renders (and
the readout polls) only while Orca's own notifications setting is on.
Also single-flights the authorization helper so simultaneous agent
completions share one readout process.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-08 22:21:40 -07:00 committed by GitHub
parent dd0a42cf9c
commit 25ea2bbfd1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1492 additions and 282 deletions

View File

@ -158,6 +158,10 @@ module.exports = {
}
if (context.electronPlatformName === 'darwin') {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
await signMacNotificationStatusHelper(
join(resourcesDir, 'orca-notification-status'),
context.packager
)
}
},
win: {
@ -247,6 +251,10 @@ module.exports = {
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
to: 'Orca Computer Use.app'
},
{
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'orca-notification-status'
},
featureWallResources
],
target: [
@ -418,6 +426,37 @@ async function signMacComputerUseHelper(helperAppPath, packager) {
})
}
async function signMacNotificationStatusHelper(helperPath, packager) {
if (!existsSync(helperPath)) {
if (isMacRelease) {
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
}
return
}
const codeSigningInfo =
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
? await packager.codeSigningInfo.value
: null
const identity =
process.env.CSC_NAME ??
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
(isMacRelease ? null : '-')
if (!identity) {
throw new Error('Missing signing identity for orca-notification-status helper')
}
// Why: macOS keys notification records to the code-signing identifier; the
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
// (and any later) `codesign --force` derives the correct identifier. Sign
// before the outer Orca.app is sealed, like the computer-use helper.
const args = ['--force', '--sign', identity]
if (isMacRelease) {
args.push('--options', 'runtime', '--timestamp')
}
args.push(helperPath)
execFileSync('codesign', args, { stdio: 'inherit' })
execFileSync('codesign', ['--verify', '--strict', helperPath], { stdio: 'inherit' })
}
function codesignArgs(identity, targetPath) {
const args = ['--force', '--deep', '--sign', identity]
if (isMacRelease) {

View File

@ -8,6 +8,8 @@ if (process.platform !== 'darwin') {
}
runPnpmScript('build:computer-macos')
runPnpmScript('build:notification-status-macos')
process.exit(0)
function runPnpmScript(scriptName) {
const npmExecPath = process.env.npm_execpath
@ -22,5 +24,7 @@ function runPnpmScript(scriptName) {
if (result.signal) {
process.kill(process.pid, result.signal)
}
process.exit(result.status ?? (result.error ? 1 : 0))
if (result.status !== 0 || result.error) {
process.exit(result.status ?? 1)
}
}

View File

@ -0,0 +1,97 @@
#!/usr/bin/env node
// Builds the orca-notification-status helper binary.
//
// The helper reads UNUserNotificationCenter settings for the app it ships
// inside (see native/notification-status-macos/main.swift). The target
// CFBundleIdentifier is embedded as a __TEXT,__info_plist section so every
// later `codesign --force` pass (electron-builder's signing, the dev runner's
// ad-hoc deep sign) derives the correct code identifier automatically —
// macOS keys notification records to that identifier.
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const sourcePath = path.join(repoRoot, 'native', 'notification-status-macos', 'main.swift')
const defaultOutputPath = path.join(
repoRoot,
'native',
'notification-status-macos',
'.build',
'release',
'orca-notification-status'
)
if (process.platform !== 'darwin') {
process.exit(0)
}
const args = process.argv.slice(2)
const bundleId = readArg('--bundle-id') ?? 'com.stablyai.orca'
const outputPath = readArg('--output') ?? defaultOutputPath
// Why: dev launches only need the host architecture; release builds ship a
// universal binary matching the app's x64 + arm64 targets.
const singleArch = args.includes('--single-arch')
const workDir = path.join(tmpdir(), `orca-notification-status-${process.pid}`)
mkdirSync(workDir, { recursive: true })
try {
const plistPath = path.join(workDir, 'Info.plist')
writeFileSync(plistPath, embeddedInfoPlist(bundleId), 'utf8')
const triples = singleArch
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
const builtBinaries = triples.map((triple) => {
const output = path.join(workDir, `orca-notification-status-${triple}`)
execFileSync(
'swiftc',
[
'-O',
sourcePath,
'-target',
triple.replace('-apple-macosx', '-apple-macosx11.0'),
'-o',
output,
'-Xlinker',
'-sectcreate',
'-Xlinker',
'__TEXT',
'-Xlinker',
'__info_plist',
'-Xlinker',
plistPath
],
{ stdio: 'inherit' }
)
return output
})
mkdirSync(path.dirname(outputPath), { recursive: true })
if (builtBinaries.length === 1) {
execFileSync('cp', [builtBinaries[0], outputPath])
} else {
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
}
execFileSync('chmod', ['755', outputPath])
} finally {
rmSync(workDir, { recursive: true, force: true })
}
function readArg(name) {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
function embeddedInfoPlist(identifier) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>${identifier}</string>
<key>CFBundleName</key>
<string>orca-notification-status</string>
</dict>
</plist>
`
}

View File

@ -97,16 +97,6 @@ function setPlistValue(plistPath, key, value) {
execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath])
}
function sanitizeBundleIdPart(value) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9.-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'dev'
)
}
function sanitizeMacAppBundleName(value) {
return (
Array.from(value, (char) => {
@ -138,7 +128,10 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v4'
// v6: bundle the notification-status helper (real permission readout) and
// ad-hoc re-sign after plist edits so Notification Center accepts the
// bundle; bumping forces stale cached copies to be recreated.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@ -151,7 +144,17 @@ function prepareMacDevElectronApp() {
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
const bundleId = `com.stablyai.orca.dev.${sanitizeBundleIdPart(hash)}`
// Why: one stable id for every dev instance. Per-instance ids registered a
// new macOS Notification Settings entry for each branch × Electron version,
// piling up "Orca: <branch>" rows forever and breaking the notification
// settings deep-link (System Settings can't resolve an id it has no entry
// for and falls back to the root list). macOS keys notification permission
// by bundle id, so a single id also means granting notifications to one dev
// instance covers all of them. Trade-off: when two dev instances run at
// once, macOS may route a notification click to the other instance —
// Electron drops clicks for notification ids it didn't create, so the
// click is lost, not misdirected.
const bundleId = 'com.stablyai.orca.dev'
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
@ -206,9 +209,45 @@ function prepareMacDevElectronApp() {
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
// Why no re-sign: dev launches execute the copied Electron binary directly,
// and Electron's framework bundle is ambiguous to codesign when deep-signing
// an already-built distribution. Avoid blocking `pn dev` on local signing.
// Why: the notification-status helper reads the app's real macOS
// notification authorization (UNUserNotificationCenter has no Electron
// API). It must live inside the bundle and carry the dev bundle id as its
// embedded/code-sign identifier — macOS keys notification records to the
// signing identifier. Non-fatal: without swiftc the permission card falls
// back to delivery-probe heuristics.
try {
execFileSync(
process.execPath,
[
path.join(repoRoot, 'config', 'scripts', 'build-notification-status-macos.mjs'),
'--bundle-id',
bundleId,
'--single-arch',
'--output',
path.join(appPath, 'Contents', 'MacOS', 'orca-notification-status')
],
{ stdio: 'inherit' }
)
} catch (error) {
console.warn(
`[orca-dev] notification-status helper build failed (permission card falls back to probes): ${error?.message ?? error}`
)
}
// Why: the plist edits above (and the copy itself) break the bundle's
// ad-hoc seal, and macOS refuses Notification Center registration for
// invalidly-signed apps — every dev notification fails with UNErrorDomain
// error 1 and the app never appears in System Settings > Notifications.
// An ad-hoc re-sign restores delivery, the permission prompt, and the
// notification-settings deep link for dev builds. Non-fatal: a signing
// failure should not block `pnpm dev`.
try {
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
} catch (error) {
console.warn(
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
)
}
writeFileSync(markerPath, expectedMarker, 'utf8')
process.env.ELECTRON_EXEC_PATH = executablePath
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -0,0 +1,35 @@
// Prints the app's macOS notification settings as JSON and exits.
//
// Why this exists: Electron exposes no API for UNUserNotificationCenter
// authorization, and scheduling silently succeeds even while macOS suppresses
// display, so the renderer cannot know whether the user actually receives
// notifications. This binary must run from inside the app bundle (NSBundle
// resolves the bundle by walking up from the executable path) and must be
// code-signed with the app's identifier macOS keys notification records to
// the signing identifier, which is why the build embeds an Info.plist section
// with the target CFBundleIdentifier.
import Foundation
import UserNotifications
let semaphore = DispatchSemaphore(value: 0)
var authorization = "unknown"
var alert = "unknown"
UNUserNotificationCenter.current().getNotificationSettings { settings in
switch settings.authorizationStatus {
case .authorized: authorization = "authorized"
case .provisional: authorization = "provisional"
case .ephemeral: authorization = "ephemeral"
case .denied: authorization = "denied"
case .notDetermined: authorization = "not-determined"
@unknown default: authorization = "unknown"
}
switch settings.alertSetting {
case .enabled: alert = "enabled"
case .disabled: alert = "disabled"
case .notSupported: alert = "not-supported"
@unknown default: alert = "unknown"
}
semaphore.signal()
}
_ = semaphore.wait(timeout: .now() + 3)
print("{\"authorization\":\"\(authorization)\",\"alert\":\"\(alert)\"}")

View File

@ -42,6 +42,7 @@
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"build:relay": "node config/scripts/build-relay.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
"build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs",
"build:native": "node config/scripts/build-native-for-platform.mjs",
"smoke:computer": "node config/scripts/computer-use-smoke.mjs",
"verify:computer-native": "node config/scripts/verify-computer-native.mjs",

View File

@ -0,0 +1,96 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
export type NotificationAuthorizationStatus = 'authorized' | 'denied' | 'not-determined' | 'unknown'
const HELPER_EXECUTABLE = 'orca-notification-status'
const HELPER_TIMEOUT_MS = 4000
let cachedHelperPath: string | null | undefined
/**
* Resolves the bundled notification-status helper binary.
*
* Why: the helper must live inside the app bundle NSBundle resolves the
* process's bundle by walking up from the executable path, and macOS keys
* notification records to that identity. Both dev copies and packaged builds
* place it next to the Electron executable in Contents/MacOS.
*/
function resolveHelperPath(): string | null {
if (cachedHelperPath !== undefined) {
return cachedHelperPath
}
if (process.platform !== 'darwin') {
cachedHelperPath = null
return cachedHelperPath
}
// Dev copies place the helper next to the Electron executable; packaged
// builds ship it via extraResources. Both are inside the .app, which is
// what NSBundle resolution requires.
const candidates = [
join(dirname(process.execPath), HELPER_EXECUTABLE),
...(process.resourcesPath ? [join(process.resourcesPath, HELPER_EXECUTABLE)] : [])
]
cachedHelperPath = candidates.find((candidate) => existsSync(candidate)) ?? null
return cachedHelperPath
}
/**
* Reads the app's real macOS notification authorization via a helper binary
* calling UNUserNotificationCenter.getNotificationSettings. Returns null when
* the helper is unavailable or fails, so callers can fall back to weaker
* delivery-probe evidence.
*
* Why a helper at all: Electron exposes no API for notification authorization
* (scheduling silently succeeds even while macOS is suppressing display), so
* the only truthful signal is the native settings read.
*/
let readInFlight: Promise<NotificationAuthorizationStatus | null> | null = null
export function readNotificationAuthorizationStatus(): Promise<NotificationAuthorizationStatus | null> {
const helperPath = resolveHelperPath()
if (!helperPath) {
return Promise.resolve(null)
}
// Why: simultaneous agent completions across worktrees each consult the
// readout — one in-flight helper run answers all of them.
if (readInFlight) {
return readInFlight
}
readInFlight = runStatusHelper(helperPath).finally(() => {
readInFlight = null
})
return readInFlight
}
function runStatusHelper(helperPath: string): Promise<NotificationAuthorizationStatus | null> {
return new Promise((resolve) => {
execFile(helperPath, [], { timeout: HELPER_TIMEOUT_MS }, (error, stdout) => {
if (error) {
resolve(null)
return
}
try {
const parsed = JSON.parse(String(stdout).trim()) as { authorization?: string }
switch (parsed.authorization) {
case 'authorized':
case 'provisional':
case 'ephemeral':
resolve('authorized')
return
case 'denied':
resolve('denied')
return
case 'not-determined':
resolve('not-determined')
return
default:
resolve('unknown')
}
} catch {
resolve(null)
}
})
})
}

View File

@ -70,6 +70,17 @@ vi.mock('electron', () => ({
}
}))
const { readAuthorizationStatusMock } = vi.hoisted(() => ({
readAuthorizationStatusMock: vi.fn(
(): Promise<'authorized' | 'denied' | 'not-determined' | 'unknown' | null> =>
Promise.resolve(null)
)
}))
vi.mock('./notification-authorization-status', () => ({
readNotificationAuthorizationStatus: readAuthorizationStatusMock
}))
// Why: notifications.ts pulls in the tray module (for the minimized attention
// dot), which transitively loads app-icon/electron-toolkit; stub it so this
// suite stays focused on notification dispatch and avoids that import chain.
@ -106,6 +117,8 @@ describe('registerNotificationHandlers', () => {
notificationRemoveListenerMock.mockClear()
notificationIsSupportedMock.mockReset()
notificationIsSupportedMock.mockReturnValue(true)
readAuthorizationStatusMock.mockReset()
readAuthorizationStatusMock.mockResolvedValue(null)
getAllWindowsMock.mockReset()
getAllWindowsMock.mockReturnValue([])
shellOpenExternalMock.mockClear()
@ -176,7 +189,7 @@ describe('registerNotificationHandlers', () => {
return call[1] as () => void
}
it('registers the IPC handler', () => {
it('registers the IPC handler', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -192,7 +205,7 @@ describe('registerNotificationHandlers', () => {
expect(handleMock).toHaveBeenCalledWith('notifications:dispatch', expect.any(Function))
})
it('opens the current macOS app notification settings entry', () => {
it('opens the current macOS app notification settings entry', async () => {
const originalPlatform = process.platform
const originalBundleId = process.env.ORCA_DEV_MACOS_BUNDLE_ID
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
@ -225,7 +238,7 @@ describe('registerNotificationHandlers', () => {
}
})
it('opens Windows notification settings', () => {
it('opens Windows notification settings', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
try {
@ -249,7 +262,7 @@ describe('registerNotificationHandlers', () => {
}
})
it('suppresses notifications when disabled in settings', () => {
it('suppresses notifications when disabled in settings', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -262,14 +275,14 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({
delivered: false,
reason: 'disabled'
})
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('suppresses active-worktree notifications while Orca is focused', () => {
it('suppresses active-worktree notifications while Orca is focused', async () => {
getAllWindowsMock.mockReturnValue([
{
isDestroyed: () => false,
@ -289,7 +302,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete', isActiveWorktree: true })).toEqual({
expect(await handler({}, { source: 'agent-task-complete', isActiveWorktree: true })).toEqual({
delivered: false,
reason: 'suppressed-focus'
})
@ -355,7 +368,7 @@ describe('registerNotificationHandlers', () => {
})
})
it('delivers a notification when the event is allowed', () => {
it('delivers a notification when the event is allowed', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -369,7 +382,10 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler({}, { source: 'agent-task-complete', repoLabel: 'orca', worktreeLabel: 'feat/notis' })
await handler(
{},
{ source: 'agent-task-complete', repoLabel: 'orca', worktreeLabel: 'feat/notis' }
)
).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith(
expectedNativeNotificationOptions({
@ -380,7 +396,7 @@ describe('registerNotificationHandlers', () => {
expect(notificationShowMock).toHaveBeenCalledTimes(1)
})
it('uses the macOS default notification sound when no custom sound is configured', () => {
it('uses the macOS default notification sound when no custom sound is configured', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
try {
@ -397,7 +413,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith({
title: 'Orca notifications are on',
body: 'This is a test notification from Orca.',
@ -408,7 +424,7 @@ describe('registerNotificationHandlers', () => {
}
})
it('does not request a native macOS sound when a custom sound is configured', () => {
it('does not request a native macOS sound when a custom sound is configured', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
try {
@ -425,7 +441,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith({
title: 'Orca notifications are on',
body: 'This is a test notification from Orca.',
@ -436,7 +452,7 @@ describe('registerNotificationHandlers', () => {
}
})
it('focuses the originating terminal pane when a notification with paneKey is clicked', () => {
it('focuses the originating terminal pane when a notification with paneKey is clicked', async () => {
const webContentsSend = vi.fn()
const restore = vi.fn()
const focus = vi.fn()
@ -464,7 +480,7 @@ describe('registerNotificationHandlers', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
const handler = getDispatchHandler()
expect(
handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1', paneKey })
await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1', paneKey })
).toEqual({ delivered: true })
expect(vi.getTimerCount()).toBe(1)
@ -488,7 +504,7 @@ describe('registerNotificationHandlers', () => {
})
})
it('clears the retained notification fallback timer when the native notification closes', () => {
it('clears the retained notification fallback timer when the native notification closes', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -501,7 +517,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true })
expect(vi.getTimerCount()).toBe(1)
const closeHandler = getNotificationEventHandler('close')
@ -511,7 +527,7 @@ describe('registerNotificationHandlers', () => {
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler)
})
it('releases retained notifications when native delivery fails', () => {
it('releases retained notifications when native delivery fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
registerNotificationHandlers({
@ -526,7 +542,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true })
expect(vi.getTimerCount()).toBe(1)
const failedHandler = getNotificationEventHandler('failed')
@ -542,7 +558,7 @@ describe('registerNotificationHandlers', () => {
}
})
it('formats agent-task-complete with the agent response when a status snapshot is present', () => {
it('formats agent-task-complete with the agent response when a status snapshot is present', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -556,7 +572,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@ -580,7 +596,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('includes the repo name when multiple repos are active', () => {
it('includes the repo name when multiple repos are active', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -594,7 +610,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@ -617,7 +633,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('keeps a readable body when no assistant response was captured', () => {
it('keeps a readable body when no assistant response was captured', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -631,7 +647,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@ -654,7 +670,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('formats blocked and interrupted agent snapshots distinctly', () => {
it('formats blocked and interrupted agent snapshots distinctly', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -668,7 +684,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@ -682,7 +698,7 @@ describe('registerNotificationHandlers', () => {
).toEqual({ delivered: true })
vi.advanceTimersByTime(5001)
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@ -712,7 +728,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('normalizes custom agent labels and re-bounds multiline assistant previews', () => {
it('normalizes custom agent labels and re-bounds multiline assistant previews', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -727,7 +743,7 @@ describe('registerNotificationHandlers', () => {
const longAssistantMessage = `Line one\n\n${'x'.repeat(400)}`
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@ -754,7 +770,7 @@ describe('registerNotificationHandlers', () => {
expect(options.body.length).toBeLessThanOrEqual(180)
})
it('uses tool context before falling back when no prompt or assistant preview exists', () => {
it('uses tool context before falling back when no prompt or assistant preview exists', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -768,7 +784,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@ -790,7 +806,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('uses rich formatter output for mobile notifications before the native support guard', () => {
it('uses rich formatter output for mobile notifications before the native support guard', async () => {
notificationIsSupportedMock.mockReturnValue(false)
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
@ -809,7 +825,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@ -833,7 +849,7 @@ describe('registerNotificationHandlers', () => {
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications when notifications are disabled', () => {
it('does not dispatch mobile notifications when notifications are disabled', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@ -850,7 +866,7 @@ describe('registerNotificationHandlers', () => {
)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'disabled'
})
@ -858,7 +874,7 @@ describe('registerNotificationHandlers', () => {
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications when the source is disabled', () => {
it('does not dispatch mobile notifications when the source is disabled', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@ -875,7 +891,7 @@ describe('registerNotificationHandlers', () => {
)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'source-disabled'
})
@ -883,7 +899,7 @@ describe('registerNotificationHandlers', () => {
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications for focused active-worktree notifications', () => {
it('does not dispatch mobile notifications for focused active-worktree notifications', async () => {
getAllWindowsMock.mockReturnValue([
{
isDestroyed: () => false,
@ -907,7 +923,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{ source: 'agent-task-complete', worktreeId: 'repo::wt1', isActiveWorktree: true }
)
@ -919,7 +935,7 @@ describe('registerNotificationHandlers', () => {
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications for cooldown-suppressed bursts', () => {
it('does not dispatch mobile notifications for cooldown-suppressed bursts', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@ -936,10 +952,10 @@ describe('registerNotificationHandlers', () => {
)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'cooldown'
})
@ -950,7 +966,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('does not forward explicit desktop test notifications to mobile clients', () => {
it('does not forward explicit desktop test notifications to mobile clients', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@ -967,12 +983,12 @@ describe('registerNotificationHandlers', () => {
)
const handler = getDispatchHandler()
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('dismisses active native notifications and fans out mobile dismissal once per id', () => {
it('dismisses active native notifications and fans out mobile dismissal once per id', async () => {
const dispatchMobileNotification = vi.fn()
const dismissMobileNotification = vi.fn()
registerNotificationHandlers(
@ -991,7 +1007,7 @@ describe('registerNotificationHandlers', () => {
const dispatchHandler = getDispatchHandler()
expect(
dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:one' })
await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:one' })
).toEqual({ delivered: true })
const dismissHandler = getDismissHandler()
@ -1003,7 +1019,7 @@ describe('registerNotificationHandlers', () => {
expect(dismissMobileNotification).toHaveBeenCalledWith('agent:one')
})
it('fans out mobile dismissal even when there is no active native notification', () => {
it('fans out mobile dismissal even when there is no active native notification', async () => {
const dismissMobileNotification = vi.fn()
registerNotificationHandlers(
{
@ -1026,7 +1042,7 @@ describe('registerNotificationHandlers', () => {
expect(dismissMobileNotification).toHaveBeenCalledWith('agent:missing')
})
it('closes the previous native notification when replacing the same id', () => {
it('closes the previous native notification when replacing the same id', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -1040,18 +1056,18 @@ describe('registerNotificationHandlers', () => {
const dispatchHandler = getDispatchHandler()
expect(
dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' })
await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' })
).toEqual({ delivered: true })
vi.advanceTimersByTime(5001)
expect(
dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' })
await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' })
).toEqual({ delivered: true })
expect(notificationCloseMock).toHaveBeenCalledTimes(1)
expect(notificationShowMock).toHaveBeenCalledTimes(2)
})
it('silences the native notification when a custom sound is configured', () => {
it('silences the native notification when a custom sound is configured', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -1065,7 +1081,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith({
title: 'Orca notifications are on',
body: 'This is a test notification from Orca.',
@ -1073,7 +1089,7 @@ describe('registerNotificationHandlers', () => {
})
})
it('returns source-disabled when the specific source toggle is off', () => {
it('returns source-disabled when the specific source toggle is off', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -1086,13 +1102,13 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({
delivered: false,
reason: 'source-disabled'
})
})
it('deduplicates repeated notifications for the same worktree', () => {
it('deduplicates repeated notifications for the same worktree', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -1105,23 +1121,23 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'cooldown'
})
vi.advanceTimersByTime(5001)
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(notificationShowMock).toHaveBeenCalledTimes(2)
})
it('bounds notification cooldown keys during unique worktree bursts', () => {
it('bounds notification cooldown keys during unique worktree bursts', async () => {
notificationIsSupportedMock.mockReturnValue(false)
registerNotificationHandlers({
getSettings: () => ({
@ -1136,24 +1152,24 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
for (let i = 0; i < 75; i++) {
expect(handler({}, { source: 'terminal-bell', worktreeId: `repo::wt-${i}` })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: `repo::wt-${i}` })).toEqual({
delivered: false,
reason: 'not-supported'
})
}
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-0' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-0' })).toEqual({
delivered: false,
reason: 'not-supported'
})
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-74' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-74' })).toEqual({
delivered: false,
reason: 'cooldown'
})
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('deduplicates agent-task-complete and terminal-bell for the same worktree', () => {
it('deduplicates agent-task-complete and terminal-bell for the same worktree', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -1167,17 +1183,46 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'cooldown'
})
expect(notificationShowMock).toHaveBeenCalledTimes(1)
})
it('does not cooldown explicit test notifications', () => {
it('skips native delivery and reports blocked-by-system when macOS would swallow it', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
try {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never)
readAuthorizationStatusMock.mockResolvedValue('denied')
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({
delivered: false,
reason: 'blocked-by-system'
})
// Why: a swallowed native notification would still pile up in the
// Notification Center delivered list — skip creating it entirely.
expect(notificationCtorMock).not.toHaveBeenCalled()
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
}
})
it('does not cooldown explicit test notifications', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -1191,8 +1236,8 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationShowMock).toHaveBeenCalledTimes(2)
})
@ -1211,6 +1256,9 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
const result = handler({}, { source: 'test', requireDisplayConfirmation: true })
// Why: the darwin authorization gate resolves before the notification is
// created, so flush microtasks before grabbing its event listeners.
await vi.advanceTimersByTimeAsync(0)
const showHandler = getNotificationOnceEventHandler('show')
const failedHandler = getNotificationOnceEventHandler('failed')
showHandler()
@ -1236,6 +1284,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
const result = handler({}, { source: 'test', requireDisplayConfirmation: true })
await vi.advanceTimersByTimeAsync(0)
const showHandler = getNotificationOnceEventHandler('show')
const failedHandler = getNotificationOnceEventHandler('failed')
await vi.advanceTimersByTimeAsync(2501)
@ -1285,13 +1334,13 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getLoadSoundHandler()
await expect(handler({})).resolves.toEqual({
expect(await handler({})).toEqual({
ok: false,
reason: 'unsupported-type'
})
})
it('resolves the sound path without reading the file', () => {
it('resolves the sound path without reading the file', async () => {
const soundPath = join(tempDir, 'sound.ogg')
writeFileSync(soundPath, Buffer.from([1, 2, 3]))
registerNotificationHandlers({
@ -1307,10 +1356,10 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getResolveSoundPathHandler()
expect(handler({})).toEqual({ ok: true, path: soundPath })
expect(await handler({})).toEqual({ ok: true, path: soundPath })
})
it('rejects unsupported types from resolveSoundPath without touching the disk', () => {
it('rejects unsupported types from resolveSoundPath without touching the disk', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@ -1324,7 +1373,203 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getResolveSoundPathHandler()
expect(handler({})).toEqual({ ok: false, reason: 'unsupported-type' })
expect(await handler({})).toEqual({ ok: false, reason: 'unsupported-type' })
})
})
describe('notifications:probeDelivery', () => {
const originalPlatform = process.platform
function getProbeDeliveryHandler(): (event: unknown, args?: { force?: boolean }) => unknown {
const call = handleMock.mock.calls.find(
(c: unknown[]) => c[0] === 'notifications:probeDelivery'
)
if (!call) {
throw new Error('notifications:probeDelivery handler not registered')
}
return call[1] as (event: unknown, args?: { force?: boolean }) => unknown
}
function getProbeOnceEventHandler(eventName: string): (...args: unknown[]) => void {
// Why: findLast — a test may run several probes, and only the newest
// probe's listeners can settle the pending promise.
const call = notificationOnceMock.mock.calls.findLast((c: unknown[]) => c[0] === eventName)
if (!call) {
throw new Error(`Probe notification ${eventName} once handler not registered`)
}
return call[1] as (...args: unknown[]) => void
}
function createStore(ui: Record<string, unknown> = {}): {
getSettings: () => unknown
getUI: () => Record<string, unknown>
updateUI: ReturnType<typeof vi.fn>
} {
const state = { ...ui }
return {
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
}),
getUI: () => state,
updateUI: vi.fn((updates: Record<string, unknown>) => {
Object.assign(state, updates)
})
}
}
beforeEach(() => {
vi.useFakeTimers()
handleMock.mockReset()
removeHandlerMock.mockReset()
notificationCtorMock.mockClear()
notificationShowMock.mockClear()
notificationCloseMock.mockClear()
notificationOnMock.mockClear()
notificationOnceMock.mockClear()
notificationRemoveListenerMock.mockClear()
notificationIsSupportedMock.mockReset()
notificationIsSupportedMock.mockReturnValue(true)
readAuthorizationStatusMock.mockReset()
readAuthorizationStatusMock.mockResolvedValue(null)
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
it('reports unsupported on non-darwin platforms without probing', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
const store = createStore()
registerNotificationHandlers(store as never)
await expect(getProbeDeliveryHandler()({})).resolves.toEqual({
state: 'unsupported',
authoritative: false
})
expect(notificationCtorMock).not.toHaveBeenCalled()
expect(store.updateUI).not.toHaveBeenCalled()
})
it('reports authoritative states straight from the authorization readout', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
readAuthorizationStatusMock.mockResolvedValue('authorized')
expect(await handler({})).toEqual({ state: 'delivered', authoritative: true })
readAuthorizationStatusMock.mockResolvedValue('denied')
expect(await handler({})).toEqual({ state: 'blocked', authoritative: true })
// No probe notifications were needed for either readout.
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('fires one dialog-trigger probe per session while the decision is pending', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
readAuthorizationStatusMock.mockResolvedValue('not-determined')
expect(await handler({})).toEqual({
state: 'awaiting-decision',
authoritative: true
})
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
// Polling again while pending must not spam more probe notifications.
expect(await handler({}, { force: true })).toEqual({
state: 'awaiting-decision',
authoritative: true
})
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
})
it('marks the one-shot permission registration as done so startup cannot re-prompt', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const result = getProbeDeliveryHandler()({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(store.updateUI).toHaveBeenCalledWith({ notificationPermissionRequested: true })
getProbeOnceEventHandler('failed')({}, 'not allowed')
await expect(result).resolves.toEqual({ state: 'blocked', authoritative: false })
})
it('falls back to delivery probes when the readout is unavailable', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const result = getProbeDeliveryHandler()({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(notificationShowMock).toHaveBeenCalledTimes(1)
getProbeOnceEventHandler('show')()
await expect(result).resolves.toEqual({ state: 'delivered', authoritative: false })
// No persisted confirmation on purpose: OS permission changes between runs.
expect(store.updateUI).not.toHaveBeenCalledWith({ notificationDeliveryConfirmed: true })
})
it('serves session evidence without probing again until forced', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
const probeResult = handler({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
getProbeOnceEventHandler('show')()
await expect(probeResult).resolves.toEqual({ state: 'delivered', authoritative: false })
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
// Cached session evidence answers non-force calls with no new probe.
expect(await handler({})).toEqual({ state: 'delivered', authoritative: false })
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
// Force bypasses the cache and schedules a fresh probe.
const forced = handler({}, { force: true }) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(notificationCtorMock).toHaveBeenCalledTimes(2)
getProbeOnceEventHandler('show')()
await expect(forced).resolves.toEqual({ state: 'delivered', authoritative: false })
})
it('serves cached failure evidence after a rejected probe', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
const probeResult = handler({}, { force: true }) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
getProbeOnceEventHandler('failed')({}, 'Notifications are not allowed for this application')
await expect(probeResult).resolves.toEqual({ state: 'blocked', authoritative: false })
expect(await handler({})).toEqual({ state: 'blocked', authoritative: false })
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
})
it('resolves blocked on timeout without recording a definitive failure', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
const probeResult = handler({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(3001)
await expect(probeResult).resolves.toEqual({ state: 'blocked', authoritative: false })
expect(notificationCloseMock).toHaveBeenCalledTimes(1)
// A timeout is ambiguous evidence, so the next non-force call probes again.
const secondResult = handler({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(notificationCtorMock).toHaveBeenCalledTimes(2)
getProbeOnceEventHandler('show')()
await expect(secondResult).resolves.toEqual({ state: 'delivered', authoritative: false })
})
})
@ -1356,7 +1601,7 @@ describe('triggerStartupNotificationRegistration', () => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
it('shows welcome notification when not yet requested', () => {
it('shows welcome notification when not yet requested', async () => {
const store = {
getUI: () => ({ notificationPermissionRequested: undefined }),
updateUI: vi.fn()
@ -1372,7 +1617,7 @@ describe('triggerStartupNotificationRegistration', () => {
expect(notificationShowMock).toHaveBeenCalledTimes(1)
})
it('does not fire when notificationPermissionRequested flag is set', () => {
it('does not fire when notificationPermissionRequested flag is set', async () => {
const store = {
getUI: () => ({ notificationPermissionRequested: true }),
updateUI: vi.fn()
@ -1383,7 +1628,7 @@ describe('triggerStartupNotificationRegistration', () => {
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('does nothing on non-darwin platforms', () => {
it('does nothing on non-darwin platforms', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
const store = {
getUI: () => ({ notificationPermissionRequested: undefined }),
@ -1395,7 +1640,7 @@ describe('triggerStartupNotificationRegistration', () => {
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('clears startup notification timers when the notification is clicked', () => {
it('clears startup notification timers when the notification is clicked', async () => {
const store = {
getUI: () => ({ notificationPermissionRequested: undefined }),
updateUI: vi.fn()
@ -1413,7 +1658,7 @@ describe('triggerStartupNotificationRegistration', () => {
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', expect.any(Function))
})
it('cleans up startup notification registration when native delivery fails', () => {
it('cleans up startup notification registration when native delivery fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const store = {

View File

@ -13,6 +13,7 @@ import thumpSoundPath from '../../../resources/notification-sounds/thump.mp3?ass
import twoToneSoundPath from '../../../resources/notification-sounds/two-tone.mp3?asset'
import type { Store } from '../persistence'
import type {
NotificationDeliveryProbeResult,
NotificationDispatchRequest,
NotificationDispatchResult,
NotificationDismissResult,
@ -23,6 +24,7 @@ import type {
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { buildNotificationOptions } from './notification-options'
import { readNotificationAuthorizationStatus } from './notification-authorization-status'
import { parsePaneKey } from '../../shared/stable-pane-id'
import { setTrayAttention } from '../tray/system-tray'
import { isMainWindowVisible } from '../window/main-window-visibility'
@ -98,6 +100,113 @@ function retainNotificationUntilRelease(
return release
}
const NOTIFICATION_PROBE_RESULT_TIMEOUT_MS = 3000
const NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS = 4000
// Why: Electron has no API to read macOS UNUserNotificationCenter
// authorization, so the freshest signal we have is what happened to the last
// notification we scheduled. Session-scoped on purpose: OS-level permission
// can change between runs, and a stale positive renders a false green card.
let lastObservedDeliveryOutcome: 'delivered' | 'failed' | null = null
let deliveryProbeInFlight: Promise<NotificationDeliveryProbeResult> | null = null
// Why: firing one probe notification is what instantiates Electron's
// presenter and pops the macOS permission dialog. Once per session is enough
// while the authorization readout reports the decision as pending.
let permissionDialogTriggeredThisSession = false
/**
* Fallback signal for hosts without the native helper. Schedules a silent
* probe notification and reports whether macOS accepted it. 'failed' means
* the request was rejected (permission denied, or an unsigned build). On a
* fresh install the probe also instantiates Electron's notification
* presenter, which is what makes macOS pop the "Allow notifications?" dialog.
*
* Known ambiguity with no public API to resolve it (verified on macOS 26):
* while the dialog is unanswered and when notifications are toggled off in
* System Settings after being authorized macOS still accepts requests and
* silently swallows them, so 'delivered' can over-report. 'failed' fires for
* hard rejections (unsigned builds, dialog-level denial). The bundled
* notification-status helper exists precisely to avoid this ambiguity.
*/
function probeNotificationDelivery(): Promise<NotificationDeliveryProbeResult> {
if (deliveryProbeInFlight) {
return deliveryProbeInFlight
}
permissionDialogTriggeredThisSession = true
const probe = new Notification({
title: 'Orca notifications are on',
body: 'Orca will alert you when agents finish or terminals need attention.',
silent: true
})
activeNotifications.add(probe)
deliveryProbeInFlight = new Promise<NotificationDeliveryProbeResult>((resolve) => {
let settled = false
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
function releaseProbe(): void {
activeNotifications.delete(probe)
probe.removeListener('show', onShow)
probe.removeListener('failed', onFailed)
probe.close()
}
function settle(state: 'delivered' | 'blocked'): void {
if (settled) {
return
}
settled = true
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
lastObservedDeliveryOutcome = state === 'delivered' ? 'delivered' : 'failed'
resolve({ state, authoritative: false })
}
function onShow(): void {
settle('delivered')
// Why: when delivery works the probe banner is visible, so it doubles
// as the user-facing confirmation — let it linger briefly instead of
// vanishing the instant it appears.
const closeTimer = setTimeout(releaseProbe, NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS)
if (typeof closeTimer.unref === 'function') {
closeTimer.unref()
}
}
function onFailed(_event: unknown, _error?: string): void {
// Why: a rejected probe is an expected outcome (denied permission), not
// an anomaly — logging it would spam the console on every poll while
// the onboarding card waits for the user to allow notifications.
settle('blocked')
releaseProbe()
}
probe.once('show', onShow)
probe.once('failed', onFailed)
// Why: don't record a 'failed' outcome on timeout — a missing callback is
// ambiguous, while the 'failed' event is a definitive rejection.
timeoutTimer = setTimeout(() => {
if (!settled) {
settled = true
resolve({ state: 'blocked', authoritative: false })
releaseProbe()
}
}, NOTIFICATION_PROBE_RESULT_TIMEOUT_MS)
if (typeof timeoutTimer.unref === 'function') {
timeoutTimer.unref()
}
probe.show()
}).finally(() => {
deliveryProbeInFlight = null
})
return deliveryProbeInFlight
}
function getMacNotificationSettingsUrl(): string {
const bundleId = process.env.ORCA_DEV_MACOS_BUNDLE_ID ?? MACOS_PACKAGED_BUNDLE_ID
return `${MACOS_NOTIFICATION_SETTINGS_URL}?id=${encodeURIComponent(bundleId)}`
@ -205,10 +314,15 @@ function pruneRecentNotifications(recentNotifications: Map<string, number>, now:
export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void {
const recentNotifications = new Map<string, number>()
// Why: handler registration marks a fresh session — permission evidence
// from a previous registration must not leak into the new one.
lastObservedDeliveryOutcome = null
deliveryProbeInFlight = null
permissionDialogTriggeredThisSession = false
ipcMain.removeHandler('notifications:openSystemSettings')
ipcMain.removeHandler('notifications:getPermissionStatus')
ipcMain.removeHandler('notifications:requestPermission')
ipcMain.removeHandler('notifications:probeDelivery')
ipcMain.handle('notifications:openSystemSettings', (): void => {
openNotificationSystemSettings()
})
@ -227,10 +341,52 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
})
ipcMain.handle('notifications:getPermissionStatus', getPermissionStatus)
ipcMain.handle('notifications:requestPermission', (): NotificationPermissionStatusResult => {
triggerStartupNotificationRegistration(store)
return getPermissionStatus()
})
ipcMain.handle(
'notifications:probeDelivery',
async (_event, args?: { force?: boolean }): Promise<NotificationDeliveryProbeResult> => {
// Why: macOS-only. Windows/Linux have no equivalent first-use permission
// dialog, so the onboarding card that consumes this never renders there.
if (process.platform !== 'darwin' || !Notification.isSupported()) {
return { state: 'unsupported', authoritative: false }
}
// Why: probes (and the native helper's first-launch path) surface the
// macOS permission dialog — mark the one-shot startup registration as
// done so it can't fire a second prompt later.
if (store.getUI().notificationPermissionRequested !== true) {
store.updateUI({ notificationPermissionRequested: true })
}
// Preferred source: the bundled helper reads the real
// UNUserNotificationCenter authorization. Silent, so polling with it
// tracks System Settings changes live without flashing banners.
const authorization = await readNotificationAuthorizationStatus()
if (authorization === 'authorized') {
lastObservedDeliveryOutcome = 'delivered'
return { state: 'delivered', authoritative: true }
}
if (authorization === 'denied') {
lastObservedDeliveryOutcome = 'failed'
return { state: 'blocked', authoritative: true }
}
if (authorization === 'not-determined') {
// Why: the dialog only appears once something asks — fire a single
// probe per session to trigger it, then report the pending decision.
if (!permissionDialogTriggeredThisSession) {
void probeNotificationDelivery()
}
return { state: 'awaiting-decision', authoritative: true }
}
// Helper unavailable ('unknown' status is also unusable evidence):
// fall back to scheduling-based probes with session caching, which
// avoids repeated probe banners when delivery works.
if (!args?.force && lastObservedDeliveryOutcome !== null) {
return {
state: lastObservedDeliveryOutcome === 'delivered' ? 'delivered' : 'blocked',
authoritative: false
}
}
return probeNotificationDelivery()
}
)
ipcMain.removeHandler('notifications:dismiss')
ipcMain.handle('notifications:dismiss', (_event, ids: string[]): NotificationDismissResult => {
@ -335,116 +491,140 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
return { delivered: false, reason: 'not-supported' }
}
if (getEffectiveNotificationSoundId(settings) !== 'system') {
notificationOptions.silent = true
} else if (process.platform === 'darwin') {
// Why: macOS treats an unset notification sound as silent. When Orca is
// using the OS sound, ask Electron for the default notification sound.
notificationOptions.sound = 'default'
}
const notification = new Notification(notificationOptions)
if (args.notificationId) {
const previous = activeNotificationsById.get(args.notificationId)
if (previous) {
previous.notification.close()
previous.release()
function deliverNativeNotification():
| NotificationDispatchResult
| Promise<NotificationDispatchResult> {
if (getEffectiveNotificationSoundId(settings) !== 'system') {
notificationOptions.silent = true
} else if (process.platform === 'darwin') {
// Why: macOS treats an unset notification sound as silent. When Orca is
// using the OS sound, ask Electron for the default notification sound.
notificationOptions.sound = 'default'
}
}
// Why: prevent GC from collecting the notification (and its click
// handler) while it's still visible in macOS Notification Center.
let clickHandler: (() => void) | null = null
let failedHandler: ((_event: unknown, error?: string) => void) | null = null
const entryForId: { notification: Notification; release: () => void } | null =
args.notificationId ? { notification, release: () => {} } : null
const release = retainNotificationUntilRelease(notification, () => {
if (clickHandler) {
notification.removeListener('click', clickHandler)
clickHandler = null
}
if (failedHandler) {
notification.removeListener('failed', failedHandler)
failedHandler = null
}
if (
args.notificationId &&
activeNotificationsById.get(args.notificationId) === entryForId
) {
activeNotificationsById.delete(args.notificationId)
}
})
if (entryForId && args.notificationId) {
entryForId.release = release
activeNotificationsById.set(args.notificationId, entryForId)
}
failedHandler = (_event, error) => {
// Why: Electron 42's macOS UNNotification backend reports unsigned
// apps and native delivery errors here; release immediately instead
// of retaining a dead notification until the fallback timer.
logNativeNotificationFailure(args.source, error)
release()
}
notification.on('failed', failedHandler)
// Why: clicking a notification should bring Orca to the foreground and
// switch to the worktree/pane that triggered it. Worktree activation owns
// repo/sidebar state; the optional focusTerminal follow-up uses the stable
// pane leaf id so split-pane notifications land on the exact pane.
// Why: worktreeId is formatted as "repoId::worktreePath". If the
// separator is missing we cannot reliably extract a repoId, so skip
// the click-to-navigate binding — the notification still fires but
// clicking it will not attempt to switch to an unknown worktree.
if (args.worktreeId && args.worktreeId.includes('::')) {
const repoId = getRepoIdFromWorktreeId(args.worktreeId)
clickHandler = () => {
release()
const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
if (!win) {
return
}
if (process.platform === 'darwin') {
app.focus({ steal: true })
}
if (win.isMinimized()) {
win.restore()
}
win.focus()
win.webContents.send('ui:activateWorktree', {
repoId,
worktreeId: args.worktreeId
})
const paneTarget = args.paneKey ? parsePaneKey(args.paneKey) : null
if (paneTarget) {
win.webContents.send('ui:focusTerminal', {
tabId: paneTarget.tabId,
worktreeId: args.worktreeId,
leafId: paneTarget.leafId,
ackPaneKeyOnSuccess: args.paneKey,
flashFocusedPane: true,
scrollToBottomIfOutputSinceLastView: true
})
const notification = new Notification(notificationOptions)
if (args.notificationId) {
const previous = activeNotificationsById.get(args.notificationId)
if (previous) {
previous.notification.close()
previous.release()
}
}
notification.on('click', clickHandler)
}
const displayConfirmation = args.requireDisplayConfirmation
? waitForNotificationDisplay(notification)
: null
notification.show()
if (displayConfirmation) {
return displayConfirmation.then((displayed) => {
if (!displayed) {
release()
return { delivered: false, reason: 'not-displayed' }
// Why: prevent GC from collecting the notification (and its click
// handler) while it's still visible in macOS Notification Center.
let clickHandler: (() => void) | null = null
let failedHandler: ((_event: unknown, error?: string) => void) | null = null
const entryForId: { notification: Notification; release: () => void } | null =
args.notificationId ? { notification, release: () => {} } : null
const release = retainNotificationUntilRelease(notification, () => {
if (clickHandler) {
notification.removeListener('click', clickHandler)
clickHandler = null
}
if (failedHandler) {
notification.removeListener('failed', failedHandler)
failedHandler = null
}
if (
args.notificationId &&
activeNotificationsById.get(args.notificationId) === entryForId
) {
activeNotificationsById.delete(args.notificationId)
}
return { delivered: true }
})
if (entryForId && args.notificationId) {
entryForId.release = release
activeNotificationsById.set(args.notificationId, entryForId)
}
failedHandler = (_event, error) => {
// Why: Electron 42's macOS UNNotification backend reports unsigned
// apps and native delivery errors here; release immediately instead
// of retaining a dead notification until the fallback timer.
logNativeNotificationFailure(args.source, error)
// A definitive rejection — feeds the permission card's evidence.
lastObservedDeliveryOutcome = 'failed'
release()
}
notification.on('failed', failedHandler)
// Why: clicking a notification should bring Orca to the foreground and
// switch to the worktree/pane that triggered it. Worktree activation owns
// repo/sidebar state; the optional focusTerminal follow-up uses the stable
// pane leaf id so split-pane notifications land on the exact pane.
// Why: worktreeId is formatted as "repoId::worktreePath". If the
// separator is missing we cannot reliably extract a repoId, so skip
// the click-to-navigate binding — the notification still fires but
// clicking it will not attempt to switch to an unknown worktree.
if (args.worktreeId && args.worktreeId.includes('::')) {
const repoId = getRepoIdFromWorktreeId(args.worktreeId)
clickHandler = () => {
release()
const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
if (!win) {
return
}
if (process.platform === 'darwin') {
app.focus({ steal: true })
}
if (win.isMinimized()) {
win.restore()
}
win.focus()
win.webContents.send('ui:activateWorktree', {
repoId,
worktreeId: args.worktreeId
})
const paneTarget = args.paneKey ? parsePaneKey(args.paneKey) : null
if (paneTarget) {
win.webContents.send('ui:focusTerminal', {
tabId: paneTarget.tabId,
worktreeId: args.worktreeId,
leafId: paneTarget.leafId,
ackPaneKeyOnSuccess: args.paneKey,
flashFocusedPane: true,
scrollToBottomIfOutputSinceLastView: true
})
}
}
notification.on('click', clickHandler)
}
const displayConfirmation = args.requireDisplayConfirmation
? waitForNotificationDisplay(notification)
: null
notification.show()
if (displayConfirmation) {
return displayConfirmation.then((displayed) => {
if (!displayed) {
release()
return { delivered: false, reason: 'not-displayed' }
}
lastObservedDeliveryOutcome = 'delivered'
return { delivered: true }
})
}
return { delivered: true }
}
return { delivered: true }
if (process.platform !== 'darwin') {
return deliverNativeNotification()
}
// Why: macOS silently swallows accepted notifications while permission
// is denied or the permission dialog is unanswered (verified on macOS
// 26). Skip the doomed native notification and tell the caller, so the
// renderer can surface an in-app fallback pointing at System Settings.
// The mobile dispatch above is unaffected — paired devices have their
// own notification channel.
return readNotificationAuthorizationStatus().then((authorization) => {
if (authorization === 'denied' || authorization === 'not-determined') {
lastObservedDeliveryOutcome = 'failed'
return { delivered: false, reason: 'blocked-by-system' }
}
return deliverNativeNotification()
})
}
)
@ -582,6 +762,7 @@ export function triggerStartupNotificationRegistration(store: Store): void {
// Why: Electron 42 requires code-signed macOS apps for UNNotification
// delivery. Unsigned builds fail here instead of producing the permission UI.
logNativeNotificationFailure('startup registration', error)
lastObservedDeliveryOutcome = 'failed'
cleanup()
}

View File

@ -126,6 +126,7 @@ import type {
GetRateLimitResult,
NotificationDispatchRequest,
NotificationDispatchResult,
NotificationDeliveryProbeResult,
NotificationDismissResult,
NotificationPermissionStatusResult,
NotificationSoundResult,
@ -1995,7 +1996,7 @@ export type PreloadApi = {
dismiss: (ids: string[]) => Promise<NotificationDismissResult>
openSystemSettings: () => Promise<void>
getPermissionStatus: () => Promise<NotificationPermissionStatusResult>
requestPermission: () => Promise<NotificationPermissionStatusResult>
probeDelivery: (args?: { force?: boolean }) => Promise<NotificationDeliveryProbeResult>
playSound: (options?: { force?: boolean; volume?: number }) => Promise<NotificationSoundResult>
}
onboarding: {

View File

@ -36,6 +36,7 @@ import type {
MemorySnapshot,
NotificationDismissResult,
NotificationDispatchResult,
NotificationDeliveryProbeResult,
NotificationPermissionStatusResult,
NotificationSoundDataResult,
NotificationSoundPathResult,
@ -1879,8 +1880,8 @@ const api = {
openSystemSettings: (): Promise<void> => ipcRenderer.invoke('notifications:openSystemSettings'),
getPermissionStatus: (): Promise<NotificationPermissionStatusResult> =>
ipcRenderer.invoke('notifications:getPermissionStatus'),
requestPermission: (): Promise<NotificationPermissionStatusResult> =>
ipcRenderer.invoke('notifications:requestPermission'),
probeDelivery: (args?: { force?: boolean }): Promise<NotificationDeliveryProbeResult> =>
ipcRenderer.invoke('notifications:probeDelivery', args),
playSound: async (options?: {
force?: boolean
volume?: number

View File

@ -0,0 +1,228 @@
import { useEffect, useState } from 'react'
import { BellRing, Check, Settings, TriangleAlert } from 'lucide-react'
import type { NotificationDeliveryProbeResult } from '../../../../shared/types'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
export type MacNotificationPermissionState =
| 'checking'
| 'awaiting-permission'
| 'enabled'
| 'blocked'
const MAC_PROBE_POLL_INTERVAL_MS = 2500
// Why: bounded so an abandoned onboarding tab doesn't probe forever; ~3
// minutes comfortably covers answering the dialog or flipping the toggle
// in System Settings.
const MAC_PROBE_POLL_MAX_ATTEMPTS = 72
export function resolveMacNotificationPermissionState(
probeState: NotificationDeliveryProbeResult['state'],
promptedBefore: boolean
): MacNotificationPermissionState | null {
if (probeState === 'unsupported') {
return null
}
if (probeState === 'delivered') {
return 'enabled'
}
if (probeState === 'awaiting-decision') {
return 'awaiting-permission'
}
// Why: probe-fallback hosts can't tell "unanswered dialog" from "denied" —
// a first-ever probe is what makes macOS show the permission dialog, so
// its rejection means "unanswered", not "denied".
return promptedBefore ? 'blocked' : 'awaiting-permission'
}
export function useMacNotificationPermissionState(
enabled: boolean = true
): [MacNotificationPermissionState | null, (state: MacNotificationPermissionState | null) => void] {
const [macPermissionState, setMacPermissionState] =
useState<MacNotificationPermissionState | null>(null)
useEffect(() => {
// Why: while Orca's own notifications setting is off, the OS permission
// is irrelevant — a green "notifications are enabled" card next to a
// disabled toggle reads as a contradiction. Hide the card and skip the
// readout polling entirely until the setting is back on.
if (!enabled) {
setMacPermissionState(null)
return
}
let cancelled = false
let pollTimer: ReturnType<typeof setTimeout> | null = null
let pollAttempts = 0
function schedulePoll(promptedBefore: boolean): void {
if (cancelled || pollAttempts >= MAC_PROBE_POLL_MAX_ATTEMPTS) {
return
}
pollTimer = setTimeout(() => {
pollAttempts += 1
void window.api.notifications.probeDelivery({ force: true }).then((probe) => {
if (cancelled) {
return
}
setMacPermissionState(resolveMacNotificationPermissionState(probe.state, promptedBefore))
// Why: authoritative readouts are silent, so keep tracking System
// Settings live in every state — flipping the toggle updates the
// card within a poll. Probe fallbacks flash a banner when delivery
// works, so for them polling stops once the card turns green.
if (probe.authoritative || probe.state !== 'delivered') {
schedulePoll(promptedBefore)
}
})
}, MAC_PROBE_POLL_INTERVAL_MS)
}
void (async () => {
const status = await window.api.notifications.getPermissionStatus()
if (cancelled) {
return
}
if (status.platform !== 'darwin' || !status.supported) {
return
}
setMacPermissionState('checking')
// Why: `status.requested` is read before the probe stamps it, so a
// fresh install (where the check itself pops the macOS dialog) renders
// as "answer the dialog" instead of "blocked" on probe-fallback hosts.
const probe = await window.api.notifications.probeDelivery()
if (cancelled) {
return
}
const resolved = resolveMacNotificationPermissionState(probe.state, status.requested)
setMacPermissionState(resolved)
if (resolved !== null && (probe.authoritative || resolved !== 'enabled')) {
schedulePoll(status.requested)
}
})()
return () => {
cancelled = true
if (pollTimer) {
clearTimeout(pollTimer)
}
}
}, [enabled])
return [macPermissionState, setMacPermissionState]
}
export function MacNotificationPermissionCard({
state
}: {
state: MacNotificationPermissionState | null
}): React.JSX.Element | null {
if (state === 'checking') {
return (
<section className="rounded-xl border border-border bg-muted/20 px-5 py-4 text-[13px] text-muted-foreground">
{translate(
'auto.components.onboarding.NotificationStep.56b836215c',
'Checking notification permission…'
)}
</section>
)
}
if (state === 'enabled') {
return (
<section className="flex items-center gap-2.5 rounded-xl border border-emerald-500/30 bg-emerald-500/[0.07] px-5 py-4">
<Check className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" strokeWidth={3} />
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">
{translate(
'auto.components.onboarding.NotificationStep.fd84d3e9b8',
'Notifications are enabled'
)}
</div>
<p className="text-[13px] leading-relaxed text-muted-foreground">
{translate(
'auto.components.onboarding.NotificationStep.4f7bce5644',
'macOS will alert you when agents finish or terminals need attention.'
)}
</p>
</div>
</section>
)
}
if (state === 'awaiting-permission') {
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<BellRing className="size-4" />
{translate(
'auto.components.onboarding.NotificationStep.95d99b52fa',
'Allow notifications for Orca'
)}
</div>
<p className="max-w-[58ch] text-[13px] leading-relaxed text-muted-foreground">
{translate(
'auto.components.onboarding.mac.notification.permission.card.f696515944',
'Click Allow in the macOS dialog.'
)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={() => void window.api.notifications.openSystemSettings()}
>
<Settings className="size-3.5" />
{translate(
'auto.components.onboarding.NotificationStep.4f6a1da718',
'Open System Settings'
)}
</Button>
</div>
</section>
)
}
if (state === 'blocked') {
return (
<section
role="alert"
className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-5 py-4"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 text-sm font-semibold text-amber-700 dark:text-amber-300">
<TriangleAlert className="size-4" />
{translate(
'auto.components.onboarding.NotificationStep.90b5d2e363',
'macOS is not delivering Orca notifications'
)}
</div>
<p className="max-w-[58ch] text-[13px] leading-relaxed text-amber-700/80 dark:text-amber-200/80">
{translate(
'auto.components.onboarding.mac.notification.permission.card.721d2bedb6',
'Turn on Allow notifications for Orca in System Settings.'
)}
</p>
</div>
<Button
type="button"
size="sm"
className="gap-2"
onClick={() => void window.api.notifications.openSystemSettings()}
>
<Settings className="size-3.5" />
{translate(
'auto.components.onboarding.NotificationStep.4f6a1da718',
'Open System Settings'
)}
</Button>
</div>
</section>
)
}
return null
}

View File

@ -2,6 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import { NotificationStep } from './NotificationStep'
import { resolveMacNotificationPermissionState } from '../notifications/mac-notification-permission-card'
function createSettings(
notificationOverrides: Partial<GlobalSettings['notifications']> = {}
@ -47,4 +48,42 @@ describe('NotificationStep', () => {
expect(html).not.toContain('Notification sound volume')
expect(html).not.toContain('80%')
})
it('does not render a macOS permission card before the delivery probe resolves', () => {
const html = renderToStaticMarkup(
<NotificationStep settings={createSettings()} updateSettings={vi.fn()} />
)
expect(html).not.toContain('Open System Settings')
expect(html).not.toContain('Notifications are enabled')
})
})
describe('resolveMacNotificationPermissionState', () => {
it('hides the card when notifications are unsupported', () => {
expect(resolveMacNotificationPermissionState('unsupported', false)).toBeNull()
expect(resolveMacNotificationPermissionState('unsupported', true)).toBeNull()
})
it('maps delivered probes to enabled', () => {
expect(resolveMacNotificationPermissionState('delivered', false)).toBe('enabled')
expect(resolveMacNotificationPermissionState('delivered', true)).toBe('enabled')
})
it('maps a pending authorization decision to the awaiting card', () => {
expect(resolveMacNotificationPermissionState('awaiting-decision', false)).toBe(
'awaiting-permission'
)
expect(resolveMacNotificationPermissionState('awaiting-decision', true)).toBe(
'awaiting-permission'
)
})
it('treats a first-ever rejection as an unanswered permission dialog', () => {
expect(resolveMacNotificationPermissionState('blocked', false)).toBe('awaiting-permission')
})
it('treats a rejection after a prior prompt as blocked', () => {
expect(resolveMacNotificationPermissionState('blocked', true)).toBe('blocked')
})
})

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { BellRing, FileAudio, Settings, Upload } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { BellRing, FileAudio, Upload } from 'lucide-react'
import { toast } from 'sonner'
import type { GlobalSettings, NotificationPermissionStatusResult } from '../../../../shared/types'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '@/components/ui/button'
import {
Select,
@ -13,6 +13,10 @@ import {
} from '@/components/ui/select'
import { sendNotificationSettingsTestNotification } from '@/components/settings/NotificationsPane'
import { getNotificationSoundOptions } from '@/components/notification-sound-options'
import {
MacNotificationPermissionCard,
useMacNotificationPermissionState
} from '@/components/notifications/mac-notification-permission-card'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
@ -39,8 +43,11 @@ export function NotificationStep({
}: NotificationStepProps): React.JSX.Element {
const notificationSettings = settings?.notifications
const notificationSettingsRef = useRef(notificationSettings)
const [permissionStatus, setPermissionStatus] =
useState<NotificationPermissionStatusResult | null>(null)
// Why: undefined settings are still loading — assume enabled (the default)
// so the fresh-install permission flow starts without waiting.
const [macPermissionState, setMacPermissionState] = useMacNotificationPermissionState(
notificationSettings?.enabled !== false
)
const [isPickingSound, setIsPickingSound] = useState(false)
const [selectPortalRoot, setSelectPortalRoot] = useState<HTMLElement | null>(null)
const syncedNotificationSettingsRef = useRef(notificationSettings)
@ -59,18 +66,6 @@ export function NotificationStep({
setSelectPortalRoot(node?.closest<HTMLElement>('[data-onboarding-overlay]') ?? node)
}, [])
useEffect(() => {
let cancelled = false
void window.api.notifications.getPermissionStatus().then((status) => {
if (!cancelled) {
setPermissionStatus(status)
}
})
return () => {
cancelled = true
}
}, [])
const updateNotificationSettings = async (
updates: Partial<GlobalSettings['notifications']>
): Promise<void> => {
@ -91,14 +86,6 @@ export function NotificationStep({
const getCustomSoundVolume = (): number =>
notificationSettingsRef.current?.customSoundVolume ?? 100
const handleMacPermission = async (): Promise<void> => {
const status = await window.api.notifications.requestPermission()
if (mountedRef.current) {
setPermissionStatus(status)
}
await window.api.notifications.openSystemSettings()
}
const previewSound = async (
customSoundId: GlobalSettings['notifications']['customSoundId']
): Promise<void> => {
@ -155,7 +142,22 @@ export function NotificationStep({
)
return
}
await sendNotificationSettingsTestNotification(notificationSettings, getCustomSoundVolume())
const showsMacPermissionCard = macPermissionState !== null
const outcome = await sendNotificationSettingsTestNotification(
notificationSettings,
getCustomSoundVolume(),
showsMacPermissionCard ? { suppressSystemPermissionToasts: true } : undefined
)
if (!mountedRef.current || !showsMacPermissionCard) {
return
}
// Why: the test doubles as a permission re-check — its confirmed outcome
// is fresher than whatever the mount-time probe reported.
if (outcome === 'delivered') {
setMacPermissionState('enabled')
} else if (outcome === 'not-displayed') {
setMacPermissionState('blocked')
}
}
if (!notificationSettings) {
@ -172,43 +174,10 @@ export function NotificationStep({
const customPath = notificationSettings.customSoundPath
const selectedSoundId = notificationSettings.customSoundId
const soundOptions = getNotificationSoundOptions(customPath)
const isMac = permissionStatus?.platform === 'darwin'
return (
<div ref={setSelectPortalHost} className="space-y-5">
{isMac ? (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Settings className="size-4" />
{translate(
'auto.components.onboarding.NotificationStep.d2dba86837',
'Allow Orca in macOS'
)}
</div>
<p className="max-w-[58ch] text-[13px] leading-relaxed text-muted-foreground">
{translate(
'auto.components.onboarding.NotificationStep.aa36281b00',
'Open System Settings and make sure Orca is allowed to send notifications.'
)}
</p>
</div>
<Button
type="button"
size="sm"
className="gap-2"
onClick={() => void handleMacPermission()}
>
<Settings className="size-3.5" />
{translate(
'auto.components.onboarding.NotificationStep.8124d085a6',
'Open Mac Settings'
)}
</Button>
</div>
</section>
) : null}
<MacNotificationPermissionCard state={macPermissionState} />
<section className="space-y-3">
<div className="space-y-1">

View File

@ -4,6 +4,10 @@ import { Button } from '../ui/button'
import { Separator } from '../ui/separator'
import { BellRing, Bot, Siren } from 'lucide-react'
import { useAppStore } from '@/store'
import {
MacNotificationPermissionCard,
useMacNotificationPermissionState
} from '@/components/notifications/mac-notification-permission-card'
import { NotificationSettingToggle } from './NotificationSettingToggle'
import { NotificationSoundSection } from './NotificationSoundSection'
import {
@ -30,6 +34,9 @@ export function NotificationsPane({
}: NotificationsPaneProps): React.JSX.Element {
const notificationSettings = settings.notifications
const notificationSettingsRef = useRef(notificationSettings)
const [macPermissionState, setMacPermissionState] = useMacNotificationPermissionState(
notificationSettings.enabled
)
const updateNotificationSettings = async (
updates: Partial<GlobalSettings['notifications']>
@ -76,11 +83,31 @@ export function NotificationsPane({
const handleSendTestNotification = async (): Promise<void> => {
useAppStore.getState().recordFeatureInteraction('notifications')
await sendNotificationSettingsTestNotification(notificationSettings, volumeDraft)
const showsMacPermissionCard = macPermissionState !== null
const outcome = await sendNotificationSettingsTestNotification(
notificationSettings,
volumeDraft,
// Why: the card renders delivery state inline, so the ambiguous darwin
// "check if a banner appeared" toasts would contradict it.
showsMacPermissionCard ? { suppressSystemPermissionToasts: true } : undefined
)
if (!showsMacPermissionCard) {
return
}
if (outcome === 'delivered') {
setMacPermissionState('enabled')
} else if (outcome === 'not-displayed') {
setMacPermissionState('blocked')
}
}
return (
<div className="space-y-1">
{macPermissionState !== null ? (
<div className="pb-3">
<MacNotificationPermissionCard state={macPermissionState} />
</div>
) : null}
<NotificationSettingToggle
label={translate(
'auto.components.settings.NotificationsPane.841c8c549f',

View File

@ -50,10 +50,20 @@ export function resolveNotificationVolumeDraftState(
: createNotificationVolumeDraftState(sourceVolume)
}
export type NotificationTestOutcome = 'delivered' | 'not-displayed' | 'not-sent'
type SendTestNotificationOptions = {
/** Why: the onboarding step renders delivery state inline, so the ambiguous
* darwin "check if a banner appeared" toasts would just duplicate (or
* contradict) the card the user is already looking at. */
suppressSystemPermissionToasts?: boolean
}
export async function sendNotificationSettingsTestNotification(
notificationSettings: GlobalSettings['notifications'],
volumeDraft: number
): Promise<void> {
volumeDraft: number,
options?: SendTestNotificationOptions
): Promise<NotificationTestOutcome> {
const permissionStatus = await window.api.notifications.getPermissionStatus()
if (!permissionStatus.supported) {
toast.error(
@ -62,7 +72,7 @@ export async function sendNotificationSettingsTestNotification(
'Notifications are not supported on this system'
)
)
return
return 'not-sent'
}
const result = await window.api.notifications.dispatch({
@ -84,7 +94,10 @@ export async function sendNotificationSettingsTestNotification(
'Custom notification sound could not be played'
)
)
return
return 'delivered'
}
if (options?.suppressSystemPermissionToasts) {
return 'delivered'
}
const settingsCopy = getSystemNotificationSettingsCopy(permissionStatus.platform)
if (permissionStatus.platform === 'darwin' && settingsCopy) {
@ -109,15 +122,18 @@ export async function sendNotificationSettingsTestNotification(
}
}
)
return
return 'delivered'
}
toast.success(
translate('auto.components.settings.NotificationsPane.d3d54e0915', 'Test notification sent')
)
return
return 'delivered'
}
if (result.reason === 'not-displayed') {
if (result.reason === 'not-displayed' || result.reason === 'blocked-by-system') {
if (options?.suppressSystemPermissionToasts) {
return 'not-displayed'
}
const settingsCopy = getSystemNotificationSettingsCopy(permissionStatus.platform)
if (settingsCopy) {
toast.error(settingsCopy.failureTitle, {
@ -146,7 +162,7 @@ export async function sendNotificationSettingsTestNotification(
}
)
}
return
return 'not-displayed'
}
toast.error(
@ -160,4 +176,5 @@ export async function sendNotificationSettingsTestNotification(
'Test notification was not delivered'
)
)
return 'not-sent'
}

View File

@ -3,6 +3,7 @@ import { useAppStore } from '@/store'
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound'
import { showBlockedNotificationFallbackToast } from '@/lib/blocked-notification-fallback'
import { buildAgentNotificationId } from '../../../../shared/agent-notification-id'
import { isSupersededAgentCompletionSnapshot } from './agent-completion-snapshot-staleness'
import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types'
@ -189,6 +190,13 @@ export function dispatchTerminalNotification(
.then((result) => {
if (result.delivered) {
void playDesktopNotificationSound(customSoundId, customSoundVolume)
return
}
// Why: macOS is silently swallowing notifications (permission off or
// prompt unanswered) — surface an in-app pointer at the fix instead of
// letting the alert vanish without a trace.
if (result.reason === 'blocked-by-system') {
showBlockedNotificationFallbackToast()
}
})
.catch((err) => {

View File

@ -545,6 +545,13 @@
},
"ephemeralVmWorkspaceTarget": {
"projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime."
},
"blocked": {
"notification": {
"fallback": {
"de50bef680": "macOS is blocking Orca notifications"
}
}
}
},
"hooks": {
@ -10015,7 +10022,15 @@
"3cd5374e22": "Notification settings are still loading",
"b6a994e36e": "Notification sound could not be played",
"c0692baa52": "Choose Custom File",
"ac80d97e02": "Change Custom File"
"ac80d97e02": "Change Custom File",
"56b836215c": "Checking notification permission…",
"fd84d3e9b8": "Notifications are enabled",
"4f7bce5644": "macOS will alert you when agents finish or terminals need attention.",
"95d99b52fa": "Allow notifications for Orca",
"94562ba367": "macOS is asking for permission. Click Allow in the dialog and this step updates automatically.",
"4f6a1da718": "Open System Settings",
"90b5d2e363": "macOS is not delivering Orca notifications",
"2c47f5465f": "Turn on Allow notifications for Orca in System Settings. This step updates automatically once enabled."
},
"OnboardingFlow": {
"1b5e182e9f": "Welcome to Orca",
@ -10141,6 +10156,17 @@
"windowsDefault": "Windows default",
"rightClickBehavior": "Right-click behavior",
"rightClickBehaviorDescription": "Pick the terminal mouse behavior that matches your Windows muscle memory."
},
"mac": {
"notification": {
"permission": {
"card": {
"f696515944": "Click Allow in the macOS dialog.",
"3d18cf71f9": "Updates automatically.",
"721d2bedb6": "Turn on Allow notifications for Orca in System Settings."
}
}
}
}
},
"new": {

View File

@ -545,6 +545,13 @@
},
"ephemeralVmWorkspaceTarget": {
"projectRootRegistrationFailed": "No se pudo registrar en el host la raíz del proyecto creada por la receta."
},
"blocked": {
"notification": {
"fallback": {
"de50bef680": "macOS está bloqueando las notificaciones de Orca"
}
}
}
},
"hooks": {
@ -10015,7 +10022,15 @@
"3cd5374e22": "La configuración de notificaciones aún se está cargando",
"b6a994e36e": "No se pudo reproducir el sonido de notificación",
"c0692baa52": "Elija un archivo personalizado",
"ac80d97e02": "Cambiar archivo personalizado"
"ac80d97e02": "Cambiar archivo personalizado",
"56b836215c": "Comprobando el permiso de notificaciones…",
"fd84d3e9b8": "Las notificaciones están activadas",
"4f7bce5644": "macOS te avisará cuando los agentes terminen o los terminales necesiten atención.",
"95d99b52fa": "Permitir notificaciones de Orca",
"94562ba367": "macOS está pidiendo permiso. Haz clic en Permitir en el diálogo y este paso se actualizará automáticamente.",
"4f6a1da718": "Abrir Configuración del sistema",
"90b5d2e363": "macOS no está entregando las notificaciones de Orca",
"2c47f5465f": "Activa Permitir notificaciones para Orca en Configuración del sistema. Este paso se actualizará automáticamente cuando estén activadas."
},
"OnboardingFlow": {
"1b5e182e9f": "Bienvenido a Orca",
@ -10141,6 +10156,17 @@
"windowsDefault": "Predeterminada de Windows",
"rightClickBehavior": "Comportamiento del clic derecho",
"rightClickBehaviorDescription": "Elige el comportamiento del ratón en la terminal que coincida con tu memoria muscular de Windows."
},
"mac": {
"notification": {
"permission": {
"card": {
"f696515944": "Haz clic en Permitir en el diálogo de macOS.",
"3d18cf71f9": "Se actualizará automáticamente.",
"721d2bedb6": "Activa Permitir notificaciones para Orca en Configuración del sistema."
}
}
}
}
},
"new": {

View File

@ -545,6 +545,13 @@
},
"ephemeralVmWorkspaceTarget": {
"projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime."
},
"blocked": {
"notification": {
"fallback": {
"de50bef680": "macOS が Orca の通知をブロックしています"
}
}
}
},
"hooks": {
@ -10015,7 +10022,15 @@
"3cd5374e22": "通知設定をまだ読み込み中です",
"b6a994e36e": "通知音が再生できませんでした",
"c0692baa52": "カスタムファイルの選択",
"ac80d97e02": "カスタムファイルの変更"
"ac80d97e02": "カスタムファイルの変更",
"56b836215c": "通知の権限を確認しています…",
"fd84d3e9b8": "通知は有効です",
"4f7bce5644": "エージェントの完了やターミナルの要対応時に macOS が通知します。",
"95d99b52fa": "Orca の通知を許可",
"94562ba367": "macOS が権限を求めています。ダイアログで許可をクリックすると、このステップは自動的に更新されます。",
"4f6a1da718": "システム設定を開く",
"90b5d2e363": "macOS が Orca の通知を配信していません",
"2c47f5465f": "システム設定で Orca の通知を許可するをオンにします。有効になると、このステップは自動的に更新されます。"
},
"OnboardingFlow": {
"1b5e182e9f": "Orca へようこそ",
@ -10141,6 +10156,17 @@
"windowsDefault": "Windows の既定",
"rightClickBehavior": "右クリックの動作",
"rightClickBehaviorDescription": "Windows で慣れた terminal のマウス操作に合うものを選びます。"
},
"mac": {
"notification": {
"permission": {
"card": {
"f696515944": "macOS のダイアログで許可をクリックします。",
"3d18cf71f9": "自動的に更新されます。",
"721d2bedb6": "システム設定で Orca の通知を許可するをオンにします。"
}
}
}
}
},
"new": {

View File

@ -545,6 +545,13 @@
},
"ephemeralVmWorkspaceTarget": {
"projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime."
},
"blocked": {
"notification": {
"fallback": {
"de50bef680": "macOS가 Orca 알림을 차단하고 있습니다"
}
}
}
},
"hooks": {
@ -10015,7 +10022,15 @@
"3cd5374e22": "알림 설정이 아직 로드 중입니다.",
"b6a994e36e": "알림음을 재생할 수 없습니다.",
"c0692baa52": "사용자 정의 파일 선택",
"ac80d97e02": "사용자 정의 파일 변경"
"ac80d97e02": "사용자 정의 파일 변경",
"56b836215c": "알림 권한을 확인하는 중…",
"fd84d3e9b8": "알림이 활성화되었습니다",
"4f7bce5644": "에이전트가 완료되거나 터미널에 주의가 필요할 때 macOS가 알려 줍니다.",
"95d99b52fa": "Orca 알림 허용",
"94562ba367": "macOS가 권한을 요청하고 있습니다. 대화 상자에서 허용을 클릭하면 이 단계가 자동으로 업데이트됩니다.",
"4f6a1da718": "시스템 설정 열기",
"90b5d2e363": "macOS가 Orca 알림을 전달하지 않고 있습니다",
"2c47f5465f": "시스템 설정에서 Orca에 대한 알림 허용을 켜세요. 활성화되면 이 단계가 자동으로 업데이트됩니다."
},
"OnboardingFlow": {
"1b5e182e9f": "Orca에 오신 것을 환영합니다",
@ -10141,6 +10156,17 @@
"windowsDefault": "Windows 기본값",
"rightClickBehavior": "오른쪽 클릭 동작",
"rightClickBehaviorDescription": "Windows에서 익숙한 terminal 마우스 동작을 선택하세요."
},
"mac": {
"notification": {
"permission": {
"card": {
"f696515944": "macOS 대화 상자에서 허용을 클릭하세요.",
"3d18cf71f9": "자동으로 업데이트됩니다.",
"721d2bedb6": "시스템 설정에서 Orca에 대한 알림 허용을 켜세요."
}
}
}
}
},
"new": {

View File

@ -545,6 +545,13 @@
},
"ephemeralVmWorkspaceTarget": {
"projectRootRegistrationFailed": "在运行时上注册环境模板创建的项目根目录失败"
},
"blocked": {
"notification": {
"fallback": {
"de50bef680": "macOS 正在阻止 Orca 通知"
}
}
}
},
"hooks": {
@ -10015,7 +10022,15 @@
"3cd5374e22": "通知设置仍在加载中",
"b6a994e36e": "无法播放通知声音",
"c0692baa52": "选择自定义文件",
"ac80d97e02": "更改自定义文件"
"ac80d97e02": "更改自定义文件",
"56b836215c": "正在检查通知权限…",
"fd84d3e9b8": "通知已启用",
"4f7bce5644": "当代理完成或终端需要处理时macOS 会提醒你。",
"95d99b52fa": "允许 Orca 发送通知",
"94562ba367": "macOS 正在请求权限。在对话框中点按允许,此步骤会自动更新。",
"4f6a1da718": "打开系统设置",
"90b5d2e363": "macOS 未送达 Orca 的通知",
"2c47f5465f": "在系统设置中启用 Orca 的允许通知。启用后此步骤会自动更新。"
},
"OnboardingFlow": {
"1b5e182e9f": "欢迎使用 Orca",
@ -10141,6 +10156,17 @@
"windowsDefault": "Windows 默认值",
"rightClickBehavior": "右键单击行为",
"rightClickBehaviorDescription": "选择符合你在 Windows 上使用习惯的终端鼠标行为。"
},
"mac": {
"notification": {
"permission": {
"card": {
"f696515944": "在 macOS 对话框中点按允许。",
"3d18cf71f9": "将自动更新。",
"721d2bedb6": "在系统设置中为 Orca 开启允许通知。"
}
}
}
}
},
"new": {

View File

@ -0,0 +1,39 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
// Why: agent completions can dispatch in bursts; one in-app pointer at the
// broken OS setting per session teaches the fix without nagging.
let shownThisSession = false
/**
* In-app stand-in for a native notification that macOS silently swallowed
* (dispatch returned 'blocked-by-system'): tells the user notifications are
* off at the OS level and deep-links to the app's System Settings pane.
*/
export function showBlockedNotificationFallbackToast(): void {
if (shownThisSession) {
return
}
shownThisSession = true
toast.warning(
translate(
'auto.lib.blocked.notification.fallback.de50bef680',
'macOS is blocking Orca notifications'
),
{
description: translate(
'auto.components.onboarding.mac.notification.permission.card.721d2bedb6',
'Turn on Allow notifications for Orca in System Settings.'
),
action: {
label: translate(
'auto.components.onboarding.NotificationStep.4f6a1da718',
'Open System Settings'
),
onClick: () => {
void window.api.notifications.openSystemSettings()
}
}
}
)
}

View File

@ -2502,8 +2502,7 @@ function createNotificationsApi(): NonNullable<Partial<PreloadApi>['notification
openSystemSettings: () => Promise.resolve(),
getPermissionStatus: () =>
Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }),
requestPermission: () =>
Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }),
probeDelivery: () => Promise.resolve({ state: 'unsupported' as const, authoritative: false }),
playSound: () => Promise.resolve({ played: false, reason: 'missing-path' })
}
}

View File

@ -3026,7 +3026,9 @@ export type NotificationDispatchRequest = {
export type NotificationDispatchResult = {
delivered: boolean
/** Present when delivered is false. Tells the caller why delivery was skipped. */
/** Present when delivered is false. Tells the caller why delivery was skipped.
* 'blocked-by-system' means the OS-level permission readout says macOS
* would silently swallow the notification (denied or prompt unanswered). */
reason?:
| 'disabled'
| 'source-disabled'
@ -3034,6 +3036,7 @@ export type NotificationDispatchResult = {
| 'cooldown'
| 'not-supported'
| 'not-displayed'
| 'blocked-by-system'
}
export type NotificationDismissResult = {
@ -3106,6 +3109,18 @@ export type NotificationPermissionStatusResult = {
requested: boolean
}
/** Outcome of a macOS notification permission check. Preferred source is the
* bundled native helper reading UNUserNotificationCenter authorization
* (authoritative); when unavailable, a silent delivery probe supplies weaker
* scheduling-based evidence. 'awaiting-decision' means the macOS permission
* dialog has not been answered yet. */
export type NotificationDeliveryProbeResult = {
state: 'delivered' | 'blocked' | 'awaiting-decision' | 'unsupported'
/** True when the state comes from the native authorization readout. Silent
* to poll; probe-based fallbacks flash a banner when delivery works. */
authoritative: boolean
}
export type WorktreeCardProperty =
| 'status'
| 'unread'