diff --git a/.gitignore b/.gitignore index ce91cb8c7..095e4835e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ packages/masterfile/lib/data/masterfile.json # Favicon public/favicon/* !public/favicon/fallback.ico +!public/favicon/fallback-*.png # Asset Links JSON /public/.well-known/assetlinks.json @@ -90,3 +91,4 @@ server/.cache/* # Misc ts-check.js +.history diff --git a/config/multi-domain-example/README.md b/config/multi-domain-example/README.md index 4327c83df..1205dbde2 100644 --- a/config/multi-domain-example/README.md +++ b/config/multi-domain-example/README.md @@ -76,3 +76,5 @@ pm2 start ecosystem.config.js - The `NODE_CONFIG_ENV` var names should not contain `/` or `.` - The `NODE_CONFIG_ENV` value does not have to be related to the domain its representing. The URL for the map could be `https://www.my-super-map.com` and the `NODE_CONFIG_ENV` could be `applemap` or `orangemap` or `bananamap` or whatever you want, as long as you point the nginx reverse proxy to the correct instance of the app - Custom favicons can be set by putting the respective `{NODE_CONFIG_ENV}.ico` in the `public/favicon` folder +- Custom PWA icons (the icon used when the map is added to a phone's home screen) follow the same pattern in the same folder: `{NODE_CONFIG_ENV}-180.png` (Apple touch icon), `{NODE_CONFIG_ENV}-192.png`, `{NODE_CONFIG_ENV}-256.png` and `{NODE_CONFIG_ENV}-512.png`. An optional `{NODE_CONFIG_ENV}-maskable.png` can be added for Android adaptive icons. Any icon that isn't provided falls back to the bundled `fallback-*.png` files +- The web app manifest is generated at build time from `map.general.title` (app name), `map.general.headerTitle` (short name) and `map.theme` (theme and background colors), so each domain gets its own diff --git a/packages/vite-plugins/lib/favicon.js b/packages/vite-plugins/lib/favicon.js index 53fb5513c..cac5cdc87 100644 --- a/packages/vite-plugins/lib/favicon.js +++ b/packages/vite-plugins/lib/favicon.js @@ -4,11 +4,22 @@ const fs = require('fs') const { log, TAGS } = require('@rm/logger') +/** Sizes we ship a `fallback-{size}.png` for, admins may override each one */ +const ICON_SIZES = [180, 192, 256, 512] + +/** + * @typedef {object} PwaOptions + * @property {string} [name] Full app name, shown on the splash screen + * @property {string} [shortName] Name shown under the home screen icon + * @property {{ style?: string, primary?: string }} [theme] + */ + /** * @param {boolean} isDevelopment + * @param {PwaOptions} [pwa] * @returns {import('vite').Plugin} */ -const faviconPlugin = (isDevelopment) => { +const faviconPlugin = (isDevelopment, pwa = {}) => { const basePath = path.join(__dirname, '../../../public/favicon') const markerPath = path.join( __dirname, @@ -19,8 +30,105 @@ const faviconPlugin = (isDevelopment) => { ? path.join(basePath, `${process.env.NODE_CONFIG_ENV}.ico`) : path.join(basePath, `favicon.ico`) const favicon = fs.existsSync(custom) ? custom : fallback + + /** + * Mirrors the favicon.ico lookup for the PWA pngs: + * `{NODE_CONFIG_ENV}-{suffix}.png` => `favicon-{suffix}.png` => `fallback-{suffix}.png` + * @param {string | number} suffix + * @param {boolean} [hasFallback] + * @returns {string | null} + */ + const resolveIcon = (suffix, hasFallback = true) => { + const found = [ + ...(process.env.NODE_CONFIG_ENV + ? [`${process.env.NODE_CONFIG_ENV}-${suffix}.png`] + : []), + `favicon-${suffix}.png`, + ...(hasFallback ? [`fallback-${suffix}.png`] : []), + ].find((file) => fs.existsSync(path.join(basePath, file))) + return found ? path.join(basePath, found) : null + } + + /** + * Reads the dimensions straight out of the IHDR so an overridden icon reports + * its real size instead of the one it was named after + * @param {string} file + * @returns {string} + */ + const pngSize = (file) => { + const header = Buffer.alloc(24) + const fd = fs.openSync(file, 'r') + try { + fs.readSync(fd, header, 0, 24, 0) + } finally { + fs.closeSync(fd) + } + return `${header.readUInt32BE(16)}x${header.readUInt32BE(20)}` + } + + const maskable = resolveIcon('maskable', false) + + /** @type {Record} Emitted file name => source file */ + const icons = Object.fromEntries( + [ + ...ICON_SIZES.map((size) => [ + size === 180 ? 'apple-touch-icon.png' : `icon-${size}.png`, + resolveIcon(size), + ]), + ...(maskable ? [['icon-maskable.png', maskable]] : []), + ].filter(([, file]) => !!file), + ) + + const theme = pwa.theme || {} + const themeColor = theme.primary || '#ff5722' + const manifest = JSON.stringify( + { + name: pwa.name || 'ReactMap', + short_name: pwa.shortName || pwa.name || 'ReactMap', + start_url: '/', + scope: '/', + display: 'standalone', + background_color: theme.style === 'light' ? '#fafafa' : '#212121', + theme_color: themeColor, + icons: Object.entries(icons) + // Only the manifest icons, apple-touch-icon is linked from the html + .filter(([fileName]) => fileName !== 'apple-touch-icon.png') + .map(([fileName, file]) => ({ + src: `/${fileName}`, + sizes: pngSize(file), + type: 'image/png', + purpose: fileName === 'icon-maskable.png' ? 'maskable' : 'any', + })), + }, + null, + 2, + ) + return { name: 'vite-plugin-favicon', + transformIndexHtml() { + /** @type {import('vite').HtmlTagDescriptor[]} */ + const tags = [ + { + tag: 'link', + attrs: { rel: 'manifest', href: '/manifest.webmanifest' }, + injectTo: 'head', + }, + { + tag: 'meta', + attrs: { name: 'theme-color', content: themeColor }, + injectTo: 'head', + }, + ] + if ('apple-touch-icon.png' in icons) { + tags.push({ + tag: 'link', + attrs: { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }, + injectTo: 'head', + }) + } + return tags + }, generateBundle() { this.emitFile({ type: 'asset', @@ -37,6 +145,22 @@ const faviconPlugin = (isDevelopment) => { } catch (e) { log.error(TAGS.build, 'Error loading favicon', e) } + try { + Object.entries(icons).forEach(([fileName, file]) => { + this.emitFile({ + type: 'asset', + fileName, + source: fs.readFileSync(file), + }) + }) + this.emitFile({ + type: 'asset', + fileName: 'manifest.webmanifest', + source: manifest, + }) + } catch (e) { + log.error(TAGS.build, 'Error loading PWA icons', e) + } }, configureServer(server) { server.middlewares.use((req, res, next) => { @@ -50,6 +174,17 @@ const faviconPlugin = (isDevelopment) => { res.end(fs.readFileSync(markerPath)) return } + if (req.url === '/manifest.webmanifest') { + res.writeHead(200, { 'Content-Type': 'application/manifest+json' }) + res.end(manifest) + return + } + const icon = icons[(req.url || '').slice(1)] + if (icon) { + res.writeHead(200, { 'Content-Type': 'image/png' }) + res.end(fs.readFileSync(icon)) + return + } next() }) }, diff --git a/public/favicon/fallback-180.png b/public/favicon/fallback-180.png new file mode 100644 index 000000000..f814dc425 Binary files /dev/null and b/public/favicon/fallback-180.png differ diff --git a/public/favicon/fallback-192.png b/public/favicon/fallback-192.png new file mode 100644 index 000000000..4f9d3999a Binary files /dev/null and b/public/favicon/fallback-192.png differ diff --git a/public/favicon/fallback-256.png b/public/favicon/fallback-256.png new file mode 100644 index 000000000..ac48fc29a Binary files /dev/null and b/public/favicon/fallback-256.png differ diff --git a/public/favicon/fallback-512.png b/public/favicon/fallback-512.png new file mode 100644 index 000000000..7538fca90 Binary files /dev/null and b/public/favicon/fallback-512.png differ diff --git a/vite.config.js b/vite.config.js index f630f4815..47bb364b8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -59,6 +59,8 @@ const viteConfig = defineConfig(({ mode }) => { ) } + const mapTheme = config.getSafe('map.theme') + const sentry = config.getSafe('sentry.client') sentry.enabled = sentry.enabled || !!env.SENTRY_DSN if (env.SENTRY_AUTH_TOKEN) sentry.authToken = env.SENTRY_AUTH_TOKEN @@ -98,7 +100,11 @@ const viteConfig = defineConfig(({ mode }) => { ] : []), localePlugin(isDevelopment), - faviconPlugin(isDevelopment), + faviconPlugin(isDevelopment, { + name: config.getSafe('map.general.title'), + shortName: config.getSafe('map.general.headerTitle'), + theme: mapTheme, + }), muteWarningsPlugin([ ['SOURCEMAP_ERROR', "Can't resolve original location of error"], ]), @@ -141,7 +147,7 @@ const viteConfig = defineConfig(({ mode }) => { startLon: config.getSafe('map.general.startLon'), startZoom: config.getSafe('map.general.startZoom'), }, - theme: config.getSafe('map.theme'), + theme: mapTheme, }, api: { polling: config.getSafe('api.polling'),