Skip to main content

entracte_lib/scheduler/commands/
backup.rs

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";
24/// Hard cap on the on-disk size of a bundle file we'll deserialize.
25/// Realistic worst case: ~300 B per logged event × ~50 events/day ×
26/// a decade ≈ 55 MB. 64 MiB gives a generous multiple of that while
27/// keeping the peak allocation (read into `String`, then parse) low
28/// enough not to stress a 4 GB tray-app footprint. Larger files
29/// short-circuit before parse so an accidentally-picked 10 GiB blob
30/// can't OOM the deserializer.
31const MAX_BACKUP_BYTES: u64 = 64 * 1024 * 1024;
32/// Only the settings window invokes backup IPC. Overlays never need
33/// it; gate at the command boundary so a future renderer bug that
34/// leaks the IPC handle to an overlay can't initiate a destructive
35/// import or exfiltrate state.
36const 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
60/// Filter what the export writes for the supporter file.
61///
62/// LemonSqueezy records are bound to an `instance_id` tied to the host
63/// they were activated on — restoring them on a different machine
64/// silently grants up to 30 days of offline grace before the
65/// background revalidator deactivates them server-side. To avoid
66/// users sharing licence files across devices and getting cut off
67/// later, strip LemonSqueezy records on export and only carry the
68/// manual (Ed25519) source through, which verifies locally with no
69/// machine binding.
70///
71/// Routes the read through `supporter::load()` so a tampered or
72/// unsigned on-disk record (signature mismatch, oversized file) is
73/// dropped at the source — otherwise we'd carry a forged record into
74/// the bundle that `load()` would then reject on import, silently
75/// losing the user's supporter status.
76fn 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    // Re-serialize the verified record rather than re-reading the file:
83    // `supporter::load` has already done the disk read + signature
84    // check, so a second `fs::read_to_string` would only ever differ
85    // on a TOCTOU race (file removed between our two reads). Going
86    // through the same `serde_json::to_string_pretty` codepath
87    // `supporter::save` uses keeps the on-the-wire bytes identical.
88    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
99/// Events are checked *leniently*: a line we can't parse — e.g. an event
100/// `type` written by a newer Entracte — must not brick the whole import.
101/// The events file is written verbatim and the stats reader
102/// ([`crate::stats::read_all`]) already drops lines it can't parse, so
103/// unknown events are preserved on disk and simply ignored when computing
104/// stats. We only count and warn so the drop isn't silent — matching the
105/// runtime reader's tolerance rather than the old all-or-nothing reject.
106fn 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/// Per-file action staged for the commit phase. Writes land in
169/// `.<name>.import.tmp` alongside the final path so the rename is
170/// across a single directory entry (atomic on every filesystem we
171/// support). Removes have no temp — they're just deferred unlinks.
172#[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
195/// Sibling path where the existing target is parked for the duration
196/// of the commit. If a later stage's commit fails we rename this back
197/// into place; if every stage succeeds we delete it during finalize.
198fn 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/// Result of committing one staged action. Carries the path of the
258/// `.pre-import.bak` we parked the previous target at (if any) so we
259/// can either roll it back on a later failure or unlink it on
260/// finalize.
261#[derive(Debug)]
262struct CommittedStage {
263    final_path: PathBuf,
264    backup_path: Option<PathBuf>,
265    /// True for `Write` actions (we placed bytes at `final_path`),
266    /// false for `Remove` (target was moved aside, nothing replaced
267    /// it). Rollback only needs to unlink the new file for writes.
268    placed_new_content: bool,
269}
270
271/// Apply one staged action, parking the existing target at
272/// `.pre-import.bak` first so a later commit failure can be rolled
273/// back. A pre-existing `.bak` (residue from a previously-failed
274/// import) is unlinked first so the parking rename succeeds on
275/// Windows, which doesn't overwrite a present destination.
276fn 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                // Restore this single stage's backup before bubbling
314                // up so the caller-level rollback sees a clean prior
315                // state for the failing path.
316                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            // The rename-aside above already removed the target from
333            // its final path; nothing else to do.
334            Ok(CommittedStage {
335                final_path: stage.final_path.clone(),
336                backup_path,
337                placed_new_content: false,
338            })
339        }
340    }
341}
342
343/// Reverse-restore every committed stage from its `.pre-import.bak`.
344/// Best-effort: each step swallows errors because a) we're already in
345/// a failure path and b) a failed individual rollback shouldn't
346/// abort the rest. Stale `.bak` files left by a catastrophic rollback
347/// failure are picked up by the next import's `commit_stage` (it
348/// unlinks the stale `.bak` before parking).
349fn 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
360/// Happy-path cleanup after every stage committed successfully.
361/// Unlinks the `.pre-import.bak` files we parked during commit so
362/// they don't linger as confusing sibling files.
363fn 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
371/// RAII guard that flips `Scheduler::import_in_progress` on construction
372/// and restores it on drop, including on panic. The run loop checks the
373/// flag once per tick and short-circuits while it's set.
374struct 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
396/// Single-line breadcrumb the import flow drops into the log file
397/// on success. Pulled out of the `log::info!` call so the format
398/// arguments are exercised by a unit test even when no logger is
399/// installed in the test binary (the `log` crate short-circuits
400/// argument evaluation when `log_enabled!(Info)` is false).
401fn 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    // Stage every write up front so any I/O error (out of space, perms,
423    // filesystem readonly) fails before we mutate the live state. Once
424    // every `.import.tmp` is on disk and synced, the commit phase is a
425    // tight loop of single-directory renames — as close to atomic as we
426    // can get without filesystem transactions.
427    // Pair every target path with the bytes that should land at it
428    // (or `None` to mean "remove the existing file") so the staging
429    // loop has exactly one `?` exit point. Each tuple becomes one
430    // `.<name>.import.tmp` and, on commit, one `.pre-import.bak`.
431    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    // The Logger thread opens `events_path` fresh for each append.
463    // Hold its `write_lock` across every rename so an in-flight append
464    // can't land on the old inode (which we're about to unlink) and
465    // disappear with it. Mirrors `stats::clear_log`'s coordination.
466    //
467    // Each `commit_stage` parks the existing target at a sibling
468    // `.pre-import.bak` before renaming the new contents into place.
469    // If any stage fails mid-loop we reverse-iterate over the
470    // committed stages and rename the backups back, leaving the
471    // tree as if no commit had happened. Without this, a 4th-of-5
472    // commit failure would leave 3 files at their new contents and
473    // 2 at their old — a state no version of the app ever produces.
474    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        // `active_settings` rebuilds the `#[serde(skip)]` `derived` cache
506        // from the imported (deserialised) profile fields.
507        *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    // Reseed the break-timer cursors against the restored settings.
524    // The 1Hz run loop reads `timers` every tick; without this reset
525    // it would compare the restored `Settings::micro_interval_secs`
526    // against pre-import `last_micro`, potentially firing a break
527    // immediately. `set_active_profile` does the same when switching.
528    {
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    // Import replaces the events log rather than clearing it, so a
538    // separate event name keeps the distinction available to any
539    // future listener. The renderer already calls `refreshDigest`
540    // directly after `import_backup_from_path` resolves, so today
541    // this is purely informational.
542    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        // We're at v1 today, so version 0 stands in for "an older
676        // bundle." Exists so a v2 bump that flips `>` back to `!=`
677        // breaks this immediately.
678        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        // A line we can't parse — here an event `type` from a hypothetical
723        // newer Entracte — must not brick the import. It's preserved on disk
724        // and dropped by the stats reader, so the bundle still validates.
725        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        // Reading a directory as if it were a file returns IsADirectory
773        // (or similar non-NotFound kind), which exercises the error
774        // mapping branch.
775        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        // `read_capped` returns InvalidData when the file exceeds the
784        // cap; the wrapper folds that into the same "failed to read"
785        // error shape so import surfaces it consistently.
786        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        // `supporter::save` signs the record; `exportable_supporter`
796        // must verify and pass the on-disk text through verbatim.
797        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        // Hand-edited supporter file (signature no longer matches) must
818        // not be carried into the bundle — `supporter::load` rejects it.
819        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        // Before commit, the target still holds the old contents and
872        // the staged temp exists alongside it.
873        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        // The previous target is parked at .pre-import.bak until
880        // finalize. Verify, then run finalize and verify the bak
881        // is cleaned up.
882        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        // Remove parks the target at .pre-import.bak so rollback can
897        // restore it. Finalize then unlinks the bak.
898        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        // Park a regular file where the parent directory should be —
920        // `create_dir_all` returns AlreadyExists/NotADirectory and the
921        // stage call surfaces that as a typed error.
922        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        // A directory squatting at the final path is a weird state
933        // (no version of the app produces it); refusing keeps the
934        // commit phase deterministic and gives the rollback flow a
935        // single, well-named injection point for tests.
936        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        // A `.pre-import.bak` left over from a previously crashed
950        // rollback would otherwise make Windows's non-overwriting
951        // rename fail. The commit path unlinks it first.
952        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        // Build a `StagedFile` whose tmp path doesn't exist on disk.
1039        // `commit_stage` passes the metadata check, succeeds at the
1040        // bak rename (so the original is now parked aside), then
1041        // FAILS at rename(tmp → final) because tmp is missing.
1042        // The self-rollback branch must rename the bak back into
1043        // place so the caller sees an untouched final_path.
1044        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        // chmod the parent directory to 0o000 so `symlink_metadata`
1069        // on a child returns PermissionDenied (not NotFound). The
1070        // catch-all `Err(e)` arm in `commit_stage` must surface that
1071        // rather than treat it like "target absent".
1072        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        // Restore perms BEFORE asserting so TempDir cleanup works
1086        // even on assertion failure.
1087        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        // A `.pre-import.bak` that is a directory blocks the bak
1095        // rename: our `let _ = fs::remove_file(bak)` silently no-ops
1096        // on a dir, and `fs::rename(file, dir)` returns an error.
1097        // The function must surface that as "failed to back up …"
1098        // rather than letting it slip past unmapped.
1099        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        // Final path untouched; the new bytes never reached it.
1108        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// =====================================================================
1137// Integration-test rig: drives the `#[tauri::command]`-wrapped paths
1138// end-to-end via `mock_app_with_scheduler`. Proves that a bundle
1139// produced from one scheduler restores into another with the right
1140// in-memory + on-disk state and that the renderer-facing events fire.
1141// =====================================================================
1142#[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    /// Build a `WebviewWindow` with the production "main" label so the
1198    /// gate check in `export_backup_to_path` / `import_backup_from_path`
1199    /// passes. Tests that exercise the gate-reject path build a webview
1200    /// with a different label explicitly.
1201    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        // Stash a stale `last_micro` so we can prove it was reset.
1299        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    /// Real end-to-end: write a bundle via `export_backup_to_path`,
1375    /// blow away in-memory state, then `import_backup_from_path` and
1376    /// observe state come back.
1377    #[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        // Source app: two profiles, "Work" active. Persist so the
1383        // export command reads the same state we just set up.
1384        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        // Destination app: single Default profile. Run import.
1424        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        // Round-trip with every optional payload populated so the
1450        // `write_optional` calls inside apply each take the Some branch.
1451        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        // events_path is blocked by a directory squatting on it.
1498        // `commit_stage` refuses to commit over a directory, so the
1499        // import aborts and rolls back any earlier staged content.
1500        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        // Stage failure must roll back: settings_path (which staged
1537        // successfully) gets cleaned up rather than left as a dangling
1538        // .import.tmp next to the real settings.
1539        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        // And the import_in_progress flag is cleared on the way out.
1545        assert!(!sched.import_in_progress.load(Ordering::Relaxed));
1546    }
1547
1548    #[tokio::test]
1549    async fn apply_surfaces_config_blocked_failure() {
1550        // A directory at `config_path` either fails the stage write
1551        // (parent-of-target check) or the commit's
1552        // refusing-to-overwrite-directory guard, depending on how the
1553        // path resolves; either shape is acceptable so long as the
1554        // import aborts cleanly.
1555        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        // Drive a real mid-commit failure: stages 1-4 commit, stage 5
1598        // (supporter) hits a directory at the final path and refuses.
1599        // The rollback must restore every earlier target to its
1600        // pre-import contents and clean up every `.pre-import.bak`.
1601        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        // In-memory state is untouched too — the import never
1679        // reached the lock-and-replace phase.
1680        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        // Park a regular file where `screen_time_path`'s *parent dir*
1687        // should be. `stage_write` calls `create_dir_all` on the
1688        // parent and surfaces NotADirectory as a stage-time error,
1689        // which exercises the staging closure's `?` early-return and
1690        // the `discard_all` cleanup of the (config + events + pause)
1691        // tmps that staged successfully before screen-time blew up.
1692        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                // `Some(_)` so the screen-time stage takes the Write
1712                // branch and calls into create_dir_all — `None` would
1713                // route through Remove and never touch the parent.
1714                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        // The earlier-staged config/events/pause tmps must be cleaned
1732        // up so a subsequent retry isn't blocked on Windows by stale
1733        // `.import.tmp` siblings.
1734        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        // Original targets untouched (no commit phase ran).
1747        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        // Happy-path application must leave the flag false after a
1754        // successful import — proving the RAII guard ran its Drop.
1755        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        // Files past `MAX_BACKUP_BYTES` short-circuit before parse so a
1828        // 10 GiB blob can't OOM the deserializer. The fixture writes
1829        // a small file but uses a tiny test cap to exercise the branch.
1830        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        // A webview with any label other than "main" hits the gate and
1851        // gets refused before touching disk. Overlay windows in
1852        // production carry labels like `overlay-0`.
1853        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        // Build a syntactically-valid bundle so the gate isn't preempted
1885        // by a "missing file" error.
1886        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        // And the scheduler is untouched — no profile change happened.
1916        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    /// Drives `export_backup_to_path` + `import_backup_from_path`
1961    /// through the real Tauri IPC pipeline (not the bare function
1962    /// calls). Builds the `#[tauri::command]` wrappers exactly the way
1963    /// the renderer does — `mock_builder().invoke_handler(generate_handler![...])`
1964    /// + `get_ipc_response` — so the macro-generated dispatchers
1965    /// (`__cmd__<name>`) actually run, covering the decorator lines
1966    /// that direct function calls leave untouched.
1967    #[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        // Source app: two profiles, "Work" active.
1977        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        // The rig test mod is gated to non-Windows already, and the
2016        // mock runtime doesn't enforce origin checks, so the macOS/Linux
2017        // scheme is fine on every platform we compile this test on.
2018        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        // Destination app: single Default profile. Import via IPC.
2035        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        // The rig test mod is gated to non-Windows already, and the
2052        // mock runtime doesn't enforce origin checks, so the macOS/Linux
2053        // scheme is fine on every platform we compile this test on.
2054        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}