@@ -73,6 +73,25 @@ public class Enforcer
7373 private DateTime ? _periodWindowStartUtc ;
7474 private string ? _periodWindowLabel ;
7575
76+ // Verrou de confiance pour les plages hard mode : éditer periods.json
77+ // à la main pendant qu'une plage bloque activement (désactiver,
78+ // supprimer, ou simplement reculer son heure de fin) n'a plus d'effet
79+ // tant que l'heure de fin légitimement observée n'est pas atteinte.
80+ // Pas de porte de secours par code ici (volontairement, plus simple
81+ // que ce qu'on avait testé) - la vraie échappatoire reste de fermer
82+ // le watchdog élevé depuis le Gestionnaire des tâches (déjà le cas
83+ // avant ce verrou, ça continue de marcher pareil). En mémoire
84+ // seulement : un redémarrage du watchdog réinitialise la confiance,
85+ // même compromis assumé qu'ailleurs dans cette classe.
86+ private readonly Dictionary < string , TrustedHardPeriod > _trustedHardPeriods = new ( ) ;
87+
88+ internal class TrustedHardPeriod
89+ {
90+ public required List < string > Apps ;
91+ public required List < string > Sites ;
92+ public required DateTime LockedUntil ;
93+ }
94+
7695 private readonly Action < string > _notify ; // "done" | "break" | "work"
7796
7897 public Enforcer ( Action < string > notify )
@@ -105,20 +124,22 @@ public async Task TickAsync()
105124 {
106125 var s = Session . Load ( ) ; // fait aussi avancer les phases pomodoro dues
107126 var periodsData = Periods . Load ( ) ;
108- var activePeriods = Periods . GetActivePeriods ( periodsData , DateTime . Now ) ;
127+ var now = DateTime . Now ;
128+ var activePeriods = Periods . GetActivePeriods ( periodsData , now ) ;
109129 // Comme Session.IsBlockingActive pour une session manuelle : une
110130 // plage en PomodoroMode ne doit pas bloquer pendant sa pause,
111131 // même si elle reste "active" au sens fenêtre horaire (l'anneau
112132 // continue de tourner côté UI, seul le blocage s'arrête).
113- var blockingPeriods = activePeriods . Where ( p => ! p . PomodoroMode || ! Periods . GetPomodoroTiming ( p , DateTime . Now ) . IsBreak ) . ToList ( ) ;
133+ var blockingPeriods = activePeriods . Where ( p => ! p . PomodoroMode || ! Periods . GetPomodoroTiming ( p , now ) . IsBreak ) . ToList ( ) ;
134+ var phantomHardPeriods = ResolveHardLockedPeriods ( periodsData , now ) ;
114135 var sessionBlocking = Session . IsBlockingActive ( s ) ;
115136 // Indépendante de toute session/plage - "je ne veux jamais
116137 // pouvoir aller sur X" (voir AlwaysBlocklist). Un Load() de plus
117138 // par tick est négligeable : Blocklist.Load() tourne déjà tout
118139 // aussi souvent dès qu'une session manuelle est active, sur un
119140 // fichier tout aussi petit.
120141 var always = AlwaysBlocklist . Load ( ) ;
121- var shouldBlock = sessionBlocking || blockingPeriods . Count > 0 || always . Apps . Count > 0 || always . Sites . Count > 0 ;
142+ var shouldBlock = sessionBlocking || blockingPeriods . Count > 0 || always . Apps . Count > 0 || always . Sites . Count > 0 || phantomHardPeriods . Count > 0 ;
122143
123144 TrackPeriodHistory ( s , activePeriods ) ;
124145
@@ -150,6 +171,11 @@ public async Task TickAsync()
150171 }
151172 foreach ( var a in always . Apps ) apps . Add ( a ) ;
152173 foreach ( var st in always . Sites ) sites . Add ( st ) ;
174+ foreach ( var p in phantomHardPeriods )
175+ {
176+ foreach ( var a in p . Apps ) apps . Add ( a ) ;
177+ foreach ( var st in p . Sites ) sites . Add ( st ) ;
178+ }
153179
154180 try
155181 {
@@ -233,6 +259,41 @@ public async Task TickAsync()
233259 _lastPhase = curPhase ;
234260 }
235261
262+ // Plusieurs plages hard mode peuvent être verrouillées à la fois
263+ // (clé = Period.Id, stable même si la plage est renommée ensuite).
264+ // Retourne les plages "fantômes" - verrouillées en mémoire mais plus
265+ // présentes/actives légitimement dans periods.json (désactivée,
266+ // supprimée, horaire reculé...) - dont il faut quand même continuer
267+ // à bloquer apps/sites jusqu'à l'heure de fin retenue.
268+ internal List < TrustedHardPeriod > ResolveHardLockedPeriods ( PeriodsData periodsData , DateTime now )
269+ {
270+ var seenIds = new HashSet < string > ( ) ;
271+ foreach ( var p in periodsData . Periods )
272+ {
273+ if ( ! p . HardMode || ! Periods . PeriodCoversNow ( p , now ) ) continue ;
274+ seenIds . Add ( p . Id ) ;
275+ var end = now . AddSeconds ( Periods . GetTiming ( p , now ) . RemainingSeconds ) ;
276+ if ( ! _trustedHardPeriods . TryGetValue ( p . Id , out var trusted ) || end >= trusted . LockedUntil )
277+ {
278+ _trustedHardPeriods [ p . Id ] = new TrustedHardPeriod
279+ {
280+ Apps = new List < string > ( p . Apps ) ,
281+ Sites = new List < string > ( p . Sites ) ,
282+ LockedUntil = end ,
283+ } ;
284+ }
285+ }
286+
287+ var phantoms = new List < TrustedHardPeriod > ( ) ;
288+ foreach ( var ( id , trusted ) in _trustedHardPeriods . ToList ( ) )
289+ {
290+ if ( seenIds . Contains ( id ) ) continue ; // toujours légitimement actif, rien à défendre
291+ if ( now < trusted . LockedUntil ) phantoms . Add ( trusted ) ;
292+ else _trustedHardPeriods . Remove ( id ) ; // engagement écoulé
293+ }
294+ return phantoms ;
295+ }
296+
236297 // Une session manuelle a sa propre fenêtre (Session.Stop / fin de
237298 // pomodoro), donc on ne suit une fenêtre de plage que quand aucune
238299 // session n'est active du tout - sinon le même temps se
0 commit comments