diff --git a/README.md b/README.md index 36bfc87..1959731 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ ## To-Do - Show error in pop-up when an install fails - Checkboxes for additional packages (themes, ports, games) -- Backup and restore functionality --- @@ -62,6 +61,7 @@ - ✓ Extract archives (.7z, .zip) or burn raw images (.img, .img.gz) - ✓ Cross-platform: Windows, Linux, macOS - ✓ Update mode: preserve saves/ROMs while updating system files +- ✓ **Preserve user data**: backup/restore emulator configs, RetroArch settings, SSH keys during updates - ✓ Multi-repository support with asset filtering - ✓ **Boxart scraper**: Download cover art for ROMs from Libretro database @@ -304,7 +304,8 @@ pub const REPO_OPTIONS: &[RepoOption] = &[ info: "Stable releases of SpruceOS.\nSupported devices: Miyoo A30", // ← Info text (use \n for line breaks) display_name: Some("SpruceOS Stable"), // ← OPTIONAL: Full name for success/error popups (falls back to name if None) supports_update_mode: true, // ← Show update mode checkbox (true for archives, false for raw images) - update_directories: &["Retroarch", "spruce"], // ← Folders deleted during updates + supports_preserve_mode: true, // ← Enable preserve/merge of user data during updates + update_directories: &["Retroarch", "spruce"], // ← Paths deleted during updates (can be selective) allowed_extensions: Some(&[".7z"]), // ← File types to show (None = all) asset_display_mappings: None, // ← User-friendly names (see advanced below) }, @@ -320,7 +321,8 @@ pub const REPO_OPTIONS: &[RepoOption] = &[ name: "Stable", url: "yourorg/yourrepo", // ← Your GitHub username/repo info: "Official stable builds.\nSupported: Device X, Y, Z", - supports_update_mode: true, // Archives support updates + supports_update_mode: true, // Archives support updates + supports_preserve_mode: false, // ← Set false unless you need spruce-specific preserve/merge logic update_directories: &["System", "Apps"], // What gets replaced during updates allowed_extensions: None, // Show all file types asset_display_mappings: None, @@ -329,7 +331,8 @@ pub const REPO_OPTIONS: &[RepoOption] = &[ name: "Beta", url: "yourorg/yourrepo-beta", info: "Beta builds - may be unstable!\nTesting new features.", - supports_update_mode: true, // Archives support updates + supports_update_mode: true, // Archives support updates + supports_preserve_mode: false, // ← Set false for non-spruce repos update_directories: &["System"], allowed_extensions: Some(&[".7z", ".zip"]), // Only show archives asset_display_mappings: None, @@ -339,6 +342,7 @@ pub const REPO_OPTIONS: &[RepoOption] = &[ url: "yourorg/yourrepo-images", info: "Full disk images for fresh installs only.", supports_update_mode: false, // Raw images (.img.gz) don't support updates + supports_preserve_mode: false, update_directories: &[], // Not used for raw images allowed_extensions: Some(&[".img.gz", ".img"]), // Only raw images asset_display_mappings: None, @@ -441,19 +445,29 @@ supports_update_mode: false, // Hide checkbox - for raw disk images (.img.gz, . ##### **G. Advanced: Update Mode Directories** -When update mode is enabled (archives only), these directories get deleted before extraction: +When update mode is enabled (archives only), the paths listed in `update_directories` get deleted before extraction. These can be top-level directories **or** selective subdirectory/file paths: ```rust -update_directories: &["Retroarch", "spruce", "System"], // These get deleted -// Everything else (Roms/, Saves/, etc.) is preserved! +// Simple: delete entire top-level directories +update_directories: &["Retroarch", "spruce", "System"], + +// Selective: delete specific subdirectories/files within parents +// (preserves user-added content in the parent directory) +update_directories: &["App/SystemApp1", "App/SystemApp2", "Emu/NES", "Emu/SNES"], ``` +The SpruceOS repos use a shared constant `SPRUCE_UPDATE_DELETE_PATHS` that lists ~113 selective paths +mirroring the on-device updater's behavior. **Other CFW teams** should define their own list of +directories/files to delete, or use simple top-level directory names. + **How it works:** 1. User checks "Update Mode" checkbox (only visible when `supports_update_mode: true`) 2. Installer mounts existing SD card (no format!) -3. Only deletes the specified directories -4. Extracts new files -5. User's saves/ROMs stay intact +3. If "Preserve user data" is enabled: backs up user configs to local temp directory +4. Deletes the specified paths (directories are removed recursively, files are removed individually) +5. Extracts and copies new files +6. If "Preserve user data" is enabled: restores backed-up configs to SD card +7. User's saves/ROMs stay intact @@ -986,6 +1000,155 @@ Arcade systems use MAME-style ROM naming where the ROM filename is a short code --- +#### **STEP 12: Preserve User Data Configuration** (Optional - For Update Mode) + +When update mode is enabled, the installer can back up and restore user-specific files across updates. This is **entirely controlled by the `supports_preserve_mode` flag** on each repository — set it to `false` and none of the preserve/merge logic runs. + +
+Click to expand preserve mode configuration guide + +##### **A. For Other CFW/OS Teams (Non-Spruce)** + +**The simplest approach: set `supports_preserve_mode: false` on all your repos.** This disables the entire preserve system — no backup, no restore, no config merging. Your update mode will simply delete `update_directories` and extract the new release, which is all most projects need. + +```rust +RepoOption { + name: "Stable", + supports_update_mode: true, + supports_preserve_mode: false, // ← No preserve logic runs. Simple delete + extract. + update_directories: &["System", "Apps"], + // ... +}, +``` + +When `supports_preserve_mode` is `false`: +- The "Preserve user data" checkbox is hidden from the UI +- No backup or restore operations occur during updates +- The spruce-specific config merge code never executes +- The spruce-specific `SPRUCE_UPDATE_DELETE_PATHS` constant is not used (you define your own `update_directories`) +- `UPDATE_PRESERVE_PATHS` is ignored +- `src/preserve.rs` is never called + +**In short: with `supports_preserve_mode: false`, the preserve system is completely inert.** The installer behaves as a straightforward delete-and-extract updater with no spruce-specific behavior. + +##### **B. SpruceOS-Specific Preserve System** + +The following sections describe the preserve system as configured for SpruceOS. This is only relevant if you set `supports_preserve_mode: true` and want to use or adapt the spruce-specific logic. + +##### **C. Per-Repository Control** + +The `supports_preserve_mode` field on each `RepoOption` controls whether the "Preserve user data" checkbox appears when update mode is active: + +```rust +RepoOption { + name: "Stable", + supports_update_mode: true, + supports_preserve_mode: true, // Show preserve checkbox in update mode + // ... +}, +RepoOption { + name: "TwigUI", + supports_update_mode: false, + supports_preserve_mode: false, // No preserve for raw image repos + // ... +}, +``` + +##### **D. Static Preserve Paths** + +**Location:** `src/config.rs` → `UPDATE_PRESERVE_PATHS` + +This constant lists paths (relative to SD card root) that get backed up before deletion and blindly restored after installation, overwriting new defaults with the user's existing files: + +```rust +pub const UPDATE_PRESERVE_PATHS: &[&str] = &[ + // Emulator configs + "Emu/PICO8/.lexaloffle", + "Emu/DC/config", + "Emu/NDS/backup", + "Emu/NDS/config/drastic-A30.cfg", + // RetroArch configs (overlays/shaders/cheats not needed — RetroArch/ is no longer deleted) + "RetroArch/.retroarch/config", + // Network services + "spruce/bin/Syncthing/config", + "spruce/etc/ssh/keys", + // Add your custom paths here... +]; +``` + +- Each entry is a path relative to the SD card root +- Can be a file or directory (directories are backed up recursively) +- Paths that don't exist on the SD card are silently skipped + +##### **E. Dynamic Config Merge (SpruceOS-Specific)** + +In addition to the static backup/restore above, the installer performs **smart config merging** for spruce-specific JSON config files. This mirrors the on-device updater's `merge_configs.py` behavior: + +- **Emu configs** (`Emu/*/config.json`): Dynamically discovered at backup time. On restore, the user's `"selected"` values are merged into the new release's config — but only if the selected value still exists in the new config's `"options"` array. This prevents broken references to removed options. +- **Spruce system config** (`Saves/spruce/spruce-config.json`): Same smart merge logic. +- **Theme configs** (`Themes/*/config*.json`): Dynamically discovered and blindly restored (plain copy, no merge). + +This logic lives in `src/preserve.rs` (`backup_dynamic_configs()` and `restore_and_merge_configs()`). It only runs when `supports_preserve_mode: true`. + +##### **F. Selective Deletion (SpruceOS-Specific)** + +The SpruceOS repos use `SPRUCE_UPDATE_DELETE_PATHS` — a shared constant listing ~113 selective paths that mirror the on-device updater's `delete_files.sh`. Instead of deleting entire top-level directories (e.g., all of `App/`), it deletes specific subdirectories and files within each parent, preserving: + +| Preserved Item | Why | +|------|------| +| Custom apps in `App/` | User-installed apps like PortMaster, BootLogo | +| Custom `Emu/` folders | User-created emulator folders with custom names | +| `RetroArch/` (entire dir) | User-added overlays, shaders, cheats | +| `spruce/bin`, `spruce/bin64` | PyUI binaries and platform-specific files | + +**Other CFW teams** don't use this constant — they define their own `update_directories` list (see STEP 1, Section G). + +##### **G. How It Works** + +**Update mode with preserve ON (default for spruce repos):** +1. Mount SD card +2. **Static backup**: Copy all `UPDATE_PRESERVE_PATHS` from SD to local temp directory +3. **Dynamic backup**: Scan and copy emu/theme/spruce configs from SD to temp (spruce-specific) +4. **Delete**: Remove `update_directories` paths from SD card +5. **Install**: Extract and copy new release files to SD +6. **Smart merge**: Merge backed-up emu/spruce configs into new release configs (spruce-specific) +7. **Static restore**: Copy remaining backed-up files from temp to SD (overwriting new defaults) +8. Clean up temp backup directory + +**Update mode with preserve OFF (hard reset):** +1. Mount SD card +2. **Delete**: Remove `update_directories` paths from SD card +3. **Install**: Extract and copy new release files to SD +4. *(No backup/restore — user gets fresh default configs)* + +In both cases, Roms, BIOS, and Saves directories are **not** in `update_directories`, so they are always kept. + +##### **H. UI Behavior** + +- The "Preserve user data" checkbox only appears when: + - Update mode is checked + - The selected repository has `supports_preserve_mode: true` +- The checkbox defaults to ON (checked) +- When the user clicks through to the Update Preview modal: + - **Preserve ON**: Shows list of preserved data categories + - **Preserve OFF**: Shows a warning that user configs will be lost +- The checkbox resets to ON after installation completes, errors, or is cancelled + +##### **I. Implementation Files** + +| File | What it does | +|------|-------------| +| `src/config.rs` | `UPDATE_PRESERVE_PATHS`, `SPRUCE_UPDATE_DELETE_PATHS`, `supports_preserve_mode` field | +| `src/preserve.rs` | Static backup/restore + dynamic config merge (spruce-specific smart merge logic) | +| `src/delete.rs` | Deletes directories and files listed in `update_directories` | +| `src/app/state.rs` | `BackingUp`/`Restoring` app states, `preserve_data` field | +| `src/app/logic.rs` | Wires backup before delete, restore after copy | +| `src/app/ui.rs` | Checkbox UI, preview modal, state wiring | + +
+ +--- + ### 🧪 Testing Your Rebrand #### **Local Build Test:** @@ -1011,6 +1174,7 @@ cargo build --release --features icon - [ ] Repository dropdown shows your repos - [ ] Colors match your brand - [ ] Update Mode: If enabled, checkbox lists correct directories; if disabled, checkbox is hidden +- [ ] Preserve Mode: "Preserve user data" checkbox appears when update mode is checked (if repo supports it) - [ ] Download works from your GitHub repo - [ ] SD card gets labeled with your `VOLUME_LABEL` - [ ] macOS: Terminal has Full Disk Access granted (if testing on macOS) @@ -1059,6 +1223,7 @@ cargo build --release --features icon 8. ⬜ `assets/Fonts/nunwen.ttf` - Custom font 9. ⬜ `.github/workflows/*.yml` - Artifact names 10. ⬜ `.vscode/launch.json` - Debug config (if using VS Code) +11. ⬜ `src/config.rs` `UPDATE_PRESERVE_PATHS` - Customize backup paths for update mode --- @@ -1105,6 +1270,7 @@ src/ ├── burn.rs - Raw image burning (.img/.gz) with sector alignment ├── copy.rs - File copying with progress tracking ├── delete.rs - Selective directory deletion (update mode) +├── preserve.rs - Backup/restore user data during updates ├── eject.rs - Safe drive ejection ├── github.rs - GitHub API integration ├── fat32.rs - Custom FAT32 formatter (Windows >32GB) diff --git a/src/app/logic.rs b/src/app/logic.rs index b52738d..da2eeaa 100644 --- a/src/app/logic.rs +++ b/src/app/logic.rs @@ -16,7 +16,7 @@ // ============================================================================ use super::{InstallerApp, AppState, get_available_disk_space}; -use crate::config::{REPO_OPTIONS, TEMP_PREFIX, VOLUME_LABEL}; +use crate::config::{REPO_OPTIONS, TEMP_PREFIX, VOLUME_LABEL, UPDATE_PRESERVE_PATHS}; use crate::burn::{burn_image, BurnProgress}; use crate::copy::{copy_directory_with_progress, CopyProgress}; use crate::delete::{delete_directories, DeleteProgress}; @@ -24,6 +24,7 @@ use crate::drives::DriveInfo; use crate::extract::{extract_7z_with_progress, ExtractProgress}; use crate::format::{format_drive_fat32, FormatProgress}; use crate::github::{download_asset, get_latest_release, DownloadProgress, Asset}; +use crate::preserve::{backup_preserve_paths, restore_preserve_paths, backup_dynamic_configs, restore_and_merge_configs, PreserveProgress}; use crate::boxart_scraper::{BoxArtScraper, ScrapeProgress}; use eframe::egui; use std::path::PathBuf; @@ -260,6 +261,7 @@ impl InstallerApp { let ctx_clone = ctx.clone(); let volume_label = VOLUME_LABEL.to_string(); let update_mode = self.update_mode; + let preserve_data = self.preserve_data && self.update_mode; let update_directories: Vec = repo.update_directories.iter().map(|s| s.to_string()).collect(); // Create cancellation and pause tokens @@ -587,7 +589,8 @@ impl InstallerApp { crate::debug::log("Format complete"); } // End of format block for archive mode - // Step 3.5: Get mount path and delete directories for update mode (only for archive mode) + // Step 3.5: Get mount path, backup user data, and delete directories for update mode (only for archive mode) + let temp_backup_dir = temp_dir.join(format!("{}_backup", TEMP_PREFIX)); let dest_path_from_update = if !is_raw_image && update_mode { // First, get mount path for the existing installation crate::debug::log("Update mode: Getting existing mount path..."); @@ -602,6 +605,108 @@ impl InstallerApp { } }; + // Backup user data before deletion (if preserve_data is enabled) + if preserve_data { + let _ = state_tx_clone.send(AppState::BackingUp); + log("Backing up user data..."); + set_progress(0, 100, "Backing up user data..."); + + // Clean up any previous backup + let _ = tokio::fs::remove_dir_all(&temp_backup_dir).await; + + let (bak_tx, mut bak_rx) = mpsc::unbounded_channel::(); + let progress_bak = progress.clone(); + let ctx_bak = ctx_clone.clone(); + + let bak_handle = tokio::spawn(async move { + while let Some(prog) = bak_rx.recv().await { + if let Ok(mut p) = progress_bak.lock() { + match prog { + PreserveProgress::Started { total_paths } => { + p.current = 0; + p.total = total_paths as u64; + p.message = format!("Backing up {} paths...", total_paths); + } + PreserveProgress::BackingUp { ref path } => { + p.message = format!("Backing up: {}", path); + } + PreserveProgress::Restoring { .. } => {} + PreserveProgress::Completed => { + p.current = p.total; + p.message = "Backup complete".to_string(); + } + PreserveProgress::Cancelled => { + p.message = "Backup cancelled".to_string(); + } + PreserveProgress::Error(ref e) => { + p.message = format!("Backup error: {}", e); + } + } + } + ctx_bak.request_repaint(); + } + }); + + if let Err(e) = backup_preserve_paths(&mount_path, UPDATE_PRESERVE_PATHS, &temp_backup_dir, bak_tx, cancel_token_clone.clone()).await { + if e.contains("cancelled") { + log("Backup cancelled"); + let _ = tokio::fs::remove_dir_all(&temp_backup_dir).await; + let _ = state_tx_clone.send(AppState::Idle); + let _ = drive_poll_tx_clone.send(true); + return; + } + log(&format!("Backup error: {}", e)); + let _ = tokio::fs::remove_dir_all(&temp_backup_dir).await; + let _ = state_tx_clone.send(AppState::Error); + let _ = drive_poll_tx_clone.send(true); + return; + } + + let _ = bak_handle.await; + log("User data backup complete"); + crate::debug::log("User data backup complete"); + + // Backup dynamic configs (emu settings, spruce config, theme configs) + log("Backing up dynamic configs..."); + set_progress(0, 100, "Backing up dynamic configs..."); + + let (dbak_tx, mut dbak_rx) = mpsc::unbounded_channel::(); + let progress_dbak = progress.clone(); + let ctx_dbak = ctx_clone.clone(); + + let dbak_handle = tokio::spawn(async move { + while let Some(prog) = dbak_rx.recv().await { + if let Ok(mut p) = progress_dbak.lock() { + match &prog { + PreserveProgress::BackingUp { path } => { + p.message = format!("Backing up: {}", path); + } + PreserveProgress::Cancelled => { + p.message = "Backup cancelled".to_string(); + } + _ => {} + } + } + ctx_dbak.request_repaint(); + } + }); + + if let Err(e) = backup_dynamic_configs(&mount_path, &temp_backup_dir, dbak_tx, cancel_token_clone.clone()).await { + if e.contains("cancelled") { + log("Backup cancelled"); + let _ = tokio::fs::remove_dir_all(&temp_backup_dir).await; + let _ = state_tx_clone.send(AppState::Idle); + let _ = drive_poll_tx_clone.send(true); + return; + } + // Non-fatal - static backup already succeeded + log(&format!("Warning: Dynamic config backup error: {}", e)); + crate::debug::log(&format!("WARNING: Dynamic config backup error: {}", e)); + } + let _ = dbak_handle.await; + crate::debug::log("Dynamic config backup phase complete"); + } + // Delete old directories let _ = state_tx_clone.send(AppState::Deleting); log("Deleting old directories..."); @@ -1003,6 +1108,83 @@ impl InstallerApp { write_card_log("Cleaned up temp download file"); crate::debug::log("Cleaned up temp download file"); + // Step 6: Restore preserved user data (if update mode with preserve_data) + if update_mode && preserve_data && temp_backup_dir.exists() { + let _ = state_tx_clone.send(AppState::Restoring); + log("Restoring user data..."); + set_progress(0, 100, "Restoring user data..."); + + let dest_for_restore = dest_path.as_ref().expect("dest_path should be Some in archive mode").clone(); + + let (rst_tx, mut rst_rx) = mpsc::unbounded_channel::(); + let progress_rst = progress.clone(); + let ctx_rst = ctx_clone.clone(); + + let rst_handle = tokio::spawn(async move { + while let Some(prog) = rst_rx.recv().await { + if let Ok(mut p) = progress_rst.lock() { + match prog { + PreserveProgress::Started { total_paths } => { + p.current = 0; + p.total = total_paths as u64; + p.message = format!("Restoring {} files...", total_paths); + } + PreserveProgress::BackingUp { .. } => {} + PreserveProgress::Restoring { ref path } => { + p.message = format!("Restoring: {}", path); + } + PreserveProgress::Completed => { + p.current = p.total; + p.message = "Restore complete".to_string(); + } + PreserveProgress::Cancelled => { + p.message = "Restore cancelled".to_string(); + } + PreserveProgress::Error(ref e) => { + p.message = format!("Restore error: {}", e); + } + } + } + ctx_rst.request_repaint(); + } + }); + + // Smart merge dynamic configs first (before blind overwrite of static files) + log("Merging dynamic configs..."); + let merge_tx = rst_tx.clone(); + if let Err(e) = restore_and_merge_configs(&dest_for_restore, &temp_backup_dir, merge_tx, cancel_token_clone.clone()).await { + if e.contains("cancelled") { + log("Restore cancelled"); + let _ = state_tx_clone.send(AppState::Idle); + let _ = drive_poll_tx_clone.send(true); + return; + } + // Non-fatal - continue with static restore + log(&format!("Warning: Config merge error: {}", e)); + crate::debug::log(&format!("WARNING: Config merge error: {}", e)); + } else { + log("Dynamic config merge complete"); + crate::debug::log("Dynamic config merge complete"); + } + + // Restore static files (blind overwrite) and clean up backup directory + if let Err(e) = restore_preserve_paths(&dest_for_restore, &temp_backup_dir, rst_tx, cancel_token_clone.clone()).await { + if e.contains("cancelled") { + log("Restore cancelled"); + let _ = state_tx_clone.send(AppState::Idle); + let _ = drive_poll_tx_clone.send(true); + return; + } + // Restore errors are non-fatal - log warning but continue + log(&format!("Warning: Could not restore some user data: {}", e)); + crate::debug::log(&format!("WARNING: Restore error: {}", e)); + } else { + let _ = rst_handle.await; + log("User data restore complete"); + crate::debug::log("User data restore complete"); + } + } + // Copy debug log to SD card log("Writing debug log to SD card..."); crate::debug::log("Copying debug log to SD card..."); @@ -1038,9 +1220,11 @@ impl InstallerApp { AppState::FetchingRelease => "Fetching release...".to_string(), AppState::Downloading => "Downloading...".to_string(), AppState::Formatting => "Formatting...".to_string(), + AppState::BackingUp => "Backing up user data...".to_string(), AppState::Deleting => "Deleting old directories...".to_string(), AppState::Extracting => "Extracting...".to_string(), AppState::Copying => "Copying...".to_string(), + AppState::Restoring => "Restoring user data...".to_string(), AppState::Burning => "Burning image...".to_string(), AppState::Complete => "COMPLETE".to_string(), AppState::Error => "ERROR".to_string(), diff --git a/src/app/state.rs b/src/app/state.rs index 3f0e447..d700f9e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -20,9 +20,11 @@ pub enum AppState { FetchingRelease, Downloading, Formatting, + BackingUp, Deleting, Extracting, Copying, + Restoring, Burning, ScrapingBoxart, Complete, @@ -50,6 +52,7 @@ pub struct InstallerApp { // HIDE UPDATE MODE: To completely remove, delete this field and all references // (Easier approach: just hide the checkbox in ui.rs - this field stays but is unused) pub(super) update_mode: bool, + pub(super) preserve_data: bool, // "Preserve user data" checkbox (backup/restore during update) // Progress tracking pub(super) state: AppState, @@ -200,6 +203,7 @@ impl InstallerApp { selected_repo_idx: DEFAULT_REPO_INDEX, // HIDE UPDATE MODE: Remove this if you delete the update_mode field above update_mode: false, + preserve_data: true, state: AppState::Idle, progress: Arc::new(Mutex::new(ProgressInfo { current: 0, diff --git a/src/app/ui.rs b/src/app/ui.rs index 6e4ee26..13f8725 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -207,18 +207,21 @@ impl eframe::App for InstallerApp { self.state = AppState::Complete; self.cancel_token = None; self.update_mode = false; // Reset update mode + self.preserve_data = true; // Reset preserve data to default self.was_just_paused = false; // Clear pause flag on completion progress.message.clear(); } else if progress.message == "ERROR" { self.state = AppState::Error; self.cancel_token = None; self.update_mode = false; // Reset update mode + self.preserve_data = true; // Reset preserve data to default self.was_just_paused = false; // Clear pause flag on error progress.message.clear(); } else if progress.message == "CANCELLED" || progress.message.contains("Download paused at") { self.state = AppState::Idle; self.cancel_token = None; self.update_mode = false; // Reset update mode + self.preserve_data = true; // Reset preserve data to default self.manifest_display_name = None; progress.message.clear(); } else if progress.message.starts_with("SCRAPE_COMPLETE") { @@ -257,6 +260,9 @@ impl eframe::App for InstallerApp { || progress.message.contains("partition") { self.state = AppState::Formatting; + } else if progress.message.contains("Backing up") || progress.message.contains("Backup") + { + self.state = AppState::BackingUp; } else if progress.message.contains("Deleting") || progress.message.contains("deletion") { self.state = AppState::Deleting; @@ -266,6 +272,9 @@ impl eframe::App for InstallerApp { } else if progress.message.contains("Copying") || progress.message.contains("Copy") { self.state = AppState::Copying; + } else if progress.message.contains("Restoring") || progress.message.contains("Restore") + { + self.state = AppState::Restoring; } } } @@ -278,9 +287,11 @@ impl eframe::App for InstallerApp { | AppState::FetchingRelease | AppState::Downloading | AppState::Formatting + | AppState::BackingUp | AppState::Deleting | AppState::Extracting | AppState::Copying + | AppState::Restoring | AppState::Ejecting | AppState::Cancelling | AppState::ScrapingBoxart @@ -493,18 +504,38 @@ impl eframe::App for InstallerApp { // Show directories to be deleted let update_dirs = REPO_OPTIONS[self.selected_repo_idx].update_directories; egui::ScrollArea::vertical() - .max_height(200.0) + .max_height(150.0) .show(ui, |ui| { for dir in update_dirs { - ui.label(format!("• {}/", dir)); + ui.label(format!(" \u{2022} {}/", dir)); } }); ui.add_space(12.0); - ui.colored_label( - egui::Color32::from_rgb(104, 157, 106), - "All other files will be preserved." - ); + + if self.preserve_data { + ui.colored_label( + egui::Color32::from_rgb(104, 157, 106), + "The following user data will be preserved:" + ); + ui.add_space(4.0); + ui.label(" \u{2022} RetroArch configs & overlays"); + ui.label(" \u{2022} Emulator saves & settings"); + ui.label(" \u{2022} Syncthing config & SSH keys"); + ui.label(" \u{2022} Spruce config & theme backups"); + } else { + ui.colored_label( + ui.visuals().warn_fg_color, + "\u{26A0} User data preservation is disabled." + ); + ui.add_space(4.0); + ui.label("Emulator configs, RetroArch settings, and other user"); + ui.label("customizations within deleted directories will be lost."); + ui.colored_label( + egui::Color32::from_rgb(104, 157, 106), + "Roms, BIOS, and Saves will still be kept." + ); + } ui.add_space(12.0); ui.separator(); ui.add_space(8.0); @@ -932,9 +963,11 @@ impl eframe::App for InstallerApp { | AppState::FetchingRelease | AppState::Downloading | AppState::Formatting + | AppState::BackingUp | AppState::Deleting | AppState::Extracting | AppState::Copying + | AppState::Restoring | AppState::Cancelling ); @@ -1146,6 +1179,19 @@ impl eframe::App for InstallerApp { // END HIDE UPDATE MODE - Comment through here to disable the checkbox // ======================================================================== + // Preserve user data checkbox (only show when update mode is ON and repo supports it) + let current_repo_supports_preserve = REPO_OPTIONS[self.selected_repo_idx].supports_preserve_mode; + if !show_progress && self.update_mode && current_repo_supports_preserve { + ui.horizontal(|ui| { + ui.vertical_centered(|ui| { + ui.checkbox(&mut self.preserve_data, "Preserve user data"); + }); + }); + } else if !current_repo_supports_preserve || !self.update_mode { + // Reset preserve_data to default when conditions aren't met + self.preserve_data = true; + } + ui.add_space(12.0); // Progress bar @@ -1170,7 +1216,7 @@ impl eframe::App for InstallerApp { // Downloading, Formatting, Extracting, and Copying report percentages let is_indeterminate = matches!( self.state, - AppState::FetchingAssets | AppState::FetchingRelease | AppState::Deleting | AppState::Idle + AppState::FetchingAssets | AppState::FetchingRelease | AppState::BackingUp | AppState::Deleting | AppState::Restoring | AppState::Idle ); if is_indeterminate { @@ -1234,9 +1280,11 @@ impl eframe::App for InstallerApp { | AppState::FetchingRelease | AppState::Downloading | AppState::Formatting + | AppState::BackingUp | AppState::Deleting | AppState::Extracting | AppState::Copying + | AppState::Restoring | AppState::AwaitingConfirmation | AppState::Ejecting | AppState::Cancelling @@ -1278,9 +1326,11 @@ impl eframe::App for InstallerApp { AppState::FetchingRelease | AppState::Downloading | AppState::Formatting + | AppState::BackingUp | AppState::Deleting | AppState::Extracting | AppState::Copying + | AppState::Restoring ) && self.cancel_token.is_some(); if can_cancel { diff --git a/src/config.rs b/src/config.rs index fbdfcc3..dbd13ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -109,6 +109,9 @@ pub struct AssetDisplayMapping { /// Set to None to show all assets /// - `asset_display_mappings`: Optional mappings to show user-friendly device names /// instead of technical filenames in the selection UI +/// - `supports_preserve_mode`: Whether to show "Preserve user data" checkbox in update mode +/// When enabled, user data is backed up before deletion and restored after install +/// The paths to preserve are defined in UPDATE_PRESERVE_PATHS /// /// Example (archive-based repository): /// ``` @@ -121,6 +124,7 @@ pub struct AssetDisplayMapping { /// update_directories: &["Retroarch", "spruce"], /// allowed_extensions: Some(&[".7z", ".zip"]), // Only show archives /// asset_display_mappings: None, +/// supports_preserve_mode: true, // Show preserve user data checkbox /// } /// ``` /// @@ -135,6 +139,7 @@ pub struct AssetDisplayMapping { /// update_directories: &[], // Not used for raw images /// allowed_extensions: Some(&[".img.gz", ".img"]), // Only raw images /// asset_display_mappings: None, +/// supports_preserve_mode: false, // No preserve for raw images /// } /// ``` pub struct RepoOption { @@ -146,8 +151,147 @@ pub struct RepoOption { pub update_directories: &'static [&'static str], pub allowed_extensions: Option<&'static [&'static str]>, pub asset_display_mappings: Option<&'static [AssetDisplayMapping]>, + pub supports_preserve_mode: bool, // Show "Preserve user data" checkbox in update mode } +// ---------------------------------------------------------------------------- +// SELECTIVE DELETE PATHS FOR UPDATE MODE +// ---------------------------------------------------------------------------- +// Mirrors the on-device App/-Updater/delete_files.sh behavior. +// Only specific subdirectories/files are deleted — NOT entire top-level dirs. +// This preserves user customizations (custom apps, custom emu folders, +// user-added RetroArch assets, etc.) that would be lost by wholesale deletion. +// ---------------------------------------------------------------------------- + +pub const SPRUCE_UPDATE_DELETE_PATHS: &[&str] = &[ + // App: specific subdirs only + // (preserves BootLogo, fn_editor, PortMaster, RandomGame, user-added apps) + "App/-FirmwareUpdate-", + "App/-OTA", + "App/-Updater", + "App/Credits", + "App/FileManagement", + "App/GameNursery", + "App/MiyooGamelist", + "App/PixelReader", + "App/PyUI", + "App/RetroArch", + "App/spruceBackup", + "App/spruceRestore", + "App/ThemeGarden", + "App/USBStorageMode", + // Emu: specific system folders only + // (preserves custom-named folders the user created) + "Emu/A30PORTS", + "Emu/AMIGA", + "Emu/ARCADE", + "Emu/ARDUBOY", + "Emu/ATARI", + "Emu/ATARIST", + "Emu/CHAI", + "Emu/COLECO", + "Emu/COMMODORE", + "Emu/CPC", + "Emu/CPS1", + "Emu/CPS2", + "Emu/CPS3", + "Emu/-CUSTOM-SYSTEM-", + "Emu/DC", + "Emu/DOOM", + "Emu/DOS", + "Emu/EASYRPG", + "Emu/EIGHTHUNDRED", + "Emu/.emu_setup", + "Emu/FAIRCHILD", + "Emu/FAKE08", + "Emu/FBNEO", + "Emu/FC", + "Emu/FDS", + "Emu/FIFTYTWOHUNDRED", + "Emu/GAMETANK", + "Emu/GB", + "Emu/GBA", + "Emu/GBC", + "Emu/GG", + "Emu/GW", + "Emu/INTELLIVISION", + "Emu/LYNX", + "Emu/MAME2003PLUS", + "Emu/MD", + "Emu/MEDIA", + "Emu/MEGADUCK", + "Emu/MS", + "Emu/MSU1", + "Emu/MSUMD", + "Emu/MSX", + "Emu/N64", + "Emu/NAOMI", + "Emu/NDS", + "Emu/NEOCD", + "Emu/NEOGEO", + "Emu/NGP", + "Emu/NGPC", + "Emu/ODYSSEY", + "Emu/OPENBOR", + "Emu/PC98", + "Emu/PCE", + "Emu/PCECD", + "Emu/PICO8", + "Emu/POKE", + "Emu/PORTS", + "Emu/PS", + "Emu/PSP", + "Emu/QUAKE", + "Emu/SATELLAVIEW", + "Emu/SATURN", + "Emu/SCUMMVM", + "Emu/SEGACD", + "Emu/SEGASGONE", + "Emu/SEVENTYEIGHTHUNDRED", + "Emu/SFC", + "Emu/SGB", + "Emu/SGFX", + "Emu/SUFAMI", + "Emu/SUPERVISION", + "Emu/THIRTYTWOX", + "Emu/TIC", + "Emu/VB", + "Emu/VECTREX", + "Emu/VIC20", + "Emu/VIDEOPAC", + "Emu/WOLF", + "Emu/WS", + "Emu/WSC", + "Emu/X68000", + "Emu/ZXS", + // spruce: specific subdirs only + // (preserves bin, bin64, a30, brick, flip, miyoomini — needed for PyUI) + "spruce/archives", + "spruce/etc", + "spruce/FIRMWARE_UPDATE", + "spruce/flags", + "spruce/imgs", + "spruce/scripts", + "spruce/www", + "spruce/spruce", + // SD card root: specific items + // (RetroArch is NOT deleted — preserves user-added overlays/shaders/cheats) + ".github", + ".tmp_update", + "Icons", + "miyoo", + "miyoo355", + "trimui", + ".gitattributes", + ".gitignore", + "autorun.inf", + "create_spruce_release.bat", + "create_spruce_release.sh", + "LICENSE", + "Pico8.Native.INFO.txt", + "README.md", +]; + pub const REPO_OPTIONS: &[RepoOption] = &[ RepoOption { name: "Stable", @@ -155,9 +299,10 @@ pub const REPO_OPTIONS: &[RepoOption] = &[ info: "Stable releases of spruceOS.\nSupported devices:\nMiyoo A30, Miyoo Flip, Miyoo Mini Flip, TrimUI Smart Pro, TrimUI Smart Pro S, TrimUI Brick\n[For more info check out our Wiki](https://github.com/spruceUI/spruceOS/wiki)", display_name: Some("spruceOS Stable"), // Display name for popups supports_update_mode: true, // Archive-based (.7z) - update_directories: &[".tmp_update", "App", "Emu", "Icons", "miyoo", "miyoo355", "RetroArch", "spruce", "trimui"], + update_directories: SPRUCE_UPDATE_DELETE_PATHS, allowed_extensions: Some(&[".7z"]), // Only show 7z archives asset_display_mappings: None, + supports_preserve_mode: true, }, RepoOption { name: "Nightlies", @@ -165,9 +310,10 @@ pub const REPO_OPTIONS: &[RepoOption] = &[ info: "Nightly development builds.\n⚠️ Warning: May be unstable! \nSupported devices:\nMiyoo A30, Miyoo Flip, Miyoo Mini Flip, TrimUI Smart Pro, TrimUI Smart Pro S, TrimUI Brick", display_name: Some("spruceOS Nightly"), // Display name for popups supports_update_mode: true, // Supports archives - update_directories: &[".tmp_update", "App", "Emu", "Icons", "miyoo", "miyoo355", "RetroArch", "spruce", "trimui"], + update_directories: SPRUCE_UPDATE_DELETE_PATHS, allowed_extensions: None, // Show all assets asset_display_mappings: None, + supports_preserve_mode: true, }, RepoOption { name: "SprigUI", @@ -178,6 +324,7 @@ pub const REPO_OPTIONS: &[RepoOption] = &[ update_directories: &["Retroarch", "spruce"], allowed_extensions: Some(&[".7z"]), // Only show 7z archives asset_display_mappings: None, + supports_preserve_mode: false, }, RepoOption { name: "TwigUI", @@ -188,12 +335,55 @@ pub const REPO_OPTIONS: &[RepoOption] = &[ update_directories: &["Retroarch", "spruce"], allowed_extensions: Some(&[".img.gz"]), // Only show .img.gz files asset_display_mappings: None, + supports_preserve_mode: false, }, ]; /// Index of the default repository selection (0 = first option) pub const DEFAULT_REPO_INDEX: usize = 0; +// ---------------------------------------------------------------------------- +// UPDATE PRESERVE PATHS +// ---------------------------------------------------------------------------- +// Paths to preserve during update mode (relative to SD card root). +// These are backed up before deletion and restored after installation. +// Can be files or directories. Only used when preserve_data is enabled. +// Mirrors the on-device spruceBackup.sh backup paths. +// ---------------------------------------------------------------------------- + +pub const UPDATE_PRESERVE_PATHS: &[&str] = &[ + // Emulator configs and saves + "App/spruceRestore/.lastUpdate", + "Emu/PICO8/.lexaloffle", + "Emu/.emu_setup/n64_controller/Custom.rmp", + "Emu/DC/config", + "Emu/NDS/backup", + "Emu/NDS/backup-32", + "Emu/NDS/backup-64", + "Emu/NDS/config/drastic-A30.cfg", + "Emu/NDS/config/drastic-Brick.cfg", + "Emu/NDS/config/drastic-SmartPro.cfg", + "Emu/NDS/config/drastic-SmartProS.cfg", + "Emu/NDS/config/drastic-Flip.cfg", + "Emu/NDS/config/drastic-Pixel2.cfg", + "Emu/NDS/savestates", + "Emu/NDS/resources/settings_A30.json", + "Emu/NDS/resources/settings_Flip.json", + "Emu/NDS/resources/settings_Pixel2.json", + "Emu/SATURN/.yabasanshiro", + // RetroArch configs (overlays/shaders/cheats not needed — RetroArch/ is no longer deleted) + "RetroArch/.retroarch/config", + "RetroArch/platform/retroarch-A30.cfg", + "RetroArch/platform/retroarch-Brick.cfg", + "RetroArch/platform/retroarch-Flip.cfg", + "RetroArch/platform/retroarch-SmartPro.cfg", + "RetroArch/platform/retroarch-SmartProS.cfg", + "RetroArch/platform/retroarch-Pixel2.cfg", + // Network services + "spruce/bin/Syncthing/config", + "spruce/etc/ssh/keys", +]; + // ---------------------------------------------------------------------------- // WINDOW SETTINGS // ---------------------------------------------------------------------------- diff --git a/src/delete.rs b/src/delete.rs index 788ee7b..1f95f7d 100644 --- a/src/delete.rs +++ b/src/delete.rs @@ -44,11 +44,11 @@ pub async fn delete_directories( let dir_path = mount_path.join(dir_name); - crate::debug::log(&format!("Checking directory: {:?}", dir_path)); + crate::debug::log(&format!("Checking path: {:?}", dir_path)); - // Check if directory exists + // Check if path exists (file or directory) if !dir_path.exists() { - crate::debug::log(&format!("Directory does not exist, skipping: {}", dir_name)); + crate::debug::log(&format!("Path does not exist, skipping: {}", dir_name)); continue; } @@ -57,10 +57,16 @@ pub async fn delete_directories( name: dir_name.to_string(), }); - crate::debug::log(&format!("Deleting directory: {}", dir_name)); + crate::debug::log(&format!("Deleting: {}", dir_name)); - // Delete the directory recursively - match tokio::fs::remove_dir_all(&dir_path).await { + // Delete directory or file as appropriate + let result = if dir_path.is_dir() { + tokio::fs::remove_dir_all(&dir_path).await + } else { + tokio::fs::remove_file(&dir_path).await + }; + + match result { Ok(_) => { crate::debug::log(&format!("Successfully deleted: {}", dir_name)); } diff --git a/src/main.rs b/src/main.rs index 6f10570..601a7e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod format; mod github; mod mame_db; mod manifest; +mod preserve; #[cfg(target_os = "macos")] mod mac; diff --git a/src/preserve.rs b/src/preserve.rs new file mode 100644 index 0000000..455910a --- /dev/null +++ b/src/preserve.rs @@ -0,0 +1,507 @@ +// Copyright (C) 2026 SpruceOS Team +// Licensed under CC BY-NC 4.0 (Creative Commons Attribution-NonCommercial 4.0 International) + +use serde_json::Value; +use std::path::Path; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +#[derive(Debug)] +pub enum PreserveProgress { + Started { total_paths: usize }, + BackingUp { path: String }, + Restoring { path: String }, + Completed, + Cancelled, + #[allow(dead_code)] + Error(String), +} + +/// Recursively copy a file or directory from src to dst, preserving relative structure. +async fn copy_recursive(src: &Path, dst: &Path) -> Result<(), String> { + if src.is_dir() { + tokio::fs::create_dir_all(dst).await + .map_err(|e| format!("Failed to create directory {:?}: {}", dst, e))?; + + let mut entries = tokio::fs::read_dir(src).await + .map_err(|e| format!("Failed to read directory {:?}: {}", src, e))?; + + while let Some(entry) = entries.next_entry().await + .map_err(|e| format!("Failed to read entry in {:?}: {}", src, e))? { + let entry_src = entry.path(); + let entry_dst = dst.join(entry.file_name()); + Box::pin(copy_recursive(&entry_src, &entry_dst)).await?; + } + } else if src.is_file() { + // Ensure parent directory exists + if let Some(parent) = dst.parent() { + tokio::fs::create_dir_all(parent).await + .map_err(|e| format!("Failed to create parent dir {:?}: {}", parent, e))?; + } + tokio::fs::copy(src, dst).await + .map_err(|e| format!("Failed to copy {:?} to {:?}: {}", src, dst, e))?; + } + // Skip symlinks and other special files + Ok(()) +} + +/// Backup preserve paths from the SD card to a local temp directory. +/// Each path in `preserve_paths` is relative to `mount_path`. +/// Files are copied to `temp_backup_dir` preserving their relative path structure. +pub async fn backup_preserve_paths( + mount_path: &Path, + preserve_paths: &[&str], + temp_backup_dir: &Path, + progress_tx: mpsc::UnboundedSender, + cancel_token: CancellationToken, +) -> Result<(), String> { + if cancel_token.is_cancelled() { + let _ = progress_tx.send(PreserveProgress::Cancelled); + return Err("Backup cancelled".to_string()); + } + + let _ = progress_tx.send(PreserveProgress::Started { + total_paths: preserve_paths.len(), + }); + + crate::debug::log_section("Backing Up User Data"); + crate::debug::log(&format!("Mount path: {:?}", mount_path)); + crate::debug::log(&format!("Backup dir: {:?}", temp_backup_dir)); + + // Create the backup directory + tokio::fs::create_dir_all(temp_backup_dir).await + .map_err(|e| format!("Failed to create backup directory: {}", e))?; + + for rel_path in preserve_paths { + if cancel_token.is_cancelled() { + let _ = progress_tx.send(PreserveProgress::Cancelled); + return Err("Backup cancelled".to_string()); + } + + let src = mount_path.join(rel_path); + let dst = temp_backup_dir.join(rel_path); + + if !src.exists() { + crate::debug::log(&format!("Path does not exist, skipping: {}", rel_path)); + continue; + } + + let _ = progress_tx.send(PreserveProgress::BackingUp { + path: rel_path.to_string(), + }); + + crate::debug::log(&format!("Backing up: {}", rel_path)); + + copy_recursive(&src, &dst).await?; + } + + let _ = progress_tx.send(PreserveProgress::Completed); + crate::debug::log("User data backup complete"); + + Ok(()) +} + +/// Restore preserved paths from the local temp backup directory back to the SD card. +/// Walks the backup directory recursively and copies all files back to `mount_path` +/// at their original relative paths, overwriting any existing files. +/// Cleans up `temp_backup_dir` when done. +pub async fn restore_preserve_paths( + mount_path: &Path, + temp_backup_dir: &Path, + progress_tx: mpsc::UnboundedSender, + cancel_token: CancellationToken, +) -> Result<(), String> { + if cancel_token.is_cancelled() { + let _ = progress_tx.send(PreserveProgress::Cancelled); + return Err("Restore cancelled".to_string()); + } + + // Count entries for progress reporting + let paths = collect_relative_files(temp_backup_dir, temp_backup_dir).await?; + + let _ = progress_tx.send(PreserveProgress::Started { + total_paths: paths.len(), + }); + + crate::debug::log_section("Restoring User Data"); + crate::debug::log(&format!("Backup dir: {:?}", temp_backup_dir)); + crate::debug::log(&format!("Mount path: {:?}", mount_path)); + crate::debug::log(&format!("Files to restore: {}", paths.len())); + + for rel_path in &paths { + if cancel_token.is_cancelled() { + let _ = progress_tx.send(PreserveProgress::Cancelled); + return Err("Restore cancelled".to_string()); + } + + let src = temp_backup_dir.join(rel_path); + let dst = mount_path.join(rel_path); + + let _ = progress_tx.send(PreserveProgress::Restoring { + path: rel_path.clone(), + }); + + crate::debug::log(&format!("Restoring: {}", rel_path)); + + // Ensure parent directory exists + if let Some(parent) = dst.parent() { + tokio::fs::create_dir_all(parent).await + .map_err(|e| format!("Failed to create parent dir {:?}: {}", parent, e))?; + } + + tokio::fs::copy(&src, &dst).await + .map_err(|e| format!("Failed to restore {:?}: {}", rel_path, e))?; + } + + // Clean up backup directory + crate::debug::log("Cleaning up backup directory..."); + let _ = tokio::fs::remove_dir_all(temp_backup_dir).await; + + let _ = progress_tx.send(PreserveProgress::Completed); + crate::debug::log("User data restore complete"); + + Ok(()) +} + +/// Recursively collect all file paths relative to `base` within `dir`. +async fn collect_relative_files(dir: &Path, base: &Path) -> Result, String> { + let mut results = Vec::new(); + + if !dir.exists() { + return Ok(results); + } + + let mut entries = tokio::fs::read_dir(dir).await + .map_err(|e| format!("Failed to read directory {:?}: {}", dir, e))?; + + while let Some(entry) = entries.next_entry().await + .map_err(|e| format!("Failed to read entry in {:?}: {}", dir, e))? { + let path = entry.path(); + + if path.is_dir() { + let sub_results = Box::pin(collect_relative_files(&path, base)).await?; + results.extend(sub_results); + } else if path.is_file() { + if let Ok(rel) = path.strip_prefix(base) { + results.push(rel.to_string_lossy().to_string()); + } + } + } + + Ok(results) +} + +/// Smart merge of "selected" values from old config into new config. +/// Rust port of merge_configs.py from the on-device spruceRestore updater. +/// +/// Recursively walks JSON objects. When it finds a "selected" key: +/// - If old value is null, skip +/// - If sibling "options" array exists and old value is in it, copy old -> new +/// - If no "options" key exists (free-text field), always copy old -> new +fn merge_json_selected(old: &Value, new: &mut Value) { + let (Some(old_obj), Some(new_obj)) = (old.as_object(), new.as_object_mut()) else { + return; + }; + + for (key, old_val) in old_obj { + if !new_obj.contains_key(key) { + continue; + } + + if key == "selected" { + if old_val.is_null() { + continue; + } + + // Check sibling "options" array in the new config to validate old value + let should_copy = if let Some(options) = new_obj.get("options") { + if let Some(options_arr) = options.as_array() { + options_arr.contains(old_val) + } else { + false // "options" exists but isn't an array + } + } else { + true // No "options" key at all — free-text field, always copy + }; + + if should_copy { + if let Some(new_val) = new_obj.get_mut(key) { + *new_val = old_val.clone(); + } + } + } else { + // Recurse into nested objects + if let Some(new_val) = new_obj.get_mut(key) { + merge_json_selected(old_val, new_val); + } + } + } +} + +/// Read old and new JSON config files, merge "selected" values from old into new, +/// and write the merged result back to the new config file. +async fn merge_config_file(old_path: &Path, new_path: &Path) -> Result<(), String> { + let old_data = tokio::fs::read_to_string(old_path).await + .map_err(|e| format!("Failed to read old config: {}", e))?; + let new_data = tokio::fs::read_to_string(new_path).await + .map_err(|e| format!("Failed to read new config: {}", e))?; + + let old_json: Value = serde_json::from_str(&old_data) + .map_err(|e| format!("Failed to parse old config JSON: {}", e))?; + let mut new_json: Value = serde_json::from_str(&new_data) + .map_err(|e| format!("Failed to parse new config JSON: {}", e))?; + + merge_json_selected(&old_json, &mut new_json); + + let merged = serde_json::to_string_pretty(&new_json) + .map_err(|e| format!("Failed to serialize merged config: {}", e))?; + + tokio::fs::write(new_path, merged).await + .map_err(|e| format!("Failed to write merged config: {}", e))?; + + Ok(()) +} + +/// Backup dynamic config files from the SD card to the temp backup directory. +/// Mirrors the on-device spruceBackup.sh behavior: +/// - Scans Emu/*/config.json -> _dynamic_emu_configs/.json +/// - Copies Saves/spruce/spruce-config.json -> _dynamic_spruce_config/spruce-config.json +/// - Scans Themes/*/config*.json -> _dynamic_theme_configs// +pub async fn backup_dynamic_configs( + mount_path: &Path, + temp_backup_dir: &Path, + progress_tx: mpsc::UnboundedSender, + cancel_token: CancellationToken, +) -> Result<(), String> { + if cancel_token.is_cancelled() { + let _ = progress_tx.send(PreserveProgress::Cancelled); + return Err("Backup cancelled".to_string()); + } + + crate::debug::log_section("Backing Up Dynamic Configs"); + + let emu_backup_dir = temp_backup_dir.join("_dynamic_emu_configs"); + let spruce_backup_dir = temp_backup_dir.join("_dynamic_spruce_config"); + let theme_backup_dir = temp_backup_dir.join("_dynamic_theme_configs"); + + // 1. Backup emulator configs: Emu/*/config.json + let emu_dir = mount_path.join("Emu"); + if emu_dir.exists() { + let mut entries = tokio::fs::read_dir(&emu_dir).await + .map_err(|e| format!("Failed to read Emu directory: {}", e))?; + + while let Some(entry) = entries.next_entry().await + .map_err(|e| format!("Failed to read Emu entry: {}", e))? { + if cancel_token.is_cancelled() { + let _ = progress_tx.send(PreserveProgress::Cancelled); + return Err("Backup cancelled".to_string()); + } + + let path = entry.path(); + if path.is_dir() { + let config_path = path.join("config.json"); + if config_path.exists() { + let emu_name = entry.file_name().to_string_lossy().to_string(); + tokio::fs::create_dir_all(&emu_backup_dir).await + .map_err(|e| format!("Failed to create emu backup dir: {}", e))?; + + let dst = emu_backup_dir.join(format!("{}.json", emu_name)); + let _ = progress_tx.send(PreserveProgress::BackingUp { + path: format!("Emu/{}/config.json", emu_name), + }); + crate::debug::log(&format!("Backing up emu config: {}", emu_name)); + + tokio::fs::copy(&config_path, &dst).await + .map_err(|e| format!("Failed to backup emu config {}: {}", emu_name, e))?; + } + } + } + } + + // 2. Backup spruce config: Saves/spruce/spruce-config.json + let spruce_config = mount_path.join("Saves").join("spruce").join("spruce-config.json"); + if spruce_config.exists() { + tokio::fs::create_dir_all(&spruce_backup_dir).await + .map_err(|e| format!("Failed to create spruce backup dir: {}", e))?; + + let dst = spruce_backup_dir.join("spruce-config.json"); + let _ = progress_tx.send(PreserveProgress::BackingUp { + path: "Saves/spruce/spruce-config.json".to_string(), + }); + crate::debug::log("Backing up spruce-config.json"); + + tokio::fs::copy(&spruce_config, &dst).await + .map_err(|e| format!("Failed to backup spruce-config.json: {}", e))?; + } + + // 3. Backup theme configs: Themes/*/config*.json + let themes_dir = mount_path.join("Themes"); + if themes_dir.exists() { + let mut entries = tokio::fs::read_dir(&themes_dir).await + .map_err(|e| format!("Failed to read Themes directory: {}", e))?; + + while let Some(entry) = entries.next_entry().await + .map_err(|e| format!("Failed to read Themes entry: {}", e))? { + if cancel_token.is_cancelled() { + let _ = progress_tx.send(PreserveProgress::Cancelled); + return Err("Backup cancelled".to_string()); + } + + let path = entry.path(); + if path.is_dir() { + let theme_name = entry.file_name().to_string_lossy().to_string(); + let mut theme_entries = tokio::fs::read_dir(&path).await + .map_err(|e| format!("Failed to read theme dir {}: {}", theme_name, e))?; + + while let Some(theme_entry) = theme_entries.next_entry().await + .map_err(|e| format!("Failed to read theme entry in {}: {}", theme_name, e))? { + let fname = theme_entry.file_name().to_string_lossy().to_string(); + if fname.starts_with("config") && fname.ends_with(".json") { + let theme_dst_dir = theme_backup_dir.join(&theme_name); + tokio::fs::create_dir_all(&theme_dst_dir).await + .map_err(|e| format!("Failed to create theme backup dir: {}", e))?; + + let dst = theme_dst_dir.join(&fname); + let _ = progress_tx.send(PreserveProgress::BackingUp { + path: format!("Themes/{}/{}", theme_name, fname), + }); + crate::debug::log(&format!("Backing up theme config: {}/{}", theme_name, fname)); + + tokio::fs::copy(theme_entry.path(), &dst).await + .map_err(|e| format!("Failed to backup theme config {}/{}: {}", theme_name, fname, e))?; + } + } + } + } + } + + crate::debug::log("Dynamic config backup complete"); + Ok(()) +} + +/// Restore dynamic configs with smart merging. +/// Mirrors the on-device spruceRestore.sh behavior: +/// - Emu configs: merge old "selected" values into new configs via merge_json_selected +/// - Spruce config: merge old "selected" values into new config +/// - Theme configs: plain copy (overwrite) +/// Cleans up the _dynamic_* subdirectories when done. +pub async fn restore_and_merge_configs( + mount_path: &Path, + temp_backup_dir: &Path, + progress_tx: mpsc::UnboundedSender, + cancel_token: CancellationToken, +) -> Result<(), String> { + if cancel_token.is_cancelled() { + let _ = progress_tx.send(PreserveProgress::Cancelled); + return Err("Restore cancelled".to_string()); + } + + crate::debug::log_section("Restoring Dynamic Configs (Smart Merge)"); + + let emu_backup_dir = temp_backup_dir.join("_dynamic_emu_configs"); + let spruce_backup_dir = temp_backup_dir.join("_dynamic_spruce_config"); + let theme_backup_dir = temp_backup_dir.join("_dynamic_theme_configs"); + + // 1. Merge emulator configs + if emu_backup_dir.exists() { + let mut entries = tokio::fs::read_dir(&emu_backup_dir).await + .map_err(|e| format!("Failed to read emu backup dir: {}", e))?; + + while let Some(entry) = entries.next_entry().await + .map_err(|e| format!("Failed to read emu backup entry: {}", e))? { + if cancel_token.is_cancelled() { + let _ = progress_tx.send(PreserveProgress::Cancelled); + return Err("Restore cancelled".to_string()); + } + + let fname = entry.file_name().to_string_lossy().to_string(); + if !fname.ends_with(".json") { + continue; + } + + let emu_name = fname.strip_suffix(".json").unwrap_or(&fname); + let new_config_path = mount_path.join("Emu").join(emu_name).join("config.json"); + + if !new_config_path.exists() { + crate::debug::log(&format!("Emu {} no longer exists in new install, skipping merge", emu_name)); + continue; + } + + let _ = progress_tx.send(PreserveProgress::Restoring { + path: format!("Emu/{}/config.json (merge)", emu_name), + }); + + match merge_config_file(&entry.path(), &new_config_path).await { + Ok(()) => { + crate::debug::log(&format!("Merged emu config: {}", emu_name)); + } + Err(e) => { + crate::debug::log(&format!("WARNING: Failed to merge emu config {}: {}", emu_name, e)); + } + } + } + let _ = tokio::fs::remove_dir_all(&emu_backup_dir).await; + } + + // 2. Merge spruce config + let old_spruce_config = spruce_backup_dir.join("spruce-config.json"); + if old_spruce_config.exists() { + let new_spruce_config = mount_path.join("Saves").join("spruce").join("spruce-config.json"); + if new_spruce_config.exists() { + let _ = progress_tx.send(PreserveProgress::Restoring { + path: "Saves/spruce/spruce-config.json (merge)".to_string(), + }); + + match merge_config_file(&old_spruce_config, &new_spruce_config).await { + Ok(()) => { + crate::debug::log("Merged spruce-config.json"); + } + Err(e) => { + crate::debug::log(&format!("WARNING: Failed to merge spruce-config.json: {}", e)); + } + } + } + let _ = tokio::fs::remove_dir_all(&spruce_backup_dir).await; + } + + // 3. Copy theme configs (plain overwrite, no merge — matches on-device behavior) + if theme_backup_dir.exists() { + let mut themes = tokio::fs::read_dir(&theme_backup_dir).await + .map_err(|e| format!("Failed to read theme backup dir: {}", e))?; + + while let Some(theme_entry) = themes.next_entry().await + .map_err(|e| format!("Failed to read theme backup entry: {}", e))? { + if cancel_token.is_cancelled() { + let _ = progress_tx.send(PreserveProgress::Cancelled); + return Err("Restore cancelled".to_string()); + } + + let theme_name = theme_entry.file_name().to_string_lossy().to_string(); + let theme_dest = mount_path.join("Themes").join(&theme_name); + + if !theme_dest.exists() { + crate::debug::log(&format!("Theme {} no longer exists in new install, skipping", theme_name)); + continue; + } + + if let Ok(mut configs) = tokio::fs::read_dir(theme_entry.path()).await { + while let Ok(Some(config_entry)) = configs.next_entry().await { + let config_fname = config_entry.file_name().to_string_lossy().to_string(); + let _ = progress_tx.send(PreserveProgress::Restoring { + path: format!("Themes/{}/{}", theme_name, config_fname), + }); + crate::debug::log(&format!("Restoring theme config: {}/{}", theme_name, config_fname)); + + let dst = theme_dest.join(&config_fname); + if let Err(e) = tokio::fs::copy(config_entry.path(), &dst).await { + crate::debug::log(&format!("WARNING: Failed to restore theme config {}/{}: {}", theme_name, config_fname, e)); + } + } + } + } + let _ = tokio::fs::remove_dir_all(&theme_backup_dir).await; + } + + crate::debug::log("Dynamic config restore complete"); + Ok(()) +}