feat: web push

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2024-11-27 22:50:15 +08:00
parent d1975c2021
commit cd32115871
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
8 changed files with 286 additions and 3 deletions

View File

@ -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"
},

View File

@ -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)
})

View File

@ -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
}
}
}
})
}

View File

@ -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({

View File

@ -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",

View File

@ -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: {}

6
scripts/mitproxy.py Normal file
View File

@ -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

39
scripts/run-proxy.sh Normal file
View File

@ -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