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(())
+}