-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp_state.rs
More file actions
2775 lines (2558 loc) · 124 KB
/
Copy pathapp_state.rs
File metadata and controls
2775 lines (2558 loc) · 124 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! [`AppState`]: the Tauri-managed shared application state (SPEC s11).
//!
//! Held behind Tauri's `State<AppState>` and reached by every IPC command
//! (SPEC s11.3). It owns the [`StateRepo`] handle plus one orchestrator
//! handle per account - the `Arc<dyn Orchestrator>` control surface and the
//! `JoinHandle` of its spawned run loop (SPEC s5: one orchestrator per
//! account). The remote-construction mode records whether assembly built
//! real `GoogleDriveStore`s or the in-memory fake (`DRIVEN_USE_FAKE_REMOTE`).
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use driven_core::orchestrator::Orchestrator;
use driven_core::state::StateRepo;
use driven_core::types::AccountId;
use driven_drive::fake::InMemoryRemoteStore;
use driven_power::SleepWakeMonitor;
use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
/// How the per-account remote store was constructed at assembly time.
///
/// Recorded so IPC / diagnostics can tell a real run from a fake-backed one
/// (`DRIVEN_USE_FAKE_REMOTE=1` selects [`RemoteMode::Fake`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteMode {
/// Real `GoogleDriveStore` built from the keyring refresh token.
RealGoogleDrive,
/// `InMemoryRemoteStore` (test / dev; `DRIVEN_USE_FAKE_REMOTE=1`).
Fake,
}
/// One account's live orchestrator: the control-surface handle plus EVERY
/// per-account tokio task spawned by `assembly::build_account` (SPEC s5,
/// ROADMAP M5 "no orphaned tokio tasks"; DESIGN s5.10.2 in-flight drain).
///
/// R-P1-1: a clean Quit must leave NO orphaned tasks. Four tasks are spawned
/// per account and ALL are tracked here so [`Self::shutdown`] can drain them:
/// - `run_loop`: [`Orchestrator::run`]. Stopped via `Orchestrator::shutdown()`.
/// - `watcher_bridge`: forwards `NotifyWatcher` scan-ticks into the
/// orchestrator. The watcher owns the `mpsc::Sender`, so its `recv().await`
/// never closes on its own; it must be signalled via [`Self::bridge_shutdown`]
/// (it `select!`s on that watch) or aborted.
/// - `event_bridge`: forwards the orchestrator's `OrchestratorEvent` broadcast
/// to the tray + webview. It ends naturally when the broadcast closes (the
/// orchestrator dropped) but is ALSO signalled so quit does not have to wait
/// on a `Lagged`/slow consumer; aborted on timeout.
/// - `power_poller`: the `RealPowerSource` 30s poll loop. It loops forever (no
/// natural end), so its handle is KEPT and ABORTED on shutdown - dropping it
/// (the old bug) orphaned the task.
///
/// Held so IPC can drive the orchestrator (`trigger` / `set_paused` / `state`)
/// and so [`Self::shutdown`] can stop + join every task on quit.
pub struct AccountHandle {
/// The per-account orchestrator control surface.
pub orchestrator: Arc<dyn Orchestrator>,
/// B2: the per-account LIVE crypto provider. Held so the source-command
/// layer can REFRESH its source metadata (`crypto.refresh(..)`) after a
/// source add / toggle / remove, so a mid-session encrypted source's key is
/// resolved on the next tick (not stranded `Unavailable` until restart).
pub crypto: Arc<crate::crypto_provider_impl::KeystoreCryptoProvider>,
/// The spawned run-loop task. Behind a `Mutex<Option<..>>` so the shutdown
/// path can TAKE + await it by value; `None` once drained.
run_loop: Mutex<Option<JoinHandle<()>>>,
/// The watcher-bridge task (forwards scan-ticks), or `None` when no enabled
/// source produced a watcher. Drained on shutdown.
watcher_bridge: Mutex<Option<JoinHandle<()>>>,
/// The orchestrator-event -> tray/IPC bridge task. Drained on shutdown.
event_bridge: Mutex<Option<JoinHandle<()>>>,
/// The power-source poller task. Looped forever; ABORTED on shutdown.
power_poller: Mutex<Option<JoinHandle<()>>>,
/// The OS sleep/wake EDGE monitor (DESIGN s5.10.1, issue #33), or `None`
/// when its per-OS registration failed at build time (the app then degrades
/// to the 30 s poll). TORN DOWN on shutdown - its `stop()` unregisters the
/// Win32 suspend/resume callback / stops the macOS `CFRunLoop` thread /
/// aborts the Linux logind DBus task - so no OS handle, thread, or task
/// outlives the account.
sleep_wake_monitor: Mutex<Option<SleepWakeMonitor>>,
/// Shutdown signal the watcher + event bridges `select!` on (R-P1-1). Set to
/// `true` by [`Self::shutdown`] so a bridge whose source never closes
/// (the watcher owns its `Sender`) still exits promptly.
bridge_shutdown: watch::Sender<bool>,
}
/// The SHORT per-task graceful-drain budget on quit (DESIGN s5.10.2) for the
/// AUXILIARY tasks (watcher bridge, event bridge, power poller): await each this
/// long before aborting it. These tasks carry no in-flight upload work - they
/// only forward signals or poll - so they should stop near-instantly once the
/// run loop has exited; the short budget keeps a single wedged bridge/poller
/// from holding the join indefinitely.
const TASK_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
/// R2-P2-1: the run loop's OWN graceful-drain budget on quit. The run loop is
/// the ONLY per-account task that may be mid-`execute` (a large in-flight
/// upload), so DESIGN s5.10.2's "let the current cycle finish" guarantee must
/// give it the FULL drain window - not the short [`TASK_DRAIN_TIMEOUT`] the
/// signal-only bridges use. Before this fix the run loop shared the 5s bridge
/// budget, so a >5s in-flight upload was aborted on explicit Quit even though
/// the intended drain window was ~20s.
///
/// R3-P1-1: `lib.rs`'s quit sweep no longer wraps the per-account drains in an
/// outer timeout (that could drop a cancellation-unsafe drain mid-abort and
/// orphan a task); instead it runs every account's `shutdown()` concurrently and
/// lets each self-bound by this budget (plus the short auxiliary one).
pub const RUN_LOOP_DRAIN_TIMEOUT: Duration = Duration::from_secs(20);
/// The collected per-account task handles + the bridge shutdown sender, returned
/// by `assembly::build_account` and stored on [`AccountHandle`]. Groups the four
/// tracked tasks so the constructor signature stays readable (R-P1-1).
pub struct AccountTasks {
/// B2: the per-account live crypto provider (so the handle can expose it for
/// refresh on a source change).
pub crypto: Arc<crate::crypto_provider_impl::KeystoreCryptoProvider>,
/// [`Orchestrator::run`] loop.
pub run_loop: JoinHandle<()>,
/// Watcher -> orchestrator scan-tick bridge, or `None` if none was spawned.
pub watcher_bridge: Option<JoinHandle<()>>,
/// Orchestrator-event -> tray/IPC bridge.
pub event_bridge: JoinHandle<()>,
/// Power-source poll loop.
pub power_poller: JoinHandle<()>,
/// The OS sleep/wake EDGE monitor (DESIGN s5.10.1, issue #33), or `None`
/// when its per-OS registration failed (degrade to the 30 s poll).
pub sleep_wake_monitor: Option<SleepWakeMonitor>,
/// The sender the watcher + event bridges `select!` on for shutdown.
pub bridge_shutdown: watch::Sender<bool>,
}
impl AccountHandle {
/// Build a handle from the orchestrator control surface + the collected
/// per-account task set (R-P1-1).
#[must_use]
pub fn new(orchestrator: Arc<dyn Orchestrator>, tasks: AccountTasks) -> Self {
Self {
orchestrator,
crypto: tasks.crypto,
run_loop: Mutex::new(Some(tasks.run_loop)),
watcher_bridge: Mutex::new(tasks.watcher_bridge),
event_bridge: Mutex::new(Some(tasks.event_bridge)),
power_poller: Mutex::new(Some(tasks.power_poller)),
sleep_wake_monitor: Mutex::new(tasks.sleep_wake_monitor),
bridge_shutdown: tasks.bridge_shutdown,
}
}
/// Stop + JOIN every per-account task so quit leaves NO orphaned tokio task
/// (R-P1-1, ROADMAP M5 acceptance; DESIGN s5.10.2 graceful drain).
///
/// Order (R2-P2-1):
/// 1. signal the orchestrator to stop after its in-flight cycle
/// (`Orchestrator::shutdown()`), and signal the bridges via
/// [`Self::bridge_shutdown`] (the watcher bridge's source never closes on
/// its own);
/// 2. drain the RUN LOOP FIRST with its OWN full [`RUN_LOOP_DRAIN_TIMEOUT`]
/// budget - it is the only task that may be mid-upload, so DESIGN s5.10.2's
/// "let the current cycle finish" applies to it and it must NOT be cut off
/// by the short bridge budget;
/// 3. ONLY AFTER the run loop has exited, drain the auxiliary tasks (watcher
/// bridge, event bridge, power poller) with the short [`TASK_DRAIN_TIMEOUT`]
/// - they carry no upload work and stop promptly once the loop is gone.
///
/// For EVERY tracked handle: await-with-timeout, and on timeout abort the
/// task AND AWAIT the aborted handle - so the task is truly GONE, not merely
/// abort-requested. The power poller loops forever, so it always takes the
/// abort path; the others normally drain cleanly.
///
/// Idempotent: a second call finds every handle already taken and returns
/// immediately. Errors awaiting a task (cancelled / panicked) are swallowed -
/// the post-condition is "the task is no longer running".
pub async fn shutdown(&self) {
// 1) Signal stop. The orchestrator finishes its current cycle then
// returns; the bridges observe the watch flip and select! out.
self.orchestrator.shutdown();
// A send error only means there are no live bridge receivers (already
// gone) - benign.
let _ = self.bridge_shutdown.send(true);
// 2) Drain the run loop FIRST with the FULL in-flight budget (R2-P2-1):
// a large upload mid-`execute` gets the intended ~20s to finish
// rather than being aborted at the 5s bridge budget.
drain_or_abort(&self.run_loop, RUN_LOOP_DRAIN_TIMEOUT).await;
// 3) THEN drain the signal-only auxiliary tasks with the SHORT budget.
// With the run loop already gone they should stop near-instantly.
drain_or_abort(&self.watcher_bridge, TASK_DRAIN_TIMEOUT).await;
drain_or_abort(&self.event_bridge, TASK_DRAIN_TIMEOUT).await;
drain_or_abort(&self.power_poller, TASK_DRAIN_TIMEOUT).await;
// 4) Tear down the OS sleep/wake monitor (issue #33): its `stop()`
// unregisters the Win32 suspend/resume callback / stops the macOS
// CFRunLoop thread / aborts the Linux logind DBus task, so no OS
// handle, thread, or task outlives the account. A `None` (registration
// failed at build time) is a no-op.
if let Some(monitor) = self.sleep_wake_monitor.lock().await.take() {
monitor.stop();
}
}
}
/// Take the handle out of `slot` and drive it to a true stop: await it up to
/// `budget`; on timeout `abort()` it and AWAIT the aborted handle so the task is
/// genuinely finished before this returns (R-P1-1). A `None` slot (already
/// drained / never spawned) is a no-op. R2-P2-1: the budget is a parameter so
/// the run loop gets the full [`RUN_LOOP_DRAIN_TIMEOUT`] while the auxiliary
/// tasks use the short [`TASK_DRAIN_TIMEOUT`].
///
/// `tokio::time::timeout` MOVES the `JoinHandle` into itself and, on elapse,
/// DROPS it - and a dropped `JoinHandle` does NOT cancel its task (it merely
/// detaches it). So we capture an [`tokio::task::AbortHandle`] BEFORE the
/// timeout, and on elapse abort via it, then RE-AWAIT the same task via a second
/// `JoinHandle` we also kept... which `timeout` consumed. To avoid that, we do
/// not hand the original handle to `timeout`; we `select!` between the handle and
/// a sleep so the handle stays in scope and can be re-awaited after an abort.
async fn drain_or_abort(slot: &Mutex<Option<JoinHandle<()>>>, budget: Duration) {
let Some(mut handle) = slot.lock().await.take() else {
return;
};
let abort = handle.abort_handle();
tokio::select! {
// Bias toward the task finishing: if it completes within the budget we
// take this arm and never abort.
biased;
_join_result = &mut handle => {
// Joined cleanly (or the task panicked - either way it is gone).
}
() = tokio::time::sleep(budget) => {
// Budget elapsed: request cancellation, then AWAIT the same handle
// so the task is genuinely finished (a JoinError::cancelled is the
// expected, swallowed result) before we return.
abort.abort();
let _ = handle.await;
}
}
}
/// The Tauri-managed application state (SPEC s11).
pub struct AppState {
/// The SQLite state layer (SPEC s2), shared by every account + IPC path.
state: Arc<dyn StateRepo>,
/// Per-account orchestrator handles, keyed by [`AccountId`].
///
/// A2: behind a sync [`std::sync::Mutex`] (never held across an await -
/// only ever for a quick insert / clone-out) so the wizard can HOT-SPAWN a
/// brand-new account's orchestrator mid-session (`finish_add_account` ->
/// `spawn_account`) and `sync_now` finds it without a restart. Each handle
/// is an [`Arc`] so a caller can clone it out and drive / await it after
/// releasing the map lock.
accounts: std::sync::Mutex<HashMap<AccountId, Arc<AccountHandle>>>,
/// How remotes were constructed this run (real Drive vs in-memory fake).
remote_mode: RemoteMode,
/// C5-P2-1: per-account pause "generation" token. Every pause/resume bumps
/// the account's generation; a TIMED pause captures the new generation and
/// its detached auto-resume timer only fires if the generation still
/// matches when it wakes. A newer pause/resume (e.g. a `pause(None)`
/// indefinite pause issued before the old timer fires) bumps the generation
/// and thereby CANCELS the stale timer's auto-resume. Behind a sync `Mutex`
/// (only ever held for a counter bump/read, never across an await).
pause_generations: std::sync::Mutex<HashMap<AccountId, u64>>,
/// C1 (SPEC s11.6.1): one-shot dialog-token -> path bindings. The backend
/// OWNS the native folder / save-file dialogs; each returns an opaque token
/// bound to the path the USER actually chose. A path-bearing write command
/// (`add_source`, `export_diagnostic_bundle`) must present a token that maps
/// to exactly that path - so the (untrusted) webview can never inject an
/// arbitrary path. Single-use (spent when the write commits) with a bounded
/// TTL so a leaked token cannot be replayed later. Behind a sync `Mutex`
/// (only ever held for a quick insert / take, never across an await).
dialog_tokens: std::sync::Mutex<HashMap<String, DialogTokenBinding>>,
/// R2-P1-1: per-account ASYNC lock serialising the FIRST-encrypted-source
/// critical section (ensure-master-key -> stamp -> insert source). Without
/// it two concurrent `add_source` calls on an account whose
/// `encryption_master_key_id` is still NULL could BOTH generate DIFFERENT
/// master keys into the same keychain slot and wrap different source keys -
/// leaving one source permanently unrestorable. The lock (a `tokio::Mutex`
/// so it can be held across the awaited DB write) makes the second add see
/// the master key the first installed and wrap under the SAME key. Keyed by
/// account; the inner map is behind a sync `Mutex` only to hand out the
/// per-account `Arc<tokio::Mutex<()>>` (never held across an await).
ensure_master_key_locks: std::sync::Mutex<HashMap<AccountId, Arc<Mutex<()>>>>,
/// R2-P1-2: per-account in-memory fake remote store, shared between the
/// Drive-folder picker (`pick_drive_folder`) and the orchestrator's
/// uploader (assembly `build_remote`) so a folder id the picker mints in
/// fake mode is visible to the uploader. Created on demand, one instance per
/// account ([`InMemoryRemoteStore`] is `Clone` over a shared `Arc<Mutex>`, so
/// every clone sees the same backing objects). Only ever populated in
/// [`RemoteMode::Fake`]; in real mode it stays empty.
fake_remote_stores: FakeRemoteStores,
/// M8: live restore-job records, keyed by job id. Each entry carries the
/// latest [`RestoreJobStatus`](crate::commands::dtos::RestoreJobStatus)
/// snapshot (the background task writes it on every progress tick, so
/// `get_restore_job` can serve a webview that subscribed late / missed an
/// event), plus the per-job CANCEL control + spawned [`JoinHandle`] so
/// `cancel_restore_job` and the app-shutdown drain can stop an in-flight job
/// (M8-P1-1). Behind a sync `Mutex` (only ever held for a quick insert /
/// clone-out / take, never across an await). Terminal entries are retained so
/// a late poll still sees the result, but they are TTL-pruned + count-capped
/// (M8-P2-3) so a long-running tray app does not leak snapshots forever.
restore_jobs: std::sync::Mutex<HashMap<String, RestoreJobEntry>>,
/// M9a (SPEC s15.2): the in-app updater runtime - the pending checked update
/// (held so `install_update` stages + applies the SAME object the check
/// found) plus the periodic-check task handle + shutdown signal, so the
/// app-quit drain joins it with NO orphan (mirrors the M5 no-orphan
/// bookkeeping).
updater: UpdaterRuntime,
/// 2026-08-14 follow-up: live disk/network throughput sampling runtime.
iostat: IostatRuntime,
/// issue #308 (2026-08-17 follow-up): live bottleneck-classification
/// sampling runtime (the Activity dashboard's Bottleneck stat tile).
bottleneck: BottleneckRuntime,
/// issue #309: the debug-logging-mode 24h auto-off watchdog's task handle
/// and shutdown signal, so the app-quit drain joins it with no orphan
/// (mirrors [`UpdaterRuntime`]/[`IostatRuntime`]). No shared "hub" field
/// like those two - the watchdog only reads/writes the persisted settings
/// KV directly, nothing else on `AppState` needs to observe it.
debug_mode: DebugModeRuntime,
/// The ONE in-flight streaming exclusion preview
/// ([`crate::commands::exclusion_stream`]). The exclusion editor re-previews
/// on every rule edit, so without a single-slot registry a user tweaking
/// globs over a large folder would stack N concurrent full-tree walks;
/// starting a preview cancels the one it supersedes, and the editor cancels
/// the last one when it closes. Behind an `Arc` because the blocking walk
/// task deregisters itself when it ends, outliving the command that spawned
/// it.
exclusion_previews: Arc<crate::commands::exclusion_stream::PreviewRegistry>,
/// The exclusion editor's in-memory folder-tree cache
/// ([`crate::commands::preview_cache`]). Walking the source folder and
/// classifying it under the candidate rules are separate jobs, and only the
/// second one changes when a glob is edited - so the first pass over a root
/// records what it found here and every later pass re-classifies from
/// memory, turning a minutes-long re-walk per keystroke into milliseconds.
/// Editor-scoped: dropped when the previewed root changes and freed when the
/// editor closes. Behind an `Arc` for the same reason as the registry above
/// (the blocking pass outlives the command that spawned it).
preview_tree_cache: Arc<crate::commands::preview_cache::PreviewTreeCache>,
/// M9b (SPEC s16): the anonymous-telemetry runtime - the periodic ping task
/// handle + shutdown signal, so the app-quit drain joins it with NO orphan
/// (mirrors the M9a updater bookkeeping). The ping itself is best-effort and
/// holds no state here beyond the task control surface.
telemetry: TelemetryRuntime,
/// M9c D4 (M6 R4-P1-1, DATA-SAFETY); R4-P1-1 made DURABLE: per-source
/// recovery-phrase ACK gate, a reconstructed-on-startup MIRROR of the durable
/// `recovery_phrase_acks` table. The FIRST encrypted source for an account is
/// persisted DISABLED (excluded from the scheduler + manual sync, which filter
/// on `enabled`) and a pending-ack record is written DURABLY in the same
/// transaction as the source insert + master-key stamp. The source is only
/// ENABLED once `ack_recovery_phrase_saved` lands - and that ack is REJECTED
/// unless a real backend `reveal_recovery_phrase` was recorded first
/// (`revealed == true`). So a user can never tick "I saved it" without the
/// backend having actually revealed the phrase, and no encrypted backups run
/// before the recovery phrase is durably saveable - closing the
/// unrestorable-backup window EVEN ACROSS A CRASH/RESTART.
///
/// R4-P1-1: the DURABLE table is the source of truth for every gate decision
/// (the command layer reads/writes it via [`StateRepo`]); this in-memory map is
/// a reconstructed mirror so a fresh process resumes the exact pending-ack gate,
/// kept in sync as the commands mutate the durable state. Behind a sync `Mutex`
/// (only ever held for a quick insert / read / take, never across an await).
recovery_acks: std::sync::Mutex<HashMap<driven_core::types::SourceId, RecoveryAckState>>,
/// Issue #25 (DESIGN s5.3.1): the app-side least-privilege VSS helper broker
/// lifecycle owner, or `None` when the helper is not in play (off Windows, the
/// app is already elevated, or the `windows.vss_helper` setting is off). Built
/// ONCE by `assembly::build_and_spawn` and installed here so (a) the quit sweep
/// can shut the broker down (no elevated process outlives the app) and (b)
/// `get_vss_helper_status` can report truthful liveness. Behind a sync `Mutex`
/// (set once at boot, read for status/shutdown - never held across an await).
vss_helper: std::sync::Mutex<Option<Arc<crate::vss_helper::VssHelperManager>>>,
/// DESIGN s5.3.2: the macOS sibling of [`Self::vss_helper`] - the app-side
/// APFS snapshot broker lifecycle owner, or `None` when it is not in play
/// (off macOS, or the bundled sidecar could not be located). Built ONCE by
/// `assembly::build_and_spawn` and installed here so (a) the quit sweep can
/// shut the broker down (no root process outlives the app) and (b)
/// `get_apfs_helper_status` can report truthful liveness.
apfs_helper: std::sync::Mutex<Option<Arc<crate::apfs_helper::ApfsHelperManager>>>,
}
/// M9c D4: one pending recovery-phrase ack - the owning account (so the ack can
/// reconfigure it once the source is enabled) and whether the backend has actually
/// REVEALED the phrase (`reveal_recovery_phrase`). `ack_recovery_phrase_saved` is
/// rejected unless `revealed` is true. R4-P1-1: a mirror of the durable
/// `recovery_phrase_acks` row.
struct RecoveryAckState {
/// The account owning the pending-ack source (used to reconfigure on enable).
account_id: AccountId,
/// True once `reveal_recovery_phrase` has actually returned the phrase from the
/// backend for this source. The ack is ineffective until this is set.
revealed: bool,
}
/// M9a (SPEC s15.2): the in-app updater runtime state held on [`AppState`].
///
/// `pending` holds the [`tauri_plugin_updater::Update`] the most recent check
/// found (manual or periodic) PLUS the channel it came from, so `install_update`
/// can `download_and_install` the SAME object without re-resolving the manifest
/// AND emit `updater:downloaded` with the REAL channel (R1-P2-3). It is TAKEN on
/// install but RESTORED on a download/install failure (R1-P2-2) so the banner's
/// next Install retries instead of failing "no pending update"; a fresh check
/// also re-populates it. `task` + `shutdown` track the single app-wide
/// periodic-check task so the quit drain stops + joins it with no orphan.
#[derive(Default)]
pub struct UpdaterRuntime {
/// The update the latest check found (with its channel string), awaiting
/// install; `None` when up to date / not yet checked / already installed.
pending: std::sync::Mutex<Option<(tauri_plugin_updater::Update, String)>>,
/// The spawned periodic-check task, behind `Option` so the shutdown drain
/// can TAKE + await it by value; `None` once drained / never spawned.
task: std::sync::Mutex<Option<JoinHandle<()>>>,
/// The shutdown signal the periodic-check task `select!`s on, so it exits
/// promptly on quit rather than waiting out its 6h interval.
shutdown: std::sync::Mutex<Option<watch::Sender<bool>>>,
}
/// 2026-08-14 follow-up: the live disk/network throughput runtime held on
/// [`AppState`] - the app-global counters+ring hub every executor credits,
/// plus the sampler task's lifecycle slots (mirrors [`UpdaterRuntime`] so the
/// quit drain stops + joins it with no orphan).
#[derive(Default)]
pub struct IostatRuntime {
/// The counters + trailing-sample ring. Installed by assembly (the SAME
/// hub whose counters the executors were built with); the quiesced boot
/// path keeps the default hub (all-zero graphs).
hub: std::sync::Mutex<Arc<crate::iostat_hub::IoStatHub>>,
/// The spawned sampler task, behind `Option` so the shutdown drain can
/// TAKE + await it by value; `None` once drained / never spawned.
task: std::sync::Mutex<Option<JoinHandle<()>>>,
/// The shutdown signal the sampler `select!`s on.
shutdown: std::sync::Mutex<Option<watch::Sender<bool>>>,
}
/// issue #308 (2026-08-17 follow-up): the live bottleneck-classification
/// runtime held on [`AppState`] - the latest-snapshot hub plus the sampler
/// task's lifecycle slots (mirrors [`IostatRuntime`], which this sampler
/// reads from). Unlike `IostatRuntime` there is nothing to "install" from
/// assembly: the hub reads the app-global IO counters and the accounts map
/// straight off `AppState` each tick, so the default hub is already correct
/// even in the quiesced boot path (it just classifies `NotBackingUp`).
#[derive(Default)]
pub struct BottleneckRuntime {
/// The latest-snapshot hub the sampler pushes into and the
/// `bottleneck_status` command reads.
hub: Arc<crate::bottleneck_hub::BottleneckHub>,
/// The spawned sampler task, behind `Option` so the shutdown drain can
/// TAKE + await it by value; `None` once drained / never spawned.
task: std::sync::Mutex<Option<JoinHandle<()>>>,
/// The shutdown signal the sampler `select!`s on.
shutdown: std::sync::Mutex<Option<watch::Sender<bool>>>,
}
/// issue #309: the debug-logging-mode 24h auto-off watchdog's runtime state
/// held on [`AppState`] - just the task's lifecycle slots (mirrors
/// [`UpdaterRuntime`]/[`TelemetryRuntime`]'s task+shutdown pair). No "hub"
/// field like [`BottleneckRuntime`]/[`IostatRuntime`]: the watchdog reads and
/// writes the persisted `global.debug_logging_*` settings directly via its
/// `StateRepo` handle, so there is nothing else on `AppState` for another
/// caller to read.
#[derive(Default)]
pub struct DebugModeRuntime {
/// The spawned watchdog task, behind `Option` so the shutdown drain can
/// TAKE + await it by value; `None` once drained / never spawned.
task: std::sync::Mutex<Option<JoinHandle<()>>>,
/// The shutdown signal the watchdog `select!`s on.
shutdown: std::sync::Mutex<Option<watch::Sender<bool>>>,
}
/// M9b (SPEC s16): the anonymous-telemetry runtime state held on [`AppState`].
///
/// `task` + `shutdown` track the single app-wide periodic-ping task so the quit
/// drain stops + joins it with no orphan (mirrors [`UpdaterRuntime`]). The ping
/// reads settings + aggregates on each tick, so no payload state lives here.
#[derive(Default)]
pub struct TelemetryRuntime {
/// The spawned periodic-ping task, behind `Option` so the shutdown drain can
/// TAKE + await it by value; `None` once drained / never spawned.
task: std::sync::Mutex<Option<JoinHandle<()>>>,
/// The shutdown signal the periodic-ping task `select!`s on, so it exits
/// promptly on quit rather than waiting out its 24h interval.
shutdown: std::sync::Mutex<Option<watch::Sender<bool>>>,
/// M9b (P1-2): a cancellation flag flipped to `true` the instant
/// `set_telemetry_enabled(false)` commits, so an IN-FLIGHT ping that is
/// mid-build aborts BEFORE its network send (the disable is honored
/// immediately, not merely on the next 24h tick). Re-armed to `false` on
/// re-enable. Shared with the ping task via [`AppState::telemetry_cancel`].
cancel: Arc<std::sync::atomic::AtomicBool>,
/// M9b R3-P1-2: the SEND-ADMISSION gate shared between the ping path and the
/// disable path. The ping ACQUIRES this lock, re-checks the cancel flag + pref
/// UNDER it, and starts the network send while holding it; the disable path
/// sets [`Self::cancel`] FIRST and then ACQUIRES the SAME gate to coordinate,
/// so a disable can never be admitted concurrently with a send's final
/// re-check, and a send that begins after the disable observed the gate sees
/// the cancel flag already set and aborts. A `tokio::Mutex` so it can be held
/// across the awaited send. Shared via [`AppState::telemetry_send_gate`].
send_gate: Arc<Mutex<()>>,
/// DESIGN s13: the app-global latency sampler shared into every account's
/// executor + orchestrator (the SAME `Arc`), read at ping-build time for the
/// scan / upload-per-MB percentiles. Default-ON; boot replaces it via
/// [`AppState::install_telemetry_latency`] with one initialized from the
/// persisted `telemetry.enabled` pref BEFORE any capture, and the enable/
/// disable toggle flips it in lockstep with the pref
/// (`telemetry::apply_enabled_change`). Shared via [`AppState::telemetry_latency`].
latency: Arc<driven_core::telemetry::LatencyReservoir>,
}
/// M8 (P2-3): max number of TERMINAL restore-job records retained for late
/// polling. Active (non-terminal) jobs are never evicted by the cap; only
/// finished ones are pruned once this many accumulate (oldest-terminal first).
const MAX_RETAINED_TERMINAL_JOBS: usize = 32;
/// M8 (P2-3): how long a TERMINAL restore-job record is retained for a late
/// `get_restore_job` poll before it is eligible for pruning. Generous (the
/// webview reconciles right after a job ends) but bounded so the map cannot grow
/// without limit across many restores in one long-lived session.
const TERMINAL_JOB_TTL: Duration = Duration::from_secs(3600);
/// M8 (P1-1): the per-job cancellation control shared between the spawned restore
/// task and the IPC / shutdown paths. A plain [`AtomicBool`] checked between
/// frames in the stream loop (no extra dependency): set once, observed
/// monotonically. Cloned (`Arc`) into the spawned task.
pub type RestoreCancel = Arc<AtomicBool>;
/// M8: one tracked restore job - its latest status snapshot, the instant it
/// reached a terminal state (for TTL pruning, `None` while running), the shared
/// cancel flag, and the spawned task handle (taken on cancel / shutdown so the
/// drain can await it).
struct RestoreJobEntry {
/// The latest status snapshot served by `get_restore_job`.
status: crate::commands::dtos::RestoreJobStatus,
/// When the job reached a terminal state, for TTL pruning; `None` while it
/// is still running.
terminal_at: Option<Instant>,
/// The shared cancel flag the spawned task observes between frames.
cancel: RestoreCancel,
/// The spawned job task, behind `Option` so the shutdown drain can TAKE +
/// await it by value; `None` once the job finished or was drained.
handle: std::sync::Mutex<Option<JoinHandle<()>>>,
}
/// R2-P1-2: the shared per-account fake-remote-store registry. An `Arc` so
/// assembly (which builds the orchestrator's store BEFORE [`AppState`] exists)
/// and [`AppState`] hold the SAME map - the orchestrator's fake store and the
/// picker's fake store are then guaranteed to be the same instance per account.
pub type FakeRemoteStores = Arc<std::sync::Mutex<HashMap<AccountId, InMemoryRemoteStore>>>;
/// R2-P1-2: get-or-create the fake remote store for `account` in `registry`.
/// A free function (not a method) so assembly's pre-[`AppState`] boot phase -
/// which builds the orchestrator's store before `AppState` exists - shares the
/// SAME registry the picker later reads via [`AppState::fake_remote_store`].
/// Returns a clone (the store wraps a shared `Arc<Mutex>`).
#[must_use]
pub fn fake_remote_store_in(
registry: &FakeRemoteStores,
account: AccountId,
) -> InMemoryRemoteStore {
registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(account)
.or_insert_with(|| new_fake_store(account))
.clone()
}
/// Env var naming a JSON [`FaultPlan`](driven_drive::fake::fault_plan::FaultPlan)
/// file applied to every fake store this process creates (agent QA harness).
/// DOUBLY gated: it is only honoured when the fake remote is also selected
/// (`DRIVEN_USE_FAKE_REMOTE=1`), so the plan can never affect a real backend.
pub const ENV_TEST_FAULT_PLAN: &str = "DRIVEN_TEST_FAULT_PLAN";
/// Build a fresh fake store for the registry, arming the env-configured fault
/// plan when the harness gate is open (see [`resolve_fault_plan`]).
fn new_fake_store(account: AccountId) -> InMemoryRemoteStore {
let store = InMemoryRemoteStore::new();
let use_fake = std::env::var(crate::assembly::ENV_USE_FAKE_REMOTE)
.map(|v| v == "1")
.unwrap_or(false);
let plan_path = std::env::var(ENV_TEST_FAULT_PLAN).ok();
match resolve_fault_plan(use_fake, plan_path.as_deref()) {
FaultPlanResolution::None => store,
FaultPlanResolution::Armed(plan) => {
tracing::info!(
target: "driven::app::state",
account_id = %account,
?plan,
"agent QA harness: fault plan armed on the fake remote store"
);
plan.apply_to(store)
}
FaultPlanResolution::Error(msg) => {
// Fail LOUDLY but not fatally: store creation is infallible by
// contract (the picker + assembly paths cannot surface an error
// here). The harness asserts plan-armed via this log line + the
// fault actually firing, so a broken plan cannot silently pass.
tracing::error!(
target: "driven::app::state",
account_id = %account,
%msg,
"agent QA harness: DRIVEN_TEST_FAULT_PLAN was set but NOT applied"
);
store
}
}
}
/// Outcome of resolving the env-configured fault plan (pure decision, so the
/// gating is unit-testable without touching process-global env vars).
#[derive(Debug, PartialEq)]
enum FaultPlanResolution {
/// No plan configured (or the fake-remote gate is closed with no plan set).
None,
/// A plan parsed and ready to arm.
Armed(driven_drive::fake::fault_plan::FaultPlan),
/// The plan was requested but could not be honoured - surfaced as an
/// ERROR log so a harness scenario can never silently run fault-free.
Error(String),
}
/// Pure gating + parse for the [`ENV_TEST_FAULT_PLAN`] seam:
/// - no `plan_path`: nothing to do;
/// - `plan_path` set while the fake remote is NOT selected: an error (the
/// harness misconfigured the environment - the plan would never fire);
/// - otherwise read + parse the JSON plan, propagating read/parse failures.
fn resolve_fault_plan(use_fake_remote: bool, plan_path: Option<&str>) -> FaultPlanResolution {
use driven_drive::fake::fault_plan::FaultPlan;
let Some(path) = plan_path.filter(|p| !p.is_empty()) else {
return FaultPlanResolution::None;
};
if !use_fake_remote {
return FaultPlanResolution::Error(format!(
"{ENV_TEST_FAULT_PLAN} is set ({path}) but DRIVEN_USE_FAKE_REMOTE=1 is not - \
the fault plan only exists on the fake remote, so this configuration is a \
harness bug"
));
}
let json = match std::fs::read_to_string(path) {
Ok(j) => j,
Err(e) => return FaultPlanResolution::Error(format!("failed to read {path}: {e}")),
};
match FaultPlan::from_json(&json) {
Ok(plan) => FaultPlanResolution::Armed(plan),
Err(e) => FaultPlanResolution::Error(format!("failed to parse {path}: {e}")),
}
}
/// M8 (P2-3): prune retained TERMINAL restore-job records so the map cannot grow
/// unbounded across many restores in one long-lived tray session. Two bounds:
/// 1. TTL: a terminal job older than [`TERMINAL_JOB_TTL`] is dropped.
/// 2. count cap: if more than [`MAX_RETAINED_TERMINAL_JOBS`] terminal jobs
/// remain, the OLDEST-terminal ones are dropped down to the cap.
///
/// Active (non-terminal) jobs are NEVER pruned - only finished ones - so an
/// in-flight job's status + cancel handle always survive.
fn prune_terminal_jobs(map: &mut HashMap<String, RestoreJobEntry>) {
let now = Instant::now();
// 1) TTL: drop terminal jobs older than the retention window.
map.retain(|_, e| match e.terminal_at {
Some(t) => now.duration_since(t) < TERMINAL_JOB_TTL,
None => true,
});
// 2) count cap: if too many terminal jobs remain, drop the oldest.
let mut terminal: Vec<(String, Instant)> = map
.iter()
.filter_map(|(id, e)| e.terminal_at.map(|t| (id.clone(), t)))
.collect();
if terminal.len() > MAX_RETAINED_TERMINAL_JOBS {
terminal.sort_by_key(|(_, t)| *t);
let drop_n = terminal.len() - MAX_RETAINED_TERMINAL_JOBS;
for (id, _) in terminal.into_iter().take(drop_n) {
map.remove(&id);
}
}
}
/// C1: one backend-minted dialog-token binding - the path the user chose via a
/// native dialog plus the instant the binding expires (single-use TTL).
struct DialogTokenBinding {
/// The path the native dialog returned (a folder for the folder dialog, a
/// concrete file path for the save dialog).
path: std::path::PathBuf,
/// When the token stops being valid (mint time + [`DIALOG_TOKEN_TTL`]).
/// Stored as the deadline rather than the mint instant so tests can force
/// expiry without `Instant` subtraction (which can underflow soon after
/// boot in CI).
expires_at: std::time::Instant,
}
/// C1: how long a backend-minted dialog token stays valid.
///
/// This must cover the LONGEST honest gap between the native dialog and the
/// write command that spends the token - which is NOT "immediately": the
/// add-source wizard picks the local folder first and then walks the whole
/// tree for the exclusion preview, browses the destination, and waits on the
/// user to think, which on a big source is well over the 5 minutes this
/// originally allowed. An expired token used to surface as `local.io_error`
/// ("disk error"), sending users to check a healthy drive. An hour keeps the
/// replay window bounded (the token is also single-use and process-local)
/// without a live wizard session ever outrunning it.
const DIALOG_TOKEN_TTL: Duration = Duration::from_secs(3600);
impl AppState {
/// Build the managed state from the state repo, the per-account handles,
/// the remote-construction mode, and the shared fake-remote-store registry
/// (called by `assembly::build_and_spawn`). The `fake_remote_stores` map is
/// the SAME one assembly threaded into `build_remote`, so the orchestrator's
/// fake store and the picker's fake store are one instance per account
/// (R2-P1-2).
#[must_use]
pub fn new(
state: Arc<dyn StateRepo>,
accounts: HashMap<AccountId, AccountHandle>,
remote_mode: RemoteMode,
fake_remote_stores: FakeRemoteStores,
) -> Self {
let accounts = accounts
.into_iter()
.map(|(id, handle)| (id, Arc::new(handle)))
.collect();
Self {
state,
accounts: std::sync::Mutex::new(accounts),
remote_mode,
pause_generations: std::sync::Mutex::new(HashMap::new()),
dialog_tokens: std::sync::Mutex::new(HashMap::new()),
ensure_master_key_locks: std::sync::Mutex::new(HashMap::new()),
fake_remote_stores,
restore_jobs: std::sync::Mutex::new(HashMap::new()),
updater: UpdaterRuntime::default(),
iostat: IostatRuntime::default(),
bottleneck: BottleneckRuntime::default(),
debug_mode: DebugModeRuntime::default(),
exclusion_previews: Arc::default(),
preview_tree_cache: Arc::default(),
telemetry: TelemetryRuntime::default(),
recovery_acks: std::sync::Mutex::new(HashMap::new()),
vss_helper: std::sync::Mutex::new(None),
apfs_helper: std::sync::Mutex::new(None),
}
}
// --- issue #25: least-privilege VSS helper broker (DESIGN s5.3.1) --------
/// Install the VSS helper broker lifecycle manager built by
/// `assembly::build_and_spawn` (Windows + un-elevated + `windows.vss_helper`
/// on). Called once at boot before `.manage(..)`; a subsequent call replaces
/// it (defensive - boot installs exactly once).
pub fn set_vss_helper_manager(&self, manager: Arc<crate::vss_helper::VssHelperManager>) {
*self.vss_helper.lock().unwrap_or_else(|e| e.into_inner()) = Some(manager);
}
/// The installed VSS helper broker manager, if one is in play. `None` off
/// Windows / when elevated / when the setting is off. Returns a cloned `Arc`
/// so `get_vss_helper_status` can read liveness without holding the lock.
#[must_use]
pub fn vss_helper_manager(&self) -> Option<Arc<crate::vss_helper::VssHelperManager>> {
self.vss_helper
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Shut the VSS helper broker down at app quit (best-effort; a no-op if the
/// broker was never launched or no manager is in play), so no elevated
/// process outlives the app session. Mirrors the M9a/M9b task drains.
pub fn shutdown_vss_helper(&self) {
if let Some(manager) = self.vss_helper_manager() {
manager.shutdown();
}
}
// --- DESIGN s5.3.2: macOS APFS snapshot broker --------------------------
/// Install the APFS broker lifecycle manager built by
/// `assembly::build_and_spawn` (macOS only). Called once at boot before
/// `.manage(..)`; a subsequent call replaces it.
pub fn set_apfs_helper_manager(&self, manager: Arc<crate::apfs_helper::ApfsHelperManager>) {
*self.apfs_helper.lock().unwrap_or_else(|e| e.into_inner()) = Some(manager);
}
/// The installed APFS broker manager, if one is in play. `None` off macOS.
/// Returns a cloned `Arc` so `get_apfs_helper_status` can read liveness
/// without holding the lock.
#[must_use]
pub fn apfs_helper_manager(&self) -> Option<Arc<crate::apfs_helper::ApfsHelperManager>> {
self.apfs_helper
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Shut the APFS broker down at app quit (best-effort; a no-op when no
/// manager is in play), so no root process - and no mounted snapshot -
/// outlives the app session. The broker also self-terminates when the app
/// pid it was launched with exits, so this is belt-and-braces.
pub fn shutdown_apfs_helper(&self) {
if let Some(manager) = self.apfs_helper_manager() {
manager.shutdown();
}
}
/// Shut the VSS helper broker down BEFORE an in-app update installer runs
/// (issue #125), returning whether a manager was disabled (so the caller can
/// RE-ARM it if the install then fails - see
/// [`Self::rearm_vss_helper_after_failed_update`]).
///
/// The Windows NSIS updater overwrites the bundled `driven-vss-helper.exe`
/// sidecar, but its stock process-kill only targets the MAIN binary
/// (`driven-app.exe`) - never the sidecar. A running elevated broker holds an
/// open handle to its own exe, so the install fails with "Error opening file
/// for writing: ...driven-vss-helper.exe". `download_and_install` runs the
/// NSIS installer synchronously (`/P /R`), so the broker must be gone BEFORE
/// that call.
///
/// Unlike the app-quit [`Self::shutdown_vss_helper`] (a bare `shutdown()`),
/// this uses `set_enabled(false)`, which is a SUPERSET: it performs the same
/// Shutdown+reap (including abandoning + reaping a `Pending` launch per the
/// #113 generation semantics) AND disables the manager so a still-running
/// sync that hits a locked file cannot RE-LAUNCH the elevated broker (and
/// re-lock the exe) during the potentially-long `download_and_install`
/// window. A memoised session decline is reset to `NotAttempted` by the
/// underlying shutdown; that is inherent to any shutdown-based sweep and
/// harmless here (an update is user-consented and a successful install
/// restarts the app anyway).
///
/// Best-effort + idempotent: a no-op (returns `false`) when no manager is in
/// play (off Windows / elevated / setting off).
pub fn shutdown_vss_helper_for_update(&self) -> bool {
if let Some(manager) = self.vss_helper_manager() {
manager.set_enabled(false);
true
} else {
false
}
}
/// Re-arm the VSS helper broker after a FAILED update install (issue #125):
/// the app keeps running, so undo the
/// [`Self::shutdown_vss_helper_for_update`] disable so locked-file backup is
/// available again on demand (a LAZY re-launch on the next locked file - no
/// forced UAC prompt), rather than staying silently degraded until the next
/// app restart. Best-effort + idempotent; a no-op when no manager is in play.
pub fn rearm_vss_helper_after_failed_update(&self) {
if let Some(manager) = self.vss_helper_manager() {
manager.set_enabled(true);
}
}
// --- M9c D4: recovery-phrase ACK gate (M6 R4-P1-1, DATA-SAFETY) ---------
/// Lock the recovery-ack map, recovering a poisoned lock (house rule: never
/// panic on a poisoned lock).
fn lock_recovery_acks(
&self,
) -> std::sync::MutexGuard<'_, HashMap<driven_core::types::SourceId, RecoveryAckState>> {
self.recovery_acks.lock().unwrap_or_else(|e| e.into_inner())
}
/// R4-P1-1: reconstruct the in-memory recovery-ack mirror from the DURABLE
/// `recovery_phrase_acks` table on startup, so a process that restarts mid
/// onboarding (after the first encrypted source + master key were persisted but
/// before reveal+ack) resumes the EXACT pending-ack gate - the disabled source
/// is still reveal/ackable and no second encrypted source can enable without the
/// durable ack. Called once after assembly builds [`AppState`]. Errors reading
/// the table are logged and treated as "no pending acks" (the durable table is
/// still the gate source of truth for the command layer; the mirror is a
/// convenience), but a healthy boot always succeeds.
pub async fn reconstruct_recovery_acks_from_db(&self) {
match self.state.list_pending_recovery_acks().await {
Ok(rows) => {
let mut map = self.lock_recovery_acks();
map.clear();
for r in rows {
map.insert(
r.source_id,
RecoveryAckState {
account_id: r.account_id,
revealed: r.revealed,
},
);
}
tracing::info!(
pending_recovery_acks = map.len(),
"reconstructed durable recovery-phrase ack gate from SQLite (R4-P1-1)"
);
}
Err(err) => {
tracing::error!(%err, "failed to reconstruct recovery-phrase ack gate from SQLite; the durable table still gates the commands");
}
}
}
/// M9c D4 / R4-P1-1: register in the in-memory MIRROR that `source` (on
/// `account`) was persisted DISABLED and is awaiting a recovery-phrase ack. The
/// DURABLE record is written atomically with the source insert by
/// `add_source` (via [`StateRepo::insert_first_encrypted_source_pending_ack`]);
/// this only mirrors that durable state into the in-memory map. Until the ack
/// lands the source stays disabled, so the scheduler + manual sync (which filter
/// on `enabled`) never back it up.
pub fn register_pending_recovery_ack(
&self,
source: driven_core::types::SourceId,
account: AccountId,
) {
self.lock_recovery_acks().insert(
source,
RecoveryAckState {
account_id: account,
revealed: false,
},
);
}
/// M9c D4: record that the backend actually REVEALED the phrase for `source`
/// (`reveal_recovery_phrase`). Returns `true` if `source` had a pending ack
/// (so the reveal is meaningful), `false` for an unknown / already-acked source.
/// The ack is only accepted after this has been recorded.
pub fn record_recovery_reveal(&self, source: driven_core::types::SourceId) -> bool {
match self.lock_recovery_acks().get_mut(&source) {
Some(entry) => {
entry.revealed = true;
true
}
None => false,
}
}
/// M9c D4: whether `source` has a pending recovery ack AND its phrase has been
/// revealed by the backend - the precondition `ack_recovery_phrase_saved`
/// enforces. `None` means no pending ack at all; `Some(false)` means pending but
/// the phrase was never revealed (the ack must be rejected); `Some(true)` means
/// the ack may proceed.
#[must_use]
pub fn recovery_reveal_recorded(&self, source: driven_core::types::SourceId) -> Option<bool> {
self.lock_recovery_acks().get(&source).map(|e| e.revealed)
}
/// M9c D4: the account owning `source`'s pending recovery ack, if one exists.
#[must_use]
pub fn pending_recovery_ack_account(
&self,
source: driven_core::types::SourceId,
) -> Option<AccountId> {
self.lock_recovery_acks().get(&source).map(|e| e.account_id)
}
/// M9c D4: clear the pending recovery ack for `source` once it has been enabled
/// (the ack succeeded). Idempotent - clearing an unknown source is a no-op.
pub fn clear_pending_recovery_ack(&self, source: driven_core::types::SourceId) {
self.lock_recovery_acks().remove(&source);
}
// --- M9a updater runtime (SPEC s15.2) ----------------------------------
/// M9a: record the [`tauri_plugin_updater::Update`] a check found PLUS the
/// channel string it came from, so a subsequent `install_update` stages +
/// applies the SAME object without re-resolving the manifest AND emits the
/// REAL channel on `updater:downloaded` (R1-P2-3). Overwrites any prior
/// pending update (a newer check supersedes an older one). `None` clears it.
pub fn set_pending_update(&self, update: Option<(tauri_plugin_updater::Update, String)>) {
*self
.updater
.pending
.lock()
.unwrap_or_else(|e| e.into_inner()) = update;
}
/// M9a: TAKE (single-use) the pending update + its channel for installation.
/// `None` when no check has found an update (so `install_update` returns a
/// clear "nothing to install" error rather than guessing). On a failed
/// install the caller RESTORES it via [`Self::set_pending_update`] so the
/// banner's next Install retries (R1-P2-2).
#[must_use]
pub fn take_pending_update(&self) -> Option<(tauri_plugin_updater::Update, String)> {
self.updater
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
}
/// M9a (R2-P1-3): PEEK (non-consuming) the pending update as an owned
/// snapshot, so the `get_pending_update_info` IPC can hydrate the webview's
/// updater store on startup WITHOUT taking the pending update (install still
/// needs it). Returns `(version, notes, published_at_rfc3339, channel)` while
/// holding the lock briefly; `None` when no check has recorded an update.
/// Kept as owned primitives so `AppState` stays decoupled from the updater
/// DTO mapping (updater.rs builds the `UpdateInfo`).
#[must_use]
pub fn peek_pending_update(&self) -> Option<(String, Option<String>, Option<String>, String)> {
self.updater
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.map(|(update, channel)| {
(
update.version.clone(),
update.body.clone().filter(|b| !b.is_empty()),
update.date.map(|d| d.to_string()),
channel.clone(),
)
})
}