feat(pwa): add screenshots and orientation to webmanifest (#2109)

This commit is contained in:
Joaquín Sánchez 2023-05-21 18:28:28 +02:00 committed by GitHub
parent 22556984fa
commit dfb5a665f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 230 additions and 127 deletions

View file

@ -136,9 +136,10 @@ export function useUploadMediaAttachment(draftRef: Ref<Draft>) {
let failedAttachments = $ref<MediaAttachmentUploadError[]>([]) let failedAttachments = $ref<MediaAttachmentUploadError[]>([])
const dropZoneRef = ref<HTMLDivElement>() const dropZoneRef = ref<HTMLDivElement>()
const maxPixels const maxPixels = $computed(() => {
= currentInstance.value!.configuration?.mediaAttachments?.imageMatrixLimit return currentInstance.value?.configuration?.mediaAttachments?.imageMatrixLimit
?? 4096 ** 2 ?? 4096 ** 2
})
const loadImage = (inputFile: Blob) => new Promise<HTMLImageElement>((resolve, reject) => { const loadImage = (inputFile: Blob) => new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(inputFile) const url = URL.createObjectURL(inputFile)

View file

@ -323,6 +323,10 @@
"dismiss": "Dismiss", "dismiss": "Dismiss",
"install": "Install", "install": "Install",
"install_title": "Install Elk", "install_title": "Install Elk",
"screenshots": {
"dark": "Screenshot of Elk running in dark mode",
"light": "Screenshot of Elk running in light mode"
},
"title": "New Elk update available!", "title": "New Elk update available!",
"update": "Update", "update": "Update",
"update_available_short": "Update Elk", "update_available_short": "Update Elk",

View file

@ -311,6 +311,10 @@
"dismiss": "Descartar", "dismiss": "Descartar",
"install": "Instalar", "install": "Instalar",
"install_title": "Instalar Elk", "install_title": "Instalar Elk",
"screenshots": {
"dark": "Captura de pantalla de Elk ejecutándose en modo oscuro",
"light": "Captura de pantalla de Elk ejecutándose en modo claro"
},
"title": "Nueva versión de Elk disponible", "title": "Nueva versión de Elk disponible",
"update": "Actualizar", "update": "Actualizar",
"update_available_short": "Actualiza Elk", "update_available_short": "Actualiza Elk",

View file

@ -6,48 +6,124 @@ import { getEnv } from '../../config/env'
import { i18n } from '../../config/i18n' import { i18n } from '../../config/i18n'
import type { LocaleObject } from '#i18n' import type { LocaleObject } from '#i18n'
// We have to extend the ManifestOptions interface from 'vite-plugin-pwa' export type LocalizedWebManifest = Record<string, Partial<ManifestOptions>>
// as that interface doesn't define the share_target field of Web App Manifests.
interface ExtendedManifestOptions extends ManifestOptions {
share_target: {
action: string
method: string
enctype: string
params: {
title: string
text: string
url: string
files: [{
name: string
accept: string[]
}]
}
}
}
export type LocalizedWebManifest = Record<string, Partial<ExtendedManifestOptions>>
export const pwaLocales = i18n.locales as LocaleObject[] export const pwaLocales = i18n.locales as LocaleObject[]
type WebManifestEntry = Pick<ExtendedManifestOptions, 'name' | 'short_name' | 'description'> type WebManifestEntry = Pick<ManifestOptions, 'name' | 'short_name' | 'description' | 'screenshots' | 'shortcuts'>
type RequiredWebManifestEntry = Required<WebManifestEntry & Pick<ExtendedManifestOptions, 'dir' | 'lang'>> type RequiredWebManifestEntry = Required<WebManifestEntry & Pick<ManifestOptions, 'dir' | 'lang' | 'screenshots' | 'shortcuts'>>
export async function createI18n(): Promise<LocalizedWebManifest> { export async function createI18n(): Promise<LocalizedWebManifest> {
const { env } = await getEnv() const { env } = await getEnv()
const envName = `${env === 'release' ? '' : `(${env})`}` const envName = `${env === 'release' ? '' : `(${env})`}`
const { pwa } = await readI18nFile('en.json') const { action, nav, pwa } = await readI18nFile('en.json')
const defaultManifest: Required<WebManifestEntry> = pwa.webmanifest[env] const defaultManifest: Required<WebManifestEntry> = pwa.webmanifest[env]
const defaultShortcuts: ManifestOptions['shortcuts'] = [{
name: nav.home,
url: '/home',
icons: [
{ src: 'shortcuts/home-96x96.png', sizes: '96x96', type: 'image/png' },
{ src: 'shortcuts/home.png', sizes: '192x192', type: 'image/png' },
],
}, {
name: nav.local,
url: '/',
icons: [
{ src: 'shortcuts/local-96x96.png', sizes: '96x96', type: 'image/png' },
{ src: 'shortcuts/local.png', sizes: '192x192', type: 'image/png' },
],
}, {
name: nav.notifications,
url: '/notifications',
icons: [
{ src: 'shortcuts/notifications-96x96.png', sizes: '96x96', type: 'image/png' },
{ src: 'shortcuts/notifications.png', sizes: '192x192', type: 'image/png' },
],
}, {
name: action.compose,
url: '/compose',
icons: [
{ src: 'shortcuts/compose-96x96.png', sizes: '96x96', type: 'image/png' },
{ src: 'shortcuts/compose.png', sizes: '192x192', type: 'image/png' },
],
}, {
name: nav.settings,
url: '/settings',
icons: [
{ src: 'shortcuts/settings-96x96.png', sizes: '96x96', type: 'image/png' },
{ src: 'shortcuts/settings.png', sizes: '192x192', type: 'image/png' },
],
}]
const defaultScreenshots: ManifestOptions['screenshots'] = [{
src: 'screenshots/dark-1.webp',
sizes: '3840x2400',
type: 'image/webp',
label: pwa.screenshots.dark,
}, {
src: 'screenshots/light-1.webp',
sizes: '3840x2400',
type: 'image/webp',
label: pwa.screenshots.light,
}]
const manifestEntries: Partial<ManifestOptions> = {
scope: '/',
id: '/',
start_url: '/',
orientation: 'natural',
display: 'standalone',
display_override: ['window-controls-overlay'],
categories: ['social', 'social networking'],
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: 'maskable-icon.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
share_target: {
action: '/web-share-target',
method: 'POST',
enctype: 'multipart/form-data',
params: {
title: 'title',
text: 'text',
url: 'url',
files: [
{
name: 'files',
accept: ['image/*', 'video/*'],
},
],
},
},
}
const locales: RequiredWebManifestEntry[] = await Promise.all( const locales: RequiredWebManifestEntry[] = await Promise.all(
pwaLocales pwaLocales
.filter(l => l.code !== 'en-US') .filter(l => l.code !== 'en-US')
.map(async ({ code, dir = 'ltr', file, files }) => { .map(async ({ code, dir = 'ltr', file, files }) => {
// read locale file or files // read locale file or files
const { pwa, app_name, app_desc_short } = file const { action, app_desc_short, app_name, nav, pwa } = file
? await readI18nFile(file) ? await readI18nFile(file)
: await findBestWebManifestData(files, env) : await findBestWebManifestData(files, env)
const entry: WebManifestEntry = pwa?.webmanifest?.[env] ?? {} const entry = pwa?.webmanifest?.[env] ?? {}
if (!entry.name && app_name) if (!entry.name && app_name)
entry.name = dir === 'rtl' ? `${envName} ${app_name}` : `${app_name} ${envName}` entry.name = dir === 'rtl' ? `${envName} ${app_name}` : `${app_name} ${envName}`
@ -57,11 +133,45 @@ export async function createI18n(): Promise<LocalizedWebManifest> {
if (!entry.description && app_desc_short) if (!entry.description && app_desc_short)
entry.description = app_desc_short entry.description = app_desc_short
// clone default screenshots and shortcuts
const useScreenshots = [...defaultScreenshots.map(screenshot => ({ ...screenshot }))]
const useShortcuts = [...defaultShortcuts.map(shortcut => ({ ...shortcut }))]
const pwaScreenshots = pwa?.screenshots
if (pwaScreenshots) {
useScreenshots.forEach((screenshot, idx) => {
if (idx === 0 && pwaScreenshots?.dark)
screenshot.label = pwaScreenshots.dark
if (idx === 1 && pwaScreenshots?.light)
screenshot.label = pwaScreenshots.light
})
}
useShortcuts.forEach((shortcut, idx) => {
if (idx === 0 && nav?.home)
shortcut.name = nav.home
if (idx === 1 && nav?.local)
shortcut.name = nav.local
if (idx === 2 && nav?.notifications)
shortcut.name = nav.notifications
if (idx === 3 && action?.compose)
shortcut.name = action?.compose
if (idx === 4 && nav?.settings)
shortcut.name = nav.settings
})
return <RequiredWebManifestEntry>{ return <RequiredWebManifestEntry>{
...defaultManifest, ...defaultManifest,
...entry, ...entry,
lang: code, lang: code,
dir, dir,
screenshots: useScreenshots,
shortcuts: useShortcuts,
} }
}), }),
) )
@ -69,13 +179,19 @@ export async function createI18n(): Promise<LocalizedWebManifest> {
...defaultManifest, ...defaultManifest,
lang: 'en-US', lang: 'en-US',
dir: 'ltr', dir: 'ltr',
screenshots: defaultScreenshots,
shortcuts: defaultShortcuts,
}) })
return locales.reduce((acc, { lang, dir, name, short_name, description }) => { return locales.reduce((acc, {
lang,
dir,
name,
short_name,
description,
shortcuts,
screenshots,
}) => {
acc[lang] = { acc[lang] = {
scope: '/',
id: '/',
start_url: '/',
display: 'standalone',
lang, lang,
name, name,
short_name, short_name,
@ -83,47 +199,11 @@ export async function createI18n(): Promise<LocalizedWebManifest> {
dir, dir,
background_color: '#ffffff', background_color: '#ffffff',
theme_color: '#ffffff', theme_color: '#ffffff',
icons: [ ...manifestEntries,
{ shortcuts,
src: 'pwa-192x192.png', screenshots,
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: 'maskable-icon.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
share_target: {
action: '/web-share-target',
method: 'POST',
enctype: 'multipart/form-data',
params: {
title: 'title',
text: 'text',
url: 'url',
files: [
{
name: 'files',
accept: ['image/*', 'video/*'],
},
],
},
},
} }
acc[`${lang}-dark`] = { acc[`${lang}-dark`] = {
scope: '/',
id: '/',
start_url: '/',
display: 'standalone',
lang, lang,
name, name,
short_name, short_name,
@ -131,41 +211,9 @@ export async function createI18n(): Promise<LocalizedWebManifest> {
dir, dir,
background_color: '#111111', background_color: '#111111',
theme_color: '#111111', theme_color: '#111111',
icons: [ ...manifestEntries,
{ shortcuts,
src: 'pwa-192x192.png', screenshots,
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: 'maskable-icon.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
share_target: {
action: '/web-share-target',
method: 'POST',
enctype: 'multipart/form-data',
params: {
title: 'title',
text: 'text',
url: 'url',
files: [
{
name: 'files',
accept: ['image/*', 'video/*'],
},
],
},
},
} }
return acc return acc
@ -185,23 +233,30 @@ interface PWAEntry {
short_name?: string short_name?: string
description?: string description?: string
}> }>
screenshots?: Record<string, string>
shortcuts?: Record<string, string>
} }
interface JsonEntry { interface JsonEntry {
pwa?: PWAEntry pwa?: PWAEntry
app_name?: string app_name?: string
app_desc_short?: string app_desc_short?: string
action?: Record<string, any>
nav?: Record<string, any>
screenshots?: Record<string, string>
} }
async function findBestWebManifestData(files: string[], env: string) { async function findBestWebManifestData(files: string[], env: string) {
const entries: JsonEntry[] = await Promise.all(files.map(async (file) => { const entries: JsonEntry[] = await Promise.all(files.map(async (file) => {
const { pwa, app_name, app_desc_short } = await readI18nFile(file) const { action, app_name, app_desc_short, nav, pwa } = await readI18nFile(file)
return { pwa, app_name, app_desc_short } return { action, app_name, app_desc_short, nav, pwa }
})) }))
let pwa: PWAEntry | undefined let pwa: PWAEntry | undefined
let app_name: string | undefined let app_name: string | undefined
let app_desc_short: string | undefined let app_desc_short: string | undefined
const action: Record<string, any> = {}
const nav: Record<string, any> = {}
for (const entry of entries) { for (const entry of entries) {
const webmanifest = entry?.pwa?.webmanifest?.[env] const webmanifest = entry?.pwa?.webmanifest?.[env]
@ -226,7 +281,28 @@ async function findBestWebManifestData(files: string[], env: string) {
if (entry.app_desc_short) if (entry.app_desc_short)
app_desc_short = entry.app_desc_short app_desc_short = entry.app_desc_short
if (entry.nav) {
['home', 'local', 'notifications', 'settings'].forEach((key) => {
const value = entry.nav![key]
if (value)
nav[key] = value
})
} }
return { pwa, app_name, app_desc_short } if (entry.action?.compose)
action.compose = entry.action.compose
if (entry.pwa?.screenshots) {
if (!pwa)
pwa = {}
pwa.screenshots = pwa.screenshots ?? {}
Object
.entries(entry.pwa.screenshots)
.forEach(([key, value]) => pwa!.screenshots![key] = value)
}
}
return { action, app_desc_short, app_name, nav, pwa }
} }

View file

@ -94,7 +94,7 @@
"ufo": "^1.1.1", "ufo": "^1.1.1",
"ultrahtml": "^1.2.0", "ultrahtml": "^1.2.0",
"unimport": "^3.0.6", "unimport": "^3.0.6",
"vite-plugin-pwa": "^0.14.7", "vite-plugin-pwa": "^0.15.0",
"vue-advanced-cropper": "^2.8.8", "vue-advanced-cropper": "^2.8.8",
"vue-virtual-scroller": "2.0.0-beta.8", "vue-virtual-scroller": "2.0.0-beta.8",
"workbox-build": "^6.5.4", "workbox-build": "^6.5.4",

View file

@ -1,5 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n() const { t } = useI18n()
useHydratedHead({ useHydratedHead({
title: () => t('nav.compose'), title: () => t('nav.compose'),
}) })

View file

@ -185,10 +185,10 @@ importers:
version: 5.0.1 version: 5.0.1
tauri-plugin-log-api: tauri-plugin-log-api:
specifier: github:tauri-apps/tauri-plugin-log specifier: github:tauri-apps/tauri-plugin-log
version: github.com/tauri-apps/tauri-plugin-log/be811332676ff73e1e891f31ce3de8d447fdf07e version: github.com/tauri-apps/tauri-plugin-log/5e14c2cad7335a4284a6caad81d8cf37dd675a27
tauri-plugin-store-api: tauri-plugin-store-api:
specifier: github:tauri-apps/tauri-plugin-store specifier: github:tauri-apps/tauri-plugin-store
version: github.com/tauri-apps/tauri-plugin-store/f4ef29684e4a32eddf51befaae98a5e498df8574 version: github.com/tauri-apps/tauri-plugin-store/0558a1f6c869ae0afdb0181dfa1ea31be8cf4893
theme-vitesse: theme-vitesse:
specifier: ^0.6.4 specifier: ^0.6.4
version: 0.6.4 version: 0.6.4
@ -208,8 +208,8 @@ importers:
specifier: ^3.0.6 specifier: ^3.0.6
version: 3.0.6(rollup@2.79.1) version: 3.0.6(rollup@2.79.1)
vite-plugin-pwa: vite-plugin-pwa:
specifier: ^0.14.7 specifier: ^0.15.0
version: 0.14.7(vite@4.3.4)(workbox-build@6.5.4)(workbox-window@6.5.4) version: 0.15.0(vite@4.3.4)(workbox-build@6.5.4)(workbox-window@6.5.4)
vue-advanced-cropper: vue-advanced-cropper:
specifier: ^2.8.8 specifier: ^2.8.8
version: 2.8.8(vue@3.2.45) version: 2.8.8(vue@3.2.45)
@ -13387,18 +13387,16 @@ packages:
- supports-color - supports-color
dev: false dev: false
/vite-plugin-pwa@0.14.7(vite@4.3.4)(workbox-build@6.5.4)(workbox-window@6.5.4): /vite-plugin-pwa@0.15.0(vite@4.3.4)(workbox-build@6.5.4)(workbox-window@6.5.4):
resolution: {integrity: sha512-dNJaf0fYOWncmjxv9HiSa2xrSjipjff7IkYE5oIUJ2x5HKu3cXgA8LRgzOwTc5MhwyFYRSU0xyN0Phbx3NsQYw==} resolution: {integrity: sha512-gpmx3BeubsRIXRBkjPToOTJbo8fknNmZFQs24i0TPZyaNVa0n27YHDo0Y72amnO70WvHKGE3e1fn8SYUP7e8SA==}
peerDependencies: peerDependencies:
vite: ^3.1.0 || ^4.0.0 vite: ^3.1.0 || ^4.0.0
workbox-build: ^6.5.4 workbox-build: ^6.5.4
workbox-window: ^6.5.4 workbox-window: ^6.5.4
dependencies: dependencies:
'@rollup/plugin-replace': 5.0.2(rollup@3.21.3)
debug: 4.3.4 debug: 4.3.4
fast-glob: 3.2.12 fast-glob: 3.2.12
pretty-bytes: 6.1.0 pretty-bytes: 6.1.0
rollup: 3.21.3
vite: 4.3.4(@types/node@18.16.3) vite: 4.3.4(@types/node@18.16.3)
workbox-build: 6.5.4 workbox-build: 6.5.4
workbox-window: 6.5.4 workbox-window: 6.5.4
@ -14276,16 +14274,16 @@ packages:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: true dev: true
github.com/tauri-apps/tauri-plugin-log/be811332676ff73e1e891f31ce3de8d447fdf07e: github.com/tauri-apps/tauri-plugin-log/5e14c2cad7335a4284a6caad81d8cf37dd675a27:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/be811332676ff73e1e891f31ce3de8d447fdf07e} resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/5e14c2cad7335a4284a6caad81d8cf37dd675a27}
name: tauri-plugin-log-api name: tauri-plugin-log-api
version: 0.0.0 version: 0.0.0
dependencies: dependencies:
'@tauri-apps/api': 1.2.0 '@tauri-apps/api': 1.2.0
dev: false dev: false
github.com/tauri-apps/tauri-plugin-store/f4ef29684e4a32eddf51befaae98a5e498df8574: github.com/tauri-apps/tauri-plugin-store/0558a1f6c869ae0afdb0181dfa1ea31be8cf4893:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-store/tar.gz/f4ef29684e4a32eddf51befaae98a5e498df8574} resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-store/tar.gz/0558a1f6c869ae0afdb0181dfa1ea31be8cf4893}
name: tauri-plugin-store-api name: tauri-plugin-store-api
version: 0.0.0 version: 0.0.0
dependencies: dependencies:

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

BIN
public/shortcuts/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/shortcuts/local.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -32,8 +32,23 @@ if (import.meta.env.DEV)
// deny api and server page calls // deny api and server page calls
let denylist: undefined | RegExp[] let denylist: undefined | RegExp[]
if (import.meta.env.PROD) if (import.meta.env.PROD) {
denylist = [/^\/api\//, /^\/login\//, /^\/oauth\//, /^\/signin\//, /^\/web-share-target\//] denylist = [
/^\/api\//,
/^\/login\//,
/^\/oauth\//,
/^\/signin\//,
/^\/web-share-target\//,
// exclude shiki: has its own cache
/^\/shiki\//,
// exclude shiki: has its own cache
/^\/emojis\//,
// exclude sw: if the user navigates to it, fallback to index.html
/^\/sw.js$/,
// exclude webmanifest: has its own cache
/^\/manifest-(.*).webmanifest$/,
]
}
// only cache pages and external assets on local build + start or in production // only cache pages and external assets on local build + start or in production
if (import.meta.env.PROD) { if (import.meta.env.PROD) {
@ -59,7 +74,7 @@ if (import.meta.env.PROD) {
plugins: [ plugins: [
new CacheableResponsePlugin({ statuses: [200] }), new CacheableResponsePlugin({ statuses: [200] }),
// 365 days max // 365 days max
new ExpirationPlugin({ maxAgeSeconds: 60 * 60 * 24 * 365 }), new ExpirationPlugin({ purgeOnQuotaError: true, maxAgeSeconds: 60 * 60 * 24 * 365 }),
], ],
}), }),
) )
@ -74,7 +89,7 @@ if (import.meta.env.PROD) {
plugins: [ plugins: [
new CacheableResponsePlugin({ statuses: [200] }), new CacheableResponsePlugin({ statuses: [200] }),
// 15 days max // 15 days max
new ExpirationPlugin({ maxAgeSeconds: 60 * 60 * 24 * 15 }), new ExpirationPlugin({ purgeOnQuotaError: true, maxAgeSeconds: 60 * 60 * 24 * 15 }),
], ],
}), }),
) )