diff --git a/composables/setups.ts b/composables/setups.ts index 90b12a22..1dab3918 100644 --- a/composables/setups.ts +++ b/composables/setups.ts @@ -11,6 +11,8 @@ export function setupPageHeader() { return acc }, {} as Record) + const publicRuntimeConfig = useRuntimeConfig().public + useHeadFixed({ htmlAttrs: { lang: () => locale.value, @@ -23,12 +25,17 @@ export function setupPageHeader() { titleTemplate += ` (${buildInfo.env})` return titleTemplate }, - link: process.client && useRuntimeConfig().public.pwaEnabled + link: process.client && publicRuntimeConfig.pwaEnabled && !publicRuntimeConfig.tauriPlatform ? () => [{ key: 'webmanifest', rel: 'manifest', href: `/manifest-${locale.value}${colorMode.value === 'dark' ? '-dark' : ''}.webmanifest`, }] - : [], + : process.client && publicRuntimeConfig.pwaEnabled && publicRuntimeConfig.tauriPlatform + ? () => [{ + rel: 'manifest', + href: '/manifest.webmanifest', + }] + : [], }) } diff --git a/modules/pwa/i18n.ts b/modules/pwa/i18n.ts index 2c8c960e..02fd1fe3 100644 --- a/modules/pwa/i18n.ts +++ b/modules/pwa/i18n.ts @@ -37,6 +37,34 @@ export const createI18n = async (): Promise => { const defaultManifest: Required = pwa.webmanifest[env] + if (process.env.TAURI_PLATFORM) { + return { + 'en-US': { + ...defaultManifest, + lang: 'en-US', + dir: 'ltr', + scope: '/', + id: '/', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#ffffff', + icons: [ + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + ], + }, + } + } + const locales: RequiredWebManifestEntry[] = await Promise.all( pwaLocales .filter(l => l.code !== 'en-US') diff --git a/modules/pwa/index.ts b/modules/pwa/index.ts index 7b8e6ead..4d6df7ce 100644 --- a/modules/pwa/index.ts +++ b/modules/pwa/index.ts @@ -22,6 +22,7 @@ export default defineNuxtModule({ return vitePwaClientPlugin?.api } let webmanifests: LocalizedWebManifest | undefined + const tauriPlatform = !!process.env.TAURI_PLATFORM // TODO: combine with configurePWAOptions? nuxt.hook('nitro:init', (nitro) => { @@ -41,50 +42,59 @@ export default defineNuxtModule({ throw new Error('Remove vite-plugin-pwa plugin from Vite Plugins entry in Nuxt config file!') webmanifests = await createI18n() - const generateManifest = (entry: string) => { - const manifest = webmanifests![entry] - if (!manifest) - throw new Error(`No webmanifest found for locale/theme ${entry}`) - return JSON.stringify(manifest) - } - viteInlineConfig.plugins.push({ - name: 'elk:pwa:locales:build', - apply: 'build', - generateBundle(_, bundle) { - if (options.disable || !bundle) - return - Object.keys(webmanifests!).map(wm => [wm, `manifest-${wm}.webmanifest`]).forEach(([wm, fileName]) => { - bundle[fileName] = { - isAsset: true, - type: 'asset', - name: undefined, - source: generateManifest(wm), - fileName, - } - }) - }, - }) - viteInlineConfig.plugins.push({ - name: 'elk:pwa:locales:dev', - apply: 'serve', - configureServer(server) { - const localeMatcher = new RegExp(`^${nuxt.options.app.baseURL}manifest-(.*).webmanifest$`) - server.middlewares.use((req, res, next) => { - const match = req.url?.match(localeMatcher) - const entry = match && webmanifests![match[1]] - if (entry) { - res.statusCode = 200 - res.setHeader('Content-Type', 'application/manifest+json') - res.write(JSON.stringify(entry), 'utf-8') - res.end() - } - else { - next() - } - }) - }, - }) + if (tauriPlatform) { + options.filename = 'tauri-sw.ts' + options.manifest = webmanifests['en-US']! + options.injectManifest = options.injectManifest || {} + options.injectManifest.injectionPoint = undefined + } + else { + const generateManifest = (entry: string) => { + const manifest = webmanifests![entry] + if (!manifest) + throw new Error(`No webmanifest found for locale/theme ${entry}`) + return JSON.stringify(manifest) + } + viteInlineConfig.plugins.push({ + name: 'elk:pwa:locales:build', + apply: 'build', + generateBundle(_, bundle) { + if (options.disable || !bundle) + return + + Object.keys(webmanifests!).map(wm => [wm, `manifest-${wm}.webmanifest`]).forEach(([wm, fileName]) => { + bundle[fileName] = { + isAsset: true, + type: 'asset', + name: undefined, + source: generateManifest(wm), + fileName, + } + }) + }, + }) + viteInlineConfig.plugins.push({ + name: 'elk:pwa:locales:dev', + apply: 'serve', + configureServer(server) { + const localeMatcher = new RegExp(`^${nuxt.options.app.baseURL}manifest-(.*).webmanifest$`) + server.middlewares.use((req, res, next) => { + const match = req.url?.match(localeMatcher) + const entry = match && webmanifests![match[1]] + if (entry) { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/manifest+json') + res.write(JSON.stringify(entry), 'utf-8') + res.end() + } + else { + next() + } + }) + }, + }) + } configurePWAOptions(options, nuxt) const plugins = VitePWA(options) @@ -102,7 +112,7 @@ export default defineNuxtModule({ next() } nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => { - if (isServer) + if (isServer || !tauriPlatform) return viteServer.middlewares.stack.push({ route: webManifest, handle: emptyHandle }) @@ -131,6 +141,9 @@ export default defineNuxtModule({ } else { nuxt.hook('nitro:config', async (nitroConfig) => { + if (!tauriPlatform) + return + nitroConfig.routeRules = nitroConfig.routeRules || {} for (const locale of pwaLocales) { nitroConfig.routeRules![`/manifest-${locale.code}.webmanifest`] = { diff --git a/modules/tauri/index.ts b/modules/tauri/index.ts index eb00a5b8..3bb1b117 100644 --- a/modules/tauri/index.ts +++ b/modules/tauri/index.ts @@ -15,7 +15,6 @@ export default defineNuxtModule({ if (nuxt.options.dev) nuxt.options.ssr = false - nuxt.options.pwa.disable = true nuxt.options.sourcemap.client = false nuxt.options.alias = { @@ -44,12 +43,6 @@ export default defineNuxtModule({ // cleanup files copied from the public folder that we don't need nuxt.hook('close', async () => { await rm('.output/public/_redirects') - await rm('.output/public/apple-touch-icon.png') - await rm('.output/public/elk-og.png') - await rm('.output/public/favicon.ico') - await rm('.output/public/pwa-192x192.png') - await rm('.output/public/pwa-512x512.png') - await rm('.output/public/robots.txt') }) }, }) diff --git a/nuxt.config.ts b/nuxt.config.ts index aee63570..238b0f34 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -92,6 +92,7 @@ export default defineNuxtConfig({ public: { env: '', // set in build-env module buildInfo: {} as BuildInfo, // set in build-env module + tauriPlatform: !!process.env.TAURI_PLATFORM, pwaEnabled: !isDevelopment || process.env.VITE_DEV_PWA === 'true', // We use LibreTranslate(https://github.com/LibreTranslate/LibreTranslate) as our default translation server #76 translateApi: '', diff --git a/service-worker/tauri-sw.ts b/service-worker/tauri-sw.ts new file mode 100644 index 00000000..6ce73f37 --- /dev/null +++ b/service-worker/tauri-sw.ts @@ -0,0 +1,97 @@ +/// +/// +import { clientsClaim } from 'workbox-core' +import { registerRoute } from 'workbox-routing' +import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies' +import { CacheableResponsePlugin } from 'workbox-cacheable-response' +import { ExpirationPlugin } from 'workbox-expiration' +import { onNotificationClick, onPush } from './web-push-notifications' + +declare let self: ServiceWorkerGlobalScope + +self.skipWaiting() +clientsClaim() + +if (import.meta.env.DEV) { + // Avoid caching on dev: force always go to the server + registerRoute( + () => true, + new NetworkFirst({ + cacheName: 'elk-dev', + plugins: [ + new CacheableResponsePlugin({ statuses: [-1] }), + ], + }), + ) +} + +if (import.meta.env.PROD) { + const denyList: RegExp[] = [/^\/api\//, /^\/login\//, /^\/oauth\//, /^\/signin\//, /^\/web-share-target\//] + const matchDenyList = (url: URL) => { + const pathnameAndSearch = url.pathname + url.search + for (const regExp of denyList) { + if (regExp.test(pathnameAndSearch)) + return false + } + + return true + } + // Cache page navigations (html) with a Network First strategy + registerRoute( + ({ sameOrigin, request, url }) => { + return sameOrigin && request.mode === 'navigate' && matchDenyList(url) + }, + new NetworkFirst({ + cacheName: 'elk-pages', + plugins: [ + new CacheableResponsePlugin({ statuses: [200] }), + ], + }), + ) + + // Cache CSS, JS, and Web Worker requests with a Stale While Revalidate strategy + registerRoute( + ({ sameOrigin, request }) => + sameOrigin && (request.destination === 'style' + || request.destination === 'manifest' + || request.destination === 'script' + || request.destination === 'worker'), + new StaleWhileRevalidate({ + cacheName: 'elk-assets', + plugins: [ + new CacheableResponsePlugin({ statuses: [200] }), + ], + }), + ) + + // include shiki cache + registerRoute( + ({ sameOrigin, url }) => + sameOrigin && url.pathname.startsWith('/shiki/'), + new StaleWhileRevalidate({ + cacheName: 'elk-shiki', + plugins: [ + new CacheableResponsePlugin({ statuses: [200] }), + // 365 days max + new ExpirationPlugin({ maxAgeSeconds: 60 * 60 * 24 * 365 }), + ], + }), + ) + + // Cache images with a Cache First strategy + registerRoute( + ({ sameOrigin, request }) => + sameOrigin && request.destination === 'image', + new CacheFirst({ + cacheName: 'elk-images', + plugins: [ + new CacheableResponsePlugin({ statuses: [200] }), + // 150 max, 30 days max: purge on quota error + new ExpirationPlugin({ purgeOnQuotaError: true, maxEntries: 150, maxAgeSeconds: 60 * 60 * 24 * 30 }), + ], + }), + ) +} + +self.addEventListener('push', onPush) +self.addEventListener('notificationclick', onNotificationClick)