From 2c8c0b5eb0f03a843da83a72c81fcadac31c3354 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 4 Jan 2026 06:48:17 +0100 Subject: [PATCH] feat: enhance service worker functionality with improved caching strategies, client communication, and service worker registration options --- package.json | 2 + pnpm-lock.yaml | 65 ++++ public/sw.js | 362 +++++++++++++++++-- src/components/layout/ClientLayout.tsx | 70 ++-- src/components/settings/CacheSettings.tsx | 393 +++++++++++++++++++++ src/components/settings/ClientSettings.tsx | 2 + src/components/ui/collapsible.tsx | 13 + src/components/ui/scroll-area.tsx | 47 +++ src/contexts/ServiceWorkerContext.tsx | 294 +++++++++++++++ src/hooks/useCacheUpdate.ts | 98 +++++ src/i18n/messages/en/common.json | 26 ++ src/i18n/messages/fr/common.json | 26 ++ src/lib/registerSW.ts | 133 ++++++- 13 files changed, 1466 insertions(+), 65 deletions(-) create mode 100644 src/components/settings/CacheSettings.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/contexts/ServiceWorkerContext.tsx create mode 100644 src/hooks/useCacheUpdate.ts diff --git a/package.json b/package.json index 17da5d3..edc286e 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,12 @@ "@prisma/client": "^6.17.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc86618..66720ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-dialog': specifier: 1.1.15 version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -33,6 +36,9 @@ importers: '@radix-ui/react-radio-group': specifier: ^1.3.8 version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-select': specifier: ^2.1.6 version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -680,6 +686,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -894,6 +913,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -3543,6 +3575,22 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) @@ -3765,6 +3813,23 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/number': 1.1.1 diff --git a/public/sw.js b/public/sw.js index ee0ae8c..8e13fe2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,14 +1,20 @@ -// StripStream Service Worker - Version 1 -// Architecture: Cache-as-you-go for static resources only +// StripStream Service Worker - Version 2 +// Architecture: SWR (Stale-While-Revalidate) for all resources -const VERSION = "v1"; +const VERSION = "v2"; const STATIC_CACHE = `stripstream-static-${VERSION}`; +const API_CACHE = `stripstream-api-${VERSION}`; +const IMAGES_CACHE = `stripstream-images-${VERSION}`; const RSC_CACHE = `stripstream-rsc-${VERSION}`; const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager const OFFLINE_PAGE = "/offline.html"; const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"]; +// Cache size limits +const IMAGES_CACHE_MAX_SIZE = 100 * 1024 * 1024; // 100MB +const IMAGES_CACHE_MAX_ENTRIES = 500; + // ============================================================================ // Utility Functions - Request Detection // ============================================================================ @@ -22,13 +28,79 @@ function isNextRSCRequest(request) { return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1"; } +function isApiRequest(url) { + return url.includes("/api/komga/") && !url.includes("/api/komga/images/"); +} + +function isImageRequest(url) { + return url.includes("/api/komga/images/"); +} + +function isBookPageRequest(url) { + // Book pages: /api/komga/images/books/{id}/pages/{num} or /api/komga/books/{id}/pages/{num} + // These are handled by manual download (DownloadManager) - don't cache via SWR + return ( + (url.includes("/api/komga/images/books/") || url.includes("/api/komga/books/")) && + url.includes("/pages/") + ); +} + +function isBooksManualCache(url) { + // Check if this is a request that should be handled by the books manual cache + return url.includes("/api/komga/images/books/") && url.includes("/pages"); +} + +// ============================================================================ +// Client Communication +// ============================================================================ + +async function notifyClients(message) { + const clients = await self.clients.matchAll({ type: "window" }); + clients.forEach((client) => { + client.postMessage(message); + }); +} + +// ============================================================================ +// Cache Management +// ============================================================================ + +async function getCacheSize(cacheName) { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + let totalSize = 0; + + for (const request of keys) { + const response = await cache.match(request); + if (response) { + const blob = await response.clone().blob(); + totalSize += blob.size; + } + } + + return totalSize; +} + +async function trimCache(cacheName, maxEntries) { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + + if (keys.length > maxEntries) { + // Remove oldest entries (FIFO) + const toDelete = keys.slice(0, keys.length - maxEntries); + await Promise.all(toDelete.map((key) => cache.delete(key))); + // eslint-disable-next-line no-console + console.log(`[SW] Trimmed ${toDelete.length} entries from ${cacheName}`); + } +} + // ============================================================================ // Cache Strategies // ============================================================================ /** * Cache-First: Serve from cache, fallback to network - * Used for: Next.js static resources + * Used for: Next.js static resources (immutable) */ async function cacheFirstStrategy(request, cacheName, options = {}) { const cache = await caches.open(cacheName); @@ -56,21 +128,57 @@ async function cacheFirstStrategy(request, cacheName, options = {}) { /** * Stale-While-Revalidate: Serve from cache immediately, update in background - * Used for: RSC payloads + * Used for: API calls, images */ -async function staleWhileRevalidateStrategy(request, cacheName) { +async function staleWhileRevalidateStrategy(request, cacheName, options = {}) { const cache = await caches.open(cacheName); const cached = await cache.match(request); // Start network request (don't await) const fetchPromise = fetch(request) - .then((response) => { + .then(async (response) => { if (response.ok) { - cache.put(request, response.clone()); + // Clone response for cache + const responseToCache = response.clone(); + + // Check if content changed (for notification) + if (cached && options.notifyOnChange) { + try { + const cachedResponse = await cache.match(request); + if (cachedResponse) { + // For JSON APIs, compare content + if (options.isJson) { + const oldText = await cachedResponse.text(); + const newText = await response.clone().text(); + if (oldText !== newText) { + notifyClients({ + type: "CACHE_UPDATED", + url: request.url, + timestamp: Date.now(), + }); + } + } + } + } catch { + // Ignore comparison errors + } + } + + // Update cache + await cache.put(request, responseToCache); + + // Trim cache if needed (for images) + if (options.maxEntries) { + trimCache(cacheName, options.maxEntries); + } } return response; }) - .catch(() => null); + .catch((error) => { + // eslint-disable-next-line no-console + console.log("[SW] Network request failed:", request.url, error.message); + return null; + }); // Return cached version immediately if available if (cached) { @@ -87,14 +195,13 @@ async function staleWhileRevalidateStrategy(request, cacheName) { } /** - * Navigation Strategy: Network-First with SPA fallback + * Network-First: Try network, fallback to cache * Used for: Page navigations */ -async function navigationStrategy(request) { - const cache = await caches.open(STATIC_CACHE); +async function networkFirstStrategy(request, cacheName) { + const cache = await caches.open(cacheName); try { - // Try network first const response = await fetch(request); if (response.ok) { cache.put(request, response.clone()); @@ -155,9 +262,10 @@ self.addEventListener("activate", (event) => { (async () => { // Clean up old caches, but preserve BOOKS_CACHE const cacheNames = await caches.keys(); + const currentCaches = [STATIC_CACHE, API_CACHE, IMAGES_CACHE, RSC_CACHE, BOOKS_CACHE]; + const cachesToDelete = cacheNames.filter( - (name) => - name.startsWith("stripstream-") && name !== BOOKS_CACHE && !name.endsWith(`-${VERSION}`) + (name) => name.startsWith("stripstream-") && !currentCaches.includes(name) ); await Promise.all(cachesToDelete.map((name) => caches.delete(name))); @@ -170,10 +278,172 @@ self.addEventListener("activate", (event) => { await self.clients.claim(); // eslint-disable-next-line no-console console.log("[SW] Activated and claimed clients"); + + // Notify clients that SW is ready + notifyClients({ type: "SW_ACTIVATED", version: VERSION }); })() ); }); +// ============================================================================ +// Message Handler - Client Communication +// ============================================================================ + +self.addEventListener("message", async (event) => { + const { type, payload } = event.data || {}; + + switch (type) { + case "GET_CACHE_STATS": { + try { + const [staticSize, apiSize, imagesSize, booksSize] = await Promise.all([ + getCacheSize(STATIC_CACHE), + getCacheSize(API_CACHE), + getCacheSize(IMAGES_CACHE), + getCacheSize(BOOKS_CACHE), + ]); + + const staticCache = await caches.open(STATIC_CACHE); + const apiCache = await caches.open(API_CACHE); + const imagesCache = await caches.open(IMAGES_CACHE); + const booksCache = await caches.open(BOOKS_CACHE); + + const [staticKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([ + staticCache.keys(), + apiCache.keys(), + imagesCache.keys(), + booksCache.keys(), + ]); + + event.source.postMessage({ + type: "CACHE_STATS", + payload: { + static: { size: staticSize, entries: staticKeys.length }, + api: { size: apiSize, entries: apiKeys.length }, + images: { size: imagesSize, entries: imagesKeys.length }, + books: { size: booksSize, entries: booksKeys.length }, + total: staticSize + apiSize + imagesSize + booksSize, + }, + }); + } catch (error) { + event.source.postMessage({ + type: "CACHE_STATS_ERROR", + payload: { error: error.message }, + }); + } + break; + } + + case "CLEAR_CACHE": { + try { + const cacheType = payload?.cacheType || "all"; + const cachesToClear = []; + + if (cacheType === "all" || cacheType === "static") { + cachesToClear.push(STATIC_CACHE); + } + if (cacheType === "all" || cacheType === "api") { + cachesToClear.push(API_CACHE); + } + if (cacheType === "all" || cacheType === "images") { + cachesToClear.push(IMAGES_CACHE); + } + if (cacheType === "all" || cacheType === "rsc") { + cachesToClear.push(RSC_CACHE); + } + // Note: BOOKS_CACHE is not cleared by default, only explicitly + + await Promise.all( + cachesToClear.map(async (cacheName) => { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + await Promise.all(keys.map((key) => cache.delete(key))); + }) + ); + + event.source.postMessage({ + type: "CACHE_CLEARED", + payload: { caches: cachesToClear }, + }); + } catch (error) { + event.source.postMessage({ + type: "CACHE_CLEAR_ERROR", + payload: { error: error.message }, + }); + } + break; + } + + case "SKIP_WAITING": { + self.skipWaiting(); + break; + } + + case "GET_VERSION": { + event.source.postMessage({ + type: "SW_VERSION", + payload: { version: VERSION }, + }); + break; + } + + case "GET_CACHE_ENTRIES": { + try { + const cacheType = payload?.cacheType; + let cacheName; + + switch (cacheType) { + case "static": + cacheName = STATIC_CACHE; + break; + case "api": + cacheName = API_CACHE; + break; + case "images": + cacheName = IMAGES_CACHE; + break; + case "books": + cacheName = BOOKS_CACHE; + break; + default: + throw new Error("Invalid cache type"); + } + + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + + const entries = await Promise.all( + keys.map(async (request) => { + const response = await cache.match(request); + let size = 0; + if (response) { + const blob = await response.clone().blob(); + size = blob.size; + } + return { + url: request.url, + size, + }; + }) + ); + + // Sort by size descending + entries.sort((a, b) => b.size - a.size); + + event.source.postMessage({ + type: "CACHE_ENTRIES", + payload: { cacheType, entries }, + }); + } catch (error) { + event.source.postMessage({ + type: "CACHE_ENTRIES_ERROR", + payload: { error: error.message }, + }); + } + break; + } + } +}); + // ============================================================================ // Fetch Handler - Request Routing // ============================================================================ @@ -188,24 +458,68 @@ self.addEventListener("fetch", (event) => { return; } - // Route 1: Next.js RSC payloads → Stale-While-Revalidate - if (isNextRSCRequest(request)) { - event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE)); + // Route 1: Book pages (handled by DownloadManager) - Check manual cache only, no SWR + if (isBookPageRequest(url.href)) { + event.respondWith( + (async () => { + // Check the manual books cache + const booksCache = await caches.open(BOOKS_CACHE); + const cached = await booksCache.match(request); + if (cached) { + return cached; + } + + // Not in cache - fetch from network without caching + // Book pages are large and should only be cached via manual download + return fetch(request); + })() + ); return; } - // Route 2: Next.js static resources → Cache-First with ignoreSearch + // Route 2: Next.js RSC payloads → Stale-While-Revalidate + if (isNextRSCRequest(request)) { + event.respondWith( + staleWhileRevalidateStrategy(request, RSC_CACHE, { + notifyOnChange: false, + }) + ); + return; + } + + // Route 3: Next.js static resources → Cache-First with ignoreSearch if (isNextStaticResource(url.href)) { event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true })); return; } - // Route 3: Navigation → Network-First with SPA fallback - if (request.mode === "navigate") { - event.respondWith(navigationStrategy(request)); + // Route 4: API requests (JSON) → SWR with notification + if (isApiRequest(url.href)) { + event.respondWith( + staleWhileRevalidateStrategy(request, API_CACHE, { + notifyOnChange: true, + isJson: true, + }) + ); return; } - // Route 4: Everything else → Network only (no caching) - // This includes: API calls, images, and other dynamic content + // Route 5: Image requests (thumbnails, covers) → SWR with cache size management + // Note: Book pages are excluded (Route 1) and only use manual download cache + if (isImageRequest(url.href)) { + event.respondWith( + staleWhileRevalidateStrategy(request, IMAGES_CACHE, { + maxEntries: IMAGES_CACHE_MAX_ENTRIES, + }) + ); + return; + } + + // Route 6: Navigation → Network-First with SPA fallback + if (request.mode === "navigate") { + event.respondWith(networkFirstStrategy(request, STATIC_CACHE)); + return; + } + + // Route 7: Everything else → Network only (no caching) }); diff --git a/src/components/layout/ClientLayout.tsx b/src/components/layout/ClientLayout.tsx index 65d6208..9c31c6f 100644 --- a/src/components/layout/ClientLayout.tsx +++ b/src/components/layout/ClientLayout.tsx @@ -7,9 +7,9 @@ import { Sidebar } from "@/components/layout/Sidebar"; import { InstallPWA } from "../ui/InstallPWA"; import { Toaster } from "@/components/ui/toaster"; import { usePathname } from "next/navigation"; -import { registerServiceWorker } from "@/lib/registerSW"; import { NetworkStatus } from "../ui/NetworkStatus"; import { usePreferences } from "@/contexts/PreferencesContext"; +import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext"; import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; import logger from "@/lib/logger"; @@ -135,10 +135,6 @@ export default function ClientLayout({ }; }, [isSidebarOpen]); - useEffect(() => { - // Enregistrer le service worker - registerServiceWorker(); - }, []); // Ne pas afficher le header et la sidebar sur les routes publiques et le reader const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/"); @@ -151,37 +147,39 @@ export default function ClientLayout({ return ( - {/* Background fixe pour les images et gradients */} - {hasCustomBackground &&
} -
- {!isPublicRoute && ( -
- )} - {!isPublicRoute && ( - - )} -
{children}
- - - -
+ + {/* Background fixe pour les images et gradients */} + {hasCustomBackground &&
} +
+ {!isPublicRoute && ( +
+ )} + {!isPublicRoute && ( + + )} +
{children}
+ + + +
+ ); } diff --git a/src/components/settings/CacheSettings.tsx b/src/components/settings/CacheSettings.tsx new file mode 100644 index 0000000..95095c1 --- /dev/null +++ b/src/components/settings/CacheSettings.tsx @@ -0,0 +1,393 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useTranslate } from "@/hooks/useTranslate"; +import { useToast } from "@/components/ui/use-toast"; +import { useServiceWorker } from "@/contexts/ServiceWorkerContext"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Database, + Trash2, + RefreshCw, + HardDrive, + Image, + FileJson, + BookOpen, + CheckCircle2, + XCircle, + Loader2, + ChevronDown, + ChevronRight, +} from "lucide-react"; + +interface CacheStats { + static: { size: number; entries: number }; + api: { size: number; entries: number }; + images: { size: number; entries: number }; + books: { size: number; entries: number }; + total: number; +} + +interface CacheEntry { + url: string; + size: number; +} + +type CacheType = "static" | "api" | "images" | "books"; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +} + +function extractPathFromUrl(url: string): string { + try { + const urlObj = new URL(url); + return urlObj.pathname + urlObj.search; + } catch { + return url; + } +} + +interface CacheItemProps { + icon: React.ReactNode; + label: string; + size: number; + entries: number; + cacheType: CacheType; + onClear?: () => void; + isClearing?: boolean; + description?: string; + onLoadEntries: (cacheType: CacheType) => Promise; +} + +function CacheItem({ + icon, + label, + size, + entries, + cacheType, + onClear, + isClearing, + description, + onLoadEntries, +}: CacheItemProps) { + const { t } = useTranslate(); + const [isOpen, setIsOpen] = useState(false); + const [cacheEntries, setCacheEntries] = useState(null); + const [isLoadingEntries, setIsLoadingEntries] = useState(false); + + const handleToggle = async (open: boolean) => { + setIsOpen(open); + if (open && !cacheEntries && !isLoadingEntries) { + setIsLoadingEntries(true); + const loadedEntries = await onLoadEntries(cacheType); + setCacheEntries(loadedEntries); + setIsLoadingEntries(false); + } + }; + + return ( + +
+
+ + + +
+
+

{formatBytes(size)}

+

+ {entries} {entries === 1 ? t("settings.cache.entry") : t("settings.cache.entries")} +

+
+ {onClear && ( + + )} +
+
+ +
+ {isLoadingEntries ? ( +
+ + + {t("settings.cache.loadingEntries")} + +
+ ) : cacheEntries ? ( + +
+ {cacheEntries.map((entry, index) => ( +
+ + {extractPathFromUrl(entry.url)} + + + {formatBytes(entry.size)} + +
+ ))} + {cacheEntries.length === 0 && ( +

+ {t("settings.cache.noEntries")} +

+ )} +
+
+ ) : ( +

+ {t("settings.cache.loadError")} +

+ )} +
+
+
+
+ ); +} + +export function CacheSettings() { + const { t } = useTranslate(); + const { toast } = useToast(); + const { isSupported, isReady, version, getCacheStats, getCacheEntries, clearCache } = + useServiceWorker(); + + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [clearingCache, setClearingCache] = useState(null); + + const loadStats = useCallback(async () => { + if (!isReady) return; + + setIsLoading(true); + try { + const cacheStats = await getCacheStats(); + setStats(cacheStats); + } finally { + setIsLoading(false); + } + }, [isReady, getCacheStats]); + + useEffect(() => { + loadStats(); + }, [loadStats]); + + const handleClearCache = async (cacheType: "all" | "static" | "api" | "images") => { + setClearingCache(cacheType); + try { + const success = await clearCache(cacheType); + if (success) { + toast({ + title: t("settings.cache.cleared"), + description: t("settings.cache.clearedDesc"), + }); + await loadStats(); + } else { + toast({ + variant: "destructive", + title: t("settings.error.title"), + description: t("settings.cache.clearError"), + }); + } + } finally { + setClearingCache(null); + } + }; + + const handleLoadEntries = useCallback( + async (cacheType: CacheType): Promise => { + return getCacheEntries(cacheType); + }, + [getCacheEntries] + ); + + // Calculer le pourcentage du cache utilisé (basé sur 100MB limite images) + const maxCacheSize = 100 * 1024 * 1024; // 100MB + const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0; + + if (!isSupported) { + return ( + + +
+ + {t("settings.cache.title")} +
+ {t("settings.cache.notSupported")} +
+
+ ); + } + + return ( + + +
+
+ + {t("settings.cache.title")} +
+
+ {isReady ? ( + + + {version || "Active"} + + ) : ( + + + {t("settings.cache.initializing")} + + )} + +
+
+ {t("settings.cache.description")} +
+ + {/* Barre de progression globale */} + {stats && ( +
+
+ {t("settings.cache.totalStorage")} + {formatBytes(stats.total)} +
+ +

+ {t("settings.cache.imagesQuota", { used: Math.round(usagePercent) })} +

+
+ )} + + {/* Liste des caches */} +
+ {stats ? ( + <> + } + label={t("settings.cache.static")} + size={stats.static.size} + entries={stats.static.entries} + cacheType="static" + description={t("settings.cache.staticDesc")} + onClear={() => handleClearCache("static")} + isClearing={clearingCache === "static"} + onLoadEntries={handleLoadEntries} + /> + } + label={t("settings.cache.api")} + size={stats.api.size} + entries={stats.api.entries} + cacheType="api" + description={t("settings.cache.apiDesc")} + onClear={() => handleClearCache("api")} + isClearing={clearingCache === "api"} + onLoadEntries={handleLoadEntries} + /> + } + label={t("settings.cache.images")} + size={stats.images.size} + entries={stats.images.entries} + cacheType="images" + description={t("settings.cache.imagesDesc")} + onClear={() => handleClearCache("images")} + isClearing={clearingCache === "images"} + onLoadEntries={handleLoadEntries} + /> + } + label={t("settings.cache.books")} + size={stats.books.size} + entries={stats.books.entries} + cacheType="books" + description={t("settings.cache.booksDesc")} + onLoadEntries={handleLoadEntries} + /> + + ) : isLoading ? ( +
+ +
+ ) : ( +

+ {t("settings.cache.unavailable")} +

+ )} +
+ + {/* Bouton vider tout */} + {stats && stats.total > 0 && ( + + )} +
+
+ ); +} diff --git a/src/components/settings/ClientSettings.tsx b/src/components/settings/ClientSettings.tsx index 0731081..2793312 100644 --- a/src/components/settings/ClientSettings.tsx +++ b/src/components/settings/ClientSettings.tsx @@ -6,6 +6,7 @@ import { DisplaySettings } from "./DisplaySettings"; import { KomgaSettings } from "./KomgaSettings"; import { BackgroundSettings } from "./BackgroundSettings"; import { AdvancedSettings } from "./AdvancedSettings"; +import { CacheSettings } from "./CacheSettings"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Monitor, Network } from "lucide-react"; @@ -40,6 +41,7 @@ export function ClientSettings({ initialConfig }: ClientSettingsProps) { +
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..396cb26 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,13 @@ +"use client"; + +import * as React from "react"; +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; + diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..b1d5bc6 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,47 @@ +"use client"; + +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; + diff --git a/src/contexts/ServiceWorkerContext.tsx b/src/contexts/ServiceWorkerContext.tsx new file mode 100644 index 0000000..faba1db --- /dev/null +++ b/src/contexts/ServiceWorkerContext.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react"; +import type { ReactNode } from "react"; +import { registerServiceWorker } from "@/lib/registerSW"; +import logger from "@/lib/logger"; + +interface CacheStats { + static: { size: number; entries: number }; + api: { size: number; entries: number }; + images: { size: number; entries: number }; + books: { size: number; entries: number }; + total: number; +} + +interface CacheEntry { + url: string; + size: number; +} + +interface CacheUpdate { + url: string; + timestamp: number; +} + +type CacheType = "all" | "static" | "api" | "images" | "rsc" | "books"; + +interface ServiceWorkerContextValue { + isSupported: boolean; + isReady: boolean; + version: string | null; + hasNewVersion: boolean; + cacheUpdates: CacheUpdate[]; + clearCacheUpdate: (url: string) => void; + clearAllCacheUpdates: () => void; + getCacheStats: () => Promise; + getCacheEntries: (cacheType: CacheType) => Promise; + clearCache: (cacheType?: CacheType) => Promise; + skipWaiting: () => void; + reloadForUpdate: () => void; +} + +const ServiceWorkerContext = createContext(null); + +export function ServiceWorkerProvider({ children }: { children: ReactNode }) { + const [isSupported, setIsSupported] = useState(false); + const [isReady, setIsReady] = useState(false); + const [version, setVersion] = useState(null); + const [hasNewVersion, setHasNewVersion] = useState(false); + const [cacheUpdates, setCacheUpdates] = useState([]); + const pendingRequests = useRef void>>(new Map()); + const waitingWorkerRef = useRef(null); + + // Handle messages from service worker + const handleMessage = useCallback((event: MessageEvent) => { + const { type, payload } = event.data || {}; + + switch (type) { + case "SW_ACTIVATED": + setIsReady(true); + setVersion(payload?.version || null); + break; + + case "SW_VERSION": + setVersion(payload?.version || null); + break; + + case "CACHE_UPDATED": + setCacheUpdates((prev) => { + // Avoid duplicates for the same URL within 1 second + const existing = prev.find( + (u) => u.url === payload.url && Date.now() - u.timestamp < 1000 + ); + if (existing) return prev; + return [...prev, { url: payload.url, timestamp: payload.timestamp }]; + }); + break; + + case "CACHE_STATS": + const statsResolver = pendingRequests.current.get("CACHE_STATS"); + if (statsResolver) { + statsResolver(payload); + pendingRequests.current.delete("CACHE_STATS"); + } + break; + + case "CACHE_STATS_ERROR": + const statsErrorResolver = pendingRequests.current.get("CACHE_STATS"); + if (statsErrorResolver) { + statsErrorResolver(null); + pendingRequests.current.delete("CACHE_STATS"); + } + break; + + case "CACHE_CLEARED": + const clearResolver = pendingRequests.current.get("CACHE_CLEARED"); + if (clearResolver) { + clearResolver(true); + pendingRequests.current.delete("CACHE_CLEARED"); + } + break; + + case "CACHE_CLEAR_ERROR": + const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED"); + if (clearErrorResolver) { + clearErrorResolver(false); + pendingRequests.current.delete("CACHE_CLEARED"); + } + break; + + case "CACHE_ENTRIES": + const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES"); + if (entriesResolver) { + entriesResolver(payload.entries); + pendingRequests.current.delete("CACHE_ENTRIES"); + } + break; + + case "CACHE_ENTRIES_ERROR": + const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES"); + if (entriesErrorResolver) { + entriesErrorResolver(null); + pendingRequests.current.delete("CACHE_ENTRIES"); + } + break; + } + }, []); + + // Initialize service worker communication + useEffect(() => { + if (typeof window === "undefined" || !("serviceWorker" in navigator)) { + setIsSupported(false); + return; + } + + setIsSupported(true); + + // Register service worker + registerServiceWorker({ + onSuccess: (registration) => { + logger.info({ scope: registration.scope }, "Service worker registered"); + setIsReady(true); + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" }); + } + }, + onUpdate: (registration) => { + logger.info("New service worker version available"); + setHasNewVersion(true); + waitingWorkerRef.current = registration.waiting; + }, + onError: (error) => { + logger.error({ err: error }, "Service worker registration failed"); + }, + }); + + // Listen for messages + navigator.serviceWorker.addEventListener("message", handleMessage); + + // Check if already controlled + if (navigator.serviceWorker.controller) { + setIsReady(true); + // Request version + navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" }); + } + + // Listen for controller changes + const handleControllerChange = () => { + setIsReady(true); + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" }); + } + }; + + navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange); + + return () => { + navigator.serviceWorker.removeEventListener("message", handleMessage); + navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange); + }; + }, [handleMessage]); + + const clearCacheUpdate = useCallback((url: string) => { + setCacheUpdates((prev) => prev.filter((u) => u.url !== url)); + }, []); + + const clearAllCacheUpdates = useCallback(() => { + setCacheUpdates([]); + }, []); + + const getCacheStats = useCallback(async (): Promise => { + if (!navigator.serviceWorker.controller) return null; + + return new Promise((resolve) => { + pendingRequests.current.set("CACHE_STATS", resolve as (value: unknown) => void); + navigator.serviceWorker.controller!.postMessage({ type: "GET_CACHE_STATS" }); + + // Timeout after 5 seconds + setTimeout(() => { + if (pendingRequests.current.has("CACHE_STATS")) { + pendingRequests.current.delete("CACHE_STATS"); + resolve(null); + } + }, 5000); + }); + }, []); + + const getCacheEntries = useCallback( + async (cacheType: CacheType): Promise => { + if (!navigator.serviceWorker.controller) return null; + + return new Promise((resolve) => { + pendingRequests.current.set("CACHE_ENTRIES", resolve as (value: unknown) => void); + navigator.serviceWorker.controller!.postMessage({ + type: "GET_CACHE_ENTRIES", + payload: { cacheType }, + }); + + // Timeout after 10 seconds (can be slow for large caches) + setTimeout(() => { + if (pendingRequests.current.has("CACHE_ENTRIES")) { + pendingRequests.current.delete("CACHE_ENTRIES"); + resolve(null); + } + }, 10000); + }); + }, + [] + ); + + const clearCache = useCallback(async (cacheType: CacheType = "all"): Promise => { + if (!navigator.serviceWorker.controller) return false; + + return new Promise((resolve) => { + pendingRequests.current.set("CACHE_CLEARED", resolve as (value: unknown) => void); + navigator.serviceWorker.controller!.postMessage({ + type: "CLEAR_CACHE", + payload: { cacheType }, + }); + + // Timeout after 10 seconds + setTimeout(() => { + if (pendingRequests.current.has("CACHE_CLEARED")) { + pendingRequests.current.delete("CACHE_CLEARED"); + resolve(false); + } + }, 10000); + }); + }, []); + + const skipWaiting = useCallback(() => { + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ type: "SKIP_WAITING" }); + } + }, []); + + const reloadForUpdate = useCallback(() => { + if (waitingWorkerRef.current) { + waitingWorkerRef.current.postMessage({ type: "SKIP_WAITING" }); + setHasNewVersion(false); + // Reload will happen automatically when new SW takes control + window.location.reload(); + } + }, []); + + return ( + + {children} + + ); +} + +export function useServiceWorker() { + const context = useContext(ServiceWorkerContext); + if (!context) { + throw new Error("useServiceWorker must be used within a ServiceWorkerProvider"); + } + return context; +} diff --git a/src/hooks/useCacheUpdate.ts b/src/hooks/useCacheUpdate.ts new file mode 100644 index 0000000..8ec23a7 --- /dev/null +++ b/src/hooks/useCacheUpdate.ts @@ -0,0 +1,98 @@ +"use client"; + +import { useMemo, useCallback } from "react"; +import { useServiceWorker } from "@/contexts/ServiceWorkerContext"; + +interface UseCacheUpdateOptions { + /** Match exact URL or use pattern matching */ + exact?: boolean; +} + +interface UseCacheUpdateResult { + /** Whether there's a pending update for this URL pattern */ + hasUpdate: boolean; + /** Timestamp of the last update */ + lastUpdateTime: number | null; + /** Clear the update notification for this URL */ + clearUpdate: () => void; + /** All matching updates */ + updates: Array<{ url: string; timestamp: number }>; +} + +/** + * Hook to listen for cache updates from the service worker + * + * @param urlPattern - URL or pattern to match against cache updates + * @param options - Options for matching behavior + * + * @example + * // Match exact URL + * const { hasUpdate, clearUpdate } = useCacheUpdate('/api/komga/home', { exact: true }); + * + * @example + * // Match URL pattern (contains) + * const { hasUpdate, clearUpdate } = useCacheUpdate('/api/komga/series'); + * + * @example + * // Use in component + * useEffect(() => { + * if (hasUpdate) { + * refetch(); + * clearUpdate(); + * } + * }, [hasUpdate, refetch, clearUpdate]); + */ +export function useCacheUpdate( + urlPattern: string, + options: UseCacheUpdateOptions = {} +): UseCacheUpdateResult { + const { exact = false } = options; + const { cacheUpdates, clearCacheUpdate } = useServiceWorker(); + + const matchingUpdates = useMemo(() => { + return cacheUpdates.filter((update) => { + if (exact) { + return update.url === urlPattern || update.url.endsWith(urlPattern); + } + return update.url.includes(urlPattern); + }); + }, [cacheUpdates, urlPattern, exact]); + + const hasUpdate = matchingUpdates.length > 0; + + const lastUpdateTime = useMemo(() => { + if (matchingUpdates.length === 0) return null; + return Math.max(...matchingUpdates.map((u) => u.timestamp)); + }, [matchingUpdates]); + + const clearUpdate = useCallback(() => { + matchingUpdates.forEach((update) => { + clearCacheUpdate(update.url); + }); + }, [matchingUpdates, clearCacheUpdate]); + + return { + hasUpdate, + lastUpdateTime, + clearUpdate, + updates: matchingUpdates, + }; +} + +/** + * Hook to check if any cache update is available + * Useful for showing a global "refresh available" indicator + */ +export function useAnyCacheUpdate(): { + hasAnyUpdate: boolean; + updateCount: number; + clearAll: () => void; +} { + const { cacheUpdates, clearAllCacheUpdates } = useServiceWorker(); + + return { + hasAnyUpdate: cacheUpdates.length > 0, + updateCount: cacheUpdates.length, + clearAll: clearAllCacheUpdates, + }; +} diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index ba10d87..e4d324b 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -136,6 +136,32 @@ "title": "Error saving configuration", "message": "An error occurred while saving the configuration" } + }, + "cache": { + "title": "Cache & Storage", + "description": "Manage local cache for optimal offline experience.", + "notSupported": "Offline cache is not supported by your browser.", + "initializing": "Initializing...", + "totalStorage": "Total storage", + "imagesQuota": "{used}% of images quota used", + "static": "Static resources", + "staticDesc": "Next.js scripts, styles and assets", + "api": "API data", + "apiDesc": "Series, books and library metadata", + "images": "Images", + "imagesDesc": "Covers and thumbnails (100 MB limit)", + "books": "Offline books", + "booksDesc": "Manually downloaded pages", + "clearAll": "Clear all cache", + "cleared": "Cache cleared", + "clearedDesc": "Cache has been cleared successfully", + "clearError": "Error clearing cache", + "unavailable": "Cache statistics unavailable", + "entry": "entry", + "entries": "entries", + "loadingEntries": "Loading entries...", + "noEntries": "No entries in this cache", + "loadError": "Error loading entries" } }, "library": { diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 651e3f5..06404fa 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -136,6 +136,32 @@ "title": "Erreur lors de la sauvegarde de la configuration", "message": "Une erreur est survenue lors de la sauvegarde de la configuration" } + }, + "cache": { + "title": "Cache et stockage", + "description": "Gérez le cache local pour une expérience hors-ligne optimale.", + "notSupported": "Le cache hors-ligne n'est pas supporté par votre navigateur.", + "initializing": "Initialisation...", + "totalStorage": "Stockage total", + "imagesQuota": "{used}% du quota images utilisé", + "static": "Ressources statiques", + "staticDesc": "Scripts, styles et assets Next.js", + "api": "Données API", + "apiDesc": "Métadonnées des séries, livres et bibliothèques", + "images": "Images", + "imagesDesc": "Couvertures et vignettes (limite 100 Mo)", + "books": "Livres hors-ligne", + "booksDesc": "Pages téléchargées manuellement", + "clearAll": "Vider tout le cache", + "cleared": "Cache vidé", + "clearedDesc": "Le cache a été vidé avec succès", + "clearError": "Erreur lors du vidage du cache", + "unavailable": "Statistiques du cache non disponibles", + "entry": "entrée", + "entries": "entrées", + "loadingEntries": "Chargement des entrées...", + "noEntries": "Aucune entrée dans ce cache", + "loadError": "Erreur lors du chargement des entrées" } }, "library": { diff --git a/src/lib/registerSW.ts b/src/lib/registerSW.ts index c082d06..74aa4f8 100644 --- a/src/lib/registerSW.ts +++ b/src/lib/registerSW.ts @@ -1,14 +1,137 @@ import logger from "@/lib/logger"; -export const registerServiceWorker = async () => { +interface ServiceWorkerRegistrationOptions { + onUpdate?: (registration: ServiceWorkerRegistration) => void; + onSuccess?: (registration: ServiceWorkerRegistration) => void; + onError?: (error: Error) => void; +} + +/** + * Register the service worker with optional callbacks for update and success events + */ +export const registerServiceWorker = async ( + options: ServiceWorkerRegistrationOptions = {} +): Promise => { if (typeof window === "undefined" || !("serviceWorker" in navigator)) { - return; + return null; + } + + const { onUpdate, onSuccess, onError } = options; + + try { + const registration = await navigator.serviceWorker.register("/sw.js", { + scope: "/", + }); + + // Check for updates immediately + registration.update().catch(() => { + // Ignore update check errors + }); + + // Handle updates + registration.addEventListener("updatefound", () => { + const newWorker = registration.installing; + + if (!newWorker) return; + + newWorker.addEventListener("statechange", () => { + if (newWorker.state === "installed") { + if (navigator.serviceWorker.controller) { + // New service worker available + logger.info("New service worker available"); + onUpdate?.(registration); + } else { + // First install + logger.info("Service worker installed for the first time"); + onSuccess?.(registration); + } + } + }); + }); + + // If already active, call success + if (registration.active) { + onSuccess?.(registration); + } + + return registration; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.error({ err }, "Service Worker registration failed"); + onError?.(err); + return null; + } +}; + +/** + * Unregister all service workers + */ +export const unregisterServiceWorker = async (): Promise => { + if (typeof window === "undefined" || !("serviceWorker" in navigator)) { + return false; } try { - await navigator.serviceWorker.register("/sw.js"); - // logger.info("Service Worker registered with scope:", registration.scope); + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((reg) => reg.unregister())); + logger.info("All service workers unregistered"); + return true; } catch (error) { - logger.error({ err: error }, "Service Worker registration failed:"); + logger.error({ err: error }, "Failed to unregister service workers"); + return false; + } +}; + +/** + * Send a message to the active service worker + */ +export const sendMessageToSW = (message: unknown): Promise => { + return new Promise((resolve) => { + if (!navigator.serviceWorker.controller) { + resolve(null); + return; + } + + const messageChannel = new MessageChannel(); + + messageChannel.port1.onmessage = (event) => { + resolve(event.data as T); + }; + + navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]); + + // Timeout after 5 seconds + setTimeout(() => { + resolve(null); + }, 5000); + }); +}; + +/** + * Check if the app is running as a PWA (standalone mode) + */ +export const isPWA = (): boolean => { + if (typeof window === "undefined") return false; + + return ( + window.matchMedia("(display-mode: standalone)").matches || + // iOS Safari + ("standalone" in window.navigator && + (window.navigator as { standalone?: boolean }).standalone === true) + ); +}; + +/** + * Get the current service worker registration + */ +export const getServiceWorkerRegistration = async (): Promise => { + if (typeof window === "undefined" || !("serviceWorker" in navigator)) { + return null; + } + + try { + return await navigator.serviceWorker.ready; + } catch { + return null; } };