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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ Where the provided flags are:
- `--ios` - explicitly run iOS asset generation. Using a platform flag makes the platform list exclusive.
- `--android` - explicitly run Android asset generation. Using a platform flag makes the platform list exclusive.
- `--pwa` - explicitly run PWA asset generation. Using a platform flag makes the platform list exclusive.

- `--pwaAppleSizesFile <path>` - Path to a file containing Apple device screen sizes. The file should contain device size declarations in the format `WIDTHxHEIGHT @DENSITYx` (e.g., `1290x2796 @3x`).
- `--pwaNoAppleFetch` - Whether to fetch the latest screen sizes for Apple devices from the official Apple site. Set to true if running offline to use local cached sizes (may be occasionally out of date).
### Usage - Custom Mode

This mode provides full control over the assets used to generate icons and splash screens, but requires more source files. To use this mode, provide custom icons and splash screen source images as shown below:
Expand Down Expand Up @@ -88,6 +89,8 @@ This tool will create and/or update the web app manifest used in your project, a

By default, the tool will look for the manifest file in `public`, `src`, and `www` in that order. Use the flag `--pwaManifestPath` to specify the exact path to your web app manifest.

Instead of fetching device sizes from Apple's website, you can supply a file containing screen sizes using the `--pwaAppleSizesFile` flag. The file should contain device size declarations in the format `WIDTHxHEIGHT @DENSITYx` (e.g., `1290x2796 @3x`), which will be collected and used to generate splash IOS screens.

### Help

