From 286d5bc7641186378c75737bb293fefb770457ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:47:37 +0000 Subject: [PATCH 1/2] fix(console): derive PWA manifest icon types and sizes from the branding favicon The runtime manifest declared every icon as an SVG at sizes 'any'. A PNG or ICO branding favicon now declares its real type and measured size, and a branding without a square raster of at least 192 px gains the Console's own 192 px and 512 px PNGs (rendered from public/favicon.svg), so a branded Console stays installable. The raster favicon is measured off the boot path: the manifest link lands once the measurement settles. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSqNEXXfJECHQqN7mG --- ui/apps/console/public/icon-192.png | Bin 0 -> 3439 bytes ui/apps/console/public/icon-512.png | Bin 0 -> 15203 bytes ui/apps/console/src/main.tsx | 62 +++---- ui/apps/console/src/pwaManifestIcons.test.ts | 163 +++++++++++++++++++ ui/apps/console/src/pwaManifestIcons.ts | 134 +++++++++++++++ 5 files changed, 329 insertions(+), 30 deletions(-) create mode 100644 ui/apps/console/public/icon-192.png create mode 100644 ui/apps/console/public/icon-512.png create mode 100644 ui/apps/console/src/pwaManifestIcons.test.ts create mode 100644 ui/apps/console/src/pwaManifestIcons.ts diff --git a/ui/apps/console/public/icon-192.png b/ui/apps/console/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..2daf0c6cc5b4e7f17fb73de4d57ea6ced84392b6 GIT binary patch literal 3439 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 literal 0 HcmV?d00001 diff --git a/ui/apps/console/public/icon-512.png b/ui/apps/console/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..32877de7145b7ff09b16a2256d641319d4b8b92f GIT binary patch literal 15203 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 literal 0 HcmV?d00001 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; +} From 29065950895832a12b95da51779f2747c1982158 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:56:45 +0000 Subject: [PATCH 2/2] chore(changeset): console PWA manifest icons Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSqNEXXfJECHQqN7mG --- .changeset/29332-console-pwa-manifest-icons.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/29332-console-pwa-manifest-icons.md 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.