77 */
88
99import { WarningScheduler } from './warnings.js' ;
10+ import { OfflineHandler } from './offline.js' ;
1011
1112// Activity ID 8 = Screen Time (device-level master switch)
1213const SCREEN_TIME_ACTIVITY = 8 ;
@@ -35,6 +36,15 @@ export class Checker {
3536 this . _hardLockTimeout = ( options . hardLockTimeout || 300 ) * 1000 ;
3637 this . _gracePeriod = ( options . gracePeriod || 300 ) * 1000 ;
3738
39+ // Disk-backed offline cache. The grace window is measured from the last
40+ // SUCCESSFUL check (persisted to ~/.allow2/cache.json), so it survives a
41+ // daemon restart during an outage — a restart can no longer reset the
42+ // grace clock (which would let a child farm grace by rebooting).
43+ this . _offline = options . offlineHandler || new OfflineHandler ( {
44+ gracePeriod : options . gracePeriod || 300 ,
45+ cachePath : options . cachePath ,
46+ } ) ;
47+
3848 this . _tz = Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone ;
3949
4050 // Per-activity state: Map<activityId, { allowed: boolean, remaining: number }>
@@ -142,7 +152,7 @@ export class Checker {
142152 try {
143153 await this . _doCheck ( ) ;
144154 } catch ( err ) {
145- this . _handleError ( err ) ;
155+ await this . _handleError ( err ) ;
146156 }
147157
148158 if ( this . _running ) {
@@ -168,11 +178,18 @@ export class Checker {
168178 log : true ,
169179 } ) ;
170180
171- // Successful API call — clear offline state
181+ // Successful API call — clear offline state and persist the result so
182+ // the offline grace window (and last-known-good decision) survive a
183+ // daemon restart.
172184 if ( this . _offlineSince ) {
173185 this . _offlineSince = null ;
174186 this . _offlineGraceEmitted = false ;
175187 }
188+ try {
189+ await this . _offline . cacheResult ( result ) ;
190+ } catch ( _e ) {
191+ // Disk write failure is non-fatal — enforcement continues.
192+ }
176193
177194 this . _processResult ( result ) ;
178195 }
@@ -261,21 +278,41 @@ export class Checker {
261278 } , this . _hardLockTimeout ) ;
262279 }
263280
264- _handleError ( err ) {
281+ async _handleError ( err ) {
265282 // HTTP 401 = credentials revoked, device unpaired
266283 if ( err && err . status === 401 ) {
267284 this . _emit ( 'unpaired' , { error : err } ) ;
268285 this . stop ( ) ;
269286 return ;
270287 }
271288
272- // Network / timeout errors → offline handling
289+ // Network / timeout errors → offline handling.
290+ //
291+ // Grace is measured from the last SUCCESSFUL check, read from the
292+ // disk-backed cache, so it survives a daemon restart mid-outage. If the
293+ // device has NEVER synced (no cache yet), fall back to in-memory
294+ // first-failure tracking so a brand-new device still gets its grace
295+ // window rather than an instant deny.
273296 const now = Date . now ( ) ;
274- if ( ! this . _offlineSince ) {
275- this . _offlineSince = now ;
297+ let offlineDuration ;
298+
299+ let elapsedSec = Infinity ;
300+ try {
301+ elapsedSec = await this . _offline . getGraceElapsed ( ) ;
302+ } catch ( _e ) {
303+ elapsedSec = Infinity ;
276304 }
277305
278- const offlineDuration = now - this . _offlineSince ;
306+ if ( elapsedSec !== Infinity && Number . isFinite ( elapsedSec ) ) {
307+ offlineDuration = elapsedSec * 1000 ;
308+ // Keep _offlineSince coherent for the event payload / diagnostics.
309+ this . _offlineSince = now - offlineDuration ;
310+ } else {
311+ if ( ! this . _offlineSince ) {
312+ this . _offlineSince = now ;
313+ }
314+ offlineDuration = now - this . _offlineSince ;
315+ }
279316
280317 if ( offlineDuration < this . _gracePeriod ) {
281318 if ( ! this . _offlineGraceEmitted ) {
0 commit comments