diff --git a/.gitignore b/.gitignore index 9984da2..c3c57d2 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ lerna-debug.log* # Yarn .yarn/* !.yarn/releases +.wrangler/ diff --git a/example/package.json b/example/package.json index 3ae02e6..91d9679 100644 --- a/example/package.json +++ b/example/package.json @@ -1,6 +1,7 @@ { "name": "example", "description": "Demo App with Routing built-in (recommended)", + "type": "module", "engines": { "node": ">=15.0.0" }, diff --git a/example/public/robots.txt b/example/public/robots.txt index e69de29..c2a49f4 100644 --- a/example/public/robots.txt +++ b/example/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/example/src/routes/index.tsx b/example/src/routes/index.tsx index c1e8d85..6a1e77b 100644 --- a/example/src/routes/index.tsx +++ b/example/src/routes/index.tsx @@ -1,4 +1,5 @@ -import { component$ } from "@builder.io/qwik"; +import { component$, useVisibleTask$ } from "@builder.io/qwik"; +import { usePWA } from '@qwikdev/pwa/client' import type { DocumentHead } from "@builder.io/qwik-city"; import Counter from "~/components/starter/counter/counter"; @@ -7,6 +8,12 @@ import Infobox from "~/components/starter/infobox/infobox"; import Starter from "~/components/starter/next-steps/next-steps"; export default component$(() => { + const pwa = usePWA() + useVisibleTask$(({ track }) => { + track(pwa.isPWAInstalled) + console.log(pwa) + }) + return ( <> diff --git a/example/vite.config.ts b/example/vite.config.ts index c70e9af..eb4f0cc 100644 --- a/example/vite.config.ts +++ b/example/vite.config.ts @@ -3,6 +3,7 @@ import { qwikVite } from "@builder.io/qwik/optimizer"; import { qwikCity } from "@builder.io/qwik-city/vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { type PWAOptions, qwikPwa } from "@qwikdev/pwa"; +console.log(import.meta.resolve('@qwikdev/pwa')) const config: PWAOptions | undefined = process.env.CUSTOM_CONFIG === "true" ? { config: true } diff --git a/package.json b/package.json index d04783d..21c451c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,16 @@ "default": "./lib/sw.qwik.cjs" } }, + "./client": { + "import": { + "types": "./lib-types/client.d.mts", + "default": "./lib/client.qwik.js" + }, + "require": { + "types": "./lib-types/client.d.cts", + "default": "./lib/client.qwik.cjs" + } + }, "./*": "./*" }, "main": "lib/index.qwik.js", @@ -60,6 +70,9 @@ "sw": [ "./lib-types/sw.d.ts" ], + "client": [ + "./lib-types/client.d.ts" + ], "*": [ "./*" ] diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..359063d --- /dev/null +++ b/src/client.ts @@ -0,0 +1,119 @@ +import { $, NoSerialize, Signal, noSerialize } from '@builder.io/qwik' +// @ts-ignore +import webManifest from 'virtual:qwik-pwa/manifest' +import { useOnWindow, useSignal, useStore, useVisibleTask$ } from "@builder.io/qwik"; + +const installPromptKey = 'qwik-pwa:hide-install' + +export function usePWA() { + const registrationError = useSignal(false) + const swActivated = useSignal(false) + const showInstallPrompt = useSignal(false) + const hideInstall = useSignal(false) + const swRegistration = useSignal | null>() + + const offlineReady = useSignal(false) + const needRefresh = useSignal(false) + + const isPWAInstalled = useSignal(false) + + let deferredPrompt: Signal | null> = useSignal(null) + let beforeInstallPrompt: Signal | null> = useSignal(null) + + let install = $(async () => { + if (hideInstall.value) { + return + } + if (!showInstallPrompt.value || !deferredPrompt.value) { + showInstallPrompt.value = false + return + } + + showInstallPrompt.value = false + deferredPrompt.value.prompt() + await deferredPrompt.value.userChoice + }) + let cancelInstall = $(() => { + if (hideInstall.value) { + return + } + deferredPrompt.value = null + showInstallPrompt.value = false + window.removeEventListener('beforeinstallprompt', beforeInstallPrompt.value!) + hideInstall.value = true + localStorage.setItem(installPromptKey, 'true') + }) + + useOnWindow('load', $(async () => { + debugger + // https://thomashunter.name/posts/2021-12-11-detecting-if-pwa-twa-is-installed + const ua = navigator.userAgent + const ios = ua.match(/iPhone|iPad|iPod/) + const useDisplay = webManifest.display === 'standalone' || webManifest.display === 'minimal-ui' ? `${webManifest.display}` : 'standalone' + const standalone = window.matchMedia(`(display-mode: ${useDisplay})`).matches + isPWAInstalled.value = !!(standalone || (ios && !ua.match(/Safari/))) + hideInstall.value = localStorage.getItem(installPromptKey) === 'true' + + window.matchMedia(`(display-mode: ${useDisplay})`).addEventListener('change', (e) => { + // PWA on fullscreen mode will not match standalone nor minimal-ui + if (!isPWAInstalled.value && e.matches) + isPWAInstalled.value = true + }) + + const registrations = await navigator.serviceWorker.getRegistrations() + swRegistration.value = noSerialize(registrations.find((r) => { + if (!r.active?.scriptURL) { + return false + } + const url = new URL(r.active?.scriptURL) + if (url.pathname === '/service-worker.js') { + return true + } + })) + + swRegistration.value?.installing?.addEventListener('statechange', (e) => { + swActivated.value = (e.target as ServiceWorker).state === 'activated' + }) + + ;(await navigator.serviceWorker.getRegistrations()).forEach((registration) => { + registration.addEventListener('statechange', (e) => { + console.log('state', e) + }) + }) + + if (!hideInstall.value) { + beforeInstallPrompt.value = noSerialize((e: Event) => { + e.preventDefault() + deferredPrompt.value = noSerialize(e as InstallPromptEvent) + showInstallPrompt.value = true + }) + window.addEventListener('beforeinstallprompt', beforeInstallPrompt.value!) + + window.addEventListener('appinstalled', () => { + deferredPrompt.value = null + showInstallPrompt.value = false + }) + } + })) + + const cancelPrompt = $(() => { + + }) + + return { + registrationError, + swActivated, + showInstallPrompt, + hideInstall, + isPWAInstalled, + install, + cancelInstall, + cancelPrompt + } +} + +type InstallPromptEvent = Event & { + prompt: () => void + userChoice: Promise<{ outcome: 'dismissed' | 'accepted' }> +} + diff --git a/src/plugins/assets.ts b/src/plugins/assets.ts index ec175c9..0736230 100644 --- a/src/plugins/assets.ts +++ b/src/plugins/assets.ts @@ -1,18 +1,29 @@ +import { readFile } from "node:fs/promises"; import type { QwikPWAContext } from "../context"; import type { Plugin } from "vite"; +import path from "node:path"; -const VIRTUAL = "virtual:qwik-pwa/head"; -const RESOLVED_VIRTUAL = `\0${VIRTUAL}`; +const VIRTUAL_HEAD = "virtual:qwik-pwa/head"; +const VIRTUAL_MANIFEST = "virtual:qwik-pwa/manifest" +const RESOLVED_VIRTUAL_HEAD = `\0${VIRTUAL_HEAD}`; +const RESOLVED_VIRTUAL_MANIFEST = `\0${VIRTUAL_MANIFEST}`; export default function AssetsPlugin(ctx: QwikPWAContext): Plugin { return { name: "qwik-pwa:assets", enforce: "post", resolveId(id) { - return id === VIRTUAL ? RESOLVED_VIRTUAL : undefined; + switch(id) { + case VIRTUAL_HEAD: + return RESOLVED_VIRTUAL_HEAD + case VIRTUAL_MANIFEST: + return RESOLVED_VIRTUAL_MANIFEST + default: + return undefined + } }, async load(id) { - if (id === RESOLVED_VIRTUAL) { + if (id === RESOLVED_VIRTUAL_HEAD) { const assets = await ctx.assets; return ( (await assets?.resolveHtmlLinks()) ?? @@ -21,6 +32,10 @@ export const meta = []; ` ); } + if (id === RESOLVED_VIRTUAL_MANIFEST) { + const manifest = await readFile(path.join(ctx.publicDir, ctx.webManifestUrl), 'utf-8') + return `export default ${manifest}` + } }, buildStart() { // add web manifest to watcher, and so we can reload the page when it changes @@ -41,7 +56,7 @@ export const meta = []; // - invalidate resolved virtual module or // - send full page reload if resolved virtual module is not found const resolvedVirtual = - server.moduleGraph.getModuleById(RESOLVED_VIRTUAL); + server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_HEAD); if (resolvedVirtual) { return [resolvedVirtual]; } diff --git a/src/sw.ts b/src/sw.ts index df58ea1..fba820d 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -63,31 +63,24 @@ export function setupPwa(mode: "auto-update" | "prompt" = "auto-update") { } if (mode === "prompt") { - if (import.meta.env.DEV) { - console.warn( - `Qwik PWA v${version}\nWARNING: "prompt" mode not available yet`, - ); - } - /* self.addEventListener("message", (event) => { if (event.data.type === "SKIP_WAITING") { self.skipWaiting(); } }); - */ } - // else { - // Skip-Waiting Service Worker-based solution - self.addEventListener("activate", async () => { - // after we've taken over, iterate over all the current clients (windows) - const clients = await self.clients.matchAll({ type: "window" }); - clients.forEach((client) => { - // ...and refresh each one of them - client.navigate(client.url); + else { + // Skip-Waiting Service Worker-based solution + self.addEventListener("activate", async () => { + // after we've taken over, iterate over all the current clients (windows) + const clients = await self.clients.matchAll({ type: "window" }); + clients.forEach((client) => { + // ...and refresh each one of them + client.navigate(client.url); + }); }); - }); - self.skipWaiting(); - // } + self.skipWaiting(); + } const base = "/build/"; // TODO: it should be dynamic based on the build const qprefetchEvent = new MessageEvent("message", { @@ -121,10 +114,10 @@ export type AppSymbols = Map; export type AppBundle = | [bundleName: string, importedBundleIds: number[]] | [ - bundleName: string, - importedBundleIds: number[], - symbolHashesInBundle: string[], - ]; + bundleName: string, + importedBundleIds: number[], + symbolHashesInBundle: string[], + ]; export type LinkBundle = [routePattern: RegExp, bundleIds: number[]]; diff --git a/vite.config.ts b/vite.config.ts index 89d0292..16d5f6a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ build: { target: "es2020", lib: { - entry: ["./src/index.ts", "./src/sw.ts", "./src/head.ts"], + entry: ["./src/index.ts", "./src/sw.ts", "./src/head.ts", "./src/client.ts"], formats: ["es", "cjs"], fileName: (format, entryName) => `${entryName}.qwik.${format === "es" ? "js" : "cjs"}`, @@ -23,6 +23,7 @@ export default defineConfig({ external: [ "fast-glob", "virtual:qwik-pwa/head", + "virtual:qwik-pwa/manifest", ...excludeAll(builtinModules), ...builtinModules, /^node:.*/,