Skip to content
Merged
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
2 changes: 1 addition & 1 deletion app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'./plugins/withNotificationSounds.js',
'./plugins/withMediaButtonModule.js',
'./plugins/withInCallAudioModule.js',
['app-icon-badge', appIconBadgeConfig],
['./plugins/with-app-icon-badge.js', appIconBadgeConfig],
],
extra: {
...ClientEnv,
Expand Down
92 changes: 92 additions & 0 deletions plugins/__tests__/with-app-icon-badge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { AppIconBadgeConfig } from 'app-icon-badge/types';

const mockExecFileSync = jest.fn();
const mockExistsSync = jest.fn(() => true);
const mockStatSync = jest.fn(() => ({ size: 100 }));

jest.mock('child_process', () => ({
execFileSync: mockExecFileSync,
}));

jest.mock('fs', () => ({
existsSync: mockExistsSync,
statSync: mockStatSync,
}));

interface TestExpoConfig {
_internal?: {
projectRoot?: string;
};
android?: {
adaptiveIcon?: {
foregroundImage?: string;
};
};
icon?: string;
ios?: {
icon?: string;
};
}

const withAppIconBadge = jest.requireActual('../with-app-icon-badge.js') as (config: TestExpoConfig, options?: AppIconBadgeConfig) => TestExpoConfig;

describe('withAppIconBadge', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('generates all configured icons before rewriting their config paths', () => {
const config: TestExpoConfig = {
_internal: { projectRoot: '/project' },
icon: './assets/icon.png',
ios: { icon: './assets/ios-icon.png' },
android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png' } },
};
const options: AppIconBadgeConfig = {
enabled: true,
badges: [{ type: 'banner', text: 'development' }],
};

const result = withAppIconBadge(config, options);

expect(mockExecFileSync).toHaveBeenCalledTimes(1);
const generatorArguments = mockExecFileSync.mock.calls[0]?.[1] as string[];
const payload = JSON.parse(generatorArguments[1] ?? '{}') as {
jobs: { isAdaptiveIcon: boolean; outputPath: string; sourcePath: string }[];
};
expect(payload.jobs).toEqual([
{
sourcePath: '/project/assets/icon.png',
outputPath: '/project/.expo/app-icon-badge/icon.png',
isAdaptiveIcon: false,
},
{
sourcePath: '/project/assets/ios-icon.png',
outputPath: '/project/.expo/app-icon-badge/ios-icon.png',
isAdaptiveIcon: false,
},
{
sourcePath: '/project/assets/adaptive-icon.png',
outputPath: '/project/.expo/app-icon-badge/foregroundImage.png',
isAdaptiveIcon: true,
},
]);
expect(result.icon).toBe('.expo/app-icon-badge/icon.png');
expect(result.ios?.icon).toBe('.expo/app-icon-badge/ios-icon.png');
expect(result.android?.adaptiveIcon?.foregroundImage).toBe('.expo/app-icon-badge/foregroundImage.png');
});

it('leaves icon paths untouched when badges are disabled', () => {
const config: TestExpoConfig = {
_internal: { projectRoot: '/project' },
icon: './assets/icon.png',
android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png' } },
};

const result = withAppIconBadge(config, { enabled: false, badges: [] });

expect(mockExecFileSync).not.toHaveBeenCalled();
expect(result.icon).toBe('./assets/icon.png');
expect(result.android?.adaptiveIcon?.foregroundImage).toBe('./assets/adaptive-icon.png');
});
});
77 changes: 77 additions & 0 deletions plugins/with-app-icon-badge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const { execFileSync } = require('child_process');
const { existsSync, statSync } = require('fs');
const { resolve } = require('path');

const GENERATED_DIRECTORY = '.expo/app-icon-badge';
const GENERATED_APP_ICON = `${GENERATED_DIRECTORY}/icon.png`;
const GENERATED_IOS_ICON = `${GENERATED_DIRECTORY}/ios-icon.png`;
const GENERATED_ADAPTIVE_ICON = `${GENERATED_DIRECTORY}/foregroundImage.png`;

