react-native-icon-switcher 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,6 +107,10 @@ Icons with no art for a platform are omitted from that platform's generated outp
107
107
 
108
108
  **Android reserved names:** names may not be `round`, `foreground`, `background`, or `monochrome`, nor end in `_round`, `_foreground`, `_background`, or `_monochrome`.
109
109
 
110
+ **Legacy Android icons (API 24–25):** besides the adaptive layers and `mipmap-anydpi-v26` XML, `sync` and the plugin write `ic_launcher*.png` the way Android Studio's Image Asset tool does: the visible centre of an adaptive icon (72 dp of the 108 dp layers; a plain path uses the whole image) in a 38 dp rounded square or a 44 dp circle, with a soft drop shadow, on a transparent 48 dp canvas.
111
+
112
+ **Managed iOS icon sets:** when `sync` or the plugin rewrites an `.appiconset` it owns, it deletes files in that folder that the new `Contents.json` does not reference (`sync --check` reports them). Other icon sets are left alone.
113
+
110
114
  ## Usage
111
115
 
112
116
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-icon-switcher",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Switch the app icon at runtime on React Native and Expo from one config file, with the native wiring generated for you",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -1,4 +1,17 @@
1
1
  import type { HexColor, ResolvedConfig } from '../types';
2
+ /** Drop shadow tuned to match Image Asset exports at xxxhdpi. */
3
+ export declare const LEGACY_SHADOW_OFFSET_Y_DP = 0.75;
4
+ export declare const LEGACY_SHADOW_BLUR_DP = 1;
5
+ export declare const LEGACY_SHADOW_OPACITY = 0.25;
6
+ /**
7
+ * Largest per-channel difference at which two PNGs still count as the same
8
+ * image. libvips resampling rounds slightly differently on arm64 and x86, so
9
+ * icons generated on a Mac and re-generated in Linux CI can differ by a level
10
+ * or two without any visible change.
11
+ */
12
+ export declare const PNG_PIXEL_TOLERANCE = 3;
13
+ /** True when both buffers are PNGs of the same size whose pixels differ by at most `PNG_PIXEL_TOLERANCE`. */
14
+ export declare function pngsMatchWithinTolerance(a: Buffer, b: Buffer): Promise<boolean>;
2
15
  export type IosVariant = 'light' | 'dark' | 'tinted';
