expo-app-blocker 0.1.52 → 0.1.53

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/plugin/src/index.js +146 -159
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
4
4
  "description": "Expo module for cross-platform app blocking. Android: UsageStatsManager + Overlay. iOS: Screen Time API (FamilyControls + ManagedSettings + DeviceActivity).",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -232,13 +232,19 @@ function withAppBlockerIOS(config, pluginConfig) {
232
232
  // Populate `targets/` synchronously at config-eval time so the
233
233
  // `@bacons/apple-targets` plugin (registered just below) can glob the
234
234
  // directory and register the Shield/DeviceActivityMonitor/ShieldConfiguration
235
- // extensions on the first prebuild. Done synchronously because @bacons globs
236
- // during config evaluation, before any withDangerousMod queued by this plugin
237
- // would run.
235
+ // extensions on the first prebuild. We also run placeholder substitution and
236
+ // copy the shield icon here, not in a later withDangerousMod, so the on-disk
237
+ // state matches the plugin config after every config evaluation — not just
238
+ // during `expo prebuild`. Otherwise non-prebuild config loads (EAS env probe,
239
+ // autolinking, doctor) would re-copy the placeholder templates over the
240
+ // substituted Swift files and leave the tree in a broken state.
238
241
  const projectRoot = config._internal?.projectRoot;
239
242
  if (projectRoot) {
240
243
  const targetsDir = path.join(projectRoot, "targets");
241
244
  const packageTargetsDir = path.resolve(__dirname, "..", "..", "targets");
245
+
246
+ // 1. Copy template Swift files + expo-target.config.js from this package
247
+ // into the consumer's `targets/`. Preserves any user-managed assets.
242
248
  if (fs.existsSync(packageTargetsDir)) {
243
249
  for (const dir of fs.readdirSync(packageTargetsDir)) {
244
250
  const srcDir = path.join(packageTargetsDir, dir);
@@ -252,6 +258,140 @@ function withAppBlockerIOS(config, pluginConfig) {
252
258
  }
253
259
  }
254
260
  }
261
+
262
+ // 2. Substitute placeholders in the freshly-copied Swift files. The
263
+ // substitution map mirrors the original withDangerousMod block — kept
264
+ // here so config-eval produces final, build-ready Swift in one pass.
265
+ function hexToRgb(hex) {
266
+ const h = hex.replace("#", "");
267
+ return {
268
+ r: (parseInt(h.substring(0, 2), 16) / 255).toFixed(3),
269
+ g: (parseInt(h.substring(2, 4), 16) / 255).toFixed(3),
270
+ b: (parseInt(h.substring(4, 6), 16) / 255).toFixed(3),
271
+ };
272
+ }
273
+
274
+ const shield = pluginConfig?.ios?.shield || {};
275
+ const primaryColor = hexToRgb(shield.primaryButtonColor || "#fb6107");
276
+ const titleColor = hexToRgb(shield.titleColor || "#111111");
277
+ const subtitleColor = hexToRgb(shield.subtitleColor || "#737373");
278
+ const bgColorHex = shield.backgroundColor || null;
279
+ const bgColor = bgColorHex ? hexToRgb(bgColorHex) : null;
280
+
281
+ const blurStyleMap = {
282
+ "systemUltraThinMaterial": ".systemUltraThinMaterial",
283
+ "systemThinMaterial": ".systemThinMaterial",
284
+ "systemMaterial": ".systemMaterial",
285
+ "systemThickMaterial": ".systemThickMaterial",
286
+ "systemChromeMaterial": ".systemChromeMaterial",
287
+ "systemUltraThinMaterialLight": ".systemUltraThinMaterialLight",
288
+ "systemThinMaterialLight": ".systemThinMaterialLight",
289
+ "systemMaterialLight": ".systemMaterialLight",
290
+ "systemThickMaterialLight": ".systemThickMaterialLight",
291
+ "systemChromeMaterialLight": ".systemChromeMaterialLight",
292
+ "systemUltraThinMaterialDark": ".systemUltraThinMaterialDark",
293
+ "systemThinMaterialDark": ".systemThinMaterialDark",
294
+ "systemMaterialDark": ".systemMaterialDark",
295
+ "systemThickMaterialDark": ".systemThickMaterialDark",
296
+ "systemChromeMaterialDark": ".systemChromeMaterialDark",
297
+ "regular": ".regular",
298
+ "prominent": ".prominent",
299
+ "light": ".light",
300
+ "dark": ".dark",
301
+ "extraLight": ".extraLight",
302
+ };
303
+ const blurRaw = shield.backgroundBlurStyle || (bgColorHex ? null : "systemThickMaterial");
304
+ const blurSwift = blurRaw && blurStyleMap[blurRaw] ? blurStyleMap[blurRaw] : null;
305
+
306
+ const notification = pluginConfig?.ios?.notification || {};
307
+ const notificationTitle = notification.title || "App Blocker";
308
+ const notificationBody = notification.body || "Tap to return to the app and complete the unlock challenge.";
309
+ const notificationAttachIcon = notification.attachIcon === false ? "false" : "true";
310
+
311
+ const tempUnlockTitle = shield.tempUnlockTitle || "Almost there!";
312
+ const tempUnlockSubtitle = shield.tempUnlockSubtitle || "Your free time is loading. Try again in a moment.";
313
+ const tempUnlockButtonLabel = shield.tempUnlockButtonLabel || "OK";
314
+
315
+ const countSuffixTemplate = shield.countSuffix !== undefined
316
+ ? shield.countSuffix
317
+ : " You have {count} apps blocked.";
318
+
319
+ // Swift string-literal escaping for plugin substitutions that land inside
320
+ // `"..."` literals. `\(` is the Swift interpolation escape; rendering it
321
+ // unescaped here is intentional — see renderCountSuffixSwift.
322
+ function escapeSwiftString(s) {
323
+ return String(s)
324
+ .replace(/\\/g, "\\\\")
325
+ .replace(/"/g, '\\"')
326
+ .replace(/\n/g, "\\n")
327
+ .replace(/\r/g, "\\r");
328
+ }
329
+ function renderCountSuffixSwift(template) {
330
+ if (!template) return '""';
331
+ const escaped = escapeSwiftString(template);
332
+ return `"${escaped.replace(/\{count\}/g, "\\(count)")}"`;
333
+ }
334
+
335
+ const replacements = {
336
+ "APP_GROUP_PLACEHOLDER": appGroup,
337
+ "SHIELD_TITLE_PLACEHOLDER": shield.title || "Hold on!",
338
+ "SHIELD_SUBTITLE_PLACEHOLDER": shield.subtitle || "{appName} is blocked.",
339
+ "SHIELD_PRIMARY_BUTTON_PLACEHOLDER": shield.primaryButtonLabel || "Earn Free Time",
340
+ "SHIELD_SECONDARY_BUTTON_PLACEHOLDER": shield.secondaryButtonLabel === null ? "none" : (shield.secondaryButtonLabel || "Not now"),
341
+ "SHIELD_TEMP_UNLOCK_TITLE_PLACEHOLDER": tempUnlockTitle,
342
+ "SHIELD_TEMP_UNLOCK_SUBTITLE_PLACEHOLDER": tempUnlockSubtitle,
343
+ "SHIELD_TEMP_UNLOCK_BUTTON_PLACEHOLDER": tempUnlockButtonLabel,
344
+ "SHIELD_COUNT_SUFFIX_SWIFT_PLACEHOLDER": renderCountSuffixSwift(countSuffixTemplate),
345
+ "NOTIFICATION_TITLE_PLACEHOLDER": notificationTitle,
346
+ "NOTIFICATION_BODY_PLACEHOLDER": notificationBody,
347
+ "NOTIFICATION_ATTACH_ICON_PLACEHOLDER": notificationAttachIcon,
348
+ "SHIELD_PRIMARY_R_PLACEHOLDER": primaryColor.r,
349
+ "SHIELD_PRIMARY_G_PLACEHOLDER": primaryColor.g,
350
+ "SHIELD_PRIMARY_B_PLACEHOLDER": primaryColor.b,
351
+ "SHIELD_TITLE_R_PLACEHOLDER": titleColor.r,
352
+ "SHIELD_TITLE_G_PLACEHOLDER": titleColor.g,
353
+ "SHIELD_TITLE_B_PLACEHOLDER": titleColor.b,
354
+ "SHIELD_SUBTITLE_R_PLACEHOLDER": subtitleColor.r,
355
+ "SHIELD_SUBTITLE_G_PLACEHOLDER": subtitleColor.g,
356
+ "SHIELD_SUBTITLE_B_PLACEHOLDER": subtitleColor.b,
357
+ "SHIELD_BG_COLOR_PLACEHOLDER": bgColor
358
+ ? `UIColor(red: ${bgColor.r}, green: ${bgColor.g}, blue: ${bgColor.b}, alpha: 1.0)`
359
+ : "nil",
360
+ "SHIELD_BLUR_STYLE_PLACEHOLDER": blurSwift || "nil",
361
+ };
362
+
363
+ if (fs.existsSync(targetsDir)) {
364
+ for (const dir of fs.readdirSync(targetsDir)) {
365
+ const dirPath = path.join(targetsDir, dir);
366
+ if (!fs.statSync(dirPath).isDirectory()) continue;
367
+ for (const file of fs.readdirSync(dirPath)) {
368
+ if (!file.endsWith(".swift")) continue;
369
+ const filePath = path.join(dirPath, file);
370
+ let content = fs.readFileSync(filePath, "utf-8");
371
+ for (const [key, value] of Object.entries(replacements)) {
372
+ content = content.replace(new RegExp(key, "g"), value);
373
+ }
374
+ fs.writeFileSync(filePath, content);
375
+ }
376
+ }
377
+ }
378
+
379
+ // 3. Copy shield icon into ShieldConfiguration + ShieldAction target assets.
380
+ const shieldIcon = pluginConfig?.ios?.shield?.icon;
381
+ if (shieldIcon) {
382
+ const iconSrc = path.isAbsolute(shieldIcon)
383
+ ? shieldIcon
384
+ : path.resolve(projectRoot, shieldIcon);
385
+ if (fs.existsSync(iconSrc)) {
386
+ for (const target of ["ShieldConfiguration", "ShieldAction"]) {
387
+ const assetsDir = path.join(targetsDir, target, "assets");
388
+ if (!fs.existsSync(assetsDir)) {
389
+ fs.mkdirSync(assetsDir, { recursive: true });
390
+ }
391
+ fs.copyFileSync(iconSrc, path.join(assetsDir, "shield-icon.png"));
392
+ }
393
+ }
394
+ }
255
395
  }
256
396
 
257
397
  // Auto-register `@bacons/apple-targets` so users don't have to add it to
@@ -311,162 +451,9 @@ function withAppBlockerIOS(config, pluginConfig) {
311
451
  }
312
452
  }
313
453
 
314
- // Templates were copied to `targets/` at config-eval time (see
315
- // withAppBlockerIOS). This block only resolves the directory for the
316
- // placeholder-substitution and shield-icon-copy steps below.
317
- const targetsDir = path.join(path.dirname(platformRoot), "targets");
318
-
319
- // Helper: hex to RGB floats
320
- function hexToRgb(hex) {
321
- const h = hex.replace("#", "");
322
- return {
323
- r: (parseInt(h.substring(0, 2), 16) / 255).toFixed(3),
324
- g: (parseInt(h.substring(2, 4), 16) / 255).toFixed(3),
325
- b: (parseInt(h.substring(4, 6), 16) / 255).toFixed(3),
326
- };
327
- }
328
-
329
- // Shield config defaults
330
- const shield = pluginConfig?.ios?.shield || {};
331
- const primaryColor = hexToRgb(shield.primaryButtonColor || "#fb6107");
332
- const titleColor = hexToRgb(shield.titleColor || "#111111");
333
- const subtitleColor = hexToRgb(shield.subtitleColor || "#737373");
334
- // Background color (solid) - separate from blur
335
- const bgColorHex = shield.backgroundColor || null;
336
- const bgColor = bgColorHex ? hexToRgb(bgColorHex) : null;
337
-
338
- // Blur style mapping
339
- const blurStyleMap = {
340
- "systemUltraThinMaterial": ".systemUltraThinMaterial",
341
- "systemThinMaterial": ".systemThinMaterial",
342
- "systemMaterial": ".systemMaterial",
343
- "systemThickMaterial": ".systemThickMaterial",
344
- "systemChromeMaterial": ".systemChromeMaterial",
345
- "systemUltraThinMaterialLight": ".systemUltraThinMaterialLight",
346
- "systemThinMaterialLight": ".systemThinMaterialLight",
347
- "systemMaterialLight": ".systemMaterialLight",
348
- "systemThickMaterialLight": ".systemThickMaterialLight",
349
- "systemChromeMaterialLight": ".systemChromeMaterialLight",
350
- "systemUltraThinMaterialDark": ".systemUltraThinMaterialDark",
351
- "systemThinMaterialDark": ".systemThinMaterialDark",
352
- "systemMaterialDark": ".systemMaterialDark",
353
- "systemThickMaterialDark": ".systemThickMaterialDark",
354
- "systemChromeMaterialDark": ".systemChromeMaterialDark",
355
- "regular": ".regular",
356
- "prominent": ".prominent",
357
- "light": ".light",
358
- "dark": ".dark",
359
- "extraLight": ".extraLight",
360
- };
361
- const blurRaw = shield.backgroundBlurStyle || (bgColorHex ? null : "systemThickMaterial");
362
- const blurSwift = blurRaw && blurStyleMap[blurRaw] ? blurStyleMap[blurRaw] : null;
363
-
364
- // Notification config (shown when the user taps the Shield primary button).
365
- // All copy is configurable so non-English apps can localize without forking.
366
- const notification = pluginConfig?.ios?.notification || {};
367
- const notificationTitle = notification.title || "App Blocker";
368
- const notificationBody = notification.body || "Tap to return to the app and complete the unlock challenge.";
369
- // attachIcon defaults to true to preserve current behavior; set to false
370
- // to drop the duplicate icon attachment so only the system app icon shows.
371
- const notificationAttachIcon = notification.attachIcon === false ? "false" : "true";
372
-
373
- // Temporary-unlock state copy (shown when the user has just earned time
374
- // and the Shield is briefly visible while ManagedSettings clears).
375
- const tempUnlockTitle = shield.tempUnlockTitle || "Almost there!";
376
- const tempUnlockSubtitle = shield.tempUnlockSubtitle || "Your free time is loading. Try again in a moment.";
377
- const tempUnlockButtonLabel = shield.tempUnlockButtonLabel || "OK";
378
-
379
- // "You have N apps blocked" suffix appended to the subtitle when more
380
- // than one app is blocked. Set countSuffix to "" to drop it entirely,
381
- // or to a localized template like " יש לך {count} אפליקציות חסומות.".
382
- // Defaults preserve the legacy English suffix.
383
- const countSuffixTemplate = shield.countSuffix !== undefined
384
- ? shield.countSuffix
385
- : " You have {count} apps blocked.";
386
-
387
- // Swift string-literal escaping. Plugin substitutions land inside `"..."`
388
- // literals so backslashes, quotes, and the Swift interpolation escape
389
- // `\(` MUST all be escaped or the extension fails to compile.
390
- function escapeSwiftString(s) {
391
- return String(s)
392
- .replace(/\\/g, "\\\\")
393
- .replace(/"/g, '\\"')
394
- .replace(/\n/g, "\\n")
395
- .replace(/\r/g, "\\r");
396
- }
397
-
398
- // Render the count suffix template into a Swift expression. We use
399
- // `\(count)` interpolation when the template includes `{count}` so the
400
- // runtime value is substituted. Empty template → empty literal.
401
- function renderCountSuffixSwift(template) {
402
- if (!template) return '""';
403
- const escaped = escapeSwiftString(template);
404
- return `"${escaped.replace(/\{count\}/g, "\\(count)")}"`;
405
- }
406
-
407
- // All placeholder replacements
408
- const replacements = {
409
- "APP_GROUP_PLACEHOLDER": appGroup,
410
- "SHIELD_TITLE_PLACEHOLDER": shield.title || "Hold on!",
411
- "SHIELD_SUBTITLE_PLACEHOLDER": shield.subtitle || "{appName} is blocked.",
412
- "SHIELD_PRIMARY_BUTTON_PLACEHOLDER": shield.primaryButtonLabel || "Earn Free Time",
413
- "SHIELD_SECONDARY_BUTTON_PLACEHOLDER": shield.secondaryButtonLabel === null ? "none" : (shield.secondaryButtonLabel || "Not now"),
414
- "SHIELD_TEMP_UNLOCK_TITLE_PLACEHOLDER": tempUnlockTitle,
415
- "SHIELD_TEMP_UNLOCK_SUBTITLE_PLACEHOLDER": tempUnlockSubtitle,
416
- "SHIELD_TEMP_UNLOCK_BUTTON_PLACEHOLDER": tempUnlockButtonLabel,
417
- "SHIELD_COUNT_SUFFIX_SWIFT_PLACEHOLDER": renderCountSuffixSwift(countSuffixTemplate),
418
- "NOTIFICATION_TITLE_PLACEHOLDER": notificationTitle,
419
- "NOTIFICATION_BODY_PLACEHOLDER": notificationBody,
420
- "NOTIFICATION_ATTACH_ICON_PLACEHOLDER": notificationAttachIcon,
421
- "SHIELD_PRIMARY_R_PLACEHOLDER": primaryColor.r,
422
- "SHIELD_PRIMARY_G_PLACEHOLDER": primaryColor.g,
423
- "SHIELD_PRIMARY_B_PLACEHOLDER": primaryColor.b,
424
- "SHIELD_TITLE_R_PLACEHOLDER": titleColor.r,
425
- "SHIELD_TITLE_G_PLACEHOLDER": titleColor.g,
426
- "SHIELD_TITLE_B_PLACEHOLDER": titleColor.b,
427
- "SHIELD_SUBTITLE_R_PLACEHOLDER": subtitleColor.r,
428
- "SHIELD_SUBTITLE_G_PLACEHOLDER": subtitleColor.g,
429
- "SHIELD_SUBTITLE_B_PLACEHOLDER": subtitleColor.b,
430
- "SHIELD_BG_COLOR_PLACEHOLDER": bgColor
431
- ? `UIColor(red: ${bgColor.r}, green: ${bgColor.g}, blue: ${bgColor.b}, alpha: 1.0)`
432
- : "nil",
433
- "SHIELD_BLUR_STYLE_PLACEHOLDER": blurSwift || "nil",
434
- };
435
-
436
- // Inject all placeholders into extension Swift files
437
- if (fs.existsSync(targetsDir)) {
438
- const dirs = fs.readdirSync(targetsDir);
439
- for (const dir of dirs) {
440
- const dirPath = path.join(targetsDir, dir);
441
- if (!fs.statSync(dirPath).isDirectory()) continue;
442
- const files = fs.readdirSync(dirPath);
443
- for (const file of files) {
444
- if (!file.endsWith(".swift")) continue;
445
- const filePath = path.join(dirPath, file);
446
- let content = fs.readFileSync(filePath, "utf-8");
447
- for (const [key, value] of Object.entries(replacements)) {
448
- content = content.replace(new RegExp(key, "g"), value);
449
- }
450
- fs.writeFileSync(filePath, content);
451
- }
452
- }
453
- }
454
-
455
- // Copy shield icon to ShieldConfiguration and ShieldAction target assets
456
- const shieldIcon = pluginConfig?.ios?.shield?.icon;
457
- if (shieldIcon) {
458
- const projectRoot = path.dirname(platformRoot);
459
- const iconSrc = path.resolve(projectRoot, shieldIcon);
460
- if (fs.existsSync(iconSrc)) {
461
- for (const target of ["ShieldConfiguration", "ShieldAction"]) {
462
- const assetsDir = path.join(targetsDir, target, "assets");
463
- if (!fs.existsSync(assetsDir)) {
464
- fs.mkdirSync(assetsDir, { recursive: true });
465
- }
466
- fs.copyFileSync(iconSrc, path.join(assetsDir, "shield-icon.png"));
467
- }
468
- }
469
- }
454
+ // Target Swift files + shield icon are produced at config-eval time
455
+ // (see withAppBlockerIOS). The block below only handles `ios/` patches
456
+ // that depend on the prebuilt platform tree.
470
457
 
471
458
  return config;
472
459
  },