Skip to content

Commit 1d03901

Browse files
fix(console): PWA manifest declares the branding icon's real type and size, with 192/512 PNG fallbacks (#29405)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb6KFUEBv4xhM1GGBkDXW4oV)_ Fixes #29332 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Console 运行时 PWA manifest 的图标不再一律写成 `image/svg+xml` + `sizes: 'any'`。品牌 favicon 是 SVG 时照旧;是 PNG/ICO 等位图时按真实类型与实测像素尺寸声明;没有 ≥192 px 正方形位图时,补上平台默认的 192/512 PNG(由现有 `favicon.svg` 渲染,新增两个静态文件)。 - **为什么改**:配了小尺寸 PNG favicon 的品牌 Console 在 Chrome 里不可安装(实测 `no-acceptable-icon`,最低 144 px),与 #29155 要的「品牌化可安装」相悖。 - **风险与回滚**:风险低,只动 Console 入口的 manifest 生成;manifest 链接改为在位图测量完成后才挂上(不阻塞首屏,最长 10 秒超时)。回滚即 revert 本 PR。取舍:小尺寸品牌 PNG 安装后,启动器图标显示的是平台默认图标而非品牌图标。 - **先看**:`ui/apps/console/src/pwaManifestIcons.ts`(`resolveManifestIcons` 的判定);`ui/apps/console/src/main.tsx`(`injectPwaManifest`,异步注入)。 - **两分钟验证**:`pnpm --dir ui exec vitest run apps/console/src/pwaManifestIcons.test.ts`,预期 23 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - `ui/apps/console/src/pwaManifestIcons.ts` (new, console-local): `resolveManifestIcons(faviconUrl, baseURI)` builds the manifest icon entries. - SVG favicon, or no branding (the default `favicon.svg`): one entry, `image/svg+xml`, `sizes: 'any'`, as before. Nothing is loaded. - Raster favicon (PNG, ICO, WebP, JPEG, GIF, AVIF, by path extension or `data:` media type): the image is measured through `HTMLImageElement` and declared with its own type and `WIDTHxHEIGHT`. A favicon whose type is unknown, or that fails to load, measures zero or does not answer within 10 s, is left out rather than guessed. - Whenever the result has no square raster of at least 192 px, `icon-192.png` and `icon-512.png` are appended. - `ui/apps/console/public/icon-192.png`, `icon-512.png` (new): the platform default icons, rendered once with Chromium from the existing `public/favicon.svg`. No default PNG icon existed anywhere in the repository and no server-rendered resize exists, so these are the card's "platform default icons" option (seat approved before the assets were added). - `ui/apps/console/src/main.tsx`: the manifest block moves into `injectPwaManifest`, called without `await`. The static manifest is still removed synchronously, the native-host check is unchanged, and the Blob manifest link lands once the icon measurement settles, so first paint is never held for it. `start_url`, `scope` and the absolute icon URLs from #29326 are unchanged. - Changeset: `@object-ui/console` patch. Trade-off: a branding with a small raster favicon installs with the platform default icon at launcher sizes, since browsers pick the icon closest to the size they need. Upscaling a 32 px favicon client-side would look worse and needs canvas and CSP work, so the branded icon stays in the manifest at its true size and the defaults cover the large sizes. ## Tests - `pnpm --dir ui exec vitest run apps/console/src/pwaManifestIcons.test.ts`: 23/23 passed. Covers the manifest (through `generatePWAManifest`) for no branding, SVG, small PNG, large square PNG, large non-square PNG, ICO, an unmeasurable raster and an unknown type, plus `iconTypeOf` and the browser measurement (load, error, zero size, timeout). - Ablation (back to the hard-coded `image/svg+xml` declaration, `scripts/ablation-replace.mjs` on `const type = iconTypeOf(src);`): 6 failed / 17 passed, the PNG, ICO, unmeasurable and unknown-type cases red; no branding and SVG stay green, as they should. Restored, blob equal to HEAD, `git diff HEAD` empty. - `turbo run type-check --filter=@object-ui/console`: 71/71 tasks successful. - Derived gates (`dispatch-gates --commands`): 33/33 exit 0 at `29065950`, reconciled with `--ran` (0 NOT-MEASURED, 0 UNRUN), `vitest run scripts/` included (169 files passed). `origin/main` was merged afterwards (`1b6eb74f`); none of the merged commits touch these files, and the unit test re-ran green there (23/23). ## Acceptance notes - The favicon link a few lines above the manifest block (and the same line in `index.html`) still labels every non-SVG favicon `image/png`, an ICO included. Out of this card's surface; browsers sniff favicon bytes, so nothing visible follows from it. ## Verification Installability, read with CDP (`Page.getInstallabilityErrors`, `Page.getAppManifest`) in full Chromium (`launchPersistentContext`, `channel: 'chromium'`), on a `vite build` of the Console served under `/console/` with its `base` tag and a stubbed `GET /api/v1/runtime/config` carrying `branding.faviconUrl`. | branding favicon | before (main's manifest block) | after (this branch) | |:--|:--|:--| | 32 px PNG | `no-acceptable-icon` (minimum 144 px); icon declared `image/svg+xml`, `any` | no errors; icons `32x32` PNG + `192x192` + `512x512` | | 256 px PNG | no errors; icon declared `image/svg+xml`, `any` | no errors; one icon `256x256` `image/png` | | SVG | no errors; one SVG `any` | no errors; one SVG `any` | | none | no errors; default SVG `any` | no errors; default SVG `any` | --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSqNEXXfJECHQqN7mG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fe5ed7c commit 1d03901

