feat: add presence-gated Expo push notifications
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import "@/styles/unistyles";
|
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 { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
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 { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
|
||||||
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
|
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { Platform } from "react-native";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
import { SlidingSidebar } from "@/components/sliding-sidebar";
|
import { SlidingSidebar } from "@/components/sliding-sidebar";
|
||||||
import { DownloadToast } from "@/components/download-toast";
|
import { DownloadToast } from "@/components/download-toast";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
@@ -34,6 +35,63 @@ import {
|
|||||||
import { getIsTauriMac } from "@/constants/layout";
|
import { getIsTauriMac } from "@/constants/layout";
|
||||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||||
|
|
||||||
|
function PushNotificationRouter() {
|
||||||
|
const router = useRouter();
|
||||||
|
const lastHandledIdRef = useRef<string | null>(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<string, unknown>
|
||||||
|
| 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 }) {
|
function QueryProvider({ children }: { children: ReactNode }) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
@@ -335,6 +393,7 @@ export default function RootLayout() {
|
|||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<DaemonRegistryProvider>
|
<DaemonRegistryProvider>
|
||||||
<DaemonConnectionsProvider>
|
<DaemonConnectionsProvider>
|
||||||
|
<PushNotificationRouter />
|
||||||
<MultiDaemonSessionHost />
|
<MultiDaemonSessionHost />
|
||||||
<ProvidersWrapper>
|
<ProvidersWrapper>
|
||||||
<SidebarAnimationProvider>
|
<SidebarAnimationProvider>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { useDaemonClient } from "@/hooks/use-daemon-client";
|
import { useDaemonClient } from "@/hooks/use-daemon-client";
|
||||||
import { useAudioPlayer } from "@/hooks/use-audio-player";
|
import { useAudioPlayer } from "@/hooks/use-audio-player";
|
||||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||||
|
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||||
import {
|
import {
|
||||||
applyStreamEvent,
|
applyStreamEvent,
|
||||||
generateMessageId,
|
generateMessageId,
|
||||||
@@ -388,6 +389,7 @@ export function SessionProvider({
|
|||||||
|
|
||||||
// Client activity tracking (heartbeat, push token registration)
|
// Client activity tracking (heartbeat, push token registration)
|
||||||
useClientActivity({ client, focusedAgentId });
|
useClientActivity({ client, focusedAgentId });
|
||||||
|
usePushTokenRegistration({ client, serverId });
|
||||||
|
|
||||||
// State for voice detection flags (will be set by RealtimeContext)
|
// State for voice detection flags (will be set by RealtimeContext)
|
||||||
const isDetectingRef = useRef(false);
|
const isDetectingRef = useRef(false);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AppState, Platform } from "react-native";
|
|||||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||||
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||||
|
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
|
||||||
|
|
||||||
interface ClientActivityOptions {
|
interface ClientActivityOptions {
|
||||||
client: DaemonClientV2;
|
client: DaemonClientV2;
|
||||||
@@ -12,14 +13,15 @@ interface ClientActivityOptions {
|
|||||||
/**
|
/**
|
||||||
* Handles client activity reporting:
|
* Handles client activity reporting:
|
||||||
* - Heartbeat sending every 15 seconds
|
* - Heartbeat sending every 15 seconds
|
||||||
* - App visibility tracking (updates lastActivityAt on foreground)
|
* - App visibility tracking
|
||||||
* - Sends heartbeat immediately when focused agent changes
|
* - Records lastActivityAt only on real user activity (not on heartbeat)
|
||||||
*/
|
*/
|
||||||
export function useClientActivity({ client, focusedAgentId }: ClientActivityOptions): void {
|
export function useClientActivity({ client, focusedAgentId }: ClientActivityOptions): void {
|
||||||
const lastActivityAtRef = useRef<Date>(new Date());
|
const lastActivityAtRef = useRef<Date>(new Date());
|
||||||
const appVisibleRef = useRef(AppState.currentState === "active");
|
const appVisibleRef = useRef(AppState.currentState === "active");
|
||||||
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
|
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
|
||||||
|
const lastImmediateHeartbeatAtRef = useRef<number>(0);
|
||||||
|
|
||||||
const deviceType = Platform.OS === "web" ? "web" : "mobile";
|
const deviceType = Platform.OS === "web" ? "web" : "mobile";
|
||||||
|
|
||||||
@@ -28,17 +30,7 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendHeartbeat = useCallback(() => {
|
const sendHeartbeat = useCallback(() => {
|
||||||
if (!client.isConnected) {
|
if (!client.isConnected) return;
|
||||||
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,
|
|
||||||
});
|
|
||||||
client.sendHeartbeat({
|
client.sendHeartbeat({
|
||||||
deviceType,
|
deviceType,
|
||||||
focusedAgentId,
|
focusedAgentId,
|
||||||
@@ -47,35 +39,77 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
|||||||
});
|
});
|
||||||
}, [client, deviceType, focusedAgentId]);
|
}, [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
|
// Track app visibility
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[ClientActivity] AppState effect mounted, current:", AppState.currentState);
|
|
||||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||||
console.log("[ClientActivity] AppState changed:", nextState);
|
|
||||||
appVisibleRef.current = nextState === "active";
|
appVisibleRef.current = nextState === "active";
|
||||||
if (nextState === "active") {
|
if (nextState === "active") {
|
||||||
recordUserActivity();
|
recordUserActivity();
|
||||||
|
maybeSendImmediateHeartbeat();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => subscription.remove();
|
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
|
// Send heartbeat on focused agent change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevFocusedAgentIdRef.current !== focusedAgentId) {
|
if (prevFocusedAgentIdRef.current !== focusedAgentId) {
|
||||||
console.log("[ClientActivity] focusedAgentId changed:", prevFocusedAgentIdRef.current, "->", focusedAgentId);
|
|
||||||
prevFocusedAgentIdRef.current = focusedAgentId;
|
prevFocusedAgentIdRef.current = focusedAgentId;
|
||||||
|
recordUserActivity();
|
||||||
sendHeartbeat();
|
sendHeartbeat();
|
||||||
}
|
}
|
||||||
}, [focusedAgentId, sendHeartbeat]);
|
}, [focusedAgentId, recordUserActivity, sendHeartbeat]);
|
||||||
|
|
||||||
// Periodic heartbeat
|
// Periodic heartbeat
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[ClientActivity] Heartbeat effect mounted, isConnected:", client.isConnected);
|
|
||||||
|
|
||||||
const startHeartbeat = () => {
|
const startHeartbeat = () => {
|
||||||
console.log("[ClientActivity] startHeartbeat called");
|
|
||||||
if (heartbeatIntervalRef.current) {
|
if (heartbeatIntervalRef.current) {
|
||||||
clearInterval(heartbeatIntervalRef.current);
|
clearInterval(heartbeatIntervalRef.current);
|
||||||
}
|
}
|
||||||
@@ -84,7 +118,6 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopHeartbeat = () => {
|
const stopHeartbeat = () => {
|
||||||
console.log("[ClientActivity] stopHeartbeat called");
|
|
||||||
if (heartbeatIntervalRef.current) {
|
if (heartbeatIntervalRef.current) {
|
||||||
clearInterval(heartbeatIntervalRef.current);
|
clearInterval(heartbeatIntervalRef.current);
|
||||||
heartbeatIntervalRef.current = null;
|
heartbeatIntervalRef.current = null;
|
||||||
@@ -92,7 +125,6 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
|||||||
};
|
};
|
||||||
|
|
||||||
const unsubscribe = client.subscribeConnectionStatus((state) => {
|
const unsubscribe = client.subscribeConnectionStatus((state) => {
|
||||||
console.log("[ClientActivity] Connection status changed:", state.status);
|
|
||||||
if (state.status === "connected") {
|
if (state.status === "connected") {
|
||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
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<string | null>(null);
|
||||||
|
const lastSentTokenRef = useRef<string | null>(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]);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import * as Notifications from "expo-notifications";
|
|
||||||
|
|
||||||
type OsNotificationPayload = {
|
type OsNotificationPayload = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,8 +6,6 @@ type OsNotificationPayload = {
|
|||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let isNativeConfigured = false;
|
|
||||||
let permissionState: "unknown" | "granted" | "denied" = "unknown";
|
|
||||||
let permissionRequest: Promise<boolean> | null = null;
|
let permissionRequest: Promise<boolean> | null = null;
|
||||||
|
|
||||||
function getWebNotificationConstructor(): {
|
function getWebNotificationConstructor(): {
|
||||||
@@ -20,73 +17,25 @@ function getWebNotificationConstructor(): {
|
|||||||
return NotificationConstructor ?? null;
|
return NotificationConstructor ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function configureNativeNotifications(): Promise<void> {
|
|
||||||
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<boolean> {
|
async function ensureNotificationPermission(): Promise<boolean> {
|
||||||
if (Platform.OS === "web") {
|
const NotificationConstructor = getWebNotificationConstructor();
|
||||||
const NotificationConstructor = getWebNotificationConstructor();
|
if (!NotificationConstructor) {
|
||||||
if (!NotificationConstructor) {
|
return false;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
if (NotificationConstructor.permission === "granted") {
|
||||||
if (permissionState === "granted") {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (permissionState === "denied") {
|
if (NotificationConstructor.permission === "denied") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (permissionRequest) {
|
if (permissionRequest) {
|
||||||
return permissionRequest;
|
return permissionRequest;
|
||||||
}
|
}
|
||||||
|
permissionRequest = Promise.resolve(
|
||||||
permissionRequest = (async () => {
|
NotificationConstructor.requestPermission
|
||||||
const existing = await Notifications.getPermissionsAsync();
|
? NotificationConstructor.requestPermission()
|
||||||
if (existing.status === "granted") {
|
: "denied"
|
||||||
permissionState = "granted";
|
).then((permission) => permission === "granted");
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!existing.canAskAgain) {
|
|
||||||
permissionState = "denied";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const requested = await Notifications.requestPermissionsAsync();
|
|
||||||
permissionState = requested.status === "granted" ? "granted" : "denied";
|
|
||||||
return permissionState === "granted";
|
|
||||||
})();
|
|
||||||
|
|
||||||
const result = await permissionRequest;
|
const result = await permissionRequest;
|
||||||
permissionRequest = null;
|
permissionRequest = null;
|
||||||
return result;
|
return result;
|
||||||
@@ -95,36 +44,22 @@ async function ensureNotificationPermission(): Promise<boolean> {
|
|||||||
export async function sendOsNotification(
|
export async function sendOsNotification(
|
||||||
payload: OsNotificationPayload
|
payload: OsNotificationPayload
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (Platform.OS === "web") {
|
// Mobile/native notifications should be remote push only.
|
||||||
const NotificationConstructor = getWebNotificationConstructor();
|
if (Platform.OS !== "web") {
|
||||||
if (!NotificationConstructor) {
|
return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const granted = await ensureNotificationPermission();
|
|
||||||
if (!granted) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
new NotificationConstructor(payload.title, {
|
|
||||||
body: payload.body,
|
|
||||||
data: payload.data,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await configureNativeNotifications();
|
const NotificationConstructor = getWebNotificationConstructor();
|
||||||
|
if (!NotificationConstructor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const granted = await ensureNotificationPermission();
|
const granted = await ensureNotificationPermission();
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
new NotificationConstructor(payload.title, {
|
||||||
await Notifications.scheduleNotificationAsync({
|
body: payload.body,
|
||||||
content: {
|
data: payload.data,
|
||||||
title: payload.title,
|
|
||||||
body: payload.body,
|
|
||||||
data: payload.data,
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,38 @@
|
|||||||
import type pino from "pino";
|
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.
|
* Store for Expo push tokens.
|
||||||
* Tokens are used to send push notifications when all clients are stale.
|
*
|
||||||
|
* Tokens are persisted to disk so pushes still work after daemon restarts.
|
||||||
*/
|
*/
|
||||||
export class PushTokenStore {
|
export class PushTokenStore {
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private tokens: Set<string> = new Set();
|
private tokens: Set<string> = new Set();
|
||||||
|
private readonly filePath: string;
|
||||||
|
|
||||||
constructor(logger: pino.Logger) {
|
constructor(logger: pino.Logger, filePath: string) {
|
||||||
this.logger = logger.child({ component: "token-store" });
|
this.logger = logger.child({ component: "token-store" });
|
||||||
|
this.filePath = filePath;
|
||||||
|
this.loadFromDisk();
|
||||||
}
|
}
|
||||||
|
|
||||||
addToken(token: string): void {
|
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");
|
this.logger.debug({ total: this.tokens.size }, "Added token");
|
||||||
}
|
}
|
||||||
|
|
||||||
removeToken(token: string): void {
|
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) {
|
if (deleted) {
|
||||||
|
this.persist();
|
||||||
this.logger.debug({ total: this.tokens.size }, "Removed token");
|
this.logger.debug({ total: this.tokens.size }, "Removed token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,4 +40,35 @@ export class PushTokenStore {
|
|||||||
getAllTokens(): string[] {
|
getAllTokens(): string[] {
|
||||||
return Array.from(this.tokens);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ export class WebSocketSessionBridge {
|
|||||||
this.dictation = dictation ?? null;
|
this.dictation = dictation ?? null;
|
||||||
|
|
||||||
const pushLogger = this.logger.child({ module: "push" });
|
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.pushService = new PushService(pushLogger, this.pushTokenStore);
|
||||||
|
|
||||||
this.agentManager.setAgentAttentionCallback((params) => {
|
this.agentManager.setAgentAttentionCallback((params) => {
|
||||||
@@ -391,16 +394,42 @@ export class WebSocketSessionBridge {
|
|||||||
"broadcastAgentAttention"
|
"broadcastAgentAttention"
|
||||||
);
|
);
|
||||||
|
|
||||||
const allClientsStale = allStates.every((state) => state.isStale);
|
const hasActiveWebClient = allStates.some(
|
||||||
this.logger.debug({ allClientsStale }, "Client staleness check");
|
(state) => state.deviceType === "web" && !state.isStale
|
||||||
if (allClientsStale) {
|
);
|
||||||
|
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();
|
const tokens = this.pushTokenStore.getAllTokens();
|
||||||
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
||||||
if (tokens.length > 0) {
|
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, {
|
void this.pushService.sendPush(tokens, {
|
||||||
title: "Agent needs attention",
|
title,
|
||||||
body: `Reason: ${params.reason}`,
|
body,
|
||||||
data: { agentId: params.agentId },
|
data: { agentId: params.agentId, reason: params.reason },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user