function assertGeneratedImage(projectRoot, imagePath) {
const absolutePath = resolve(projectRoot, imagePath);
if (!existsSync(absolutePath) || statSync(absolutePath).size === 0) {
throw new Error(`App icon badge generator did not create a valid image at ${imagePath}`);
}
}

function withAppIconBadge(config, options = {}) {
if (options.enabled === false) {
return config;
}

const projectRoot = config._internal?.projectRoot ?? process.cwd();
const jobs = [];

if (config.icon) {
jobs.push({
sourcePath: resolve(projectRoot, config.icon),
outputPath: resolve(projectRoot, GENERATED_APP_ICON),
isAdaptiveIcon: false,
});
}

if (config.ios?.icon) {
jobs.push({
sourcePath: resolve(projectRoot, config.ios.icon),
outputPath: resolve(projectRoot, GENERATED_IOS_ICON),
isAdaptiveIcon: false,
});
}

if (config.android?.adaptiveIcon?.foregroundImage) {
jobs.push({
sourcePath: resolve(projectRoot, config.android.adaptiveIcon.foregroundImage),
outputPath: resolve(projectRoot, GENERATED_ADAPTIVE_ICON),
isAdaptiveIcon: true,
});
}

if (jobs.length === 0) {
return config;
}

const generatorPath = resolve(__dirname, '../scripts/generate-app-icon-badges.js');
execFileSync(process.execPath, [generatorPath, JSON.stringify({ badges: options.badges ?? [], jobs })], {
cwd: projectRoot,
stdio: 'inherit',
});
Comment on lines +54 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Uncaught exception in the execFileSync subprocess call propagates raw child-process errors without context when the generator script fails (non-zero exit, missing file, crash). Wrap the call in try/catch, attach context (projectRoot, job count, target paths), and rethrow a mapped application-level Error preserving the original via cause. Also found in scripts/generate-app-icon-badges.js:41.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File plugins/with-app-icon-badge.js:

Line 54 to 57:

Uncaught exception in the `execFileSync` subprocess call propagates raw child-process errors without context when the generator script fails (non-zero exit, missing file, crash). Wrap the call in try/catch, attach context (projectRoot, job count, target paths), and rethrow a mapped application-level Error preserving the original via cause. Also found in scripts/generate-app-icon-badges.js:41.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


if (config.icon) {
assertGeneratedImage(projectRoot, GENERATED_APP_ICON);
config.icon = GENERATED_APP_ICON;
}

if (config.ios?.icon) {
assertGeneratedImage(projectRoot, GENERATED_IOS_ICON);
config.ios.icon = GENERATED_IOS_ICON;
}

if (config.android?.adaptiveIcon?.foregroundImage) {
assertGeneratedImage(projectRoot, GENERATED_ADAPTIVE_ICON);
config.android.adaptiveIcon.foregroundImage = GENERATED_ADAPTIVE_ICON;
}

return config;
}

module.exports = withAppIconBadge;
76 changes: 76 additions & 0 deletions scripts/generate-app-icon-badges.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const { copyFileSync, mkdirSync, readFileSync, unlinkSync } = require('fs');
const { dirname } = require('path');

const { addBadge } = require('app-icon-badge');
const Jimp = require('jimp');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

jq -e '
  .dependencies.jimp //
  .devDependencies.jimp //
  .optionalDependencies.jimp
' package.json

Repository: Resgrid/IC

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -eu

printf '--- package manager / scripts context ---\n'
jq -r '{type: .type, scripts: .scripts, dependencies, devDependencies, optionalDependencies, packageManager}' package.json 2>/dev/null || true

printf '\n--- jimp references ---\n'
rg -n "require\\(['\"]jimp['\"]\\)|from ['\"]jimp['\"]|jimp" -S --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' .

printf '\n--- lock/package files ---\n'
git ls-files | rg '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|pnpm-workspace\.yaml)$' || true

Repository: Resgrid/IC

Length of output: 239


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- package manager / scripts context ---'
jq -r '{type: .type, scripts: .scripts, dependencies, devDependencies, optionalDependencies, packageManager}' package.json 2>/dev/null || true

printf '%s\n' ''
printf '%s\n' '--- jimp references ---'
rg -n "require\\(['\"]jimp['\"]\\)|from ['\"]jimp['\"]|jimp" -S --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' .

