From af89de1237d5543b6f0f0686a252ad708d76dc26 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Feb 2026 12:59:00 +0700 Subject: [PATCH] feat: add presence-gated Expo push notifications --- packages/app/src/app/_layout.tsx | 63 +++++++++- packages/app/src/contexts/session-context.tsx | 2 + packages/app/src/hooks/use-client-activity.ts | 78 +++++++++---- .../src/hooks/use-push-token-registration.ts | 109 ++++++++++++++++++ packages/app/src/utils/os-notifications.ts | 105 ++++------------- .../server/src/server/push/token-store.ts | 54 ++++++++- .../src/server/websocket-session-bridge.ts | 43 +++++-- 7 files changed, 332 insertions(+), 122 deletions(-) create mode 100644 packages/app/src/hooks/use-push-token-registration.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 3e8435120..16cd0187e 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,5 +1,5 @@ import "@/styles/unistyles"; -import { Stack, usePathname } from "expo-router"; +import { Stack, usePathname, useRouter } from "expo-router"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -15,9 +15,10 @@ import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-reg import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context"; import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState, useEffect, type ReactNode, useMemo } from "react"; +import { useState, useEffect, type ReactNode, useMemo, useRef } from "react"; import { Platform } from "react-native"; import * as Linking from "expo-linking"; +import * as Notifications from "expo-notifications"; import { SlidingSidebar } from "@/components/sliding-sidebar"; import { DownloadToast } from "@/components/download-toast"; import { ToastProvider } from "@/contexts/toast-context"; @@ -34,6 +35,63 @@ import { import { getIsTauriMac } from "@/constants/layout"; import { useTrafficLightPadding } from "@/utils/tauri-window"; +function PushNotificationRouter() { + const router = useRouter(); + const lastHandledIdRef = useRef(null); + + useEffect(() => { + if (Platform.OS === "web") { + return; + } + + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + // When the app is open, don't show OS banners. + shouldShowAlert: false, + shouldShowBanner: false, + shouldShowList: false, + shouldPlaySound: false, + shouldSetBadge: false, + }), + }); + + const openFromResponse = (response: Notifications.NotificationResponse) => { + const identifier = response.notification.request.identifier; + if (lastHandledIdRef.current === identifier) { + return; + } + lastHandledIdRef.current = identifier; + + const data = response.notification.request.content.data as + | Record + | undefined; + const agentId = typeof data?.agentId === "string" ? data.agentId : null; + + if (agentId) { + // Legacy route resolves agent -> host once sessions reconnect. + router.push(`/agent/${agentId}` as any); + } else { + router.push("/agents" as any); + } + }; + + const subscription = + Notifications.addNotificationResponseReceivedListener(openFromResponse); + + void Notifications.getLastNotificationResponseAsync().then((response) => { + if (response) { + openFromResponse(response); + } + }); + + return () => { + subscription.remove(); + }; + }, [router]); + + return null; +} + function QueryProvider({ children }: { children: ReactNode }) { const [queryClient] = useState( () => @@ -335,6 +393,7 @@ export default function RootLayout() { + diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 66a9a9eff..d60ea2f49 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -13,6 +13,7 @@ import { useMutation } from "@tanstack/react-query"; import { useDaemonClient } from "@/hooks/use-daemon-client"; import { useAudioPlayer } from "@/hooks/use-audio-player"; import { useClientActivity } from "@/hooks/use-client-activity"; +import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { applyStreamEvent, generateMessageId, @@ -388,6 +389,7 @@ export function SessionProvider({ // Client activity tracking (heartbeat, push token registration) useClientActivity({ client, focusedAgentId }); + usePushTokenRegistration({ client, serverId }); // State for voice detection flags (will be set by RealtimeContext) const isDetectingRef = useRef(false); diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index c33d88174..ad2099a14 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -3,6 +3,7 @@ import { AppState, Platform } from "react-native"; import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; const HEARTBEAT_INTERVAL_MS = 15_000; +const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; interface ClientActivityOptions { client: DaemonClientV2; @@ -12,14 +13,15 @@ interface ClientActivityOptions { /** * Handles client activity reporting: * - Heartbeat sending every 15 seconds - * - App visibility tracking (updates lastActivityAt on foreground) - * - Sends heartbeat immediately when focused agent changes + * - App visibility tracking + * - Records lastActivityAt only on real user activity (not on heartbeat) */ export function useClientActivity({ client, focusedAgentId }: ClientActivityOptions): void { const lastActivityAtRef = useRef(new Date()); const appVisibleRef = useRef(AppState.currentState === "active"); const heartbeatIntervalRef = useRef | null>(null); const prevFocusedAgentIdRef = useRef(focusedAgentId); + const lastImmediateHeartbeatAtRef = useRef(0); const deviceType = Platform.OS === "web" ? "web" : "mobile"; @@ -28,17 +30,7 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti }, []); const sendHeartbeat = useCallback(() => { - if (!client.isConnected) { - console.log("[ClientActivity] sendHeartbeat skipped - not connected"); - return; - } - lastActivityAtRef.current = new Date(); - console.log("[ClientActivity] sendHeartbeat", { - deviceType, - focusedAgentId, - lastActivityAt: lastActivityAtRef.current.toISOString(), - appVisible: appVisibleRef.current, - }); + if (!client.isConnected) return; client.sendHeartbeat({ deviceType, focusedAgentId, @@ -47,35 +39,77 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti }); }, [client, deviceType, focusedAgentId]); + const maybeSendImmediateHeartbeat = useCallback(() => { + if (!client.isConnected) return; + const now = Date.now(); + if (now - lastImmediateHeartbeatAtRef.current < ACTIVITY_HEARTBEAT_THROTTLE_MS) { + return; + } + lastImmediateHeartbeatAtRef.current = now; + sendHeartbeat(); + }, [client, sendHeartbeat]); + // Track app visibility useEffect(() => { - console.log("[ClientActivity] AppState effect mounted, current:", AppState.currentState); const subscription = AppState.addEventListener("change", (nextState) => { - console.log("[ClientActivity] AppState changed:", nextState); appVisibleRef.current = nextState === "active"; if (nextState === "active") { recordUserActivity(); + maybeSendImmediateHeartbeat(); } }); return () => subscription.remove(); - }, [recordUserActivity]); + }, [maybeSendImmediateHeartbeat, recordUserActivity]); + + // Track user activity on web for accurate staleness. + useEffect(() => { + if (Platform.OS !== "web") return; + if (typeof document === "undefined") return; + + const handleUserActivity = () => { + recordUserActivity(); + maybeSendImmediateHeartbeat(); + }; + + const handleVisibilityChange = () => { + const visible = document.visibilityState === "visible"; + appVisibleRef.current = visible; + if (visible) { + recordUserActivity(); + maybeSendImmediateHeartbeat(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleUserActivity); + window.addEventListener("pointerdown", handleUserActivity, { passive: true }); + window.addEventListener("keydown", handleUserActivity); + window.addEventListener("wheel", handleUserActivity, { passive: true }); + window.addEventListener("touchstart", handleUserActivity, { passive: true }); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", handleUserActivity); + window.removeEventListener("pointerdown", handleUserActivity); + window.removeEventListener("keydown", handleUserActivity); + window.removeEventListener("wheel", handleUserActivity); + window.removeEventListener("touchstart", handleUserActivity); + }; + }, [maybeSendImmediateHeartbeat, recordUserActivity]); // Send heartbeat on focused agent change useEffect(() => { if (prevFocusedAgentIdRef.current !== focusedAgentId) { - console.log("[ClientActivity] focusedAgentId changed:", prevFocusedAgentIdRef.current, "->", focusedAgentId); prevFocusedAgentIdRef.current = focusedAgentId; + recordUserActivity(); sendHeartbeat(); } - }, [focusedAgentId, sendHeartbeat]); + }, [focusedAgentId, recordUserActivity, sendHeartbeat]); // Periodic heartbeat useEffect(() => { - console.log("[ClientActivity] Heartbeat effect mounted, isConnected:", client.isConnected); - const startHeartbeat = () => { - console.log("[ClientActivity] startHeartbeat called"); if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); } @@ -84,7 +118,6 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti }; const stopHeartbeat = () => { - console.log("[ClientActivity] stopHeartbeat called"); if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; @@ -92,7 +125,6 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti }; const unsubscribe = client.subscribeConnectionStatus((state) => { - console.log("[ClientActivity] Connection status changed:", state.status); if (state.status === "connected") { startHeartbeat(); } else { diff --git a/packages/app/src/hooks/use-push-token-registration.ts b/packages/app/src/hooks/use-push-token-registration.ts new file mode 100644 index 000000000..9248fe413 --- /dev/null +++ b/packages/app/src/hooks/use-push-token-registration.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useRef } from "react"; +import { Platform } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as Notifications from "expo-notifications"; +import Constants from "expo-constants"; +import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; + +const STORAGE_PREFIX = "@paseo:expo-push-token:"; + +function getExpoProjectId(): string | null { + const fromEas = (Constants as any)?.easConfig?.projectId; + if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim(); + + const fromExtra = (Constants as any)?.expoConfig?.extra?.eas?.projectId; + if (typeof fromExtra === "string" && fromExtra.trim()) return fromExtra.trim(); + + return null; +} + +async function ensurePushPermission(): Promise { + const existing = await Notifications.getPermissionsAsync(); + if (existing.status === "granted") return true; + if (!existing.canAskAgain) return false; + const requested = await Notifications.requestPermissionsAsync(); + return requested.status === "granted"; +} + +export function usePushTokenRegistration(params: { + client: DaemonClientV2; + serverId: string; +}): void { + const { client, serverId } = params; + const tokenRef = useRef(null); + const lastSentTokenRef = useRef(null); + + const registerIfPossible = useCallback(async () => { + if (Platform.OS === "web") return; + if (!client.isConnected) return; + const token = tokenRef.current; + if (!token) return; + if (lastSentTokenRef.current === token) return; + lastSentTokenRef.current = token; + client.registerPushToken(token); + }, [client]); + + useEffect(() => { + if (Platform.OS === "web") return; + + const storageKey = `${STORAGE_PREFIX}${serverId}`; + let cancelled = false; + + const run = async () => { + const cached = await AsyncStorage.getItem(storageKey); + if (cancelled) return; + if (cached && typeof cached === "string") { + tokenRef.current = cached; + } + + const granted = await ensurePushPermission(); + if (!granted || cancelled) return; + + if (Platform.OS === "android") { + await Notifications.setNotificationChannelAsync("default", { + name: "default", + importance: Notifications.AndroidImportance.DEFAULT, + }); + } + + const projectId = getExpoProjectId(); + if (!projectId) { + console.warn("[PushToken] Missing EAS projectId; cannot fetch Expo push token"); + return; + } + + const result = await Notifications.getExpoPushTokenAsync({ projectId }); + if (cancelled) return; + + const token = result.data; + if (typeof token !== "string" || !token.trim()) return; + + tokenRef.current = token; + await AsyncStorage.setItem(storageKey, token); + await registerIfPossible(); + }; + + void run().catch((error) => { + console.warn("[PushToken] Failed to register push token", error); + }); + + return () => { + cancelled = true; + }; + }, [registerIfPossible, serverId]); + + useEffect(() => { + const unsubscribe = client.subscribeConnectionStatus((state) => { + if (state.status === "connected") { + void registerIfPossible(); + } else { + // Re-register on the next successful connect. + lastSentTokenRef.current = null; + } + }); + if (client.isConnected) { + void registerIfPossible(); + } + return unsubscribe; + }, [client, registerIfPossible]); +} diff --git a/packages/app/src/utils/os-notifications.ts b/packages/app/src/utils/os-notifications.ts index fe4221ef8..9a73265ac 100644 --- a/packages/app/src/utils/os-notifications.ts +++ b/packages/app/src/utils/os-notifications.ts @@ -1,5 +1,4 @@ import { Platform } from "react-native"; -import * as Notifications from "expo-notifications"; type OsNotificationPayload = { title: string; @@ -7,8 +6,6 @@ type OsNotificationPayload = { data?: Record; }; -let isNativeConfigured = false; -let permissionState: "unknown" | "granted" | "denied" = "unknown"; let permissionRequest: Promise | null = null; function getWebNotificationConstructor(): { @@ -20,73 +17,25 @@ function getWebNotificationConstructor(): { return NotificationConstructor ?? null; } -async function configureNativeNotifications(): Promise { - if (isNativeConfigured || Platform.OS === "web") { - return; - } - isNativeConfigured = true; - - Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldShowBanner: true, - shouldShowList: true, - shouldPlaySound: false, - shouldSetBadge: false, - }), - }); -} - async function ensureNotificationPermission(): Promise { - if (Platform.OS === "web") { - const NotificationConstructor = getWebNotificationConstructor(); - if (!NotificationConstructor) { - return false; - } - if (NotificationConstructor.permission === "granted") { - return true; - } - if (NotificationConstructor.permission === "denied") { - return false; - } - if (permissionRequest) { - return permissionRequest; - } - permissionRequest = Promise.resolve( - NotificationConstructor.requestPermission - ? NotificationConstructor.requestPermission() - : "denied" - ).then((permission) => permission === "granted"); - const result = await permissionRequest; - permissionRequest = null; - return result; + const NotificationConstructor = getWebNotificationConstructor(); + if (!NotificationConstructor) { + return false; } - - if (permissionState === "granted") { + if (NotificationConstructor.permission === "granted") { return true; } - if (permissionState === "denied") { + if (NotificationConstructor.permission === "denied") { return false; } if (permissionRequest) { return permissionRequest; } - - permissionRequest = (async () => { - const existing = await Notifications.getPermissionsAsync(); - if (existing.status === "granted") { - permissionState = "granted"; - return true; - } - if (!existing.canAskAgain) { - permissionState = "denied"; - return false; - } - const requested = await Notifications.requestPermissionsAsync(); - permissionState = requested.status === "granted" ? "granted" : "denied"; - return permissionState === "granted"; - })(); - + permissionRequest = Promise.resolve( + NotificationConstructor.requestPermission + ? NotificationConstructor.requestPermission() + : "denied" + ).then((permission) => permission === "granted"); const result = await permissionRequest; permissionRequest = null; return result; @@ -95,36 +44,22 @@ async function ensureNotificationPermission(): Promise { export async function sendOsNotification( payload: OsNotificationPayload ): Promise { - if (Platform.OS === "web") { - const NotificationConstructor = getWebNotificationConstructor(); - if (!NotificationConstructor) { - return false; - } - const granted = await ensureNotificationPermission(); - if (!granted) { - return false; - } - new NotificationConstructor(payload.title, { - body: payload.body, - data: payload.data, - }); - return true; + // Mobile/native notifications should be remote push only. + if (Platform.OS !== "web") { + return false; } - await configureNativeNotifications(); + const NotificationConstructor = getWebNotificationConstructor(); + if (!NotificationConstructor) { + return false; + } const granted = await ensureNotificationPermission(); if (!granted) { return false; } - - await Notifications.scheduleNotificationAsync({ - content: { - title: payload.title, - body: payload.body, - data: payload.data, - }, - trigger: null, + new NotificationConstructor(payload.title, { + body: payload.body, + data: payload.data, }); - return true; } diff --git a/packages/server/src/server/push/token-store.ts b/packages/server/src/server/push/token-store.ts index de3c5358b..42caf3a21 100644 --- a/packages/server/src/server/push/token-store.ts +++ b/packages/server/src/server/push/token-store.ts @@ -1,25 +1,38 @@ import type pino from "pino"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; /** - * Simple in-memory store for Expo push tokens. - * Tokens are used to send push notifications when all clients are stale. + * Store for Expo push tokens. + * + * Tokens are persisted to disk so pushes still work after daemon restarts. */ export class PushTokenStore { private readonly logger: pino.Logger; private tokens: Set = new Set(); + private readonly filePath: string; - constructor(logger: pino.Logger) { + constructor(logger: pino.Logger, filePath: string) { this.logger = logger.child({ component: "token-store" }); + this.filePath = filePath; + this.loadFromDisk(); } addToken(token: string): void { - this.tokens.add(token); + const normalized = token.trim(); + if (!normalized) return; + if (this.tokens.has(normalized)) return; + this.tokens.add(normalized); + this.persist(); this.logger.debug({ total: this.tokens.size }, "Added token"); } removeToken(token: string): void { - const deleted = this.tokens.delete(token); + const normalized = token.trim(); + if (!normalized) return; + const deleted = this.tokens.delete(normalized); if (deleted) { + this.persist(); this.logger.debug({ total: this.tokens.size }, "Removed token"); } } @@ -27,4 +40,35 @@ export class PushTokenStore { getAllTokens(): string[] { return Array.from(this.tokens); } + + private loadFromDisk(): void { + try { + if (!existsSync(this.filePath)) { + return; + } + const raw = readFileSync(this.filePath, "utf-8"); + const parsed = JSON.parse(raw) as { tokens?: unknown }; + const tokens = Array.isArray(parsed.tokens) + ? parsed.tokens.filter((t): t is string => typeof t === "string" && t.trim().length > 0) + : []; + this.tokens = new Set(tokens.map((t) => t.trim())); + this.logger.info({ total: this.tokens.size }, "Loaded push tokens"); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.warn({ err }, "Failed to load push tokens"); + } + } + + private persist(): void { + try { + mkdirSync(dirname(this.filePath), { recursive: true }); + const tmpPath = `${this.filePath}.tmp`; + const payload = JSON.stringify({ tokens: Array.from(this.tokens) }, null, 2) + "\n"; + writeFileSync(tmpPath, payload); + renameSync(tmpPath, this.filePath); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.warn({ err }, "Failed to persist push tokens"); + } + } } diff --git a/packages/server/src/server/websocket-session-bridge.ts b/packages/server/src/server/websocket-session-bridge.ts index 7a5a256d8..b6e7cbc58 100644 --- a/packages/server/src/server/websocket-session-bridge.ts +++ b/packages/server/src/server/websocket-session-bridge.ts @@ -71,7 +71,10 @@ export class WebSocketSessionBridge { this.dictation = dictation ?? null; const pushLogger = this.logger.child({ module: "push" }); - this.pushTokenStore = new PushTokenStore(pushLogger); + this.pushTokenStore = new PushTokenStore( + pushLogger, + join(paseoHome, "push-tokens.json") + ); this.pushService = new PushService(pushLogger, this.pushTokenStore); this.agentManager.setAgentAttentionCallback((params) => { @@ -391,16 +394,42 @@ export class WebSocketSessionBridge { "broadcastAgentAttention" ); - const allClientsStale = allStates.every((state) => state.isStale); - this.logger.debug({ allClientsStale }, "Client staleness check"); - if (allClientsStale) { + const hasActiveWebClient = allStates.some( + (state) => state.deviceType === "web" && !state.isStale + ); + const hasActiveMobileForegroundClient = allStates.some( + (state) => state.deviceType === "mobile" && state.appVisible && !state.isStale + ); + + // Push is only a fallback when the user is away from their desktop/web. + // Also suppress push if they're actively using the mobile app. + const shouldSendPush = + params.reason !== "error" && + !hasActiveWebClient && + !hasActiveMobileForegroundClient; + + this.logger.debug( + { hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush }, + "Push gating check" + ); + + if (shouldSendPush) { const tokens = this.pushTokenStore.getAllTokens(); this.logger.info({ tokenCount: tokens.length }, "Sending push notification"); if (tokens.length > 0) { + const agent = this.agentManager.getAgent(params.agentId); + const agentTitle = agent?.config?.title ?? agent?.cwd ?? params.agentId; + const title = + params.reason === "permission" ? "Agent needs permission" : "Agent finished"; + const body = + params.reason === "permission" + ? `Permission requested: ${agentTitle}` + : `Finished: ${agentTitle}`; + void this.pushService.sendPush(tokens, { - title: "Agent needs attention", - body: `Reason: ${params.reason}`, - data: { agentId: params.agentId }, + title, + body, + data: { agentId: params.agentId, reason: params.reason }, }); } }