Skip to content

Commit

Permalink
validate icon PNGs before running build
Browse files Browse the repository at this point in the history
  • Loading branch information
dsokal committed Oct 26, 2022
1 parent eee54af commit 78ba92d
Show file tree
Hide file tree
Showing 15 changed files with 768 additions and 13 deletions.
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
* text eol=lf
*.png binary
*.jpg binary

**/yarn.lock linguist-generated
4 changes: 4 additions & 0 deletions packages/eas-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"nullthrows": "1.1.1",
"ora": "5.1.0",
"pkg-dir": "4.2.0",
"pngjs": "6.0.0",
"promise-limit": "2.7.0",
"promise-retry": "2.0.1",
"prompts": "2.4.2",
Expand All @@ -87,11 +88,13 @@
"@types/cli-progress": "3.11.0",
"@types/dateformat": "3.0.1",
"@types/envinfo": "7.8.1",
"@types/express": "4.17.14",
"@types/fs-extra": "9.0.13",
"@types/getenv": "^1.0.0",
"@types/mime": "2.0.3",
"@types/node-fetch": "2.6.2",
"@types/node-forge": "1.0.4",
"@types/pngjs": "6.0.1",
"@types/promise-retry": "1.1.3",
"@types/prompts": "2.0.14",
"@types/semver": "7.3.10",
Expand All @@ -101,6 +104,7 @@
"@types/wrap-ansi": "3.0.0",
"axios": "0.27.2",
"eslint-plugin-graphql": "4.0.0",
"express": "4.18.2",
"memfs": "3.4.7",
"mockdate": "3.0.5",
"nock": "13.2.9",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
120 changes: 120 additions & 0 deletions packages/eas-cli/src/build/__tests__/validate-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { ExitError } from '@oclif/core/lib/errors';
import path from 'path';
import { instance, mock, when } from 'ts-mockito';

import { CommonContext } from '../context';
import { validateIconForManagedProjectAsync } from '../validate';

const fixturesPath = path.join(__dirname, 'fixtures');

describe(validateIconForManagedProjectAsync, () => {
it('does not validate the icons for generic projects', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
when(ctxMock.workflow).thenReturn(Workflow.GENERIC);
const ctx = instance(ctxMock);
await expect(validateIconForManagedProjectAsync(ctx)).resolves.not.toThrow();
});

describe(Platform.ANDROID, () => {
it('exits if foregroundImage is not a file with .png extension', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.ANDROID);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
android: { adaptiveIcon: { foregroundImage: 'assets/icon.jpg' } },
});
const ctx = instance(ctxMock);

await expect(validateIconForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
});

it('exits if foregroundImage is not a png file', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.ANDROID);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
android: { adaptiveIcon: { foregroundImage: path.join(fixturesPath, 'icon-jpg.png') } },
});
const ctx = instance(ctxMock);

await expect(validateIconForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
});

it('does not throw if foregroundImage is a png file', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.ANDROID);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
android: { adaptiveIcon: { foregroundImage: path.join(fixturesPath, 'icon-alpha.png') } },
});
const ctx = instance(ctxMock);

await expect(validateIconForManagedProjectAsync(ctx)).resolves.not.toThrow();
});
});

describe(Platform.IOS, () => {
it('exits if foregroundImage is not a file with .png extension', async () => {
const ctxMock = mock<CommonContext<Platform.IOS>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.IOS);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
ios: { icon: path.join(fixturesPath, 'assets/icon.jpg') },
});
const ctx = instance(ctxMock);

await expect(validateIconForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
});

it('exits if icon is not a png file', async () => {
const ctxMock = mock<CommonContext<Platform.IOS>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.IOS);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
ios: { icon: path.join(fixturesPath, 'icon-jpg.png') },
});
const ctx = instance(ctxMock);

await expect(validateIconForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
});

it('exits if icon has alpha channel (transparency)', async () => {
const ctxMock = mock<CommonContext<Platform.IOS>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.IOS);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
ios: { icon: path.join(fixturesPath, 'icon-alpha.png') },
});
const ctx = instance(ctxMock);

await expect(validateIconForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
});

it('does not throw if icon is a png file and does not have alpha channel', async () => {
const ctxMock = mock<CommonContext<Platform.IOS>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.IOS);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
ios: { icon: path.join(fixturesPath, 'icon-no-alpha.png') },
});
const ctx = instance(ctxMock);

