diff --git a/src/project/assets/android/assets.ts b/src/project/assets/android/assets.ts deleted file mode 100644 index 4e62c31..0000000 --- a/src/project/assets/android/assets.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { - AndroidOutputAssetTemplate, - AndroidOutputAssetTemplateAdaptiveIcon -} from '../asset-types'; -import { AssetKind, AndroidDensity, Format, Platform } from '../asset-types'; - -export const ANDROID_LDPI_ICON: AndroidOutputAssetTemplate = { - platform: Platform.Android, - kind: AssetKind.Icon, - format: Format.Png, - width: 36, - height: 36, - density: AndroidDensity.Ldpi, -}; - -export const ANDROID_MDPI_ICON: AndroidOutputAssetTemplate = { - platform: Platform.Android, - kind: AssetKind.Icon, - format: Format.Png, - width: 48, - height: 48, - density: AndroidDensity.Mdpi, -}; - -export const ANDROID_HDPI_ICON: AndroidOutputAssetTemplate = { - platform: Platform.Android, - kind: AssetKind.Icon, - format: Format.Png, - width: 72, - height: 72, - density: AndroidDensity.Hdpi, -}; - -export const ANDROID_XHDPI_ICON: AndroidOutputAssetTemplate = { - platform: Platform.Android, - kind: AssetKind.Icon, - format: Format.Png, - width: 96, - height: 96, - density: AndroidDensity.Xhdpi, -}; - -export const ANDROID_XXHDPI_ICON: AndroidOutputAssetTemplate = { - platform: Platform.Android, - kind: AssetKind.Icon, - format: Format.Png, - width: 144, - height: 144, - density: AndroidDensity.Xxhdpi, -}; - -export const ANDROID_XXXHDPI_ICON: AndroidOutputAssetTemplate = { - platform: Platform.Android, - kind: AssetKind.Icon, - format: Format.Png, - width: 192, - height: 192, - density: AndroidDensity.Xxxhdpi, -}; - -/** - * Adaptive icons - */ -export const ANDROID_LDPI_ADAPTIVE_ICON: AndroidOutputAssetTemplateAdaptiveIcon = { - platform: Platform.Android, - kind: AssetKind.AdaptiveIcon, - format: Format.Png, - width: 81, - height: 81, - density: AndroidDensity.Ldpi, -}; - -export const ANDROID_MDPI_ADAPTIVE_ICON: AndroidOutputAssetTemplateAdaptiveIcon = { - platform: Platform.Android, - kind: AssetKind.AdaptiveIcon, - format: Format.Png, - width: 108, - height: 108, - density: AndroidDensity.Mdpi, -}; - -export const ANDROID_HDPI_ADAPTIVE_ICON: AndroidOutputAssetTemplateAdaptiveIcon = { - platform: Platform.Android, - kind: AssetKind.AdaptiveIcon, - format: Format.Png, - width: 162, - height: 162, - density: AndroidDensity.Hdpi, -}; - -export const ANDROID_XHDPI_ADAPTIVE_ICON: AndroidOutputAssetTemplateAdaptiveIcon = { - platform: Platform.Android, - kind: AssetKind.AdaptiveIcon, - format: Format.Png, - width: 216, - height: 216, - density: AndroidDensity.Xhdpi, -}; - -export const ANDROID_XXHDPI_ADAPTIVE_ICON: AndroidOutputAssetTemplateAdaptiveIcon = { - platform: Platform.Android, - kind: AssetKind.AdaptiveIcon, - format: Format.Png, - width: 324, - height: 324, - density: AndroidDensity.Xxhdpi, -}; - -export const ANDROID_XXXHDPI_ADAPTIVE_ICON: AndroidOutputAssetTemplateAdaptiveIcon = { - platform: Platform.Android, - kind: AssetKind.AdaptiveIcon, - format: Format.Png, - width: 432, - height: 432, - density: AndroidDensity.Xxxhdpi, -}; diff --git a/src/project/assets/android/generator.ts b/src/project/assets/android/generator.ts deleted file mode 100644 index 0893047..0000000 --- a/src/project/assets/android/generator.ts +++ /dev/null @@ -1,380 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { mkdirp, pathExists, writeFile } from '@ionic/utils-fs'; -import { dirname, join } from 'path'; -import type { OutputInfo, Sharp } from 'sharp'; -import sharp from 'sharp'; - -import type { AssetGeneratorOptions } from '../asset-generator'; -import { AssetGenerator } from '../asset-generator'; -import type { - AndroidOutputAssetTemplate, - AndroidOutputAssetTemplateAdaptiveIcon -} from '../asset-types'; -import { AssetKind, Platform } from '../asset-types'; -import type { InputAsset } from '../input-asset'; -import { OutputAsset } from '../output-asset'; -import type { MobileProject } from '../../project'; - -import * as AndroidAssetTemplates from './assets'; - -export class AndroidAssetGenerator extends AssetGenerator { - constructor(options: AssetGeneratorOptions = {}) { - super(options); - } - - async generate(asset: InputAsset, project: MobileProject): Promise { - const androidDir = project.config.android?.path; - await asset.load() - - if (!androidDir) { - throw new Error("No android project found") - } - - if (asset.platform !== Platform.Any && asset.platform !== Platform.Android) { - return []; - } - - switch (asset.kind) { - case AssetKind.Logo: - case AssetKind.LogoDark: - return this.generateFromLogo(asset, project); - case AssetKind.Icon: - return this.generateLegacyIcon(asset, project); - case AssetKind.IconForeground: - return this.generateAdaptiveIconForeground(asset, project); - case AssetKind.IconBackground: - return this.generateAdaptiveIconBackground(asset, project); - } - - return []; - } - - /** - * Generate from logo combines all of the other operations into a single operation - * from a single asset source file. In this mode, a logo along with a background color - * is used to generate all icons and splash screens (with dark mode where possible). - */ - private async generateFromLogo(asset: InputAsset, project: MobileProject): Promise { - const pipe = asset.pipeline(); - const generated: OutputAsset[] = []; - - if (!pipe) { - throw new Error('Sharp instance not created'); - } - - // Generate adaptive icons - const generatedAdaptiveIcons = await this._generateAdaptiveIconsFromLogo(project, asset, pipe); - generated.push(...generatedAdaptiveIcons); - - if (asset.kind === AssetKind.Logo) { - // Generate legacy icons - const generatedLegacyIcons = await this.generateLegacyIcon(asset, project); - generated.push(...generatedLegacyIcons); - } - - return [...generated]; - } - - // Generate adaptive icons from the source logo - private async _generateAdaptiveIconsFromLogo( - project: MobileProject, - asset: InputAsset, - pipe: Sharp, - ): Promise { - // Current versions of Android don't appear to support night mode icons (13+ might?) - // so, for now, we only generate light mode ones - if (asset.kind === AssetKind.LogoDark) { - return []; - } - - // Create the background pipeline for the generated icons - const backgroundPipe = sharp({ - create: { - width: asset.width!, - height: asset.height!, - channels: 4, - background: - asset.kind === AssetKind.Logo - ? this.options.iconBackgroundColor ?? '#ffffff' - : this.options.iconBackgroundColorDark ?? '#111111', - }, - }); - - const icons = Object.values(AndroidAssetTemplates).filter( - (a) => a.kind === AssetKind.AdaptiveIcon, - ) as AndroidOutputAssetTemplateAdaptiveIcon[]; - - const backgroundImages = await Promise.all( - icons.map(async (icon) => { - return await this._generateAdaptiveIconBackground(project, asset, icon, backgroundPipe); - }), - ); - - const foregroundImages = await Promise.all( - icons.map(async (icon) => { - return await this._generateAdaptiveIconForeground(project, asset, icon, pipe); - }), - ); - - return [...foregroundImages, ...backgroundImages]; - } - - private async generateLegacyIcon(asset: InputAsset, project: MobileProject): Promise { - const icons = Object.values(AndroidAssetTemplates).filter( - (a) => a.kind === AssetKind.Icon, - ) as AndroidOutputAssetTemplate[]; - - const pipe = asset.pipeline(); - - if (!pipe) { - throw new Error('Sharp instance not created'); - } - - const collected = await Promise.all( - icons.map(async (icon) => { - const [dest, outputInfo] = await this.generateLegacyLauncherIcon(project, asset, icon); - - return new OutputAsset( - icon, - asset, - project, - { [`mipmap-${icon.density}/ic_launcher.png`]: dest }, - { [`mipmap-${icon.density}/ic_launcher.png`]: outputInfo }, - ); - }), - ); - - collected.push( - ...(await Promise.all( - icons.map(async (icon) => { - const [dest, outputInfo] = await this.generateRoundLauncherIcon(project, asset, icon); - - return new OutputAsset( - icon, - asset, - project, - { [`mipmap-${icon.density}/ic_launcher_round.png`]: dest }, - { [`mipmap-${icon.density}/ic_launcher_round.png`]: outputInfo }, - ); - }), - )), - ); - - await this.updateManifest(project); - - return collected; - } - - private async generateLegacyLauncherIcon( - project: MobileProject, - asset: InputAsset, - template: AndroidOutputAssetTemplate, - ): Promise<[string, OutputInfo]> { - const resPath = this.getResPath(project); - const parentDir = join(resPath, `mipmap-${template.density}`); - if (!(await pathExists(parentDir))) { - await mkdirp(parentDir); - } - const destRound = join(resPath, `mipmap-${template.density}`, 'ic_launcher.png'); - - // This pipeline is trick, but we need two separate pipelines - // per https://github.com/lovell/sharp/issues/2378#issuecomment-864132578 - const padding = 8; - const resized = await sharp(asset.path) - .resize(template.width, template.height) - // .composite([{ input: Buffer.from(svg), blend: 'dest-in' }]) - .toBuffer(); - const composited = await sharp(resized) - .resize(Math.max(0, template.width - padding * 2), Math.max(0, template.height - padding * 2)) - .extend({ - top: padding, - bottom: padding, - left: padding, - right: padding, - background: { r: 0, g: 0, b: 0, alpha: 0 }, - }) - .toBuffer(); - const outputInfo = await sharp(composited).png().toFile(destRound); - - return [destRound, outputInfo]; - } - - private async generateRoundLauncherIcon( - project: MobileProject, - asset: InputAsset, - template: AndroidOutputAssetTemplate, - ): Promise<[string, OutputInfo]> { - const svg = ``; - - const resPath = this.getResPath(project); - const destRound = join(resPath, `mipmap-${template.density}`, 'ic_launcher_round.png'); - - // This pipeline is tricky, but we need two separate pipelines - // per https://github.com/lovell/sharp/issues/2378#issuecomment-864132578 - const resized = await sharp(asset.path).resize(template.width, template.height).toBuffer(); - const composited = await sharp(resized) - .composite([{ input: Buffer.from(svg), blend: 'dest-in' }]) - .toBuffer(); - const outputInfo = await sharp(composited).png().toFile(destRound); - - return [destRound, outputInfo]; - } - - private async generateAdaptiveIconForeground(asset: InputAsset, project: MobileProject): Promise { - const icons = Object.values(AndroidAssetTemplates).filter( - (a) => a.kind === AssetKind.Icon, - ) as AndroidOutputAssetTemplateAdaptiveIcon[]; - - const pipe = asset.pipeline(); - - if (!pipe) { - throw new Error('Sharp instance not created'); - } - - return Promise.all( - icons.map(async (icon) => { - return await this._generateAdaptiveIconForeground(project, asset, icon, pipe); - }), - ); - } - - private async _generateAdaptiveIconForeground( - project: MobileProject, - asset: InputAsset, - icon: AndroidOutputAssetTemplateAdaptiveIcon, - pipe: Sharp, - ) { - const resPath = this.getResPath(project); - - // Create the foreground and background images - const destForeground = join(resPath, `mipmap-${icon.density}`, 'ic_launcher_foreground.png'); - const parentDir = dirname(destForeground); - if (!(await pathExists(parentDir))) { - await mkdirp(parentDir); - } - const outputInfoForeground = await pipe.resize(icon.width, icon.height).png().toFile(destForeground); - - // Create the adaptive icon XML - const icLauncherXml = ` - - - - - - - - - - `.trim(); - - const mipmapAnyPath = join(resPath, `mipmap-anydpi-v26`); - if (!(await pathExists(mipmapAnyPath))) { - await mkdirp(mipmapAnyPath); - } - const destIcLauncher = join(mipmapAnyPath, `ic_launcher.xml`); - const destIcLauncherRound = join(mipmapAnyPath, `ic_launcher_round.xml`); - await writeFile(destIcLauncher, icLauncherXml); - await writeFile(destIcLauncherRound, icLauncherXml); - - // Return the created files for this OutputAsset - return new OutputAsset( - icon, - asset, - project, - { - [`mipmap-${icon.density}/ic_launcher_foreground.png`]: destForeground, - 'mipmap-anydpi-v26/ic_launcher.xml': destIcLauncher, - 'mipmap-anydpi-v26/ic_launcher_round.xml': destIcLauncherRound, - }, - { - [`mipmap-${icon.density}/ic_launcher_foreground.png`]: outputInfoForeground, - }, - ); - } - - private async generateAdaptiveIconBackground(asset: InputAsset, project: MobileProject): Promise { - const icons = Object.values(AndroidAssetTemplates).filter( - (a) => a.kind === AssetKind.Icon, - ) as AndroidOutputAssetTemplateAdaptiveIcon[]; - - const pipe = asset.pipeline(); - - if (!pipe) { - throw new Error('Sharp instance not created'); - } - - return Promise.all( - icons.map(async (icon) => { - return await this._generateAdaptiveIconBackground(project, asset, icon, pipe); - }), - ); - } - private async _generateAdaptiveIconBackground( - project: MobileProject, - asset: InputAsset, - icon: AndroidOutputAssetTemplateAdaptiveIcon, - pipe: Sharp, - ) { - const resPath = this.getResPath(project); - - const destBackground = join(resPath, `mipmap-${icon.density}`, 'ic_launcher_background.png'); - const parentDir = dirname(destBackground); - if (!(await pathExists(parentDir))) { - await mkdirp(parentDir); - } - - const outputInfoBackground = await pipe.resize(icon.width, icon.height).png().toFile(destBackground); - - // Create the adaptive icon XML - const icLauncherXml = ` - - - - - - - - - - `.trim(); - - const mipmapAnyPath = join(resPath, `mipmap-anydpi-v26`); - if (!(await pathExists(mipmapAnyPath))) { - await mkdirp(mipmapAnyPath); - } - const destIcLauncher = join(mipmapAnyPath, `ic_launcher.xml`); - const destIcLauncherRound = join(mipmapAnyPath, `ic_launcher_round.xml`); - await writeFile(destIcLauncher, icLauncherXml); - await writeFile(destIcLauncherRound, icLauncherXml); - - // Return the created files for this OutputAsset - return new OutputAsset( - icon, - asset, - project, - { - [`mipmap-${icon.density}/ic_launcher_background.png`]: destBackground, - 'mipmap-anydpi-v26/ic_launcher.xml': destIcLauncher, - 'mipmap-anydpi-v26/ic_launcher_round.xml': destIcLauncherRound, - }, - { - [`mipmap-${icon.density}/ic_launcher_background.png`]: outputInfoBackground, - }, - ); - } - - private async updateManifest(project: MobileProject) { - project.android?.getAndroidManifest()?.setAttrs('manifest/application', { - 'android:icon': '@mipmap/ic_launcher', - 'android:roundIcon': '@mipmap/ic_launcher_round', - }); - - await project.commit(); - } - - private getResPath(project: MobileProject): string { - return join(project.config.android!.path!, 'app', 'src', this.options.androidFlavor ?? 'main', 'res'); - } -} diff --git a/src/project/assets/asset-generator.ts b/src/project/assets/asset-generator.ts deleted file mode 100644 index acfeacf..0000000 --- a/src/project/assets/asset-generator.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { InputAsset } from './input-asset'; -import type { OutputAsset } from './output-asset'; -import type { MobileProject } from '../project'; - -export abstract class AssetGenerator { - constructor(public options: AssetGeneratorOptions) {} - - abstract generate(asset: InputAsset, project: MobileProject): Promise; -} - -export interface AssetGeneratorOptions { - // Background color for icon generation - iconBackgroundColor?: string; - // Background color for icon generation for use in dark mode scenarios - iconBackgroundColorDark?: string; - // Android product flavor name where generated assets will be created. Default: main - androidFlavor?: string; -} diff --git a/src/project/assets/asset-types.ts b/src/project/assets/asset-types.ts deleted file mode 100644 index 8cbd829..0000000 --- a/src/project/assets/asset-types.ts +++ /dev/null @@ -1,93 +0,0 @@ -export enum AssetKind { - Logo = 'logo', - LogoDark = 'logo-dark', - AdaptiveIcon = 'adaptive-icon', - Icon = 'icon', - IconForeground = 'icon-foreground', - IconBackground = 'icon-background', -} - -export enum Platform { - Any = 'any', - Ios = 'ios', - Android = 'android' -} - -export enum Format { - Png = 'png', - Jpeg = 'jpeg', - Svg = 'svg', - WebP = 'webp', - Unknown = 'unknown', -} - -export enum AndroidDensity { - Default = '', - Ldpi = 'ldpi', - Mdpi = 'mdpi', - Hdpi = 'hdpi', - Xhdpi = 'xhdpi', - Xxhdpi = 'xxhdpi', - Xxxhdpi = 'xxxhdpi', - LandLdpi = 'land-ldpi', - LandMdpi = 'land-mdpi', - LandHdpi = 'land-hdpi', - LandXhdpi = 'land-xhdpi', - LandXxhdpi = 'land-xxhdpi', - LandXxxhdpi = 'land-xxxhdpi', - PortLdpi = 'port-ldpi', - PortMdpi = 'port-mdpi', - PortHdpi = 'port-hdpi', - PortXhdpi = 'port-xhdpi', - PortXxhdpi = 'port-xxhdpi', - PortXxxhdpi = 'port-xxxhdpi', - DefaultNight = 'night', - LdpiNight = 'night-ldpi', - MdpiNight = 'night-mdpi', - HdpiNight = 'night-hdpi', - XhdpiNight = 'night-xhdpi', - XxhdpiNight = 'night-xxhdpi', - XxxhdpiNight = 'night-xxxhdpi', - LandLdpiNight = 'land-night-ldpi', - LandMdpiNight = 'land-night-mdpi', - LandHdpiNight = 'land-night-hdpi', - LandXhdpiNight = 'land-night-xhdpi', - LandXxhdpiNight = 'land-night-xxhdpi', - LandXxxhdpiNight = 'land-night-xxxhdpi', - PortLdpiNight = 'port-night-ldpi', - PortMdpiNight = 'port-night-mdpi', - PortHdpiNight = 'port-night-hdpi', - PortXhdpiNight = 'port-night-xhdpi', - PortXxhdpiNight = 'port-night-xxhdpi', - PortXxxhdpiNight = 'port-night-xxxhdpi', -} - -export interface OutputAssetTemplate { - platform: Platform; - kind: AssetKind; - format: Format; - width: number; - height: number; - scale?: number; -} - -export interface IosOutputAssetTemplate extends OutputAssetTemplate { - name: string; - idiom: IosIdiom; -} - -// https://developer.apple.com/library/archive/documentation/Xcode/Reference/xcode_ref-Asset_Catalog_Format/ImageSetType.html#//apple_ref/doc/uid/TP40015170-CH25-SW2 -export enum IosIdiom { - Universal = 'universal', - iPhone = 'iphone', - iPad = 'ipad', - Watch = 'watch', - TV = 'tv', -} - -export interface AndroidOutputAssetTemplate extends OutputAssetTemplate { - density: AndroidDensity; -} -export interface AndroidOutputAssetTemplateAdaptiveIcon extends OutputAssetTemplate { - density: AndroidDensity; -} diff --git a/src/project/assets/input-asset.ts b/src/project/assets/input-asset.ts deleted file mode 100644 index c9360c3..0000000 --- a/src/project/assets/input-asset.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { basename, extname } from 'path'; -import sharp from 'sharp'; - -import type { AssetGenerator } from './asset-generator'; -import type { AssetKind, Platform } from './asset-types'; -import { Format } from './asset-types'; -import type { OutputAsset } from './output-asset'; -import type { MobileProject } from '../project'; - -/** - * An instance of an asset that we will use to generate - * a number of output assets. - */ -export class InputAsset { - private filename: string; - public width?: number; - public height?: number; - - private _sharp: sharp.Sharp | null = null; - - constructor( - public path: string, - public kind: AssetKind, - public platform: Platform, - ) { - this.filename = basename(path); - } - - pipeline(): sharp.Sharp | undefined { - return this._sharp?.clone(); - } - - format(): Format.Jpeg | Format.Png | Format.Svg | Format.Unknown { - const ext = extname(this.filename); - - switch (ext) { - case '.png': - return Format.Png; - case '.jpg': - case '.jpeg': - return Format.Jpeg; - case '.svg': - return Format.Svg; - } - - return Format.Unknown; - } - - async load(): Promise { - this._sharp = await sharp(this.path); - - const metadata = await this._sharp.metadata(); - this.width = metadata.width; - this.height = metadata.height; - } - - async generate(strategy: AssetGenerator, project: MobileProject): Promise { - return strategy.generate(this, project); - } -} diff --git a/src/project/assets/ios/generator.ts b/src/project/assets/ios/generator.ts deleted file mode 100644 index 86fa397..0000000 --- a/src/project/assets/ios/generator.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { readFile, rmSync, writeFile } from '@ionic/utils-fs'; -import { join } from 'path'; -import sharp from 'sharp'; - -import type { AssetGeneratorOptions } from '../asset-generator'; -import { AssetGenerator } from '../asset-generator'; -import type { IosOutputAssetTemplate } from '../asset-types'; -import { AssetKind, Format, IosIdiom, Platform } from '../asset-types'; -import type { InputAsset } from '../input-asset'; -import { OutputAsset } from '../output-asset'; -import type { MobileProject } from '../../project'; - -export const IOS_APP_ICON_SET_NAME = 'AppIcon'; -export const IOS_APP_ICON_SET_PATH = `App/Assets.xcassets/${IOS_APP_ICON_SET_NAME}.appiconset`; - -export const IOS_1024_ICON: IosOutputAssetTemplate = { - platform: Platform.Ios, - idiom: IosIdiom.Universal, - kind: AssetKind.Icon, - name: 'AppIcon-512@2x.png', - format: Format.Png, - width: 1024, - height: 1024, -}; -export class IosAssetGenerator extends AssetGenerator { - constructor(options: AssetGeneratorOptions = {}) { - super(options); - } - - async generate(asset: InputAsset, project: MobileProject): Promise { - const iosDir = project.config.ios?.path; - await asset.load() - - if (!iosDir) { - throw new Error('No ios project found'); - } - - if (asset.platform !== Platform.Any && asset.platform !== Platform.Ios) { - return []; - } - - switch (asset.kind) { - case AssetKind.Logo: - case AssetKind.LogoDark: - return this.generateFromLogo(asset, project); - case AssetKind.Icon: - return this.generateIcons(asset, project, [IOS_1024_ICON]); - } - - return []; - } - - private async generateFromLogo(asset: InputAsset, project: MobileProject): Promise { - const pipe = asset.pipeline(); - - if (!pipe) { - throw new Error('Sharp instance not created'); - } - - // Generate logos - let logos: OutputAsset[] = []; - if (asset.kind === AssetKind.Logo) { - logos = await this.generateIcons(asset, project, [IOS_1024_ICON]); - } - - return [...logos]; - } - - private async generateIcons( - asset: InputAsset, - project: MobileProject, - icons: IosOutputAssetTemplate[], - ): Promise { - const pipe = asset.pipeline(); - - if (!pipe) { - throw new Error('Sharp instance not created'); - } - - const iosDir = project.config.ios!.path!; - const lightDefaultBackground = '#ffffff'; - const generated = await Promise.all( - icons.map(async (icon) => { - const dest = join(iosDir, IOS_APP_ICON_SET_PATH, icon.name); - - const outputInfo = await pipe - .resize(icon.width, icon.height) - .png() - .flatten({ background: this.options.iconBackgroundColor ?? lightDefaultBackground }) - .toFile(dest); - - return new OutputAsset( - icon, - asset, - project, - { - [icon.name]: dest, - }, - { - [icon.name]: outputInfo, - }, - ); - }), - ); - - await this.updateIconsContentsJson(generated, project); - - return generated; - } - - private async updateIconsContentsJson(generated: OutputAsset[], project: MobileProject) { - const assetsPath = join(project.config.ios!.path!, IOS_APP_ICON_SET_PATH); - const contentsJsonPath = join(assetsPath, 'Contents.json'); - const json = await readFile(contentsJsonPath, { encoding: 'utf-8' }); - - const parsed = JSON.parse(json); - - const withoutMissing = []; - for (const g of generated) { - const width = g.template.width; - const height = g.template.height; - - parsed.images.map((i: any) => { - if (i.filename !== (g.template as IosOutputAssetTemplate).name) { - rmSync(join(assetsPath, i.filename)); - } - }); - - withoutMissing.push({ - idiom: (g.template as IosOutputAssetTemplate).idiom, - size: `${width}x${height}`, - filename: (g.template as IosOutputAssetTemplate).name, - platform: Platform.Ios, - }); - } - - parsed.images = withoutMissing; - - await writeFile(contentsJsonPath, JSON.stringify(parsed, null, 2)); - } -} diff --git a/src/project/assets/output-asset.ts b/src/project/assets/output-asset.ts deleted file mode 100644 index d4ef1f4..0000000 --- a/src/project/assets/output-asset.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { OutputInfo } from 'sharp'; - -import type { OutputAssetTemplate } from './asset-types'; -import type { InputAsset } from './input-asset'; -import type { MobileProject } from '../project'; - -/** - * An instance of a generated asset - */ -export class OutputAsset { - constructor( - public template: OutputAssetTemplateType, - public asset: InputAsset, - public project: MobileProject, - public destFilenames: { [name: string]: string }, - public outputInfoMap: { [name: string]: OutputInfo }, - ) {} - - getDestFilename(assetName: string): string { - return this.destFilenames[assetName]; - } - - getOutputInfo(assetName: string): OutputInfo { - return this.outputInfoMap[assetName]; - } -} diff --git a/src/project/declarations.d.ts b/src/project/declarations.d.ts index aa7f788..457f9b0 100644 --- a/src/project/declarations.d.ts +++ b/src/project/declarations.d.ts @@ -2,3 +2,8 @@ declare module "xcode" { var x: any; export = x; } + +declare module "xcode/lib/pbxFile" { + var x: any; + export = x; +} diff --git a/src/project/icons/adaptive-icon.ts b/src/project/icons/adaptive-icon.ts new file mode 100644 index 0000000..3e81065 --- /dev/null +++ b/src/project/icons/adaptive-icon.ts @@ -0,0 +1,129 @@ +import { mkdirp, pathExists, unlink, writeFile } from '@ionic/utils-fs'; +import { join } from 'path'; + +import type { XmlFile } from '../xml'; +import type { MobileProject } from '../project'; + +/** The layers an `` can declare. Android treats all of them as optional. */ +export type AdaptiveIconLayer = 'foreground' | 'background' | 'monochrome'; + +const ADAPTIVE_ICON_DIR = 'mipmap-anydpi-v26'; + +// Both descriptors get the same content. ic_launcher_round.xml is only read on API 25, and +// adaptive icons need API 26, but the template ships it, so keep the two in sync. +const DESCRIPTOR_FILES = ['ic_launcher.xml', 'ic_launcher_round.xml']; + +const EMPTY = ` + +`; + +/** + * The project's `mipmap-anydpi-v26/ic_launcher.xml` and `ic_launcher_round.xml` descriptors. + * + * Each write replaces a whole layer element, so layers written by something else survive. A + * wrapper such as 0.5.0's `` is replaced + * outright, rather than having its drawable swapped inside the inset. + */ +export class AdaptiveIconDescriptor { + private constructor(private readonly files: XmlFile[]) {} + + /** Open both descriptors, creating either if it is missing. */ + static async open(project: MobileProject): Promise { + const dir = join(resRoot(project), ADAPTIVE_ICON_DIR); + + if (!(await pathExists(dir))) { + await mkdirp(dir); + } + + const files: XmlFile[] = []; + + for (const name of DESCRIPTOR_FILES) { + const path = join(dir, name); + + // XmlFile.load() reads from disk, so the file has to exist before it can be opened. + if (!(await pathExists(path))) { + await writeFile(path, EMPTY); + } + + files.push( + await openXml(project, join(ADAPTIVE_ICON_DIR, name), 'adaptive-icon'), + ); + } + + return new AdaptiveIconDescriptor(files); + } + + /** Delete both descriptors. API 26+ then falls back to the legacy launcher PNGs. */ + static async remove(project: MobileProject): Promise { + for (const name of DESCRIPTOR_FILES) { + const path = join(resRoot(project), ADAPTIVE_ICON_DIR, name); + + // Close it in the VFS first, or the next commit writes the in-memory copy back to disk. + const open = project.vfs.get(path); + if (open) { + project.vfs.close(open); + } + + if (await pathExists(path)) { + await unlink(path); + } + } + } + + setLayer(layer: AdaptiveIconLayer, drawable: string): void { + const fragment = `<${layer} android:drawable="${drawable}"/>`; + + for (const file of this.files) { + const target = `adaptive-icon/${layer}`; + + if (file.find(target)?.length) { + file.replaceFragment(target, fragment); + } else { + file.injectFragment('adaptive-icon', fragment); + } + } + } +} + +/** Absolute path to the project's `res` directory. */ +export function resRoot(project: MobileProject): string { + const root = project.android?.getResourcesRoot(); + + if (!root) { + throw new Error('No android project found'); + } + + return root; +} + +/** + * Open a resource XML file, failing if its root element isn't `expectedRoot`. + * + * `XmlFile.load()` never rejects. A parse failure is logged and leaves an empty document, and an + * empty file parses as ``. In both cases every xpath matches nothing, so edits are + * dropped while the caller is told the write succeeded. Checking the root turns that into an + * error instead. + */ +export async function openXml( + project: MobileProject, + relativePath: string, + expectedRoot: string, +): Promise { + const file = project.android?.getResourceXmlFile(relativePath); + + if (!file) { + throw new Error(`No android project found; cannot open ${relativePath}`); + } + + await file.load(); + + const root = file.getDocumentElement()?.nodeName; + + if (root !== expectedRoot) { + throw new Error( + `${relativePath} is not a <${expectedRoot}> document (found ${root ? `<${root}>` : 'nothing parseable'})`, + ); + } + + return file; +} diff --git a/src/project/icons/android.ts b/src/project/icons/android.ts new file mode 100644 index 0000000..0229862 --- /dev/null +++ b/src/project/icons/android.ts @@ -0,0 +1,342 @@ +import { pathExists, unlink, writeFile } from '@ionic/utils-fs'; + +import { assertParentDirs } from '../util/fs'; +import { join } from 'path'; +import sharp from 'sharp'; + +import type { AdaptiveIconLayer } from './adaptive-icon'; +import { AdaptiveIconDescriptor, openXml, resRoot } from './adaptive-icon'; +import type { MobileProject } from '../project'; + +export type { AdaptiveIconLayer }; + +/** + * How a source image is fitted onto the 108dp layer canvas. + * + * - `as-is`: an authored 108dp layer that already carries its own padding. + * - `viewport`: full-bleed artwork, scaled down to the area the mask can show. + * + * `viewport` doesn't use Google's 66dp safe zone. That zone sizes a bare mark to survive a + * circular mask, and on full-bleed artwork it leaves a ring of background showing. + * https://developer.android.com/develop/ui/views/launch/icon_design_adaptive + */ +export type LayerFit = 'as-is' | 'viewport'; + +const LEGACY_ICON_SIZES = { + ldpi: 36, + mdpi: 48, + hdpi: 72, + xhdpi: 96, + xxhdpi: 144, + xxxhdpi: 192, +} as const; + +// The 108dp layer canvas at each density. No ldpi, since the template ships no such folder. +const ADAPTIVE_LAYER_SIZES = { + mdpi: 108, + hdpi: 162, + xhdpi: 216, + xxhdpi: 324, + xxxhdpi: 432, +} as const; + +/** + * The mask draws the central 72dp of the 108dp canvas but can expose up to 74.25dp, so artwork + * is scaled to bleed a little past that, to 76dp. + */ +const VIEWPORT_SCALE = 76 / 108; + +// sharp defaults to `cover`, which center-crops anything that isn't already square and cuts the +// ends off a wordmark. Fit inside instead. +const CONTAIN = { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, +} as const; + +const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 } as const; + +const LAYER_DRAWABLE: Record = { + foreground: 'ic_launcher_foreground', + background: 'ic_launcher_background', + monochrome: 'ic_launcher_monochrome', +}; + +const BACKGROUND_COLOR_RESOURCE = 'ic_launcher_background'; +const BACKGROUND_COLOR_FILE = join('values', 'ic_launcher_background.xml'); + +const mipmap = (project: MobileProject, density: string, file: string) => + join(resRoot(project), `mipmap-${density}`, file); + +/** + * Generate `mipmap-/ic_launcher.png` and `ic_launcher_round.png`. + * + * These are only read below API 26, and they aren't interchangeable with the adaptive icon, so + * a caller setting adaptive layers needs them as well. + */ +export async function generateLegacyIcons( + source: string, + project: MobileProject, +): Promise { + const written = await Promise.all( + Object.entries(LEGACY_ICON_SIZES).flatMap(([density, size]) => [ + writeLegacyIcon(source, project, density, size), + writeLegacyRoundIcon(source, project, density, size), + ]), + ); + + await commit(project); + + return written; +} + +/** Point the adaptive icon's foreground at an image. */ +export async function setAdaptiveIconForeground( + source: string, + project: MobileProject, + fit: LayerFit = 'as-is', +): Promise { + return setImageLayer('foreground', source, project, fit); +} + +/** Point the adaptive icon's background at an image, replacing any background color. */ +export async function setAdaptiveIconBackground( + source: string, + project: MobileProject, + fit: LayerFit = 'as-is', +): Promise { + return setImageLayer('background', source, project, fit); +} + +/** + * Point the adaptive icon's monochrome layer at an image. + * + * Only read when the user enables themed icons (API 33+). The system tints it, so only the + * image's alpha channel has any effect, not its colors. + */ +export async function setAdaptiveIconMonochrome( + source: string, + project: MobileProject, + fit: LayerFit = 'as-is', +): Promise { + return setImageLayer('monochrome', source, project, fit); +} + +/** + * Make the adaptive icon's background a solid color, replacing any background image. + * + * @param color a hex color, e.g. `#FF5733`. Written verbatim and not validated here. + */ +export async function setAdaptiveIconBackgroundColor( + color: string, + project: MobileProject, +): Promise { + await writeBackgroundColor(project, color); + + const descriptor = await AdaptiveIconDescriptor.open(project); + descriptor.setLayer('background', `@color/${BACKGROUND_COLOR_RESOURCE}`); + + await commit(project); +} + +/** + * Remove both descriptors and the layer images at each density this module writes. Layer images + * left in other density buckets by older versions, and the background color resource, are left + * alone. + * + * The descriptors are deleted rather than emptied, since an `` with no children is + * valid and renders nothing, while a missing one falls back to the legacy PNGs. + */ +export async function clearAdaptiveIcon(project: MobileProject): Promise { + await AdaptiveIconDescriptor.remove(project); + + await Promise.all( + Object.keys(ADAPTIVE_LAYER_SIZES).flatMap(density => + Object.values(LAYER_DRAWABLE).map(async drawable => { + const path = mipmap(project, density, `${drawable}.png`); + + if (await pathExists(path)) { + await unlink(path); + } + }), + ), + ); +} + +async function setImageLayer( + layer: AdaptiveIconLayer, + source: string, + project: MobileProject, + fit: LayerFit, +): Promise { + const drawable = LAYER_DRAWABLE[layer]; + + const written = await Promise.all( + Object.entries(ADAPTIVE_LAYER_SIZES).map(async ([density, size]) => { + const dest = mipmap(project, density, `${drawable}.png`); + await assertParentDirs(dest); + await writeLayerImage(source, size, fit, dest); + + return dest; + }), + ); + + const descriptor = await AdaptiveIconDescriptor.open(project); + descriptor.setLayer(layer, `@mipmap/${drawable}`); + + await commit(project); + + return written; +} + +async function writeLayerImage( + source: string, + size: number, + fit: LayerFit, + dest: string, +): Promise { + if (fit === 'as-is') { + await sharp(source).resize(size, size, CONTAIN).png().toFile(dest); + return; + } + + const inner = Math.round(size * VIEWPORT_SCALE); + const before = Math.floor((size - inner) / 2); + const after = size - inner - before; + + // resize and extend need separate pipelines, per + // https://github.com/lovell/sharp/issues/2378#issuecomment-864132578 + const resized = await sharp(source) + .resize(inner, inner, CONTAIN) + .png() + .toBuffer(); + + await sharp(resized) + .extend({ + top: before, + bottom: after, + left: before, + right: after, + background: TRANSPARENT, + }) + .png() + .toFile(dest); +} + +async function writeLegacyIcon( + source: string, + project: MobileProject, + density: string, + size: number, +): Promise { + const dest = mipmap(project, density, 'ic_launcher.png'); + await assertParentDirs(dest); + + // Pre-adaptive launchers drew these bitmaps unmasked, so the margin has to be baked into the + // image. size / 12 keeps it at the template's ~83% artwork at every density. + const padding = Math.round(size / 12); + + // resize and extend need separate pipelines, per + // https://github.com/lovell/sharp/issues/2378#issuecomment-864132578 + // Every intermediate is encoded as PNG. Without that the buffer keeps the source's format, and + // a JPEG has no alpha channel for the letterbox padding, which then comes out black. + const resized = await sharp(source) + .resize(size, size, CONTAIN) + .png() + .toBuffer(); + const padded = await sharp(resized) + .resize( + Math.max(0, size - padding * 2), + Math.max(0, size - padding * 2), + CONTAIN, + ) + .extend({ + top: padding, + bottom: padding, + left: padding, + right: padding, + background: TRANSPARENT, + }) + .png() + .toBuffer(); + + await sharp(padded).png().toFile(dest); + + return dest; +} + +async function writeLegacyRoundIcon( + source: string, + project: MobileProject, + density: string, + size: number, +): Promise { + const dest = mipmap(project, density, 'ic_launcher_round.png'); + await assertParentDirs(dest); + + const circle = ``; + + // Encoded as PNG for the same reason as the legacy icon above, and it matters more here. + // `dest-in` composites the circle into the alpha channel, so without one the mask does nothing + // and the icon stays square. + const resized = await sharp(source) + .resize(size, size, CONTAIN) + .png() + .toBuffer(); + const masked = await sharp(resized) + .composite([{ input: Buffer.from(circle), blend: 'dest-in' }]) + .png() + .toBuffer(); + + await sharp(masked).png().toFile(dest); + + return dest; +} + +/** + * Point `@color/ic_launcher_background` at a color, creating the file if needed. + * + * This is the only path that writes an `@color` reference into the descriptor, and it always + * writes the color node too, so the descriptor can't end up referencing a color that doesn't + * exist. aapt2 fails the build on that. + */ +async function writeBackgroundColor( + project: MobileProject, + color: string, +): Promise { + const dest = join(resRoot(project), BACKGROUND_COLOR_FILE); + const fragment = `${color}`; + + if (!(await pathExists(dest))) { + await assertParentDirs(dest); + await writeFile( + dest, + `\n\n ${fragment}\n\n`, + ); + return; + } + + // The template ships this file and it may hold other colors, so replace just the one node. + const file = await openXml(project, BACKGROUND_COLOR_FILE, 'resources'); + const target = `resources/color[@name='${BACKGROUND_COLOR_RESOURCE}']`; + + if (file.find(target)?.length) { + file.replaceFragment(target, fragment); + } else { + file.injectFragment('resources', fragment); + } +} + +/** + * Point the manifest at the launcher icons and flush the project. Every exported function ends + * here, so callers never have to commit themselves. + */ +async function commit(project: MobileProject): Promise { + project.android?.getAndroidManifest()?.setAttrs('manifest/application', { + 'android:icon': '@mipmap/ic_launcher', + 'android:roundIcon': '@mipmap/ic_launcher_round', + }); + + await project.commit(); +} diff --git a/src/project/icons/index.ts b/src/project/icons/index.ts new file mode 100644 index 0000000..6a62d21 --- /dev/null +++ b/src/project/icons/index.ts @@ -0,0 +1,16 @@ +import * as androidIcons from './android'; +import * as iosIcons from './ios'; + +export type { LayerFit, AdaptiveIconLayer } from './android'; + +/** + * Android launcher icons: the legacy bitmaps used below API 26, and the adaptive icon layers. + * Each function writes its files, updates the manifest, and commits. + */ +export { androidIcons }; + +/** + * iOS app icons: the flat app icon set, and the layered Icon Composer `.icon` bundle. + * Each function writes its files, registers them with the Xcode project, and commits. + */ +export { iosIcons }; diff --git a/src/project/icons/ios.ts b/src/project/icons/ios.ts new file mode 100644 index 0000000..4df5382 --- /dev/null +++ b/src/project/icons/ios.ts @@ -0,0 +1,219 @@ +import { + copy, + pathExists, + readFile, + remove, + rmSync, + stat, + writeFile, +} from '@ionic/utils-fs'; +import { join } from 'path'; +import sharp from 'sharp'; + +import { assertParentDirs } from '../util/fs'; +import type { MobileProject } from '../project'; + +export const IOS_APP_ICON_SET_NAME = 'AppIcon'; +export const IOS_APP_ICON_SET_PATH = `App/Assets.xcassets/${IOS_APP_ICON_SET_NAME}.appiconset`; + +/** + * An Icon Composer bundle, installed beside the asset catalog. It deliberately shares the app + * icon set's name, because a target declares a single `ASSETCATALOG_COMPILER_APPICON_NAME`. Only + * one of the two can exist, so setting either one clears the other. + */ +export const IOS_LAYERED_APP_ICON_PATH = `App/${IOS_APP_ICON_SET_NAME}.icon`; + +/** The single entry an iOS app icon set needs; Xcode derives every other size from it. */ +const APP_ICON = { + name: 'AppIcon-512@2x.png', + idiom: 'universal', + size: 1024, +} as const; + +const DEFAULT_BACKGROUND_COLOR = '#ffffff'; + +// `xcode` only recognizes a handful of extensions and types the rest as `unknown`. A `.icon` +// typed that way is copied in as an opaque directory and never reaches actool, giving no icon +// and no build error. +const LAYERED_APP_ICON_FILE_TYPE = 'folder.iconcomposer.icon'; + +const APP_ICON_NAME_BUILD_SETTING = 'ASSETCATALOG_COMPILER_APPICON_NAME'; + +/** + * Set the app icon from a single source image, written as an app icon set. Clears any layered + * app icon. + * + * @param source path to the app icon + * @param backgroundColor the color transparency is flattened onto. The App Store rejects icons + * with an alpha channel, so this is baked into the pixels instead of kept as a separate layer. + * @returns the files written + */ +export async function setAppIcon( + source: string, + project: MobileProject, + backgroundColor = DEFAULT_BACKGROUND_COLOR, +): Promise { + const assetsPath = join(iosRoot(project), IOS_APP_ICON_SET_PATH); + const dest = join(assetsPath, APP_ICON.name); + + await removeLayeredAppIcon(project); + + await assertParentDirs(dest); + + await sharp(source) + .resize(APP_ICON.size, APP_ICON.size) + .png() + .flatten({ background: backgroundColor }) + .toFile(dest); + + await updateContentsJson(assetsPath); + + await commit(project); + + return [dest]; +} + +/** + * Set the app icon from an Icon Composer `.icon` bundle, the only way to provide a dark or + * tinted appearance. The bundle is copied in verbatim; nothing here writes `icon.json`. + * + * Clears the app icon set. actool back-deploys flattened icons from the bundle, so older iOS + * versions stay covered, but building needs Xcode 26+. + * + * @param source path to the `.icon` bundle + * @returns the installed bundle, which Xcode treats as a single file + */ +export async function setLayeredAppIcon( + source: string, + project: MobileProject, +): Promise { + await assertLayeredAppIconBundle(source); + + const dest = join(iosRoot(project), IOS_LAYERED_APP_ICON_PATH); + + // Remove first rather than copying over the top, or a layer dropped since the last install + // would be left behind in Assets/ and still compiled. + await remove(dest); + await copy(source, dest); + + await remove(join(iosRoot(project), IOS_APP_ICON_SET_PATH)); + + await project.ios?.addResourceFile( + IOS_LAYERED_APP_ICON_PATH, + LAYERED_APP_ICON_FILE_TYPE, + ); + + await commit(project); + + return [dest]; +} + +/** + * Delete the bundle and remove it from the Xcode project. Unregistering goes through the VFS, so + * the caller has to commit afterwards. `clearAdaptiveIcon` only unlinks and needs no commit. + */ +async function removeLayeredAppIcon(project: MobileProject): Promise { + await project.ios?.removeResourceFile( + IOS_LAYERED_APP_ICON_PATH, + LAYERED_APP_ICON_FILE_TYPE, + ); + + await remove(join(iosRoot(project), IOS_LAYERED_APP_ICON_PATH)); +} + +/** + * Check the source really is a `.icon` bundle before anything is copied. The check is shallow, + * since Icon Composer authors these. Without it, a wrong path installs cleanly and only shows up + * later as an app with no icon. + */ +async function assertLayeredAppIconBundle(source: string): Promise { + if (!(await pathExists(source))) { + throw new Error(`No .icon bundle at ${source}`); + } + + if (!(await stat(source)).isDirectory()) { + throw new Error( + `${source} is not a .icon bundle; a .icon is a directory, not a file`, + ); + } + + const manifest = join(source, 'icon.json'); + + if (!(await pathExists(manifest))) { + throw new Error(`${source} is not a .icon bundle; it has no icon.json`); + } + + let parsed: any; + + try { + parsed = JSON.parse(await readFile(manifest, { encoding: 'utf-8' })); + } catch (e) { + throw new Error(`${manifest} is not valid JSON: ${(e as Error).message}`); + } + + if (!parsed?.groups || !parsed?.['supported-platforms']) { + throw new Error( + `${manifest} is missing "groups" or "supported-platforms"; Icon Composer requires both`, + ); + } +} + +/** Absolute path to the iOS project directory. */ +function iosRoot(project: MobileProject): string { + const root = project.config.ios?.path; + + if (!root) { + throw new Error('No ios project found'); + } + + return root; +} + +async function updateContentsJson(assetsPath: string): Promise { + const contentsJsonPath = join(assetsPath, 'Contents.json'); + + // Setting a layered app icon deletes the set, so the template's copy may be gone. + const parsed = (await pathExists(contentsJsonPath)) + ? JSON.parse(await readFile(contentsJsonPath, { encoding: 'utf-8' })) + : { images: [], info: { version: 1, author: 'xcode' } }; + + // NOTE: this drops every other image the catalog listed and deletes the files. Dark and tinted + // variants would have to coexist here rather than replace each other, so for those use + // `setLayeredAppIcon`. + for (const image of parsed.images ?? []) { + if (image.filename && image.filename !== APP_ICON.name) { + rmSync(join(assetsPath, image.filename), { force: true }); + } + } + + parsed.images = [ + { + idiom: APP_ICON.idiom, + size: `${APP_ICON.size}x${APP_ICON.size}`, + filename: APP_ICON.name, + platform: 'ios', + }, + ]; + + await writeFile(contentsJsonPath, JSON.stringify(parsed, null, 2)); +} + +/** + * Point the target at the app icon and flush the project. Every exported function ends here, so + * callers never have to commit themselves. + * + * Setting the build property is usually a no-op, since the Capacitor template already sets it, + * but not every project does. + */ +async function commit(project: MobileProject): Promise { + if (project.ios?.getAppTarget()) { + project.ios.setBuildProperty( + null, + null, + APP_ICON_NAME_BUILD_SETTING, + IOS_APP_ICON_SET_NAME, + ); + } + + await project.commit(); +} diff --git a/src/project/index.ts b/src/project/index.ts index d013cd2..46fb069 100644 --- a/src/project/index.ts +++ b/src/project/index.ts @@ -11,7 +11,4 @@ export * from './strings'; export * from './xcconfig'; export * from './vfs'; export * from './util/plist'; -export * from "./assets/ios/generator"; -export * from "./assets/android/generator" -export * from "./assets/input-asset"; -export * from "./assets/asset-types"; +export * from "./icons"; diff --git a/src/project/ios/project.ts b/src/project/ios/project.ts index a027314..11c3533 100644 --- a/src/project/ios/project.ts +++ b/src/project/ios/project.ts @@ -10,6 +10,7 @@ import { PlistFile } from '../plist'; import { PlatformProject } from '../platform-project'; import { Logger } from '../logger'; import { assertParentDirs } from '../util/fs'; +import PbxFile from 'xcode/lib/pbxFile'; const defaultEntitlementsPlist = ` @@ -538,22 +539,123 @@ export class IosProject extends PlatformProject { * the project tree) if the app target can't be found. */ async addFile(path: string): Promise { + const { relativePath, groupKey } = this.resolvePbxGroup(path); + + this.pbxProject?.addSourceFile(relativePath, {}, groupKey); + } + + /** + * Add a file to the app target's Resources build phase. + * + * `pbxProject.addResourceFile()` isn't used because it dereferences `pbxGroupByName('Resources')` + * without a null check and the Capacitor template has no such group, so it throws. The + * primitives it wraps are called directly instead. + * + * @param path project-relative, e.g. `App/AppIcon.icon` + * @param lastKnownFileType the pbx file type, since `xcode` infers `unknown` for most extensions + */ + async addResourceFile(path: string, lastKnownFileType?: string): Promise { + const pbx = this.pbxProject; + + if (!pbx) { + return; + } + + const { relativePath, groupKey, targetId } = this.resolvePbxGroup(path); + const file = new PbxFile(relativePath, { lastKnownFileType, target: targetId }); + file.target = targetId; + + // Always remove and re-add, rather than bailing out when a reference already exists. That + // keeps repeat calls idempotent, and it repairs a reference that was never added to the + // build phase. One dragged into Xcode with "Add to targets" unchecked passes a dedupe check + // but never reaches actool. + this.unregisterResourceFile(file, groupKey); + + file.uuid = pbx.generateUuid(); + file.fileRef = pbx.generateUuid(); + + pbx.addToPbxBuildFileSection(file); + pbx.addToPbxResourcesBuildPhase(file); + pbx.addToPbxFileReferenceSection(file); + pbx.addToPbxGroup(file, groupKey); + + // PbxFile always sets the same keys, and the writer only drops empty ones under + // `omitEmptyValues`, which would change how the whole project serializes. Prune this one + // object instead, or the pbxproj ends up with literal `fileEncoding = undefined;` lines. + const ref = pbx.pbxFileReferenceSection()[file.fileRef]; + + for (const key of Object.keys(ref)) { + if (ref[key] === undefined) { + delete ref[key]; + } + } + } + + /** Remove a file added by {@link addResourceFile}. A no-op if it was never added. */ + async removeResourceFile(path: string, lastKnownFileType?: string): Promise { + const pbx = this.pbxProject; + + if (!pbx) { + return; + } + + const { relativePath, groupKey, targetId } = this.resolvePbxGroup(path); + const file = new PbxFile(relativePath, { lastKnownFileType, target: targetId }); + + file.target = targetId; + + this.unregisterResourceFile(file, groupKey); + } + + /** + * Remove a file from the build file section, the reference section, its group and the Resources + * build phase. Removing the reference copies the existing uuid onto `file`, so the later steps + * match it. Anything already absent is skipped. + */ + private unregisterResourceFile(file: any, groupKey: string | undefined): void { + const pbx = this.pbxProject; + + if (!pbx) { + return; + } + + pbx.removeFromPbxBuildFileSection(file); + pbx.removeFromPbxFileReferenceSection(file); + pbx.removeFromPbxGroup(file, groupKey); + pbx.removeFromPbxResourcesBuildPhase(file); + } + + /** + * Work out where a project-relative path belongs in the pbx tree. A path starting with the + * target directory goes in the app target's group, anything else in the unnamed group at the + * root. The returned path is relative to that group, which is what a file reference stores. + */ + private resolvePbxGroup(path: string): { + relativePath: string; + groupKey: string | undefined; + targetId: string | undefined; + } { const groups = this.pbxProject?.hash.project.objects['PBXGroup'] ?? []; + const emptyGroup = Object.entries(groups).find(([key, value]: [string, any]) => { return value.isa === 'PBXGroup' && typeof value.name === 'undefined' }); - const appTarget = this.getAppTargetName(); + // Look the target up once; getTargets() rebuilds every target and its build configurations. + const target = this.getAppTarget(); + const appTarget = target?.name; const appGroup = Object.entries(groups).find(([key, value]: [string, any]) => { return value.isa === 'PBXGroup' && (value.name === appTarget || value.path === appTarget); }); const pathSplit = path.split(sep); - if (pathSplit[0] === appTarget && appGroup) { - this.pbxProject?.addSourceFile(pathSplit.slice(1).join(sep), {}, appGroup?.[0]); - } else { - this.pbxProject?.addSourceFile(path, {}, emptyGroup?.[0]); - } + const inAppGroup = pathSplit[0] === appTarget && !!appGroup; + + return { + relativePath: inAppGroup ? pathSplit.slice(1).join(sep) : path, + groupKey: inAppGroup ? appGroup?.[0] : emptyGroup?.[0], + targetId: target?.id, + }; } private async assertEntitlementsFile(targetName: IosTargetName, buildName: IosBuildName | null) { diff --git a/src/project/xml.ts b/src/project/xml.ts index 23b3f5d..13102e7 100644 --- a/src/project/xml.ts +++ b/src/project/xml.ts @@ -205,12 +205,16 @@ export class XmlFile extends VFSStorable { const index = Array.prototype.indexOf.call(n.parentNode?.childNodes, n); if (index >= 0) { const parent = n.parentNode; - const parsed = parseXmlString(fragment); + // Parse once per match, because insertBefore moves nodes rather than copying them, so + // a single parsed fragment would be emptied into the first match. + const replacements = parseXmlFragment(fragment); + parent!.removeChild(n); - parent!.insertBefore( - parsed.documentElement, - parent?.childNodes[index] ?? null, - ); + + // Read the anchor after removing the node. childNodes[index] is then whatever followed + // it, or undefined if it was last. + const anchor = parent?.childNodes[index] ?? null; + replacements.forEach(r => parent!.insertBefore(r, anchor)); } });