See the help instructions on the command line with the `--help` flag.
Expand Down
2 changes: 2 additions & 0 deletions src/asset-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface AssetGeneratorOptions {
pwaManifestPath?: string;
// Whether to fetch latest device sizes from official apple site
pwaNoAppleFetch?: boolean;
// Path to the file containing the Apple device sizes
pwaAppleSizesFile?: string;
// Scale amount for logo when generating splashes. Default: 0.2 (20%)
logoSplashScale?: number;
// Specific width for logo when generating splashes. (not used by default)
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export function runProgram(ctx: Context): void {
'--pwaNoAppleFetch',
'Whether to fetch the latest screen sizes for Apple devices from the official Apple site. Set to true if running offline to use local cached sizes (may be occasionally out of date)',
)
.option(
'--pwaAppleSizesFile <path>',
"Path to a file containing Apple device screen sizes. The file should contain device size declarations in the format `WIDTHxHEIGHT @DENSITYx` (e.g., `1290x2796 @3x`). If provided, this file will be used instead of fetching sizes from Apple's website.",
)
.option(
'--assetPath <path>',
'Path to the assets directory for your project. By default will check "assets" and "resources" directories, in that order.',
Expand Down
24 changes: 16 additions & 8 deletions src/platforms/pwa/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,26 @@ export const ASSETS = {
};

// From https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/
// Updated @2025-11-13
export const PWA_IOS_DEVICE_SIZES = [
'2048x2732@2x',
'1179x2556@3x',
'1125x2436@3x',
'1206x2622@3x',
'1488x2266@2x',
'640x1136@2x',
'1668x2388@2x',
'828x1792@2x',
'1260x2736@3x',
'1080x1920@3x',
'1242x2688@3x',
'1284x2778@3x',
'2048x2732@2x',
'1640x2360@2x',
'1668x2224@2x',
'1290x2796@3x',
'1320x2868@3x',
'1620x2160@2x',
'750x1334@2x',
'1536x2048@2x',
'1284x2778@3x',
'1242x2688@3x',
'1170x2532@3x',
'1125x2436@3x',
'1080x1920@3x',
'828x1792@2x',
'750x1334@2x',
'640x1136@2x',
];
44 changes: 24 additions & 20 deletions src/platforms/pwa/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { mkdirp, pathExists, readFile, readJSON, rmSync, writeJSON } from '@ionic/utils-fs';
import fetch from 'node-fetch';
import parse from 'node-html-parser';
import { basename, extname, join, posix, relative, sep } from 'path';
import type { Sharp } from 'sharp';
import sharp from 'sharp';
Expand Down Expand Up @@ -28,6 +27,7 @@ export interface ManifestIcon {
type?: string;
}

const DEVICE_DECLARATION = new RegExp(/(?<width>\d+)x(?<height>\d+)[\s\D]+@(?<density>\d)x/g);
export class PwaAssetGenerator extends AssetGenerator {
constructor(options: AssetGeneratorOptions = {}) {
super(options);
Expand All @@ -42,30 +42,34 @@ export class PwaAssetGenerator extends AssetGenerator {
}

async getSplashSizes(): Promise<string[]> {
const appleInterfacePage = `https://developer.apple.com/design/human-interface-guidelines/foundations/layout/`;
// Apple has switched to JS based web pages, so we need to fetch the JSON data directly
const appleInterfaceJsonPath = `https://developer.apple.com/tutorials/data/design/human-interface-guidelines/layout.json`;

let assetSizes = PWA_IOS_DEVICE_SIZES;
if (!this.options.pwaNoAppleFetch) {
try {
const res = await fetch(appleInterfacePage);

const html = await res.text();
const assetSizes = PWA_IOS_DEVICE_SIZES;

const doc = parse(html);
if (this.options.pwaAppleSizesFile) {
try {
const contents = await readFile(this.options.pwaAppleSizesFile, { encoding: 'utf-8' });
const allResolutions = [...contents.matchAll(DEVICE_DECLARATION)];
const deduped = new Set(allResolutions.map((match) => `${match[1]}x${match[2]}@${match[3]}x`));

const target = doc.querySelector('main > section .row > .column table');
const sizes = target?.querySelectorAll('tr > td:nth-child(2)') ?? [];
const sizeStrings = sizes.map((td) => {
const t = td.innerText;
return t
.slice(t.indexOf('pt (') + 4)
.slice(0, -1)
.replace(' px ', '');
});
return Array.from(deduped);
} catch (error) {
warn(
`Unable to load iOS HIG screen sizes to generate iOS PWA splash screens from file ${this.options.pwaAppleSizesFile}`,
);
}
}
if (!this.options.pwaNoAppleFetch) {
try {
const res = await fetch(appleInterfaceJsonPath);

const deduped = new Set(sizeStrings);
//Instead of parsing the JSON, we will just scrape resolutions directly from the text
const raw_json_text = await res.text();
const allResolutions = [...raw_json_text.matchAll(DEVICE_DECLARATION)];
const deduped = new Set(allResolutions.map((match) => `${match[1]}x${match[2]}@${match[3]}x`));

assetSizes = Array.from(deduped);
return Array.from(deduped);
} catch (e) {
warn(
`Unable to load iOS HIG screen sizes to generate iOS PWA splash screens. Using local snapshot of device sizes. Use --pwaNoAppleFetch true to always use local sizes`,
Expand Down
159 changes: 154 additions & 5 deletions test/platforms/pwa.asset.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { copy, pathExists, readJSON, rmSync as rm } from '@ionic/utils-fs';
import { copy, pathExists, readJSON, rmSync as rm, writeFile } from '@ionic/utils-fs';
import tempy from 'tempy';

import { Context, loadContext } from '../../src/ctx';
import { PwaAssetGenerator } from '../../src/platforms/pwa';
import { AssetKind, PwaOutputAssetTemplate } from '../../src/definitions';
import { ASSETS as PwaAssets, PWA_IOS_DEVICE_SIZES } from '../../src/platforms/pwa/assets';
import { ASSETS as PwaAssets, PWA_IOS_DEVICE_SIZES, ASSETS } from '../../src/platforms/pwa/assets';
import sharp from 'sharp';
import { isAbsolute, join, parse } from 'path';
import { OutputAsset } from '../../src/output-asset';



describe('PWA Asset Test', () => {
let ctx: Context;
const fixtureDir = tempy.directory();
Expand Down Expand Up @@ -76,8 +78,13 @@ describe('PWA Asset Test', () => {
.every((i: any) => !!i),
).toBe(true);
});
it('Should get splash sizes from Apple HIG', async () => {
const strategy = new PwaAssetGenerator();
const sizes = await strategy.getSplashSizes();
expect(sizes.length).toBeGreaterThan(0);
});

it.skip('Should generate PWA splashes', async () => {
it('Should generate PWA splashes', async () => {
const assets = await ctx.project.loadInputAssets();

const strategy = new PwaAssetGenerator();
Expand All @@ -88,6 +95,7 @@ describe('PWA Asset Test', () => {

generatedAssets = ((await assets.splashDark?.generate(strategy, ctx.project)) ??
[]) as OutputAsset<PwaOutputAssetTemplate>[];
expect(generatedAssets.length).toBeGreaterThan(10);
});
});

Expand Down Expand Up @@ -126,15 +134,156 @@ describe('PWA Asset Test - logo only', () => {

const strategy = new PwaAssetGenerator({
splashBackgroundColor: '#dedbef',
pwaNoAppleFetch: true,
});
strategy.options.pwaNoAppleFetch = true;

const generated = await assets.logo!.generate(strategy, ctx.project);

const manifestPath = join(fixtureDir, 'public', 'manifest.webmanifest');
const manifest = await readJSON(manifestPath);
expect(manifest['background_color']).toBe('#dedbef');

expect(generated.length).toBe(7);
const iconsLength = Object.values(ASSETS).filter((a) => a.kind === AssetKind.Icon).length;
// Light and Dark mode splashes, plus icons
expect(generated.length).toBe(2*PWA_IOS_DEVICE_SIZES.length+iconsLength);
await verifySizes(generated as OutputAsset<PwaOutputAssetTemplate>[]);
});
});

describe('PWA Asset Test - pwaAppleSizesFile', () => {
let ctx: Context;
const fixtureDir = tempy.directory();
const tempFileDir = tempy.directory();

beforeAll(async () => {
await copy('test/fixtures/app', fixtureDir);
});

beforeEach(async () => {
ctx = await loadContext(fixtureDir);
});

afterAll(async () => {
await rm(fixtureDir, { force: true, recursive: true });
await rm(tempFileDir, { force: true, recursive: true });
});

it('Should read splash sizes from pwaAppleSizesFile', async () => {
const appleSizesFile = join(tempFileDir, 'apple-sizes.txt');
const fileContent = `
iPhone 14 Pro Max: 1290x2796 @3x
iPhone 14 Pro: 1179x2556 @3x
iPhone 13 Pro Max: 1284x2778 @3x
iPad Pro 12.9": 2048x2732 @2x
iPad Air: 1640x2360 @2x
`;

await writeFile(appleSizesFile, fileContent);

const strategy = new PwaAssetGenerator({
pwaAppleSizesFile: appleSizesFile,
pwaNoAppleFetch: true,
});

const sizes = await strategy.getSplashSizes();

expect(sizes.length).toBe(5);
expect(sizes).toContain('1290x2796@3x');
expect(sizes).toContain('1179x2556@3x');
expect(sizes).toContain('1284x2778@3x');
expect(sizes).toContain('2048x2732@2x');
expect(sizes).toContain('1640x2360@2x');

// Verify format is correct (widthxheight@densityx)
sizes.forEach((size) => {
const parts = size.split('@');
expect(parts.length).toBe(2);
const [width, height] = parts[0].split('x');
expect(parseInt(width)).toBeGreaterThan(0);
expect(parseInt(height)).toBeGreaterThan(0);
expect(parts[1]).toMatch(/^\d+x$/);
});
});

it('Should deduplicate splash sizes from pwaAppleSizesFile', async () => {
const appleSizesFile = join(tempFileDir, 'apple-sizes-dupes.txt');
const fileContent = `
iPhone 14 Pro Max: 1290x2796 @3x
iPhone 14 Pro: 1179x2556 @3x
iPhone 13 Pro Max: 1284x2778 @3x
iPhone 14 Pro Max (duplicate): 1290x2796 @3x
iPad Pro 12.9": 2048x2732 @2x
iPad Air: 1640x2360 @2x
iPad Pro 12.9" (duplicate): 2048x2732 @2x
`;

await writeFile(appleSizesFile, fileContent);

const strategy = new PwaAssetGenerator({
pwaAppleSizesFile: appleSizesFile,
pwaNoAppleFetch: true,
});

const sizes = await strategy.getSplashSizes();

// Should have 5 unique sizes, not 7
expect(sizes.length).toBe(5);
expect(sizes).toContain('1290x2796@3x');
expect(sizes).toContain('1179x2556@3x');
expect(sizes).toContain('1284x2778@3x');
expect(sizes).toContain('2048x2732@2x');
expect(sizes).toContain('1640x2360@2x');
});

it('Should handle file read errors gracefully', async () => {
const nonExistentFile = join(tempFileDir, 'non-existent-file.txt');

const strategy = new PwaAssetGenerator({
pwaAppleSizesFile: nonExistentFile,
pwaNoAppleFetch: true,
});

// Should fall back to default sizes when file doesn't exist
const sizes = await strategy.getSplashSizes();

// Should return default PWA_IOS_DEVICE_SIZES
expect(sizes.length).toBeGreaterThan(0);
expect(sizes).toEqual(PWA_IOS_DEVICE_SIZES);
});

it('Should use pwaAppleSizesFile when generating splashes', async () => {
const appleSizesFile = join(tempFileDir, 'apple-sizes-custom.txt');
const fileContent = `
Custom Device 1: 1000x2000 @2x
Custom Device 2: 1500x3000 @3x
`;

await writeFile(appleSizesFile, fileContent);

const assets = await ctx.project.loadInputAssets();

const strategy = new PwaAssetGenerator({
pwaAppleSizesFile: appleSizesFile,
pwaNoAppleFetch: true,
});

const generatedAssets = ((await assets.splash?.generate(strategy, ctx.project)) ??
[]) as OutputAsset<PwaOutputAssetTemplate>[];

// Should generate splashes for the 2 custom sizes
expect(generatedAssets.length).toBe(2);

// Verify the generated assets match the custom sizes
const sizes = generatedAssets.map((asset) => {
const parts = asset.template.name.match(/apple-splash-(\d+)-(\d+)@(\d+x)/);
if (parts) {
return `${parts[1]}x${parts[2]}@${parts[3]}`;
}
return null;
}).filter(Boolean);

expect(sizes).toContain('1000x2000@2x');
expect(sizes).toContain('1500x3000@3x');
});
});