expo-app-blocker 0.1.69 → 0.1.71

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.
@@ -14,32 +14,12 @@ public class ExpoAppBlockerModule: Module {
14
14
  private var sharedDefaults: UserDefaults?
15
15
  private let userDefaults = UserDefaults.standard
16
16
  private let blockConfigStorageKey = "appBlocker.blockConfiguration.v1"
17
- // Stores the granted earned-time budget in **seconds** (Int). Presence with a
18
- // value > 0 means a temporary unlock is active. Enforcement is usage-based: the
19
- // shield is re-applied by the DeviceActivityMonitor once cumulative foreground
20
- // usage of the blocked apps reaches the threshold (see `startUsageBasedRelock`).
21
- // Stores the granted budget in SECONDS (Int). Presence with value > 0 means a
22
- // temporary unlock is active; the monitor reads it to know when to fully re-block.
17
+ // Stores the grant's wall-clock expiration (Date). Presence + a future date means
18
+ // a temporary unlock is active; the shield is re-applied once it passes.
23
19
  private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
24
20
  private let unlockActivityName = "appBlocker.temporaryUnlock"
25
- // Sub-minute usage steps. We register one DeviceActivityEvent per `usageStepSeconds`
26
- // of the budget (threshold = k×step seconds of measured usage). Each step's
27
- // eventDidReachThreshold lets the monitor extension write consumed SECONDS back to
28
- // the App Group — readable by the host app (unlike DeviceActivityReport). The event
29
- // name carries its threshold in seconds (`appBlocker.usageStep.<seconds>`).
30
- private let usageStepEventPrefix = "appBlocker.usageStep."
31
- // Consumed seconds of the active unlock, written by the monitor extension.
32
- private let usageConsumedKey = "appBlocker.usageConsumedSeconds.v1"
33
- // Target resolution. Apple's thresholds are coarse/unreliable below ~a minute, so
34
- // 30s is a best-effort finer grain; the per-step events that don't fire are still
35
- // backstopped by later (coarser-elapsed) ones and the final re-block threshold.
36
- private let usageStepSeconds = 30
37
- // Cap on registered step events. For large budgets the step auto-coarsens so the
38
- // event count stays under this (Apple degrades with too many events).
39
- private let maxUsageSteps = 60
40
21
  private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
41
22
  private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
42
- private let shieldInvokeCountKey = "appBlocker.shieldInvokeCount.v1"
43
23
  private let minimumTemporaryUnlockMinutes = 1
44
24
  private var didLoadPersistedConfig = false
45
25
 
@@ -217,30 +197,9 @@ public class ExpoAppBlockerModule: Module {
217
197
  defaults.removeObject(forKey: self.pendingInterceptsKey)
218
198
  defaults.synchronize()
219
199
  }
220
- let invokes = defaults.integer(forKey: self.shieldInvokeCountKey)
221
- NSLog("[appblocker] drainPendingIntercepts: returning \(queue.count) shieldInvokes=\(invokes)")
222
200
  return queue
223
201
  }
224
202
 
