expo-app-blocker 0.1.43 → 0.1.45

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.
@@ -19,8 +19,13 @@ jobs:
19
19
 
20
20
  - name: Determine version bump
21
21
  id: version
22
+ # Never inline `${{ github.event.head_commit.message }}` into the
23
+ # script body — GitHub Actions substitutes it BEFORE the shell runs,
24
+ # so backticks / parens / `${...}` / `{vars}` get evaluated as
25
+ # commands. Always pass via env: so it lands as a quoted string.
26
+ env:
27
+ COMMIT_MSG: ${{ github.event.head_commit.message }}
22
28
  run: |
23
- COMMIT_MSG="${{ github.event.head_commit.message }}"
24
29
  if echo "$COMMIT_MSG" | grep -qw "\[major\]"; then
25
30
  echo "bump=major" >> $GITHUB_OUTPUT
26
31
  elif echo "$COMMIT_MSG" | grep -qw "\[minor\]"; then
package/README.md CHANGED
@@ -86,13 +86,22 @@ npx expo install expo-app-blocker
86
86
 
87
87
  ### 2. Configure `app.json`
88
88
 
89
+ > **Three things are required on iOS** and skipping any of them produces a cryptic build failure:
90
+ > 1. `ios.appleTeamId` — `@bacons/apple-targets` refuses to add the extension targets without it.
91
+ > 2. `ios.entitlements` with **Family Controls + the App Group** — the extension `expo-target.config.js` files read `ios.entitlements['com.apple.security.application-groups'][0]` to learn which App Group to embed. If it's missing they fall back to `group.expo.app-blocker` and the build fails with `An Application Group with Identifier 'group.expo.app-blocker' is not available`.
92
+ > 3. `@bacons/apple-targets` must appear in the `plugins` array **after** `expo-app-blocker`. The targets only get added to the Xcode project when this plugin runs.
93
+
89
94
  ```json
90
95
  {
91
96
  "expo": {
92
97
  "scheme": "myapp",
93
98
  "ios": {
94
99
  "bundleIdentifier": "com.yourapp.id",
95
- "appleTeamId": "YOUR_TEAM_ID"
100
+ "appleTeamId": "YOUR_TEAM_ID",
101
+ "entitlements": {
102
+ "com.apple.developer.family-controls": true,
103
+ "com.apple.security.application-groups": ["group.com.yourapp.blocker"]
104
+ }
96
105
  },
97
106
  "plugins": [
98
107
  ["expo-app-blocker", {
@@ -107,12 +116,15 @@ npx expo install expo-app-blocker
107
116
  "backgroundBlurStyle": "systemThickMaterialLight"
108
117
  }
109
118
  }
110
- }]
119
+ }],
120
+ "@bacons/apple-targets"
111
121
  ]
112
122
  }
113
123
  }
114
124
  ```
115
125
 
126
+ > The App Group identifier in `ios.entitlements` and `expo-app-blocker.ios.appGroup` **must match** — they describe the same shared-storage container for the main app and the three extensions.
127
+
116
128
  ### 3. Use in your app
117
129
 
