Set up initial configuration for installing

Set up player config for mobile control
environments/review-front-serv-f1ybnc/deployments/3672
Ciarán Ainsworth 2020-01-08 12:16:41 +01:00 zatwierdzone przez Eliot Berriot
rodzic 551fb6d164
commit 2302dc0581
16 zmienionych plików z 1012 dodań i 26 usunięć

Wyświetl plik

@ -117,6 +117,13 @@ FUNKWHALE_SPA_HTML_CACHE_DURATION = env.int(
FUNKWHALE_EMBED_URL = env(
"FUNKWHALE_EMBED_URL", default=FUNKWHALE_URL + "/front/embed.html"
)
FUNKWHALE_SPA_REWRITE_MANIFEST = env.bool(
"FUNKWHALE_SPA_REWRITE_MANIFEST", default=True
)
FUNKWHALE_SPA_REWRITE_MANIFEST_URL = env.bool(
"FUNKWHALE_SPA_REWRITE_MANIFEST_URL", default=None
)
APP_NAME = "Funkwhale"
# XXX: deprecated, see #186

Wyświetl plik

@ -1,5 +1,7 @@
import html
import io
import os
import re
import time
import xml.sax.saxutils
@ -9,6 +11,8 @@ from django.core.cache import caches
from django import urls
from rest_framework import views
from funkwhale_api.federation import utils as federation_utils
from . import preferences
from . import session
from . import throttling
@ -26,6 +30,13 @@ def should_fallback_to_spa(path):
def serve_spa(request):
html = get_spa_html(settings.FUNKWHALE_SPA_HTML_ROOT)
head, tail = html.split("</head>", 1)
if settings.FUNKWHALE_SPA_REWRITE_MANIFEST:
new_url = (
settings.FUNKWHALE_SPA_REWRITE_MANIFEST_URL
or federation_utils.full_url(urls.reverse("api:v1:instance:spa-manifest"))
)
head = replace_manifest_url(head, new_url)
if not preferences.get("common__api_authentication_required"):
try:
request_tags = get_request_head_tags(request) or []
@ -66,17 +77,34 @@ def serve_spa(request):
return http.HttpResponse(head + tail)
MANIFEST_LINK_REGEX = re.compile(r"<link .*rel=(?:'|\")?manifest(?:'|\")?.*>")
def replace_manifest_url(head, new_url):
replacement = '<link rel=manifest href="{}">'.format(new_url)
head = MANIFEST_LINK_REGEX.sub(replacement, head)
return head
def get_spa_html(spa_url):
return get_spa_file(spa_url, "index.html")
def get_spa_file(spa_url, name):
if spa_url.startswith("/"):
# XXX: spa_url is an absolute path to index.html, on the local disk.
# However, we may want to access manifest.json or other files as well, so we
# strip the filename
path = os.path.join(os.path.dirname(spa_url), name)
# we try to open a local file
with open(spa_url) as f:
with open(path) as f:
return f.read()
cache_key = "spa-html:{}".format(spa_url)
cache_key = "spa-file:{}:{}".format(spa_url, name)
cached = caches["local"].get(cache_key)
if cached:
return cached
response = session.get_session().get(utils.join_url(spa_url, "index.html"),)
response = session.get_session().get(utils.join_url(spa_url, name),)
response.raise_for_status()
content = response.text
caches["local"].set(cache_key, content, settings.FUNKWHALE_SPA_HTML_CACHE_DURATION)

Wyświetl plik

@ -9,4 +9,5 @@ admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings")
urlpatterns = [
url(r"^nodeinfo/2.0/?$", views.NodeInfo.as_view(), name="nodeinfo-2.0"),
url(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"),
url(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"),
] + admin_router.urls

Wyświetl plik

@ -1,9 +1,15 @@
import json
from django.conf import settings
from dynamic_preferences.api import serializers
from dynamic_preferences.api import viewsets as preferences_viewsets
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import views
from rest_framework.response import Response
from funkwhale_api.common import middleware
from funkwhale_api.common import preferences
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import nodeinfo
@ -39,3 +45,23 @@ class NodeInfo(views.APIView):
def get(self, request, *args, **kwargs):
data = nodeinfo.get()
return Response(data, status=200, content_type=NODEINFO_2_CONTENT_TYPE)
class SpaManifest(views.APIView):
permission_classes = []
authentication_classes = []
def get(self, request, *args, **kwargs):
existing_manifest = middleware.get_spa_file(
settings.FUNKWHALE_SPA_HTML_ROOT, "manifest.json"
)
parsed_manifest = json.loads(existing_manifest)
parsed_manifest["short_name"] = settings.APP_NAME
instance_name = preferences.get("instance__name")
if instance_name:
parsed_manifest["short_name"] = instance_name
parsed_manifest["name"] = instance_name
instance_description = preferences.get("instance__short_description")
if instance_description:
parsed_manifest["description"] = instance_description
return Response(parsed_manifest, status=200)

Wyświetl plik

@ -2,6 +2,9 @@ import time
import pytest
from django.http import HttpResponse
from django.urls import reverse
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.common import middleware
from funkwhale_api.common import throttling
@ -112,7 +115,7 @@ def test_get_default_head_tags(preferences, settings):
def test_get_spa_html_from_cache(local_cache):
local_cache.set("spa-html:http://test", "hello world")
local_cache.set("spa-file:http://test:index.html", "hello world")
assert middleware.get_spa_html("http://test") == "hello world"
@ -124,16 +127,16 @@ def test_get_spa_html_from_http(local_cache, r_mock, mocker, settings):
assert middleware.get_spa_html("http://test") == "hello world"
cache_set.assert_called_once_with(
"spa-html:{}".format(url),
"spa-file:{}:index.html".format(url),
"hello world",
settings.FUNKWHALE_SPA_HTML_CACHE_DURATION,
)
def test_get_spa_html_from_disk(tmpfile):
with open(tmpfile.name, "wb") as f:
f.write(b"hello world")
assert middleware.get_spa_html(tmpfile.name) == "hello world"
def test_get_spa_html_from_disk(tmp_path):
index = tmp_path / "index.html"
index.write_bytes(b"hello world")
assert middleware.get_spa_html(str(index)) == "hello world"
def test_get_route_head_tags(mocker, settings):
@ -225,3 +228,97 @@ def test_throttle_status_middleware_returns_proper_response(mocker):
response = m(request)
assert response.status_code == 429
@pytest.mark.parametrize(
"link, new_url, expected",
[
(
"<link rel=manifest href=/manifest.json>",
"custom_url",
'<link rel=manifest href="custom_url">',
),
(
"<link href=/manifest.json rel=manifest>",
"custom_url",
'<link rel=manifest href="custom_url">',
),
(
'<link href="/manifest.json" rel=manifest>',
"custom_url",
'<link rel=manifest href="custom_url">',
),
(
'<link href=/manifest.json rel="manifest">',
"custom_url",
'<link rel=manifest href="custom_url">',
),
(
"<link href='/manifest.json' rel=manifest>",
"custom_url",
'<link rel=manifest href="custom_url">',
),
(
"<link href=/manifest.json rel='manifest'>",
"custom_url",
'<link rel=manifest href="custom_url">',
),
# not matching
(
"<link href=/manifest.json rel=notmanifest>",
"custom_url",
"<link href=/manifest.json rel=notmanifest>",
),
],
)
def test_rewrite_manifest_json_url(link, new_url, expected, mocker, settings):
settings.FUNKWHALE_SPA_REWRITE_MANIFEST = True
settings.FUNKWHALE_SPA_REWRITE_MANIFEST_URL = new_url
spa_html = "<html><head>{}</head></html>".format(link)
request = mocker.Mock(path="/")
mocker.patch.object(middleware, "get_spa_html", return_value=spa_html)
mocker.patch.object(
middleware, "get_default_head_tags", return_value=[],
)
response = middleware.serve_spa(request)
assert response.status_code == 200
expected_html = "<html><head>{}\n\n</head></html>".format(expected)
assert response.content == expected_html.encode()
def test_rewrite_manifest_json_url_rewrite_disabled(mocker, settings):
settings.FUNKWHALE_SPA_REWRITE_MANIFEST = False
settings.FUNKWHALE_SPA_REWRITE_MANIFEST_URL = "custom_url"
spa_html = "<html><head><link href=/manifest.json rel=manifest></head></html>"
request = mocker.Mock(path="/")
mocker.patch.object(middleware, "get_spa_html", return_value=spa_html)
mocker.patch.object(
middleware, "get_default_head_tags", return_value=[],
)
response = middleware.serve_spa(request)
assert response.status_code == 200
expected_html = (
"<html><head><link href=/manifest.json rel=manifest>\n\n</head></html>"
)
assert response.content == expected_html.encode()
def test_rewrite_manifest_json_url_rewrite_default_url(mocker, settings):
settings.FUNKWHALE_SPA_REWRITE_MANIFEST = True
settings.FUNKWHALE_SPA_REWRITE_MANIFEST_URL = None
spa_html = "<html><head><link href=/manifest.json rel=manifest></head></html>"
expected_url = federation_utils.full_url(reverse("api:v1:instance:spa-manifest"))
request = mocker.Mock(path="/")
mocker.patch.object(middleware, "get_spa_html", return_value=spa_html)
mocker.patch.object(
middleware, "get_default_head_tags", return_value=[],
)
response = middleware.serve_spa(request)
assert response.status_code == 200
expected_html = '<html><head><link rel=manifest href="{}">\n\n</head></html>'.format(
expected_url
)
assert response.content == expected_html.encode()

Wyświetl plik

@ -1,3 +1,5 @@
import json
from django.urls import reverse
@ -37,3 +39,25 @@ def test_admin_settings_correct_permission(db, logged_in_api_client, preferences
assert response.status_code == 200
assert len(response.data) == len(preferences.all())
def test_manifest_endpoint(api_client, mocker, preferences, tmp_path, settings):
settings.FUNKWHALE_SPA_HTML_ROOT = str(tmp_path / "index.html")
preferences["instance__name"] = "Test pod"
preferences["instance__short_description"] = "Test description"
base_payload = {
"foo": "bar",
}
manifest = tmp_path / "manifest.json"
expected = {
"foo": "bar",
"name": "Test pod",
"short_name": "Test pod",
"description": "Test description",
}
manifest.write_bytes(json.dumps(base_payload).encode())
url = reverse("api:v1:instance:spa-manifest")
response = api_client.get(url)
assert response.status_code == 200
assert response.data == expected

Wyświetl plik

@ -2,13 +2,15 @@
"name": "front",
"version": "0.1.0",
"private": true,
"description": "Funkwhale front-end",
"author": "Eliot Berriot <contact@eliotberriot.com>",
"scripts": {
"serve": "vue-cli-service serve --port ${VUE_PORT:-8000} --host ${VUE_HOST:-0.0.0.0}",
"build": "scripts/i18n-compile.sh && vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"lint": "vue-cli-service lint",
"i18n-extract": "scripts/i18n-extract.sh",
"i18n-compile": "scripts/i18n-compile.sh",
"test:unit": "vue-cli-service test:unit"
"i18n-extract": "scripts/i18n-extract.sh"
},
"dependencies": {
"axios": "^0.18.0",
@ -23,6 +25,7 @@
"masonry-layout": "^4.2.2",
"moment": "^2.22.2",
"qs": "^6.7.0",
"register-service-worker": "^1.6.2",
"sanitize-html": "^1.20.1",
"showdown": "^1.8.6",
"vue": "^2.6.10",
@ -40,6 +43,7 @@
"devDependencies": {
"@vue/cli-plugin-babel": "^3.0.0",
"@vue/cli-plugin-eslint": "^3.0.0",
"@vue/cli-plugin-pwa": "^4.1.2",
"@vue/cli-plugin-unit-mocha": "^3.0.0",
"@vue/cli-service": "^3.0.0",
"@vue/test-utils": "^1.0.0-beta.20",
@ -104,7 +108,5 @@
"iOS >= 9",
"Android >= 4",
"not dead"
],
"author": "Eliot Berriot <contact@eliotberriot.com>",
"description": "Funkwhale front-end"
]
}

Wyświetl plik

@ -11,7 +11,16 @@
<template>
<sidebar></sidebar>
<set-instance-modal @update:show="showSetInstanceModal = $event" :show="showSetInstanceModal"></set-instance-modal>
<service-messages v-if="messages.length > 0"/>
<service-messages>
<message key="refreshApp" class="large info" v-if="serviceWorker.updateAvailable">
<p>
<translate translate-context="App/Message/Paragraph">A new version of the app is available.</translate>
</p>
<button class="ui basic button" @click.stop="updateApp">
<translate translate-context="App/Message/Button">Update</translate>
</button>
</message>
</service-messages>
<transition name="queue">
<queue @touch-progress="$refs.player.setCurrentTime($event)" v-if="$store.state.ui.queueFocused"></queue>
</transition>
@ -62,10 +71,23 @@ export default {
bridge: null,
instanceUrl: null,
showShortcutsModal: false,
showSetInstanceModal: false
showSetInstanceModal: false,
}
},
async created () {
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener(
'controllerchange', () => {
if (this.serviceWorker.refreshing) return;
this.$store.commit('ui/serviceWorker', {
refreshing: true
})
window.location.reload();
}
);
}
this.openWebsocket()
let self = this
if (!this.$store.state.ui.selectedLanguage) {
@ -238,6 +260,11 @@ export default {
parts.push(this.$store.state.instance.settings.instance.name.value || 'Funkwhale')
document.title = parts.join(' – ')
},
updateApp () {
this.$store.commit('ui/serviceWorker', {updateAvailable: false})
if (!this.serviceWorker.registration || !this.serviceWorker.registration.waiting) { return; }
this.serviceWorker.registration.waiting.postMessage('skipWaiting');
}
},
computed: {
...mapState({
@ -246,6 +273,7 @@ export default {
playing: state => state.player.playing,
bufferProgress: state => state.player.bufferProgress,
isLoadingAudio: state => state.player.isLoadingAudio,
serviceWorker: state => state.ui.serviceWorker,
}),
...mapGetters({
hasNext: "queue/hasNext",

Wyświetl plik

@ -3,6 +3,7 @@
<message v-for="message in displayedMessages" :key="String(message.date)" :class="['large', getLevel(message)]">
<p>{{ message.content }}</p>
</message>
<slot></slot>
</div>
</template>

Wyświetl plik

@ -276,6 +276,15 @@ export default {
if (this.currentTrack) {
this.getSound(this.currentTrack)
}
// Add controls for notification drawer
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', this.togglePlay);
navigator.mediaSession.setActionHandler('pause', this.togglePlay);
navigator.mediaSession.setActionHandler('seekforward', this.seekForward);
navigator.mediaSession.setActionHandler('seekbackward', this.seekBackward);
navigator.mediaSession.setActionHandler('nexttrack', this.next);
navigator.mediaSession.setActionHandler('previoustrack', this.previous);
}
},
beforeDestroy () {
this.dummyAudio.unload()
@ -380,6 +389,7 @@ export default {
self.$store.commit('player/resetErrorCount')
self.$store.commit('player/errored', false)
self.$store.commit('player/duration', this.duration())
},
onloaderror: function (sound, error) {
self.removeFromCache(this)
@ -484,6 +494,12 @@ export default {
this.$store.dispatch('player/updateProgress', position)
}
},
seekForward () {
this.seek (5)
},
seekBackward () {
this.seek (-5)
},
observeProgress: function (enable) {
let self = this
if (enable) {
@ -684,6 +700,23 @@ export default {
this.playTimeout = setTimeout(async () => {
await self.loadSound(newValue, oldValue)
}, 500);
// If the session is playing as a PWA, populate the notification
// with details from the track
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: this.currentTrack.title,
artist: this.currentTrack.artist.name,
album: this.currentTrack.album.title,
artwork: [
{ src: this.currentTrack.album.cover.original, sizes: '96x96', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '128x128', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '192x192', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '256x256', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '384x384', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '512x512', type: 'image/png' },
]
});
}
},
immediate: false
},

Wyświetl plik

@ -20,6 +20,7 @@ import locales from '@/locales'
import filters from '@/filters' // eslint-disable-line
import globals from '@/components/globals' // eslint-disable-line
import './registerServiceWorker'
sync(store, router)

Wyświetl plik

@ -0,0 +1,41 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker'
import store from './store'
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
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
var checkInterval = 1000 * 60 * 60 * 2
// var checkInterval = 1000 * 5
setInterval(() => {
console.log('Checking for service worker update…')
registration.update();
}, checkInterval);
},
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)
}
})
}