225
- // TEMP diagnostic: surfaces whether the shield extension is being
226
- // invoked at all (shieldInvokeCount) and the current queue depth,
227
- // without clearing anything.
228
- Function("debugInterceptState") { () -> [String: Any] in
229
- guard let defaults = self.sharedDefaults else { return ["available": false] }
230
- defaults.synchronize()
231
- var depth = 0
232
- if let json = defaults.string(forKey: self.pendingInterceptsKey),
233
- let data = json.data(using: .utf8),
234
- let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
235
- depth = parsed.count
236
- }
237
- return [
238
- "available": true,
239
- "shieldInvokeCount": defaults.integer(forKey: self.shieldInvokeCountKey),
240
- "queueDepth": depth,
241
- ]
242
- }
243
-
244
203
  Function("isAppBlocked") { (bundleIdentifier: String) -> Bool in
245
204
  self.ensureLoadedPersistedConfig()
246
205
  guard let config = self.currentBlockConfig else {
@@ -261,9 +220,11 @@ public class ExpoAppBlockerModule: Module {
261
220
  return
262
221
  }
263
222
 
264
- let budgetSeconds = sanitizedDurationMinutes * 60
265
- self.sharedDefaults?.set(budgetSeconds, forKey: self.temporaryUnlockKey)
266
- self.sharedDefaults?.set(0, forKey: self.usageConsumedKey)
223
+ // Wall-clock expiry is the source of truth. (Usage-based relock was
224
+ // removed: Apple won't fire DeviceActivity usage thresholds under ~15 min,
225
+ // so short earned-time budgets never re-blocked and the countdown froze.)
226
+ let expirationDate = Date().addingTimeInterval(TimeInterval(sanitizedDurationMinutes * 60))
227
+ self.sharedDefaults?.set(expirationDate, forKey: self.temporaryUnlockKey)
267
228
 
268
229
  DispatchQueue.main.async {
269
230
  self.store.shield.applications = nil
@@ -271,40 +232,50 @@ public class ExpoAppBlockerModule: Module {
271
232
  self.store.shield.webDomains = nil
272
233
  }
273
234
 
274
- // Re-block once cumulative *usage* of the blocked apps hits the budget.
275
- // iOS only counts foreground time, so the budget pauses/resumes for free.
235
+ // Best-effort schedule: only fires for grants ~15 min (Apple's minimum).
236
+ // Shorter grants throw here and are re-blocked by the host instead
237
+ // getRemainingUnlockTime and the foreground checkAndApplyUnlockState relock
238
+ // once the expiration passes.
276
239
  do {
277
- try self.startUsageBasedRelock(budgetSeconds: budgetSeconds)
240
+ try self.scheduleRelockActivity(expirationDate: expirationDate)
278
241
  } catch {
279
- // Monitoring failed to start (e.g. threshold below Apple's granularity).
280
- // The unlock still happens; the shield falls back to re-applying on the
281
- // next app launch via checkAndApplyUnlockState.
282
- print("[AppBlocker] Usage-based relock failed to start: \(error.localizedDescription)")
242
+ print("[AppBlocker] Schedule relock failed (duration may be too short): \(error.localizedDescription)")
283
243
  }
284
244
 
285
- // `expiresAt` is a best-effort wall-clock hint only — actual relock is
286
- // usage-based, so the real expiry depends on how much the apps are used.
287
- let approxExpiresAt = Date().addingTimeInterval(TimeInterval(budgetSeconds)).timeIntervalSince1970
288
245
  DispatchQueue.main.async {
289
246
  promise.resolve([
290
247
  "unlocked": true,
291
- "expiresAt": approxExpiresAt
248
+ "expiresAt": expirationDate.timeIntervalSince1970
292
249
  ])
293
250
  }
294
251
  }
295
252
  }
296
253
 
297
254
  Function("isTemporarilyUnlocked") { () -> Bool in
298
- return self.remainingUnlockSeconds() > 0
255
+ guard let expirationDate = self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Date else {
256
+ return false
257
+ }
258
+ if Date() < expirationDate {
259
+ return true
260
+ }
261
+ self.relockApps()
262
+ return false
299
263
  }
300
264
 
301
- // Remaining = granted budget minus the consumed minutes the monitor extension
302
- // has written to the App Group as usage accrued. Steps down by ~1 minute per
303
- // minute of actual blocked-app usage and freezes while the apps aren't used.
304
- // It's minute-granular (not smooth seconds) and may lag real usage slightly —
305
- // Apple's thresholds are coarse. Drops to 0 once the budget is fully spent.
265
+ // Wall-clock remaining seconds, counting down in real time. When the host app
266
+ // polls this (e.g. the blocking-status banner, every second) and the grant has
267
+ // expired, it re-applies the shield the primary relock path for grants under
268
+ // ~15 min, which Apple's DeviceActivity cannot enforce on its own.
306
269
  Function("getRemainingUnlockTime") { () -> Int in
307
- return self.remainingUnlockSeconds()
270
+ guard let expirationDate = self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Date else {
271
+ return 0
272
+ }
273
+ let remaining = expirationDate.timeIntervalSince(Date())
274
+ if remaining > 0 {
275
+ return Int(remaining)
276
+ }
277
+ self.relockApps()
278
+ return 0
308
279
  }
309
280
 
310
281
  AsyncFunction("relockApps") { (promise: Promise) in
@@ -400,14 +371,25 @@ public class ExpoAppBlockerModule: Module {
400
371
 
401
372
  ensureLoadedPersistedConfig()
402
373
 
403
- if remainingUnlockSeconds() > 0 {
404
- // Unlock still active — keep the shield off. The usage-threshold monitor
405
- // started in `temporaryUnlock` persists across app launches and will
406
- // re-apply the shield once the usage budget is spent.
407
- DispatchQueue.main.async {
408
- self.store.shield.applications = nil
409
- self.store.shield.applicationCategories = nil
410
- self.store.shield.webDomains = nil
374
+ if let expirationDate = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date {
375
+ let remaining = expirationDate.timeIntervalSince(Date())
376
+
377
+ if remaining > 0 {
378
+ // Unlock still active — keep the shield off and (re-)arm the schedule so a
379
+ // grant ≥ ~15 min re-blocks even while the user stays in a blocked app.
380
+ DispatchQueue.main.async {
381
+ self.store.shield.applications = nil
382
+ self.store.shield.applicationCategories = nil
383
+ self.store.shield.webDomains = nil
384
+ }
385
+
386
+ do {
387
+ try scheduleRelockActivity(expirationDate: expirationDate)
388
+ } catch {
389
+ relockApps()
390
+ }
391
+ } else {
392
+ relockApps()
411
393
  }
412
394
  } else if let config = currentBlockConfig {
413
395
  do {
@@ -540,86 +522,47 @@ public class ExpoAppBlockerModule: Module {
540
522
 
541
523
  // MARK: - Activity Scheduling
542
524
 
543
- /// Start usage-based monitoring: re-apply the shield once cumulative foreground
544
- /// usage of the blocked apps reaches `budgetSeconds`. iOS counts only active usage,
545
- /// so the budget naturally pauses when the apps aren't in use and resumes on return.
525
+ /// Arm a time-based re-block: a `DeviceActivitySchedule` whose interval ends at
526
+ /// `expirationDate`. The DeviceActivityMonitor's `intervalDidEnd` re-applies the
527
+ /// shield at that wall-clock moment, so a grant re-blocks even while the user
528
+ /// stays in a blocked app.
546
529
  ///
547
- /// We register a series of threshold events stepping by `usageStepSeconds` (auto-
548
- /// coarsened so the count stays under `maxUsageSteps`). The event name carries its
549
- /// threshold in seconds (`usageStepEventPrefix + <seconds>`). Each step's
550
- /// `eventDidReachThreshold` (in the DeviceActivityMonitor extension) writes the
551
- /// consumed-second count to the App Group — giving the host app a sub-minute,
552
- /// pause-when-away consumed counter (`getRemainingUnlockTime`). The final step
553
- /// (== budget) is where the monitor re-applies the shield.
554
- ///
555
- /// Uses an all-day repeating schedule purely as a container for the events.
556
- /// `repeats: true` keeps the monitor alive across days so apps are never left
557
- /// unlocked without enforcement; the tradeoff is that `intervalDidEnd` clears any
558
- /// unspent budget at the day boundary (earned time does not carry across midnight).
559
- ///
560
- /// Note: Apple's usage thresholds are coarse/unreliable below ~a minute, so the
561
- /// finest steps may fire late or be skipped — later steps and the final threshold
562
- /// still re-block, bounding overshoot to roughly one step.
563
- private func startUsageBasedRelock(budgetSeconds: Int) throws {
530
+ /// Apple requires the interval to span at least ~15 minutes, so grants shorter
531
+ /// than that make `startMonitoring` throw the caller treats that as non-fatal
532
+ /// and relies on the host-side relock (getRemainingUnlockTime poll /
533
+ /// checkAndApplyUnlockState on foreground) once the expiration passes.
534
+ private func scheduleRelockActivity(expirationDate: Date) throws {
564
535
  scheduleLock.lock()
565
536
  defer { scheduleLock.unlock() }
566
537
 
567
538
  cancelRelockActivityLocked()
568
539
 
569
- guard let config = currentBlockConfig else {
570
- print("[AppBlocker] startUsageBasedRelock: no active config, monitoring not started")
571
- return
572
- }
573
- let appTokens = Set(config.items.compactMap { $0.appToken })
574
- let categoryTokens = Set(config.items.compactMap { $0.categoryToken })
575
- let webDomainTokens = Set(config.items.compactMap { $0.webDomainToken })
576
-
577
- guard !appTokens.isEmpty || !categoryTokens.isEmpty || !webDomainTokens.isEmpty else {
578
- print("[AppBlocker] startUsageBasedRelock: no blockable tokens, monitoring not started")
579
- return
580
- }
581
-
582
- let budget = max(1, budgetSeconds)
583
- // Step by usageStepSeconds, but coarsen so we never exceed maxUsageSteps events.
584
- let step = max(usageStepSeconds, Int(ceil(Double(budget) / Double(maxUsageSteps))))
585
- var thresholds: [Int] = []
586
- var t = step
587
- while t < budget {
588
- thresholds.append(t)
589
- t += step
590
- }
591
- thresholds.append(budget) // always include the exact budget as the final re-block
592
-
593
- var events: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:]
594
- for seconds in thresholds {
595
- events[DeviceActivityEvent.Name("\(usageStepEventPrefix)\(seconds)")] = DeviceActivityEvent(
596
- applications: appTokens,
597
- categories: categoryTokens,
598
- webDomains: webDomainTokens,
599
- threshold: dateComponents(fromSeconds: seconds)
540
+ let activityName = DeviceActivityName(unlockActivityName)
541
+ let calendar = Calendar.current
542
+ let now = Date()
543
+ let startComponents = calendar.dateComponents([.hour, .minute, .second], from: now)
544
+ let endComponents = calendar.dateComponents([.hour, .minute, .second], from: expirationDate)
545
+ let nowDay = calendar.startOfDay(for: now)
546
+ let expirationDay = calendar.startOfDay(for: expirationDate)
547
+
548
+ let schedule: DeviceActivitySchedule
549
+ if nowDay == expirationDay {
550
+ schedule = DeviceActivitySchedule(
551
+ intervalStart: startComponents,
552
+ intervalEnd: endComponents,
553
+ repeats: false
554
+ )
555
+ } else {
556
+ // Expiration crosses midnight — cap the interval at end-of-day; the host
557
+ // relock handles the remainder on next foreground.
558
+ schedule = DeviceActivitySchedule(
559
+ intervalStart: startComponents,
560
+ intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
561
+ repeats: false
600
562
  )
601
563
  }
602
564
 
603
- // CRITICAL: the interval must start ~now, not at midnight. DeviceActivityEvent
604
- // thresholds measure usage accumulated *within the interval, from its start*. An
605
- // all-day [00:00, 23:59] interval would count usage since midnight — so any prior
606
- // blocked-app use today would have already crossed the thresholds before monitoring
607
- // began, and the system never fires a (new) crossing → the shield never re-applies.
608
- // Starting the interval at the current time makes thresholds count from the unlock
609
- // moment. repeats:false because we re-register on every unlock.
610
- let now = Date()
611
- let startComps = Calendar.current.dateComponents([.hour, .minute, .second], from: now)
612
- let schedule = DeviceActivitySchedule(
613
- intervalStart: startComps,
614
- intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
615
- repeats: false
616
- )
617
-
618
- try activityCenter.startMonitoring(
619
- DeviceActivityName(unlockActivityName),
620
- during: schedule,
621
- events: events
622
- )
565
+ try activityCenter.startMonitoring(activityName, during: schedule)
623
566
  }
624
567
 
625
568
  private func cancelRelockActivity() {
@@ -640,28 +583,17 @@ public class ExpoAppBlockerModule: Module {
640
583
  /// Clear all persisted unlock state (budget + consumed counter).
641
584
  private func clearUnlockState() {
642
585
  sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
643
- sharedDefaults?.removeObject(forKey: usageConsumedKey)
644
586
  }
645
587
 
646
- /// Seconds of earned time still available: the granted budget minus the seconds
647
- /// of blocked-app usage the monitor extension has recorded. 0 if no active unlock
648
- /// or the budget is fully consumed. Clamped at 0.
588
+ /// Seconds of earned time still available: wall-clock time until the grant's
589
+ /// expiration. 0 if no active unlock or it has already expired. Clamped at 0.
649
590
  private func remainingUnlockSeconds() -> Int {
650
- let budgetSeconds = (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
651
- if budgetSeconds <= 0 { return 0 }
652
- let consumedSeconds = (sharedDefaults?.object(forKey: usageConsumedKey) as? Int) ?? 0
653
- return max(0, budgetSeconds - consumedSeconds)
591
+ guard let expirationDate = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date else {
592
+ return 0
593
+ }
594
+ return max(0, Int(expirationDate.timeIntervalSince(Date())))
654
595
  }
655
596
 
656
- /// Build a DateComponents threshold from a total number of seconds (normalized
657
- /// into hour/minute/second so the system reads it cleanly).
658
- private func dateComponents(fromSeconds total: Int) -> DateComponents {
659
- return DateComponents(
660
- hour: total / 3600,
661
- minute: (total % 3600) / 60,
662
- second: total % 60
663
- )
664
- }
665
597
 
666
598
  // MARK: - Serialization
667
599
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
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",
package/src/index.ts CHANGED
@@ -244,16 +244,6 @@ export function drainPendingIntercepts(): PendingIntercept[] {
244
244
  return NativeModule.drainPendingIntercepts() ?? [];
245
245
  }
246
246
 
247
- /**
248
- * TEMP diagnostic: returns `{ shieldInvokeCount, queueDepth }` (iOS) without
249
- * clearing the queue. `shieldInvokeCount` proves whether the shield
250
- * extension is being invoked by the OS at all.
251
- */
252
- export function debugInterceptState(): Record<string, unknown> {
253
- if (Platform.OS !== "ios" && Platform.OS !== "android") return {};
254
- return (NativeModule.debugInterceptState?.() as Record<string, unknown>) ?? {};
255
- }
256
-
257
247
  export function addPendingUnlockListener(
258
248
  handler: () => void
259
249
  ): { remove: () => void } | null {
@@ -11,12 +11,10 @@ import Foundation
11
11
  class DeviceActivityMonitorExtension: DeviceActivityMonitor {
12
12
  // CONFIGURE: Replace with your App Group identifier
13
13
  private let appGroupIdentifier = "APP_GROUP_PLACEHOLDER"
14
+ // Holds the grant's wall-clock expiration (Date); kept in sync with
15
+ // ExpoAppBlockerModule.swift. Presence + a future date means an unlock is active.
14
16
  private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
15
17
  private let blockConfigStorageKey = "appBlocker.blockConfiguration.v1"
16
- // Keep these in sync with ExpoAppBlockerModule.swift. temporaryUnlockKey holds the
17
- // budget in seconds; usageConsumedKey accumulates consumed seconds.
18
- private let usageStepEventPrefix = "appBlocker.usageStep."
19
- private let usageConsumedKey = "appBlocker.usageConsumedSeconds.v1"
20
18
 
21
19
  private let store = ManagedSettingsStore()
22
20
  private var sharedDefaults: UserDefaults?
@@ -28,61 +26,20 @@ class DeviceActivityMonitorExtension: DeviceActivityMonitor {
28
26
 
29
27
  override func intervalDidEnd(for activity: DeviceActivityName) {
30
28
  super.intervalDidEnd(for: activity)
31
- // Intentionally a no-op. Re-block is driven solely by eventDidReachThreshold
32
- // (the usage budget). We must NOT clear unlock state or reapply the shield here:
33
- // stopMonitoring() during a re-grant also fires intervalDidEnd, which would wipe
34
- // the freshly-granted budget and re-shield the apps immediately after the user
35
- // earned time. (Tradeoff: an unspent budget is not force-cleared at the daily
36
- // schedule boundary — an acceptable edge case.)
37
- }
38
-
39
- override func intervalDidStart(for activity: DeviceActivityName) {
40
- super.intervalDidStart(for: activity)
41
- }
42
-
43
- // Fires once per usage step (threshold = N seconds of measured blocked-app use).
44
- // We write the consumed-second count back to the App Group so the host app can
45
- // show a live, pause-when-away countdown; once consumed reaches the budget we
46
- // clear the unlock and re-apply the shield.
47
- override func eventDidReachThreshold(
48
- _ event: DeviceActivityEvent.Name,
49
- activity: DeviceActivityName
50
- ) {
51
- super.eventDidReachThreshold(event, activity: activity)
52
-
53
- let stepSeconds = parseStepSeconds(from: event.rawValue)
54
- guard stepSeconds > 0 else {
55
- // Unknown event — treat as a full relock to stay safe.
56
- clearUnlockState()
57
- reapplyBlockConfiguration()
29
+ // The schedule's interval ends at the grant's wall-clock expiration → re-block.
30
+ // Guard against the spurious intervalDidEnd that stopMonitoring() fires during a
31
+ // re-grant: if the stored expiration is still in the future this is a re-arm, not
32
+ // an expiry, so leave the freshly-granted unlock intact.
33
+ if let expiration = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date,
34
+ Date() < expiration {
58
35
  return
59
36
  }
60
-
61
- // Record consumed seconds monotonically (steps can arrive out of order).
62
- let prev = sharedDefaults?.integer(forKey: usageConsumedKey) ?? 0
63
- if stepSeconds > prev {
64
- sharedDefaults?.set(stepSeconds, forKey: usageConsumedKey)
65
- }
66
-
67
- let budgetSeconds = sharedDefaults?.integer(forKey: temporaryUnlockKey) ?? 0
68
- if budgetSeconds <= 0 || stepSeconds >= budgetSeconds {
69
- // Budget fully spent — re-block.
70
- clearUnlockState()
71
- reapplyBlockConfiguration()
72
- }
73
- }
74
-
75
- /// Extract the threshold seconds from an event name like `appBlocker.usageStep.90`;
76
- /// 0 if not a usage step.
77
- private func parseStepSeconds(from rawName: String) -> Int {
78
- guard rawName.hasPrefix(usageStepEventPrefix) else { return 0 }
79
- let suffix = rawName.dropFirst(usageStepEventPrefix.count)
80
- return Int(suffix) ?? 0
37
+ sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
38
+ reapplyBlockConfiguration()
81
39
  }
82
40
 
83
- private func clearUnlockState() {
84
- sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
85
- sharedDefaults?.removeObject(forKey: usageConsumedKey)
41
+ override func intervalDidStart(for activity: DeviceActivityName) {
42
+ super.intervalDidStart(for: activity)
86
43
  }
87
44
 
88
45
  private func reapplyBlockConfiguration() {
@@ -8,7 +8,6 @@ class ShieldActionExtension: ShieldActionDelegate {
8
8
  private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
9
9
  private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
10
10
  private let lastInterceptTsKey = "appBlocker.lastInterceptTs.v1"
11
- private let shieldInvokeCountKey = "appBlocker.shieldInvokeCount.v1"
12
11
  private let interceptDebounceMs: Double = 2_000
13
12
  private let maxPendingIntercepts = 200
14
13
  private let pendingUnlockNotificationIdentifier = "expo.appblocker.pendingUnlock.local"
@@ -70,15 +69,9 @@ class ShieldActionExtension: ShieldActionDelegate {
70
69
  guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
71
70
  defaults.synchronize()
72
71
 
73
- let invokes = defaults.integer(forKey: shieldInvokeCountKey) + 1
74
- defaults.set(invokes, forKey: shieldInvokeCountKey)
75
-
76
72
  let nowMs = Date().timeIntervalSince1970 * 1000.0
77
73
  let lastMs = defaults.double(forKey: lastInterceptTsKey)
78
- if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs {
79
- defaults.synchronize()
80
- return
81
- }
74
+ if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs { return }
82
75
 
83
76
  var queue: [[String: Any]] = []
84
77
  if let json = defaults.string(forKey: pendingInterceptsKey),
@@ -96,7 +89,6 @@ class ShieldActionExtension: ShieldActionDelegate {
96
89
  }
97
90
  defaults.set(nowMs, forKey: lastInterceptTsKey)
98
91
  defaults.synchronize()
99
- NSLog("[appblocker] ShieldAction recordIntercept queued depth=\(queue.count) invokes=\(invokes)")
100
92
  }
101
93
 
102
94
  private func setPendingUnlockFlag() {
@@ -49,35 +49,22 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
49
49
  // those bursts into one logical block event.
50
50
  private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
51
51
  private let lastInterceptTsKey = "appBlocker.lastInterceptTs.v1"
52
- private let shieldInvokeCountKey = "appBlocker.shieldInvokeCount.v1"
53
52
  private let interceptDebounceMs: Double = 2_000
54
53
  private let maxPendingIntercepts = 200
55
54
 
55
+ // Best-effort block recording. iOS caches the shield configuration and
56
+ // does NOT reliably re-invoke this data source per open, so the action
57
+ // handler (ShieldAction) is the primary recorder; this is a bonus path
58
+ // for the cases the system does re-invoke. Writes share the same App
59
+ // Group JSON queue + debounce as ShieldAction.
56
60
  private func recordIntercept(appName: String) {
57
- guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else {
58
- NSLog("[appblocker] recordIntercept: no app-group defaults")
59
- return
60
- }
61
- // The extension is a short-lived process; pull a fresh view before
62
- // read-modify-write so we don't clobber the app's drain.
61
+ guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
63
62
  defaults.synchronize()
64
63
 
65
- // Diagnostic: count every shield render, before any debounce/branch,
66
- // so the app can confirm the extension is actually being invoked.
67
- let invokes = defaults.integer(forKey: shieldInvokeCountKey) + 1
68
- defaults.set(invokes, forKey: shieldInvokeCountKey)
69
-
70
64
  let nowMs = Date().timeIntervalSince1970 * 1000.0
71
65
  let lastMs = defaults.double(forKey: lastInterceptTsKey)
72
- if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs {
73
- defaults.synchronize()
74
- NSLog("[appblocker] recordIntercept: debounced (invokes=\(invokes))")
75
- return
76
- }
66
+ if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs { return }
77
67
 
78
- // Persist as a JSON string — the most robust representation to carry a
79
- // nested array across the extension→app process boundary via the App
80
- // Group (a raw [[String: Any]] does not reliably survive).
81
68
  var queue: [[String: Any]] = []
82
69
  if let json = defaults.string(forKey: pendingInterceptsKey),
83
70
  let data = json.data(using: .utf8),
@@ -93,10 +80,7 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
93
80
  defaults.set(json, forKey: pendingInterceptsKey)
94
81
  }
95
82
  defaults.set(nowMs, forKey: lastInterceptTsKey)
96
- // Force a flush to cfprefsd — the OS may suspend/kill the extension
97
- // immediately after rendering, before an unflushed write persists.
98
83
  defaults.synchronize()
99
- NSLog("[appblocker] recordIntercept: queued \(appName), depth=\(queue.count) invokes=\(invokes)")
100
84
  }
101
85
 
102
86
  private func makeConfig(appName: String) -> ShieldConfiguration {