await expect(validateIconForManagedProjectAsync(ctx)).resolves.not.toThrow();
});
});
});
7 changes: 6 additions & 1 deletion packages/eas-cli/src/build/android/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import {
import { AndroidBuildContext, BuildContext, CommonContext } from '../context';
import { transformMetadata } from '../graphql';
import { logCredentialsSource } from '../utils/credentials';
import { checkGoogleServicesFileAsync, checkNodeEnvVariable } from '../validate';
import {
checkGoogleServicesFileAsync,
checkNodeEnvVariable,
validateIconForManagedProjectAsync,
} from '../validate';
import { transformJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
Expand Down Expand Up @@ -53,6 +57,7 @@ This means that it will most likely produce an AAB and you will not be able to i

checkNodeEnvVariable(ctx);
await checkGoogleServicesFileAsync(ctx);
await validateIconForManagedProjectAsync(ctx);

const gradleContext = await resolveGradleBuildContextAsync(ctx.projectDir, buildProfile);

Expand Down
7 changes: 6 additions & 1 deletion packages/eas-cli/src/build/ios/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/ta
import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommonContext, IosBuildContext } from '../context';
import { transformMetadata } from '../graphql';
import { checkGoogleServicesFileAsync, checkNodeEnvVariable } from '../validate';
import {
checkGoogleServicesFileAsync,
checkNodeEnvVariable,
validateIconForManagedProjectAsync,
} from '../validate';
import { ensureIosCredentialsAsync } from './credentials';
import { transformJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
Expand All @@ -28,6 +32,7 @@ export async function createIosContextAsync(

checkNodeEnvVariable(ctx);
await checkGoogleServicesFileAsync(ctx);
await validateIconForManagedProjectAsync(ctx);

const xcodeBuildContext = await resolveXcodeBuildContextAsync(
{
Expand Down
72 changes: 72 additions & 0 deletions packages/eas-cli/src/build/validate.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { Errors } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';

import Log, { learnMore } from '../log';
import {
ImageNonPngError,
ImageTransparencyError,
ensurePNGIsNotTransparentAsync,
isPNGAsync,
} from '../utils/image';
import { getVcsClient } from '../vcs';
import { CommonContext } from './context';

Expand Down Expand Up @@ -50,3 +58,67 @@ export async function checkGoogleServicesFileAsync<T extends Platform>(
function isInsideDirectory(file: string, directory: string): boolean {
return file.startsWith(directory);
}

export async function validateIconForManagedProjectAsync<T extends Platform>(
ctx: CommonContext<T>
): Promise<void> {
if (ctx.workflow !== Workflow.MANAGED) {
return;
}

if (ctx.platform === Platform.ANDROID) {
await validateAndroidIconAsync(ctx as CommonContext<Platform.ANDROID>);
} else {
await validateIosIconAsync(ctx as CommonContext<Platform.IOS>);
}
}

async function validateAndroidIconAsync(ctx: CommonContext<Platform.ANDROID>): Promise<void> {
if (!ctx.exp.android?.adaptiveIcon?.foregroundImage) {
return;
}

const { foregroundImage } = ctx.exp.android.adaptiveIcon;
if (!foregroundImage.endsWith('.png')) {
Log.error(`The Android Adaptive Icon foreground image must be a PNG file.`);
Log.error(`expo.android.adaptiveIcon.foregroundImage = ${chalk.bold(foregroundImage)}`);
Errors.exit(1);
}

if (!(await isPNGAsync(foregroundImage))) {
Log.error(`The Android Adaptive Icon foreground image must be a PNG file.`);
Log.error(`${chalk.bold(foregroundImage)} is not valid PNG`);
Errors.exit(1);
}
}

async function validateIosIconAsync(ctx: CommonContext<Platform.IOS>): Promise<void> {
const icon = ctx.exp.ios?.icon ?? ctx.exp.icon;
if (!icon) {
return;
}

if (!icon.endsWith('.png')) {
Log.error(`The iOS app icon must be a PNG file.`);
Log.error(`expo${ctx.exp.ios?.icon ? '.ios' : ''}.icon = ${chalk.bold(icon)}`);
Errors.exit(1);
}

try {
await ensurePNGIsNotTransparentAsync(icon);
} catch (err: any) {
if (err instanceof ImageNonPngError) {
Log.error(`The iOS app icon must be a PNG file.`);
Log.error(`expo${ctx.exp.ios?.icon ? '.ios' : ''}.icon = ${chalk.bold(icon)}`);
Errors.exit(1);
} else if (err instanceof ImageTransparencyError) {
Log.error(
`Your iOS app icon can't have transparency if you wish to upload your app to the Apple App Store.`
);
Log.error(learnMore('https://expo.fyi/remove-alpha-channel'));
Errors.exit(1);
} else {
throw err;
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions packages/eas-cli/src/utils/__tests__/image-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import express from 'express';
import http from 'http';
import path from 'path';

import { ImageNonPngError, ImageTransparencyError, ensurePNGIsNotTransparentAsync } from '../image';

const TEST_SERVER_PORT = 2137;

const fixturesPath = path.join(__dirname, 'fixtures');
const transparentPngPath = path.join(fixturesPath, 'icon-alpha.png');
const nonTransparentPngPath = path.join(fixturesPath, 'icon-no-alpha.png');
const jpgPath = path.join(fixturesPath, 'icon.jpg');
const nonTransparentPngURL = `http://127.0.0.1:${TEST_SERVER_PORT}/icon-alpha.png`;

describe(ensurePNGIsNotTransparentAsync, () => {
describe('local paths', () => {
it('throws an error for non-PNG files', async () => {
await expect(ensurePNGIsNotTransparentAsync(jpgPath)).rejects.toThrowError(ImageNonPngError);
});

it('throws an error for transparent PNGs', async () => {
await expect(ensurePNGIsNotTransparentAsync(transparentPngPath)).rejects.toThrowError(
ImageTransparencyError
);
});

it('does not throw for non transparent PNGs', async () => {
await expect(ensurePNGIsNotTransparentAsync(nonTransparentPngPath)).resolves.not.toThrow();
});
});

describe('remote URLs', () => {
let server: http.Server;

beforeAll(async () => {
await new Promise<void>(res => {
const app = express();
app.use(express.static(fixturesPath));
server = app.listen(TEST_SERVER_PORT, () => {
res();
});
});
});

afterAll(async () => {
await new Promise<void>(res => {
server.close(() => {
res();
});
});
});

it('works with remote URLs', async () => {
await expect(ensurePNGIsNotTransparentAsync(nonTransparentPngURL)).rejects.toThrowError(
ImageTransparencyError
);
});
});
});
Loading

0 comments on commit 78ba92d

Please sign in to comment.