3
16
  export type AssetJob = {
4
17
  kind: 'ios-png';
@@ -72,6 +85,13 @@ export declare function writeAssets(plan: AssetPlan, opts: {
72
85
  iosAssetCatalog: string;
73
86
  androidResDir: string;
74
87
  }): Promise<WrittenFile[]>;
88
+ /**
89
+ * Remove unreferenced files from managed `.appiconset` folders in `plan`.
90
+ * Returns absolute paths of stale files (removed or that would be removed in check mode).
91
+ */
92
+ export declare function pruneStaleIosAppIconSets(iosAssetCatalog: string, plan: AssetPlan, opts: {
93
+ check: boolean;
94
+ }): Promise<string[]>;
75
95
  /** Test helper: corner pixel RGB after iOS flatten (alpha removed). */
76
96
  export declare function _readCornerRgb(pngPath: string): Promise<{
77
97
  r: number;
@@ -3,8 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PNG_PIXEL_TOLERANCE = exports.LEGACY_SHADOW_OPACITY = exports.LEGACY_SHADOW_BLUR_DP = exports.LEGACY_SHADOW_OFFSET_Y_DP = void 0;
7
+ exports.pngsMatchWithinTolerance = pngsMatchWithinTolerance;
6
8
  exports.planAssets = planAssets;
7
9
  exports.writeAssets = writeAssets;
10
+ exports.pruneStaleIosAppIconSets = pruneStaleIosAppIconSets;
8
11
  exports._readCornerRgb = _readCornerRgb;
9
12
  const node_crypto_1 = __importDefault(require("node:crypto"));
10
13
  const promises_1 = __importDefault(require("node:fs/promises"));
@@ -25,6 +28,47 @@ const ADAPTIVE_DENSITIES = [
25
28
  { folder: 'mipmap-xxxhdpi', size: 432 },
26
29
  ];
27
30
  const PNG_OPTIONS = { compressionLevel: 9, adaptiveFiltering: false };
31
+ /** Legacy launcher canvas (API 24–25), matching Android Studio Image Asset at 48 dp. */
32
+ const LEGACY_CANVAS_DP = 48;
33
+ const LEGACY_SQUARE_INSET_DP = 5;
34
+ const LEGACY_SQUARE_BODY_DP = 38;
35
+ const LEGACY_SQUARE_RADIUS_DP = 3;
36
+ const LEGACY_ROUND_INSET_DP = 2;
37
+ const LEGACY_ROUND_DIAMETER_DP = 44;
38
+ const ADAPTIVE_LAYER_DP = 108;
39
+ const ADAPTIVE_VIEWPORT_DP = 72;
40
+ /** Drop shadow tuned to match Image Asset exports at xxxhdpi. */
41
+ exports.LEGACY_SHADOW_OFFSET_Y_DP = 0.75;
42
+ exports.LEGACY_SHADOW_BLUR_DP = 1;
43
+ exports.LEGACY_SHADOW_OPACITY = 0.25;
44
+ /**
45
+ * Largest per-channel difference at which two PNGs still count as the same
46
+ * image. libvips resampling rounds slightly differently on arm64 and x86, so
47
+ * icons generated on a Mac and re-generated in Linux CI can differ by a level
48
+ * or two without any visible change.
49
+ */
50
+ exports.PNG_PIXEL_TOLERANCE = 3;
51
+ const PNG_SIGNATURE = Buffer.from([
52
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
53
+ ]);
54
+ /** True when both buffers are PNGs of the same size whose pixels differ by at most `PNG_PIXEL_TOLERANCE`. */
55
+ async function pngsMatchWithinTolerance(a, b) {
56
+ if (!a.subarray(0, 8).equals(PNG_SIGNATURE) ||
57
+ !b.subarray(0, 8).equals(PNG_SIGNATURE)) {
58
+ return false;
59
+ }
60
+ const [left, right] = await Promise.all([a, b].map((buf) => (0, sharp_1.default)(buf).ensureAlpha().raw().toBuffer({ resolveWithObject: true })));
61
+ if (left.info.width !== right.info.width ||
62
+ left.info.height !== right.info.height) {
63
+ return false;
64
+ }
65
+ for (let i = 0; i < left.data.length; i += 1) {
66
+ if (Math.abs(left.data[i] - right.data[i]) > exports.PNG_PIXEL_TOLERANCE) {
67
+ return false;
68
+ }
69
+ }
70
+ return true;
71
+ }
28
72
  function _iosSetFolder(icon) {
29
73
  return icon.isDefault
30
74
  ? 'AppIcon.appiconset'
@@ -274,31 +318,123 @@ async function _flattenPng(sourceAbs, flattenOn, width, height) {
274
318
  .png(PNG_OPTIONS)
275
319
  .toBuffer();
276
320
  }
277
- async function _applyRoundMask(image, size) {
278
- const mask = Buffer.from(`<svg width="${size}" height="${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="white"/></svg>`);
279
- return (0, sharp_1.default)(image)
321
+ function _legacyDpToPx(dp, canvasPx) {
322
+ return Math.round((dp * canvasPx) / LEGACY_CANVAS_DP);
323
+ }
324
+ function _legacyShapeLayout(canvasPx, round) {
325
+ if (round) {
326
+ const inset = _legacyDpToPx(LEGACY_ROUND_INSET_DP, canvasPx);
327
+ const bodyPx = _legacyDpToPx(LEGACY_ROUND_DIAMETER_DP, canvasPx);
328
+ return { inset, bodyPx, radiusPx: bodyPx / 2 };
329
+ }
330
+ return {
331
+ inset: _legacyDpToPx(LEGACY_SQUARE_INSET_DP, canvasPx),
332
+ bodyPx: _legacyDpToPx(LEGACY_SQUARE_BODY_DP, canvasPx),
333
+ radiusPx: _legacyDpToPx(LEGACY_SQUARE_RADIUS_DP, canvasPx),
334
+ };
335
+ }
336
+ function _legacyShapeMaskSvg(bodyPx, round, radiusPx) {
337
+ if (round) {
338
+ const r = bodyPx / 2;
339
+ return Buffer.from(`<svg width="${bodyPx}" height="${bodyPx}"><circle cx="${r}" cy="${r}" r="${r}" fill="white"/></svg>`);
340
+ }
341
+ return Buffer.from(`<svg width="${bodyPx}" height="${bodyPx}"><rect width="${bodyPx}" height="${bodyPx}" rx="${radiusPx}" ry="${radiusPx}" fill="white"/></svg>`);
342
+ }
343
+ async function _legacyMaskedContent(content, bodyPx, round, radiusPx) {
344
+ const mask = _legacyShapeMaskSvg(bodyPx, round, radiusPx);
345
+ return (0, sharp_1.default)(content)
346
+ .resize(bodyPx, bodyPx, { fit: 'cover' })
347
+ .ensureAlpha()
280
348
  .composite([{ input: mask, blend: 'dest-in' }])
281
- .png(PNG_OPTIONS)
349
+ .png()
282
350
  .toBuffer();
283
351
  }
284
- async function _legacyPng(sourceAbs, size, round) {
285
- const resized = await (0, sharp_1.default)(sourceAbs)
286
- .resize(size, size, { fit: 'cover' })
287
- .png()
352
+ function _boxBlurLine(src, size, radius, horizontal) {
353
+ const out = new Uint8Array(src.length);
354
+ const window = radius * 2 + 1;
355
+ for (let line = 0; line < size; line += 1) {
356
+ const at = (i) => (horizontal ? line * size + i : i * size + line);
357
+ let sum = 0;
358
+ for (let i = -radius; i <= radius; i += 1) {
359
+ if (i >= 0 && i < size) {
360
+ sum += src[at(i)];
361
+ }
362
+ }
363
+ for (let i = 0; i < size; i += 1) {
364
+ out[at(i)] = Math.floor(sum / window);
365
+ const leaving = i - radius;
366
+ const entering = i + radius + 1;
367
+ if (leaving >= 0) {
368
+ sum -= src[at(leaving)];
369
+ }
370
+ if (entering < size) {
371
+ sum += src[at(entering)];
372
+ }
373
+ }
374
+ }
375
+ return out;
376
+ }
377
+ /**
378
+ * Black drop shadow of the shape on a transparent canvas. The blur is done in
379
+ * integer arithmetic here rather than with an SVG filter: librsvg's float blur
380
+ * produced different bytes on macOS and Linux, which broke `sync --check` in CI.
381
+ */
382
+ async function _legacyShadowLayer(canvasPx, shapeMarkup, offsetY, blurPx) {
383
+ const shapeSvg = Buffer.from(`<svg width="${canvasPx}" height="${canvasPx}" xmlns="http://www.w3.org/2000/svg">${shapeMarkup}</svg>`);
384
+ const shape = await (0, sharp_1.default)(shapeSvg)
385
+ .ensureAlpha()
386
+ .extractChannel(3)
387
+ .raw()
288
388
  .toBuffer();
289
- if (!round) {
290
- return (0, sharp_1.default)(resized).png(PNG_OPTIONS).toBuffer();
389
+ // Three box-blur passes approximate a Gaussian.
390
+ const radius = Math.max(1, Math.round(blurPx / 2));
391
+ let alpha = Uint8Array.from(shape);
392
+ for (let pass = 0; pass < 3; pass += 1) {
393
+ alpha = _boxBlurLine(alpha, canvasPx, radius, true);
394
+ alpha = _boxBlurLine(alpha, canvasPx, radius, false);
395
+ }
396
+ const opacity256 = Math.round(exports.LEGACY_SHADOW_OPACITY * 256);
397
+ const rgba = Buffer.alloc(canvasPx * canvasPx * 4);
398
+ for (let y = 0; y < canvasPx; y += 1) {
399
+ const sourceY = y - offsetY;
400
+ if (sourceY < 0 || sourceY >= canvasPx) {
401
+ continue;
402
+ }
403
+ for (let x = 0; x < canvasPx; x += 1) {
404
+ const a = alpha[sourceY * canvasPx + x];
405
+ rgba[(y * canvasPx + x) * 4 + 3] = Math.floor((a * opacity256) / 256);
406
+ }
291
407
  }
292
- return _applyRoundMask(resized, size);
408
+ return (0, sharp_1.default)(rgba, {
409
+ raw: { width: canvasPx, height: canvasPx, channels: 4 },
410
+ })
411
+ .png()
412
+ .toBuffer();
293
413
  }
294
- async function _legacyCompositePng(foregroundAbs, size, round, background) {
414
+ async function _legacyStudioPng(content, canvasPx, round) {
415
+ const { inset, bodyPx, radiusPx } = _legacyShapeLayout(canvasPx, round);
416
+ const masked = await _legacyMaskedContent(content, bodyPx, round, radiusPx);
417
+ const shadowOffsetY = _legacyDpToPx(exports.LEGACY_SHADOW_OFFSET_Y_DP, canvasPx);
418
+ const blurPx = Math.max(1, _legacyDpToPx(exports.LEGACY_SHADOW_BLUR_DP, canvasPx));
419
+ const cx = inset + bodyPx / 2;
420
+ const cy = inset + bodyPx / 2;
421
+ const shapeMarkup = round
422
+ ? `<circle cx="${cx}" cy="${cy}" r="${bodyPx / 2}" fill="#000"/>`
423
+ : `<rect x="${inset}" y="${inset}" width="${bodyPx}" height="${bodyPx}" rx="${radiusPx}" ry="${radiusPx}" fill="#000"/>`;
424
+ const shadowLayer = await _legacyShadowLayer(canvasPx, shapeMarkup, shadowOffsetY, blurPx);
425
+ return (0, sharp_1.default)(shadowLayer)
426
+ .composite([{ input: masked, left: inset, top: inset, blend: 'over' }])
427
+ .png(PNG_OPTIONS)
428
+ .toBuffer();
429
+ }
430
+ async function _adaptiveLayerComposite(foregroundAbs, layerPx, background) {
295
431
  let base;
296
432
  if ('color' in background) {
297
433
  const { r, g, b } = _hexToRgb(background.color);
298
434
  base = await (0, sharp_1.default)({
299
435
  create: {
300
- width: size,
301
- height: size,
436
+ width: layerPx,
437
+ height: layerPx,
302
438
  channels: 4,
303
439
  background: { r, g, b, alpha: 255 },
304
440
  },
@@ -308,25 +444,41 @@ async function _legacyCompositePng(foregroundAbs, size, round, background) {
308
444
  }
309
445
  else {
310
446
  base = await (0, sharp_1.default)(background.imageAbs)
311
- .resize(size, size, { fit: 'cover' })
447
+ .resize(layerPx, layerPx, { fit: 'cover' })
312
448
  .png()
313
449
  .toBuffer();
314
450
  }
315
451
  const foreground = await (0, sharp_1.default)(foregroundAbs)
316
- .resize(size, size, {
452
+ .resize(layerPx, layerPx, {
317
453
  fit: 'contain',
318
454
  background: { r: 0, g: 0, b: 0, alpha: 0 },
319
455
  })
320
456
  .png()
321
457
  .toBuffer();
322
- const flat = await (0, sharp_1.default)(base)
458
+ const cropPx = Math.round((layerPx * ADAPTIVE_VIEWPORT_DP) / ADAPTIVE_LAYER_DP);
459
+ const cropInset = Math.round((layerPx - cropPx) / 2);
460
+ const composed = await (0, sharp_1.default)(base)
323
461
  .composite([{ input: foreground, blend: 'over' }])
324
- .png(PNG_OPTIONS)
462
+ .png()
463
+ .toBuffer();
464
+ return (0, sharp_1.default)(composed)
465
+ .extract({
466
+ left: cropInset,
467
+ top: cropInset,
468
+ width: cropPx,
469
+ height: cropPx,
470
+ })
471
+ .png()
325
472
  .toBuffer();
326
- if (!round) {
327
- return flat;
328
- }
329
- return _applyRoundMask(flat, size);
473
+ }
474
+ async function _legacyPng(sourceAbs, size, round) {
475
+ const content = await (0, sharp_1.default)(sourceAbs).ensureAlpha().png().toBuffer();
476
+ return _legacyStudioPng(content, size, round);
477
+ }
478
+ async function _legacyCompositePng(foregroundAbs, size, round, background) {
479
+ const layerPx = Math.round((size * ADAPTIVE_LAYER_DP) / LEGACY_CANVAS_DP);
480
+ const viewport = await _adaptiveLayerComposite(foregroundAbs, layerPx, background);
481
+ return _legacyStudioPng(viewport, size, round);
330
482
  }
331
483
  async function _resizePng(sourceAbs, size) {
332
484
  return (0, sharp_1.default)(sourceAbs)
@@ -452,6 +604,48 @@ async function writeAssets(plan, opts) {
452
604
  }
453
605
  return written;
454
606
  }
607
+ function _iosFilenamesForContentsJob(job) {
608
+ const keep = new Set(['Contents.json']);
609
+ for (const variant of job.variants) {
610
+ keep.add(variant === 'light' ? 'AppIcon-1024.png' : `AppIcon-1024-${variant}.png`);
611
+ }
612
+ return keep;
613
+ }
614
+ /**
615
+ * Remove unreferenced files from managed `.appiconset` folders in `plan`.
616
+ * Returns absolute paths of stale files (removed or that would be removed in check mode).
617
+ */
618
+ async function pruneStaleIosAppIconSets(iosAssetCatalog, plan, opts) {
619
+ const staleAbs = [];
620
+ for (const asset of plan.assets) {
621
+ if (asset.job.kind !== 'ios-contents') {
622
+ continue;
623
+ }
624
+ const setRel = node_path_1.default.dirname(asset.relativePath);
625
+ const setDir = node_path_1.default.join(iosAssetCatalog, setRel);
626
+ const keep = _iosFilenamesForContentsJob(asset.job);
627
+ let entries;
628
+ try {
629
+ entries = await promises_1.default.readdir(setDir, { withFileTypes: true });
630
+ }
631
+ catch {
632
+ continue;
633
+ }
634
+ for (const entry of entries) {
635
+ const name = entry.name;
636
+ // Only plain files are ours to remove; leave folders and links alone.
637
+ if (!entry.isFile() || keep.has(name)) {
638
+ continue;
639
+ }
640
+ const abs = node_path_1.default.join(setDir, name);
641
+ staleAbs.push(abs);
642
+ if (!opts.check) {
643
+ await promises_1.default.unlink(abs);
644
+ }
645
+ }
646
+ }
647
+ return staleAbs;
648
+ }
455
649
  /** Test helper: corner pixel RGB after iOS flatten (alpha removed). */
456
650
  async function _readCornerRgb(pngPath) {
457
651
  var _a, _b, _c;
@@ -125,6 +125,7 @@ const withIconSwitcher = (config, props) => {
125
125
  iosAssetCatalog,
126
126
  androidResDir: path_1.default.join(projectRoot, '.noop-android'),
127
127
  });
128
+ await (0, assets_1.pruneStaleIosAppIconSets)(iosAssetCatalog, plan, { check: false });
128
129
  await _writeTypesIfChanged(projectRoot, resolved);
129
130
  return config;
130
131
  },
@@ -36,6 +36,12 @@ async function _applyFileChange(_projectRoot, abs, next, check) {
36
36
  if (existing !== null && existing.equals(nextBuf)) {
37
37
  return 'unchanged';
38
38
  }
39
+ // Keep a generated image that only differs by resampling rounding (for
40
+ // example made on another CPU), so --check passes and nothing is rewritten.
41
+ if (existing !== null &&
42
+ (await (0, assets_1.pngsMatchWithinTolerance)(existing, nextBuf))) {
43
+ return 'unchanged';
44
+ }
39
45
  if (check) {
40
46
  return 'updated';
41
47
  }
@@ -76,6 +82,13 @@ async function _syncAssets(resolved, projectRoot, iosAssetCatalog, androidResDir
76
82
  unchanged += 1;
77
83
  }
78
84
  }
85
+ const staleIos = await (0, assets_1.pruneStaleIosAppIconSets)(iosAssetCatalog, plan, {
86
+ check,
87
+ });
88
+ for (const abs of staleIos) {
89
+ driftedPaths.push(_rel(projectRoot, abs));
90
+ updated += 1;
91
+ }
79
92
  }
80
93
  finally {
81
94
  await promises_1.default.rm(tmpRoot, { recursive: true, force: true });
@@ -43,20 +43,27 @@ function applyXcodeProject(project, cfg) {
43
43
  if (!settings.ASSETCATALOG_COMPILER_APPICON_NAME) {
44
44
  console.warn(`[react-native-icon-switcher] Build configuration "${String(configName)}" has no ASSETCATALOG_COMPILER_APPICON_NAME — set the primary app icon in Xcode`);
45
45
  }
46
- settings.ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES =
47
- _formatAlternateAppIconNamesSetting(alternateNames);
48
- settings.ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = 'NO';
46
+ _setBuildSettingPreservingQuoting(settings, 'ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES', alternateNames);
47
+ _setBuildSettingPreservingQuoting(settings, 'ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS', 'NO');
49
48
  }
50
49
  return project;
51
50
  }
52
- function _formatAlternateAppIconNamesSetting(raw) {
51
+ function _setBuildSettingPreservingQuoting(settings, key, desired) {
52
+ const existing = settings[key];
53
+ if (existing !== undefined && _unquote(existing) === desired) {
54
+ return;
55
+ }
56
+ settings[key] = _formatXcodeprojBuildSettingValue(desired);
57
+ }
58
+ function _formatXcodeprojBuildSettingValue(raw) {
53
59
  if (!raw) {
54
60
  return '';
55
61
  }
56
- if (!raw.includes(' ')) {
62
+ if (/^[A-Za-z0-9_$/:.]+$/.test(raw)) {
57
63
  return raw;
58
64
  }
59
- return `"${raw}"`;
65
+ const escaped = raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
66
+ return `"${escaped}"`;
60
67
  }
61
68
  function _findFirstApplicationTarget(project) {
62
69
  var _a;