diff --git a/.changeset/29332-console-pwa-manifest-icons.md b/.changeset/29332-console-pwa-manifest-icons.md new file mode 100644 index 00000000000..6debb8974a3 --- /dev/null +++ b/.changeset/29332-console-pwa-manifest-icons.md @@ -0,0 +1,10 @@ +--- +'@object-ui/console': patch +--- + +fix(console): the PWA manifest declares the branding icon's real type and size, and adds 192 px and 512 px PNG icons when the branding has no large enough square raster + +Clause-②: no +Metadata-protocol: none + +The Console's runtime web app manifest declared every icon as `image/svg+xml` at `sizes: 'any'`, so a PNG or ICO branding favicon was mis-declared, and a small one left the branded Console not installable (Chrome: `no-acceptable-icon`, minimum 144 px). An SVG favicon (and the default icon) keeps the single scalable entry. A raster favicon now declares its own MIME type and the pixel size it measures at; one whose type or size cannot be established is left out rather than guessed. When the branding offers no square raster of at least 192 px, the Console's own `icon-192.png` and `icon-512.png`, rendered from its default `favicon.svg`, are added, so such a Console installs with the default icon at launcher sizes. The favicon is measured off the boot path: the manifest link lands once the measurement settles. `start_url`, `scope` and the absolute icon URLs are unchanged. diff --git a/ui/apps/console/public/icon-192.png b/ui/apps/console/public/icon-192.png new file mode 100644 index 00000000000..2daf0c6cc5b Binary files /dev/null and b/ui/apps/console/public/icon-192.png differ diff --git a/ui/apps/console/public/icon-512.png b/ui/apps/console/public/icon-512.png new file mode 100644 index 00000000000..32877de7145 Binary files /dev/null and b/ui/apps/console/public/icon-512.png differ diff --git a/ui/apps/console/src/main.tsx b/ui/apps/console/src/main.tsx index bd1bb3625c6..31a4622b82e 100644 --- a/ui/apps/console/src/main.tsx +++ b/ui/apps/console/src/main.tsx @@ -23,6 +23,7 @@ import { loadLocales } from './loadLocales'; import { seedTenantLanguage } from './languageSeed'; import { preflightAuth } from './lib/auth-preflight'; import { withConsoleBaseRootRelative } from './utils/consoleBase'; +import { resolveManifestIcons } from './pwaManifestIcons'; const AUTH_URL = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; @@ -157,36 +158,7 @@ Promise.all([ // leaves the manifest in place, which a WebView never acts on. const existingManifest = document.querySelector('link[rel="manifest"]'); if (existingManifest) existingManifest.remove(); - if (!getNativeHost()) { - const manifestJson = generatePWAManifest({ - enabled: true, - name: getProductName(), - shortName: getProductShortName(), - description: getPwaDescription(), - themeColor: getPwaThemeColor(), - backgroundColor: '#ffffff', - display: 'standalone', - // Absolute URLs, because a Blob URL is no base to resolve `./` against: - // the installed app opens at, and stays inside, the Console's mount. - startUrl: CONSOLE_SCOPE, - scope: CONSOLE_SCOPE, - orientation: 'any', - icons: [ - { - src: new URL(faviconUrl || './favicon.svg', document.baseURI).href, - sizes: 'any', - type: 'image/svg+xml', - purpose: 'any', - }, - ], - }); - const blob = new Blob([JSON.stringify(manifestJson)], { type: 'application/json' }); - const manifestUrl = URL.createObjectURL(blob); - const manifestLink = document.createElement('link'); - manifestLink.rel = 'manifest'; - manifestLink.href = manifestUrl; - document.head.appendChild(manifestLink); - } + if (!getNativeHost()) void injectPwaManifest(faviconUrl); // The Console's Service Worker (objectstack#29155): the offline app shell // and the push handlers. Not awaited — it must never hold up first paint. @@ -207,6 +179,36 @@ Promise.all([ ); }); +/** + * Generates the Console's web app manifest from runtime branding and links it + * as a Blob URL. Not awaited: a raster branding favicon is measured first + * (`./pwaManifestIcons.ts`), and that load must never hold up first paint, so + * the manifest link lands once the measurement settles. + */ +async function injectPwaManifest(faviconUrl: string | undefined): Promise { + const icons = await resolveManifestIcons(faviconUrl, document.baseURI); + const manifestJson = generatePWAManifest({ + enabled: true, + name: getProductName(), + shortName: getProductShortName(), + description: getPwaDescription(), + themeColor: getPwaThemeColor(), + backgroundColor: '#ffffff', + display: 'standalone', + // Absolute URLs, because a Blob URL is no base to resolve `./` against: + // the installed app opens at, and stays inside, the Console's mount. + startUrl: CONSOLE_SCOPE, + scope: CONSOLE_SCOPE, + orientation: 'any', + icons, + }); + const blob = new Blob([JSON.stringify(manifestJson)], { type: 'application/json' }); + const manifestLink = document.createElement('link'); + manifestLink.rel = 'manifest'; + manifestLink.href = URL.createObjectURL(blob); + document.head.appendChild(manifestLink); +} + /** * Registers the Console's Service Worker (`service-worker.js`, emitted at the * build root by `vite.config.ts`) under the Console's own scope, never the site diff --git a/ui/apps/console/src/pwaManifestIcons.test.ts b/ui/apps/console/src/pwaManifestIcons.test.ts new file mode 100644 index 00000000000..e9931a341b1 --- /dev/null +++ b/ui/apps/console/src/pwaManifestIcons.test.ts @@ -0,0 +1,163 @@ +/** + * The Console manifest's icons, by branding favicon (objectstack#29332): each + * icon declares its real type and size, and a branding without a large enough + * square raster gains the Console's 192 px and 512 px PNGs, so the Console + * stays installable. + */ +import { describe, it, expect, vi } from 'vitest'; +import { generatePWAManifest } from '@object-ui/mobile'; +import { + iconTypeOf, + measureIconInBrowser, + resolveManifestIcons, + type IconSize, + type MeasureIcon, +} from './pwaManifestIcons'; + +const BASE = 'https://acme.example/_console/'; + +const DEFAULT_PNGS = [ + { src: `${BASE}icon-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: `${BASE}icon-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any' }, +]; + +/** A measurement stub that answers `size` for every source. */ +function measuring(size: IconSize | null) { + return vi.fn(async () => size); +} + +/** The icons of the manifest the Console generates for a favicon. */ +async function manifestIcons(faviconUrl: string | undefined, measure: MeasureIcon) { + const icons = await resolveManifestIcons(faviconUrl, BASE, measure); + return generatePWAManifest({ enabled: true, name: 'Acme', shortName: 'Acme', icons }).icons; +} + +describe('Console PWA manifest icons', () => { + it('no branding: the default SVG, scalable, nothing measured', async () => { + const measure = measuring(null); + expect(await manifestIcons(undefined, measure)).toEqual([ + { src: `${BASE}favicon.svg`, sizes: 'any', type: 'image/svg+xml', purpose: 'any' }, + ]); + expect(measure).not.toHaveBeenCalled(); + }); + + it('SVG branding: the branded SVG, scalable, nothing measured', async () => { + const measure = measuring(null); + expect(await manifestIcons('/runtime/assets/brand.svg?v=3', measure)).toEqual([ + { + src: 'https://acme.example/runtime/assets/brand.svg?v=3', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any', + }, + ]); + expect(measure).not.toHaveBeenCalled(); + }); + + it('small PNG branding: declared as a PNG at its measured size, plus the default PNGs', async () => { + const measure = measuring({ width: 32, height: 32 }); + expect(await manifestIcons('https://cdn.acme.example/favicon.png', measure)).toEqual([ + { src: 'https://cdn.acme.example/favicon.png', sizes: '32x32', type: 'image/png', purpose: 'any' }, + ...DEFAULT_PNGS, + ]); + expect(measure).toHaveBeenCalledWith('https://cdn.acme.example/favicon.png'); + }); + + it('PNG branding of at least 192 px square: the branded PNG alone', async () => { + expect(await manifestIcons('/brand/icon.PNG', measuring({ width: 256, height: 256 }))).toEqual([ + { src: 'https://acme.example/brand/icon.PNG', sizes: '256x256', type: 'image/png', purpose: 'any' }, + ]); + }); + + it('a large but non-square PNG still gets the default PNGs', async () => { + expect(await manifestIcons('/brand/wide.png', measuring({ width: 600, height: 200 }))).toEqual([ + { src: 'https://acme.example/brand/wide.png', sizes: '600x200', type: 'image/png', purpose: 'any' }, + ...DEFAULT_PNGS, + ]); + }); + + it('ICO branding: declared as an icon file at its measured size, plus the default PNGs', async () => { + expect(await manifestIcons('/favicon.ico', measuring({ width: 48, height: 48 }))).toEqual([ + { src: 'https://acme.example/favicon.ico', sizes: '48x48', type: 'image/x-icon', purpose: 'any' }, + ...DEFAULT_PNGS, + ]); + }); + + it('a raster favicon that cannot be measured is left out, the default PNGs stand in', async () => { + expect(await manifestIcons('/favicon.ico', measuring(null))).toEqual(DEFAULT_PNGS); + }); + + it('a favicon of unknown type is left out unmeasured, the default PNGs stand in', async () => { + const measure = measuring({ width: 512, height: 512 }); + expect(await manifestIcons('/api/branding/favicon', measure)).toEqual(DEFAULT_PNGS); + expect(measure).not.toHaveBeenCalled(); + }); +}); + +describe('iconTypeOf', () => { + it.each([ + ['https://a.example/x.svg', 'image/svg+xml'], + ['https://a.example/x.svg#frag', 'image/svg+xml'], + ['https://a.example/x.png?v=2', 'image/png'], + ['https://a.example/x.ico', 'image/x-icon'], + ['https://a.example/x.webp', 'image/webp'], + ['https://a.example/x.jpeg', 'image/jpeg'], + ['data:image/png;base64,AAAA', 'image/png'], + ['data:image/svg+xml,%3Csvg%2F%3E', 'image/svg+xml'], + ['https://a.example/x.svg.txt', undefined], + ['https://a.example/favicon', undefined], + ['data:text/plain,hi', undefined], + ['not a url', undefined], + ])('%s -> %s', (src, type) => { + expect(iconTypeOf(src)).toBe(type); + }); +}); + +describe('measureIconInBrowser', () => { + /** Replaces `Image` with one whose load the test drives. */ + function stubImage(outcome: { width: number; height: number } | 'error' | 'hang') { + class FakeImage { + naturalWidth = 0; + naturalHeight = 0; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_value: string) { + queueMicrotask(() => { + if (outcome === 'hang') return; + if (outcome === 'error') { + this.onerror?.(); + return; + } + this.naturalWidth = outcome.width; + this.naturalHeight = outcome.height; + this.onload?.(); + }); + } + } + vi.stubGlobal('Image', FakeImage); + } + + it('resolves the natural size of a loaded image', async () => { + stubImage({ width: 64, height: 64 }); + await expect(measureIconInBrowser('/x.png')).resolves.toEqual({ width: 64, height: 64 }); + vi.unstubAllGlobals(); + }); + + it('settles null on a load error or a zero size', async () => { + stubImage('error'); + await expect(measureIconInBrowser('/x.png')).resolves.toBeNull(); + stubImage({ width: 0, height: 0 }); + await expect(measureIconInBrowser('/x.png')).resolves.toBeNull(); + vi.unstubAllGlobals(); + }); + + it('settles null when the image never answers, after the timeout', async () => { + vi.useFakeTimers(); + stubImage('hang'); + const pending = measureIconInBrowser('/x.png', 500); + await vi.advanceTimersByTimeAsync(500); + await expect(pending).resolves.toBeNull(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); +}); diff --git a/ui/apps/console/src/pwaManifestIcons.ts b/ui/apps/console/src/pwaManifestIcons.ts new file mode 100644 index 00000000000..12d7f20ebe6 --- /dev/null +++ b/ui/apps/console/src/pwaManifestIcons.ts @@ -0,0 +1,134 @@ +/** + * The icon entries of the Console's runtime web app manifest, derived from the + * configured branding favicon (objectstack#29332). + * + * Every entry declares what its file really is: an SVG is `image/svg+xml` at + * `sizes: 'any'`; a raster asset declares its own MIME type and the pixel size + * it was measured at. An asset whose type or size cannot be established is left + * out rather than guessed. When the branding offers no square raster of at + * least 192 px, the Console's own 192 px and 512 px PNGs are added, so a + * branded Console stays installable: browsers refuse to install an app whose + * manifest has no large enough icon. + * + * Every `src` is absolute, because the manifest is served from a Blob URL, + * which is no base to resolve a relative path against. + */ +import type { PWAIcon } from '@object-ui/mobile'; + +/** The Console's default icon, used when no branding favicon is configured. */ +export const DEFAULT_ICON_PATH = './favicon.svg'; + +/** The Console's default raster icons, rendered from `public/favicon.svg`. */ +export const DEFAULT_RASTER_ICONS: ReadonlyArray<{ path: string; size: number }> = [ + { path: './icon-192.png', size: 192 }, + { path: './icon-512.png', size: 512 }, +]; + +/** The smallest square raster that makes the default PNGs unnecessary. */ +export const MIN_INSTALL_ICON_PX = 192; + +/** How long a raster favicon may take to load before it counts as unmeasured. */ +export const MEASURE_TIMEOUT_MS = 10_000; + +const SVG_TYPE = 'image/svg+xml'; + +/** MIME type by file extension, for the image formats a browser decodes. */ +const TYPE_BY_EXTENSION: Readonly> = { + svg: SVG_TYPE, + png: 'image/png', + ico: 'image/x-icon', + webp: 'image/webp', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + avif: 'image/avif', +}; + +/** A measured pixel size. */ +export interface IconSize { + width: number; + height: number; +} + +/** Loads an image and resolves its natural size, or `null` if it cannot be measured. */ +export type MeasureIcon = (src: string) => Promise; + +/** + * The MIME type of an icon URL: the media type of a `data:` URL, otherwise the + * one its path's extension names. `undefined` when neither says. + */ +export function iconTypeOf(src: string): string | undefined { + let url: URL; + try { + url = new URL(src); + } catch { + return undefined; + } + if (url.protocol === 'data:') { + const mediaType = url.pathname.split(/[;,]/, 1)[0].trim().toLowerCase(); + return Object.values(TYPE_BY_EXTENSION).includes(mediaType) ? mediaType : undefined; + } + const lastSegment = url.pathname.split('/').pop() ?? ''; + const dot = lastSegment.lastIndexOf('.'); + if (dot < 0) return undefined; + return TYPE_BY_EXTENSION[lastSegment.slice(dot + 1).toLowerCase()]; +} + +/** + * Measures an image in the browser through `HTMLImageElement`. Settles with + * `null` on a load error, a zero size or after `timeoutMs`, so a favicon that + * never answers cannot hold the manifest back forever. + */ +export function measureIconInBrowser(src: string, timeoutMs: number = MEASURE_TIMEOUT_MS): Promise { + return new Promise((resolve) => { + const image = new Image(); + const timer = setTimeout(() => settle(null), timeoutMs); + function settle(size: IconSize | null) { + clearTimeout(timer); + image.onload = null; + image.onerror = null; + resolve(size); + } + image.onload = () => { + const { naturalWidth: width, naturalHeight: height } = image; + settle(width > 0 && height > 0 ? { width, height } : null); + }; + image.onerror = () => settle(null); + image.src = src; + }); +} + +/** + * The manifest icons for a branding favicon (`undefined` = none configured), + * resolved against `baseURI`. Measures the favicon only when it is a raster + * image; an SVG or the default icon resolves without loading anything. + */ +export async function resolveManifestIcons( + faviconUrl: string | undefined, + baseURI: string, + measure: MeasureIcon = measureIconInBrowser, +): Promise { + const src = new URL(faviconUrl || DEFAULT_ICON_PATH, baseURI).href; + const type = iconTypeOf(src); + if (type === SVG_TYPE) { + return [{ src, sizes: 'any', type, purpose: 'any' }]; + } + + const icons: PWAIcon[] = []; + const size = type ? await measure(src) : null; + if (type && size) { + icons.push({ src, sizes: `${size.width}x${size.height}`, type, purpose: 'any' }); + } + const installable = size !== null && size.width === size.height && size.width >= MIN_INSTALL_ICON_PX; + if (!installable) { + for (const icon of DEFAULT_RASTER_ICONS) { + icons.push({ + src: new URL(icon.path, baseURI).href, + sizes: `${icon.size}x${icon.size}`, + type: 'image/png', + purpose: 'any', + }); + } + } + return icons; +}