Wyświetl plik

@ -0,0 +1,33 @@
// 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.
self.addEventListener('message', (e) => {
if (!e.data) {
return;
}
switch (e.data) {
case 'skipWaiting':
self.skipWaiting();
break;
default:
// NOOP
break;
}
});
workbox.core.clientsClaim();
// The precaching code provided by Workbox.
self.__precacheManifest = [].concat(self.__precacheManifest || []);
console.log('Files to be cached by service worker [before filtering]', self.__precacheManifest.length);
var excludedUrlsPrefix = [
'/js/locale-',
'/js/moment-locale-',
'/js/admin',
'/css/admin',
];
self.__precacheManifest = self.__precacheManifest.filter((e) => {
return !excludedUrlsPrefix.some(prefix => e.url.startsWith(prefix))
});
console.log('Files to be cached by service worker [after filtering]', self.__precacheManifest.length);
// workbox.precaching.suppressWarnings(); // Only used with Vue CLI 3 and Workbox v3.
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});

Wyświetl plik

@ -68,6 +68,11 @@ export default {
ordering: "creation_date",
},
},
serviceWorker: {
refreshing: false,
registration: null,
updateAvailable: false,
}
},
getters: {
showInstanceSupportMessage: (state, getters, rootState) => {
@ -160,6 +165,10 @@ export default {
orderingDirection: (state, {route, value}) => {
state.routePreferences[route].orderingDirection = value
},
serviceWorker: (state, value) => {
state.serviceWorker = {...state.serviceWorker, ...value}
}
},
actions: {
fetchUnreadNotifications ({commit}, payload) {

Wyświetl plik

@ -32,6 +32,46 @@ plugins.push(
module.exports = {
baseUrl: process.env.BASE_URL || '/front/',
productionSourceMap: false,
// Add settings for manifest file
pwa: {
name: 'Funkwhale',
themeColor: '#f2711c',
msTileColor: '#000000',
appleMobileWebAppCapable: 'yes',
appleMobileWebAppStatusBarStyle: 'black',
display: 'minimal-ui',
workboxPluginMode: 'InjectManifest',
manifestOptions: {
start_url: '.',
description: 'A social platform to enjoy and share music',
scope: "/",
categories: ["music"],
icons: [
{
'src': 'favicon.png',
'sizes': '192x192',
'type': 'image/png'
}, {
'src': 'favicon.png',
'sizes': '512x512',
'type': 'image/png'
},
]
},
workboxOptions: {
importWorkboxFrom: 'local',
// swSrc is required in InjectManifest mode.
swSrc: 'src/service-worker.js',
swDest: 'service-worker.js',
},
iconPaths: {
favicon32: 'favicon.png',
favicon16: 'favicon.png',
appleTouchIcon: 'favicon.png',
maskIcon: 'favicon.png',
msTileImage: 'favicon.png'
}
},
pages: {
embed: {
entry: 'src/embed.js',

Plik diff jest za duży Load Diff