This commit is contained in:
talorr
2026-03-27 03:36:08 +03:00
parent 8a97ce6d54
commit cda36918e8
225 changed files with 35641 additions and 0 deletions

View File

@@ -0,0 +1,784 @@
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
};
}