printf '%s\n' ''
printf '%s\n' '--- lock/package files ---'
git ls-files | rg '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|pnpm-workspace\.yaml)$' || true

Repository: Resgrid/IC

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import json
from pathlib import Path

pkg = json.loads(Path('package.json').read_text())
name = pkg.get('name')
version = pkg.get('version')

print('package:', name, version)
print('direct_jimp:', pkg.get('dependencies', {}).get('jimp') or pkg.get('devDependencies', {}).get('jimp') or pkg.get('optionalDependencies', {}).get('jimp') or '(none)')
print('direct_app_icon_badge:', pkg.get('dependencies', {}).get('app-icon-badge') or pkg.get('devDependencies', {}).get('app-icon-badge') or '(none)')

lock = Path('yarn.lock').read_text()
for pkg_name in ['"jimp@%', '"app-icon-badge@%']:
    print(f'\n--- lock entry: {pkg_name.lstrip(chr(34))} ---')
    idx = lock.find(pkg_name)
    print(idx)
    if idx != -1:
        start = lock.rfind('\n', 0, idx)
        end = lock.find('\n\n', idx)
        print(lock[start:end] if end != -1 else lock[start:start+1200])

PY

Repository: Resgrid/IC

Length of output: 287


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- scripts/generate-app-icon-badges.js ---'
cat -n scripts/generate-app-icon-badges.js

printf '%s\n' ''
printf '%s\n' '--- app-icon-badge lock entry (loose) ---'
rg -n '^\s*app-icon-badge' yarn.lock -A 8

printf '%s\n' ''
printf '%s\n' '--- jimp references in package files only ---'
python3 - <<'PY'
import json, glob
for path in ['package.json'] + glob.glob('*/*/package.json'):
    try:
        data=json.load(open(path))
    except Exception:
        continue
    deps={}
    for k in ('dependencies','devDependencies','optionalDependencies'):
        deps.update(data.get(k,{}))
    print(path, {_:deps[_] for _ in deps if _ == 'jimp' or _ == 'app-icon-badge'} or 'no jimp/app-icon-badge')
PY

Repository: Resgrid/IC

Length of output: 50366


Declare jimp as a direct script dependency.

