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:.*/,