Files
talorr cda36918e8 init
2026-03-27 03:36:08 +03:00

785 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
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<BeforeInstallPromptEvent | null>("pwa-install-prompt-event", () => null);
const anonymousClientId = useState<string | null>("anonymous-push-client-id", () => null);
const nativePushToken = useState<string | null>("native-push-token", () => null);
const nativePushDeviceId = useState<string | null>("native-push-device-id", () => null);
const nativePushInitialized = useState<boolean>("native-push-initialized", () => false);
const webPushClickInitialized = useState<boolean>("web-push-click-initialized", () => false);
const pendingPushRoute = useState<string | null>("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<NotificationPermissionState> => {
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<NotificationPermissionState> => {
if (!process.client || !("Notification" in window)) {
return "unsupported";
}
return Notification.requestPermission();
};
const requestNativePermission = async (): Promise<NotificationPermissionState> => {
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<PushSubscriptionsResponse>(
`/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<PushSubscriptionsResponse>(
`/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<string>((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<PushSubscriptionsResponse>("/me/native-push-subscriptions");
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
} else {
const clientId = await ensureAnonymousClientId();
const serverSubscriptions = await useApi<PushSubscriptionsResponse>(`/public/native-push-subscriptions/${clientId}`);
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
}
} else if (user.value) {
const serverSubscriptions = await useApi<PushSubscriptionsResponse>("/me/push-subscriptions");
hasServerSubscription = serverSubscriptions.hasActiveSubscription;
} else {
const clientId = await ensureAnonymousClientId();
const serverSubscriptions = await useApi<PushSubscriptionsResponse>(`/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
};
}