From cd3211587109d0bf3e762df128356708fcea24b1 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 27 Nov 2024 22:50:15 +0800 Subject: [PATCH] feat: web push Signed-off-by: Innei --- apps/renderer/package.json | 1 + apps/renderer/src/main.tsx | 9 ++- apps/renderer/src/push-notification.ts | 83 +++++++++++++++++++++ apps/renderer/src/sw.ts | 99 +++++++++++++++++++++++++- package.json | 1 + pnpm-lock.yaml | 51 +++++++++++++ scripts/mitproxy.py | 6 ++ scripts/run-proxy.sh | 39 ++++++++++ 8 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 apps/renderer/src/push-notification.ts create mode 100644 scripts/mitproxy.py create mode 100644 scripts/run-proxy.sh diff --git a/apps/renderer/package.json b/apps/renderer/package.json index 5fa411fc6..c5668a6ac 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -109,6 +109,7 @@ "use-sync-external-store": "1.2.2", "usehooks-ts": "3.1.0", "vfile": "6.0.3", + "web-push": "3.6.7", "zod": "3.23.8", "zustand": "5.0.1" }, diff --git a/apps/renderer/src/main.tsx b/apps/renderer/src/main.tsx index 7beea1e35..ac4358a8b 100644 --- a/apps/renderer/src/main.tsx +++ b/apps/renderer/src/main.tsx @@ -10,12 +10,19 @@ import ReactDOM from "react-dom/client" import { RouterProvider } from "react-router/dom" import { setAppIsReady } from "./atoms/app" -import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "./constants" +import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT, isWebBuild } from "./constants" import { initializeApp } from "./initialize" import { registerAppGlobalShortcuts } from "./initialize/global-shortcuts" +import { registerWebPushNotifications } from "./push-notification" import { router } from "./router" initializeApp().finally(() => { + import("./push-notification").then(({ registerWebPushNotifications }) => { + registerWebPushNotifications() + }) + if (navigator.serviceWorker && isWebBuild) { + registerWebPushNotifications() + } setAppIsReady(true) }) diff --git a/apps/renderer/src/push-notification.ts b/apps/renderer/src/push-notification.ts new file mode 100644 index 000000000..abe6b35bc --- /dev/null +++ b/apps/renderer/src/push-notification.ts @@ -0,0 +1,83 @@ +import { env } from "@follow/shared/env" +import { initializeApp } from "firebase/app" +import { getMessaging, getToken } from "firebase/messaging" + +import { apiClient } from "./lib/api-fetch" +import { router } from "./router" + +const firebaseConfig = env.VITE_FIREBASE_CONFIG ? JSON.parse(env.VITE_FIREBASE_CONFIG) : null + +export async function registerWebPushNotifications() { + if (!firebaseConfig) { + return + } + try { + const existingRegistration = await navigator.serviceWorker.getRegistration() + let registration = existingRegistration + + if (!registration) { + registration = await navigator.serviceWorker.register("/sw.js", { + scope: "/", + }) + } + + await navigator.serviceWorker.ready + + const app = initializeApp(firebaseConfig) + const messaging = getMessaging(app) + + const permission = await Notification.requestPermission() + if (permission !== "granted") { + throw new Error("Notification permission denied") + } + + // get FCM token + const token = await getToken(messaging, { + serviceWorkerRegistration: registration, + }) + + await apiClient.messaging.$post({ + json: { + token, + channel: "desktop", + }, + }) + + registerPushNotificationPostMessage() + + return token + } catch (error) { + if (error instanceof Error) { + throw new TypeError(`Failed to register push notifications: ${error.message}`) + } + throw error + } +} + +interface NavigateEntryMessage { + type: "NOTIFICATION_CLICK" + action: "NAVIGATE_ENTRY" + data: { + feedId: string + entryId: string + view: number + url: string + } +} + +type ServiceWorkerMessage = NavigateEntryMessage + +const registerPushNotificationPostMessage = () => { + navigator.serviceWorker.addEventListener("message", (event) => { + const message = event.data as ServiceWorkerMessage + + if (message.type === "NOTIFICATION_CLICK") { + switch (message.action) { + case "NAVIGATE_ENTRY": { + router.navigate(message.data.url) + break + } + } + } + }) +} diff --git a/apps/renderer/src/sw.ts b/apps/renderer/src/sw.ts index 073c9b811..1cf0126d4 100644 --- a/apps/renderer/src/sw.ts +++ b/apps/renderer/src/sw.ts @@ -6,13 +6,109 @@ import { CacheFirst, NetworkFirst } from "workbox-strategies" declare let self: ServiceWorkerGlobalScope -// 从 Service Worker 的 URL 参数中获取 PWA 状态 const isPWA = new URL(self.location.href).searchParams.get("pwa") === "true" self.addEventListener("message", (event) => { if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting() }) +interface NewEntryMessage { + description: string + entryId: string + feedId: string + title: string + type: "new-entry" + view: string +} + +type Message = NewEntryMessage + +// Firebase Cloud Messaging handler +self.addEventListener("push", (event) => { + if (event.data) { + const { data } = event.data.json() + const payload = data as Message + + switch (payload.type) { + case "new-entry": { + const notificationPromise = self.registration.showNotification(payload.title, { + body: payload.description, + icon: "https://app.follow.is/favicon.ico", + data: { + type: payload.type, + feedId: payload.feedId, + entryId: payload.entryId, + view: Number.parseInt(payload.view), + }, + }) + event.waitUntil(notificationPromise) + break + } + } + } +}) + +self.addEventListener("notificationclick", (event) => { + event.notification.close() + + const notificationData = event.notification.data + if (!notificationData) return + + let urlToOpen: URL + + switch (notificationData.type) { + case "new-entry": { + urlToOpen = new URL( + `/feeds/${notificationData.feedId}/${notificationData.entryId}`, + self.location.origin, + ) + break + } + default: { + urlToOpen = new URL("/", self.location.origin) + break + } + } + + const promiseChain = self.clients + .matchAll({ + type: "window", + includeUncontrolled: true, + }) + .then((windowClients) => { + if (windowClients.length > 0) { + const client = windowClients[0] + return client.focus() + } + return self.clients.openWindow(urlToOpen.href) + }) + .then((client) => { + if (client && "postMessage" in client) { + switch (notificationData.type) { + case "new-entry": { + client.postMessage({ + type: "NOTIFICATION_CLICK", + action: "NAVIGATE_ENTRY", + data: { + feedId: notificationData.feedId, + entryId: notificationData.entryId, + view: notificationData.view, + url: urlToOpen.pathname, + }, + }) + break + } + default: { + console.warn("Unknown notification type:", notificationData.type) + break + } + } + } + }) + + event.waitUntil(promiseChain) +}) + const preCacheExclude = new Set(["og-image.png", "opengraph-image.png"]) const precacheManifest = self.__WB_MANIFEST.filter((entry) => { @@ -22,7 +118,6 @@ precacheAndRoute(precacheManifest) cleanupOutdatedCaches() -// 根据 PWA 状态选择固定的策略 const strategy = isPWA ? new CacheFirst({ cacheName: "assets-cache-first" }) : new NetworkFirst({ diff --git a/package.json b/package.json index 16a925987..e202911cf 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "hotfix": "vv -c bump.hotfix.config.js", "lint": "eslint", "lint:fix": "eslint --fix", + "mitproxy": "bash scripts/run-proxy.sh", "polyfill-optimize": "pnpx nolyfill install", "prepare": "pnpm exec simple-git-hooks && shx test -f .env || shx cp .env.example .env", "publish": "electron-vite build && electron-forge publish", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f17e5087..bf5ba6178 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -669,6 +669,9 @@ importers: vfile: specifier: 6.0.3 version: 6.0.3 + web-push: + specifier: 3.6.7 + version: 3.6.7 zod: specifier: 3.23.8 version: 3.23.8 @@ -4847,6 +4850,9 @@ packages: asn1.js@4.10.1: resolution: {integrity: sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -7291,6 +7297,10 @@ packages: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + http_ece@1.2.1: resolution: {integrity: sha512-+tzLoMYgXvicu60sVFoswTiu6BiQ6EX3DORRJQ3W2dNpNWCyZ3tcmRFZZ3jgVyw8ziWUCeUARKCkYDY6JgFx+w==} engines: {node: '>=16'} @@ -7779,9 +7789,15 @@ packages: jwa@1.4.1: resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + jwa@2.0.0: + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + katex@0.16.11: resolution: {integrity: sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==} hasBin: true @@ -10985,6 +11001,11 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -15803,6 +15824,13 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.0 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assert-plus@1.0.0: optional: true @@ -18865,6 +18893,8 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + http_ece@1.2.0: {} + http_ece@1.2.1: {} https-proxy-agent@5.0.1: @@ -19303,11 +19333,22 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwa@2.0.0: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + jws@3.2.2: dependencies: jwa: 1.4.1 safe-buffer: 5.2.1 + jws@4.0.0: + dependencies: + jwa: 2.0.0 + safe-buffer: 5.2.1 + katex@0.16.11: dependencies: commander: 8.3.0 @@ -22935,6 +22976,16 @@ snapshots: web-namespaces@2.0.1: {} + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.5 + jws: 4.0.0 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + webidl-conversions@3.0.1: {} webidl-conversions@4.0.2: {} diff --git a/scripts/mitproxy.py b/scripts/mitproxy.py new file mode 100644 index 000000000..73105f406 --- /dev/null +++ b/scripts/mitproxy.py @@ -0,0 +1,6 @@ +from mitmproxy import http + +def request(flow: http.HTTPFlow) -> None: + if flow.request.pretty_host == "app.follow.is": + flow.request.host = "localhost" + flow.request.port = 2233 \ No newline at end of file diff --git a/scripts/run-proxy.sh b/scripts/run-proxy.sh new file mode 100644 index 000000000..117470dc5 --- /dev/null +++ b/scripts/run-proxy.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +PROXY_HOST="127.0.0.1" +PROXY_PORT="8080" + +enable_proxy() { + echo "Enabling proxy on $PROXY_HOST:$PROXY_PORT..." + networksetup -listallnetworkservices | grep -v '^*' | while IFS= read -r svc; do + sudo networksetup -setwebproxy "$svc" "$PROXY_HOST" "$PROXY_PORT" + sudo networksetup -setsecurewebproxy "$svc" "$PROXY_HOST" "$PROXY_PORT" + sudo networksetup -setwebproxystate "$svc" on + sudo networksetup -setsecurewebproxystate "$svc" on + done + echo "Proxy enabled." +} + +disable_proxy() { + echo "Disabling proxy..." + networksetup -listallnetworkservices | grep -v '^*' | while IFS= read -r svc; do + sudo networksetup -setwebproxystate "$svc" off + sudo networksetup -setsecurewebproxystate "$svc" off + done + echo "Proxy disabled." +} + +cleanup() { + echo "Exiting..." + disable_proxy + exit 0 +} + +trap cleanup SIGINT SIGTERM + +enable_proxy + +echo "Starting mitmproxy..." +mitmproxy -s scripts/mitproxy.py --ssl-insecure + +cleanup