diff --git a/README.md b/README.md index a821c5c..32fe02b 100644 --- a/README.md +++ b/README.md @@ -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 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: @@ -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. diff --git a/src/asset-generator.ts b/src/asset-generator.ts index b926d7c..4b70622 100644 --- a/src/asset-generator.ts +++ b/src/asset-generator.ts @@ -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) diff --git a/src/index.ts b/src/index.ts index 577403f..9419e81 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 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 to the assets directory for your project. By default will check "assets" and "resources" directories, in that order.', diff --git a/src/platforms/pwa/assets.ts b/src/platforms/pwa/assets.ts index 8f2d96e..53aabdc 100644 --- a/src/platforms/pwa/assets.ts +++ b/src/platforms/pwa/assets.ts @@ -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', ]; diff --git a/src/platforms/pwa/index.ts b/src/platforms/pwa/index.ts index de67777..a521cf9 100644 --- a/src/platforms/pwa/index.ts +++ b/src/platforms/pwa/index.ts @@ -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'; @@ -28,6 +27,7 @@ export interface ManifestIcon { type?: string; } +const DEVICE_DECLARATION = new RegExp(/(?\d+)x(?\d+)[\s\D]+@(?\d)x/g); export class PwaAssetGenerator extends AssetGenerator { constructor(options: AssetGeneratorOptions = {}) { super(options); @@ -42,30 +42,34 @@ export class PwaAssetGenerator extends AssetGenerator { } async getSplashSizes(): Promise { - 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`, diff --git a/test/platforms/pwa.asset.test.ts b/test/platforms/pwa.asset.test.ts index a5dcdf0..6485eff 100644 --- a/test/platforms/pwa.asset.test.ts +++ b/test/platforms/pwa.asset.test.ts @@ -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(); @@ -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(); @@ -88,6 +95,7 @@ describe('PWA Asset Test', () => { generatedAssets = ((await assets.splashDark?.generate(strategy, ctx.project)) ?? []) as OutputAsset[]; + expect(generatedAssets.length).toBeGreaterThan(10); }); }); @@ -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[]); }); }); + +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[]; + + // 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'); + }); +}); +