scripts/generate-app-icon-badges.js imports jimp directly, but the root package.json only declares app-icon-badge; jimp is only available transitively through that package. Add it under devDependencies since this is a Node script, or change the script to import the image operations from app-icon-badge itself.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/generate-app-icon-badges.js` at line 5, Add jimp as a direct root
devDependency for the generate-app-icon-badges script, keeping its existing
direct require in that script unchanged; do not rely on app-icon-badge’s
transitive dependency.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Missing direct dependency causes require('jimp') at line 5 of scripts/generate-app-icon-badges.js to throw MODULE_NOT_FOUND under pnpm's strict layout (or if app-icon-badge drops jimp), failing the Expo build via execFileSync. Add jimp as an explicit devDependency in package.json, matching the version app-icon-badge resolves.

// package.json (devDependencies):
//   "jimp": "<version matching app-icon-badge's resolved jimp>"

const { addBadge } = require('app-icon-badge');
const Jimp = require('jimp');
Prompt for LLM

File scripts/generate-app-icon-badges.js:

Line 5:

Missing direct dependency causes `require('jimp')` at line 5 of scripts/generate-app-icon-badges.js to throw MODULE_NOT_FOUND under pnpm's strict layout (or if app-icon-badge drops jimp), failing the Expo build via execFileSync. Add `jimp` as an explicit devDependency in package.json, matching the version app-icon-badge resolves.

Suggested Code:

// package.json (devDependencies):
//   "jimp": "<version matching app-icon-badge's resolved jimp>"

const { addBadge } = require('app-icon-badge');
const Jimp = require('jimp');

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const IMAGE_WRITE_TIMEOUT_MS = 30000;
const IMAGE_WRITE_POLL_INTERVAL_MS = 25;

function delay(duration) {
return new Promise((resolve) => {
setTimeout(resolve, duration);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Uncleared timer in the delay() helper creates a setTimeout with no deterministic cleanup path, keeping the handle alive on teardown or abort. Store the timer id and return a cancelable wrapper exposing clearTimeout, or accept an AbortSignal and clear on abort.

Kody rule violation: Clear timers on teardown/unmount

Prompt for LLM

File scripts/generate-app-icon-badges.js:

Line 13:

Uncleared timer in the `delay()` helper creates a setTimeout with no deterministic cleanup path, keeping the handle alive on teardown or abort. Store the timer id and return a cancelable wrapper exposing clearTimeout, or accept an AbortSignal and clear on abort.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

});
}

async function waitForValidPng(imagePath) {
const startedAt = Date.now();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Wall-clock time from Date.now() is susceptible to system clock adjustments (NTP, manual changes), making elapsed-time calculations unreliable. Use a monotonic clock such as performance.now() or process.hrtime.bigint() to record the start timestamp. Also found in scripts/generate-app-icon-badges.js:20.

Kody rule violation: Avoid `DateTime.Now` for Timing Operations

Prompt for LLM

File scripts/generate-app-icon-badges.js:

Line 18:

Wall-clock time from Date.now() is susceptible to system clock adjustments (NTP, manual changes), making elapsed-time calculations unreliable. Use a monotonic clock such as performance.now() or process.hrtime.bigint() to record the start timestamp. Also found in scripts/generate-app-icon-badges.js:20.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


while (Date.now() - startedAt < IMAGE_WRITE_TIMEOUT_MS) {
try {
const imageBuffer = readFileSync(imagePath);
if (imageBuffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
const image = await Jimp.read(imagePath);
if (image.bitmap.width > 0 && image.bitmap.height > 0) {
return;
}
}
} catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Silent exception swallowing in the catch block on line 29 discards every error with only a comment, violating deterministic error handling. Log at debug level with context (e.g., imagePath, err) or narrow the catch to specific expected error codes. Also found in scripts/generate-app-icon-badges.js:56.

Kody rule violation: Avoid empty catch blocks

Prompt for LLM

File scripts/generate-app-icon-badges.js:

Line 29:

Silent exception swallowing in the catch block on line 29 discards every error with only a comment, violating deterministic error handling. Log at debug level with context (e.g., imagePath, err) or narrow the catch to specific expected error codes. Also found in scripts/generate-app-icon-badges.js:56.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

// app-icon-badge does not await Jimp.writeAsync, so the file may not exist yet.
}

await delay(IMAGE_WRITE_POLL_INTERVAL_MS);
}

throw new Error(`Timed out waiting for app icon badge image ${imagePath}`);
}

async function generateBadgeImage(job, badges) {
const temporaryPath = `${job.outputPath}.${process.pid}.tmp.png`;
mkdirSync(dirname(job.outputPath), { recursive: true });

try {
await addBadge({
icon: job.sourcePath,
dstPath: temporaryPath,
badges,
isAdaptiveIcon: job.isAdaptiveIcon,
});
await waitForValidPng(temporaryPath);
copyFileSync(temporaryPath, job.outputPath);
await waitForValidPng(job.outputPath);
} finally {
try {
unlinkSync(temporaryPath);
} catch {
// The temporary image is absent when generation fails before Jimp starts writing.
}
}
}

async function main() {
const payload = JSON.parse(process.argv[2] ?? '{}');
if (!Array.isArray(payload.jobs) || !Array.isArray(payload.badges)) {
throw new Error('App icon badge generator requires jobs and badges arrays');
}

for (const job of payload.jobs) {
await generateBadgeImage(job, payload.badges);
}
}

main().catch((error) => {
console.error('Failed to generate app icon badges:', error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unstructured error logging via console.error with a plain message string omits the operation name and relevant identifiers required for searchability and correlation. Emit a structured record including op, err.message, and stack, or use a structured logger with fields.

Kody rule violation: Include error context in structured logs

Prompt for LLM

File scripts/generate-app-icon-badges.js:

Line 74:

Unstructured error logging via `console.error` with a plain message string omits the operation name and relevant identifiers required for searchability and correlation. Emit a structured record including op, err.message, and stack, or use a structured logger with fields.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

process.exitCode = 1;
});
Loading