1use std::fs::{self, OpenOptions};
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
9
10use crate::config::{self, ProfilesFile};
11use crate::pause_store::PauseSnapshot;
12use crate::scheduler::pause::restore_pause_state;
13use crate::scheduler::screen_time::ScreenTimeState;
14use crate::scheduler::timers::{local_today_string, reset_timers_keep_sleep};
15use crate::scheduler::Scheduler;
16use crate::screen_time_store::ScreenTimeSnapshot;
17use crate::secure_io::{read_capped, write_user_only};
18use crate::stats::LoggedEvent;
19use crate::supporter::{self, SupporterRecord, SupporterSource};
20use crate::SupporterAppState;
21
22const BACKUP_SCHEMA_VERSION: u32 = 1;
23const BUNDLE_APP_ID: &str = "io.drmowinckels.entracte";
24const MAX_BACKUP_BYTES: u64 = 64 * 1024 * 1024;
32const MAIN_WINDOW_LABEL: &str = "main";
37
38#[derive(Debug, Serialize, Deserialize)]
39struct BackupManifest {
40 schema_version: u32,
41 created_at: String,
42 app: String,
43}
44
45#[derive(Debug, Serialize, Deserialize)]
46struct BackupFiles {
47 settings_json: String,
48 events_jsonl: String,
49 pause_json: Option<String>,
50 screen_time_json: Option<String>,
51 supporter_json: Option<String>,
52}
53
54#[derive(Debug, Serialize, Deserialize)]
55struct BackupBundle {
56 manifest: BackupManifest,
57 files: BackupFiles,
58}
59
60fn exportable_supporter(supporter_path: &Path) -> Option<String> {
77 let record = supporter::load(supporter_path)?;
78 if !matches!(record.source, SupporterSource::Manual) {
79 log::info!("backup: stripping LemonSqueezy supporter record from export (machine-bound)");
80 return None;
81 }
82 serde_json::to_string_pretty(&record).ok()
89}
90
91fn read_optional_text(path: &Path, max_bytes: u64) -> Result<Option<String>, String> {
92 match read_capped(path, max_bytes) {
93 Ok(s) => Ok(Some(s)),
94 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
95 Err(e) => Err(format!("failed to read {}: {e}", path.display())),
96 }
97}
98
99fn warn_on_unparseable_events(events_jsonl: &str) {
107 let unparseable = events_jsonl
108 .lines()
109 .map(str::trim)
110 .filter(|line| !line.is_empty())
111 .filter(|line| serde_json::from_str::<LoggedEvent>(line).is_err())
112 .count();
113 if unparseable > 0 {
114 log::warn!(
115 "backup import: {unparseable} events line(s) didn't parse \
116 (e.g. event types from a newer Entracte) and will be ignored \
117 when computing stats; importing the rest"
118 );
119 }
120}
121
122fn validate_bundle(bundle: &BackupBundle) -> Result<(), String> {
123 if bundle.manifest.app != BUNDLE_APP_ID {
124 return Err(format!(
125 "backup file is for a different app ({}), expected {BUNDLE_APP_ID}",
126 bundle.manifest.app,
127 ));
128 }
129 if bundle.manifest.schema_version > BACKUP_SCHEMA_VERSION {
130 return Err(format!(
131 "backup schema version {} is newer than this app supports (max {BACKUP_SCHEMA_VERSION}) — please update Entracte",
132 bundle.manifest.schema_version,
133 ));
134 }
135
136 let profiles_file = serde_json::from_str::<ProfilesFile>(&bundle.files.settings_json)
137 .map_err(|e| format!("settings_json is invalid: {e}"))?;
138 if profiles_file.profiles.is_empty() {
139 return Err("settings_json has no profiles".to_string());
140 }
141 if !profiles_file
142 .profiles
143 .iter()
144 .any(|p| p.name == profiles_file.active)
145 {
146 return Err(format!(
147 "settings_json active profile {:?} is not in the profile list",
148 profiles_file.active,
149 ));
150 }
151 warn_on_unparseable_events(&bundle.files.events_jsonl);
152
153 if let Some(pause_json) = &bundle.files.pause_json {
154 serde_json::from_str::<PauseSnapshot>(pause_json)
155 .map_err(|e| format!("pause_json is invalid: {e}"))?;
156 }
157 if let Some(screen_time_json) = &bundle.files.screen_time_json {
158 serde_json::from_str::<ScreenTimeSnapshot>(screen_time_json)
159 .map_err(|e| format!("screen_time_json is invalid: {e}"))?;
160 }
161 if let Some(supporter_json) = &bundle.files.supporter_json {
162 serde_json::from_str::<SupporterRecord>(supporter_json)
163 .map_err(|e| format!("supporter_json is invalid: {e}"))?;
164 }
165 Ok(())
166}
167
168#[derive(Debug)]
173enum StageAction {
174 Write(PathBuf),
175 Remove,
176}
177
178#[derive(Debug)]
179struct StagedFile {
180 final_path: PathBuf,
181 action: StageAction,
182}
183
184fn stage_path_for(target: &Path) -> Result<PathBuf, String> {
185 let dir = target
186 .parent()
187 .ok_or_else(|| format!("stage path {} has no parent", target.display()))?;
188 let name = target
189 .file_name()
190 .and_then(|n| n.to_str())
191 .ok_or_else(|| format!("stage path {} has no file name", target.display()))?;
192 Ok(dir.join(format!(".{name}.import.tmp")))
193}
194
195fn bak_path_for(target: &Path) -> Result<PathBuf, String> {
199 let dir = target
200 .parent()
201 .ok_or_else(|| format!("backup path {} has no parent", target.display()))?;
202 let name = target
203 .file_name()
204 .and_then(|n| n.to_str())
205 .ok_or_else(|| format!("backup path {} has no file name", target.display()))?;
206 Ok(dir.join(format!(".{name}.pre-import.bak")))
207}
208
209fn stage_write(target: &Path, contents: &[u8]) -> Result<StagedFile, String> {
210 let dir = target
211 .parent()
212 .ok_or_else(|| format!("stage write {} has no parent", target.display()))?;
213 fs::create_dir_all(dir)
214 .map_err(|e| format!("failed to ensure parent dir for {}: {e}", target.display()))?;
215 let tmp = stage_path_for(target)?;
216 let _ = fs::remove_file(&tmp);
217
218 let mut opts = OpenOptions::new();
219 opts.write(true).create_new(true);
220 #[cfg(unix)]
221 {
222 use std::os::unix::fs::OpenOptionsExt;
223 opts.mode(0o600);
224 }
225 let mut file = opts
226 .open(&tmp)
227 .map_err(|e| format!("failed to stage {}: {e}", target.display()))?;
228 file.write_all(contents)
229 .map_err(|e| format!("failed to stage {}: {e}", target.display()))?;
230 file.sync_all()
231 .map_err(|e| format!("failed to stage {}: {e}", target.display()))?;
232 Ok(StagedFile {
233 final_path: target.to_owned(),
234 action: StageAction::Write(tmp),
235 })
236}
237
238fn stage_remove(target: &Path) -> StagedFile {
239 StagedFile {
240 final_path: target.to_owned(),
241 action: StageAction::Remove,
242 }
243}
244
245fn discard_stage(stage: &StagedFile) {
246 if let StageAction::Write(tmp) = &stage.action {
247 let _ = fs::remove_file(tmp);
248 }
249}
250
251fn discard_all(stages: &[StagedFile]) {
252 for s in stages {
253 discard_stage(s);
254 }
255}
256
257#[derive(Debug)]
262struct CommittedStage {
263 final_path: PathBuf,
264 backup_path: Option<PathBuf>,
265 placed_new_content: bool,
269}
270
271fn commit_stage(stage: &StagedFile) -> Result<CommittedStage, String> {
277 let existing_kind = match fs::symlink_metadata(&stage.final_path) {
278 Ok(m) => Some(m),
279 Err(e) if e.kind() == io::ErrorKind::NotFound => None,
280 Err(e) => {
281 return Err(format!(
282 "failed to inspect {} before commit: {e}",
283 stage.final_path.display(),
284 ))
285 }
286 };
287 if let Some(m) = &existing_kind {
288 if m.is_dir() {
289 return Err(format!(
290 "refusing to commit over directory at {}",
291 stage.final_path.display(),
292 ));
293 }
294 }
295
296 let backup_path = if existing_kind.is_some() {
297 let bak = bak_path_for(&stage.final_path)?;
298 let _ = fs::remove_file(&bak);
299 fs::rename(&stage.final_path, &bak).map_err(|e| {
300 format!(
301 "failed to back up {} before commit: {e}",
302 stage.final_path.display(),
303 )
304 })?;
305 Some(bak)
306 } else {
307 None
308 };
309
310 match &stage.action {
311 StageAction::Write(tmp) => {
312 if let Err(e) = fs::rename(tmp, &stage.final_path) {
313 if let Some(bak) = &backup_path {
317 let _ = fs::rename(bak, &stage.final_path);
318 }
319 return Err(format!(
320 "failed to commit {} (staged at {}): {e}",
321 stage.final_path.display(),
322 tmp.display(),
323 ));
324 }
325 Ok(CommittedStage {
326 final_path: stage.final_path.clone(),
327 backup_path,
328 placed_new_content: true,
329 })
330 }
331 StageAction::Remove => {
332 Ok(CommittedStage {
335 final_path: stage.final_path.clone(),
336 backup_path,
337 placed_new_content: false,
338 })
339 }
340 }
341}
342
343fn rollback_committed(committed: &[CommittedStage]) {
350 for c in committed.iter().rev() {
351 if c.placed_new_content {
352 let _ = fs::remove_file(&c.final_path);
353 }
354 if let Some(bak) = &c.backup_path {
355 let _ = fs::rename(bak, &c.final_path);
356 }
357 }
358}
359
360fn finalize_committed(committed: &[CommittedStage]) {
364 for c in committed {
365 if let Some(bak) = &c.backup_path {
366 let _ = fs::remove_file(bak);
367 }
368 }
369}
370
371struct ImportGuard<'a>(&'a AtomicBool);
375
376impl<'a> ImportGuard<'a> {
377 fn new(flag: &'a AtomicBool) -> Self {
378 flag.store(true, Ordering::Relaxed);
379 Self(flag)
380 }
381}
382
383impl<'a> Drop for ImportGuard<'a> {
384 fn drop(&mut self) {
385 self.0.store(false, Ordering::Relaxed);
386 }
387}
388
389fn ensure_main_window<R: Runtime>(webview: &WebviewWindow<R>) -> Result<(), String> {
390 if webview.label() != MAIN_WINDOW_LABEL {
391 return Err("backup commands are restricted to the main window".to_string());
392 }
393 Ok(())
394}
395
396fn import_audit_summary(bundle: &BackupBundle) -> String {
402 format!(
403 "backup: imported bundle (schema={}, events={} B, pause={}, screen_time={}, supporter={})",
404 bundle.manifest.schema_version,
405 bundle.files.events_jsonl.len(),
406 bundle.files.pause_json.is_some(),
407 bundle.files.screen_time_json.is_some(),
408 bundle.files.supporter_json.is_some(),
409 )
410}
411
412async fn apply_bundle_to_scheduler<R: Runtime>(
413 app: &AppHandle<R>,
414 scheduler: &Scheduler,
415 supporter_path: &Path,
416 bundle: BackupBundle,
417) -> Result<(), String> {
418 validate_bundle(&bundle)?;
419
420 let _import_guard = ImportGuard::new(&scheduler.import_in_progress);
421
422 let stage_plan: [(&Path, Option<&str>); 5] = [
432 (
433 &scheduler.config_path,
434 Some(bundle.files.settings_json.as_str()),
435 ),
436 (
437 &scheduler.events_path,
438 Some(bundle.files.events_jsonl.as_str()),
439 ),
440 (&scheduler.pause_path, bundle.files.pause_json.as_deref()),
441 (
442 &scheduler.screen_time_path,
443 bundle.files.screen_time_json.as_deref(),
444 ),
445 (supporter_path, bundle.files.supporter_json.as_deref()),
446 ];
447 let mut stages: Vec<StagedFile> = Vec::with_capacity(stage_plan.len());
448 let staging = (|| -> Result<(), String> {
449 for (target, content) in stage_plan {
450 stages.push(match content {
451 Some(text) => stage_write(target, text.as_bytes())?,
452 None => stage_remove(target),
453 });
454 }
455 Ok(())
456 })();
457 if let Err(e) = staging {
458 discard_all(&stages);
459 return Err(e);
460 }
461
462 let mut committed: Vec<CommittedStage> = Vec::with_capacity(stages.len());
475 let commit_result = (|| -> Result<(), String> {
476 let logger_lock = scheduler.logger.write_lock();
477 let _guard = logger_lock.lock().unwrap_or_else(|p| p.into_inner());
478 for stage in &stages {
479 committed.push(commit_stage(stage)?);
480 }
481 Ok(())
482 })();
483 if let Err(e) = commit_result {
484 rollback_committed(&committed);
485 discard_all(&stages);
486 return Err(e);
487 }
488 finalize_committed(&committed);
489
490 let profiles_file = config::load(&scheduler.config_path);
491 {
492 let mut profiles = scheduler.profiles.lock().await;
493 *profiles = profiles_file.profiles.clone();
494 }
495 {
496 let mut active = scheduler.active_profile_name.lock().await;
497 *active = profiles_file.active.clone();
498 }
499 scheduler.onboarding_completed.store(
500 profiles_file.onboarding_completed,
501 std::sync::atomic::Ordering::Relaxed,
502 );
503 {
504 let mut settings = scheduler.settings.lock().await;
505 *settings = profiles_file.active_settings();
508 }
509 let restored_pause = restore_pause_state(&scheduler.pause_path);
510 let paused = matches!(restored_pause, crate::scheduler::PauseState::PausedUntil(_));
511 {
512 let mut pause_state = scheduler.pause_state.lock().await;
513 *pause_state = restored_pause;
514 }
515 {
516 let mut screen_time = scheduler.screen_time.lock().await;
517 let today = local_today_string();
518 *screen_time = ScreenTimeState::from_snapshot(
519 crate::screen_time_store::load(&scheduler.screen_time_path),
520 &today,
521 );
522 }
523 {
529 let mut timers = scheduler.timers.lock().await;
530 reset_timers_keep_sleep(&mut timers);
531 }
532
533 log::info!("{}", import_audit_summary(&bundle));
534
535 let _ = app.emit("profile:changed", profiles_file.active);
536 let _ = app.emit("pause:changed", paused);
537 let _ = app.emit("stats:replaced", ());
543 Ok(())
544}
545
546#[tauri::command]
547pub async fn export_backup_to_path<R: Runtime>(
548 webview: WebviewWindow<R>,
549 scheduler: tauri::State<'_, Scheduler>,
550 supporter_state: tauri::State<'_, SupporterAppState>,
551 path: String,
552) -> Result<(), String> {
553 ensure_main_window(&webview)?;
554 let settings_json = serde_json::to_string_pretty(&scheduler.snapshot_profiles_file().await)
555 .map_err(|e| format!("failed to serialise settings: {e}"))?;
556 let events_jsonl =
557 read_optional_text(&scheduler.events_path, MAX_BACKUP_BYTES)?.unwrap_or_default();
558 let pause_json = read_optional_text(&scheduler.pause_path, MAX_BACKUP_BYTES)?;
559 let screen_time_json = read_optional_text(&scheduler.screen_time_path, MAX_BACKUP_BYTES)?;
560 let supporter_json = exportable_supporter(&supporter_state.path);
561
562 let bundle = BackupBundle {
563 manifest: BackupManifest {
564 schema_version: BACKUP_SCHEMA_VERSION,
565 created_at: Utc::now().to_rfc3339(),
566 app: BUNDLE_APP_ID.to_string(),
567 },
568 files: BackupFiles {
569 settings_json,
570 events_jsonl,
571 pause_json,
572 screen_time_json,
573 supporter_json,
574 },
575 };
576
577 let text = serde_json::to_string_pretty(&bundle)
578 .map_err(|e| format!("failed to serialise backup bundle: {e}"))?;
579 write_user_only(Path::new(&path), text.as_bytes())
580 .map_err(|e| format!("failed to write backup file: {e}"))?;
581 Ok(())
582}
583
584#[tauri::command]
585pub async fn import_backup_from_path<R: Runtime>(
586 webview: WebviewWindow<R>,
587 scheduler: tauri::State<'_, Scheduler>,
588 supporter_state: tauri::State<'_, SupporterAppState>,
589 path: String,
590) -> Result<(), String> {
591 ensure_main_window(&webview)?;
592 let text = read_capped(Path::new(&path), MAX_BACKUP_BYTES).map_err(|e| match e.kind() {
593 io::ErrorKind::InvalidData => format!(
594 "backup file exceeds the maximum allowed size of {} MiB",
595 MAX_BACKUP_BYTES / (1024 * 1024),
596 ),
597 _ => format!("failed to read backup file: {e}"),
598 })?;
599 let bundle: BackupBundle =
600 serde_json::from_str(&text).map_err(|e| format!("failed to parse backup file: {e}"))?;
601 let app = webview.app_handle().clone();
602 apply_bundle_to_scheduler(&app, scheduler.inner(), &supporter_state.path, bundle).await
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use crate::config::Profile;
609 use crate::scheduler::Settings;
610 use crate::test_support::temp_dir;
611 use chrono::TimeZone;
612
613 fn default_profiles_json() -> String {
614 serde_json::to_string(&ProfilesFile::single(
615 "Default".to_string(),
616 Settings::default(),
617 ))
618 .unwrap()
619 }
620
621 fn manifest() -> BackupManifest {
622 BackupManifest {
623 schema_version: BACKUP_SCHEMA_VERSION,
624 created_at: "2026-01-01T00:00:00Z".to_string(),
625 app: BUNDLE_APP_ID.to_string(),
626 }
627 }
628
629 fn valid_bundle() -> BackupBundle {
630 BackupBundle {
631 manifest: manifest(),
632 files: BackupFiles {
633 settings_json: default_profiles_json(),
634 events_jsonl: String::new(),
635 pause_json: None,
636 screen_time_json: None,
637 supporter_json: None,
638 },
639 }
640 }
641
642 fn manual_supporter_record() -> SupporterRecord {
643 SupporterRecord {
644 license_key: "manual-key".to_string(),
645 instance_id: "i".to_string(),
646 activated_at: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
647 last_validated_at: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
648 source: SupporterSource::Manual,
649 signature: String::new(),
650 }
651 }
652
653 fn lemon_supporter_record() -> SupporterRecord {
654 SupporterRecord {
655 source: SupporterSource::LemonSqueezy,
656 ..manual_supporter_record()
657 }
658 }
659
660 #[test]
661 fn validate_bundle_accepts_minimal_valid_bundle() {
662 validate_bundle(&valid_bundle()).expect("baseline bundle is valid");
663 }
664
665 #[test]
666 fn validate_bundle_rejects_future_schema_version() {
667 let mut bundle = valid_bundle();
668 bundle.manifest.schema_version = BACKUP_SCHEMA_VERSION + 1;
669 let err = validate_bundle(&bundle).expect_err("future schema is rejected");
670 assert!(err.contains("newer than this app supports"));
671 }
672
673 #[test]
674 fn validate_bundle_accepts_older_schema_version() {
675 let mut bundle = valid_bundle();
679 bundle.manifest.schema_version = BACKUP_SCHEMA_VERSION.saturating_sub(1);
680 validate_bundle(&bundle).expect("older schemas are accepted");
681 }
682
683 #[test]
684 fn validate_bundle_rejects_wrong_app_id() {
685 let mut bundle = valid_bundle();
686 bundle.manifest.app = "com.someone.else".to_string();
687 let err = validate_bundle(&bundle).expect_err("foreign app id is rejected");
688 assert!(err.contains("different app"));
689 }
690
691 #[test]
692 fn validate_bundle_rejects_empty_profiles() {
693 let mut bundle = valid_bundle();
694 bundle.files.settings_json = serde_json::to_string(&ProfilesFile {
695 profiles: vec![],
696 active: String::new(),
697 ..ProfilesFile::default()
698 })
699 .unwrap();
700 let err = validate_bundle(&bundle).expect_err("empty profiles is rejected");
701 assert!(err.contains("no profiles"));
702 }
703
704 #[test]
705 fn validate_bundle_rejects_active_not_in_profiles() {
706 let mut bundle = valid_bundle();
707 bundle.files.settings_json = serde_json::to_string(&ProfilesFile {
708 profiles: vec![Profile {
709 name: "Default".to_string(),
710 settings: Settings::default(),
711 }],
712 active: "Nonexistent".to_string(),
713 ..ProfilesFile::default()
714 })
715 .unwrap();
716 let err = validate_bundle(&bundle).expect_err("dangling active profile is rejected");
717 assert!(err.contains("not in the profile list"));
718 }
719
720 #[test]
721 fn validate_bundle_tolerates_unparseable_events_line() {
722 let mut bundle = valid_bundle();
726 bundle.files.events_jsonl =
727 r#"{"t":"2026-01-01T00:00:00Z","type":"future_event_from_newer_build"}"#.to_string();
728 validate_bundle(&bundle).expect("unknown event line is tolerated, not rejected");
729 }
730
731 #[test]
732 fn validate_bundle_rejects_invalid_settings_json() {
733 let mut bundle = valid_bundle();
734 bundle.files.settings_json = "{ not valid".to_string();
735 let err = validate_bundle(&bundle).expect_err("malformed settings is rejected");
736 assert!(err.contains("settings_json is invalid"));
737 }
738
739 #[test]
740 fn validate_bundle_rejects_invalid_pause_json() {
741 let mut bundle = valid_bundle();
742 bundle.files.pause_json = Some("not-json".to_string());
743 let err = validate_bundle(&bundle).expect_err("malformed pause is rejected");
744 assert!(err.contains("pause_json is invalid"));
745 }
746
747 #[test]
748 fn validate_bundle_rejects_invalid_supporter_json() {
749 let mut bundle = valid_bundle();
750 bundle.files.supporter_json = Some("{\"not\":\"a record\"}".to_string());
751 let err = validate_bundle(&bundle).expect_err("malformed supporter is rejected");
752 assert!(err.contains("supporter_json is invalid"));
753 }
754
755 #[test]
756 fn validate_bundle_rejects_invalid_screen_time_json() {
757 let mut bundle = valid_bundle();
758 bundle.files.screen_time_json = Some("not-json".to_string());
759 let err = validate_bundle(&bundle).expect_err("malformed screen_time is rejected");
760 assert!(err.contains("screen_time_json is invalid"));
761 }
762
763 #[test]
764 fn validate_bundle_skips_blank_event_lines() {
765 let mut bundle = valid_bundle();
766 bundle.files.events_jsonl = "\n \n".to_string();
767 validate_bundle(&bundle).expect("blank lines are tolerated");
768 }
769
770 #[test]
771 fn read_optional_text_propagates_non_notfound_error() {
772 let dir = temp_dir();
776 let err = read_optional_text(dir.path(), MAX_BACKUP_BYTES)
777 .expect_err("reading a dir is not NotFound");
778 assert!(err.contains("failed to read"));
779 }
780
781 #[test]
782 fn read_optional_text_rejects_oversized_files() {
783 let dir = temp_dir();
787 let path = dir.path().join("huge.json");
788 fs::write(&path, vec![b'x'; 2048]).unwrap();
789 let err = read_optional_text(&path, 1024).expect_err("oversize is rejected");
790 assert!(err.contains("failed to read"));
791 }
792
793 #[test]
794 fn exportable_supporter_keeps_manual_records() {
795 let dir = temp_dir();
798 let path = dir.path().join("supporter.json");
799 supporter::save(&path, &manual_supporter_record()).unwrap();
800 let want = fs::read_to_string(&path).unwrap();
801 assert_eq!(exportable_supporter(&path).as_deref(), Some(want.as_str()));
802 }
803
804 #[test]
805 fn exportable_supporter_drops_lemonsqueezy_records() {
806 let dir = temp_dir();
807 let path = dir.path().join("supporter.json");
808 supporter::save(&path, &lemon_supporter_record()).unwrap();
809 assert!(
810 exportable_supporter(&path).is_none(),
811 "LemonSqueezy records are machine-bound and shouldn't ride along in backups",
812 );
813 }
814
815 #[test]
816 fn exportable_supporter_drops_tampered_records() {
817 let dir = temp_dir();
820 let path = dir.path().join("supporter.json");
821 supporter::save(&path, &manual_supporter_record()).unwrap();
822 let raw = fs::read_to_string(&path).unwrap();
823 let tampered = raw.replace("manual-key", "FORGED-KEY");
824 fs::write(&path, tampered).unwrap();
825 assert!(exportable_supporter(&path).is_none());
826 }
827
828 #[test]
829 fn exportable_supporter_drops_unparseable_payload() {
830 let dir = temp_dir();
831 let path = dir.path().join("supporter.json");
832 fs::write(&path, "garbage").unwrap();
833 assert!(exportable_supporter(&path).is_none());
834 }
835
836 #[test]
837 fn exportable_supporter_returns_none_for_missing_file() {
838 let dir = temp_dir();
839 let missing = dir.path().join("never-existed.json");
840 assert!(exportable_supporter(&missing).is_none());
841 }
842
843 #[test]
844 fn read_optional_text_returns_none_for_missing() {
845 let dir = temp_dir();
846 let path = dir.path().join("missing.json");
847 assert!(read_optional_text(&path, MAX_BACKUP_BYTES)
848 .unwrap()
849 .is_none());
850 }
851
852 #[test]
853 fn read_optional_text_returns_some_for_existing() {
854 let dir = temp_dir();
855 let path = dir.path().join("present.json");
856 fs::write(&path, "hello").unwrap();
857 assert_eq!(
858 read_optional_text(&path, MAX_BACKUP_BYTES)
859 .unwrap()
860 .as_deref(),
861 Some("hello")
862 );
863 }
864
865 #[test]
866 fn stage_write_then_commit_replaces_target_atomically() {
867 let dir = temp_dir();
868 let path = dir.path().join("settings.json");
869 fs::write(&path, "old").unwrap();
870 let staged = stage_write(&path, b"new").expect("stage succeeds");
871 assert_eq!(fs::read_to_string(&path).unwrap(), "old");
874 let tmp = stage_path_for(&path).unwrap();
875 assert!(tmp.exists());
876 let committed = commit_stage(&staged).expect("commit succeeds");
877 assert_eq!(fs::read_to_string(&path).unwrap(), "new");
878 assert!(!tmp.exists(), "tmp moved into place by rename");
879 let bak = committed.backup_path.as_ref().expect("write had a backup");
883 assert_eq!(fs::read_to_string(bak).unwrap(), "old");
884 finalize_committed(std::slice::from_ref(&committed));
885 assert!(!bak.exists(), "finalize cleans up .pre-import.bak");
886 }
887
888 #[test]
889 fn stage_remove_then_commit_unlinks_existing_target() {
890 let dir = temp_dir();
891 let path = dir.path().join("supporter.json");
892 fs::write(&path, "{}").unwrap();
893 let staged = stage_remove(&path);
894 let committed = commit_stage(&staged).unwrap();
895 assert!(!path.exists());
896 let bak = committed.backup_path.as_ref().expect("remove had a backup");
899 assert!(bak.exists(), "remove parked target at .pre-import.bak");
900 finalize_committed(std::slice::from_ref(&committed));
901 assert!(!bak.exists());
902 }
903
904 #[test]
905 fn stage_remove_then_commit_is_ok_when_target_absent() {
906 let dir = temp_dir();
907 let path = dir.path().join("never-existed.json");
908 let staged = stage_remove(&path);
909 let committed = commit_stage(&staged).unwrap();
910 assert!(!path.exists());
911 assert!(
912 committed.backup_path.is_none(),
913 "no bak when nothing to back up"
914 );
915 }
916
917 #[test]
918 fn stage_write_fails_when_parent_is_a_file() {
919 let dir = temp_dir();
923 let blocking_file = dir.path().join("blocker");
924 fs::write(&blocking_file, "").unwrap();
925 let target = blocking_file.join("under-blocker.json");
926 let err = stage_write(&target, b"x").expect_err("blocked parent fails");
927 assert!(err.contains("failed to ensure parent dir") || err.contains("failed to stage"));
928 }
929
930 #[test]
931 fn commit_stage_refuses_to_overwrite_directory() {
932 let dir = temp_dir();
937 let path = dir.path().join("squat-dir");
938 fs::create_dir(&path).unwrap();
939 let remove_staged = stage_remove(&path);
940 let err = commit_stage(&remove_staged).expect_err("dir at remove target fails");
941 assert!(err.contains("refusing to commit over directory"));
942 let write_staged = stage_write(&path, b"x").expect("stage to sibling tmp ok");
943 let err = commit_stage(&write_staged).expect_err("dir at write target fails");
944 assert!(err.contains("refusing to commit over directory"));
945 }
946
947 #[test]
948 fn commit_stage_unlinks_stale_bak_before_parking() {
949 let dir = temp_dir();
953 let path = dir.path().join("settings.json");
954 fs::write(&path, "current").unwrap();
955 let stale_bak = bak_path_for(&path).unwrap();
956 fs::write(&stale_bak, "stale").unwrap();
957 let staged = stage_write(&path, b"new").unwrap();
958 let committed = commit_stage(&staged).expect("commit succeeds despite stale bak");
959 assert_eq!(fs::read_to_string(&path).unwrap(), "new");
960 let bak = committed.backup_path.as_ref().unwrap();
961 assert_eq!(
962 fs::read_to_string(bak).unwrap(),
963 "current",
964 "stale bak was replaced by the current target's contents",
965 );
966 finalize_committed(std::slice::from_ref(&committed));
967 }
968
969 #[test]
970 fn rollback_committed_restores_writes_in_reverse() {
971 let dir = temp_dir();
972 let a = dir.path().join("a.json");
973 let b = dir.path().join("b.json");
974 fs::write(&a, "orig-a").unwrap();
975 fs::write(&b, "orig-b").unwrap();
976 let stage_a = stage_write(&a, b"new-a").unwrap();
977 let stage_b = stage_write(&b, b"new-b").unwrap();
978 let committed = vec![
979 commit_stage(&stage_a).unwrap(),
980 commit_stage(&stage_b).unwrap(),
981 ];
982 assert_eq!(fs::read_to_string(&a).unwrap(), "new-a");
983 assert_eq!(fs::read_to_string(&b).unwrap(), "new-b");
984 rollback_committed(&committed);
985 assert_eq!(fs::read_to_string(&a).unwrap(), "orig-a");
986 assert_eq!(fs::read_to_string(&b).unwrap(), "orig-b");
987 assert!(!bak_path_for(&a).unwrap().exists());
988 assert!(!bak_path_for(&b).unwrap().exists());
989 }
990
991 #[test]
992 fn rollback_committed_restores_removed_target() {
993 let dir = temp_dir();
994 let path = dir.path().join("pause.json");
995 fs::write(&path, "original-pause").unwrap();
996 let staged = stage_remove(&path);
997 let committed = commit_stage(&staged).unwrap();
998 assert!(!path.exists(), "target moved aside by commit");
999 rollback_committed(&[committed]);
1000 assert_eq!(
1001 fs::read_to_string(&path).unwrap(),
1002 "original-pause",
1003 "rollback restored the removed file from .pre-import.bak",
1004 );
1005 }
1006
1007 #[test]
1008 fn finalize_committed_removes_all_baks() {
1009 let dir = temp_dir();
1010 let a = dir.path().join("a.json");
1011 let b = dir.path().join("b.json");
1012 fs::write(&a, "orig-a").unwrap();
1013 fs::write(&b, "orig-b").unwrap();
1014 let committed = vec![
1015 commit_stage(&stage_write(&a, b"new-a").unwrap()).unwrap(),
1016 commit_stage(&stage_write(&b, b"new-b").unwrap()).unwrap(),
1017 ];
1018 assert!(bak_path_for(&a).unwrap().exists());
1019 assert!(bak_path_for(&b).unwrap().exists());
1020 finalize_committed(&committed);
1021 assert!(!bak_path_for(&a).unwrap().exists());
1022 assert!(!bak_path_for(&b).unwrap().exists());
1023 }
1024
1025 #[test]
1026 fn discard_stage_clears_staged_tmp() {
1027 let dir = temp_dir();
1028 let path = dir.path().join("x.json");
1029 let staged = stage_write(&path, b"draft").unwrap();
1030 let tmp = stage_path_for(&path).unwrap();
1031 assert!(tmp.exists());
1032 discard_stage(&staged);
1033 assert!(!tmp.exists());
1034 }
1035
1036 #[test]
1037 fn commit_stage_restores_backup_when_final_rename_fails() {
1038 let dir = temp_dir();
1045 let final_path = dir.path().join("file.json");
1046 fs::write(&final_path, "original").unwrap();
1047 let bogus_tmp = dir.path().join("does-not-exist.tmp");
1048 let staged = StagedFile {
1049 final_path: final_path.clone(),
1050 action: StageAction::Write(bogus_tmp),
1051 };
1052 let err = commit_stage(&staged).expect_err("missing tmp fails commit");
1053 assert!(err.contains("failed to commit"), "got: {err}");
1054 assert_eq!(
1055 fs::read_to_string(&final_path).unwrap(),
1056 "original",
1057 "self-rollback restored the bak into final_path",
1058 );
1059 assert!(
1060 !bak_path_for(&final_path).unwrap().exists(),
1061 "bak was renamed back, no leftover",
1062 );
1063 }
1064
1065 #[cfg(unix)]
1066 #[test]
1067 fn commit_stage_surfaces_metadata_error() {
1068 use std::os::unix::fs::PermissionsExt;
1073 let dir = temp_dir();
1074 let parent = dir.path().join("opaque");
1075 fs::create_dir(&parent).unwrap();
1076 let target = parent.join("file.json");
1077 fs::write(&target, b"").unwrap();
1078 let original_mode = fs::metadata(&parent).unwrap().permissions().mode();
1079 fs::set_permissions(&parent, fs::Permissions::from_mode(0o000)).unwrap();
1080 let staged = StagedFile {
1081 final_path: target.clone(),
1082 action: StageAction::Remove,
1083 };
1084 let result = commit_stage(&staged);
1085 fs::set_permissions(&parent, fs::Permissions::from_mode(original_mode)).unwrap();
1088 let err = result.expect_err("opaque parent fails metadata read");
1089 assert!(err.contains("failed to inspect"), "got: {err}");
1090 }
1091
1092 #[test]
1093 fn commit_stage_surfaces_bak_rename_failure() {
1094 let dir = temp_dir();
1100 let final_path = dir.path().join("settings.json");
1101 fs::write(&final_path, "original").unwrap();
1102 let bak = bak_path_for(&final_path).unwrap();
1103 fs::create_dir(&bak).unwrap();
1104 let staged = stage_write(&final_path, b"new").unwrap();
1105 let err = commit_stage(&staged).expect_err("bak-as-dir aborts commit");
1106 assert!(err.contains("failed to back up"), "got: {err}");
1107 assert_eq!(fs::read_to_string(&final_path).unwrap(), "original");
1109 }
1110
1111 #[test]
1112 fn import_audit_summary_reports_every_optional_field() {
1113 let bundle = BackupBundle {
1114 manifest: BackupManifest {
1115 schema_version: BACKUP_SCHEMA_VERSION,
1116 created_at: "2026-01-01T00:00:00Z".to_string(),
1117 app: BUNDLE_APP_ID.to_string(),
1118 },
1119 files: BackupFiles {
1120 settings_json: String::new(),
1121 events_jsonl: "abcdef".to_string(),
1122 pause_json: Some("p".to_string()),
1123 screen_time_json: None,
1124 supporter_json: Some("s".to_string()),
1125 },
1126 };
1127 let s = import_audit_summary(&bundle);
1128 assert!(s.contains(&format!("schema={BACKUP_SCHEMA_VERSION}")));
1129 assert!(s.contains("events=6 B"));
1130 assert!(s.contains("pause=true"));
1131 assert!(s.contains("screen_time=false"));
1132 assert!(s.contains("supporter=true"));
1133 }
1134}
1135
1136#[cfg(all(test, not(target_os = "windows")))]
1143mod rig_tests {
1144 use super::*;
1145 use crate::config::{Profile, DEFAULT_PROFILE_NAME};
1146 use crate::scheduler::Settings;
1147 use crate::supporter::SupporterRecord;
1148 use crate::test_support::{mock_app_with_scheduler, temp_dir};
1149 use chrono::TimeZone;
1150 use std::sync::atomic::{AtomicBool, Ordering};
1151 use std::sync::Arc;
1152 use std::time::{Duration, Instant};
1153 use tauri::{Listener, Manager};
1154
1155 fn manual_supporter_text() -> String {
1156 serde_json::to_string(&SupporterRecord {
1157 license_key: "manual-key".to_string(),
1158 instance_id: "i".to_string(),
1159 activated_at: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1160 last_validated_at: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1161 source: SupporterSource::Manual,
1162 signature: String::new(),
1163 })
1164 .unwrap()
1165 }
1166
1167 async fn build_bundle_for(scheduler: &Scheduler, supporter_path: &Path) -> BackupBundle {
1168 let profiles_file = scheduler.snapshot_profiles_file().await;
1169 let settings_json = serde_json::to_string_pretty(&profiles_file).unwrap();
1170 let events_jsonl = read_optional_text(&scheduler.events_path, MAX_BACKUP_BYTES)
1171 .unwrap()
1172 .unwrap_or_default();
1173 let pause_json = read_optional_text(&scheduler.pause_path, MAX_BACKUP_BYTES).unwrap();
1174 let screen_time_json =
1175 read_optional_text(&scheduler.screen_time_path, MAX_BACKUP_BYTES).unwrap();
1176 let supporter_json = read_optional_text(supporter_path, MAX_BACKUP_BYTES).unwrap();
1177 BackupBundle {
1178 manifest: BackupManifest {
1179 schema_version: BACKUP_SCHEMA_VERSION,
1180 created_at: "2026-01-01T00:00:00Z".to_string(),
1181 app: BUNDLE_APP_ID.to_string(),
1182 },
1183 files: BackupFiles {
1184 settings_json,
1185 events_jsonl,
1186 pause_json,
1187 screen_time_json,
1188 supporter_json,
1189 },
1190 }
1191 }
1192
1193 fn supporter_path_in(dir: &Path) -> std::path::PathBuf {
1194 dir.join("supporter.json")
1195 }
1196
1197 fn main_webview(
1202 app: &tauri::App<tauri::test::MockRuntime>,
1203 ) -> tauri::WebviewWindow<tauri::test::MockRuntime> {
1204 tauri::WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, Default::default())
1205 .build()
1206 .expect("main webview builds")
1207 }
1208
1209 #[tokio::test]
1210 async fn round_trip_restores_profiles_and_emits_events() {
1211 let source_settings = Settings {
1212 micro_interval_secs: 600,
1213 ..Settings::default()
1214 };
1215 let (src_dir, src_sched) = crate::test_support::test_scheduler_with_profiles(
1216 vec![
1217 Profile {
1218 name: DEFAULT_PROFILE_NAME.to_string(),
1219 settings: Settings::default(),
1220 },
1221 Profile {
1222 name: "Work".to_string(),
1223 settings: source_settings.clone(),
1224 },
1225 ],
1226 "Work",
1227 );
1228 crate::config::save(
1229 &src_sched.config_path,
1230 &src_sched.snapshot_profiles_file().await,
1231 )
1232 .unwrap();
1233 let bundle = build_bundle_for(&src_sched, &supporter_path_in(src_dir.path())).await;
1234
1235 let (dest_dir, app, dest_sched) = mock_app_with_scheduler(Settings::default());
1236 let dest_supporter = supporter_path_in(dest_dir.path());
1237
1238 let profile_emitted = Arc::new(AtomicBool::new(false));
1239 let pause_emitted = Arc::new(AtomicBool::new(false));
1240 let stats_emitted = Arc::new(AtomicBool::new(false));
1241 {
1242 let p = profile_emitted.clone();
1243 let pa = pause_emitted.clone();
1244 let s = stats_emitted.clone();
1245 app.listen("profile:changed", move |_| p.store(true, Ordering::SeqCst));
1246 app.listen("pause:changed", move |_| pa.store(true, Ordering::SeqCst));
1247 app.listen("stats:replaced", move |_| s.store(true, Ordering::SeqCst));
1248 }
1249
1250 apply_bundle_to_scheduler(&app.handle().clone(), &dest_sched, &dest_supporter, bundle)
1251 .await
1252 .expect("apply succeeds");
1253
1254 assert_eq!(dest_sched.profiles.lock().await.len(), 2);
1255 assert_eq!(dest_sched.active_profile_name.lock().await.as_str(), "Work",);
1256 assert_eq!(
1257 dest_sched.settings.lock().await.micro_interval_secs,
1258 source_settings.micro_interval_secs,
1259 );
1260 assert!(profile_emitted.load(Ordering::SeqCst));
1261 assert!(pause_emitted.load(Ordering::SeqCst));
1262 assert!(stats_emitted.load(Ordering::SeqCst));
1263 }
1264
1265 #[tokio::test]
1266 async fn round_trip_restores_supporter_when_manual() {
1267 let supporter_text = manual_supporter_text();
1268 let (dest_dir, app, dest_sched) = mock_app_with_scheduler(Settings::default());
1269 let dest_supporter = supporter_path_in(dest_dir.path());
1270
1271 let bundle = BackupBundle {
1272 manifest: BackupManifest {
1273 schema_version: BACKUP_SCHEMA_VERSION,
1274 created_at: "2026-01-01T00:00:00Z".to_string(),
1275 app: BUNDLE_APP_ID.to_string(),
1276 },
1277 files: BackupFiles {
1278 settings_json: serde_json::to_string(&ProfilesFile::single(
1279 "Default".to_string(),
1280 Settings::default(),
1281 ))
1282 .unwrap(),
1283 events_jsonl: String::new(),
1284 pause_json: None,
1285 screen_time_json: None,
1286 supporter_json: Some(supporter_text.clone()),
1287 },
1288 };
1289 apply_bundle_to_scheduler(&app.handle().clone(), &dest_sched, &dest_supporter, bundle)
1290 .await
1291 .unwrap();
1292 assert_eq!(fs::read_to_string(&dest_supporter).unwrap(), supporter_text);
1293 }
1294
1295 #[tokio::test]
1296 async fn import_resets_break_timers_against_restored_settings() {
1297 let (dest_dir, app, dest_sched) = mock_app_with_scheduler(Settings::default());
1298 let stale = Instant::now() - Duration::from_secs(60 * 60);
1300 {
1301 let mut t = dest_sched.timers.lock().await;
1302 t.last_micro = stale;
1303 t.micro_warned = true;
1304 t.micro_postpone_count = 3;
1305 }
1306 let bundle = BackupBundle {
1307 manifest: BackupManifest {
1308 schema_version: BACKUP_SCHEMA_VERSION,
1309 created_at: "2026-01-01T00:00:00Z".to_string(),
1310 app: BUNDLE_APP_ID.to_string(),
1311 },
1312 files: BackupFiles {
1313 settings_json: serde_json::to_string(&ProfilesFile::single(
1314 "Default".to_string(),
1315 Settings::default(),
1316 ))
1317 .unwrap(),
1318 events_jsonl: String::new(),
1319 pause_json: None,
1320 screen_time_json: None,
1321 supporter_json: None,
1322 },
1323 };
1324 apply_bundle_to_scheduler(
1325 &app.handle().clone(),
1326 &dest_sched,
1327 &supporter_path_in(dest_dir.path()),
1328 bundle,
1329 )
1330 .await
1331 .unwrap();
1332 let t = dest_sched.timers.lock().await;
1333 assert!(
1334 t.last_micro > stale,
1335 "last_micro was reseeded against post-import wall clock",
1336 );
1337 assert!(!t.micro_warned, "warn flag cleared");
1338 assert_eq!(t.micro_postpone_count, 0, "postpone counter cleared");
1339 }
1340
1341 #[tokio::test]
1342 async fn import_rejects_future_schema() {
1343 let (dest_dir, app, sched) = mock_app_with_scheduler(Settings::default());
1344 let mut bundle = BackupBundle {
1345 manifest: BackupManifest {
1346 schema_version: BACKUP_SCHEMA_VERSION + 1,
1347 created_at: "2026-01-01T00:00:00Z".to_string(),
1348 app: BUNDLE_APP_ID.to_string(),
1349 },
1350 files: BackupFiles {
1351 settings_json: serde_json::to_string(&ProfilesFile::single(
1352 "Default".to_string(),
1353 Settings::default(),
1354 ))
1355 .unwrap(),
1356 events_jsonl: String::new(),
1357 pause_json: None,
1358 screen_time_json: None,
1359 supporter_json: None,
1360 },
1361 };
1362 bundle.manifest.schema_version = BACKUP_SCHEMA_VERSION + 1;
1363 let err = apply_bundle_to_scheduler(
1364 &app.handle().clone(),
1365 &sched,
1366 &supporter_path_in(dest_dir.path()),
1367 bundle,
1368 )
1369 .await
1370 .expect_err("future schema rejected");
1371 assert!(err.contains("newer than this app supports"));
1372 }
1373
1374 #[tokio::test]
1378 async fn export_then_import_through_commands_round_trips_state() {
1379 let bundle_dir = temp_dir();
1380 let bundle_path = bundle_dir.path().join("entracte-backup.json");
1381
1382 let source_settings = Settings {
1385 micro_interval_secs: 777,
1386 ..Settings::default()
1387 };
1388 let (src_dir, src_sched) = crate::test_support::test_scheduler_with_profiles(
1389 vec![
1390 Profile {
1391 name: DEFAULT_PROFILE_NAME.to_string(),
1392 settings: Settings::default(),
1393 },
1394 Profile {
1395 name: "Work".to_string(),
1396 settings: source_settings.clone(),
1397 },
1398 ],
1399 "Work",
1400 );
1401 crate::config::save(
1402 &src_sched.config_path,
1403 &src_sched.snapshot_profiles_file().await,
1404 )
1405 .unwrap();
1406 let src_app = crate::test_support::wrap_in_mock_app(src_sched.clone());
1407 src_app.manage(crate::SupporterAppState {
1408 path: supporter_path_in(src_dir.path()),
1409 client: reqwest::Client::new(),
1410 });
1411 let src_webview = main_webview(&src_app);
1412
1413 export_backup_to_path(
1414 src_webview,
1415 src_app.state::<Scheduler>(),
1416 src_app.state::<crate::SupporterAppState>(),
1417 bundle_path.to_string_lossy().to_string(),
1418 )
1419 .await
1420 .expect("export writes the bundle");
1421 assert!(bundle_path.exists());
1422
1423 let (dest_dir, dest_app, dest_sched) = mock_app_with_scheduler(Settings::default());
1425 dest_app.manage(crate::SupporterAppState {
1426 path: supporter_path_in(dest_dir.path()),
1427 client: reqwest::Client::new(),
1428 });
1429 let dest_webview = main_webview(&dest_app);
1430 import_backup_from_path(
1431 dest_webview,
1432 dest_app.state::<Scheduler>(),
1433 dest_app.state::<crate::SupporterAppState>(),
1434 bundle_path.to_string_lossy().to_string(),
1435 )
1436 .await
1437 .expect("import succeeds");
1438
1439 assert_eq!(dest_sched.profiles.lock().await.len(), 2);
1440 assert_eq!(dest_sched.active_profile_name.lock().await.as_str(), "Work",);
1441 assert_eq!(
1442 dest_sched.settings.lock().await.micro_interval_secs,
1443 source_settings.micro_interval_secs,
1444 );
1445 }
1446
1447 #[tokio::test]
1448 async fn apply_writes_all_optional_payloads() {
1449 let (dest_dir, app, dest_sched) = mock_app_with_scheduler(Settings::default());
1452 let pause_text = r#"{"paused":false,"until_epoch_secs":null}"#;
1453 let screen_text = r#"{"date":"2026-01-01","seconds":0}"#;
1454 let supporter_text = manual_supporter_text();
1455 let bundle = BackupBundle {
1456 manifest: BackupManifest {
1457 schema_version: BACKUP_SCHEMA_VERSION,
1458 created_at: "2026-01-01T00:00:00Z".to_string(),
1459 app: BUNDLE_APP_ID.to_string(),
1460 },
1461 files: BackupFiles {
1462 settings_json: serde_json::to_string(&ProfilesFile::single(
1463 "Default".to_string(),
1464 Settings::default(),
1465 ))
1466 .unwrap(),
1467 events_jsonl: String::new(),
1468 pause_json: Some(pause_text.to_string()),
1469 screen_time_json: Some(screen_text.to_string()),
1470 supporter_json: Some(supporter_text.clone()),
1471 },
1472 };
1473 apply_bundle_to_scheduler(
1474 &app.handle().clone(),
1475 &dest_sched,
1476 &supporter_path_in(dest_dir.path()),
1477 bundle,
1478 )
1479 .await
1480 .expect("apply succeeds");
1481 assert_eq!(
1482 fs::read_to_string(&dest_sched.pause_path).unwrap(),
1483 pause_text
1484 );
1485 assert_eq!(
1486 fs::read_to_string(&dest_sched.screen_time_path).unwrap(),
1487 screen_text,
1488 );
1489 assert_eq!(
1490 fs::read_to_string(supporter_path_in(dest_dir.path())).unwrap(),
1491 supporter_text,
1492 );
1493 }
1494
1495 #[tokio::test]
1496 async fn apply_surfaces_events_blocked_failure() {
1497 let (dest_dir, app, mut sched) = mock_app_with_scheduler(Settings::default());
1501 let blocked = dest_dir.path().join("events-blocked");
1502 fs::create_dir(&blocked).unwrap();
1503 sched.events_path = blocked;
1504 let bundle = BackupBundle {
1505 manifest: BackupManifest {
1506 schema_version: BACKUP_SCHEMA_VERSION,
1507 created_at: "2026-01-01T00:00:00Z".to_string(),
1508 app: BUNDLE_APP_ID.to_string(),
1509 },
1510 files: BackupFiles {
1511 settings_json: serde_json::to_string(&ProfilesFile::single(
1512 "Default".to_string(),
1513 Settings::default(),
1514 ))
1515 .unwrap(),
1516 events_jsonl: String::new(),
1517 pause_json: None,
1518 screen_time_json: None,
1519 supporter_json: None,
1520 },
1521 };
1522 let err = apply_bundle_to_scheduler(
1523 &app.handle().clone(),
1524 &sched,
1525 &supporter_path_in(dest_dir.path()),
1526 bundle,
1527 )
1528 .await
1529 .expect_err("events stage failure surfaces");
1530 assert!(
1531 err.contains("failed to stage")
1532 || err.contains("failed to commit")
1533 || err.contains("refusing to commit over directory"),
1534 "unexpected error: {err}",
1535 );
1536 let settings_tmp = stage_path_for(&sched.config_path).unwrap();
1540 assert!(
1541 !settings_tmp.exists(),
1542 "settings stage tmp must be cleaned up after rollback",
1543 );
1544 assert!(!sched.import_in_progress.load(Ordering::Relaxed));
1546 }
1547
1548 #[tokio::test]
1549 async fn apply_surfaces_config_blocked_failure() {
1550 let (dest_dir, app, mut sched) = mock_app_with_scheduler(Settings::default());
1556 let blocked = dest_dir.path().join("settings-blocked");
1557 fs::create_dir(&blocked).unwrap();
1558 sched.config_path = blocked;
1559 let bundle = BackupBundle {
1560 manifest: BackupManifest {
1561 schema_version: BACKUP_SCHEMA_VERSION,
1562 created_at: "2026-01-01T00:00:00Z".to_string(),
1563 app: BUNDLE_APP_ID.to_string(),
1564 },
1565 files: BackupFiles {
1566 settings_json: serde_json::to_string(&ProfilesFile::single(
1567 "Default".to_string(),
1568 Settings::default(),
1569 ))
1570 .unwrap(),
1571 events_jsonl: String::new(),
1572 pause_json: None,
1573 screen_time_json: None,
1574 supporter_json: None,
1575 },
1576 };
1577 let err = apply_bundle_to_scheduler(
1578 &app.handle().clone(),
1579 &sched,
1580 &supporter_path_in(dest_dir.path()),
1581 bundle,
1582 )
1583 .await
1584 .expect_err("config write failure surfaces");
1585 assert!(
1586 err.contains("failed to stage")
1587 || err.contains("failed to commit")
1588 || err.contains("failed to ensure parent dir")
1589 || err.contains("refusing to commit over directory"),
1590 "unexpected error: {err}",
1591 );
1592 assert!(!sched.import_in_progress.load(Ordering::Relaxed));
1593 }
1594
1595 #[tokio::test]
1596 async fn apply_rolls_back_partial_commit_failure() {
1597 let (dest_dir, app, dest_sched) = mock_app_with_scheduler(Settings::default());
1602 fs::write(&dest_sched.config_path, "orig-config").unwrap();
1603 fs::write(&dest_sched.events_path, "orig-events\n").unwrap();
1604 fs::write(&dest_sched.pause_path, "orig-pause").unwrap();
1605 fs::write(&dest_sched.screen_time_path, "orig-screen").unwrap();
1606
1607 let supporter_path = supporter_path_in(dest_dir.path());
1608 fs::create_dir(&supporter_path).unwrap();
1609
1610 let bundle = BackupBundle {
1611 manifest: BackupManifest {
1612 schema_version: BACKUP_SCHEMA_VERSION,
1613 created_at: "2026-01-01T00:00:00Z".to_string(),
1614 app: BUNDLE_APP_ID.to_string(),
1615 },
1616 files: BackupFiles {
1617 settings_json: serde_json::to_string(&ProfilesFile::single(
1618 "Default".to_string(),
1619 Settings::default(),
1620 ))
1621 .unwrap(),
1622 events_jsonl: String::new(),
1623 pause_json: Some(r#"{"paused":false,"until_epoch_secs":null}"#.to_string()),
1624 screen_time_json: Some(r#"{"date":"2026-01-01","seconds":0}"#.to_string()),
1625 supporter_json: Some(manual_supporter_text()),
1626 },
1627 };
1628 let err =
1629 apply_bundle_to_scheduler(&app.handle().clone(), &dest_sched, &supporter_path, bundle)
1630 .await
1631 .expect_err("supporter-as-dir aborts import");
1632 assert!(
1633 err.contains("refusing to commit over directory"),
1634 "unexpected error: {err}",
1635 );
1636
1637 assert_eq!(
1638 fs::read_to_string(&dest_sched.config_path).unwrap(),
1639 "orig-config",
1640 "settings restored",
1641 );
1642 assert_eq!(
1643 fs::read_to_string(&dest_sched.events_path).unwrap(),
1644 "orig-events\n",
1645 "events restored",
1646 );
1647 assert_eq!(
1648 fs::read_to_string(&dest_sched.pause_path).unwrap(),
1649 "orig-pause",
1650 "pause restored",
1651 );
1652 assert_eq!(
1653 fs::read_to_string(&dest_sched.screen_time_path).unwrap(),
1654 "orig-screen",
1655 "screen-time restored",
1656 );
1657
1658 for p in [
1659 &dest_sched.config_path,
1660 &dest_sched.events_path,
1661 &dest_sched.pause_path,
1662 &dest_sched.screen_time_path,
1663 ] {
1664 let bak = bak_path_for(p).unwrap();
1665 assert!(
1666 !bak.exists(),
1667 ".pre-import.bak left behind at {}",
1668 bak.display(),
1669 );
1670 let tmp = stage_path_for(p).unwrap();
1671 assert!(
1672 !tmp.exists(),
1673 ".import.tmp left behind at {}",
1674 tmp.display(),
1675 );
1676 }
1677
1678 assert_eq!(dest_sched.profiles.lock().await.len(), 1);
1681 assert!(!dest_sched.import_in_progress.load(Ordering::Relaxed));
1682 }
1683
1684 #[tokio::test]
1685 async fn apply_aborts_on_staging_failure() {
1686 let (dest_dir, app, mut dest_sched) = mock_app_with_scheduler(Settings::default());
1693 let blocker = dest_dir.path().join("not-a-dir");
1694 fs::write(&blocker, b"").unwrap();
1695 dest_sched.screen_time_path = blocker.join("under-blocker.json");
1696
1697 let bundle = BackupBundle {
1698 manifest: BackupManifest {
1699 schema_version: BACKUP_SCHEMA_VERSION,
1700 created_at: "2026-01-01T00:00:00Z".to_string(),
1701 app: BUNDLE_APP_ID.to_string(),
1702 },
1703 files: BackupFiles {
1704 settings_json: serde_json::to_string(&ProfilesFile::single(
1705 "Default".to_string(),
1706 Settings::default(),
1707 ))
1708 .unwrap(),
1709 events_jsonl: String::new(),
1710 pause_json: None,
1711 screen_time_json: Some(r#"{"date":"2026-01-01","seconds":0}"#.to_string()),
1715 supporter_json: None,
1716 },
1717 };
1718 let err = apply_bundle_to_scheduler(
1719 &app.handle().clone(),
1720 &dest_sched,
1721 &supporter_path_in(dest_dir.path()),
1722 bundle,
1723 )
1724 .await
1725 .expect_err("staging failure aborts import");
1726 assert!(
1727 err.contains("failed to ensure parent dir") || err.contains("failed to stage"),
1728 "unexpected error: {err}",
1729 );
1730
1731 for p in [
1735 &dest_sched.config_path,
1736 &dest_sched.events_path,
1737 &dest_sched.pause_path,
1738 ] {
1739 let tmp = stage_path_for(p).unwrap();
1740 assert!(
1741 !tmp.exists(),
1742 ".import.tmp left behind at {}",
1743 tmp.display(),
1744 );
1745 }
1746 assert!(!bak_path_for(&dest_sched.config_path).unwrap().exists());
1748 assert!(!dest_sched.import_in_progress.load(Ordering::Relaxed));
1749 }
1750
1751 #[tokio::test]
1752 async fn apply_sets_and_clears_import_in_progress_flag() {
1753 let (dest_dir, app, dest_sched) = mock_app_with_scheduler(Settings::default());
1756 assert!(!dest_sched.import_in_progress.load(Ordering::Relaxed));
1757 let bundle = BackupBundle {
1758 manifest: BackupManifest {
1759 schema_version: BACKUP_SCHEMA_VERSION,
1760 created_at: "2026-01-01T00:00:00Z".to_string(),
1761 app: BUNDLE_APP_ID.to_string(),
1762 },
1763 files: BackupFiles {
1764 settings_json: serde_json::to_string(&ProfilesFile::single(
1765 "Default".to_string(),
1766 Settings::default(),
1767 ))
1768 .unwrap(),
1769 events_jsonl: String::new(),
1770 pause_json: None,
1771 screen_time_json: None,
1772 supporter_json: None,
1773 },
1774 };
1775 apply_bundle_to_scheduler(
1776 &app.handle().clone(),
1777 &dest_sched,
1778 &supporter_path_in(dest_dir.path()),
1779 bundle,
1780 )
1781 .await
1782 .expect("apply succeeds");
1783 assert!(!dest_sched.import_in_progress.load(Ordering::Relaxed));
1784 }
1785
1786 #[tokio::test]
1787 async fn import_command_errors_on_missing_file() {
1788 let (dest_dir, dest_app, _dest_sched) = mock_app_with_scheduler(Settings::default());
1789 dest_app.manage(crate::SupporterAppState {
1790 path: supporter_path_in(dest_dir.path()),
1791 client: reqwest::Client::new(),
1792 });
1793 let bogus = dest_dir.path().join("nope.json");
1794 let err = import_backup_from_path(
1795 main_webview(&dest_app),
1796 dest_app.state::<Scheduler>(),
1797 dest_app.state::<crate::SupporterAppState>(),
1798 bogus.to_string_lossy().to_string(),
1799 )
1800 .await
1801 .expect_err("missing file is reported");
1802 assert!(err.contains("failed to read backup file"));
1803 }
1804
1805 #[tokio::test]
1806 async fn import_command_errors_on_garbage_payload() {
1807 let (dest_dir, dest_app, _dest_sched) = mock_app_with_scheduler(Settings::default());
1808 dest_app.manage(crate::SupporterAppState {
1809 path: supporter_path_in(dest_dir.path()),
1810 client: reqwest::Client::new(),
1811 });
1812 let garbage = dest_dir.path().join("garbage.json");
1813 fs::write(&garbage, "not a backup bundle").unwrap();
1814 let err = import_backup_from_path(
1815 main_webview(&dest_app),
1816 dest_app.state::<Scheduler>(),
1817 dest_app.state::<crate::SupporterAppState>(),
1818 garbage.to_string_lossy().to_string(),
1819 )
1820 .await
1821 .expect_err("malformed file is reported");
1822 assert!(err.contains("failed to parse backup file"));
1823 }
1824
1825 #[tokio::test]
1826 async fn import_command_errors_on_oversized_file() {
1827 let (dest_dir, dest_app, _dest_sched) = mock_app_with_scheduler(Settings::default());
1831 dest_app.manage(crate::SupporterAppState {
1832 path: supporter_path_in(dest_dir.path()),
1833 client: reqwest::Client::new(),
1834 });
1835 let huge = dest_dir.path().join("huge.json");
1836 fs::write(&huge, vec![b'x'; (MAX_BACKUP_BYTES as usize) + 1]).unwrap();
1837 let err = import_backup_from_path(
1838 main_webview(&dest_app),
1839 dest_app.state::<Scheduler>(),
1840 dest_app.state::<crate::SupporterAppState>(),
1841 huge.to_string_lossy().to_string(),
1842 )
1843 .await
1844 .expect_err("oversized file is reported");
1845 assert!(err.contains("exceeds the maximum allowed size"));
1846 }
1847
1848 #[tokio::test]
1849 async fn export_command_rejects_non_main_window() {
1850 let (dest_dir, dest_app, _) = mock_app_with_scheduler(Settings::default());
1854 dest_app.manage(crate::SupporterAppState {
1855 path: supporter_path_in(dest_dir.path()),
1856 client: reqwest::Client::new(),
1857 });
1858 let overlay = tauri::WebviewWindowBuilder::new(&dest_app, "overlay-0", Default::default())
1859 .build()
1860 .unwrap();
1861 let bundle_path = dest_dir.path().join("nope.json");
1862 let err = export_backup_to_path(
1863 overlay,
1864 dest_app.state::<Scheduler>(),
1865 dest_app.state::<crate::SupporterAppState>(),
1866 bundle_path.to_string_lossy().to_string(),
1867 )
1868 .await
1869 .expect_err("overlay window is refused");
1870 assert!(err.contains("restricted to the main window"));
1871 assert!(!bundle_path.exists(), "gate must refuse before any I/O");
1872 }
1873
1874 #[tokio::test]
1875 async fn import_command_rejects_non_main_window() {
1876 let (dest_dir, dest_app, dest_sched) = mock_app_with_scheduler(Settings::default());
1877 dest_app.manage(crate::SupporterAppState {
1878 path: supporter_path_in(dest_dir.path()),
1879 client: reqwest::Client::new(),
1880 });
1881 let overlay = tauri::WebviewWindowBuilder::new(&dest_app, "overlay-0", Default::default())
1882 .build()
1883 .unwrap();
1884 let bundle_path = dest_dir.path().join("bundle.json");
1887 let bundle = BackupBundle {
1888 manifest: BackupManifest {
1889 schema_version: BACKUP_SCHEMA_VERSION,
1890 created_at: "2026-01-01T00:00:00Z".to_string(),
1891 app: BUNDLE_APP_ID.to_string(),
1892 },
1893 files: BackupFiles {
1894 settings_json: serde_json::to_string(&ProfilesFile::single(
1895 "Default".to_string(),
1896 Settings::default(),
1897 ))
1898 .unwrap(),
1899 events_jsonl: String::new(),
1900 pause_json: None,
1901 screen_time_json: None,
1902 supporter_json: None,
1903 },
1904 };
1905 fs::write(&bundle_path, serde_json::to_string(&bundle).unwrap()).unwrap();
1906 let err = import_backup_from_path(
1907 overlay,
1908 dest_app.state::<Scheduler>(),
1909 dest_app.state::<crate::SupporterAppState>(),
1910 bundle_path.to_string_lossy().to_string(),
1911 )
1912 .await
1913 .expect_err("overlay window is refused");
1914 assert!(err.contains("restricted to the main window"));
1915 assert_eq!(dest_sched.profiles.lock().await.len(), 1);
1917 }
1918
1919 #[tokio::test]
1920 async fn export_strips_lemonsqueezy_supporter_record() {
1921 let bundle_dir = temp_dir();
1922 let bundle_path = bundle_dir.path().join("entracte-backup.json");
1923 let (src_dir, src_sched) = crate::test_support::test_scheduler(Settings::default());
1924 let src_supporter = supporter_path_in(src_dir.path());
1925 crate::supporter::save(
1926 &src_supporter,
1927 &SupporterRecord {
1928 license_key: "ls-key".to_string(),
1929 instance_id: "i".to_string(),
1930 activated_at: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1931 last_validated_at: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1932 source: SupporterSource::LemonSqueezy,
1933 signature: String::new(),
1934 },
1935 )
1936 .unwrap();
1937 let src_app = crate::test_support::wrap_in_mock_app(src_sched.clone());
1938 src_app.manage(crate::SupporterAppState {
1939 path: src_supporter,
1940 client: reqwest::Client::new(),
1941 });
1942
1943 export_backup_to_path(
1944 main_webview(&src_app),
1945 src_app.state::<Scheduler>(),
1946 src_app.state::<crate::SupporterAppState>(),
1947 bundle_path.to_string_lossy().to_string(),
1948 )
1949 .await
1950 .unwrap();
1951
1952 let bundle: BackupBundle =
1953 serde_json::from_str(&fs::read_to_string(&bundle_path).unwrap()).unwrap();
1954 assert!(
1955 bundle.files.supporter_json.is_none(),
1956 "LemonSqueezy record must not ride along in the bundle",
1957 );
1958 }
1959
1960 #[tokio::test]
1968 async fn backup_commands_round_trip_through_tauri_ipc() {
1969 use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY};
1970 use tauri::webview::InvokeRequest;
1971 use tauri::{ipc::CallbackFn, WebviewWindowBuilder};
1972
1973 let bundle_dir = temp_dir();
1974 let bundle_path = bundle_dir.path().join("entracte-backup.json");
1975
1976 let source_settings = Settings {
1978 micro_interval_secs: 1234,
1979 ..Settings::default()
1980 };
1981 let (src_dir, src_sched) = crate::test_support::test_scheduler_with_profiles(
1982 vec![
1983 Profile {
1984 name: DEFAULT_PROFILE_NAME.to_string(),
1985 settings: Settings::default(),
1986 },
1987 Profile {
1988 name: "Work".to_string(),
1989 settings: source_settings.clone(),
1990 },
1991 ],
1992 "Work",
1993 );
1994 crate::config::save(
1995 &src_sched.config_path,
1996 &src_sched.snapshot_profiles_file().await,
1997 )
1998 .unwrap();
1999
2000 let src_app = mock_builder()
2001 .invoke_handler(tauri::generate_handler![
2002 super::export_backup_to_path,
2003 super::import_backup_from_path,
2004 ])
2005 .build(mock_context(noop_assets()))
2006 .expect("mock app builds");
2007 src_app.manage(src_sched);
2008 src_app.manage(crate::SupporterAppState {
2009 path: supporter_path_in(src_dir.path()),
2010 client: reqwest::Client::new(),
2011 });
2012 let src_webview = WebviewWindowBuilder::new(&src_app, "main", Default::default())
2013 .build()
2014 .unwrap();
2015 let url = "tauri://localhost".parse().unwrap();
2019 get_ipc_response(
2020 &src_webview,
2021 InvokeRequest {
2022 cmd: "export_backup_to_path".into(),
2023 callback: CallbackFn(0),
2024 error: CallbackFn(1),
2025 url,
2026 body: serde_json::json!({ "path": bundle_path.to_string_lossy() }).into(),
2027 headers: Default::default(),
2028 invoke_key: INVOKE_KEY.to_string(),
2029 },
2030 )
2031 .expect("export command succeeds via IPC");
2032 assert!(bundle_path.exists(), "export wrote a bundle file");
2033
2034 let (dest_dir, dest_sched) = crate::test_support::test_scheduler(Settings::default());
2036 let dest_app = mock_builder()
2037 .invoke_handler(tauri::generate_handler![
2038 super::export_backup_to_path,
2039 super::import_backup_from_path,
2040 ])
2041 .build(mock_context(noop_assets()))
2042 .expect("mock app builds");
2043 dest_app.manage(dest_sched.clone());
2044 dest_app.manage(crate::SupporterAppState {
2045 path: supporter_path_in(dest_dir.path()),
2046 client: reqwest::Client::new(),
2047 });
2048 let dest_webview = WebviewWindowBuilder::new(&dest_app, "main", Default::default())
2049 .build()
2050 .unwrap();
2051 let url = "tauri://localhost".parse().unwrap();
2055 get_ipc_response(
2056 &dest_webview,
2057 InvokeRequest {
2058 cmd: "import_backup_from_path".into(),
2059 callback: CallbackFn(0),
2060 error: CallbackFn(1),
2061 url,
2062 body: serde_json::json!({ "path": bundle_path.to_string_lossy() }).into(),
2063 headers: Default::default(),
2064 invoke_key: INVOKE_KEY.to_string(),
2065 },
2066 )
2067 .expect("import command succeeds via IPC");
2068
2069 assert_eq!(dest_sched.profiles.lock().await.len(), 2);
2070 assert_eq!(dest_sched.active_profile_name.lock().await.as_str(), "Work",);
2071 assert_eq!(
2072 dest_sched.settings.lock().await.micro_interval_secs,
2073 source_settings.micro_interval_secs,
2074 );
2075 }
2076}