Commit 9aec846a authored by Kasper Seweryn's avatar Kasper Seweryn 🥞 Committed by Georg Krause
Browse files

Add PWA support

parent 20a6acb3
Pipeline #20484 passed with stage
in 13 seconds
Fixes service worker (#1634)
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'
}
}
......@@ -49,6 +49,7 @@
"@babel/core": "7.17.9",
"@babel/plugin-transform-runtime": "7.17.0",
"@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",
......@@ -71,12 +72,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"
......@@ -131,14 +138,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"
},
......
/* 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>
......
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'
}
]
})
}
})
}
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)
......
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', () => {
......
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",
......
import { defineConfig } 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 VUE_PORT = +process.env.VUE_PORT
......@@ -16,6 +15,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) {
......@@ -34,7 +45,7 @@ export default defineConfig(() => ({
},
resolve: {
alias: {
'~': path.resolve(__dirname, './src')
'~': resolve(__dirname, './src')
}
}
}))
This diff is collapsed.
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment