@@ -96,11 +96,96 @@ export async function clearE2eKeyFromSW(): Promise<void> {
9696 }
9797}
9898
99+ // ---- macOS native notification bridge ----
100+
101+ declare global {
102+ interface Window {
103+ __BOTSCHAT_NATIVE__ ?: boolean ;
104+ __BOTSCHAT_PLATFORM__ ?: string ;
105+ __BOTSCHAT_NATIVE_NOTIFY__ ?: ( payload : {
106+ title : string ;
107+ body : string ;
108+ sessionKey ?: string ;
109+ } ) => void ;
110+ __BOTSCHAT_NATIVE_REQUEST_PERMISSION__ ?: ( ) => void ;
111+ }
112+ }
113+
114+ function isMacOSNative ( ) : boolean {
115+ return ! ! ( window . __BOTSCHAT_NATIVE__ && window . __BOTSCHAT_PLATFORM__ === "macos" ) ;
116+ }
117+
118+ async function initMacOSPush ( ) : Promise < void > {
119+ try {
120+ window . __BOTSCHAT_NATIVE_REQUEST_PERMISSION__ ?.( ) ;
121+ dlog . info ( "Push" , "macOS native notification permission requested" ) ;
122+ } catch ( err ) {
123+ dlog . error ( "Push" , "macOS notification init failed" , err ) ;
124+ }
125+ }
126+
127+ /**
128+ * Show a native macOS notification when a message arrives via WS and
129+ * the window is not focused. Call this from the WS message handler.
130+ */
131+ export function notifyIfBackground ( msg : {
132+ type : string ;
133+ text ?: string ;
134+ caption ?: string ;
135+ sessionKey ?: string ;
136+ agentName ?: string ;
137+ } ) : void {
138+ if ( ! isMacOSNative ( ) ) return ;
139+ if ( ! document . hidden && document . hasFocus ( ) ) return ;
140+ if ( ! window . __BOTSCHAT_NATIVE_NOTIFY__ ) return ;
141+
142+ let body = "" ;
143+ const title = msg . agentName || "BotsChat" ;
144+
145+ if ( msg . type === "agent.text" && msg . text ) {
146+ body = msg . text . length > 200 ? msg . text . slice ( 0 , 200 ) + "…" : msg . text ;
147+ } else if ( msg . type === "agent.media" ) {
148+ body = msg . caption || "Sent a media file" ;
149+ } else {
150+ return ;
151+ }
152+
153+ window . __BOTSCHAT_NATIVE_NOTIFY__ ( { title, body, sessionKey : msg . sessionKey } ) ;
154+ }
155+
156+ // ---- Service Worker message listener (notification click → navigation) ----
157+
158+ function setupSWMessageListener ( ) : void {
159+ if ( ! ( "serviceWorker" in navigator ) ) return ;
160+ navigator . serviceWorker . addEventListener ( "message" , ( event ) => {
161+ if ( event . data ?. type === "push-nav" && event . data . sessionKey ) {
162+ dlog . info ( "Push" , `SW postMessage push-nav: ${ event . data . sessionKey } ` ) ;
163+ firePushNav ( event . data . sessionKey ) ;
164+ }
165+ } ) ;
166+
167+ // Also check URL for push_session param (when SW opens a new window)
168+ const params = new URLSearchParams ( window . location . search ) ;
169+ const pushSession = params . get ( "push_session" ) ;
170+ if ( pushSession ) {
171+ dlog . info ( "Push" , `URL push_session param: ${ pushSession } ` ) ;
172+ firePushNav ( pushSession ) ;
173+ // Clean up the URL parameter
174+ params . delete ( "push_session" ) ;
175+ const clean = params . toString ( ) ;
176+ const newUrl = window . location . pathname + ( clean ? "?" + clean : "" ) + window . location . hash ;
177+ window . history . replaceState ( { } , "" , newUrl ) ;
178+ }
179+ }
180+
99181// ---- Push initialization ----
100182
101183export async function initPushNotifications ( ) : Promise < void > {
102184 if ( initialized ) return ;
103185
186+ // Listen for SW notification-click messages (must be before any early return)
187+ setupSWMessageListener ( ) ;
188+
104189 // Sync E2E key so push notifications can be decrypted
105190 await syncE2eKeyToSW ( ) ;
106191
@@ -109,7 +194,9 @@ export async function initPushNotifications(): Promise<void> {
109194 syncE2eKeyToSW ( ) . catch ( ( ) => { } ) ;
110195 } ) ;
111196
112- if ( Capacitor . isNativePlatform ( ) ) {
197+ if ( isMacOSNative ( ) ) {
198+ await initMacOSPush ( ) ;
199+ } else if ( Capacitor . isNativePlatform ( ) ) {
113200 await initNativePush ( ) ;
114201 } else {
115202 await initWebPush ( ) ;
0 commit comments