feat(pwa): свой сервис-воркер и включение уведомлений

Воркер теперь собирается через injectManifest: generateSW умеет только кэш, а
уведомление показывает именно воркер — страница при этом может быть закрыта.
Нажатие на уведомление переиспользует уже открытое окно, а не плодит второе.

Включение — строкой на главном экране, по нажатию. Не всплывающим окном на
входе: разрешение спрашивается один раз за установку, отказ браузер запоминает
навсегда, и потраченная впустую попытка закрывает канал. Если сервер уведомления
не настроил — разрешение не запрашиваем вовсе.

Отдельные тексты вместо молчания: на iPhone без установки на домашний экран
уведомления невозможны в принципе, и об этом сказано прямо.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:29:43 +10:00
co-authored by Claude Opus 5
parent 0a09570d98
commit 562a6d35fe
10 changed files with 345 additions and 5 deletions
+3 -1
View File
@@ -33,7 +33,9 @@
"typescript": "^5.5.4",
"vite": "^5.4.2",
"vite-plugin-pwa": "^0.20.5",
"vitest": "^2.0.5"
"vitest": "^2.0.5",
"workbox-core": "^7.4.1",
"workbox-precaching": "^7.4.1"
}
},
"node_modules/@adobe/css-tools": {
+3 -1
View File
@@ -37,6 +37,8 @@
"typescript": "^5.5.4",
"vite": "^5.4.2",
"vite-plugin-pwa": "^0.20.5",
"vitest": "^2.0.5"
"vitest": "^2.0.5",
"workbox-core": "^7.4.1",
"workbox-precaching": "^7.4.1"
}
}
+14
View File
@@ -183,3 +183,17 @@ export const getFines = async (status: "all" | "open") =>
export const getFine = async (id: number) =>
FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json());
export const PushKeySchema = z.object({ key: z.string(), enabled: z.boolean() });
export const getPushKey = async () =>
PushKeySchema.parse(await api.get("driver/push/key").json());
export const subscribePush = async (sub: { endpoint: string; p256dh: string; auth: string }) =>
api.post("driver/push/subscribe", { json: sub }).json();
export const unsubscribePush = async (endpoint: string) =>
api.post("driver/push/unsubscribe", { json: { endpoint } }).json();
/** Пробное уведомление себе. Доступно только при просмотре сотрудником. */
export const sendTestPush = async () => api.post("driver/push/test").json();
@@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { Bell, BellOff } from "lucide-react";
import { toast } from "sonner";
import { currentState, disable, enable, type PushState } from "@/lib/push";
/**
* Включение уведомлений — одной строкой на главном экране.
*
* Не всплывающим окном на входе: разрешение спрашивается один раз за установку,
* и потраченная впустую попытка закрывает канал навсегда. Здесь водитель жмёт
* сам, увидев, ради чего.
*/
export function NotificationsRow() {
const [state, setState] = useState<PushState | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let alive = true;
currentState().then((s) => { if (alive) setState(s); });
return () => { alive = false; };
}, []);
if (state === null || state === "unsupported") return null;
async function toggle() {
setBusy(true);
try {
setState(state === "on" ? await disable() : await enable());
} catch {
toast.error("Не удалось изменить настройку уведомлений");
} finally {
setBusy(false);
}
}
if (state === "needs-install") {
return (
<p className="text-muted text-xs text-center mt-6">
Чтобы получать уведомления о штрафах и платежах, добавьте приложение на домашний экран.
</p>
);
}
if (state === "denied") {
return (
<p className="text-muted text-xs text-center mt-6">
Уведомления запрещены в настройках браузера включить их можно только там.
</p>
);
}
return (
<button
className="flex items-center justify-center gap-1.5 text-muted text-xs mt-6 w-full"
disabled={busy}
onClick={toggle}
>
{state === "on" ? <BellOff size={15} /> : <Bell size={15} />}
{state === "on" ? "Выключить уведомления" : "Включить уведомления"}
</button>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/api/driver", () => ({
getPushKey: vi.fn(async () => ({ key: "BIIh_RELLY", enabled: true })),
subscribePush: vi.fn(async () => ({ ok: true })),
unsubscribePush: vi.fn(async () => ({ ok: true })),
}));
import { currentState, enable, supported } from "./push";
import * as api from "@/api/driver";
const SUB = {
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
toJSON: () => ({
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
keys: { p256dh: "p256", auth: "auth" },
}),
unsubscribe: vi.fn(async () => true),
};
function stubBrowser(opts: {
permission?: NotificationPermission;
existing?: unknown;
ios?: boolean;
standalone?: boolean;
} = {}) {
const perm = opts.permission ?? "default";
const registration = {
pushManager: {
getSubscription: vi.fn(async () => opts.existing ?? null),
subscribe: vi.fn(async () => SUB),
},
};
vi.stubGlobal("navigator", {
serviceWorker: {
getRegistration: vi.fn(async () => registration),
ready: Promise.resolve(registration),
},
userAgent: opts.ios ? "iPhone" : "Android Chrome",
standalone: opts.standalone,
});
const notification = {
permission: perm,
requestPermission: vi.fn(async () => (opts.permission === "denied" ? "denied" : "granted")),
};
// Notification обязан лежать И в window: supported() проверяет именно его —
// так же, как это делает браузер.
vi.stubGlobal("window", {
PushManager: function () {},
Notification: notification,
matchMedia: () => ({ matches: !!opts.standalone }),
});
vi.stubGlobal("Notification", notification);
return registration;
}
describe("уведомления в приложении", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("iPhone без установки на экран: объясняем, а не молчим", async () => {
vi.stubGlobal("navigator", { userAgent: "iPhone", standalone: false });
vi.stubGlobal("window", { matchMedia: () => ({ matches: false }) });
expect(supported()).toBe(false);
expect(await currentState()).toBe("needs-install");
});
it("разрешение отклонено — переспросить нечем", async () => {
stubBrowser({ permission: "denied" });
expect(await currentState()).toBe("denied");
});
it("подписка уходит на сервер с ключами из браузера", async () => {
stubBrowser();
expect(await enable()).toBe("on");
expect(api.subscribePush).toHaveBeenCalledWith({
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
p256dh: "p256",
auth: "auth",
});
});
it("сервер не настроен — разрешение НЕ спрашиваем", async () => {
// Единственная попытка на установку: потратить её впустую нельзя.
vi.mocked(api.getPushKey).mockResolvedValueOnce({ key: "", enabled: false });
stubBrowser();
expect(await enable()).toBe("off");
expect(Notification.requestPermission).not.toHaveBeenCalled();
expect(api.subscribePush).not.toHaveBeenCalled();
});
});
+87
View File
@@ -0,0 +1,87 @@
import { getPushKey, subscribePush, unsubscribePush } from "@/api/driver";
/**
* Включение уведомлений в приложении.
*
* Главное правило экрана вокруг этого кода: разрешение спрашивается ОДИН раз за
* всю жизнь установки. Отказ браузер запоминает, переспросить нельзя — только
* руками в настройках сайта, куда водитель не пойдёт. Поэтому запрос идёт
* строго по нажатию и только когда сервер подтвердил, что уведомления настроены.
*/
export type PushState =
| "unsupported" // браузер не умеет (или iOS без установки на домашний экран)
| "needs-install" // iOS: умеет, но только когда приложение добавлено на экран
| "denied" // разрешение отклонено — переспросить нечем
| "off" // можно включить
| "on"; // подписка есть
function isStandalone(): boolean {
return window.matchMedia("(display-mode: standalone)").matches ||
// iOS до 17 не поддерживает display-mode: standalone в matchMedia
(navigator as { standalone?: boolean }).standalone === true;
}
function isIos(): boolean {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
}
export function supported(): boolean {
return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
}
export async function currentState(): Promise<PushState> {
if (!supported()) return isIos() && !isStandalone() ? "needs-install" : "unsupported";
if (Notification.permission === "denied") return "denied";
const reg = await navigator.serviceWorker.getRegistration();
const sub = await reg?.pushManager.getSubscription();
return sub ? "on" : "off";
}
/** base64url → байты: ключ VAPID браузер принимает только так.
* Возвращаем ArrayBuffer, а не Uint8Array: типы DOM ждут BufferSource. */
function keyToBytes(base64: string): ArrayBuffer {
const padded = base64.replace(/-/g, "+").replace(/_/g, "/") +
"=".repeat((4 - (base64.length % 4)) % 4);
const raw = atob(padded);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
return bytes.buffer;
}
/** Включить уведомления. Возвращает новое состояние. */
export async function enable(): Promise<PushState> {
if (!supported()) return currentState();
const { key, enabled } = await getPushKey();
// Ключа нет — сервер уведомления не отправляет. Разрешение не спрашиваем:
// потратить единственную попытку впустую нельзя.
if (!enabled || !key) return "off";
const permission = await Notification.requestPermission();
if (permission !== "granted") return permission === "denied" ? "denied" : "off";
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: keyToBytes(key),
});
const json = sub.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
await subscribePush({
endpoint: json.endpoint ?? sub.endpoint,
p256dh: json.keys?.p256dh ?? "",
auth: json.keys?.auth ?? "",
});
return "on";
}
/** Выключить: снимаем подписку и в браузере, и на сервере. */
export async function disable(): Promise<PushState> {
const reg = await navigator.serviceWorker.getRegistration();
const sub = await reg?.pushManager.getSubscription();
if (sub) {
// Сначала сервер: не сумеем достучаться — подписка останется живой, и
// уведомления продолжат приходить, а это лучше «выключил, а они идут».
await unsubscribePush(sub.endpoint);
await sub.unsubscribe();
}
return "off";
}
@@ -6,6 +6,7 @@ import { getBalance, type StatementKind } from "@/api/driver";
import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
import { useAuth } from "@/store/auth";
import { AppHeader } from "@/components/AppHeader";
import { NotificationsRow } from "@/components/NotificationsRow";
import { Sheet } from "@/components/Sheet";
import { Spinner } from "@/components/Spinner";
import { Statement } from "@/components/Statement";
@@ -109,6 +110,8 @@ export function BalancePage() {
<Statement from={from} to={to} kind={kind}
onFrom={setFrom} onTo={setTo} onKind={setKind} />
<NotificationsRow />
<div className="flex justify-center mt-6 text-muted text-xs">
{/* Отдельного раздела «Мои пополнения» нет намеренно: пополнения видны в
выписке выше по фильтру, а дублирующий пустой экран сбивал с толку. */}
+69
View File
@@ -0,0 +1,69 @@
/// <reference lib="webworker" />
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
import { clientsClaim } from "workbox-core";
/**
* Сервис-воркер приложения.
*
* Свой, а не сгенерированный: `generateSW` умеет только кэш, а нам нужны
* обработчики `push` и `notificationclick` — уведомления показывает именно
* воркер, страница для этого может быть закрыта.
*
* Кэш остаётся прежним (precache от workbox), поведение обновления тоже:
* `skipWaiting` + `clientsClaim`, как было при registerType: "autoUpdate".
*/
declare const self: ServiceWorkerGlobalScope;
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
self.skipWaiting();
clientsClaim();
interface PushPayload {
title?: string;
body?: string;
url?: string;
tag?: string;
}
self.addEventListener("push", (event: PushEvent) => {
let data: PushPayload = {};
try {
data = event.data ? (event.data.json() as PushPayload) : {};
} catch {
// Пуш без разбираемой нагрузки: показать что-то всё равно надо — молчание
// выглядит как «уведомления не работают».
data = {};
}
const title = data.title || "Премиум Водитель";
event.waitUntil(
self.registration.showNotification(title, {
body: data.body || "",
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
// tag схлопывает однотипные: пять штрафов подряд не завалят шторку.
tag: data.tag,
data: { url: data.url || "/" },
}),
);
});
self.addEventListener("notificationclick", (event: NotificationEvent) => {
event.notification.close();
const target = (event.notification.data?.url as string) || "/";
event.waitUntil(
(async () => {
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
// Уже открытое окно приложения переиспользуем: второй экземпляр рядом с
// первым сбивает с толку, и водитель теряет то, что было на экране.
for (const client of all) {
if ("focus" in client) {
await client.focus();
if ("navigate" in client) await client.navigate(target);
return;
}
}
await self.clients.openWindow(target);
})(),
);
});
+2 -2
View File
@@ -1,11 +1,11 @@
{
"compilerOptions": {
"target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable"],
"target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable", "WebWorker"],
"module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true,
"noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noFallthroughCasesInSwitch": true,
"baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vitest/globals", "@testing-library/jest-dom"]
"baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vite-plugin-pwa/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
+11 -1
View File
@@ -54,7 +54,17 @@ export default defineConfig(({ command, mode }) => {
},
plugins: [
react(),
VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }),
// injectManifest, а не generateSW: воркер свой, потому что уведомления
// показывает он (см. src/sw.ts), а сгенерированный умеет только кэш.
VitePWA({
strategies: "injectManifest",
srcDir: "src",
filename: "sw.ts",
registerType: "autoUpdate",
manifest: false,
includeAssets: ["icons/*.png"],
injectManifest: { globPatterns: ["**/*.{js,css,html,png,svg,webmanifest}"] },
}),
],
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
server: {