Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,3 +91,4 @@ server/.cache/*

# Misc
ts-check.js
.history
2 changes: 2 additions & 0 deletions config/multi-domain-example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
137 changes: 136 additions & 1 deletion packages/vite-plugins/lib/favicon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)}`
}
Comment on lines +58 to +67

const maskable = resolveIcon('maskable', false)

/** @type {Record<string, string>} 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',
Expand All @@ -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) => {
Expand All @@ -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()
})
},
Expand Down
Binary file added public/favicon/fallback-180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon/fallback-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon/fallback-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon/fallback-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 8 additions & 2 deletions vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Comment on lines +103 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Generate the manifest from the requested host's map config

When the supported legacy multiDomains configuration serves multiple domains from one process, this always builds a single manifest from the base map object. The runtime explicitly selects each domain's merged title and theme through config.getMapConfig(req) in server/src/utils/getServerSettings.js, but every alternate host will receive the base domain's name, short name, and colors when added to a home screen. The manifest and associated HTML metadata need to be selected per request/host, or generated separately for every configured domain.

Useful? React with 👍 / 👎.

}),
muteWarningsPlugin([
['SOURCEMAP_ERROR', "Can't resolve original location of error"],
]),
Expand Down Expand Up @@ -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'),
Expand Down
Loading