118
130
  ```tsx
@@ -214,6 +226,13 @@ No special setup required beyond what the config plugin handles automatically.
214
226
  | `ios.shield.backgroundColor` | `string\|null` | `null` | Solid background color (hex). e.g. `"#f6f6f6"` for light, `"#1a1a2e"` for dark |
215
227
  | `ios.shield.backgroundBlurStyle` | `string\|null` | `"systemThickMaterial"` | Blur style. See [Blur Styles](#blur-styles) for all options |
216
228
  | `ios.shield.icon` | `string` | SF Symbol | Path to custom shield icon PNG (e.g. `"./assets/shield-icon.png"`) |
229
+ | `ios.shield.tempUnlockTitle` | `string` | `"Almost there!"` | Title shown briefly while ManagedSettings clears after a successful unlock |
230
+ | `ios.shield.tempUnlockSubtitle` | `string` | `"Your free time is loading. Try again in a moment."` | Subtitle for the temporary-unlock state |
231
+ | `ios.shield.tempUnlockButtonLabel` | `string` | `"OK"` | Button label for the temporary-unlock state |
232
+ | `ios.shield.countSuffix` | `string` | `" You have {count} apps blocked."` | Appended to the subtitle when more than one app is blocked. `{count}` is replaced with the integer at runtime. Set to `""` to drop the suffix entirely. |
233
+ | `ios.notification.title` | `string` | `"App Blocker"` | Title of the local notification fired when the user taps the Shield primary button. Set to a Hebrew/Arabic/etc. string to localize. |
234
+ | `ios.notification.body` | `string` | `"Tap to return to the app and complete the unlock challenge."` | Body of the unlock notification |
235
+ | `ios.notification.attachIcon` | `boolean` | `true` | Whether to attach the shield icon as a notification image. Set to `false` to avoid the duplicate-icon look on iOS notification banners (the system app icon is always shown either way). |
217
236
  | `android.notificationTitle` | `string` | `"App Blocked"` | Notification title |
218
237
  | `android.notificationText` | `string` | `"{appName} is blocked."` | Notification text |
219
238
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
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",
@@ -285,6 +285,49 @@ function withAppBlockerIOS(config, pluginConfig) {
285
285
  const blurRaw = shield.backgroundBlurStyle || (bgColorHex ? null : "systemThickMaterial");
286
286
  const blurSwift = blurRaw && blurStyleMap[blurRaw] ? blurStyleMap[blurRaw] : null;
287
287
 
288
+ // Notification config (shown when the user taps the Shield primary button).
289
+ // All copy is configurable so non-English apps can localize without forking.
290
+ const notification = pluginConfig?.ios?.notification || {};
291
+ const notificationTitle = notification.title || "App Blocker";
292
+ const notificationBody = notification.body || "Tap to return to the app and complete the unlock challenge.";
293
+ // attachIcon defaults to true to preserve current behavior; set to false
294
+ // to drop the duplicate icon attachment so only the system app icon shows.
295
+ const notificationAttachIcon = notification.attachIcon === false ? "false" : "true";
296
+
297
+ // Temporary-unlock state copy (shown when the user has just earned time
298
+ // and the Shield is briefly visible while ManagedSettings clears).
299
+ const tempUnlockTitle = shield.tempUnlockTitle || "Almost there!";
300
+ const tempUnlockSubtitle = shield.tempUnlockSubtitle || "Your free time is loading. Try again in a moment.";
301
+ const tempUnlockButtonLabel = shield.tempUnlockButtonLabel || "OK";
302
+
303
+ // "You have N apps blocked" suffix appended to the subtitle when more
304
+ // than one app is blocked. Set countSuffix to "" to drop it entirely,
305
+ // or to a localized template like " יש לך {count} אפליקציות חסומות.".
306
+ // Defaults preserve the legacy English suffix.
307
+ const countSuffixTemplate = shield.countSuffix !== undefined
308
+ ? shield.countSuffix
309
+ : " You have {count} apps blocked.";
310
+
311
+ // Swift string-literal escaping. Plugin substitutions land inside `"..."`
312
+ // literals so backslashes, quotes, and the Swift interpolation escape
313
+ // `\(` MUST all be escaped or the extension fails to compile.
314
+ function escapeSwiftString(s) {
315
+ return String(s)
316
+ .replace(/\\/g, "\\\\")
317
+ .replace(/"/g, '\\"')
318
+ .replace(/\n/g, "\\n")
319
+ .replace(/\r/g, "\\r");
320
+ }
321
+
322
+ // Render the count suffix template into a Swift expression. We use
323
+ // `\(count)` interpolation when the template includes `{count}` so the
324
+ // runtime value is substituted. Empty template → empty literal.
325
+ function renderCountSuffixSwift(template) {
326
+ if (!template) return '""';
327
+ const escaped = escapeSwiftString(template);
328
+ return `"${escaped.replace(/\{count\}/g, "\\(count)")}"`;
329
+ }
330
+
288
331
  // All placeholder replacements
289
332
  const replacements = {
290
333
  "APP_GROUP_PLACEHOLDER": appGroup,
@@ -292,6 +335,13 @@ function withAppBlockerIOS(config, pluginConfig) {
292
335
  "SHIELD_SUBTITLE_PLACEHOLDER": shield.subtitle || "{appName} is blocked.",
293
336
  "SHIELD_PRIMARY_BUTTON_PLACEHOLDER": shield.primaryButtonLabel || "Earn Free Time",
294
337
  "SHIELD_SECONDARY_BUTTON_PLACEHOLDER": shield.secondaryButtonLabel === null ? "none" : (shield.secondaryButtonLabel || "Not now"),
338
+ "SHIELD_TEMP_UNLOCK_TITLE_PLACEHOLDER": tempUnlockTitle,
339
+ "SHIELD_TEMP_UNLOCK_SUBTITLE_PLACEHOLDER": tempUnlockSubtitle,
340
+ "SHIELD_TEMP_UNLOCK_BUTTON_PLACEHOLDER": tempUnlockButtonLabel,
341
+ "SHIELD_COUNT_SUFFIX_SWIFT_PLACEHOLDER": renderCountSuffixSwift(countSuffixTemplate),
342
+ "NOTIFICATION_TITLE_PLACEHOLDER": notificationTitle,
343
+ "NOTIFICATION_BODY_PLACEHOLDER": notificationBody,
344
+ "NOTIFICATION_ATTACH_ICON_PLACEHOLDER": notificationAttachIcon,
295
345
  "SHIELD_PRIMARY_R_PLACEHOLDER": primaryColor.r,
296
346
  "SHIELD_PRIMARY_G_PLACEHOLDER": primaryColor.g,
297
347
  "SHIELD_PRIMARY_B_PLACEHOLDER": primaryColor.b,
@@ -7,6 +7,12 @@ class ShieldActionExtension: ShieldActionDelegate {
7
7
  private let appGroupIdentifier = "APP_GROUP_PLACEHOLDER"
8
8
  private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
9
9
  private let pendingUnlockNotificationIdentifier = "expo.appblocker.pendingUnlock.local"
10
+ // Notification copy + behavior — configurable via plugin options so apps
11
+ // can localize without forking. Defaults preserve the original English
12
+ // copy and the icon attachment.
13
+ private let notificationTitle = "NOTIFICATION_TITLE_PLACEHOLDER"
14
+ private let notificationBody = "NOTIFICATION_BODY_PLACEHOLDER"
15
+ private let notificationAttachIcon = NOTIFICATION_ATTACH_ICON_PLACEHOLDER
10
16
 
11
17
  override func handle(action: ShieldAction, for application: ApplicationToken, completionHandler: @escaping (ShieldActionResponse) -> Void) {
12
18
  handleAction(action, completionHandler: completionHandler)
@@ -67,13 +73,15 @@ class ShieldActionExtension: ShieldActionDelegate {
67
73
  let center = UNUserNotificationCenter.current()
68
74
 
69
75
  let content = UNMutableNotificationContent()
70
- content.title = "App Blocker"
71
- content.body = "Tap to return to the app and complete the unlock challenge."
76
+ content.title = notificationTitle
77
+ content.body = notificationBody
72
78
  content.sound = .default
73
79
  content.userInfo = ["link": "/unlock"]
74
80
 
75
- // Attach the app icon to the notification
76
- if let iconURL = iconFileURL() {
81
+ // Attach the app icon to the notification only when the app opted in.
82
+ // When false the system app icon is the only icon shown — avoids the
83
+ // "duplicate icon" look on iOS notification banners.
84
+ if notificationAttachIcon, let iconURL = iconFileURL() {
77
85
  if let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
78
86
  content.attachments = [attachment]
79
87
  }
@@ -11,6 +11,11 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
11
11
  private let shieldSubtitle = "SHIELD_SUBTITLE_PLACEHOLDER"
12
12
  private let shieldPrimaryButtonLabel = "SHIELD_PRIMARY_BUTTON_PLACEHOLDER"
13
13
  private let shieldSecondaryButtonLabel = "SHIELD_SECONDARY_BUTTON_PLACEHOLDER"
14
+ // Temporary-unlock state copy — shown briefly while ManagedSettings clears
15
+ // after a successful unlock. Configurable via plugin options.
16
+ private let shieldTempUnlockTitle = "SHIELD_TEMP_UNLOCK_TITLE_PLACEHOLDER"
17
+ private let shieldTempUnlockSubtitle = "SHIELD_TEMP_UNLOCK_SUBTITLE_PLACEHOLDER"
18
+ private let shieldTempUnlockButtonLabel = "SHIELD_TEMP_UNLOCK_BUTTON_PLACEHOLDER"
14
19
  private let shieldPrimaryButtonColor = UIColor(red: SHIELD_PRIMARY_R_PLACEHOLDER, green: SHIELD_PRIMARY_G_PLACEHOLDER, blue: SHIELD_PRIMARY_B_PLACEHOLDER, alpha: 1.0)
15
20
  private let shieldBackgroundColor: UIColor? = SHIELD_BG_COLOR_PLACEHOLDER
16
21
  private let shieldBlurStyle: UIBlurEffect.Style? = SHIELD_BLUR_STYLE_PLACEHOLDER
@@ -44,16 +49,18 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
44
49
  backgroundBlurStyle: shieldBlurStyle,
45
50
  backgroundColor: shieldBackgroundColor,
46
51
  icon: mascotIcon,
47
- title: ShieldConfiguration.Label(text: "Almost there!", color: shieldTitleColor),
48
- subtitle: ShieldConfiguration.Label(text: "Your free time is loading. Try again in a moment.", color: shieldSubtitleColor),
49
- primaryButtonLabel: ShieldConfiguration.Label(text: "OK", color: .white),
52
+ title: ShieldConfiguration.Label(text: shieldTempUnlockTitle, color: shieldTitleColor),
53
+ subtitle: ShieldConfiguration.Label(text: shieldTempUnlockSubtitle, color: shieldSubtitleColor),
54
+ primaryButtonLabel: ShieldConfiguration.Label(text: shieldTempUnlockButtonLabel, color: .white),
50
55
  primaryButtonBackgroundColor: shieldPrimaryButtonColor,
51
56
  secondaryButtonLabel: nil
52
57
  )
53
58
  }
54
59
 
55
60
  let count = getBlockedAppCount()
56
- let context = count > 1 ? " You have \(count) apps blocked." : ""
61
+ // The plugin replaces this placeholder with a Swift string literal
62
+ // containing `\(count)` interpolation, or `""` when the user opted out.
63
+ let context = count > 1 ? SHIELD_COUNT_SUFFIX_SWIFT_PLACEHOLDER : ""
57
64
  let subtitle = shieldSubtitle.replacingOccurrences(of: "{appName}", with: appName) + context
58
65
 
59
66
  let hasSecondary = !shieldSecondaryButtonLabel.isEmpty && shieldSecondaryButtonLabel != "none"