From 25ea2bbfd1419d3139a53f31cb701954001cacca Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:21:40 -0700 Subject: [PATCH] onboarding: seamless macOS notification permission step with live state detection (#7684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix: don't log expected probe rejections while polling for permission Co-authored-by: Orca * 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: ' 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Orca --- config/electron-builder.config.cjs | 39 ++ config/scripts/build-native-for-platform.mjs | 6 +- .../build-notification-status-macos.mjs | 97 +++++ config/scripts/run-electron-vite-dev.mjs | 69 ++- evidence-awaiting-permission.png | Bin 0 -> 85153 bytes native/notification-status-macos/main.swift | 35 ++ package.json | 1 + .../ipc/notification-authorization-status.ts | 96 +++++ src/main/ipc/notifications.test.ts | 403 ++++++++++++++---- src/main/ipc/notifications.ts | 395 ++++++++++++----- src/preload/api-types.ts | 3 +- src/preload/index.ts | 5 +- .../mac-notification-permission-card.tsx | 228 ++++++++++ .../onboarding/NotificationStep.test.tsx | 39 ++ .../onboarding/NotificationStep.tsx | 89 ++-- .../components/settings/NotificationsPane.tsx | 29 +- .../settings/notification-settings-copy.ts | 33 +- .../use-notification-dispatch.ts | 8 + src/renderer/src/i18n/locales/en.json | 28 +- src/renderer/src/i18n/locales/es.json | 28 +- src/renderer/src/i18n/locales/ja.json | 28 +- src/renderer/src/i18n/locales/ko.json | 28 +- src/renderer/src/i18n/locales/zh.json | 28 +- .../src/lib/blocked-notification-fallback.ts | 39 ++ src/renderer/src/web/web-preload-api.ts | 3 +- src/shared/types.ts | 17 +- 26 files changed, 1492 insertions(+), 282 deletions(-) create mode 100644 config/scripts/build-notification-status-macos.mjs create mode 100644 evidence-awaiting-permission.png create mode 100644 native/notification-status-macos/main.swift create mode 100644 src/main/ipc/notification-authorization-status.ts create mode 100644 src/renderer/src/components/notifications/mac-notification-permission-card.tsx create mode 100644 src/renderer/src/lib/blocked-notification-fallback.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index def7142b1..a418fb49c 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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) { diff --git a/config/scripts/build-native-for-platform.mjs b/config/scripts/build-native-for-platform.mjs index a16cb3432..11ad15bc5 100755 --- a/config/scripts/build-native-for-platform.mjs +++ b/config/scripts/build-native-for-platform.mjs @@ -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) + } } diff --git a/config/scripts/build-notification-status-macos.mjs b/config/scripts/build-notification-status-macos.mjs new file mode 100644 index 000000000..e8150e32b --- /dev/null +++ b/config/scripts/build-notification-status-macos.mjs @@ -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 ` + + + + CFBundleIdentifier + ${identifier} + CFBundleName + orca-notification-status + + +` +} diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 97df46c51..9b8aad895 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -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: " 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 } diff --git a/evidence-awaiting-permission.png b/evidence-awaiting-permission.png new file mode 100644 index 0000000000000000000000000000000000000000..14ae830f17c8fa53212d603651d1cdc7b87bb30b GIT binary patch literal 85153 zcmb@u1yGe=^fr2EkrY&<6$xqS5;z7ef^;KD9O59IQVNP74I+K$?k+(MnX58At5|AhE#QR|lYDuxmhu+N4uNDEC(o}Ho;dvJHi9UU=8an~?hUtxd9Zcl)uQ+t}*03YQPMWZxG?@9v z^br%3A&}~r_9;2S%PSNKMjkhdYBApR5~K*Pf6}b*b|=2tC!E%nCYSka3GieQ)%f)#t-^HacbWB~|pI0%|j&oB&t($kT1#lgu*O;Q9FTt2i zNirX;S|b~zI2bQ9ORbxnP2`#87ThqCG&O@0AyOu3oiq+K#+p{nZ9boWA(h`DBli}t zldLLqbAS_W;2_U3^mu>JeI6@>vDcW~$x9tWS5XtWT-beO4)<~czx9*crK-xx;R7$n zZNElx@uF?z{+B~bBo$D|y3@bC8q|pQ%a3H@=!B=8b<9KvU6%C%-l)A1-%pGl7^kYA zc{p}LMwuz}9VUL@V3=l!IIN#^5e#=cTW&>SB6H4vG7SRq9pj;c|&7LQ|~#F`4+JiB*@2RAHdBs%)p^kF%l1 z3>giU*n&TCoIuZ7i8wvYo;QY zU&Fj_jXor;Gidp2=rym5X|daCT7eo4a-(w@9p>IpCGJUlvTb^v+jMSm(1Qa(0n@zDe2>FHZa=SK1VJwl_uX#^D zpD>c}m9dIF6Gk6;_R>VAUNz=G2CLodgN>&9G8i%`+B z-wv<8=7bn>&z)C&4yaF=9|$#Hl=>tWIFV8AN!7cI18xYoUD-z3xf1Mjxh&}2@pq@a zw)s1sPVB4+`m>Z<9p9JB{ZnP^C1d6+i?ddG^QqncBL=e%o{NqR(QKq{XPtUneW#V6 zmWzS#pw-2~OoLVNN#LB?1n%XcQaLH~Lq~$_nZK_OFIsgP zpKm;oh=?n*CM1@ATTFNp{lTXRPH{H><_=d~4$+r3*3wTDlNwy^{df}YJ4yq^nVe?4 zEsLEmg+*8qJ93azuD-IyC+swk-z!dvb~*ka#k7a_nTkluH!MHriO=$c$=d=NcpY5! z2kLV;@*YHe(09h++C$GhKguL4PAGn8s#M^r`v<>edYD0VFx%kKP^BQOefqOrJqg!c z4O`O0#+^AP^`-~>j2QRura)3XhfIC^)~S%58Rq45(KpY(l$@wq$=SW=2neOvBsAMt z@QKcE>o$KmS*+!$d!~zq@(A(PZQ~ukQNTs)$n;{y`zFKt;JKq$NjQ5e!d>NhCFfOt zNla&b`zfqN<0obuO===g#JS2DL7q8-YexkYfxjNkJ4xC9qI=0ct4DY)S>ZviJI(dQ zhs6@J*itOu^074@p3*M#?2ID$U4y<2NjV6Nx6d=Z7t9R&HZ=FLNLn&ySN+xocGTk! zZCs(&E)||rY)XWw-SN*3N^RopQC5{B3_WGSUXJQ7r~mCc5Yi((**N4Jr*?ok?olef|C# z)SW7vFq=)mDClTR(+2#zgS{lt(g0eiJI=~(2hE_L6Hc%66E{?6=R&f9IN576RFVaLjHO47420_9jA@iHFS#0k1-Pl);S&&y4E6Bs~WAZ1`^AZ;KtHmxuY$1OQ| zu=%Y_clE;eXx6knk3iC_ zui~%2&{x&7U)m#+f+Mc%U-3zfz50TRa%6u0m!e++mXl!)L@>|57S^C!UA`qbyz$n$ z9Q7SiVWis|_QoT+JbEGXwvNw_y@{R5YnDbFeF*Hm%XW>_?Y_qnxBU819${Df0Y6mH z2A|Zk<0+~Bp9g(=Bl`)CV}JFQp%xn$48);BG?8WXW$Trfo}!pnDfZs~d&*>9mCjz$ z_CdiP-HkZidov%jqI7VQ#~S{4Y(m3@qIQP|s%VgoC-TNjB%wPoX=krSWV~;)7Aj*t zEc@o^e;j3`)3}_NdFGqUHu(``qv4On!3f&*So&735q%DysLs6<b%w+I2#&&5Xs65&BM%SHYWU3t?cG(Rt;kAAP+tZkf*%0nc=*&jgR%CINgW*av4I z@q9tny~V^yjPx1}BeMTVm|z&2l6ILqJ8YF@;=~9^KdpHvka-c!RY5yWz zH&!O?!JSQyZ!tCGF|3`TT}qI_`{=OyZXPq1pZ}Q>U0kX-W^|}MEYZ#Sw)Zze_gLrN z?1ml;qViT^&KNp~UJ;Ro!+UWVqTHq~0kzS++$`^LtfQo)WPrV%(QTF5i9k~^-SfgO zCOa~Xq`5@lURrIcz19lrQ2$q2uN5?3#-Np=81jYgh}J|e6JV>DVj`ih8dU}HFNTGb zN#5t|TFNNOnbjeFTYJ_&Ewr8z-^UJC;C6A{sptAp)b%nyd*bN4 z990GVlDuO;Y0#E%zL`kLe>q}5g*Qj{@}XCG$q3bmLL#e9dzZp7e*bNW+SlGXA!3&m zg*lIU)QI!4-7$G7K|CA+?``U~Mwlew@P^b`qX^EnFE8q6;kSP!E=G9cH0IR=p?I}- zj30y|llS!bjf;6Q;ir2eV2`-}MXyzabe*0;aLkGSt6g$sFxwlnavLre1b&3z{8ykr zylU@zUDq#=IV`kGhZ#fG7rTs&jSqTWDUoO?cG2TXuFp6=6@N8AH!arh9XA>wH4sTsg^j*v-Jw}< zb-!WQD1P(dG?q-}^{Q2hEYX*s$Ci0IRlPwKJ$42LNz|_@E42qX=4Gub=SCT_&#KYm zbM3kjD5`NHq`QZ1sL?6;Dd9wwrQ;hL&n#$3`YF;^puu@wo@L(edQj*wHzA_QJy91Q z-e(khzvf>5X8)Q%F?V`tX+OVVd$?opVlc69zCR!3BkC)kEPQ9WuJCo`RHa-4Dv+q4Bf?4a zfT^l^WU6n=H~9|8Ni5O`Id?01Wrz#iwk(#*_9*49@hhukmV#zUlRzjU!Q zCtZZSmmsW9_%!7gucf7B99hSadV zd$u&prW)KXEdRd3F!s-JPvI9%j%oF}Lh@1~{T4tHTyX*7EzKD%R@q;I-5=`3!^ogx zq0@-*vW4!y=rEDk#P!jC%6nerBl!d_`}W3Fu*@Hyr+$fyTZ}oRI6frBMtX;g`uK}T z)p1N(Y1SY_T(75B}cSLql1>U0m-M+sD89 zxdS)q=AXM?3d*T6Vbe?t)8A0kMUvw=p{H!Dx94Ywmh&Tk3Y8& zb^f=D=0+~0&sx(G`TZRv;!p{-cgjig%MG5124QHH$1@SDGT~WIXuY2%*i|02E-(#$ zv>W(My?}q);n~Z~KfZ}yPFh>x!FW+k5^3<0jT<<^B2u#f{+NUkh=BhNsYiS zvZ!ru(S0}5Sfl~2C6qoPX^-dn_+>}hK&bunQ?~abFpL?{dkW>$QC4mFEWWEpIvN_V z+MtH~PU~)%xE4W1&U8YX2i4j;>t@#UTidqgWK>e^7SCgkh6Q4Z^Cg;Vn3LhiGUEO0 z_eZ`-O;Ud*ijavsF`3~GZw7*BYp$6TXyos79DDlLs{Yo;agPt}nX~J}lXC7-@2@USC}H_GtIWT|4+S zKjA&#OJsZB>L56yUY~J~r1E28q0RN4XQB&5{@q%A?PF&&q$^;64aa~f>ow95_s1M1 zbj3*Re%^evWT;QNvKd#XT&`O{3q8T&rF-B3fm?8$ke9}`@>|qebxlJ?u~#YHIW3vVs_JY(REa97-N)xslyW`k({*ghw+DU zA)ZmV1L?tAV{xxngC=iybRg_$X=(XhpALqMk)b<{6dv9|TQpUCj5-D4eAC~)D;}DX zCib5Y9KZJPF}$X+AlYi)1Rm^#%Zu-QWz0^EE4-!dL`Rh>88-gvF-7&P{(Y|lDar70 z8f37mas@eT3SPK6YneC-5>n$DS&j6!45o1tPxHpWFN|4Tp{`F!6YDAe+Z2+j126t0 z>Csd&C&_2V3@mXY+14idh;I^&{C#G~^^|oS_z8tnt@%*sQSpN;<9PJNd$i$KSra5Z z*^w!q#|8@1x8Ew2hP-~Pwak+-g;*V=&bNPBj(0r;+6WFZ{o8(~{M{&0(f{1;nGhE6 zO`B`4hoojTTwnb^pBw-0RN?<=+6|x!{|Muaas4DsAJ$XMyl}-5Kp_2Wn%GDVu~(r_ zS>*@&eWGo784D|8c^vTLt#4gF5Y!=D9zrdhh|2KrixYPLR$R<^C}p^WZtik zvWfEV#h0L#>*Cel$8eecn%&*|&yI7yDew^TkJQxe<=q=~Fnz`2RsOq7JVP!G9%YUJ zQB7_tPA zKyrWWkU%qbtD?8gygedK#YG5*KJsrT*;Q)bWRWVyZeOww2yEQb2ZE4X`N28)8-YVa zqs|U-6MYR{;;~bKF$$)TyHyu1=0y=mPth=HDi0FUksvWEPBlNk2@5ZWi_sKgi7%Z} zBbHuH0)lAK03<>E_|3uQSV4@R(ZnqF2xGZqn|tYY0KEl&h^BH3MkYbAbRcfbMOsn`G^!Wl zZf9M(Nq|W20FhVlqvI?_Nt#tNt+cbn&;9^RpLh4=@CkA!dV|Ao<1cTaWjN%`4BBTS*Nq|dQq@s zP#~*3SB!g}6-%79_q?aVuqd)8nQo7{bYPZ&1RT8Dx+89uiY!Jo*tT{E6`+wxTj1@-sJzmyW3JjZ{0V zIzBZdY-Ap{6)yo_#d zu6V#Z_NK|zx$ejCS*TT5O#qJk!w2-s5M%L5Q&ZE-Y&kvFs(OD=n^XVC`$iw!a3+QF za1xVtdq-@=lv`jCgS`c{x16hgVpHxd^*pb#+g<3qBYGJdGih$#yt&fCYc=lt%l{4m z1@F>$)$Z84dQsCZDv{@cpI%K6#W+(y9n>m^^xViHnnmX=QctiWSx|Ic-dyWr6`K<^ zP62n>2esdcnp*)YKr+ksKBj@E45Z*2wJhmeXgyMH66Xm=ZRM(oOHZ_H@sN9gg2SuK zD^bkCdACzwvnK_II`P8#{kI7y`Po=ldWpyh6k_>1=gar3unX@Z?y`89vvh)*d(Y(~ z0sqoS$>JwUE9v8lLJEQ1qi_!u($h_j%EUeWvu!GOzWD+T5d&%sMQY<1F{!t_h397{ zVkR&rVX@e6O!X ztdsX6wwF1mfuLpKTQw@a%Oz{Y9UPsp0sZJR915)vGBoMO`gk5 z<%{zeUBY?g>mK(z2Ce@FaI@^tuRp}GT+vFcCSo?aFXkOYKQ?>o1~u(J9C`2^TQTo; zbnb9>u~8x{RJvId9U4TUH%&wYcC|9|QAhVBkjoU?>ys3>UMuw28TMj$LVOR~YlV5X zutlO-P@5VeNg3AMS-e`AvX1!cp?7Qd)31KSE)$A5}@f#j9A>%W3_sYvOW) z5$Sj@x|f|kFqU8|x*LsSRI}+Fk#+`$DVrj_B`rSriFX4>76i+jZx(ZUzR1n}~r3lfwDHy?ZwhsM@j3NAYy`2`x1Q zdk!jouKUr7GX1F}b&MeVdU#^9%ZV29ea*|%9`nzqkInmpu}VX5q8F=n(8IkYp(3bKw<<59ohgJ$oW__2Jo zhyVd6f{`tZ8i;%8X?E><4O$li4N#wb)EN~sk*zyIFcd68q3g2VqR*G z0UIJ4Cd6TRle%<9wV_585|vP>^d9}w!w2GV zWltRe2*3Zz|I-E?Y~~%tl7fGWdWz$`$mz)GMt6^*3#`7$!Qb742E$Um!133~3ONCwFyF zZ#iwz_J;}BX&o9W?bMAV=BWM$k-g`uduP@8?cS6`pHe`fs2$ug<%Ue}4&9-o$2PYb zmY$gOM?x}HrVl;dkX%?H*H}qq`hFP5bVhv`qq6ZwFT%#tkiRN#n!_I~`>87k={y%A zekAq0;*2K=GU{VH2c2c+=hlQ!6IP}?NUsejU1#VN$AHGzehE&Ikv+T^5*T)WBRJBb z4f=}UO<)I`SCERzqY>IygA60{U7$#s*<2((%@=+Kae*`O78k(ks{&Lh5F_8&lnGn3E|lC0biMcuq#`X%{w z2cuikWHL^xgPj-agu3qaF_V0`2g&>fMt#${#RDVIcbah6SYGN$0Z{4Ung%T|FlcAV z-Mz0KhZ9Z{TVOh!PeA`#Yb(o z(gu)mT8)+GU9JF#o;Jc(FG*TtC&=xpZMYKb_og~A)e$oNRM9_>W2-B>O9gV z!av|XZA50p%O#!hk;Qp_%*@P1T=tE9+eYqSu26EK98GDS7ZHdsJ-0>>6!wu$z z?))V4*#qdpH`W3$wYdXqGyg`uGiGJtHbachpv{6PA>iE{zrbZIBX8De4qi^ zE*|f%3Rn&+vcsUtzY(G89@z18?rM9vg@QF>M1<7BD#56e!;0mS(sxs5-Z(UZd&uWf zU+*PVbl1A<20htn3!)ZDu0mWPdh4-ul()ZR?SrcpQ>B%qel0A zf9C7<;wk4@g1QdJ5#uPzceOSER$hWL2!z@AF7!mzNqLWFK&3v!O7K?+-Y3fKhxKd4 z;`J7e_q!Zd{hd$OWhzCcC;r&rh2n|3t8LUeZ_h{oOsiS^f~#~LlX?j)Uig#aFM-?8 ziv|tJ;f+5JK1wFko@~@u2^t*7$!%CJzdT#XDd4qk#AP~~b=zAin*K!Dl_2WY9>Kf{ zb}>~Z+1j(O(YF-L zt(uNU+d6o|smv(tBR^o|Y;EdS6zgb8H$H2+v%$;~kB5j9WOdi8w4VNOk6@TyE34Ga z{w`CJ#M$Yt!dTnsJpzkbiXj#qAu|L?AUTf-IG9CE0QJKW8zp(=52{kTxgAzi7&>G> z3yc_rH>Bp3kIDx7C#?6q`|)p~?P0LW(i0dGafUjv_#5(^ut%DqpG?QvJAd_b)APK~ z5(66_IeU(pI%keNJ6}_e803m0r1jCY*^ga|4hjRciOdZJoTCqw`CpO4d5&<&-JJv)A(PH=L(5~=&+ zeHFJMC2hb5eqGBO`u>k6g~;VSx?k4C9ISkBkKFICe_+5{C%gcSi4d%@{#U)4zsoeA zAR%zH!kQL=a3Xt{3cnasVKDG&V=oTo^xm6y{Nf zm*8=1CQ^SlPS9osjALOak#-fIp|YBjK1f{CGH-lgV$Y-a2sn{(jh{(hVW%Ts3*kLZ zO~)LbFE@E>;DtRU>k4@p9I)~U6F4NV;g{9N&Y+a}eW=dAH)LF~ia#yHX`Lu%yA?E| z)jd!IV`Z8$rFvGg@fhBFLd4LMD4B6-ZI`CzSNGglpY?dT=MgBn+!h`~M|x}DQZqbe zix+Vk9Gk9~@=(?rFEvy4>=l@LpVGQsKGr3s`opr%wDVt|g1Fh6C|$>*@CfcikK<)W z4R!Yf&$E9Z;2rLEi#y3LPh28c2t=tp9jBC(BYjP(`gf)OU4FLm<&r=r_#?~kCF^Jt z5lg0jE{2GVlAL7&Lzmb!j@H4H9brHRUc7={3#a6VR8)#tKp`3=Hq;WrcV@u{ww`i&xv3v`4;(AErdrC ziFUb1RD<9^9juRm8gaeuaB_8`rh@VuELI^=Ocw)`t|$;YmpPN^t3gBq0sOnLLv|-( z-_iLTbx6r)-d|=hgvVLC^@S+`oJDGELaNLx1=0O}U8f1-%j^PS2~&o^pHX|TeqGTK zT$0tRLxESE_N338W~#P1oXXYNEIVAYTbd4eFl`rIMNcLY zBgJrBpR4=p)wwdWi5-sHIgQ)21`13LH@k8r+V{!`7u|WpYC(vJJpMcCvKT+*ak75v zq2w^cb?fg>aL9gwU?JkVw?wA@{`K37?fb^Ay&?~)ze}PC=y0EOFkqzB7zX}Ax{Jy~ z8N>U02OrIbh6Km!Mt{|K-_ZW|7L>nx0IS+Ap}!dVIl^}@bNEfTyeHnn?E!Heku&ES z^9q6pi#FQxwCzpYWe)t|$>-Qcr^WTP!D>nevJ9F2+P6jMf>l;hAChA@QsEH5{i8 zqJ#G|ttzKKUBrh@fvIs&<3xkKHem1zE)xN;}|Vpilo>6m^O}y2I*RsZCb>;af$*?imQ^|0zkKZ))zs&!VSrZ($+Mi%Db(k!Xb26C>G zuyZoOPr71vcv|^4rj){>?x682ItmqY!(!Q-0=V>=*SbyGbKsR+F7k=)d0J)durmk4 z##w=J>*wcYO`iz+dOKCL)G^y2s90vnG7I<_Qe z8N6SZ93I0pgyuG#3tD%zmEy>58^{Lgq7if)*zoHa1p4Ucn~7+jUEzE{dC~%S7W(3l za?kD2=jUVAcpL!m$45+@a_sD4!0Kylom1h+eXYQ6Q;}T-9aOLp`W4CeLfPkUzwls2JOz0%-L`I+dB|CSxk(vU@3X6p!5#BTy zWl3nPabg+IdrAUgb*F<^Rxe9+2&53(xDP{EGoLKs!ALQ`q`fcaDd8{g@5A&&2ox9p z4M*xsI6ieH>l=hX&J+XxWA4YE#WZ~_czwDMdA(?L4lfYcpP7!K>0=zIg}Cl<@_(Nj zqNLM%{ch|~v&U}s9^VE!45L}DWU+X4lj;!(khippuMjF=35+4}g|wyOd=j@Qw}Boy zvx)PaZFm2rOXDj4;UDB_ zV^h?LAa%5K3XpovP`lodu~+4RItH$6K3ZxP&L|)8!s1TEU#P~$#J4H;!-@A(Zp4?q zia2fq)uJ;4=nz6-VDMBz`87QjpJ_+kJ_-0thTHv(?dYKOg|b=-e^TxU{qH_joyR2l zJedDTnF|_|z77v)y?$S7Il`?S^f2yehO_a*`8z7N1#+HZ0qucOz$({Z<=uUSmapVI zu6wYnr&Sa&0_yoh0cQXW(uzNPNzaqnE-GbbBydr8#A4{8~U*jeZLX%R?BdDm$c<} zIO%{-;?ps-U#+Z$YAr8F2N)EFgVxdD@5EtQS;_Z$P3N9h+`C?pg^?#|5cJ>K!&yK02)=O4jD-o>RJykGydQfU^@%)`lZ zjDe$59uHe;?j-5gj9&!-NSo#X8gHS=NUDZSNuG4VtJX!I!0$&QGXKdskv!*dZNzXt=CAxEz76+a?en2x1Xj|#v$gwruM91g) zZ_sp1w9=gNEKSA{%E< zUpJ97ts9rsHuY6FsmVx>biaxuKJ_q-EnFpNX+?y%7vtJoZ7Q!#ZElC8Gf&9xg^no zlscItLZABzazjGW>B6f9>b|4ssBQRcNrxV3#EV5m=Jk6T=Ws8nCfPkry{~mYr`NFF zSK}+3L^Xhrv8QJscJ7@5+aQN^5C4Sv^*D$XP-PY5+uq{KCo7@F8JldyN^khC`1`|7 zT7*~un{JlW*gGvyrUpjdJt;ZABN_Yu!QTP8#o@Z zYM80zuf*$2mcsiS{2Woa4}WtTsfSft?tA8#um!xpOZHEIN9Yo>`w{==WYB^3bj;S3 zh>_flY|HcW0>Bc=>+Z}HD0RJ7Hfg_ow?ONOmWSOlH`B)N13C7Q@g2|Se9VeH-JXwm z3l}TFqnKy)R25@tZe8yu7!C+hIT4H9EpmzKt_j5KIyYxD++C~vgb znyarp)Y6M-@;^RgeQ9miIB-YYFx0+~^Q!v`bW)uc6CfV8sJ3XtKaAaZG?Lz3R4dqH zJC9ZzrP+EXlA}94aFt=*RWfJ#!_h`}Xg$q@M#aq&UW%{j*SiQ&eE>QMW{w5i<^|Qr z<{zAlR=X{T&%UP{(PSF3dc0){v0d7#6zC&UlZfpI|6O1#}_akDtgb8?R zQUg7e+IZilEFS0Q?&_xrjn9OtqLt>M&1BnshY~IAnDS>c`uyip*-`Sigao(0Yn;z( zkeOip%-6O@oY}q^QAM-;nZ4&S0M+zfRiF+knRSK6W=?%r@3*XZ%i3|4V_Qo0^!!dl z>)_Joh3hBu$`0+6zj59k)nW0hfa0KJcIW(`0CwpBza^FBJErfvUXD*~`g8WDj}<95 zu|MYTea7uS@}qo;b17qhF*02NR>*mWtD$G@;&5y6$hO=28I@;X5LWBKq}R``F11Hq z9OvgSolMKcFN{mP>*}XliDCb}6a>8^M+1!_`zg)KFlZn|!uNYc>qyi3ROt?8V`zi| z#_ZdFP9I$8rf;Qow`n$8(_HT^c@_waE6Lrz-@*=?6PYFcu4YT%424-!|JHg`QGF~G zmIa+h;D}y5U`S*$+Md3|x6T#qQ`PT8rV&VA(=Lv`5AVL1tlBsTO%)iv>LG`OC_Z(L zpe;fVSOk)wOy!Xm|LseW2Hef9@|tJ~mbu4s zi^@EFpod4G_Ifeg>MB3?G`1wwVf`j?r&se_W|p@%{$9@tiDr5H-`6>`|9@SGFJ)wY z0}{t|e?UD$=$y0ul-v4mgB(Yd5r8ApS@nRtyQ8kNeKZ?<#m<;l&|yHJc>;#benNH& z{W5QL?ZrePyY$`_P}0fa@?D#=-wWHze*XJpjD`(1UvYW4{;%Z{n1fcyGR!oO|t#-F- zcd;9Y;w~~WUwT152>3wRFD8BYreO0zQgfbv{T&-Kc_zb^E-5qzSTVuB5RoS?J9Dp7B;!GyeujU+`BvJ@ik+;L z07@em&~B*jMtSjo_ynD)j?Qy!p&j4YMUIzJt}33gd%((w0|14yD9@HN66r$QuqiG7 zb|bTtGXUZEW3N{Zz)g0oQo;4oKBdyZS{FM3oq7*<-yXm^UZuo-!;9{q++yXI&hLY1 zL=OOCV%+hK&vFBLj4l2TA{pnZ$Te&Cii4gE1JJ8 zo~PKJ@4bJyHxRGv2+;l$g!LQKLg+l=MjVZ+xkB(ug|N)lpfAmp*aS>8SJx@l0jz+7 z{*<+o_wnlRsD4O8QFT#4kO=Fk6suB~sC>ytv8@M0S5fh=)^5`hl>&a()#gL)({kTH zz}xP1Vd}DJOwVj^P5&WnUwSfdhxB`dKCoLA@4ZrpgaNReB)0e zcQYCMq1*L2wt`>EmkVDJiP*(#=j-ZZXqtTmJ$vy8-Tb^P$NK?&_iKNe=_y`^S;eG3 zuFx@vRy2ZUU7GULtX2*Zj)i0t`$#DDJ0GXCKB9wH-eq{5^WLMjH;VH*8kU@Lm5+z^ zz0{85k}=#Gu>+lot8%E%MrLr3zFdsET^;!+EcPNgw3c0vNxrN^`7Q^1sF68>2wYu#@ z2#g0Q>$(;>o|Q;q3m@9&IC}Fs6s1iY<2M9o0y!aJ9!yKH_t9oUS>o1ouLG{l+$Mo7 zieRs|$kx|-`cGHT>)@e@05{Ta8y%*e_LJd@H)Ql(GrJ+47xC?UX73^1n9O$B!Eu>8 zH_A{!2Obl_ynUwiL*;qAaEzsf7OC(l9@48dLw!LvlKZXAAZrhn^@LAujh~uiYbw`& ztk{~4SvLN{U~T7p|3VB?ZxFf{xAihpk^V<=z*K~&Xk*<1&*)z$ce`I~P1#r%$bjI4qssb24m zMEtmGWC1$gfTYP>KaW_CTGJy7ubEgk= z2=Q^W95wCUd{r|Yv%x#A%ag$!s+4}>sl@TKNQAcWq5SDT(tFY+#!lQP5BEXibsHFQ z?HXg+hH4VQhEO9Bi%uJ_ynx~0<+7XL-CH>Qy1Mj@J$J{>Pah@f@yrp_>agLoX-r=W zoz&V#c9r)f)5-1c_Z;Mu9Wg2VHJ6fHgwp@;Dc#O)#}s~gmGB^tk@ovuj?wY7!csFG zyfxA5qT@Q<^S39yVJPTUt_26T44v;|z&%hOqltgV!)!h~&F{ALVkRPJl1X-_@S}LR?Qri@ zuvbBxX9eXiJ7+kPoJ@C~5K_)o`J1J;tRS7WbL|;igb%qrvai{-N97mclrJ#(>(2RC z?rWd74F~ohT`{1LQSug0bbM$4UStZD-WmNdZPFC`LZ?!)a`xTs6cm^6eEvX$IioKZ zE}K-oDn*+=JMHwNtNLLr9x_Gc4$jqREZQb~h;S^s>FxZG4>DoWj27&g%FO8$xv$SU zndFlxkfF}Y0v6BG(%ij0kc`^iC80c^?fl}C$rg=;B$pXf7oP6>8uM&F^l^p!p!SH- zu;qPMLZ8tyXprd*rh8Y{h7h^nBdB}p%Jdp@@Un8UQm1lP(G||G-k5|SOtSnT6lKZ` z=}nlOS2sZABi!e8uD%2Q{(s{Ai~orp)BlfGJO1~E_Q!|Z$GM7qLgAPvc{_qKr^ zI>xnHP=AKY6!#`OXV2*}#&}VIzB%Z(Nf)JBk^jX7EXD(MOCjNj(39nv0M1F{C|v*# zkYyI?A7f@e5{XsL__L1p%?68$h5YG?6WY~3KOPz`daHZPhC^+SwCffOgu+e-U>>nA`9JOkQ6HJ4qZPzE{x#=cGf$snuG zG$%KA4a{rSoBCUVOxK9z_XjLIj>^C2hou0yU?P{@jsc(mjJ3#ko_598u#w*0-rX*d zg5!*C#)^qcjD5lCU_ec7qY>SE+(6rxqh8>7I!hZ1FCPJ#t=PZ0Z~*{ufjym+&rzG6 zK;v}(OzrqM^WXpo=m7W)h{F3bLn#xeW)rLcrp!oiHE#`~md~9jp*h)GE&;staF*lC zvPeUL%Mg%x@#hUfqD>QAOn!$kY%KE)>DU2*w<^O8@Mc&*_E=7fnsl0ScYMi9Q8)ih z`?Q)ovs}29K8_d2l;_J}9rZv`IDQLsRjBgw)M5F{qrgH25R}mZ*l@?DKuUohb76Am zJTK@%#{ggdnbzZ;kZme8&-GH7`!U?0=EGwp3nI5c-0L5g$q&u2Ns`aUy!}_R0N+AEuKj|OOTWLMN4^P`Zk5p z*eOt64Gyyr^6Jy!0(N8%O|ux3F`{Y-U@`p29Ew zE<;(CHoR)$|?9U z+MRnx`B=0d5BSZPXZBJ#Ac+}6fy$EiZt91l#)Gon$p(CV_x;G6`w#rl@7Rn^KPM91 zJK3zSXaeO;Y4Y^Nc?3nso`AOZ+Ha=B3zb?#vn#>!?eX5S9oW`-&`+zSb1OAg3P&w` z15^?|q19pX)WQrWpd0YanV||C#*L}^bN(^QS;-6MSJ!hfM)(3z+u>xr!byR%dMO$F zor0$X2dhKfd}-^NK$sSfogGs72WE2X5?a);>*NDPUGatJbkRR>yc-o6IRW;F=*gB> z?=V#PW&uz$ih%=6(eHq2Zql}cU07^dMyq4JV#=z+YpycZJB?eomhrA?P1z%=Az#e( z1+G@AS^d0Qx@kzW6cj9ug>i88 zAT3I7WiW3H?4Ct-AUJHaKuC+y_izz(=P$~hxfyi69qj4D`FPx6NEd9HXXBoxWH3z~ z5EjM2Sxi@&cXratn{5pi->95L?_BW@I+y{y$OIYP$=wdJa$4xz&U2jp-2z0ju{-1OhssZv;e{1t1Am0#7?pj+}`HTKG-)#P`O= zXX2xGeIr2|uacu#HYt`bh>%~X2@mDfWSv2nj(z#aN$q7prO*X~3GqfCrJHb)Tc9BF$l& z{^S7d5?*CHRKcyJJHsVqe6(@8*Qao2=s5al#557kN=@swR&;P=SvH_%Wu77#c>3H4 zkPh_YPpEsH3<*Wn9($gOB%}lhx6>bjD50*sl;F68oc1Nt1K#dV&Slu#C9=83C=Yt6 zI5Vk1cd+sK87ieG(>;5Y9OQ|^i+^BumQ`HlLZwPkj$w_4+I~->CQ6G%YSG$}k>#to zGyo(xUi7~YXoIfxM3)6}b){2q0%--T;$llL!tYKC=$8_=nRyYfoBt}{bf<$8g#Pj? z592M%!7e>vNL*vog(yCd@Rmgq3e6D@(JCjh)3$yemY)PMwj7*AmdIDmHT+condqKh z#9bEOwf?Mdi__!)4s+0Af@c6)Z$?1J14sk>BI~8$UnVe+@3I>yYv!AP(tk2;*F7SA z@g@Q23_1qhj;g3fb^31Jqyhmc-s5At(H+fE?287Q^V3Cf|D8KVV_6iAb{DDAgLWE+ z_C3;}@*ZmO9{!N5YM--ePWE8Pz5ELmX!9Zz{Zd5=Tq!A=+ z1py_bOQpL@Kv6(S=?)Q*21#l5x}A5=p4~maKX%Xlxt=p`%nUrwb3fnv6Ia~~R&fHg zOKY>8nK9g4`!GnH(<`v-M##ss(Cn23ZJC9lMcvtlXuYy~@^frNexOja^w#}8+2%n# zEo8!n6xpS(Jxf--Z6)f%#m*TrysR3tN!pPn2Hiwok*(`KMdh5dP`No<3>U%w5Y6ZA z!Nq~wKxq0~TNOka*220n3uSZ zl8`{(aXi02!*Oh*Jh(oZ=Wmgcny+Vb_N3p(2cwJeU9C{uX_J_fjnvz`ovX(0m`4>L5JmeR4SO15 zndX4%(F;46;`X3rEV)-e=t#GFAAA<8i{~}dM+2*o)C^ys8j@>9F zDgCQMY%SX|;cgneLg?z6?I-?*EwRq6=3JV< zCm%@aoIq+G5oHK)GvieG$~UywBM=L%%nfhq#0S58Nnp3KFHbhVW>}~{Rk6aQ>)5VF zs1b@rpf>8J*lpchaMLQg>cF0w-7Q~mJsaO}{UsQoq^Rlc710#8cQISOfLYs|Vpr4F zWD#(>gtL@-6uVQt4*(QxjS6YCc0Lh`N+V}z2-MoOY5Vi@!zw@ zsv4n4%u#e~{1L37f7PQ5{cASs10s_u+$$6QE&HE(j!KmNi3*5Dixg|v%_gU4CJ>!$Mjg(CMFgR%fZZ@Z>yR2+S49f z?`*Hss|;*b9&%${bLMv-eVl<+jOB&AKsQsBvRZ?ITMmW7`J-RIh%Pi_UH?Edb4-JQ zioq@})a7^fTLN(LXo1%`*I2V2u66}1uJ%rqY$lB3DD+ummMMsDE<%s>N6RQgn$Q46 z2fNML$TJL}Ny-7PE-i-Q@o}OD$}F^ar5Q7#m==G7M@nSvFM+-J#90|!qgW}6?w2@! z{;t8J6SvmOC3t*|98Yf?Y07BL-qWr`xp;=sxs>vm!v~M~$uEX=BF?3k-ep!^AKf?Q zQdCoD(Kp*aH5>GlnKImWjXvq&&BpL{3>1*Dc1!%k9vb4gTj+{SWNJoZ%Mffbum+k@dvZR#9Y#X{>2oORGPtTb=Ct4swA&KV28 z0oImOarZ9{vBR@Y*yIIv#wMHGhD$U0o76zkb)w zdwH{{LoJo<$x~P3AhKY-4XdB_1Mip$pB#O%TcGw7*SE~dkE1<948JrOPim+~&*ltA?a| z?9O09(5swNNsyb1`SIGpCbkiRTvSXbzSMYcB zW0j=c6BVNRT6Z6Dd8a?RVAcS{V=UU-gW=Y%HgQ)qm9Fn!@9%f|U_biVeWrM{a^WrZ zCrp2;_r$MPK0w3klA*y^6BZGNe}foI$bxgkSH6-3UUDi8TOVo+$|yhV^7SsUUMPr6 zzY%nAdGnHh=#hhB-`zX*NknQs>^9Wq`PEXgit5?b} z!3}lKl&{Ym_1tI{>qjT-|4FQ%f*XK%{0VuA z^^x=!Xc~WrJMt%|W31!eJXD$(Y3KdwqUSSZNI%bFsC7RiU}-x46*q?cht@1)ysOnN1WkVs>2IJ$B0M?;uB_L8DR+;xPGQ0`8d-%t5N zCJmuH=zE^e48nyb`y1wd-b{hS)w>_nhtObOws}c_ESqs&twpwmg1W_Gzm10^YOf1| zzGj6bw~R^@?-6XL?0V9%|Akdpn`v6tLr#ey*5V<*YNr6Md|h?@+ucDSoqsyB@y4lk zX7Eg?b^SES>JE%Ju5_)(ASy!q&_s~cw-1vN)eL84?j9D^ilk_xf>~k^B~W(&7Jxe~ z(yW9ex3^|2Y99Ct=&b75SZ!6+#V?*)tzu%s&v=iZ2!!#P^dw>{Sky9!a{T#zw+%Fj z6q{_WEyW`Nv$bHxI#(#J2`hXG5`#6n&h@43CjB2H5?*~CttuSmQe))wtf zl7c=v*{Ve>rS8}w`Wou`_$w-kVk#$tCm0N`I8C(GR!AvnExPJqenyLB7vNi8MsIFCfr0smxW`+CPzkOdsI_K9 zA$01svhQv@yx-;k&GkuTUj}`EmVh79Cb{wDl^d>s#U7d}7 z{9@%y(^eGKrZ10v$f!|VK`)1AN{r0z1N%&xJV@ks`Sy&Xl&kNXF1a^h#m~ahy`iD! z7+h)aD-XK`_W7^pcQdFWoQ1AJgyn7QnQwr{&MNV1?j^uSCztV-_%BsmiC2!?Hp(H< z&M5v74nc28;{KYVMC`TE{2+I`bLC|igdJ8es|3mF(@{qcVGh+OF`s{pBI6H_$Bx~H zzkZt!xlw695oG#85{IOD;u@BfJ0X_llLVf1>5p?w-+XcxeKQ?1~-^*vTc z_Kda#KUGl>s-ooV!?)Af&s276G}^goEoFLk4}rR$#A=&&%Alh( zYMpqq<^A5c=z-#>m0I&Z{s(}E+L=09P;&6aaMZ8Rn-iICkzJv44+Xi zeUgCTq*JUU>_d9-Wzy6?zvu=%VX!gz*%@l-=&`v1rQ54*V`pvb)d)f=zK~|6>%o(ITF>E3jtQ`tOi8Yp7F}zGFaD8R9nIBl3f&s z-t0ddc$rb=U7&pX8~j|&_rR9v7mQc1C!MJh1-siMiv7sq+FQQ}?p>iAzc6htjkmH^ z?Gskqq8rXQ%J%FW+%%iFU!K%@)i;>%W$i0$WDCUP^+3?A!C25<=&e>4;O|HKkCx9> z)2}n;``=yEdOQNd)R)1BznNxLKEab#H`G1bGM$&|G zH3iX++eGy4q$}_B%^sd)`ch8zx6@b6hlx~TuLV@96ZfqVB4~Uf>TyuDCLO4;RFr->BAQ}zp8sB<=Og+E&q9{3!nrr;56K;b;HV6x_0z@1|X!xMMqn; zxmu5#LJ$&n=*UG+pVMN*MA`=-L~=vV(ae2USCa!e9F(UHv#+(?B`SLGgIz4_O{#sF z$+dO-Y=!PP5^g{);x0bEWi=w&%u|f<*}!G!AqKP+=C|@wF7W0%c4b|ci6k2)FbLvS zR7+LA+fjfm8VJk+{Piu%{)hNw=y)qd0_v@Q*tz6pp}{Fep1s;>hJEE|bPpzah}Jjp zml&x2e^<6$-easW$qXC!=FJ#VROJ<J+L(bPUS~@z;{%H6i z<}G#xdE9>fK-hU^19iIh`bejSuIUEYRzCxz$N8$?JT;xP zx#B6qV*-XVYfUIQd!0kE{pi5a8;h5lA}-ACOt%e`7iivd6NS$F=w==Iq{xQB;GR^6 zT{(XSes^{)_sm#x6wy0>Rb}!b8HqXaUw=p9|=7xbS^ZN2-Ws z+xs8VvAQg$KbkDc9{d)gcx@c_+LhIjL@Rh)j_vjS_i5rhfn26;JnJf{KQ0QD=KtLP z9}3s&WZM9Vt1n~wf9T*Wr&KP% z>|e%d@B{`5!O6|WFo>PLzP~^H6U|Qm_vz1D@$~Sn!I5(PF#?_)B!I6TWFXJIT!vi; z^EFSq=F(v0p;6TJP|@2t$;O070UXzZ8k4z*^W~z2aKM37*c%c>~Jf@nd^{KwrS?d=^6!nh9W*o%#vH>on9@ zOPvN47?*DPhC}E~v?hM_&BD|CQ9ris#uLZFIlKqUcrsSDj!aBB&Mp=19Qio z2!cfSs#7*1Au*%JB#IAZ+o5FwxTD(d>WU)-SsZt#;X=})F6g@9<8!LK>w7mbO~P{Z zYZ10T+Yd4%&}@z3eGbMRSiu`{Z2UlcIFjc_p#J*w4gw%>05dHn-6+byNS1JG6J*{C zsqff~^Z;aHEwq~efuU;uIh=nITSqizb%4^u05j4iDVciC4MH%2XOoW>y}h-uitR>h zD(gdsfv3Bt9)ZV0d`0U71EYkZ`1XcEwZP~Qb^xxpckP{DB9EI zJ;ayV6AGl$K+zdCsr!_|QaaX&JrCTmw-LkuQb_WyAG(byY-*TSomswSj=wAP)r)M` z{v&{edrN(7%{0Q|{;v@20q*VR1HT=o&xVuS&3NB1LE!{)dHHqPTi&qgseJ zf;p8qYzk4rVVd&6cgc1i0j?ab5|}sA(+$+)65a75o*TA}x3KnGn1IufXcV{qA(#IX zEsPJo*wJe6ErmY|%u))@pR&LVyZp@daXgkI;e0rP-K>R0x@N1pDG)8n(Tj_?+^pOd60r7f9mjS$wfcPOg^ppA-!8ZOd`*mQU(gN zGheniM&ZQhn_QS^l~_FCfm_y<7V5V`u%qGM^O9jWjq6ugFAn+8 z7>B6ZR??lH7%dBAA;a4MNYeHR-;DZr7qy~j+=EXx>IWk$S*o0tlS#)2s7&oI_2;S} zMvYsw^ILcV1+3z3q@iC(YAs*A!obg6t)Q%&@FWHP%O0jG6O4^dthAC}9ig@0SWwfv zd4u7V)gHgiXX?uPyI);QPxPkam-g3wEub@;uW!{3gp9_3us_R4 z1y@+Sp|Yo6n@lp7xFV6775qd|c$`V^YLgRz&)%iT&CVa#+v5>G?Xh%A9xzKGP0tMG zs5<*VD}Tkz7Xp`SOmG4_&5}dgk4RAOO+aBeFLnebWAcXttxc!z@;>S`y8;RSE3tOl z)^ihiT6v~duf?-#Q%>EhP|f*ZN>9Mqj*EFM*j}!#7AaJ=CEisKFY^<&R}h`M#1M&c z*vu5SS5jC!KT-HxT^etXN?AWmjzxrXua%(W=vq-LElbq9-<{SDsYWE@Yp8%rNi?TU zdtk{`T{cP#*L?C?G($Fo8{*EZ>hYhZ03EAa=YxUZXuTS)LiIYCfCcAucfLR^&pMz% zIVqUBq52|2S%_LTA?VU%a;1`p8ku&Jk!1H=^Od7zL>v-0q+YASs1vNzrN!S6LqO|h zW{ASyh82FNUqQ{-O&*3Dq-89Wjc);~KPRcal#sHBxXZ~E|AkCaZfv+J|ZyBQ**oO+~3pLTo; z`1XMQXxE5QVk5_ZWoUZJ?WPvrp5MC*oE`i2arp4I%lr6i6%qM^D?!qeJYe0-4dHQkAwW$&*c4d8orp-j^-9gPYR<9`WlBS-k`l=d!CONhTWh(OpvL z&#BnU!gS{!tNX&dw9FRq+Dqs*Vqgbx94?rbJ`|>Pd^hbF`yj}|LeX*8g#4S=QO(ie zU03912g)3iC5{ry{>yWjdHMf23416>q(Ues*m+T_w=61Xyl*tie#+;~70kHU4{cX6T}=VV6NE)OCZ3?W7g z5}Z9Mp{9=`_$?Al+doMOzk+ymV4tgU}&1eGP+4@F=JsH!)1Kt&yYP(FjHD zBzxK=wdUx{9M5>oRGOAQQY(&S835<^_!%|MZP5z_cY>T&a>)r%W?S-nTFM>^k^;kg z#fWqvS~~0xYP@W3TbC_j^-7&w+c`Fy;szX&}8J_Cb(lzZt2=W>!={H zx|+#@U>089lGSbAW%ac3oOoD!)vjM_EPX3p4-x}=RAbH7jde0K^8FI2q$RVh z$EVc&tvF@sLV7Xh+=JfxGKee($Z}_EEu(n_-SYK5XGN#oIjR14^dkZdNGla*eeIyr z9e6^uvsw8A0_2iwpW9XAH?nJW__3zSKEHK@A(dEuK@Dd0zw`0&3I5*Ww#aYQvxt`N z;PWL?i1*fq%U8m*p{@)1dk~X@c0J%Nlbn8Iori{)6blS9{WhQ>$lz*r>KRDe5xxzX zLKyTxl@@wOl;+ooVKZ7(OQ>{mSHun31E=)gqL25=DZPkM2wpsO+LSRcNwM(X1Z=Or6se-i#uSVJy z?Iu6oa!YAdqub@6%q&9Tpt)E#;X7Nv+s%6UjM+@`Q@uS+{A4`tX*VhO# zrJZ1)=?g$5b|sVV_FNg-z0A_6WaVzmyX{0H&q@ z-YYowC`XUs@WaIVkW=uTye%6vykIV$Tvkw^k*SY(gnOQomAI*{XqKkMDmCXmrCn$j zct$VCJ@kT3kE6*abzi@Au7LjSZ_u@fIE?+p_76issAkv_mLJpoIRTScCHR|;isqx6 zf%A?FnL=cr%UIlF>$h{4#oGn|3kAy)kdUyngEAp`8VaBr${*AAv0L4__dr+1w`FcW z;m^WeJ$4K&?@p9hz77kfne@J=KZhN!cj(sexTQa3(2Z6w^wk68$}b8hzo&-iH%@Fc z|J{`QiY$rquUc+ZO;^33<#HUTa05=fo-?||b!Rs@R-czDD)-m^X->RL*@Kh40@#@2 zYlq*PvlMxs@V4V~Wp}IanrMdzRYgxQw|W(P9GF(h)3*v%V0C*ey-wL4wha)|{^A8< zf76OZOuUiAP^Vd9)N+qSCc86M5=n%ALF%`@JROlnnHJDQXe4Eh6QZh}a^x@Yk}kd) zuK9!v#7y~K0*MrR+y$|n2?ZZAGf}7OsA;V^H2AaEE7BHmx=)%QGW&J` zyhNe6Ws7|SPmL&6kGku@3+8))ooQ-FTpWt69>nw~W66IGcu9%FK@q1Zz~_;#M6F$p zFg%7jj@?+;DHGx&T9Vbd;j!!oxi_p+>7(i8EgZUSpx(h>5etrd^#=n^BJw_ z9Q{&aJvtv(wh(el_SDv=7pUPJ=S8TXN=Jyd#ux1vm7o*{w=Af^)$e&3WiFs_yu}$`9#_Ooh%VT;? ziHb-0SKg8BA2Vvw8k&&o`W3F+58kbR;9YBZr*bQ2hLP5&mjX!?!CvdGylKoBq0!Da zkC#;^4+;J>S(di&@qn4M?V+)JmBqBx;PaCs=O&U~>+TunWA0UPFBp4R3JMB>5IgEr zsn`B~=sY^N^SA{}6|LH~P}(?lbk9l*C*y*Ne&d4=m((*bhdL z^jvVuXDkiAd4+;Qc|V~&%m z_wR51|G%2J4*zek)>ofMNbCwkZz3KSf#z`K5#PH+<5eXkB~?{b0Aa2?`&p>K)cvaq z*hQjrn}94?Wp6A#%tzUUrP8mwz`ToiM@d4+O+`d0Bw*R_DnJ`8;LQVQkywkhIM~^n zomHwDiF`1f!VKadD)El%xc7$waOQlZk6f}GG3>Weie_eJSN}d5fftfU$4{&YT_Yh0W0cF3q+2sLN8D8 zTndmKKv_`!n)r$WOi48F{|9^Lf7CLHZ=1u_8gX^Q)xJL7D{4L;aS||9kA8{C{HFxu zh*1-MyAr;Glr~77kGdQp#BE+iLFZ0s=Sh*#Tjl*d&a2!y9v65i(fbkYMU||bBpo!k zhDplMWW+KQ0rtS=?{MweuEn;G^QIKk()jrY-Q{0!XbJnLM2|xyzMrx0$B}5vPt=eU zKjj~uo0A4k*WP*UID~+u??BA?@Fd1!jc1PZsoYX0h3xwKA&R9uBJ0w}? zM`MJBuC67n?UQn$G~Hr5e0y-4-&3HE66I7+JBHt$LO4g&YFO%LE4It!V=~{)%TzTU zdc7m!yhXsSmZ=>?gD-q`5UN`GWMlpxKO$^8vOM0Htw2H|V9|r&_Z$G$UyEq#mhCC+ zkl2~s5NFBH$6V8#;CDo94L{z=G&0X(?>%3GbT;=jhjrEF-0%s|{7)<@`qTTNtTuj$AC~e6i>pJjN+}8|bnO@n-$+X^|*NTBfC?0UvDg z5?50k1is;UGeSP`CjbLFI=YacAb`e3m?aAtX@xnv)MCwI%r&#qv|3#uuYM)zgSomp zk;vl$y#Ucn<`ChBAv6r_sh!x4Isr@q-6yuxq}M!X!I-j^U4`7188W8hUF!^-mZGE_D5ASO?pJb4)D zRLUB@&E;(T9g6qBSLz1RDE0~v`;k?adLD=-HWTNFIqcjFm;nH~tP?6`mX4#ksU2R+ zlAI+N?-^e_33o-}uk+s zo(l-CE(G+(ZoT7dTwF{0t6~~PnBO@~73eJm!T5<`W>3M@A1c`if7hZZg@-tYj z;Bywd$o8t`1*Hx(Nb6kp4IeHnZ3Tp~g{y7v=KI}84+CKpKIX&)KCGalCGO&Gt08bg z*CW483g5X6C+xxWMX%76JbizoLv{-xDg`wv@F`lL)4B~Z#I>JVCIeJ z&*j86OZi5{>l=hf%8R#+8gL+5v@XS^&yNs>mMi{8WJH7i=3G|uS_i($>CwIqrAh52 zp)tbFp{Yph=Y*PVs;k-N(!uP@%Lp-r)dh?6I!p<{mwKgf-wb(fEyRGjbsldq9&3tw zeAw<2GLK-?m@MJ#eV{=`e!2Zg`5Nc>f*E~b2#>yPIhKTK1g}O(}ry=-;*tz&+L_D52eL0 zku5-D(fkr`_y-uR_6+uezk0J7J}lX-aB)&Q2j(G?jMl`$0D<>ymBdzRsdvQ&qMeqj z%1Krq=CG5@g8c57>y+IfMA(Sn``zrl@=4>4bL?cLB2gu>8j{RwWkfmS6mr*cQ4B8G zT4m~&1*vib{l@)uzhlo;S`>mkfcXu%A8kM7Kc}x@qZ-7181#;3pwdkO=Om>cTlK_!cfDJGTqB-1x^BCkUQ4YLiXrFRqM2WdAlkFGHf;^e5jdN{bRu9%AU+=+carn4FrR+nH;V~zXANtYyzeozKy1f`@=L(rv9OR@E3TewC5Uz zG!nQIX9|uM&flP5b6biaq=)&#;aD2j7>v?!JhBfJsL&p^xSt=C-XGW0{ZZJ9vrKKm z9Zp13f8;cLeV6_>s*`1>3ph}-;o#K-{d!{E2Uq5QZtYXTPrr0OhCkP=LTAWLEcOzW zf4Yc@5HwAOnsSv~h~?ZZOatRt*`wh8Nz+Le{KEU*iH^6w0#kZJjhX$Wn|L+a9{*#a zKdDxdHkCVgFmB>zCYS!zZE}?Pjdz?gY=Dr%l!R05X^askPB9=8EAV@ZsyvRrqSs;TbO<}0B&Nr%YAxWPzPd|rNZon6 zh>Qm6$rQ^RV?h^?n%zK<Ibe`{1r6%xOzh`y;4&tIkNdUbH7 zT;kA^ODBpq?&Q?3?MYKN_tAI@9VvMN_^3Besaa>>wqo%WkKZ>{&hT^gg5j@E(|%9Y z+Fc;Nfj*dx!6aR_+hKxHcSzfk@9520eS!#IXPj2qu6|_QOL`^2`WeFvoKip#odHab zNpzo%5`rlfujG&j_i zs+q|<5FOGi{=&D8-MvFRX!%eDz19$2I;Iu?2p(&{7O`_5f{O|sDKS`ReOEE|$PbK( z_?Nd6Owp;b#el8zot@Fh>VwLl+XyuDELlPhQHs|nVHp`Ev>iC;G3C#ufs@b<>=w!5 zfn{{8CqH#FLZI22$kB!@LjccOAjW=GhRrRWY$^)&qV9lIVCsZc03jv=S7)}jX15pB zwg^WW1_i74 z!hV{f{N_2K(MWrusgd&lRr|bF#&hi*$!5YCGkf|C zz_!rRT{)QNq%ypuVlq+}yk?tp^f}SS?`co3;2kw#jKfA&fk^^*R!ECxMRLXFw2gPJ zW9%Y*4%BPHq&jnGo* z3^78a5*}~NQG`S#xfAWNP!yt#aIM{gTcjJ1!#Ef%QjMJr)jV#4bRnx(nY+CsLZI&R zd>?$Sg7)t(0NA_k+ulhS(@(E_)xMu%Pomwr)Qw>89+4g92y_* zGWYEG*$Y<~9D~Y9#s)38jIW#n8MaoV*I7V_#|X&SfU45-)tknSPlSzia4s(`gLxJL zt&&$ZHwK@;^WY$uSblA6aFYnj)-6(6n1y$yXP^6c&gKh6p^+e{?L5$1U}5zsKi4fu zh#_{4t(iMRZOJBA$at#wI~Bu$X;ttU7@yN+*Gr<)PZCdrQFLk6OU@S9g0&74co~|C zsr%JtIw~&X#0h#Sa1AtU7QKtpZGQVKpHcel;?vhj-7($YS8PwDuX07Ku_fFcxrrcrb( z_I_L1@(hff?TReZ$tQ*pMmXYcLh zUIAV1U`e|)k@(}?U9$fiv5UXZ`p4B;eO~Ex+*=b${NuOnI46G}_<}1E761_GLOe7$ z4|it1M<3DeJnpasK`tW-;#&`io?a$W5~b9!GjE@w=ZwDz2eQ|%> zEM8;Xfc95`3se$8M&#EU2rbe|8mKx{X$$2yUJv%>2*W7jS8mK+*&$#C zpUK{bmBxDPM@3z3U5gh86GKoDDEyp4?(dU>&JiLuxs3vAeu-%vpM{_X*L&4L7me*! z!eCe4<@^l@rMpf&o>`9|)e=cFUQ$cJ@*xvR+=+~SXDbExp|5e*Yf~6sZ~tB!xU{J> z$jO&jUd#5{6XlasC+U=4k8#yZk2^IdO@fp*qkP)eP|_O=%ea(Bfq zf0un{g0dvi=@0&M%YV=A#rW<4{ZpPncIq=68{UW2md(-(<6~9CFUWZS`ndsB*_KVg zVYB;=-}B$^+-XZEpHBy`Cz0!_XLeogVfrTKX>-=0DB3_oQ5ock)N-8F=+I6hI1E7O z@?CR6L)s3vHqLbCKobWXv$#`3{L9pFQsOZ~;&Tm0?`O=#~NOdisMq^72cl(lT$8-wv)$;on(9?wQY6;+?j)w`Ak6=8}q{SiJ5! z%1A5S{+;=C`O^#x&%`InBTRd!?V<=AL{Irgpa$3Z|3XaFw2=uO+n z%i$2z(YT;3ZHvmncmJv6C>H;#roGT^j1;`S;;(*xIitSEcxO2j=|ut(I!Dft_AvUk zo~my!QY1`w-+I53&h$-Brw6kLDZ9Rbdup3S;wj(mO{dhw7&6)n1-I`W zW-Nz^Z~D&aR`DS&lgBDH`@DJ{;ZeX@EaMtVIrN(&Ps>SvoPwL(?VH+-HOm|HX@hs6 zvR+iFlT6h%6Na6>|DV&Xx|$YW*xWx`bE#u)ZIO$a)`ro3Oz_S2S^y%q%#XWS`2KWp z%k^!m7qINgg4S$?`?g<3=4Ab&(*3!w6gMMMcWnA(UTSwDSC35btRWT$ke@o&^TOBA zQFZrvtH@9JG(r!P!X)Y=)~P{^y=7fc`N~aEHn_IY zJ;hAtiR_cR33=Z{G;R7bbyV>W&P9*X|vI+SP}f?7?GBHt4$>ot=1yfGAc_Z z-h!R!k?8(~*R{daGgFu0Y4WEYRRQhspkr|tYc#Oo0Y@I4%tc95! z=?D?TXXY-P{MmDc0Z>L?W8~+*kyE-`65!$w)h-GmRcJhSXDqD*XA1+iY^=4$H+~g1 zskqOkWRc`O?HAQ5o&ZD!k;ILfJNP`r41@V>@<(>Cm|WWFD&npHbP}qW?Sq`Tehd== zU7G~W`SyM`FMPYiBQgs6om8Hi-|+N;PD-cPuZ5UL@GUQ6ruG0Y>#Zw)a={(Cul(e? zhd`@saX-MTEg_V)ImxE+$r{3~JhAZYUlrTk=?uI#pQTT|NG2RTU{J7+2lze$J*jBi z=(IO~xF-}g4MNxGnsQBh7>|yiF1=RtL%)*aPVJZ)8z*Ngryay{+?rYM`kRH9h%3Pf zxEj}hB#oJHaIjkdGi^rTpG&K};PlbovyO2Pt^O8*2PT6_0jkU);^MHl#|9$&9g&;Q z{EwcZgl)$z^;Y460=sF33z;tqdc_X2`o`IE@2Cl(T%ylphXB~_7E zH2OR8$_BqewU}?(jBH&ZH4)3Hx%}S+Ew}me@jQUihgjrQtKAV@L%^8OuQbJ}^I6(Y zysOT`Z3tTo`3uJ}h+{%OyTXBUZQ2eV?chrUXhTb*U<(4Sog(HladEuzAIuv&N1Qhf zTDJZ|JK)wtY!DO-!ixwIXi+8)Y5xR3$_cv{=T4^uwrU2H4`Mb9BO*o6EH5zbKl{~- zAeC|YXEA>?gIj>!a1@$ZdHZ;{c94W)L6UVhz-i~{qo*beM?7pHrF0f|ycDnpV( z7Z2grxZES^ibc5?u!7~&2}0;Olz%{DB~_AmOb{V_z`Rll^SOxQgafvNUhOYo%DF(! z5$Dqghmp~Zs!Uq*-b0KsqXu{8B&Thl7t5#Uk?I{1e#?U}^iJA)1ipxd*m-{7)&~ks z-OL*!1VfnLsjo88Uud#(V+wA1_q(FbL` zVfDN0F4T6bC}9EbB*T=y)L{2sI&RfmLWo?^f2POjoq z#FTXB`Mb^I z+*m*2^Y@B3OuZi)aK)DeinXJPP>J zae+L_>b>vOObKFp+OVq7@G{k(2$qSkR6h@*neO=?%-Y-c9IZ{z~i|K`CKQB-GsJP8pmv*NitBhuiP^7ZJ- z8>(Y*4;04AWUsisU@4QLqe(t>@5vLXzuCQS+@+9a%foNY-GtdhN(uU{8r5)5vQLK8O{yairAd3Bz zgv7wCUmMf^C>GI^*Y3Spc(XUyJ(=X|UM8YIeYJ?y@WGOEphiaeP;0*y=W4my1|}=sO++imA=wJI?J~rly~lf`A$b> z2SYG0}>}zNkA%V|8WK(MX8D4o7VX35k&K#p%~~E z{FXjvf^Lx=FA|1a(tc!GKsm_Ld3!Hh`U^dCHA0r$S!tf zX+kXJ2`|DZ%SSBbfh?{l(RKb1##aOj8P@R23AhtYGm6OiBANlwk|HvC7n>AAx$_mR z%LppOYLfT;{LUo+L+sl3di-S94?Eu`jfB=4!-7+4Sd^LX(L~%%BiiJ<)xAU@|MtX_G!W84)x;Eh4$g<0k+#*ZnAt^8@I2RAV+1nFJG3p}M9D}R zR+1ow$ESmL^UzMHhp=8)`Ow|j$5+cfUm!pns*B+9avs|&XtqVC?Ll7O50J8vVO z%3N>|$r?1V+Gg>=hmfPp z_ZsHAm4>sNQFlGr+EN!vFeeJm zT)5HQCbU41Nf7O_)+ZkHd1<1jQaGh?h@-|BKmnIFM{OojtN+yn%;y6FHYU5$oj&YE#J~X%?||($=UCSK_JrpGa_5$mBZ9~Wn$#$ zGjRVC0u<)0@sYM!h#@jVx3f zd-LoC4utY@JG!72+8?#R*e4(S8E=+O=ld<&MmKn`hnJAqGTzak~^2p>8wMI)lGYNg$$jhJx}2!F(}UQN!Kmer;#TZUAv zk=Dr?h>TA7oD2{%clGoC)wA|1JwdY&80u3~YW|no$)Z|>R=WUKg~(_j0IXH<5o*(D+vrZIAq=&OxhPi>{^v>R+KPm*o8bhZ+VChL^oF<5#dHsc5!BtfE5@rBmE>E# zeC*abV`ejaV^1@c3=6G{n#vS}OiG^>7X!1{)p~GZsE-HtrZWvnpAI^+K+7|3ey{D- z$kYA@=WxFdo${FT`L2Z#*F$I<`$GMhJ#PX|vs|)X-<}LiWl62$W}=Y0`_zm2Pip`$ zIBCXba`N=GTzUjVg86v4T8muC6)8Y^v?G~{Rk{yi{v~N2?D_oZK}-&)6~ro_M;RJH zRriC*6+FCl(koBg_&8c>$Q`A zcSuB#(=_6say}KQ*P)3|P6+2n%`5S2`uA7|pJBbO+q)=q1R)V0L%K8Xgqzb%%Z}8i zzq#J}%q{k?wkv z$w^MG7;e3IL#A6NJ2$Z`w7w5`_apS>>zKq3Mm{KA_QIMeFB23PI;6HBFdZ4}@QH7Q zO7^5J_J@Q*qeQ#9??zwP0~Mm>jZP$9q*PM)^0x6&PKEI%cFEeH$gtWw)Ynb23%Jz9 zGR|V%x6_SUR=2dNv3EQ6tK9=KQr5mN#}$Nx80@q@jT^jd8W0+o%ip`AomBm8Y-iN9 z?-X9AqDT&X^$8Te13Pb3ElWh&;g8pF7bzYI?=#2E%UxrtP*MoL1cvuC-iFf`6YA%j zsINAj9e!4;vi57XqK+e#>6-l1JbFD$_j&1cUm_b$X?rmK4yH#_j{0L1VPjUxp{ORW z%e0vzWcd@YPkjtQIHh#z=*7T`(?E!&ZK!v!w#&Gx9SH7hV@++q)kKxlSUqhxjODbUti8@Gq{srVGRbLM|;8#IP~rTp^<4^HI*qB9u9COSPFQG94AXdxmuH z$t=8SRYrrvq@cjRQZ~L6`?h>dzN5|mLS1Lf1Vw zPAjy&nzj2FIZ8u^TWd8!_Gq`_FU`Mf+5{Znx7_bvGYroCjnB0Gf#Mm{@CC5*Z?Pw`>A zSHkQ}@6eW9Paz9&k`mK#Tq#JQd|A~(*6J{R zdiVsJYpNm%#ZOv0P}@v5daj}xhiQX|hd%sqS=t3W`U0jsLCWEE zphYhY$ItzINA^^MP%^z@&LJ$!IUR%)da<*Xs*j1Q@eDvoyAGM}c_^T2F)UYQTz(Nl zKNK6Wur`JaD`K+z$A?n6?V(~FaYI33_#eW#mmLGa}tw>Fng<< zyQU-ds4x0WYH~I7IQ1V-UpKuF&#`K*D8zK3d46ys&_o{PE7?h)*xp0PY3GzU8rLe1QNCjr#imPk5baN)|6IU<>!a)+OM#68Ui&)36vm zUBs>?c@gF2GcRX|Gl}^Mnz`*B#Gv&0%|(R5rC8(> z!mOM;P0SSo5f=jV5-Rsh`UuB=V2W)JQlngKUX}>izmO1Z{6yC=Sf*7a_G+u%j9iVi zYk6TJXhing2&LiHc9j)+R{e3T$o|JPW)xe1bF6ZTf87C#MrZ`m&x|>AA_X+f)x6xY zS9b^1x;o?M#LrgSnU~MWtaiVC6~C^w5RuX4P?MWZ7!kS`k-74mX$?25Rq1^pZmTbI zlo@)E^2rmoI&DB2>s`B+B%_P)tDCHDcT+2|w94(`18DeBlZbe%e?LXIVTm-uT8$sD zn;@g_*2Vdt**l{mTFlqwTN(&RO4EKT}AAU#0GOO{Z9C`)UgK-3J5hFa7ia{V4Dt6O!Q2;n(oLfnzsoheo-3w<0eSS$mQnKQrG+AJ};`63O@uR18#^mdCt*$4&_xx!i& zW&GZY1_@neNd;d_3#X+d>pwLEF*2jHz0ulW$zO0Wrvmx_CTsmI738a#la8Hyx$9(% z%;y_n+=3cb%|i&!ri%XrE%nA2h2PGVHxU8RH3I>0s#Hr*n1D4 zth4P~5N$(az-}ahHtRPY6B+mey_g~0c=C?YB1W6laGlw@@m`HBV*&qDkBUUw}rZMX+WQ!{P~ z=UhVA%2mtbWpH|tEsHj{ox-*lX|{W}gYCIy%pAW+1$=?D@DiRZ*96~XfP?QumbttttWCOd#T z2(r+wg?K*vm+h@J>V@jnHr@t5`xyLDEOZCmG=_${G9p$lW@uU@gsVax8G zY-!oI-83OpePOS+k_3-KBFZ7r=KAuKBTw4O#c{2~M0nLk2X%DCSok=ddE%=s7Ztj^ z1c7s;>F%+=LWJXux^*FBRh+b*1Zo_zac}NcixahJzq!AF+Z;fQDtPpG-!5KQI}^<( z);we#Qz`>8&a#kMf^126Z#|eqSJEb{k^pk$d5U07~D76xC^L?^s$`Uv>47LHuKuigNjEw7sydw5o z%+Pb+TglDC>syBW!p@kZbdXhT6}zR(VzcwAwT7r!LBS_eQ>f!Q{&@BNrW_+%d*bD|;0K z4-|_Hgm(vqwuP0XE_anJTQ_bK-auzf!>Q(?go?<$n=Q7HD$Op_9@8g>>RtSu6qhVh z28Oj*v_Q(kB{B8q?^cal)cHJQdTJAqqQJh(&;>JQ)q1=@u#MtMpT}kC>qU|WMAJNB zQyb|Ud|?ajN2i87YEMsyF8e$*@czj;7R@_8$7hYs8afIdHOt9U?Y63NW2zZaG5SA! zUy9e+}W-C-4gcW?=Y8lN9jhzw-+_V;vyVgl6m06E*0QCxNZ{{hDiz{zk%&UB&S8 z^;9SuN@|W=LJCSqE#lLu%)&D|ZT+6kJ)laT8Y+@O{AkjHT0@8!EnK=bse}3@(`G4bTfDk7hg5?09>)cBj9qhh>8X=qOgip zq>H#%)cF+8)@hgSe7gdgV{Jq~mi2&cn!!!X{ev4p_p`f%oUB?W%db2KCI`({0>mgz zQ=^%vWPk(aBE%*n7O-lbfoC#E+u3dR_j@<~ zb+zX(29n{50>>jI&)|uxP`n;faKypoJT9np`wejv01)RfCSn8`g_3IU8ycx_sJh5K zdEfOmzz`R~hEd-)c@W+#A+Is;x5DsZjcLDG=dtD(^DN$GM-5pFiQ6pW(K_-knhufF zj`A<$y*c3CaobD4d$O<#O1cmAY3{P3F)nAAx$uy$bL?rMlZb~8d7Ayh!)s-KOk>?r z(l23VwYQ{lb0m;_Vr~b}mq9_jo z>QV@S=x%{t`q$c4|5{s07!3ih17n)yK=P)#X&@TXWg<1$KT@@StT$5xsS#UCwZjUD%Oki{^WHJbbL8Z~ ze~kJEa5IQz55gEK??#yb7*lfL_d^?phWHy%EY(gPVFCf$2?>Qk$huQ!uC?c&J>W{x z_hFT9r8J%oJ^J3_U7S4J<#1jmHcj&|yZUC?l>v5_;9 z+!gc)gbDoa18@fo!@RZ|R@~Js?U`^av`2smL$X9|5V%`)9f#g~ucm!-xTI^n+YMnE zi{wk!o1X=VqlRw+W>1aJ2C8q6(2>uvAx#H3g(-K@(99`tCxoUR=5?eh)LyAQ7b|VR zf;k!l&5>KWyI|J#fUD|u=oClrh(1(WUkrElq#3NU1pHS>*BWaGC!oG`!-H;KEPRH& zpiLdMByE&d=OLbPx+=XV>=eMcKn>$B3oURKykjo#`yZ~-GEFepi4mN6{anMnuNGYt zba|`MDiWl0@`WNdtJ7;e4aw&#cE)zwqQYADYSEwjlyjnG3^mFwimK#_g?vU}@=9N8 z!wNd@odvRsKf8H5PL;;m)|!ztO6X|+D=c(LM6kOcwSUd23#Qj*R7ErIS|_kgeRsJ6 ze`t@dxaCsLsN1q(Ku;!!alnzYcu>*NyU$+?dHV+^-GyD3!+52qonF`JhwoehHs%>x zRBEl;^v<^_hPnH_7UbIOB&b!;WRz7E9XNOV?BuJltMYqb_yWCRD#TkzpBSjAPWmW~ ze4y5~K^~a{ztdO$JMY8)_L7iVJRY#fYjIg|aOo`?wh3nl@70l7AVE#rXrkqzz@d=$ zdwcJ`1^enifJ($eY6{+m_y6(_rf7WHKr|~Z?sLP3H|s53#*n4Nu(DEf(%lM~)u!u9 z@U!Id7Ph-X0(9mLG@Dsx(C)oa+VLvAMre4=X1ztr7`Q+|hc6$#(uox(3nP>#j~2Dx zpLbjgJ3fV-3qCqpP!YNn@x_i-ZLBe#Dvh>YU8{i+0^a1RODNo5TxrbW(Z@)H+P;%TKmh&aOI55WqOuSk`ZN%zRR`PAN1#ct}i_L z1KcI4iLsj6^!6)ZhqAAZpa7ZxhSs*V01C+W3B6;a=PS97yFFPG3;-G~7l(}@{6`zu z;*{ahFg)@Z<<&JJCcaa=vztXP5>i4j^QMgJ6>>pQ z2G!EEs5^Ov7K!Xz_XB(n(izfcIwM~7qcjG9SPq61G>;)^X={b38buB#0h!Gv%(tm6 zC5#KvE9|_dxH?h=RKoYwj?IFMrC|?z2`Sak#H7OSRtnp?V+QAL4p7++_Sn;y#6%QW z+*90NSCFc*12V?!m&-^dQWDz1?a!eV@C5`n1fP6DilyBsI6_=5zO0An`yD-$7k?uE z5$rVFI-ObwObR&$y`8a3KdE(%So}>=5;cJQbfR5Gf?9p1eY>eAirf+7vXFL=uR{kf zvcyY#9VlRaj=Yee# z?nH(p=i`W6;zBccgf=WQRUT>rGx%L|_>m>Nn0yBI;^HvwybE(!o|)vZ*^7k_w%{iG ziGwkYYaG-Y7pZ1usO5ujiI$&vKk=hTK05Ci0mdUaHKAOPBhk9^@{=#-F8wajlFcZ2WKhCL70IQE119?Nz}Re83CDi zVo~jfYSBPBgHmG*-z61~Z1OidMKLFsBL-uc$=ZP60dc7MKdwHZ@bFynC3RW-V7G!P zShtviZA5QM8|_!Lb8x+~jwSe;}G+85<$Ia=~2<#m7OJ zsjB;=T|k2GgkMyCTS)hwGJ+S2l;MCMwmIRpw>rm=I%wiDKk@*Onm(U=;WEp|M`;f>-y`HNBx{T0H0?+Jvff<-!Zp8B z;@#tuT+7fIir-aK;Ro>m;yrZVA>o$HKq1(V${Oo@BZXX?7<7>FJCWV8yH3s zucK6oeWoexpv&}5rLO_)vp`H47`!*3y0-oVJS?Ejsx30uTUNwe_32h_Q+n#_QIY!6Ew)r@#+Ks^6l99lXPkR>OGvLxBgd3-@Asat& zYmSpRkv4`Jy_&FK8h(Xa*;p-XxVzi(J4&JBDKj%?hPrQ5vuW!;Jk072mg3IewZr+} znOp_;VA1oZn+N8;ER`Xl@`VtaPY4?_3;OZ!HJCE0LNShQngTU~6_s|}1{eZWN&F~e zI;CigM&5hQvSoD!sv%NFV5_zuU?vq)@;@;_V*wC9=wC{C*u$xE@r2?rqpXIxI~klslnXu%Wb(Ez^(Vz*Ppyf zv^+NU@&A3V?vHGtcK_wzTrpR1GN^Tii*W$a+!3&?P0lCOmylM>OMSIm*hC07lox>T ztng$5iB)Q^BSaU4XgnI_w$Q^r>cN@{&!qW^`I!n*%p%@XpPt9teH7H|k8l0H+AD|VG^w3Bf^TJFGY-L zm9E@r8j9xsR!4+Y_<};35SUtZ?V>sI-Xsy2!-nF8bU?6Gs*$4Pupz${7qa0)j{%iX zqFq)7NZH_E^dj`p^A(WQqBQL95mZK5XMjr5Rf!6U{|4cq&@ejrv#u6pK+LadgtR6% zyXqplA_+$6oxlK#n(<%c6|O9tC1+K z3;qX5p?Ct-${3DU254XA`xoX&XLNd?^&RxBPG$(_g9UU62ta-oTpmj2y6D#<6i~}X)|IfQ z+V({&sV~o0T`P7XDOJOJ(QEi{e!|!idR<2!B(X`;jF0goI?*n;ruBEAYM+rDCLx3L zxA1mW`nRTVZ0Lz~6j6YgYZ5CDEH|*nbt!=^n&L@%KDZ=vfN1I4zbSuMaPTFRkD_zw zHyC#gbsB%*%Dav2*1C70kt-t!{&u8(RP8YOjY94}8a+dap;lyZQu>7ta8Zvqv`)8( z)F{+ssGn;hTo4yi0FwN;0O<4H@WcdrxnZHW78nU-E65-gK<}q*nY$T^5LvRF zP}ubz>2D5DtknX)DeB<)!G7=^DI(u2 zPvP^C;uB{QvJ^pi@%$exno?|gu542cqZpcgcDM>XL?H*m7E)&tE|ZZr%XiFJxNoe) z(Q(P?EX2|B2>`Y-PALyH9t#t=jjiuj6w{IU;JNP-JA{)9sOm0?bm(7@_zDjEq30C# zTa+mE6_Cd1a!i~=R3j4h`69Om!doqBjwf92CKBkVSXpScqGohp>k#8r+`Imwdx0WV zzbFn-WS2x&qZnpDZwwi-jfm~FF80^kOiattJa`w^hm!z6ka>_cE)r{{FTQ*Dg$dR`2;yRsUs0FB^@M;; z;K0Vg!W}**xHc*NeyU$f6>zc6P-E+y?ZPB-?QpZE3l}{edt=Qtz1zLqsGRMUaM@x0dYV;^7|@2)WfcCJ|Bvpi~cL@e?Ao7N}?nd$lp)oUE~2Aq=S zPig!#!nM*=fRnOkz#8vsT<6Og6-3Nt+8@d4*0?f0t1bhzO+&3ie;gTlQW;K+R z`^7CTN7}@3AD641=i$J_jqOo4nq^H5eH{ZU)B3)aM0;O0{Ek8)pkIq=^x-n@)#YY4 zE^=0lU~4wo`V4;LxNLH+HEPn$x@6NSn*qe5ZB@3mTMYMq8$(bNdu=2Z_)*eK)z2?2 z5Ydw*`GPYQuF^*>GYFLYD8AC`J#*TH3-JdZm~dwa^!nYr0@Jxx9OD`btCF*DH8J2W z-6i+Y0eNEdP|2wr6}3Cob0YHNf|6eR(*P@tbohNIQJnco$xSQZG<9$3m>CLnRRe*T z6CA)GwWd6xTVb7v{p~c3Ul$C5{k=TZXqIW1=qAe!gjvpg3Q9g- z_>C`)312RHR6LsLxG_T|so{`~+vj14_p49%2zysPc>v7W20O$Fb@5%lkNmw{i6*E=r}l;F%DhB>gE&i87xN(LB5LB|0=@!r(Hp`RwJ?ed@BQhCr*LMR``P z7vg9%Qyam&a`GAJu#RlJwN`68AAABPutgNU1uP`cI;u!kD|sJtb!jPYy?xqek)zsp z55vZ)?a{Z+x3)qpedR#P$i;b4+Zt;`R;>V*z$03!C5Ln5*Stt$M;% zNX>GDeK%K}B%z5k9@6*Y(JLh<`ie&@R)-!8U>Zb@yz@@@Pz_olx8C9JFua!Iw<=;? z$esm!_1TBU#XH>nq%%Q{KXJI1zTAL4C^Vw47O>(M)7O7&WYG1#xc%($Z&8ch72VVF z`14`-rC$_@_jR>{LtN(xXz>18j%+=o>G+#e%dLs$uNNi_B75D%cvW165r?ke&$}#> z5U}hNuLH0#Y#cwdl;noM9mM9s^&;QKJw4}`6oKkIyS5ZJO*|`8BrnI9f}^^bRP4Fb zOejuxGqo+NMG8@?x7zXE2Mbk5{kdvu1I$;`_5BQw?@*8*2j@}gEVyn z2Sr^I*KZSuIj^`uhJ|>!nk_FSTk2Ev<4&6GcF+}kkU$q2l}>S-$-al$`})d>`hzD# zOiAk9e0lM20D0x)*M^C8ql7sg9jPYiKhn6VJ~CqI1-&>rxxx}$ma)*4nBMK;!w z7Y%1~xX*9~T$MX^FEn}E3$QWrRc?YGTh=$6c0yv2w-)89ZZhx@ADIK6PbNhW&@&^N zrtjjm=NJBFaOew#GT>IzIMu0WQTYjTt}bfd5Da?0DSXpzbDj7uYE&sKrQ04S2$9I4 zA3#)ra>-JM5o7U-#~N!T{ko|CT;a#*1YeyWh}Om%Vq^aFO6Apmye`5g< z>aGY;jPb&WwfLm?j1ol3-8y!@X7i~@jP}rzUe}~9-FMXx7-fTgG@f7CyJ#(-o_V$CLe;@^zlrsxOk&qpjNk|>#8RJ*oh63CuK6{AP9 zq>8bpGp~-dN{v-JD$lHc_%HolHv@tYuIL<)P&+?sWw}Q8p)Ic4zAVftC#3)`TiNaT z$n(^~zISo(Ja19oy{f=rp;)ogb-eo>Z2YoL{U_IibRFmm`9B_)uoz@cBC=gzA$=|m zfvHlCR_D40k`X!+^T3O{Wa}wISKlV@vz+7fk1qjbNrTj`Q{RtVghV6nJQIp#qXRg0 z1!xSXf-_5=Sv4nHUwqE{FjsWx-TLE`;T>ukP{#1})#@W?*aI%4VZLzgG{|Nwr)wMk zoLbwp_OSfPkZ|E_y$e2wcwdQcv)dVUeYB zQ2!Zz$XHjUZvW8wF9K(lb4<%~pt2h%7dcrI{dCD+>n+-LvksI;yH)Qh=8`jr8@kR? z!gbCWijLwx3+_Wr0V>=GnMsncCPdTTXToJ z_G6re%0U`VmN~jlbA?lKr=q%9Z(OAcDtc4GksXUAUO0*+Xy3TLqXH*o&)NHHuE-8Q zUgU4~;f&&_c#VQEr*sg~axZ_Hr7AniY)*DS05ueEKYD3_CSyLWBkW2kfnT1n{uSZ# z-b(@Upu>bEgP8XF!-RsSOI<&J;U0iy>HvboM~tsif2H`fI(2O`-&EG+vdcLV>=8`40(DUM_%#U%aVGXCI% zljkF>E06PV2Ux6^^K*Z~B9Gq{N|k)ZC4U;EvR29&=isoS#F*?Z?=D6HMg0tEW~^+ADa{2{Amd0enp;IY+*ScGPLCDU_CUa~dhn|v6dw^(g{Z5hwuXQnI@nRI@us1`8 z9lIws%HBiQbGqSU;}+}`iCaKmM&$Vu54CM6+~mt9sx3PnQXJ#Utk3PTBkYU$kDIb7 zHLkT2I}^%lg5|mc>K`IBm_decOma8V&xg%LbI~&!!x#1P?9~oVK3v1HlWoV!&<672 zu8s9sm^=u@OlZ)ej$^Ef8Wy#;0_j5S3XqU)_m_*K=l zd@ifuvVp6tVmh>58N>kEe-m}mPx?n0mI%k1RMyU)@;-yR0#3$TPgwEq9nuL{A?ACG zZ_AnbEkT~l4{E)Q(^zw}M!Z5%+)an9cle6Kr!e|y&)y+wP^R|ky>%YK8Y+vg1ZITPjU^%K)JZ2l+iRb1VWukt}syY^+bhj;rUvuWiI*)`YH zkM8}sidmKWp83UVppX}hg6cp*FLpX$6fF9uh6}o~EBu<`o8o=Mp@x@~V5RIAN{JdUk6~XJm*~)MX_@&%#5Y z2DSGuiTSdMDLQ0-T*`HEEZWe>VGID$#9fwszYVHgh6Z?@?rx-|Gv9aT^K^+kw!l9Q z-Wu9=lR&2bsvwJA`d{U&|L=coq~U^n>uA2Knc?%Sp18m1QA!=G;BiSvkJ(feVXSTu zgpTpv{=(1|7*#srPvnwl3`e;I zwI*~zWsw&UB2BeypgyOMIcNZ0+)>&~lHIa!OHp{^hVaKKlU<`U0i({c90(Beu; zy@z3&74v8h)wy0nCnt!Tfp~TbcRbagBnap^nP##kn!42Wev$MXLgr(OM&krNSzLB- zFZW3vF6L7PhfT$7kh49l2kr-=p^yVN z-&KErUMxwv{ZJ_R1UAHH1V#*2)4|Fy=xsu|>Cc9{MplM|SdX<=3+%ttcC|9(;i$N* z>B3e_m3UHnlqeP|u{ZycTLTR7(H}?oQ$BOEuwx<6a$Ru}yU|$CfX}$J;_gF)lt(Qx zh=c6N(7%&uJu+Cs?;9_@S`d5oT>YWX0rT+Vo#{F%(*v767-$l-hPB~L98i@P5Jd-N zdkt)4#16w-b_5ScR>dG^8oVt(&j@h7M>lt?$$19%_|_it3)#MR|D|s~fVlM{-Lr@f z(L%PbT1##6xK;}r7kzMx5>7TM8coh8Y~-}}{`qcY7df?()TB#E-Ii0pj+7OP??j)7 zN3J=5Cg{o6tDFa$2b_PRU00znbdqkqGL|Gm=Z3R@eUM)>xb9G{Sm^rv*r<*Y$1J~= zv-i#yM7&hl0BigS;4OcqMIm~I2oaueYA^#?R-E_iNamfLZ# z*n>m^)Ueh_*X#W_Zg+RTj2Tj;Pkym_Q;&2+oM)2Kn@5tbN3nJ9^fzadGMbuG{WQ;e zh>n{+d?v1K(e#eAqu>SnHt=eNW-1+^84NU|*lrq!XB9khp(mhHm_?9~X*aJ88uRaW zIPXFPfKs3jXVh@(4=DeK?m**Co#gkEP7q#oJP!kQ+RdsvQ(hsnZ^(C8=Xg>ye4GmNL*md63fZP)c4yUslshtFZeLdZtj$G$*{ zpS*%rs|!_0L{=KS}7$wMZU1M6?QS6|Fa;ARZWq{|~(;t15KK#VIW1(6A-u)|osDUdN7X@k? z1Qvx$PQJaNn1Ig;p!ioiEaVmH!rrXPqO~V$8x_cffCQ&mf{8YAp+J@zovk6J6ohUS z=b#fAWd>kdj?MiOAEt^INX6Y%1T+rFvyS^0l^r055vyuVoS&X+>2LYTfJx zfY#5vtTP-higR+`zzo9alKaJqKAaWQgt58qp*rh;`dTRU^ecf+TTG6la|_}xoUHqh zV_`5oJ3mY3aNY0mDiPeJR3bh`#|IAn)9=)gUt~)Xw~HM(XdMJ{Bij123xHH4xR)fm zxL0hGWxwLa`v3-nGZdy!lZb%@RE#CyjkX^z@+$bTNJ8i8Gq? zct`g>4zL97f|68QfQ(?1F5-sVgZZH})Mm(>(|C@WQ|Phdc9Q_S0Vnc868=mKTgx-3 zFtBjg=}cIZCpbU1_*0vvEg83|mSq2N(sv`5xjoOPIlq>9Qp zWJ-YOrtnc)AqB@VIweifCje2+Ok8~!iHZIA5Al{r5?C&(seQ0ncMNaGW1~13MwvSl zbL@e@M_Dx3+5p5M*v}nV-vEb(LBkIIf5QI%nOR-$QpvE-xOQ#F<6?vU;JRUzu3@tP zY2}&Us(j03^!A>XO$=Hx3Vd5(N+)He{}6Ghz@)i8pdu4%HDzHpuWWY9eWuD*L?g-PQAv=yZEx1by9oQJVTdB zf=PqDq2J^;)6dqA^~~L!awjT68A8UeYZc+6vUneNyX8ao0%&S#Z7W-5?0W3q+~k?| zW#?Gr`XW&MveoI4`!GfQ(v>J3u$$Ncv-iihw=v@H(&lqp}JeV|d-Xe!N2 zh2{9$Xb<6*T2zulsb+~Gt*x@&2&c)IPEZT$^o8NnPb3UtbX>Y@EJ`omd9k(1R4wqG zQ&?b5H4$;NW)7zYhm|Ixy})2ev+d-!n&2$04CBQ2h9|y{3-!7cxi^|Wjr+XlLv)Bj z)`pl32PakIL(CjMWtiMmnj5`RWs_iXimkOED>dPu!lQU4Y6ekUff&|;!|>1 zkhLQ$&cnsX&k7eFOC1Z>zAQxv@j+U>`L4X9)*F(W3(OQ1o;N3|o2eRPd)PU}hs41H^`fb@r4`G|9co|C zZ#c=WR%`l9wr1)iyJC{do5}CL4IU;f(J)qws8w{GX3)>{}RdY;bd3f6S0 zuG#k!8@+g=wv>_1%1RZHlJE^RoxA~kbFr36@s(-2&L?~_8T(>Z^EqpXvERhGrYT1| z$Td6nPEzA?$Hv>;wIl0m1;*9OyaGkiE~TETi79cv@YYM@(@X832${fg3$?dBU(~Ox zW98FKRdZNxovE;k(XbMS!(O*8(mvbVePOt}xdk=lYFJS32Mn@axE6F;#p`6;b&EG( z_21FZ?(1>cq=rG_vhA85V$u(^4Z;n?bZetVRvo-&dR+bS-7E3SQUs!Q-7(X7wrizD zskZj5?vDO;v&M_*M#sY9nOWI)?vODF^jfF8{y>!4qn1;hy!|i4Xzr%^0_#|bW$=CZ zEZ1>b-$nAo6AKPJI*;N}akr5ifw-Mn^6N(nA8sQPUAhVwxHwS@I~<}7=qG~+~r&sx5kQr_C;@kia&>#f_iRhyyh)mkU0GrDm}Wn07Ks#6~K8FxQ# zNhmw`!R+nmx0;xsk~qJwmTm0DYRhy4n=(#inzwxdyjsI%!5XNcSoL`!0@cQ!-2<|o zrWx6lyqp%P$q94i&+f*J+cURYoO8`0X2M`fXG!JznENt@7sR)IxXO8js$ZhNo>60M z$fY-o?-$vD`RkpiibJhu=(m<+PpHHkk!pMPzMEgBd_4_(xnTmCL6VY-VXc!HR&qIf?J*x|V8X4y_lvnh}yh3Y2A7md17owe#OxyU9$Qujjflh8; zYyLbHeU?*Hulj^}k=3%yY_~Gxf%4gFbbq4Kp<;jX$jQ8B1Zwrub{z{D9_d?~3V(T< z;rEcUnA`AZdf}nTv_L&xnK-UE?(5neg}#@=n3nB|-rvR>uxVD)!#%ZOiaV^c%pC%cx+wCe%F$4YY2tPiF1ga=XT&QE_MR5AAxfs3PrCvM?f$e%orYCT-%#czT$u*_PSfz#`MVQr4c23I^0$pi;bu?!2*TF@6Kh0Xpj367T_fKecV&<-7zH<>~Cr;jUN1_R?r)=Q0FNM?Q@DXFp{ zg&M#n;^Y;Ph1vz3vohm{97{I-sNRM!b-}kE3O=0wkhW4JzAt{? zRP5uC#s}%U4hg!)74#c5eajI0O+Q`2q_(Tk%-@3FrO9x9gN~h1LqFzcvs5yOn(P=c z+qfp{HKt6xeg{MW{N= zt8%@Nout-8FDqS=`vPtTwWj0z(VCCP4ycvvYv4fMI8c06g2;9iC`Bf9Go)K;f_}M1 zj!h2ejy7`qvdc?v0NQUnQx{tkpgudkjEvw@iRQ|Wo_7DX+@~qi$n8<1XR=B`4cq+% zAvNPTyEGH@3ZxJGnaIM)LxJEsKpvT|sE7tHV}Ue%8g+fpNP6%ueQ{?ul;RKgitsMH zC6Cm0&ttckp%)2pvQWHnLgX^1e{7#NhjqEEu2Sd6^3?4CmVySEBAk8+yfiwfQ!W+4 z#dc}+=faGs?tYas|A1JQwwfP5L;3|YPBa4yKY zObLU?oV50FHNnf^KDxj)?n3Hzb9gD>8Q6sgccR>gvlV~RAtVjiYE)@bnY*9lS%T6^ zKIGP{?x^K?e?dBpjIwgK5zG9IOF z%yzcP(Wj3+i%q{Zkh~O%OnDVTQY=$$@-up3RtU5OMZq_v5o^w!rixi;Z@`&=0eVD9da+u9bqL#?a6=W!(G4W)PW`8-q;`%THv(b2k?iFd;FpwxqTcq34RngQUuQJE=^4E}r>2$IY^t9}Zrv z7ez*_IO|crxhu&FIiMS=T|s$Mhyq#M@y!2F+9I3fJ<|_JMe_?8Bvf5$C5^4A{i+>2 zwmlDCWz~+*mS)~q8){!ub$7$AubQM<4JjeZ(e+WzHmiBopeE6B8*ShrXJJ?wDw zi8r#9Sr?pSZ=40rOL)@81cfX+m#Gqe=p@beu?)~fKZ@#|CNcT6&;TAWP1`E3`!P>1 zjv|N`OJ!PSXXg6**b)DHJxtzSOzU)QvO-U=m~3TOq4c_*lNs1m>RRuMJ*xD7ieYfC zN}eB!#|U4-aG839VZ37cUGqj5UbGj$zIN1~VS{O{$z*}zNegYY@CKcwJZ9p}w(LRA zYctH_b4}H})u%tt*076RoOyaVpuergHUM8ZrPO$<(fmo`@Zq2!7H_75ml!^Bx~bJX z%bXE?6UjmXYL17pI+Dnt050>#Dn@oja=L~BMDZ4-m8ob18tVwqf=?^-oYLS!AC4Mo zgB35d9BxJDC7Jrmx(tiS9^HDM)RuTAO{9Fj&!Ir5-f$IwMG}hf%pci==t7!(p{_OB z99~MBQ}O%mrN1fCQ@s<}@#jFawyeJQ15RJrgrv`4njajGb}YOM(S|Mn_G?^&F8{^? zRB3#x$CZQB`?Czq*&4+m*DIhn%n5ngg4qx5k}7_&g$DaGHq&N)^{Yk0A7KPWSZ z+W2l8k5&H)&JATpGsVfo1>KwycGi{^`o#e!PM>A|!sV-UGH|2VwK2j0*I2zS3W=aJ z$k4iK_|y>eNVNeH9Gx^Ioh90Tl{EoDMIqEAs?da7>*&VIhwLVM&|^aZ=;4DYK>kGw zJAQkw$T>nyqV(-LYc_PwBa~{RC!*v?iDw0nJz8QbO?egSZV7U-Q2P#@otM#`{Z^;r zhdTH&^Tz;U#PbUzS{~@(n9CI7a<8~uZ4@8_os@pv?N#id-(%hMd7Y*cJM zf2tWjA@YO9hmiO&hwxJ(aX+0R{m>*~FUSLTAT^IL4`Zr2ej&UK4jz%Uk5ijvBq!cx z+>YP~EXaNzv;K;&v3iHK#iVhz<%7FIQx|=0#F(s9o`3F3eyd|W{jA`Qf3}nDaZmFy z=hPPt2RU>-C3p2FLv)gHv?B(Pqq?@haf1(LjZs=jGfxA})h6T8YHzdle#+4Eh`(p* zV*OB^$1y(FXzaLEP1~O5Uw^L1PS14N`6~`a-g_kZmu+V5)vPLlnY!9{=TDYV^lq1C zG^0^!bCAg^OUrp#B1Ce#I{(v4F?{(;R3f7$Mv;fO>2Flr76%*eD3|#;1LoDZ)Sf#R zTi_7#&u^E_3EKH|~5GjswHj5`J%+F`eho#AV7c#00|2PAnSnina zEC0CSkdkyg^(mdW^2h3NXTv3TVqt$#_OL=e&63R3qxdcO%N`fmP91rr!0=2Rq5S|o zOy1v&I9$?NoEd-8(~qWFwD*?5N#V5MgOL>P1=eCGQ*>A;3>ii$1du18&O7FU;Qys5?>RhBzrbzDl*NXg1%ONsQZ z;cm+HKuS>CaZzvbeYPg%?b_iNt0OyPF15=A-Su5yE%>rNM1ezahgNmZLWT)eUf~bC z*95q;QrG|M@vdV2z?BkBf9b3(6YApS5nQ)@+jsLY=@K312vc7tjl-hmS9IQX6?rVM zuA1vAdT;pllZk)Og_>q*$ydkXb0IvR zuj&GyK1Vf6vwg};bSp?*&ybZ^?qOD)@b%9Db2g^*;g#qeeR}vn?Ra(PyHk^0=wdRB zY-ie<05)xN<_J<#ufOcw*V0j0CD9d5-Z?aI2tqO|To#FXN9xMV@|#rp zfaM?q;2ketEp$kywtTVzeit1O@VjV?ogi1o8>DTgC-tpki?7F*95CO}aWdh%2A`*f z__q;#0ij*cjr2hf-LZoOaBC`UbjyemPCmw~%zfqa>OIP!Sm+dxC@7xQ8MRWTE4B94 z3Xx<82U}a=LD~~FC15gyrOJ9(X=)<`lgU!_y61BB3u6_qcSck6ZBC7(1$Rw!YA+vHC(b`Lvr61qi2`eqO}E@ zcpvX*5XU66@~D|VQsBurq9Hw}GrMCht$1&I+nsRl;~ZyYn@&h`YNYJ_G6Rx~1ZC^d z(|KUIfX_4W?h$nL;u_(fD%6qxt)#}* z=wGUlou2I1mA(Y=72qd_I%sqR>m0Pq@~!wR2ppMOm59F*x{?RU2=jtIK*_s<_zO8BX z+6kZIo{lrww=Ll1!tg)W3cUbrPNx2=0iI#83RhEb|Iisgo0M)+5WF4+so4$GN7!hl z&SsTkVnay&{clJcPNJ`*4a|T7mv0XLH6_spUAR8|H(wx8PjK${KvS+zrAjK4$>^f~ z9Wpp|kF+W^0{YPhAq)v2PzBDof1E(ia5y)S3OI>m7MVP&ZS9xOQnEGuiy_N9e)r07$zx9>TaFuEFCRM6(#M%##y)1ox-Z?SJ+te$gNkMR&8#k+ z=gpiUGN%+9Gu2d@C31Rq8b?>4oeGK^9p<&`SOn^iBF<7n%{wV#kLg@OV5c5t4z`D4 z7m-OlGHzkOI%FnZukxznD8i05Ak~TQ2z{QL(u7EObw7h64@k$;mR}*n6cisrOLMN} znNN(#C{^22En4#!{mERMtCY8sdSpZDiwV-8Bz-wt1HO~&6K);Jr;i^t5e-eU2yS|EK~+Wvg$i!{!u> z><*yfwx1OF=|NWtwbBFgHy^v3uKzg{&zAt5C`UO!jF75f2`2-VSSq}0 zgDH|{9`%)yKu2^^a*<>FLw~Dl#R7>Wb!X9pE1(aJ!bwWe3GvGGzrkwY{5%(D$3f6s z_LZ;id%UqkM;swG>l-#+&8__QP2>{EVkhTBZ+4t!Q~7W%9&qdAi0M zjtx@&S&HrJHazmNF%>@J0;{rF!|)-OpCU>kY>sh(zZ^r(|LquZ(=77xAy1A+3JLhmJZCwoP~nPeqnE9ujMuYjfAwvyfHkMEb(m%_GNKQ2MNi%xVigD2WxH5 znU2kQApQb0PrsndGU!&D0I$GKc%HbiS3NP#U-%`6d)*B7=kuZbzTLC;XUy6X$`1vE zWfweLUm%(Go1SS~!}=6fi7(nxuhvDBhI5l?$&x=!C9l3m73mE8jLTKc->P zn@m57tPT6CzO`a#@|(}6arOOqrlOv0RRN=!M)_c`@{Zg@6Aqy8J}*iCZAH$WZAHWd zQ*+Cql%3s!`JYJDx{d7azgo@n&}{KFAoCdX%rGlLBIe(|CK6lx-I`O!|cUj&>9Gupz{!=#-EuLXl+ znOS)F@l?o^ocM>an~XK5^NA(kCzb_D87neH!8FbVl@U7IX^u3y3UakFLMu682z#)a;*)Q}Ean0pA;96SNCAGrmABJv--6?-Go{;$j~}`Dgh};HkG)hxlJGsLLNJPW(3( z@LzcOe_rzci>LUX$MB!W@c(@*`Ohx=&o2DWE<}<*fBuVZvH#gY|JgzRv+khz`?(I+ zu0%Z>keS=-9jv?7z+nI3Dw*QL0t@p})F1Mi3|;>eH&z`~%NtpHHzDttR{WWG^@O~S z_6Miq)~z1U(AqJ!%xFl<^zh=?-}@s41h!@uE-dL6FPd=~nYj=;Qc}th&>kG@-R{^T zxXVx|!0v(&H}~Xs|5l{pZodZZ{W^CFi++vQqraco`i~tA{?4-h?;IU}ef0Z3K7~+m zA42;IeJlQOUH{v}27lf8=Rc3&f4Lvsz{F5l3ShmPKJlwe=yN|VU|_}Sxkg`gW27%K z>}1xu^y@G9-~U=`VkAkQ>(%O0IyyP%zl315vD>=oo@uI9&~A&o`ZDvhmoLBI8{bempvS&Qa--YcA-R+YTc-;`wFeoh?NWt1 z=>3}kLA4it8lLDSUCQv+pMP)3XUR|n><|#ZG}f}R@0qo(W7ZH*3kV9fK(~KoA$OSb4_7zvc-tCy%h!dSnwbmZZE$9BTUe~S*?`1Hlq_k0@} z!gu|B98T@dpQ?QUX|>Gm0sRdYaPMv%;x3=9_;wrjtIf>~R7+%gXiMRhD8}=geJL~SV zNy{`JJ}Y0qz=i$-`d3%uuer)zJofQ|Hy&=u6utav!O~~@r=IB__BJ0~z;OKU554nk z-r5zRoq|^~OW3Y&gW(UXnXV4G>*bi%3GU9Kezv3sK-H=g!|;G-;R!cUb>EqWwvGmM z0&9IydI|iRn#C0T3((o-1n<`A+}7z*L+NejB_ZS>!I&OUA#MRJem3%fGH)bqOz@5n;O z?(Q>bV0!x3;7OA=P)0@uw9#aT18lq-%=@q?v^LIuQ+#Jnd*^v^KUGG<8cmf zI`*F#ABb;H9ZAs=V6-*uC5f9$y|}s5M(fJ`!<&oW4xzVcDu1pwA8xadTWOH#P?`Ur zmw(P7X|1bW2ibeLzSz8QXa>SMP>`hcTXtP;{}@)_YE87_fL(1}BQj4ZR5)ESq)=tpt z+>{-^<4|7~6V1bsZVfvb0(|3tf>&mdWQ`l%HjUH&BDfn>3l-dLPQ zkuFD@bNe>-*czH_TXH{tGAN(MqRj8QZ+A~26LJWF#;w|;xEUPHrizCy8TXl^lN3GA7a0%md5LA{*fT?7O~NKi4LQPo^`u^`|90Smn`Fz7r$oMfQvSGUKG&`k z9e?ePDZTkIz1ANWc28r=ovX71@8sBXBFu|#er{l1aM}?#D_Z*=ls;RK!hif+Z;Vu* z@$lACf<$Xfz!Q?x)2b0#Q+JANMgVJI_gu)`rhaDPAF+e;D0{x{OzGAhcj z4FenmK?OuXkj5aShVBv(5Rg#1k#3|LF#rV-X=F%YK)Qw;N-1d=dMN3EVL<72pYi*? zJ-cVmp0nrd{PM^f&-=uk*L~e4H97|+eaCNE&K^G605G9-$(YU0K#!>V`~;9SUcz`~ z`A=WI6EbvzoEwi}RsiUrm zo|#+HE1a7C`$gs@BcN&cA8;o0utD1a6G+uX9y$!jvEI3vZ=D{;*XwolC4Rf%yRdzP zH&bf&(6ZX4nP3-UrNKfb1|8s!1%QaL$_39XG5Z1-$XP$unlXt5G%$xeFe&L4fXZ zf=!K|_jFm!{3Pz|!FmQ1V%U^8tKAq|99q!b?O z8rw04H9Mr2ERQ_{C87!6dzS(Ye*=tUqmSq@k35u(#TOD<@L!`@#$_n@F8S{+q~d# zw!yoIxDLQI!b~pKUR|tZUaV)z9@Y=a0Npd|^%-&k>PyE#DtYsVAst)j9I$WRC02H; zsj1B$b&7e3s%VIWCcVQ;h7ux-q$#TJ?{Vhe0;ry#P4{!QwZy)|7Zib$_>Hw*Lle{0 zYD5E-`t9nAoF0OlfbCw&kS={ARTkR-02Q~o+W{;$XsQq0+OQ#au_VVD?30~m%sc}K zc?bUFO@2`9Hq^>G*^94vlAB#r-ykm7Vq(6tw;FEm{i#!Ty(`(zXxCpBBJfq-dluU4F$# z5DREu<;&D$Pq9FY#XZLJr^ac>?tU4ZK{&+oX#cSPkjUCD79);Njf^)oz?p^-`QEd`lAU1tL#q-GWXOr&stZ($PZQ9m-LZfqxO9vm!`=s25Th~b6ImOI7yGnhSA31A<+o&hZ#ak2Lc zJQPSqK#ov}P}l;8WGl#sMU>O$LW#Ob%=rQ|11;~+O9i`?MV~f=JK<>3a$QXSlS4(^T}5<-#ohAV}-A| zcC8HrfM3-to~t=TK~NnPd|F->NFNQr;)uuOnV&7*W-G+gf3H6hqfns+T|znWCWp+% zvltJoGBFR~KU1LW?@5_EzFP(`7v)ZY+=E+VD!uWNoGo<*kf`A_FjC)bRVDWgcO?cZOCN+uwFVGrsnm3uxev$kI11ehWjcfV4 zVt%or-@HRjmlAaF8>q?3hTj1EY0f;;a*AvT1Tqp2^}PUp*ufj4{}g9`mFL<(a?_@b zTfhj++P7)TyDa{Sfu%W8!5SwhE#ZvsTa!)bw1t;^yK;YbHS+Jnc;q$;_=*&l(l2=);NXCo))03ecO(Q#vhm89k?IS16&Zk4=>l71zUnt?~;6Em;l_!wMl>e zT+`Q=WY$ZUmT3GF?e=k77>Cq2JrHWXT4GkgeqvUALfrpni#AmLKaS)i#<`tw#O^k| zNwMllXg>FO;t4!kJV&%X|Hi^X3+`n92AxGf=;0IVf4pa4!C(Dv zh^;sucvCY3LSb)dAgTBNW8Hg?I95xhe}ef|MpwemU1dNw2PS@PaeIO+iT}kGK!n9? zX}7V2bV_Lo6;hi40A&C3{Qnyqfx%{hQ9bJw|9?IP0EsoFaKa>gS0qeUvOr|R_CJ9G zi&-8X9_Ojb6#y{u-Bqj6sCic7)hdl|<8+_p_zB5V840bGm*D!&|6VTx9ICNExiUJ0 zbFy=~^U{#S{GU(0S|rh&91-6bzg>s$U;FPk?ZBROs+~sq;LsrM6OGk|9Uu#qX+;$e=GQX8_)4>z0lm+9Q08f9)?f-@rs9B5f&0xWQwK8U3250Ht@Bd!_ z(-w9(haXXE+9EGSsNw$tCjZ|8pwtw%w*MCk_R2&x9VlT61sKq3JJ`4@Y^nGshSG7kxh#`^A`GnuNDPY&9l5m$PWU>U1&{CD zo~|AIHwz!*Yejd6yEF=d#YVSFs6xrPnPQEpz@uz4nmZddD|^A3ui%Go(b;Xx;Qw~% zN&rfU%eZSoAsrdGebjugkm56t5nA9Hz4!dwSj)PF(N6jwa57vDMsSfm)6pz4Hkz%( z+PEK#R`$VMLo-t)b#ntDRVK1`tJWj=j9w;`etdU_xb6!d-y<AW4d&xB+jOzr3O zopWWG&c@re#5duk!$T@?uFzy?5zMLduci+y<0pizJY3ZwHPv2(sI}EFFL*wC;MVu& z$5lnbZ<()xZ<^97F=ysCcImEi8-Mjy-vR$MdSQpr4KM3D<*TqP281aL4PKfzd z^V<-LoON~7*`{81AX9th;j(3d?8Jw)pOVTIlPb5|!?3S=7OZ=^-1K^U```XrT0cC< zt}&^oA&p@1;d(T|Ft=nGPwV+dPX8m&Z$xYT6UpXH=)l!|Kn zHz+cO)W=BGzvL4wZo@rtBmac#it;)UE_G!9BAS5EPcu7B%c>rXx(se3-4qTsdoUT! zTwP?~p9mwoGwlN@N`(@!7JkOYOo2w zeEb%~(2R|YP}l4y0#CT3Vbi#pzRgFpB~9WpteKklkGv`Oo9OMi?LnJ{gm~6(|Cf6+ zLHzhh78RV|?0Y@NLXzKi-t5{E_Z^ZFQtjRzy2U8@?2+Avj6-4%na=)!#}5KH2@ob9 zqQy^2)N*j>0r-8HL128O5L7D;5!x1!=a|0rq(Gh1fk(FXkmGu1a+$h@*mlD&Pu@}G z6?e27x`s)S*C$O&mr%?$>DdblyNnynt|B+6GoGRX&AUR^QMp$dMD2Zo8(cMmT^f^ z%{py)D=KEpElG?g48Kji9fj=8eVgSciWLw=!qSnwh9y)KsABwbq{tOfuU@zRzR;kphM^>Ahig1(Kw&Yj)ed#%m&?clClnW%14l0mr*h_`DT4Z!)&I1 zI$q^lgr#SZn7GBs5~jGRtH!8_jG`Ue!Z^sMvHtc7G3dlk(Rfk0G4bB&Au;acaLS%m zCOK&n+z7ESIiJ%mW({X{=!UE+?cQ6N^if!}?fk}l{f|EX^YbdXMIAn70c5Uym2P#OY%zVovFkY1b5t3IT1RfN+nrbb;t@4$? zf*&T|1!*uHf*5%rP4y8GW-tyqaL0(#!<&erTYUOXU!0`eCh5KsIN?_)w1V8vSU}jx zm04+b^>XNrl0$%ZI6?JbQ55)e9c;2Z@^!W+E+HgcjFE2wTn{P!kWD?*pD$d0wYBrv zwS;}LcOGym9w>A}8U(H+Wd<6F2k+vAlz~VFGrhv}2O4s*f47xpS7@|leR)sUs?{uh zo+UaC1~_Q;O?z3nDM4CN6l(y3GJ^Z^OkLhMyOqq1@`dXO&p^s6;49c5h?q-IfAt&| z7#^YGJlJM#2&f7JUmu2Dz;N|PP`?zUfMjHOV$QryMnNuz4c zCHmIJkzxtnomEWuB zt&mYjLxKqQ%hlVLWK6SR{P;=7Zv$c)Rf^K=nq5e%bfuxEm`NV{2hm*%u3N8So;PhN zP?r3qw6uq$aBy?Fte%sGKFPd+_+}_*(*)H|N=g#GI=qNUk?nfqK%O;`^-t390Fi(s z7Vvu9Efg9pU$yG$^TnqvvGi(|KYN<(!eG+bA5}JAjUP6Smi8WCv@+a0@0lH#AyrQH zd&ApisZvhOGwWUmQsqXGygu~FRFWccINFO3KiIr(D)*LEhU#51B@J>5Tk6CC|E-zs z(p*Id(}-Qha-Lz5Eb_B>;F|GNB7)iy)1IJ62pOO6p;}l~yAw<-dI$Cn<9~TyHR#b0 zoh5ITi3+KExL-%oBkbOWf7NF2Y-FZCkW76U?7SjM>7KtSAr|Agij|BbYrh9|uVwnj z!OZer_-?d$^S*Hn`>+uA985~^<3UL3H`^ij8d18ul?{0o315kqLy7CjCjLpKtMe)a zAqgZ9Pu`l@A&h^)gIA)%o|-Saky8#<7djoboDS|^+DQbu@2$V=CxF=Y&^laS7b+*v zjoLt&`G`)OJ{C}t%S326T>Qz1!auNF;9AGJyTaZ%JD}@oe`i~`|sU-K$ZKF!Sey-u*_rj;2 zYw4RZ2rQtv9bbsmah^2hC(=1QYXi+3wNC>t%4~^?zcj8_h4(tR?Yfd@wOPS#QE2Ls z^ebG3c!pu7BEbU87h@0=vhVgmC~|Co~8B8N*udJ^}JhB5W&$;!^; zcRsq^k(mz5$Q#D_ZCfiFs`~UV8s@?dTw}4OvqN$s>Hu1LZ+Q=FFjd zU)NIP*V1*D&3nOe)6@%v7Cu+A*XJ}S9p{YECvYLeeCJ?ZW_<_I(v!(40+T;#d{J!2 z>mdclGbUn(IXUR#p2R86s)&Yp4?x2hTgTnML zzlSF?;O%fS$$@dcfKoHMNFXtZ1&^%DApA=W{`U%8vOE>9Nk0hINFG_+io-&~uT0yl zdshC6^U*Wn7Vz3|qIsTH*ltrz>G1_BcK#%8C4TAn>{T%KeOE%+9DB#zqw6vWuu*p9 zIVfMh7zb`uMgT@%~S}(q(PHer#?S_IAaXQC>V7}hN2omTi zB2;t;!VmwMN>Dr$Dx8Cc=12|>DeA5jcyQxAwjAS<${sGYS8PYS*5 zmB^YOv(f=uTi)rxQ`C3YJ)Nr5#KLHegfO{A-LT8m=^C}uDpG0=`8fm#P0CC}P+wLR zl68u1cZX-3Yr`MbOIox~?JI`s%oWd_tK8s;j<(xWizw^8CARh=z^-80u*V8hE;D5r zuVufferYyd4tvF`78}C*1M4iu+K~y<;>rlFu88NAOR}Mel#nwl+LSlN9BbP#}E+-aA(- zD%EUgC_{Jmd$?vEC3&sEQu2B`ezDt|$xc|teu!|FED289Gpw<35HR+*`LH)`Bo|KS z-eB_6xrhj+N79sL-q7$A+L&@+vWnECps~_#Zf{wU|3V zYtr@>J+aAh^sEPZ~(+x=Y3?zPYOr~8@@+X^dF zv52Zk(;3(~mdEzB-I>XFoc_AnAEVkfg0n>(O&?-rL@~mJf)^gLbHP$_qi?VNZ z1D=!8m&Xei;9qlVvDNnFl?wI|+-l%xI z8+S3H`!3{m2`hsgjZCj!<+oafi9}3zgqML>j~!wBS#8su+Hgq2{bA1}S+&wx98DmX z^Cuqu?YL*R%&}x2*0~qs8zT(^-#*bu<2C=W@@a571yjyeIiI|;Kg$!U!4}}^rrJO{ z1vmbjjFDZR7q{_eN#x2>cCZ5OTd3iUsH{_XH_-e21*WFc`dEavdySRb?u9SZMtvr_)F34mf`Mut3!$R)dF`@ z3|td#_&b>#G|FyhZolo2U3DmXp<)YkY0C2nO>(yb)6tv*|71d^*m? z+U?P;l;IW8SU1v5wQ$1#yH1XyyE1ZPsUept!1brIp&Eu~0UQ^SAqVHfg``tA`?Ohd zSQ4whi>DY-SFhs8j(N@53Z?Vxi@SA$m+WyHv0}O7@z>ZpjDQrXiQh~rRZFUbRbtf) z9ek9HU*?)E?QD|8Oi|1>2C7U1$}P+9vA@(v&p8D6j@BT$&+^H(<-6kXQWRIBie7uT zEH>`!P5p}p_A&IkoDYc$KF;^QV9?M{Q43tM6^hX`k)ZB}2_heRE@+Hb+|R9w%Y`Oy z(D)AL^%)+duKX|}N>3z<5ZbS9?A6%I-wivHoGF@)%@UPj%PI){wBYPGz=cU>cwzU& zTMT=x^41TmYb+gXNH0*uE{aT|@*|hIcPd)@R$gj~Q-x3+1anfIxE+;uW%Eug0B*#+>2_`3=d2jKTGDC(wy z7nc!OD&ey7U8PYCZL`8lR)rUqgpl$ORdPDwe z(cRpG)h*X!)(C;#@$lg(?nRxpbzy95+HLJ+n;+Re;^g}+*}NW(IB%I>w_x0LbdkNJzFm(i)0*Hp^+F5vz;xa5e`!#`I>ssbHUswcR%SKI{+n!v$Y!qLs;`&mX*19F0SpjQ%)PxasJeElJWT((M_q9!&Do;_t`(P- zY0L+UJ2(D`vYX(leCDQa#5x$)p!#&C`&#J!;{ZdxrKDadRwrJ!aZSU@Ssm9NbK!`G zb&1|G8fa3pi!cbQ;AawtD#$ zraK^#Y4Gx&>BwWBV=X^sRitUi8te9pd$7+grh@$H8r;bO!K-i(>un99*SaR-!#Y;R z6k$BJH}wn;2ju4qawBu0r=w_An{%{^cXGRW^Uw3S&3mvpA6$QFH7DZN1Dlx+r(aqM z>1_Ep4e<`MIA5QE%3n!&eRnTk&xz%p)r<2EGBc39`r<1AqP>f>8ADLwbVVOI<$-p= zrECK9z};b7!%*)%)`pVYCQjowA6eBRn%p9?k600I14ujTDX%$&`yU9rXc!`v^#|Xi zge^HFiTR$#4F)K1D5_CT>A%P^QZXaT$jlJfo_%&pFT!4GFZ+6W*6yCf^6>0eEjHwn z5wH6D-ZWW!KiQw$$mH03n_EE=f%V4#GlRcYl@4LX2_MV}A0`yUglKpEE5hW*B8DHk z7e2%Vq}4JXkZUNWSW^#`^k;wa%dSJtrC4hl!4w!U4Hpb|yVZ?&lc9B5CB=4UyP}cg z={%O^tHr_~!2pXVu)XP1^LdiyzQ}p{3N-GzFHNLQ!vnJc8r{bY**B})_^fS44FP9s z;&|kWYY}b-BB_}fJe<`A^Srt`EB8pki}FBKFK^aRb!5wfB&N|Xf6Due-%=$@rWZD@iWVc&DpHOIm#Nl&6IS9!0SD1q<`SRZdZ}R zUPkVP=|vj#6{McMRBWz{cfX?Y>LO8SFP&G2|RAahyazmKKkp{Wu1BlI-b=-2HHLVU@9J}r za3AmQIxU^O3ipe32kX9t5_hg)HLhL*fI-ewhdS4}c0-|_ugR4~GOw~m1nq9yI`N7n zyoAymtcH#Gb8Q4}Gw&1T=g!BWn4)&9kes54NsUs&ztM1){{E=Xk^_3h=C&IB_Dxkb zy|S8_-Zi7n$lm0UD(*;{F~jQ_M<%h{XX8e}KDZT8BX{bFjUdJ-gal>#5280dLw?M6Pp@Ln(1VvYCo=P_NHh=|u>l`Bx4d17cO= zGW)(}invoAsl2g*DECHE76$9TlqYUrJkC$y%tNlXoTwcCbNsQ9P(}@g8@{_AQByPH z)O3iV0!uTf3{-_%Xre@>^T%BN`5JdPno9n#|7DTf_@{E)XwjNax`PV)?7dB}rOqcl zMgFxR+Le{xLYB(<79%kdNnbep`;ip9;+Ph20LvHD?GgbgEMELPh|G#l$~c9}4QDP7 zr>Y~Lli?!EQ5pq2RTlxcGzS~@Q*9%u>cKwU>H=?vXzvQ|_&Q_=zr!pOac=J8b34dJ${?s2^Segrnie^3aUk(}EZS;-shU9aMH%ru@_BToytfq$BF8l(aJ z)H>|hFURVhPR1f;R-bG5hTeJm6m}`2 z7{fMu5S#?vXqpxXyhMmLy(9xV!g38jDvg`~M&i@(&@{me{lgJyQ17La?WzM`VrAwk zy^>}8{YjUKbc$N_SyD;K>Lekgj~*r;u_+i!E7^Tf?|PIB6}vrtET9;49H6(qxjQ~5 zE8rsbBoi;`2~+hRb}f!*+$A2OJnQ!EC$Q?@`33JF8iJP4KXfXz59GOV*_H8+`1)RC z$Bu_idz;dTU6a>&O#E}+SACU5md{$Cksw^sTFfbEzZNMu<@z&A0pfzqN+y?+y2L(s zXck>Ph(3|HB_F7XXHgFY{VhYGVI3fTEWlTDH!CSC(g(@p*??|^t_F(+e=4vncCLPb zfGSrhdbEQ0*q-8ZS#wvTs2AtJFP*5vWkhtgf;<98&{R>f z0;R=vypw&?TvyAN^n;rUyr}P^l1$c!>Y<%+He;K8|E6>F#?WQ-&KUa3lC$*?gBDrV zlE4iKPiM?c+Ch~`>k2r92Jv9qGZ5u<7Y+AOwAmx3uAPBtbQ`GrFBZ`2&G=AgZsv6| z8~!^4)B}M_ZV&D7fd`;M+~(f8($%Jj9HlH#L+BG>*L+Mt({}}4J z?4_leEn5#kLM5sf%+%pBc69E4svPaiG>X5|{!lfoaAb*CW?Y-XyE4*F^be85{xg1j z=`Pqg)T1Y90uBeGVWo%d;oxLL`d*{NelPa`$W|8~Uc3`wR$5nG4RDUQ>Du1HL8)zN zvpwMUTD+yb8_N8?qsQ>yVxqc(vdkKpmH!a5)t-=qTN;g;zC#NK69!u?tq7Lc3 zE9Qh_KH+llh>+l#Nkb@``$ft82}sDL=SUHIC{q3gypU=;n4`kC`gibitzU9F|M%kU zTDi*&OW!vSi2c!N`cUH%y%y<)nj$3!(`r(;%k4u9?}9n>HpUm9iqXnQyx45HtNfnv zwMW59-}$HbBdGAjH|6C1x_wpYg@s>)WeLHaL6;!>VawY{>*g5$J}bbn{7{T(%RMHr zPKye3wz3C``|_&^$m2HdESLIk%U)2O4`|zddX®4azX($O!t59BV5m`K6T;vB=f z#J6G^CQ}V#nx0d-#I&wb54o@U&C)C(=jK4X!(z34XU*VrGw&mrgK`$1#Dh2ZsW(w2 zo}n=(=%QctxxuZ3+Y_%)xf!93sNNaXGw5O*%8P7doVQoq-wvStc@wqnm2{~jv~)n| zsMWKH<#mE%nicKr?oj9_FgTU8d!dho&pA)zU*{gi9dXztvDKzC( za0yM}6soo1r{%cyg0#pbkmxHo%rpcJ#xHrBe499~epGQ^|LsZsiE7$qW{!>7VL(R- zARG9doR4>Jjms*dsZa0;_|^RweEM(oNlyHYPF85%AX=u4I@kktIRCOKyT)vtW>LCA zcr2-U$0JDL14V7#;2+x0OYIrb{CR`q$ah^a4;|O`no9nCzlD#kri)BED~lZH2*L;+ z%AZfaANncU-HMorG^xwg(1KJ0e8R-^*W?QZhBVnd0h3bZK)5J(RN(2W*wRQZ5+~w3hMU zef?`$I7=qJ9x+z8=J$nkUO0D3(yy}*x>UReHeS!VX@KFZ1P#AZD5bcb)$%)xZx6W+ zO01+n*Ra`l>%TX@eE#=v)PqnxQ4#3A6V%k9PT20AoN^6_0|+ zdN2C6irY~VRuom}8ipdjw3nL%5PlX_5vrvEm;T{FfDaydX{q}(Gr!SF1V4L)!@s=u!T-Tw5)Gi4??2lMDS zBc4zCi7&pACkS+@R$UDGah0R)v#dGb{1hAp9|~ch{Rdk69J_a{z;ZH#hZY0!xQk(y z^VqXGfMZ?tohUXGvcH|`HY4#8T>r16u=tJN#nTf}aLC9P&6l736;?YrYkL@H-<@R> zX(oD0$i8WgKNuRFluQK){Sr@n9kPM&e*@{Hp<44Bzk_}YJ_+fwHN)v%l{L@#lwyDw_AEP5aa`{g3?=_`&ECR$QVx*2SkRM|bfRlqVCVSOpYs6M zpY8!F(U<_=o$KQqr9qu&kk2r6A;ujUQG|v<=nXFC0>$(++qG8esBBjrbj5fz1GW-i zXXDAPqLKgm>=(NxF{grhTsv5RDnDhitW44QaERWU`8&Xr(mq?=#&gx(|0Mo(-Z@Sj z@UO49AZ%qXk_a&QGCRKK2$<$JB4aIu?p5c9?dO}?`N3GhPVJ3r+p=C2aGQb0`G3(> zO*Z6npoy%R^pr1CRFXtwA78~21tariLn&r2R-M`B} zx_APJ&1EQly1=&K=*fop*{+Zn0dY&$y2({1mWI1IkxcQrwbRu(k#4QFf6^yaQ_aXH zQ6W*L3j=g+2<@ZyF9U2K4-=awpTt=Musawz;Oc9e)rku-A*M4w1}}vS7e;uLLa);| z{!Y{%|737c{5q?+dRpLb!;}rWKMxyfd)(oPZdIkh=xO3CIVwtZUrF5>S<}d`0$3qX zu?Ogu0DEQ++$5P(Fc@R_Hc#*Am~IeHIP(*QeEn@?oT(0zZcC!x_T+Y6IJc=HyeW2I zRe%tZ^7-gR&AUcdE>Nnq{q+EEO;=eq{<2B9hH)m}X(@f|R9cl_YI}JYsw2y>(=y62 z<+N6uAHip~Ed^83R1wvgE`LO&z)-*H`+DXs<&8Sh@9+#bVN@ak)&uD6AiSb7!LEe$V4*BUWY>dRRsm zi44v~^!X`f((^_KwgF+lVOB_9OOnWN_<&0{y~~`){4lBRAk?i6g&G%W48)<*B*bxZ z%=;79ApB*|ch6IpqK*PW#J&DwM+&yMN8!E?obCi}M@9cIzIqbVsbR8! z?L=9QH2Bnwg${?q$EN77=irp$#$@f*J$)Sn2D7SkSJtaEjY`Kn9JrBj*8P%|X2Z>P zLnC)Y>}YG(JvrVFzX;NDNl76R$n|XclX-q(P2Ur^Bxo92dSV8@EP8cEcz%?vOQ79s zZY)J`huVG2=NQhSHf(3|?CHtOd#~3OzYU8ryp63LQl;F`2!|IhFMCBlgqe|Pf+ zM2f9$k%QlR%47tq)E@5ggxQt*K8CEgq_;+5m(4#PHo?0j1%ReIlh17eYF)W^z~!=g z1N82eZ@tP|1zk~IktA&3hG$z{EBktOf6tPZ+Hei|QlXpeqWDsw_g5^h7H57Y9b0N`OnZtMpOcMu$=ctE$#w>g0dtSeE54aBT{;l`Y&qELovF6J)K#v zL!pN~|JZh)W*MzB@x|zwTZ6(R|1kB{oX?z;85^;=y`x^brS_P-dSk{oz0)33=Kxw| z-PvoWtzGuh#HpoXX}nA%0#f7qo$iv_Sw`1xxWqcoyc0(YT#`lB+N{k0M3+YaulV8s zV#4L=`uOt0LfO;03r%hZ(?eX{>xR4TRE1ZaXZ{WZ$!4!tNk5b{rGcw7@o3qOza82- z_A}Ucom&*8so;=lX$^DgqIhCC#27(8;yJI$99Aja0v0>j1(!1q_Tcx9;brT{N*7+S#fATPkEDA-1$-imSg*DU4z z0lypVb&Xz|d5K)3DR-PynXv{-sKS=W**PdV?hQ}&C}SJBc8?v-ki?}evb11*oZ4Z- zD0n=j{k3)GWz{L)mSeTU>gl3Wb-AW*%=~`{xuq)hFu96<$#J^(pK8OOt22ZH#YlaHF*>`VOmJiY($Kyx@kz3s8S0|l-xpS)S~aw(HPdit?JOz3mg zd{E4i5KbS%O8!Qw$+TQs+o4QK>yd)Ggj!+^`IJcK2u0HMtFxx=#L=8fX6cyMI+8lV zzrBO!X!Kq!1pleN%&^t-7vAy>e(RDC+R8P$OD7CSy-0CF}*h1;f4pcu@u7pHNQ+Ji*704f=MW1i9LtrzxI zk+cQ-ffA0pnJFKR=Q3Jzwmu``hB|R5@gieHn|Bjixv6#rHeir(Tx<6lhE~4oFM>%~ zk38KiB6kqygEoA^21~kWH!G1;#Q`geX{ze9vNF`qedl1>Vs-jtQvO8M=3A57)0O4y zO8+z_1;+-ZN$eki@jN02H}__S6p(8D;wkc@#O(XI|gGB;nj6rp+DfGDc0F4KvCi zo^YGWIv-`Et|{BBDzZW#q_!%L<#h~mHj|EMr*b4e4Y|Gv=K2sgy`wXK`YQjTMYqZX5IiM7L`UBe0$q{O0;k(6|zt8i$l^ zZ4w+8TcOpmg@fte$&HPMj@w(NZqJSA!;46HBk19mm4Bs+WnNjP9>^wDrU5zqp%cxn zVI&>X$V$M+a}iP4)3LmyX0h7n?0l@uO6qKWjd3Zqq;HH2{Uu-$QENT68ZJsy%zVS$ z56<}q>T?DsujqD|`Fl{SVa$Rl`_i9frAjMixV;!jVXPM?Yh17pF#oZBjv`f+LAIWd zGyG@jU-Xek$?2ss?77Wu)8dwQo@o8S_sEFXq?doC-tR&t&_k0CRQBuC1LZoOU^57C zXthkDx}L)~rhkUhWa9R{v4t97@xgK?h=FqvO-k$J%yfNfUgH|bGe(W zV_uO=*g!akL6os|8Q#Q}56|Lce^yGFJ&DvIq|(=4UgM&sAnY6wSt$uo*C4SZ6ibiO z(ddEi9@&~}vUI;EK*ZSHcCLSX!8T<>zqood`}ZspMlH(C(?nf?yA=c5M-)ZtMB;V$__Yy}3?)=iU(U%7GZKQl#iam5|Q;fePQc z^f*1$52kE=-c_BlP+99Kfrw}ZT}gy$jK*dq08pW-%y{Y!npo<{Wj}AC)kaL=3DYpD zk`t}QG(B{70K`xpA-6&IxcoHzqj=G%~MYGlwm7kNI9|NJXi5b;V~)Tm2Tkg3X7YkQ^J+aH2c*st=X z6lUX9(X4uf3uDFQP4%#b4ute+C_ebhns}f>8>XqAp&MyAYg9n&*nGHqP3qA!T3q8p z6gYN#JqD7B92i6`|45DH6ZNGxsD=}pnKAMYB;Q$^1O>!Xa*PLt;Ke$pK! zYTRS<4W{s7&y|?4+20%GOBofunV8qNd=-PdR9lRE22hDVx0>415(H`Dkc+4E^eWS7 zk|jZ#tLhM@cyoQmNcS{&MCXKz;)!Z=aa8nG4+s9{+MM^M&Sxho`wzHO`fguYBs9HN ztQ>p;7w?@Gm!iZNO9-*Jb725v8jA0nRMkYMmx2D zL6sC57aG-w;^4H#^^dW88HJ^YRzzw2OU~4%>famZnr?MZRp;hyO46uLXK#dKYr3Ro zVJLmmI*N0#MSJCd8)~q5s8`_W_0vV22(D7-*1HePPsS8Fh%2j&b&#K*KVW2G3SKPl zg3aZ6qGik_#c{81@j1oZJ4GZO)R0tAEkX9OYkR#`>&FIGqcT_11A(4IF#@Njawfx55(6|NiL}A(NaWX}!TlO{ z&&1{LrNjGI@2TFjtUYWrRB=?Si+&&RP2^_Poy!9m1szVo&9bwl$i2ucJA|UNMg%}dXblLWGe3L z57oOY$AR`)9ZkEVQ6*$&^s-%gfH*Ip2uFYmR8w{F(C?QFo{iveS3t%78GZDT&c!pp%Jf#UbmLX zYV&KNk*}>Lji~wc(g7a-)_B?{@c6g-vCDdPB*nw0nQV_eSBu>%oJYc}hd{ysGei^E z8Svb0@yBylOjL}x>hKF(dC^kBkd{z0iqNgfl*S#5g;5PrO2ZlbGPE35D$_%TFjgCITp>uS3bWumEr%8kVI z*FzC3!HbAvr6TH(QTd4Jw_Z(yhqyp&l=s*E;k}foyII@Ou1oTCEc=MTsmsA`Muo#Q zGFqxu_Hn6G?hP+*(>mu^k89@?hoFmDYI4V8-bu!Vk_ z<*@t#wa!41J$@eVI*w&BB!`IlFpqdjM(%ju4iK1Yr&x{B0tk|ge?dBai#i#R@c2xSu_CIv5S*1%{!!-qM z+g+}vh%4fL=|jlU_Ge?m1T62_JKswqrxR*o@G^(AyV#UnTK4OF(f&2TS1ABg5M zOY4|>VGdRe3lPSRBtobh2a@eRU(&QGhUTq95F7zNXJI()UEHqvNr*sHlXsZaOYedx zZ2Ynxm9Z3+L}`%p+^AkME?PB4&A_K(gIWLhjyt zy&&Z7hh;&lEG{`4e|wxU`AdXIOtjRUC*=vZZaf#c{#-NdMeXWtsjB<8RC(qVfB0y5 ziE-O8_Mpu3SMEXTf(LC=jn}14x}Z|E?%e)e%n|`smJj0cQr-9$#7;CmT`*Vw`5ETL z&o#|E*{}K@$k8o(v8(2PAFr{j+PWlzQ;jCPuYvPKK(IMaYEsKr*=F8@Z>MxhX*Yzq z*hkE~W%TdTTCg;5>)J)B<9Yvq9Z&Pp*bc)I$z JtaD0e0sx%%{jdN4 literal 0 HcmV?d00001 diff --git a/native/notification-status-macos/main.swift b/native/notification-status-macos/main.swift new file mode 100644 index 000000000..fa8c944ec --- /dev/null +++ b/native/notification-status-macos/main.swift @@ -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)\"}") diff --git a/package.json b/package.json index 5a9b58761..c4415d209 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/ipc/notification-authorization-status.ts b/src/main/ipc/notification-authorization-status.ts new file mode 100644 index 000000000..358d0effc --- /dev/null +++ b/src/main/ipc/notification-authorization-status.ts @@ -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 | null = null + +export function readNotificationAuthorizationStatus(): Promise { + 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 { + 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) + } + }) + }) +} diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index 9a6dcd1f9..8fb5566a8 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -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 = {}): { + getSettings: () => unknown + getUI: () => Record + updateUI: ReturnType + } { + const state = { ...ui } + return { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }), + getUI: () => state, + updateUI: vi.fn((updates: Record) => { + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 = { diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 94ff8c636..47baa7e77 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -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 | 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 { + 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((resolve) => { + let settled = false + let timeoutTimer: ReturnType | 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, now: export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void { const recentNotifications = new Map() + // 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 => { + // 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 { + 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() } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0c789c5b9..3b9c581b2 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -126,6 +126,7 @@ import type { GetRateLimitResult, NotificationDispatchRequest, NotificationDispatchResult, + NotificationDeliveryProbeResult, NotificationDismissResult, NotificationPermissionStatusResult, NotificationSoundResult, @@ -1995,7 +1996,7 @@ export type PreloadApi = { dismiss: (ids: string[]) => Promise openSystemSettings: () => Promise getPermissionStatus: () => Promise - requestPermission: () => Promise + probeDelivery: (args?: { force?: boolean }) => Promise playSound: (options?: { force?: boolean; volume?: number }) => Promise } onboarding: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 54fe1215a..c02a1fa5b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -36,6 +36,7 @@ import type { MemorySnapshot, NotificationDismissResult, NotificationDispatchResult, + NotificationDeliveryProbeResult, NotificationPermissionStatusResult, NotificationSoundDataResult, NotificationSoundPathResult, @@ -1879,8 +1880,8 @@ const api = { openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), getPermissionStatus: (): Promise => ipcRenderer.invoke('notifications:getPermissionStatus'), - requestPermission: (): Promise => - ipcRenderer.invoke('notifications:requestPermission'), + probeDelivery: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('notifications:probeDelivery', args), playSound: async (options?: { force?: boolean volume?: number diff --git a/src/renderer/src/components/notifications/mac-notification-permission-card.tsx b/src/renderer/src/components/notifications/mac-notification-permission-card.tsx new file mode 100644 index 000000000..2bc94664d --- /dev/null +++ b/src/renderer/src/components/notifications/mac-notification-permission-card.tsx @@ -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(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 | 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 ( +
+ {translate( + 'auto.components.onboarding.NotificationStep.56b836215c', + 'Checking notification permission…' + )} +
+ ) + } + + if (state === 'enabled') { + return ( +
+ +
+
+ {translate( + 'auto.components.onboarding.NotificationStep.fd84d3e9b8', + 'Notifications are enabled' + )} +
+

+ {translate( + 'auto.components.onboarding.NotificationStep.4f7bce5644', + 'macOS will alert you when agents finish or terminals need attention.' + )} +

+
+
+ ) + } + + if (state === 'awaiting-permission') { + return ( +
+
+
+
+ + {translate( + 'auto.components.onboarding.NotificationStep.95d99b52fa', + 'Allow notifications for Orca' + )} +
+

+ {translate( + 'auto.components.onboarding.mac.notification.permission.card.f696515944', + 'Click Allow in the macOS dialog.' + )} +

+
+ +
+
+ ) + } + + if (state === 'blocked') { + return ( +
+
+
+
+ + {translate( + 'auto.components.onboarding.NotificationStep.90b5d2e363', + 'macOS is not delivering Orca notifications' + )} +
+

+ {translate( + 'auto.components.onboarding.mac.notification.permission.card.721d2bedb6', + 'Turn on Allow notifications for Orca in System Settings.' + )} +

+
+ +
+
+ ) + } + + return null +} diff --git a/src/renderer/src/components/onboarding/NotificationStep.test.tsx b/src/renderer/src/components/onboarding/NotificationStep.test.tsx index 9eb516cb8..04bd27f98 100644 --- a/src/renderer/src/components/onboarding/NotificationStep.test.tsx +++ b/src/renderer/src/components/onboarding/NotificationStep.test.tsx @@ -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 = {} @@ -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( + + ) + + 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') + }) }) diff --git a/src/renderer/src/components/onboarding/NotificationStep.tsx b/src/renderer/src/components/onboarding/NotificationStep.tsx index ef43e335c..711677552 100644 --- a/src/renderer/src/components/onboarding/NotificationStep.tsx +++ b/src/renderer/src/components/onboarding/NotificationStep.tsx @@ -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(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(null) const syncedNotificationSettingsRef = useRef(notificationSettings) @@ -59,18 +66,6 @@ export function NotificationStep({ setSelectPortalRoot(node?.closest('[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 ): Promise => { @@ -91,14 +86,6 @@ export function NotificationStep({ const getCustomSoundVolume = (): number => notificationSettingsRef.current?.customSoundVolume ?? 100 - const handleMacPermission = async (): Promise => { - 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 => { @@ -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 (
- {isMac ? ( -
-
-
-
- - {translate( - 'auto.components.onboarding.NotificationStep.d2dba86837', - 'Allow Orca in macOS' - )} -
-

- {translate( - 'auto.components.onboarding.NotificationStep.aa36281b00', - 'Open System Settings and make sure Orca is allowed to send notifications.' - )} -

-
- -
-
- ) : null} +
diff --git a/src/renderer/src/components/settings/NotificationsPane.tsx b/src/renderer/src/components/settings/NotificationsPane.tsx index af16bd061..5d1bb6a23 100644 --- a/src/renderer/src/components/settings/NotificationsPane.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.tsx @@ -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 @@ -76,11 +83,31 @@ export function NotificationsPane({ const handleSendTestNotification = async (): Promise => { 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 (
+ {macPermissionState !== null ? ( +
+ +
+ ) : null} { + volumeDraft: number, + options?: SendTestNotificationOptions +): Promise { 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' } diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index 2a0c2f4f3..4a39cef7d 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -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) => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index af0a3f6d9..62628f70d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 46b775190..cb1f6f584 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a774406d4..b17bf2dcc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d3fd20216..d12f3cdd7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8b83cb8f9..b86a41a03 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/lib/blocked-notification-fallback.ts b/src/renderer/src/lib/blocked-notification-fallback.ts new file mode 100644 index 000000000..d9903f96a --- /dev/null +++ b/src/renderer/src/lib/blocked-notification-fallback.ts @@ -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() + } + } + } + ) +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 7e181cdbb..49536f288 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2502,8 +2502,7 @@ function createNotificationsApi(): NonNullable['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' }) } } diff --git a/src/shared/types.ts b/src/shared/types.ts index bcf20f6c1..82daa227a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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'