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.
- package/ios/ExpoAppBlockerModule.swift +92 -160
- package/package.json +1 -1
- package/src/index.ts +0 -10
- package/targets/DeviceActivityMonitor/DeviceActivityMonitor.swift +12 -55
- package/targets/ShieldAction/ShieldActionExtension.swift +1 -9
- package/targets/ShieldConfiguration/ShieldConfigurationExtension.swift +7 -23
|
@@ -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
|
|
18
|
-
//
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
//
|
|
275
|
-
//
|
|
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.
|
|
240
|
+
try self.scheduleRelockActivity(expirationDate: expirationDate)
|
|
278
241
|
} catch {
|
|
279
|
-
|
|
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":
|
|
248
|
+
"expiresAt": expirationDate.timeIntervalSince1970
|
|
292
249
|
])
|
|
293
250
|
}
|
|
294
251
|
}
|
|
295
252
|
}
|
|
296
253
|
|
|
297
254
|
Function("isTemporarilyUnlocked") { () -> Bool in
|
|
298
|
-
|
|
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
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
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
|
-
|
|
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
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
///
|
|
544
|
-
///
|
|
545
|
-
///
|
|
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
|
-
///
|
|
548
|
-
///
|
|
549
|
-
///
|
|
550
|
-
///
|
|
551
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
let
|
|
574
|
-
let
|
|
575
|
-
let
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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:
|
|
647
|
-
///
|
|
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
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
return max(0,
|
|
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.
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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 {
|