Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/29332-console-pwa-manifest-icons.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added ui/apps/console/public/icon-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 ui/apps/console/public/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 32 additions & 30 deletions ui/apps/console/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -157,36 +158,7 @@ Promise.all([
// leaves the manifest in place, which a WebView never acts on.
const existingManifest = document.querySelector<HTMLLinkElement>('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.
Expand All @@ -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<void> {
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
Expand Down
163 changes: 163 additions & 0 deletions ui/apps/console/src/pwaManifestIcons.test.ts
Original file line number Diff line number Diff line change
@@ -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<MeasureIcon>(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();
});
});
134 changes: 134 additions & 0 deletions ui/apps/console/src/pwaManifestIcons.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {
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<IconSize | null>;

/**
* 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<IconSize | null> {
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<PWAIcon[]> {
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;
}
Loading