6 files changed

Lines changed: 339 additions & 30 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
3.36 KB
Loading
14.8 KB
Loading

‎ui/apps/console/src/main.tsx‎

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { loadLocales } from './loadLocales';
2323
import { seedTenantLanguage } from './languageSeed';
2424
import { preflightAuth } from './lib/auth-preflight';
2525
import { withConsoleBaseRootRelative } from './utils/consoleBase';
26+
import { resolveManifestIcons } from './pwaManifestIcons';
2627

2728
const AUTH_URL = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`;
2829

@@ -157,36 +158,7 @@ Promise.all([
157158
// leaves the manifest in place, which a WebView never acts on.
158159
const existingManifest = document.querySelector<HTMLLinkElement>('link[rel="manifest"]');
159160
if (existingManifest) existingManifest.remove();
160-
if (!getNativeHost()) {
161-
const manifestJson = generatePWAManifest({
162-
enabled: true,
163-
name: getProductName(),
164-
shortName: getProductShortName(),
165-
description: getPwaDescription(),
166-
themeColor: getPwaThemeColor(),
167-
backgroundColor: '#ffffff',
168-
display: 'standalone',
169-
// Absolute URLs, because a Blob URL is no base to resolve `./` against:
170-
// the installed app opens at, and stays inside, the Console's mount.
171-
startUrl: CONSOLE_SCOPE,
172-
scope: CONSOLE_SCOPE,
173-
orientation: 'any',
174-
icons: [
175-
{
176-
src: new URL(faviconUrl || './favicon.svg', document.baseURI).href,
177-
sizes: 'any',
178-
type: 'image/svg+xml',
179-
purpose: 'any',
180-
},
181-
],
182-
});
183-
const blob = new Blob([JSON.stringify(manifestJson)], { type: 'application/json' });
184-
const manifestUrl = URL.createObjectURL(blob);
185-
const manifestLink = document.createElement('link');
186-
manifestLink.rel = 'manifest';
187-
manifestLink.href = manifestUrl;
188-
document.head.appendChild(manifestLink);
189-
}
161+
if (!getNativeHost()) void injectPwaManifest(faviconUrl);
190162

191163
// The Console's Service Worker (objectstack#29155): the offline app shell
192164
// and the push handlers. Not awaited — it must never hold up first paint.
@@ -207,6 +179,36 @@ Promise.all([
207179
);
208180
});
209181

182+
/**
183+
* Generates the Console's web app manifest from runtime branding and links it
184+
* as a Blob URL. Not awaited: a raster branding favicon is measured first
185+
* (`./pwaManifestIcons.ts`), and that load must never hold up first paint, so
186+
* the manifest link lands once the measurement settles.
187+
*/
188+
async function injectPwaManifest(faviconUrl: string | undefined): Promise<void> {
189+
const icons = await resolveManifestIcons(faviconUrl, document.baseURI);
190+
const manifestJson = generatePWAManifest({
191+
enabled: true,
192+
name: getProductName(),
193+
shortName: getProductShortName(),
194+
description: getPwaDescription(),
195+
themeColor: getPwaThemeColor(),
196+
backgroundColor: '#ffffff',
197+
display: 'standalone',
198+
// Absolute URLs, because a Blob URL is no base to resolve `./` against:
199+
// the installed app opens at, and stays inside, the Console's mount.
200+
startUrl: CONSOLE_SCOPE,
201+
scope: CONSOLE_SCOPE,
202+
orientation: 'any',
203+
icons,
204+
});
205+
const blob = new Blob([JSON.stringify(manifestJson)], { type: 'application/json' });
206+
const manifestLink = document.createElement('link');
207+
manifestLink.rel = 'manifest';
208+
manifestLink.href = URL.createObjectURL(blob);
209+
document.head.appendChild(manifestLink);
210+
}
211+
210212
/**
211213
* Registers the Console's Service Worker (`service-worker.js`, emitted at the
212214
* build root by `vite.config.ts`) under the Console's own scope, never the site
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* The Console manifest's icons, by branding favicon (objectstack#29332): each
3+
* icon declares its real type and size, and a branding without a large enough
4+
* square raster gains the Console's 192 px and 512 px PNGs, so the Console
5+
* stays installable.
6+
*/
7+
import { describe, it, expect, vi } from 'vitest';
8+
import { generatePWAManifest } from '@object-ui/mobile';
9+
import {
10+
iconTypeOf,
11+
measureIconInBrowser,
12+
resolveManifestIcons,
13+
type IconSize,
14+
type MeasureIcon,
15+
} from './pwaManifestIcons';
16+
17+
const BASE = 'https://acme.example/_console/';
18+
19+
const DEFAULT_PNGS = [
20+
{ src: `${BASE}icon-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any' },
21+
{ src: `${BASE}icon-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any' },
22+
];
23+
24+
/** A measurement stub that answers `size` for every source. */
25+
function measuring(size: IconSize | null) {
26+
return vi.fn<MeasureIcon>(async () => size);
27+
}
28+
29+
/** The icons of the manifest the Console generates for a favicon. */
30+
async function manifestIcons(faviconUrl: string | undefined, measure: MeasureIcon) {
31+
const icons = await resolveManifestIcons(faviconUrl, BASE, measure);
32+
return generatePWAManifest({ enabled: true, name: 'Acme', shortName: 'Acme', icons }).icons;
33+
}
34+
35+
describe('Console PWA manifest icons', () => {
36+
it('no branding: the default SVG, scalable, nothing measured', async () => {
37+
const measure = measuring(null);
38+
expect(await manifestIcons(undefined, measure)).toEqual([
39+
{ src: `${BASE}favicon.svg`, sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
40+
]);
41+
expect(measure).not.toHaveBeenCalled();
42+
});
43+
44+
it('SVG branding: the branded SVG, scalable, nothing measured', async () => {
45+
const measure = measuring(null);
46+
expect(await manifestIcons('/runtime/assets/brand.svg?v=3', measure)).toEqual([
47+
{
48+
src: 'https://acme.example/runtime/assets/brand.svg?v=3',
49+
sizes: 'any',
50+
type: 'image/svg+xml',
51+
purpose: 'any',
52+
},
53+
]);
54+
expect(measure).not.toHaveBeenCalled();
55+
});
56+
57+
it('small PNG branding: declared as a PNG at its measured size, plus the default PNGs', async () => {
58+
const measure = measuring({ width: 32, height: 32 });
59+
expect(await manifestIcons('https://cdn.acme.example/favicon.png', measure)).toEqual([
60+
{ src: 'https://cdn.acme.example/favicon.png', sizes: '32x32', type: 'image/png', purpose: 'any' },
61+
...DEFAULT_PNGS,
62+
]);
63+
expect(measure).toHaveBeenCalledWith('https://cdn.acme.example/favicon.png');
64+
});
65+
66+
it('PNG branding of at least 192 px square: the branded PNG alone', async () => {
67+
expect(await manifestIcons('/brand/icon.PNG', measuring({ width: 256, height: 256 }))).toEqual([
68+
{ src: 'https://acme.example/brand/icon.PNG', sizes: '256x256', type: 'image/png', purpose: 'any' },
69+
]);
70+
});
71+
72+
it('a large but non-square PNG still gets the default PNGs', async () => {
73+
expect(await manifestIcons('/brand/wide.png', measuring({ width: 600, height: 200 }))).toEqual([
74+
{ src: 'https://acme.example/brand/wide.png', sizes: '600x200', type: 'image/png', purpose: 'any' },
75+
...DEFAULT_PNGS,
76+
]);
77+
});
78+
79+
it('ICO branding: declared as an icon file at its measured size, plus the default PNGs', async () => {
80+
expect(await manifestIcons('/favicon.ico', measuring({ width: 48, height: 48 }))).toEqual([
81+
{ src: 'https://acme.example/favicon.ico', sizes: '48x48', type: 'image/x-icon', purpose: 'any' },
82+
...DEFAULT_PNGS,
83+
]);
84+
});
85+
86+
it('a raster favicon that cannot be measured is left out, the default PNGs stand in', async () => {
87+
expect(await manifestIcons('/favicon.ico', measuring(null))).toEqual(DEFAULT_PNGS);
88+
});
89+
90+
it('a favicon of unknown type is left out unmeasured, the default PNGs stand in', async () => {
91+
const measure = measuring({ width: 512, height: 512 });
92+
expect(await manifestIcons('/api/branding/favicon', measure)).toEqual(DEFAULT_PNGS);
93+
expect(measure).not.toHaveBeenCalled();
94+
});
95+
});
96+
97+
describe('iconTypeOf', () => {
98+
it.each([
99+
['https://a.example/x.svg', 'image/svg+xml'],
100+
['https://a.example/x.svg#frag', 'image/svg+xml'],
101+
['https://a.example/x.png?v=2', 'image/png'],
102+
['https://a.example/x.ico', 'image/x-icon'],
103+
['https://a.example/x.webp', 'image/webp'],
104+
['https://a.example/x.jpeg', 'image/jpeg'],
105+
['data:image/png;base64,AAAA', 'image/png'],
106+
['data:image/svg+xml,%3Csvg%2F%3E', 'image/svg+xml'],
107+
['https://a.example/x.svg.txt', undefined],
108+
['https://a.example/favicon', undefined],
109+
['data:text/plain,hi', undefined],
110+
['not a url', undefined],
111+
])('%s -> %s', (src, type) => {
112+
expect(iconTypeOf(src)).toBe(type);
113+
});
114+
});
115+
116+
describe('measureIconInBrowser', () => {
117+
/** Replaces `Image` with one whose load the test drives. */
118+
function stubImage(outcome: { width: number; height: number } | 'error' | 'hang') {
119+
class FakeImage {
120+
naturalWidth = 0;
121+
naturalHeight = 0;
122+
onload: (() => void) | null = null;
123+
onerror: (() => void) | null = null;
124+
set src(_value: string) {
125+
queueMicrotask(() => {
126+
if (outcome === 'hang') return;
127+
if (outcome === 'error') {
128+
this.onerror?.();
129+
return;
130+
}
131+
this.naturalWidth = outcome.width;
132+
this.naturalHeight = outcome.height;
133+
this.onload?.();
134+
});
135+
}
136+
}
137+
vi.stubGlobal('Image', FakeImage);
138+
}
139+
140+
it('resolves the natural size of a loaded image', async () => {
141+
stubImage({ width: 64, height: 64 });
142+
await expect(measureIconInBrowser('/x.png')).resolves.toEqual({ width: 64, height: 64 });
143+
vi.unstubAllGlobals();
144+
});
145+
146+
it('settles null on a load error or a zero size', async () => {
147+
stubImage('error');
148+
await expect(measureIconInBrowser('/x.png')).resolves.toBeNull();
149+
stubImage({ width: 0, height: 0 });
150+
await expect(measureIconInBrowser('/x.png')).resolves.toBeNull();
151+
vi.unstubAllGlobals();
152+
});
153+
154+
it('settles null when the image never answers, after the timeout', async () => {
155+
vi.useFakeTimers();
156+
stubImage('hang');
157+
const pending = measureIconInBrowser('/x.png', 500);
158+
await vi.advanceTimersByTimeAsync(500);
159+
await expect(pending).resolves.toBeNull();
160+
vi.useRealTimers();
161+
vi.unstubAllGlobals();
162+
});
163+
});
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* The icon entries of the Console's runtime web app manifest, derived from the
3+
* configured branding favicon (objectstack#29332).
4+
*
5+
* Every entry declares what its file really is: an SVG is `image/svg+xml` at
6+
* `sizes: 'any'`; a raster asset declares its own MIME type and the pixel size
7+
* it was measured at. An asset whose type or size cannot be established is left
8+
* out rather than guessed. When the branding offers no square raster of at
9+
* least 192 px, the Console's own 192 px and 512 px PNGs are added, so a
10+
* branded Console stays installable: browsers refuse to install an app whose
11+
* manifest has no large enough icon.
12+
*
13+
* Every `src` is absolute, because the manifest is served from a Blob URL,
14+
* which is no base to resolve a relative path against.
15+
*/
16+
import type { PWAIcon } from '@object-ui/mobile';
17+
18+
/** The Console's default icon, used when no branding favicon is configured. */
19+
export const DEFAULT_ICON_PATH = './favicon.svg';
20+
21+
/** The Console's default raster icons, rendered from `public/favicon.svg`. */
22+
export const DEFAULT_RASTER_ICONS: ReadonlyArray<{ path: string; size: number }> = [
23+
{ path: './icon-192.png', size: 192 },
24+
{ path: './icon-512.png', size: 512 },
25+
];
26+
27+
/** The smallest square raster that makes the default PNGs unnecessary. */
28+
export const MIN_INSTALL_ICON_PX = 192;
29+
30+
/** How long a raster favicon may take to load before it counts as unmeasured. */
31+
export const MEASURE_TIMEOUT_MS = 10_000;
32+
33+
const SVG_TYPE = 'image/svg+xml';
34+
35+
/** MIME type by file extension, for the image formats a browser decodes. */
36+
const TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
37+
svg: SVG_TYPE,
38+
png: 'image/png',
39+
ico: 'image/x-icon',
40+
webp: 'image/webp',
41+
jpg: 'image/jpeg',
42+
jpeg: 'image/jpeg',
43+
gif: 'image/gif',
44+
avif: 'image/avif',
45+
};
46+
47+
/** A measured pixel size. */
48+
export interface IconSize {
49+
width: number;
50+
height: number;
51+
}
52+
53+
/** Loads an image and resolves its natural size, or `null` if it cannot be measured. */
54+
export type MeasureIcon = (src: string) => Promise<IconSize | null>;
55+
56+
/**
57+
* The MIME type of an icon URL: the media type of a `data:` URL, otherwise the
58+
* one its path's extension names. `undefined` when neither says.
59+
*/
60+
export function iconTypeOf(src: string): string | undefined {
61+
let url: URL;
62+
try {
63+
url = new URL(src);
64+
} catch {
65+
return undefined;
66+
}
67+
if (url.protocol === 'data:') {
68+
const mediaType = url.pathname.split(/[;,]/, 1)[0].trim().toLowerCase();
69+
return Object.values(TYPE_BY_EXTENSION).includes(mediaType) ? mediaType : undefined;
70+
}
71+
const lastSegment = url.pathname.split('/').pop() ?? '';
72+
const dot = lastSegment.lastIndexOf('.');
73+
if (dot < 0) return undefined;
74+
return TYPE_BY_EXTENSION[lastSegment.slice(dot + 1).toLowerCase()];
75+
}
76+
77+
/**
78+
* Measures an image in the browser through `HTMLImageElement`. Settles with
79+
* `null` on a load error, a zero size or after `timeoutMs`, so a favicon that
80+
* never answers cannot hold the manifest back forever.
81+
*/
82+
export function measureIconInBrowser(src: string, timeoutMs: number = MEASURE_TIMEOUT_MS): Promise<IconSize | null> {
83+
return new Promise((resolve) => {
84+
const image = new Image();
85+
const timer = setTimeout(() => settle(null), timeoutMs);
86+
function settle(size: IconSize | null) {
87+
clearTimeout(timer);
88+
image.onload = null;
89+
image.onerror = null;
90+
resolve(size);
91+
}
92+
image.onload = () => {
93+
const { naturalWidth: width, naturalHeight: height } = image;
94+
settle(width > 0 && height > 0 ? { width, height } : null);
95+
};
96+
image.onerror = () => settle(null);
97+
image.src = src;
98+
});
99+
}
100+
101+
/**
102+
* The manifest icons for a branding favicon (`undefined` = none configured),
103+
* resolved against `baseURI`. Measures the favicon only when it is a raster
104+
* image; an SVG or the default icon resolves without loading anything.
105+
*/
106+
export async function resolveManifestIcons(
107+
faviconUrl: string | undefined,
108+
baseURI: string,
109+
measure: MeasureIcon = measureIconInBrowser,
110+
): Promise<PWAIcon[]> {
111+
const src = new URL(faviconUrl || DEFAULT_ICON_PATH, baseURI).href;
112+
const type = iconTypeOf(src);
113+
if (type === SVG_TYPE) {
114+
return [{ src, sizes: 'any', type, purpose: 'any' }];
115+
}
116+
117+
const icons: PWAIcon[] = [];
118+
const size = type ? await measure(src) : null;
119+
if (type && size) {
120+
icons.push({ src, sizes: `${size.width}x${size.height}`, type, purpose: 'any' });
121+
}
122+
const installable = size !== null && size.width === size.height && size.width >= MIN_INSTALL_ICON_PX;
123+
if (!installable) {
124+
for (const icon of DEFAULT_RASTER_ICONS) {
125+
icons.push({
126+
src: new URL(icon.path, baseURI).href,
127+
sizes: `${icon.size}x${icon.size}`,
128+
type: 'image/png',
129+
purpose: 'any',
130+
});
131+
}
132+
}
133+
return icons;
134+
}

0 commit comments

Comments
 (0)