Add PWA support
This commit is contained in:
		
							parent
							
								
									b959371784
								
							
						
					
					
						commit
						f3ccfcbe48
					
				|  | @ -0,0 +1 @@ | |||
| Fixes service worker (#1634) | ||||
|  | @ -0,0 +1 @@ | |||
| Handle PWA correctly and provide better cache strategy for album covers (#1721) | ||||
|  | @ -24,10 +24,22 @@ module.exports = { | |||
|     'vue/no-v-html': 'off', // TODO: tackle this properly
 | ||||
|     'vue/no-use-v-if-with-v-for': 'off', | ||||
| 
 | ||||
|     '@typescript-eslint/ban-ts-comment': 'off', | ||||
|     // NOTE: Handled by typescript
 | ||||
|     'no-undef': 'off', | ||||
|     'no-unused-vars': 'off', | ||||
| 
 | ||||
|     // TODO (wvffle): Migrate to VUI
 | ||||
|     //                We're using `// @ts-ignore` in jQuery extensions
 | ||||
|     //                and gettext for vue 2
 | ||||
|     '@typescript-eslint/ban-ts-comment': 'off', | ||||
| 
 | ||||
|     // TODO (wvffle): Enable typescript rules later
 | ||||
|     '@typescript-eslint/no-this-alias': 'off', | ||||
|     '@typescript-eslint/no-empty-function': 'off' | ||||
|     '@typescript-eslint/no-empty-function': 'off', | ||||
|     '@typescript-eslint/no-unused-vars': 'off', | ||||
| 
 | ||||
|     // TODO (wvffle): Migration to pinia
 | ||||
|     //                Vuex 3 store does not have types defined, hence we use `any`
 | ||||
|     '@typescript-eslint/no-explicit-any': 'off' | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -50,6 +50,7 @@ | |||
|     "@babel/core": "7.17.12", | ||||
|     "@babel/plugin-transform-runtime": "7.17.12", | ||||
|     "@babel/preset-env": "7.16.11", | ||||
|     "@types/jest": "27.4.1", | ||||
|     "@types/jquery": "3.5.14", | ||||
|     "@types/lodash-es": "4.17.6", | ||||
|     "@typescript-eslint/eslint-plugin": "5.19.0", | ||||
|  | @ -72,12 +73,18 @@ | |||
|     "jest-cli": "27.5.1", | ||||
|     "moxios": "0.4.0", | ||||
|     "sinon": "13.0.2", | ||||
|     "ts-jest": "27.1.4", | ||||
|     "typescript": "4.6.3", | ||||
|     "unplugin-vue2-script-setup": "0.10.2", | ||||
|     "vite": "2.8.6", | ||||
|     "vite-plugin-pwa": "0.12.0", | ||||
|     "vite-plugin-vue2": "1.9.3", | ||||
|     "vue-jest": "3.0.7", | ||||
|     "vue-template-compiler": "2.6.14" | ||||
|     "vue-template-compiler": "2.6.14", | ||||
|     "workbox-core": "6.5.3", | ||||
|     "workbox-precaching": "6.5.3", | ||||
|     "workbox-routing": "6.5.3", | ||||
|     "workbox-strategies": "6.5.3" | ||||
|   }, | ||||
|   "resolutions": { | ||||
|     "vue-plyr/plyr": "3.6.12" | ||||
|  | @ -132,14 +139,19 @@ | |||
|   ], | ||||
|   "jest": { | ||||
|     "moduleFileExtensions": [ | ||||
|       "ts", | ||||
|       "js", | ||||
|       "json", | ||||
|       "vue" | ||||
|     ], | ||||
|     "transform": { | ||||
|       ".*\\.(vue)$": "vue-jest", | ||||
|       "^.+\\.js$": "babel-jest" | ||||
|       "^.+\\.js$": "babel-jest", | ||||
|       "^.+\\.ts$": "ts-jest" | ||||
|     }, | ||||
|     "transformIgnorePatterns": [ | ||||
|       "<rootDir>/node_modules/(?!lodash-es/.*)" | ||||
|     ], | ||||
|     "moduleNameMapper": { | ||||
|       "^~/(.*)$": "<rootDir>/src/$1" | ||||
|     }, | ||||
|  |  | |||
|  | @ -1,93 +0,0 @@ | |||
| /* eslint no-undef: "off" */ | ||||
| 
 | ||||
| // This is the code piece that GenerateSW mode can't provide for us.
 | ||||
| // This code listens for the user's confirmation to update the app.
 | ||||
| workbox.loadModule('workbox-routing') | ||||
| workbox.loadModule('workbox-strategies') | ||||
| workbox.loadModule('workbox-expiration') | ||||
| 
 | ||||
| self.addEventListener('message', (e) => { | ||||
|   if (!e.data) { | ||||
|     return | ||||
|   } | ||||
|   console.log('[sw] received message', e.data) | ||||
|   switch (e.data.command) { | ||||
|     case 'skipWaiting': | ||||
|       self.skipWaiting() | ||||
|       break | ||||
|     case 'serverChosen': | ||||
|       self.registerServerRoutes(e.data.serverUrl) | ||||
|       break | ||||
|     default: | ||||
|       // NOOP
 | ||||
|       break | ||||
|   } | ||||
| }) | ||||
| workbox.core.clientsClaim() | ||||
| 
 | ||||
| const router = new workbox.routing.Router() | ||||
| router.addCacheListener() | ||||
| router.addFetchListener() | ||||
| 
 | ||||
| let registeredServerRoutes = [] | ||||
| self.registerServerRoutes = (serverUrl) => { | ||||
|   console.log('[sw] Setting up API caching for', serverUrl) | ||||
|   registeredServerRoutes.forEach((r) => { | ||||
|     console.log('[sw] Unregistering previous API route...', r) | ||||
|     router.unregisterRoute(r) | ||||
|   }) | ||||
|   if (!serverUrl) { | ||||
|     return | ||||
|   } | ||||
|   const regexReadyServerUrl = serverUrl.replace('.', '\\.') | ||||
|   registeredServerRoutes = [] | ||||
|   const networkFirstPaths = [ | ||||
|     'api/v1/', | ||||
|     'media/' | ||||
|   ] | ||||
|   const networkFirstExcludedPaths = [ | ||||
|     'api/v1/listen' | ||||
|   ] | ||||
|   const strategy = new workbox.strategies.NetworkFirst({ | ||||
|     cacheName: 'api-cache:' + serverUrl, | ||||
|     plugins: [ | ||||
|       new workbox.expiration.Plugin({ | ||||
|         maxAgeSeconds: 24 * 60 * 60 * 7 | ||||
|       }) | ||||
|     ] | ||||
|   }) | ||||
|   const networkFirstRoutes = networkFirstPaths.map((path) => { | ||||
|     const regex = new RegExp(regexReadyServerUrl + path) | ||||
|     return new workbox.routing.RegExpRoute(regex, () => {}) | ||||
|   }) | ||||
|   const matcher = ({ url, event }) => { | ||||
|     for (let index = 0; index < networkFirstExcludedPaths.length; index++) { | ||||
|       const blacklistedPath = networkFirstExcludedPaths[index] | ||||
|       if (url.pathname.startsWith('/' + blacklistedPath)) { | ||||
|         // the path is blacklisted, we don't cache it at all
 | ||||
|         console.log('[sw] Path is blacklisted, not caching', url.pathname) | ||||
|         return false | ||||
|       } | ||||
|     } | ||||
|     // we call other regex matchers
 | ||||
|     for (let index = 0; index < networkFirstRoutes.length; index++) { | ||||
|       const route = networkFirstRoutes[index] | ||||
|       const result = route.match({ url, event }) | ||||
|       if (result) { | ||||
|         return result | ||||
|       } | ||||
|     } | ||||
|     return false | ||||
|   } | ||||
| 
 | ||||
|   const route = new workbox.routing.Route(matcher, strategy) | ||||
|   console.log('[sw] registering new API route...', route) | ||||
|   router.registerRoute(route) | ||||
|   registeredServerRoutes.push(route) | ||||
| } | ||||
| 
 | ||||
| // The precaching code provided by Workbox.
 | ||||
| self.__precacheManifest = [].concat(self.__precacheManifest || []) | ||||
| 
 | ||||
| // workbox.precaching.suppressWarnings(); // Only used with Vue CLI 3 and Workbox v3.
 | ||||
| workbox.precaching.precacheAndRoute(self.__precacheManifest, {}) | ||||
|  | @ -111,64 +111,6 @@ const { width } = useWindowSize() | |||
| const player = ref() | ||||
| const showShortcutsModal = ref(false) | ||||
| const showSetInstanceModal = ref(false) | ||||
| // export default { | ||||
| //   computed: { | ||||
| //     ...mapState({ | ||||
| //       serviceWorker: state => state.ui.serviceWorker | ||||
| //     }), | ||||
| //   }, | ||||
| //   watch: { | ||||
| //     'serviceWorker.updateAvailable': { | ||||
| //       handler (v) { | ||||
| //         if (!v) { | ||||
| //           return | ||||
| //         } | ||||
| //         const self = this | ||||
| //         this.$store.commit('ui/addMessage', { | ||||
| //           content: this.$pgettext('App/Message/Paragraph', 'A new version of the app is available.'), | ||||
| //           date: new Date(), | ||||
| //           key: 'refreshApp', | ||||
| //           displayTime: 0, | ||||
| //           classActions: 'bottom attached opaque', | ||||
| //           actions: [ | ||||
| //             { | ||||
| //               text: this.$pgettext('App/Message/Paragraph', 'Update'), | ||||
| //               class: 'primary', | ||||
| //               click: function () { | ||||
| //                 self.updateApp() | ||||
| //               } | ||||
| //             }, | ||||
| //             { | ||||
| //               text: this.$pgettext('App/Message/Paragraph', 'Later'), | ||||
| //               class: 'basic' | ||||
| //             } | ||||
| //           ] | ||||
| //         }) | ||||
| //       }, | ||||
| //       immediate: true | ||||
| //     } | ||||
| //   }, | ||||
| //   async created () { | ||||
| //     if (navigator.serviceWorker) { | ||||
| //       navigator.serviceWorker.addEventListener( | ||||
| //         'controllerchange', () => { | ||||
| //           if (this.serviceWorker.refreshing) return | ||||
| //           this.$store.commit('ui/serviceWorker', { | ||||
| //             refreshing: true | ||||
| //           }) | ||||
| //           window.location.reload() | ||||
| //         } | ||||
| //       ) | ||||
| //     } | ||||
| //   }, | ||||
| //   methods: { | ||||
| //     updateApp () { | ||||
| //       this.$store.commit('ui/serviceWorker', { updateAvailable: false }) | ||||
| //       if (!this.serviceWorker.registration || !this.serviceWorker.registration.waiting) { return } | ||||
| //       this.serviceWorker.registration.waiting.postMessage({ command: 'skipWaiting' }) | ||||
| //     }, | ||||
| //   } | ||||
| // } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -1,45 +1,40 @@ | |||
| import { InitModule } from '~/types' | ||||
| import { register } from 'register-service-worker' | ||||
| import { AppModule } from '~/types' | ||||
| import { registerSW } from 'virtual:pwa-register' | ||||
| import logger from '~/logging' | ||||
| import Vue from 'vue' | ||||
| 
 | ||||
| export const install: InitModule = ({ store }) => { | ||||
|   if (import.meta.env.PROD) { | ||||
|     register(`${import.meta.env.BASE_URL}service-worker.js`, { | ||||
|       registrationOptions: { scope: '/' }, | ||||
|       ready () { | ||||
|         console.log( | ||||
|           'App is being served from cache by a service worker.' | ||||
|         ) | ||||
|       }, | ||||
|       registered (registration) { | ||||
|         console.log('Service worker has been registered.') | ||||
|         // check for updates every 2 hours
 | ||||
|         const checkInterval = 1000 * 60 * 60 * 2 | ||||
|         // var checkInterval = 1000 * 5
 | ||||
|         setInterval(() => { | ||||
|           console.log('Checking for service worker update…') | ||||
|           registration.update() | ||||
|         }, checkInterval) | ||||
|         store.commit('ui/serviceWorker', { registration: registration }) | ||||
|         if (registration.active) { | ||||
|           registration.active.postMessage({ command: 'serverChosen', serverUrl: store.state.instance.instanceUrl }) | ||||
|         } | ||||
|       }, | ||||
|       cached () { | ||||
|         console.log('Content has been cached for offline use.') | ||||
|       }, | ||||
|       updatefound () { | ||||
|         console.log('New content is downloading.') | ||||
|       }, | ||||
|       updated (registration) { | ||||
|         console.log('New content is available; please refresh!') | ||||
|         store.commit('ui/serviceWorker', { updateAvailable: true, registration: registration }) | ||||
|       }, | ||||
|       offline () { | ||||
|         console.log('No internet connection found. App is running in offline mode.') | ||||
|       }, | ||||
|       error (error) { | ||||
|         console.error('Error during service worker registration:', error) | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
| const { $pgettext } = Vue.prototype | ||||
| 
 | ||||
| export const install: AppModule = ({ store }) => { | ||||
|   const updateSW = registerSW({ | ||||
|     onRegisterError () { | ||||
|       logger.default.error('SW install error') | ||||
|     }, | ||||
|     onOfflineReady () { | ||||
|       logger.default.info('Funkwhale is being served from cache by a service worker.') | ||||
|     }, | ||||
|     onRegistered () { | ||||
|       logger.default.info('Service worker has been registered.') | ||||
|     }, | ||||
|     onNeedRefresh () { | ||||
|       store.commit('ui/addMessage', { | ||||
|         content: $pgettext('App/Message/Paragraph', 'A new version of the app is available.'), | ||||
|         date: new Date(), | ||||
|         key: 'refreshApp', | ||||
|         displayTime: 0, | ||||
|         classActions: 'bottom attached opaque', | ||||
|         actions: [ | ||||
|           { | ||||
|             text: $pgettext('App/Message/Paragraph', 'Update'), | ||||
|             class: 'primary', | ||||
|             click: () => updateSW() | ||||
|           }, | ||||
|           { | ||||
|             text: $pgettext('App/Message/Paragraph', 'Later'), | ||||
|             class: 'basic' | ||||
|           } | ||||
|         ] | ||||
|       }) | ||||
|     } | ||||
|   }) | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,48 @@ | |||
| import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' | ||||
| import { NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies' | ||||
| import { ExpirationPlugin } from 'workbox-expiration' | ||||
| import { registerRoute } from 'workbox-routing' | ||||
| import { clientsClaim } from 'workbox-core' | ||||
| 
 | ||||
| declare let self: ServiceWorkerGlobalScope | ||||
| 
 | ||||
| // NOTE: Clean up outdated caches
 | ||||
| //       With each new production build, all precached assets
 | ||||
| //       that were modified are added to the cache. The old versions
 | ||||
| //       need to be removed manually.
 | ||||
| cleanupOutdatedCaches() | ||||
| 
 | ||||
| // Let new service worker claim control of already open web pages
 | ||||
| // https://developer.chrome.com/docs/workbox/modules/workbox-core/#clients-claim
 | ||||
| clientsClaim() | ||||
| 
 | ||||
| // Support for an update prompt handled by VitePWA:
 | ||||
| // https://vite-plugin-pwa.netlify.app/guide/prompt-for-update.html
 | ||||
| self.addEventListener('message', (event) => { | ||||
|   if (event.data?.type === 'SKIP_WAITING') { | ||||
|     return self.skipWaiting() | ||||
|   } | ||||
| }) | ||||
| 
 | ||||
| // NOTE: Network-First cache for API calls
 | ||||
| //       We're using cache only when the user goes offline
 | ||||
| registerRoute(({ url }) => { | ||||
|   if (url.pathname.startsWith('/api/v1/listen')) return false | ||||
|   return url.pathname.startsWith('/api/v1') | ||||
| }, new NetworkFirst({ | ||||
|   plugins: [ | ||||
|     // Expire after a week
 | ||||
|     new ExpirationPlugin({ maxAgeSeconds: 7 * 24 * 3600 }) | ||||
|   ] | ||||
| })) | ||||
| 
 | ||||
| // NOTE: Stale-While-Revalidate cache for album covers
 | ||||
| //       We're serving from cache if available and making a request
 | ||||
| //       in the background to update the cache for next request
 | ||||
| registerRoute(({ url }) => { | ||||
|   return url.pathname.startsWith('/media') | ||||
| }, new StaleWhileRevalidate()) | ||||
| 
 | ||||
| // Precache all assets and add routes for them
 | ||||
| // https://developer.chrome.com/docs/workbox/reference/workbox-precaching/#method-precacheAndRoute
 | ||||
| precacheAndRoute(self.__WB_MANIFEST) | ||||
|  | @ -9,12 +9,6 @@ function getDefaultUrl () { | |||
|   ) | ||||
| } | ||||
| 
 | ||||
| function notifyServiceWorker (registration, message) { | ||||
|   if (registration && registration.active) { | ||||
|     registration.active.postMessage(message) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state: { | ||||
|  | @ -87,7 +81,7 @@ export default { | |||
|         value = value + '/' | ||||
|       } | ||||
|       state.instanceUrl = value | ||||
|       notifyServiceWorker(state.registration, { command: 'serverChosen', serverUrl: state.instanceUrl }) | ||||
| 
 | ||||
|       // append the URL to the list (and remove existing one if needed)
 | ||||
|       if (value) { | ||||
|         const index = state.knownInstances.indexOf(value) | ||||
|  |  | |||
|  | @ -174,11 +174,6 @@ export default { | |||
|         orderingDirection: '-', | ||||
|         ordering: 'creation_date' | ||||
|       } | ||||
|     }, | ||||
|     serviceWorker: { | ||||
|       refreshing: false, | ||||
|       registration: null, | ||||
|       updateAvailable: false | ||||
|     } | ||||
|   }, | ||||
|   getters: { | ||||
|  | @ -310,9 +305,6 @@ export default { | |||
|       state.routePreferences[route].orderingDirection = value | ||||
|     }, | ||||
| 
 | ||||
|     serviceWorker: (state, value) => { | ||||
|       state.serviceWorker = { ...state.serviceWorker, ...value } | ||||
|     }, | ||||
|     window: (state, value) => { | ||||
|       state.window = value | ||||
|     } | ||||
|  |  | |||
|  | @ -19,8 +19,9 @@ export function parseAPIErrors (responseData: APIErrorResponse, parentField?: st | |||
|       } | ||||
| 
 | ||||
|       const value = responseData[field] | ||||
|       if (value as string[]) { | ||||
|         errors.push(...(value as string[]).map(err => { | ||||
|       if (Array.isArray(value)) { | ||||
|         const values = value as string[] | ||||
|         errors.push(...values.map(err => { | ||||
|           return err.toLocaleLowerCase().includes('this field ') | ||||
|             ? `${fieldName}: ${err}` | ||||
|             : err | ||||
|  |  | |||
|  | @ -19,7 +19,7 @@ export default { | |||
| 
 | ||||
|     return hours >= 1 | ||||
|       ? `${hours}:${pad(min)}:${pad(sec)}` | ||||
|       : `${pad(min)}:${pad(sec)}` | ||||
|       : `${min}:${pad(sec)}` | ||||
|   }, | ||||
|   durationFormatted (v: string) { | ||||
|     const duration = parseInt(v) | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import {expect} from 'chai' | ||||
| import moment from 'moment' | ||||
| import {truncate, ago, capitalize, year, unique} from '~/filters' | ||||
| import {truncate, ago, capitalize, year, unique} from '~/init/filters' | ||||
| 
 | ||||
| describe('filters', () => { | ||||
|   describe('truncate', () => { | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| var sinon = require('sinon') | ||||
| import {expect} from 'chai' | ||||
| import * as _ from 'lodash-es' | ||||
| 
 | ||||
| import store from '~/store/queue' | ||||
| import { testAction } from '../../utils' | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
|     "baseUrl": ".", | ||||
|     "module": "ESNext", | ||||
|     "target": "ESNext", | ||||
|     "lib": ["DOM", "ESNext"], | ||||
|     "lib": ["DOM", "ESNext", "WebWorker"], | ||||
|     "strict": true, | ||||
|     "esModuleInterop": true, | ||||
|     "jsx": "preserve", | ||||
|  |  | |||
|  | @ -1,9 +1,8 @@ | |||
| import { defineConfig, HmrOptions } from 'vite' | ||||
| import { createVuePlugin as Vue2 } from 'vite-plugin-vue2' | ||||
| import ScriptSetup from 'unplugin-vue2-script-setup/vite' | ||||
| 
 | ||||
| // @ts-ignore
 | ||||
| import path from 'path' | ||||
| import { VitePWA } from 'vite-plugin-pwa' | ||||
| import { resolve } from 'path' | ||||
| 
 | ||||
| const port = +(process.env.VUE_PORT ?? 8080) | ||||
| 
 | ||||
|  | @ -29,6 +28,18 @@ export default defineConfig(() => ({ | |||
|     // https://github.com/antfu/unplugin-vue2-script-setup
 | ||||
|     ScriptSetup(), | ||||
| 
 | ||||
|     // https://github.com/antfu/vite-plugin-pwa
 | ||||
|     VitePWA({ | ||||
|       strategies: 'injectManifest', | ||||
|       srcDir: 'src', | ||||
|       filename: 'serviceWorker.ts', | ||||
|       devOptions: { | ||||
|         enabled: true, | ||||
|         type: 'module', | ||||
|         navigateFallback: 'index.html' | ||||
|       } | ||||
|     }), | ||||
| 
 | ||||
|     { | ||||
|       name: 'fix-fomantic-ui-css', | ||||
|       transform (src, id) { | ||||
|  | @ -41,7 +52,7 @@ export default defineConfig(() => ({ | |||
|   server: { port, hmr }, | ||||
|   resolve: { | ||||
|     alias: { | ||||
|       '~': path.resolve(__dirname, './src') | ||||
|       '~': resolve(__dirname, './src') | ||||
|     } | ||||
|   }, | ||||
|   build: { | ||||
|  |  | |||
							
								
								
									
										840
									
								
								front/yarn.lock
								
								
								
								
							
							
						
						
									
										840
									
								
								front/yarn.lock
								
								
								
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Loading…
	
		Reference in New Issue
	
	 Kasper Seweryn
						Kasper Seweryn