Stop notifications after removing a host (#3176)
* fix(notifications): stop pushes after host removal Push tokens were persisted indefinitely with no relationship to app pairing state. Give registrations a 48-hour renewable lease so offline unpairing converges, and revoke immediately when the host is reachable. * fix(notifications): make token revocation bounded and durable
This commit is contained in:
@@ -5,7 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
import { startPushNotifications } from "@/push-notifications";
|
||||
import {
|
||||
createSetAgentInitializing,
|
||||
refreshAgentInitializationTimeout,
|
||||
@@ -418,7 +418,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
// Client activity tracking (heartbeat, push token registration)
|
||||
useClientActivity({ client, focusedAgentId, focusedTerminalId, onAppResumed: handleAppResumed });
|
||||
usePushTokenRegistration({ client, serverId });
|
||||
useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]);
|
||||
|
||||
const notifyAgentAttention = useCallback(
|
||||
(params: {
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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 { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const STORAGE_PREFIX = "@paseo:expo-push-token:";
|
||||
|
||||
function getExpoProjectId(): string | null {
|
||||
const constants = Constants as unknown as {
|
||||
easConfig?: { projectId?: unknown };
|
||||
expoConfig?: { extra?: { eas?: { projectId?: unknown } } };
|
||||
};
|
||||
const fromEas = constants?.easConfig?.projectId;
|
||||
if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim();
|
||||
|
||||
const fromExtra = constants?.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 === Notifications.PermissionStatus.GRANTED) return true;
|
||||
if (!existing.canAskAgain) return false;
|
||||
const requested = await Notifications.requestPermissionsAsync();
|
||||
return requested.status === Notifications.PermissionStatus.GRANTED;
|
||||
}
|
||||
|
||||
export function usePushTokenRegistration(params: { client: DaemonClient; serverId: string }): void {
|
||||
const { client, serverId } = params;
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
const lastSentTokenRef = useRef<string | null>(null);
|
||||
|
||||
const registerIfPossible = useCallback(async () => {
|
||||
if (isWeb) 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 (isWeb) 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]);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { revokeSubscription, startSubscription } from "./internal/subscriptions";
|
||||
import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./internal/types";
|
||||
|
||||
export function startPushNotifications(input: StartPushNotificationsInput): () => void {
|
||||
return startSubscription(input);
|
||||
}
|
||||
|
||||
export function revokePushNotifications(input: RevokePushNotificationsInput): Promise<void> {
|
||||
return revokeSubscription(input).catch((error) => {
|
||||
console.warn("[PushNotifications] Failed to remove local push subscription", error);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./internal/types";
|
||||
|
||||
export function startPushNotifications(_input: StartPushNotificationsInput): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
export async function revokePushNotifications(_input: RevokePushNotificationsInput): Promise<void> {
|
||||
// Push notifications are native-only.
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./internal/types";
|
||||
|
||||
export function startPushNotifications(_input: StartPushNotificationsInput): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
export async function revokePushNotifications(_input: RevokePushNotificationsInput): Promise<void> {
|
||||
// Push notifications are native-only.
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import Constants from "expo-constants";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Platform } from "react-native";
|
||||
import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./types";
|
||||
|
||||
const STORAGE_PREFIX = "@paseo:expo-push-token:";
|
||||
|
||||
function storageKey(serverId: string): string {
|
||||
return `${STORAGE_PREFIX}${serverId}`;
|
||||
}
|
||||
|
||||
function getExpoProjectId(): string | null {
|
||||
const constants = Constants as unknown as {
|
||||
easConfig?: { projectId?: unknown };
|
||||
expoConfig?: { extra?: { eas?: { projectId?: unknown } } };
|
||||
};
|
||||
const fromEas = constants.easConfig?.projectId;
|
||||
if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim();
|
||||
const fromExtra = constants.expoConfig?.extra?.eas?.projectId;
|
||||
return typeof fromExtra === "string" && fromExtra.trim() ? fromExtra.trim() : null;
|
||||
}
|
||||
|
||||
async function ensurePushPermission(): Promise<boolean> {
|
||||
const existing = await Notifications.getPermissionsAsync();
|
||||
if (existing.status === Notifications.PermissionStatus.GRANTED) return true;
|
||||
if (!existing.canAskAgain) return false;
|
||||
const requested = await Notifications.requestPermissionsAsync();
|
||||
return requested.status === Notifications.PermissionStatus.GRANTED;
|
||||
}
|
||||
|
||||
async function resolveToken(serverId: string): Promise<string | null> {
|
||||
const key = storageKey(serverId);
|
||||
const cached = await AsyncStorage.getItem(key);
|
||||
if (!(await ensurePushPermission())) {
|
||||
await AsyncStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
await Notifications.setNotificationChannelAsync("default", {
|
||||
name: "default",
|
||||
importance: Notifications.AndroidImportance.DEFAULT,
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = getExpoProjectId();
|
||||
if (!projectId) {
|
||||
console.warn("[PushNotifications] Missing EAS projectId; cannot fetch Expo push token");
|
||||
return cached;
|
||||
}
|
||||
|
||||
const result = await Notifications.getExpoPushTokenAsync({ projectId });
|
||||
const token = result.data.trim();
|
||||
if (!token) return cached;
|
||||
await AsyncStorage.setItem(key, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function startSubscription(input: StartPushNotificationsInput): () => void {
|
||||
let stopped = false;
|
||||
let token: string | null = null;
|
||||
const register = () => {
|
||||
if (!stopped && token && input.client.isConnected) {
|
||||
input.client.registerPushToken(token);
|
||||
}
|
||||
};
|
||||
|
||||
void resolveToken(input.serverId)
|
||||
.then((resolved) => {
|
||||
if (stopped) return undefined;
|
||||
token = resolved;
|
||||
register();
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => console.warn("[PushNotifications] Failed to register push token", error));
|
||||
|
||||
const unsubscribe = input.client.subscribeConnectionStatus((state) => {
|
||||
if (state.status === "connected") register();
|
||||
});
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeSubscription(input: RevokePushNotificationsInput): Promise<void> {
|
||||
const key = storageKey(input.serverId);
|
||||
const token = await AsyncStorage.getItem(key);
|
||||
if (
|
||||
token &&
|
||||
input.client?.isConnected &&
|
||||
input.client.getLastServerInfoMessage()?.features?.pushTokenRevocation === true
|
||||
) {
|
||||
try {
|
||||
await input.client.unregisterPushToken(token);
|
||||
} catch (error) {
|
||||
console.warn("[PushNotifications] Failed to revoke push token", error);
|
||||
}
|
||||
}
|
||||
await AsyncStorage.removeItem(key);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export interface StartPushNotificationsInput {
|
||||
client: DaemonClient;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export interface RevokePushNotificationsInput {
|
||||
client: DaemonClient | null;
|
||||
serverId: string;
|
||||
}
|
||||
@@ -1384,6 +1384,54 @@ describe("HostRuntimeController", () => {
|
||||
});
|
||||
|
||||
describe("HostRuntimeStore", () => {
|
||||
it("revokes push notifications before removing a host", async () => {
|
||||
const host = makeHost({ connections: [makeHost().connections[0]!] });
|
||||
const revocation = createDeferred<void>();
|
||||
const storage = createMemoryHostRuntimeStorage({
|
||||
"@paseo:daemon-registry": JSON.stringify([host]),
|
||||
"@paseo:e2e": "1",
|
||||
});
|
||||
const store = new HostRuntimeStore({
|
||||
storage,
|
||||
deps: makeDeps({}, []),
|
||||
revokePushNotifications: async ({ serverId }) => {
|
||||
expect(serverId).toBe(host.serverId);
|
||||
expect(store.getHosts().map((candidate) => candidate.serverId)).toEqual([host.serverId]);
|
||||
await revocation.promise;
|
||||
},
|
||||
});
|
||||
await store.boot();
|
||||
|
||||
const removal = store.removeHost(host.serverId);
|
||||
expect(store.getHosts().map((candidate) => candidate.serverId)).toEqual([host.serverId]);
|
||||
revocation.resolve();
|
||||
await removal;
|
||||
|
||||
expect(store.getHosts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("revokes push notifications when removing a host's final connection", async () => {
|
||||
const host = makeHost({ connections: [makeHost().connections[0]!] });
|
||||
const revokedServerIds: string[] = [];
|
||||
const storage = createMemoryHostRuntimeStorage({
|
||||
"@paseo:daemon-registry": JSON.stringify([host]),
|
||||
"@paseo:e2e": "1",
|
||||
});
|
||||
const store = new HostRuntimeStore({
|
||||
storage,
|
||||
deps: makeDeps({}, []),
|
||||
revokePushNotifications: async ({ serverId }) => {
|
||||
revokedServerIds.push(serverId);
|
||||
},
|
||||
});
|
||||
await store.boot();
|
||||
|
||||
await store.removeConnection(host.serverId, host.connections[0]!.id);
|
||||
|
||||
expect(revokedServerIds).toEqual([host.serverId]);
|
||||
expect(store.getHosts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("restores the display replica before declaring the host registry loaded", async () => {
|
||||
const host = makeHost();
|
||||
const storage = createMemoryHostRuntimeStorage();
|
||||
|
||||
@@ -57,6 +57,7 @@ import { encodeImages } from "@/utils/encode-images";
|
||||
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
|
||||
import { ReplicaCache } from "@/runtime/replica-cache";
|
||||
import { nativePerformanceTrace } from "@/performance/native-trace";
|
||||
import { revokePushNotifications } from "@/push-notifications";
|
||||
import { createAppWebSocketFactory } from "./websocket-factory";
|
||||
|
||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
@@ -1366,11 +1367,17 @@ export class HostRuntimeStore {
|
||||
private bootPromise: Promise<void> | null = null;
|
||||
private storage: HostRuntimeStorage;
|
||||
private replicaCache: ReplicaCache;
|
||||
private readonly revokePushNotifications: typeof revokePushNotifications;
|
||||
|
||||
constructor(input?: { deps?: HostRuntimeControllerDeps; storage?: HostRuntimeStorage }) {
|
||||
constructor(input?: {
|
||||
deps?: HostRuntimeControllerDeps;
|
||||
storage?: HostRuntimeStorage;
|
||||
revokePushNotifications?: typeof revokePushNotifications;
|
||||
}) {
|
||||
this.deps = input?.deps ?? createDefaultDeps();
|
||||
this.storage = input?.storage ?? AsyncStorage;
|
||||
this.replicaCache = new ReplicaCache(this.storage);
|
||||
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
|
||||
}
|
||||
|
||||
// --- Host registry ---
|
||||
@@ -1849,12 +1856,18 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
async removeHost(serverId: string): Promise<void> {
|
||||
await this.revokePushNotifications({ client: this.getClient(serverId), serverId });
|
||||
const remaining = this.hosts.filter((daemon) => daemon.serverId !== serverId);
|
||||
this.setHostsAndSync(remaining);
|
||||
await this.persistHosts();
|
||||
}
|
||||
|
||||
async removeConnection(serverId: string, connectionId: string): Promise<void> {
|
||||
const host = this.hosts.find((candidate) => candidate.serverId === serverId);
|
||||
if (host?.connections.length === 1 && host.connections[0]?.id === connectionId) {
|
||||
await this.removeHost(serverId);
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const next = this.hosts
|
||||
.map((daemon) => {
|
||||
|
||||
Reference in New Issue
Block a user