import { App as CapacitorApp } from "@capacitor/app"; import { Capacitor } from "@capacitor/core"; import { Device } from "@capacitor/device"; import { Preferences } from "@capacitor/preferences"; import { PushNotifications, type Token } from "@capacitor/push-notifications"; type PushSubscriptionsResponse = { items: Array<{ id: string; active: boolean; createdAt: string; updatedAt: string; endpoint?: string; platform?: string; }>; hasActiveSubscription: boolean; }; type BeforeInstallPromptEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; }; type NotificationPermissionState = "default" | "denied" | "granted" | "prompt" | "unsupported"; const ANON_PUSH_CLIENT_ID_KEY = "anonymous-push-client-id"; const PENDING_PUSH_ROUTE_KEY = "pending-push-route"; export function usePush() { const { user, token } = useAuth(); const config = useRuntimeConfig(); const installPromptEvent = useState("pwa-install-prompt-event", () => null); const anonymousClientId = useState("anonymous-push-client-id", () => null); const nativePushToken = useState("native-push-token", () => null); const nativePushDeviceId = useState("native-push-device-id", () => null); const nativePushInitialized = useState("native-push-initialized", () => false); const webPushClickInitialized = useState("web-push-click-initialized", () => false); const pendingPushRoute = useState("pending-push-route", () => null); const isNativeApp = () => process.client && Capacitor.isNativePlatform(); const getNativePlatform = () => { if (!isNativeApp()) { return null; } const platform = Capacitor.getPlatform(); if (platform === "android" || platform === "ios") { return platform; } return null; }; const readStoredAnonymousClientId = async () => { if (!process.client) { return null; } if (isNativeApp()) { const result = await Preferences.get({ key: ANON_PUSH_CLIENT_ID_KEY }); return result.value; } return localStorage.getItem(ANON_PUSH_CLIENT_ID_KEY); }; const persistAnonymousClientId = async (value: string) => { if (!process.client) { return; } if (isNativeApp()) { await Preferences.set({ key: ANON_PUSH_CLIENT_ID_KEY, value }); return; } localStorage.setItem(ANON_PUSH_CLIENT_ID_KEY, value); }; const clearAnonymousClientId = async () => { if (!process.client) { return; } anonymousClientId.value = null; if (isNativeApp()) { await Preferences.remove({ key: ANON_PUSH_CLIENT_ID_KEY }); return; } localStorage.removeItem(ANON_PUSH_CLIENT_ID_KEY); }; const readStoredPendingPushRoute = async () => { if (!process.client) { return null; } if (isNativeApp()) { const result = await Preferences.get({ key: PENDING_PUSH_ROUTE_KEY }); return result.value; } return localStorage.getItem(PENDING_PUSH_ROUTE_KEY); }; const persistPendingPushRoute = async (value: string | null) => { if (!process.client) { return; } pendingPushRoute.value = value; if (isNativeApp()) { if (value) { await Preferences.set({ key: PENDING_PUSH_ROUTE_KEY, value }); } else { await Preferences.remove({ key: PENDING_PUSH_ROUTE_KEY }); } return; } if (value) { localStorage.setItem(PENDING_PUSH_ROUTE_KEY, value); } else { localStorage.removeItem(PENDING_PUSH_ROUTE_KEY); } }; const normalizeTargetRoute = (targetUrl: unknown) => { if (typeof targetUrl !== "string" || targetUrl.length === 0) { return null; } if (targetUrl.startsWith("/")) { return targetUrl; } try { const parsed = new URL(targetUrl); return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/"; } catch { return null; } }; const openPushTarget = async (targetUrl: unknown) => { const route = normalizeTargetRoute(targetUrl); if (!route) { return; } await persistPendingPushRoute(route); if (user.value) { await navigateTo(route); await persistPendingPushRoute(null); return; } await navigateTo(`/login?redirect=${encodeURIComponent(route)}`); }; const consumePendingPushRoute = async () => { if (!process.client) { return null; } if (!pendingPushRoute.value) { pendingPushRoute.value = await readStoredPendingPushRoute(); } const route = pendingPushRoute.value; if (!route || !user.value) { return null; } await navigateTo(route); await persistPendingPushRoute(null); return route; }; const ensureAnonymousClientId = async () => { if (!process.client) { return "server-client"; } if (!anonymousClientId.value) { anonymousClientId.value = await readStoredAnonymousClientId(); } if (!anonymousClientId.value) { anonymousClientId.value = crypto.randomUUID().replace(/[^a-zA-Z0-9_-]/g, ""); await persistAnonymousClientId(anonymousClientId.value); } return anonymousClientId.value; }; const registerServiceWorker = async () => { if (!process.client || isNativeApp() || !("serviceWorker" in navigator)) { return null; } return navigator.serviceWorker.register("/sw.js"); }; const syncServiceWorkerContext = async () => { if (!process.client || isNativeApp() || !("serviceWorker" in navigator)) { return; } const registration = await registerServiceWorker(); const readyRegistration = registration ?? (await navigator.serviceWorker.ready); const worker = readyRegistration.active ?? navigator.serviceWorker.controller; if (!worker) { return; } worker.postMessage({ type: "push-context-sync", payload: { apiBase: config.public.apiBase, isAuthenticated: Boolean(user.value), anonymousClientId: await ensureAnonymousClientId() } }); }; const urlBase64ToUint8Array = (base64String: string) => { const normalizedInput = base64String.trim(); if (!normalizedInput || normalizedInput.startsWith("replace_")) { throw new Error("Push-уведомления еще не настроены на сервере"); } const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const base64 = (normalizedInput + padding).replace(/-/g, "+").replace(/_/g, "/"); try { const rawData = window.atob(base64); return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); } catch { throw new Error("На сервере задан некорректный VAPID public key"); } }; const isMobileDevice = () => { if (!process.client) { return false; } if (isNativeApp()) { return true; } return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); }; const isIosDevice = () => { if (!process.client) { return false; } if (isNativeApp()) { return Capacitor.getPlatform() === "ios"; } return /iPhone|iPad|iPod/i.test(navigator.userAgent); }; const isStandaloneMode = () => { if (!process.client) { return false; } if (isNativeApp()) { return true; } const standaloneMedia = window.matchMedia?.("(display-mode: standalone)")?.matches ?? false; const standaloneNavigator = "standalone" in navigator ? Boolean((navigator as Navigator & { standalone?: boolean }).standalone) : false; return standaloneMedia || standaloneNavigator; }; const setInstallPromptEvent = (event: BeforeInstallPromptEvent | null) => { installPromptEvent.value = event; }; const canTriggerInstallPrompt = () => !isNativeApp() && Boolean(installPromptEvent.value); const triggerInstallPrompt = async () => { if (!installPromptEvent.value) { return false; } await installPromptEvent.value.prompt(); const choice = await installPromptEvent.value.userChoice; installPromptEvent.value = null; return choice.outcome === "accepted"; }; const getBrowserPermissionState = (): NotificationPermissionState => { if (!process.client || !("Notification" in window)) { return "unsupported"; } return Notification.permission; }; const getNativePermissionState = async (): Promise => { if (!isNativeApp()) { return "unsupported"; } const permissions = await PushNotifications.checkPermissions(); if (permissions.receive === "prompt") { return "default"; } return permissions.receive; }; const getPermissionState = () => { if (isNativeApp()) { return getNativePermissionState(); } return Promise.resolve(getBrowserPermissionState()); }; const requestBrowserPermission = async (): Promise => { if (!process.client || !("Notification" in window)) { return "unsupported"; } return Notification.requestPermission(); }; const requestNativePermission = async (): Promise => { if (!isNativeApp()) { return "unsupported"; } const permissions = await PushNotifications.requestPermissions(); if (permissions.receive === "prompt") { return "default"; } return permissions.receive; }; const requestPermission = () => { if (isNativeApp()) { return requestNativePermission(); } return requestBrowserPermission(); }; const syncWebSubscription = async (subscription: PushSubscription) => { const serializedSubscription = subscription.toJSON(); if (!serializedSubscription.endpoint || !serializedSubscription.keys?.p256dh || !serializedSubscription.keys?.auth) { throw new Error("Браузер вернул неполную push-подписку"); } if (user.value) { await useApi("/me/push-subscriptions", { method: "POST", body: serializedSubscription }); return; } await useApi("/public/push-subscriptions", { method: "POST", body: { clientId: await ensureAnonymousClientId(), ...serializedSubscription } }); }; const syncNativeSubscription = async (tokenValue = nativePushToken.value) => { const platform = getNativePlatform(); if (!platform || !tokenValue) { return; } if (!nativePushDeviceId.value) { const device = await Device.getId(); nativePushDeviceId.value = device.identifier; } const body = { token: tokenValue, platform, deviceId: nativePushDeviceId.value ?? undefined }; if (user.value) { await useApi("/me/native-push-subscriptions", { method: "POST", body }); return; } await useApi("/public/native-push-subscriptions", { method: "POST", body: { clientId: await ensureAnonymousClientId(), ...body } }); }; const initializeNativePush = async () => { if (!isNativeApp() || nativePushInitialized.value) { return; } nativePushInitialized.value = true; await PushNotifications.createChannel({ id: "signals", name: "Signals", description: "Уведомления о новых сигналах", importance: 5, visibility: 1, sound: "default" }).catch(() => undefined); await PushNotifications.addListener("registration", async (registrationToken: Token) => { nativePushToken.value = registrationToken.value; await syncNativeSubscription(registrationToken.value); }); await PushNotifications.addListener("registrationError", (error) => { console.error("Native push registration failed", error); }); await PushNotifications.addListener("pushNotificationActionPerformed", (notification) => { const targetUrl = notification.notification.data?.url || notification.notification.data?.link || notification.notification.data?.path; void openPushTarget(targetUrl); }); await CapacitorApp.addListener("appUrlOpen", ({ url }) => { if (!url) { return; } try { const parsed = new URL(url); const route = `${parsed.pathname}${parsed.search}${parsed.hash}`; void openPushTarget(route || "/"); } catch { // Ignore invalid deep links. } }); }; const initializeWebPushRouting = () => { if (!process.client || isNativeApp() || webPushClickInitialized.value || !("serviceWorker" in navigator)) { return; } webPushClickInitialized.value = true; navigator.serviceWorker.addEventListener("message", (event) => { if (event.data?.type !== "push-notification-click") { return; } void openPushTarget(event.data.url); }); }; const getCurrentBrowserSubscription = async () => { const registration = await registerServiceWorker(); if (!registration || !("PushManager" in window)) { return null; } return registration.pushManager.getSubscription(); }; const claimAnonymousPushSubscriptions = async () => { if (!process.client || !user.value) { return; } const existingClientId = anonymousClientId.value ?? (await readStoredAnonymousClientId()); if (!existingClientId) { await syncServiceWorkerContext(); return; } anonymousClientId.value = existingClientId; if (isNativeApp()) { if (nativePushToken.value) { await syncNativeSubscription(nativePushToken.value); } const anonymousSubscriptions = await useApi( `/public/native-push-subscriptions/${existingClientId}` ).catch(() => null); for (const subscription of anonymousSubscriptions?.items ?? []) { await useApi(`/public/native-push-subscriptions/${existingClientId}/${subscription.id}`, { method: "DELETE" }).catch(() => undefined); } } else { const currentSubscription = await getCurrentBrowserSubscription(); if (currentSubscription) { await syncWebSubscription(currentSubscription); } const anonymousSubscriptions = await useApi( `/public/push-subscriptions/${existingClientId}` ).catch(() => null); for (const subscription of anonymousSubscriptions?.items ?? []) { await useApi(`/public/push-subscriptions/${existingClientId}/${subscription.id}`, { method: "DELETE" }).catch(() => undefined); } } await clearAnonymousClientId(); await syncServiceWorkerContext(); }; const getCurrentSubscription = async () => { if (isNativeApp()) { return nativePushToken.value; } return getCurrentBrowserSubscription(); }; const deactivateCurrentPushSubscription = async () => { if (!process.client || !user.value) { return; } if (isNativeApp()) { if (!nativePushToken.value) { return; } await useApi("/me/native-push-subscriptions/deactivate", { method: "POST", body: { token: nativePushToken.value } }); return; } const currentSubscription = await getCurrentBrowserSubscription(); if (!currentSubscription) { return; } const serializedSubscription = currentSubscription.toJSON(); if (!serializedSubscription.endpoint) { return; } await useApi("/me/push-subscriptions/deactivate", { method: "POST", body: { endpoint: serializedSubscription.endpoint } }); await currentSubscription.unsubscribe().catch(() => undefined); }; const subscribeToBrowserPush = async () => { const registration = await registerServiceWorker(); if (!registration || !("PushManager" in window)) { throw new Error("Push не поддерживается в этом браузере"); } const vapid = await useApi<{ publicKey: string }>("/vapid-public-key"); if (!vapid.publicKey || vapid.publicKey.startsWith("replace_")) { throw new Error("Push-уведомления еще не настроены на сервере"); } const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapid.publicKey) }); await syncWebSubscription(subscription); return subscription; }; const subscribeToNativePush = async () => { await initializeNativePush(); let permission = await getNativePermissionState(); if (permission === "default") { permission = await requestNativePermission(); } if (permission !== "granted") { throw new Error("Разрешение на уведомления не выдано"); } const device = await Device.getId(); nativePushDeviceId.value = device.identifier; return new Promise((resolve, reject) => { let finished = false; const finalize = (callback: () => void) => { if (finished) { return; } finished = true; callback(); }; PushNotifications.addListener("registration", async (registrationToken: Token) => { finalize(() => { nativePushToken.value = registrationToken.value; void syncNativeSubscription(registrationToken.value); resolve(registrationToken.value); }); }).catch(reject); PushNotifications.addListener("registrationError", (error) => { finalize(() => { reject(new Error(error.error || "Не удалось зарегистрировать устройство для push")); }); }).catch(reject); PushNotifications.register().catch((error) => { finalize(() => { reject(error instanceof Error ? error : new Error("Не удалось зарегистрировать устройство для push")); }); }); }); }; const subscribeToPush = () => { if (isNativeApp()) { return subscribeToNativePush(); } return subscribeToBrowserPush(); }; const ensurePushSubscription = async () => { const permission = await getPermissionState(); if (permission === "unsupported") { throw new Error("Push не поддерживается на этом устройстве"); } let finalPermission = permission; if (finalPermission === "default") { finalPermission = await requestPermission(); } if (finalPermission !== "granted") { throw new Error("Разрешение на уведомления не выдано"); } if (isNativeApp()) { if (nativePushToken.value) { await syncNativeSubscription(nativePushToken.value); return nativePushToken.value; } return subscribeToNativePush(); } const currentSubscription = await getCurrentBrowserSubscription(); if (currentSubscription) { await syncWebSubscription(currentSubscription); return currentSubscription; } return subscribeToBrowserPush(); }; const getPushStatus = async () => { const permission = await getPermissionState(); const isMobile = isMobileDevice(); const isIos = isIosDevice(); const isStandalone = isStandaloneMode(); const installRequired = !isNativeApp() && isMobile && !isStandalone; if (permission === "unsupported") { return { supported: false, permission, isMobile, isIos, isStandalone, installRequired, canInstall: canTriggerInstallPrompt(), hasBrowserSubscription: false, hasServerSubscription: false }; } let hasServerSubscription = false; try { if (isNativeApp()) { if (user.value) { const serverSubscriptions = await useApi("/me/native-push-subscriptions"); hasServerSubscription = serverSubscriptions.hasActiveSubscription; } else { const clientId = await ensureAnonymousClientId(); const serverSubscriptions = await useApi(`/public/native-push-subscriptions/${clientId}`); hasServerSubscription = serverSubscriptions.hasActiveSubscription; } } else if (user.value) { const serverSubscriptions = await useApi("/me/push-subscriptions"); hasServerSubscription = serverSubscriptions.hasActiveSubscription; } else { const clientId = await ensureAnonymousClientId(); const serverSubscriptions = await useApi(`/public/push-subscriptions/${clientId}`); hasServerSubscription = serverSubscriptions.hasActiveSubscription; } } catch { hasServerSubscription = false; } return { supported: true, permission, isMobile, isIos, isStandalone, installRequired, canInstall: canTriggerInstallPrompt(), hasBrowserSubscription: isNativeApp() ? Boolean(nativePushToken.value) : Boolean(await getCurrentBrowserSubscription()), hasServerSubscription }; }; return { registerServiceWorker, syncServiceWorkerContext, syncNativeSubscription, initializeNativePush, initializeWebPushRouting, isNativeApp, isMobileDevice, isIosDevice, isStandaloneMode, getPermissionState, requestPermission, getCurrentSubscription, deactivateCurrentPushSubscription, getPushStatus, subscribeToPush, ensurePushSubscription, claimAnonymousPushSubscriptions, consumePendingPushRoute, setInstallPromptEvent, canTriggerInstallPrompt, triggerInstallPrompt, ensureAnonymousClientId }; }