mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge pull request #6 from boudra/feat/push-notifications
Presence-gated Expo push notifications
This commit is contained in:
@@ -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<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 }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
@@ -335,6 +393,7 @@ export default function RootLayout() {
|
||||
<QueryProvider>
|
||||
<DaemonRegistryProvider>
|
||||
<DaemonConnectionsProvider>
|
||||
<PushNotificationRouter />
|
||||
<MultiDaemonSessionHost />
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Date>(new Date());
|
||||
const appVisibleRef = useRef(AppState.currentState === "active");
|
||||
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
|
||||
const lastImmediateHeartbeatAtRef = useRef<number>(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 {
|
||||
|
||||
109
packages/app/src/hooks/use-push-token-registration.ts
Normal file
109
packages/app/src/hooks/use-push-token-registration.ts
Normal file
@@ -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 * as Notifications from "expo-notifications";
|
||||
|
||||
type OsNotificationPayload = {
|
||||
title: string;
|
||||
@@ -7,8 +6,6 @@ type OsNotificationPayload = {
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let isNativeConfigured = false;
|
||||
let permissionState: "unknown" | "granted" | "denied" = "unknown";
|
||||
let permissionRequest: Promise<boolean> | null = null;
|
||||
|
||||
function getWebNotificationConstructor(): {
|
||||
@@ -20,73 +17,25 @@ function getWebNotificationConstructor(): {
|
||||
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> {
|
||||
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<boolean> {
|
||||
export async function sendOsNotification(
|
||||
payload: OsNotificationPayload
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<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.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user