From ada5f94483c1f6b546a7ed3d2161593b0c70e71c Mon Sep 17 00:00:00 2001 From: SundownerSport Date: Sun, 16 Aug 2026 10:06:20 -0700 Subject: [PATCH] Remove SprigUI, add a BaseOS tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SprigUI is archived — spruceOS supports the Miyoo Mini Flip properly now, so the tab pointed at a dead repo. BaseOS (pvaibhav/BaseOS) is a third-party minimal OS for Anbernic RG XX devices, added here as an install target. It ships .img.zip: a raw disk image inside a zip container. The installer classified anything ending in .zip as an archive to extract onto a formatted card, so a BaseOS install would have formatted the card and copied a 1.4 GB .img onto it rather than burning it — an unbootable card with no error. Raw-image detection now matches .img.zip, and burn.rs gained a shared open_image_reader() that streams .gz, .zip or a bare .img through one path, replacing four copy-pasted gzip blocks. A worker thread owns the ZipArchive and hands chunks over a bounded channel, since by_index borrows the archive and cannot escape into the Box the burn paths expect. Zip stores its uncompressed size in the headers, so unlike .gz it needs no pre-scan. Read is deliberately fully qualified in the new code: one of the existing per-function imports is cfg-gated and guards the statement after it, so consolidating them silently breaks the Windows build. Version bumped to 1.6.0. --- Cargo.toml | 111 +++++++++-------- assets/Mac/Info.plist | 4 +- src/app/logic.rs | 22 +++- src/app/ui.rs | 20 +-- src/burn.rs | 282 ++++++++++++++++++++++++++++++++---------- src/config.rs | 42 +++++-- 6 files changed, 326 insertions(+), 155 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dfa0619..6178c1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,55 +1,56 @@ -[package] -name = "spruceos-installer" -version = "1.5.0" -edition = "2021" -description = "SpruceOS SD Card Installer" -authors = ["SpruceOS Team (https://github.com/spruceUI)", "CMTag (https://github.com/CMTag)", "NextUI Team (https://github.com/LoveRetro)", "Helaas (https://github.com/Helaas)"] -license = "CC-BY-NC-4.0" - -[features] -default = [] -icon = [] - -[dependencies] -eframe = "0.33" -egui = "0.33" -egui_extras = { version = "0.33", features = ["all_loaders"] } -egui-thematic = "0.1" -tokio = { version = "1", features = ["rt-multi-thread", "fs", "process", "sync", "time", "io-util", "macros"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } -sevenz-rust = "0.6" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -futures-util = "0.3" -tokio-util = "0.7" -dirs = "5" -tempfile = "3" -image = { version = "0.25", default-features = false, features = ["png", "qoi"] } -lazy_static = "1.4" -libc = "0.2" -sha2 = "0.10" -flate2 = "1.0" -arboard = "3.4" -regex = "1" -which = "6.0" - -[target.'cfg(windows)'.dependencies] -windows = { version = "0.58", features = [ - "Win32_Foundation", - "Win32_Storage_FileSystem", - "Win32_System_IO", - "Win32_System_Ioctl", - "Win32_System_Registry", - "Win32_Security", -] } - -[target.'cfg(windows)'.build-dependencies] -embed-resource = "2" - -[profile.release] -opt-level = "z" -lto = true -strip = "debuginfo" - - - +[package] +name = "spruceos-installer" +version = "1.6.0" +edition = "2021" +description = "SpruceOS SD Card Installer" +authors = ["SpruceOS Team (https://github.com/spruceUI)", "CMTag (https://github.com/CMTag)", "NextUI Team (https://github.com/LoveRetro)", "Helaas (https://github.com/Helaas)"] +license = "CC-BY-NC-4.0" + +[features] +default = [] +icon = [] + +[dependencies] +eframe = "0.33" +egui = "0.33" +egui_extras = { version = "0.33", features = ["all_loaders"] } +egui-thematic = "0.1" +tokio = { version = "1", features = ["rt-multi-thread", "fs", "process", "sync", "time", "io-util", "macros"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +sevenz-rust = "0.6" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +futures-util = "0.3" +tokio-util = "0.7" +dirs = "5" +tempfile = "3" +image = { version = "0.25", default-features = false, features = ["png", "qoi"] } +lazy_static = "1.4" +libc = "0.2" +sha2 = "0.10" +flate2 = "1.0" +zip = { version = "2", default-features = false, features = ["deflate"] } +arboard = "3.4" +regex = "1" +which = "6.0" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Ioctl", + "Win32_System_Registry", + "Win32_Security", +] } + +[target.'cfg(windows)'.build-dependencies] +embed-resource = "2" + +[profile.release] +opt-level = "z" +lto = true +strip = "debuginfo" + + + diff --git a/assets/Mac/Info.plist b/assets/Mac/Info.plist index aa1b410..d8146a7 100644 --- a/assets/Mac/Info.plist +++ b/assets/Mac/Info.plist @@ -9,9 +9,9 @@ CFBundleIdentifier com.spruceos.installer CFBundleVersion - 1.5.0 + 1.6.0 CFBundleShortVersionString - 1.5.0 + 1.6.0 CFBundlePackageType APPL CFBundleExecutable diff --git a/src/app/logic.rs b/src/app/logic.rs index da2eeaa..63fa055 100644 --- a/src/app/logic.rs +++ b/src/app/logic.rs @@ -106,12 +106,23 @@ impl InstallerApp { .collect() } + /// Whether an asset is a raw disk image to burn, rather than an archive + /// whose contents get copied onto a formatted card. + /// + /// `.img.zip` is a raw image inside a zip container (BaseOS ships these) and + /// must be burned, so it is checked before the plain `.zip` archive case. + pub(super) fn is_raw_image_asset(name: &str) -> bool { + name.ends_with(".img.gz") || + name.ends_with(".img.zip") || + name.ends_with(".img") + } + /// Strip all known extensions from an asset name to get the base name fn strip_extensions(name: &str) -> String { let mut base = name.to_string(); // Remove known extensions in order of specificity - for ext in &[".img.gz", ".tar.gz", ".7z", ".zip", ".img"] { + for ext in &[".img.gz", ".img.zip", ".tar.gz", ".7z", ".zip", ".img"] { if base.ends_with(ext) { base = base.strip_suffix(ext).unwrap_or(&base).to_string(); break; // Only strip one extension @@ -136,8 +147,10 @@ impl InstallerApp { if base_names.len() == 1 { // Same base name, different extensions - pick by priority - // Priority: .7z > .zip > .img.gz > .img - const PRIORITY: &[&str] = &[".7z", ".zip", ".img.gz", ".img"]; + // Priority: .7z > .img.zip > .zip > .img.gz > .img + // .img.zip is listed before .zip so the plain-archive entry does not + // shadow it — every .img.zip name also ends with .zip. + const PRIORITY: &[&str] = &[".7z", ".img.zip", ".zip", ".img.gz", ".img"]; for ext in PRIORITY { if let Some((idx, _)) = assets.iter() @@ -399,8 +412,7 @@ impl InstallerApp { log(&format!("Disk space check passed: {} MB available", available_space / 1_048_576)); // Detect installation mode: raw image vs archive - let is_raw_image = asset.name.ends_with(".img.gz") || - asset.name.ends_with(".img"); + let is_raw_image = Self::is_raw_image_asset(&asset.name); if is_raw_image { crate::debug::log("Detected RAW IMAGE mode - will burn image to device"); diff --git a/src/app/ui.rs b/src/app/ui.rs index 13f8725..419b904 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -159,13 +159,9 @@ impl eframe::App for InstallerApp { } // Check if selected asset is a raw image - let is_raw_image = if let Some(idx) = auto_idx { - let asset_name = &self.available_assets[idx].name; - asset_name.ends_with(".img.gz") || - asset_name.ends_with(".img") - } else { - false - }; + let is_raw_image = auto_idx.is_some_and(|idx| { + Self::is_raw_image_asset(&self.available_assets[idx].name) + }); // If update mode and NOT a raw image, show preview modal; otherwise go to confirmation if self.update_mode && !is_raw_image { @@ -472,13 +468,9 @@ impl eframe::App for InstallerApp { ui.add_enabled_ui(can_continue, |ui| { if ui.button("Continue").clicked() { // Check if selected asset is a raw image - let is_raw_image = if let Some(idx) = self.selected_asset_idx { - let asset_name = &self.available_assets[idx].name; - asset_name.ends_with(".img.gz") || - asset_name.ends_with(".img") - } else { - false - }; + let is_raw_image = self.selected_asset_idx.is_some_and(|idx| { + Self::is_raw_image_asset(&self.available_assets[idx].name) + }); // If update mode and NOT a raw image, show preview; otherwise go to confirmation if self.update_mode && !is_raw_image { diff --git a/src/burn.rs b/src/burn.rs index 1df85cc..ff34c89 100644 --- a/src/burn.rs +++ b/src/burn.rs @@ -9,6 +9,9 @@ use flate2::read::GzDecoder; const CHUNK_SIZE: usize = 4 * 1024 * 1024; // 4MB chunks +/// Chunk size used when pumping bytes out of a zip entry +const ZIP_CHUNK_SIZE: usize = 1024 * 1024; // 1MB + #[derive(Debug, Clone)] pub enum BurnProgress { Started { total_bytes: u64 }, @@ -21,6 +24,179 @@ pub enum BurnProgress { Error(String), } +/// How a downloaded raw image is compressed. +/// +/// BaseOS publishes `.img.zip`; TwigUI publishes `.img.gz`. All forms are read +/// back as a plain stream of raw image bytes. +/// +/// NOTE: `Read` is written fully qualified throughout this section on purpose. +/// Several functions below have their own `use std::io::Read;`, one of which is +/// cfg-gated and guards the statement that follows it — importing `Read` at +/// module scope here would make those look redundant and invite a cleanup that +/// silently breaks the Windows build. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ImageCompression { + None, + Gzip, + Zip, +} + +fn detect_compression(image_path: &Path) -> ImageCompression { + let name = image_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + + if name.ends_with(".zip") { + ImageCompression::Zip + } else if name.ends_with(".gz") { + ImageCompression::Gzip + } else { + ImageCompression::None + } +} + +/// Streams the first entry of a zip archive as a plain `Read`. +/// +/// `ZipArchive::by_index` borrows the archive, so the resulting reader cannot +/// escape into the `Box` the burn paths expect. A worker thread owns +/// the archive instead and hands chunks over a bounded channel; the bound also +/// caps how much decompressed data is buffered ahead of the writer. +struct ZipEntryReader { + rx: std::sync::mpsc::Receiver, String>>, + current: Vec, + pos: usize, + finished: bool, +} + +impl ZipEntryReader { + fn spawn(image_path: &Path) -> Result { + // Validate the archive up front so a bad file fails here rather than + // partway through writing to the device. + let entry_name = { + let file = std::fs::File::open(image_path) + .map_err(|e| format!("Failed to open zip image: {}", e))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| format!("Failed to read zip image: {}", e))?; + if archive.is_empty() { + return Err("Zip image contains no files".to_string()); + } + let entry = archive + .by_index(0) + .map_err(|e| format!("Failed to open first zip entry: {}", e))?; + entry.name().to_string() + }; + + crate::debug::log(&format!("Zip entry: {}", entry_name)); + + let path = image_path.to_path_buf(); + let (tx, rx) = std::sync::mpsc::sync_channel::, String>>(4); + + std::thread::spawn(move || { + use std::io::Read; + + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + let _ = tx.send(Err(format!("Failed to open zip image: {}", e))); + return; + } + }; + let mut archive = match zip::ZipArchive::new(file) { + Ok(a) => a, + Err(e) => { + let _ = tx.send(Err(format!("Failed to read zip image: {}", e))); + return; + } + }; + let mut entry = match archive.by_index(0) { + Ok(e) => e, + Err(e) => { + let _ = tx.send(Err(format!("Failed to open first zip entry: {}", e))); + return; + } + }; + + let mut buffer = vec![0u8; ZIP_CHUNK_SIZE]; + loop { + match entry.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + // A send error means the reader was dropped (cancelled burn) + if tx.send(Ok(buffer[..n].to_vec())).is_err() { + return; + } + } + Err(e) => { + let _ = tx.send(Err(format!("Failed to decompress zip image: {}", e))); + return; + } + } + } + }); + + Ok(Self { + rx, + current: Vec::new(), + pos: 0, + finished: false, + }) + } +} + +impl std::io::Read for ZipEntryReader { + fn read(&mut self, out: &mut [u8]) -> std::io::Result { + while self.pos >= self.current.len() { + if self.finished { + return Ok(0); + } + match self.rx.recv() { + Ok(Ok(chunk)) => { + self.current = chunk; + self.pos = 0; + } + Ok(Err(e)) => { + self.finished = true; + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)); + } + // Sender dropped without an error: the entry was fully read + Err(_) => { + self.finished = true; + return Ok(0); + } + } + } + + let n = std::cmp::min(self.current.len() - self.pos, out.len()); + out[..n].copy_from_slice(&self.current[self.pos..self.pos + n]); + self.pos += n; + Ok(n) + } +} + +/// Opens an image as a stream of raw bytes, decompressing `.gz` and `.zip` +/// on the fly so the burn paths always see a plain image. +fn open_image_reader(image_path: &Path) -> Result, String> { + match detect_compression(image_path) { + ImageCompression::Zip => { + crate::debug::log("Detected .zip image, decompressing on-the-fly during burn"); + Ok(Box::new(ZipEntryReader::spawn(image_path)?)) + } + ImageCompression::Gzip => { + crate::debug::log("Detected .gz image, decompressing on-the-fly during burn"); + let file = std::fs::File::open(image_path) + .map_err(|e| format!("Failed to open image file: {}", e))?; + Ok(Box::new(GzDecoder::new(file))) + } + ImageCompression::None => { + let file = std::fs::File::open(image_path) + .map_err(|e| format!("Failed to open image file: {}", e))?; + Ok(Box::new(file)) + } + } +} + /// Burns a raw disk image to a device and verifies the write pub async fn burn_image( image_path: &Path, @@ -32,19 +208,44 @@ pub async fn burn_image( crate::debug::log(&format!("Image: {:?}", image_path)); crate::debug::log(&format!("Device: {}", device_path)); - // Get image size - for .gz files, we need to determine decompressed size + // Get image size - for compressed files, we need the decompressed size let compressed_size = tokio::fs::metadata(image_path) .await .map_err(|e| format!("Failed to get image size: {}", e))? .len(); - // Check if file is gzipped - let is_gzipped = image_path.extension() - .and_then(|s| s.to_str()) - .map(|s| s.eq_ignore_ascii_case("gz")) - .unwrap_or(false); + let compression = detect_compression(image_path); + + let image_size = if compression == ImageCompression::Zip { + crate::debug::log(&format!("Compressed size: {} bytes ({:.2} GB)", compressed_size, compressed_size as f64 / 1_073_741_824.0)); + + // Zip records the uncompressed size in its headers, so unlike .gz this + // needs no pre-scan of the whole file. + let decompressed_size = tokio::task::spawn_blocking({ + let image_path = image_path.to_path_buf(); + move || -> Result { + let file = std::fs::File::open(&image_path) + .map_err(|e| format!("Failed to open image for size check: {}", e))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| format!("Failed to read zip image: {}", e))?; + if archive.is_empty() { + return Err("Zip image contains no files".to_string()); + } + let entry = archive + .by_index(0) + .map_err(|e| format!("Failed to open first zip entry: {}", e))?; + Ok(entry.size()) + } + }).await + .map_err(|e| format!("Size lookup task failed: {}", e))??; + + if decompressed_size == 0 { + return Err("Zip image reports an uncompressed size of zero".to_string()); + } - let image_size = if is_gzipped { + crate::debug::log(&format!("Decompressed size: {} bytes ({:.2} GB)", decompressed_size, decompressed_size as f64 / 1_073_741_824.0)); + decompressed_size + } else if compression == ImageCompression::Gzip { crate::debug::log(&format!("Compressed size: {} bytes ({:.2} GB)", compressed_size, compressed_size as f64 / 1_073_741_824.0)); crate::debug::log("Pre-scanning .gz file to determine decompressed size..."); @@ -392,26 +593,13 @@ async fn burn_image_windows( crate::debug::log("File pointer reset, beginning image write..."); - // Check if file is gzipped and create appropriate reader - let is_gzipped = image_path.extension() - .and_then(|s| s.to_str()) - .map(|s| s.eq_ignore_ascii_case("gz")) - .unwrap_or(false); - - let file = std::fs::File::open(&image_path) + let mut image_reader = open_image_reader(&image_path) .map_err(|e| { unsafe { let _ = CloseHandle(handle); } cleanup_volumes(&volume_handles); - format!("Failed to open image file: {}", e) + e })?; - let mut image_reader: Box = if is_gzipped { - crate::debug::log("Detected .gz file, decompressing on-the-fly during burn"); - Box::new(GzDecoder::new(file)) - } else { - Box::new(file) - }; - // Windows requires 512-byte sector-aligned writes for physical drives (SECTOR_SIZE already defined above) // Allocate buffers: read buffer for decompression, sector buffer for aligned writes let mut read_buffer = vec![0u8; CHUNK_SIZE]; @@ -611,21 +799,7 @@ async fn burn_image_linux( .open(&device_path) .map_err(|e| format!("Failed to open device {}: {}. Are you running with sudo/root?", device_path, e))?; - // Check if file is gzipped and create appropriate reader - let is_gzipped = image_path.extension() - .and_then(|s| s.to_str()) - .map(|s| s.eq_ignore_ascii_case("gz")) - .unwrap_or(false); - - let file = std::fs::File::open(&image_path) - .map_err(|e| format!("Failed to open image file: {}", e))?; - - let mut image_reader: Box = if is_gzipped { - crate::debug::log("Detected .gz file, decompressing on-the-fly during burn"); - Box::new(GzDecoder::new(file)) - } else { - Box::new(file) - }; + let mut image_reader = open_image_reader(&image_path)?; let mut buffer = vec![0u8; CHUNK_SIZE]; let mut total_written = 0u64; @@ -777,21 +951,7 @@ async fn burn_image_macos( crate::debug::log("Ready to write image"); - // Check if file is gzipped and create appropriate reader - let is_gzipped = image_path.extension() - .and_then(|s| s.to_str()) - .map(|s| s.eq_ignore_ascii_case("gz")) - .unwrap_or(false); - - let file = std::fs::File::open(&image_path) - .map_err(|e| format!("Failed to open image file: {}", e))?; - - let mut image_reader: Box = if is_gzipped { - crate::debug::log("Detected .gz file, decompressing on-the-fly during burn"); - Box::new(GzDecoder::new(file)) - } else { - Box::new(file) - }; + let mut image_reader = open_image_reader(&image_path)?; // macOS raw devices with F_NOCACHE require sector-aligned writes // Use similar buffering approach as Windows implementation @@ -886,7 +1046,7 @@ async fn verify_image( ) -> Result<(), String> { crate::debug::log("Computing image hash..."); - // Compute hash of original image (decompress if .gz) + // Compute hash of original image (decompressing .gz/.zip as needed) let image_hash = tokio::task::spawn_blocking({ let image_path = image_path.to_path_buf(); let cancel_token = cancel_token.clone(); @@ -894,21 +1054,7 @@ async fn verify_image( move || -> Result { use std::io::Read; - // Check if file is gzipped and create appropriate reader - let is_gzipped = image_path.extension() - .and_then(|s| s.to_str()) - .map(|s| s.eq_ignore_ascii_case("gz")) - .unwrap_or(false); - - let file = std::fs::File::open(&image_path) - .map_err(|e| format!("Failed to open image for verification: {}", e))?; - - let mut image_reader: Box = if is_gzipped { - crate::debug::log("Decompressing .gz file for hash verification"); - Box::new(GzDecoder::new(file)) - } else { - Box::new(file) - }; + let mut image_reader = open_image_reader(&image_path)?; let mut hasher = Sha256::new(); let mut buffer = vec![0u8; CHUNK_SIZE]; diff --git a/src/config.rs b/src/config.rs index d39e3c1..8b3708f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -292,6 +292,26 @@ pub const SPRUCE_UPDATE_DELETE_PATHS: &[&str] = &[ "README.md", ]; +/// Device name mappings for BaseOS release assets. +/// +/// Assets are named `baseos--.img.zip`. Matching is a plain +/// substring test against the filename and the FIRST match wins, so more +/// specific patterns must come first — `-rg34xx` would otherwise swallow +/// `-rg34xxsp`. The leading dash keeps patterns anchored to the device field. +pub const BASEOS_DEVICE_MAPPINGS: &[AssetDisplayMapping] = &[ + AssetDisplayMapping { pattern: "-rg35xxplus", display_name: "RG35XX Plus", devices: "Anbernic RG35XX Plus" }, + AssetDisplayMapping { pattern: "-rg35xxpro", display_name: "RG35XX Pro", devices: "Anbernic RG35XX Pro" }, + AssetDisplayMapping { pattern: "-rg35xxsp", display_name: "RG35XX SP", devices: "Anbernic RG35XX SP" }, + AssetDisplayMapping { pattern: "-rg35xxh", display_name: "RG35XX H", devices: "Anbernic RG35XX H" }, + AssetDisplayMapping { pattern: "-rg34xxsp", display_name: "RG34XX SP", devices: "Anbernic RG34XX SP" }, + AssetDisplayMapping { pattern: "-rg34xx", display_name: "RG34XX", devices: "Anbernic RG34XX" }, + AssetDisplayMapping { pattern: "-rg40xxh", display_name: "RG40XX H", devices: "Anbernic RG40XX H" }, + AssetDisplayMapping { pattern: "-rg40xxv", display_name: "RG40XX V", devices: "Anbernic RG40XX V" }, + AssetDisplayMapping { pattern: "-rgcubexx", display_name: "RG CubeXX", devices: "Anbernic RG CubeXX" }, + AssetDisplayMapping { pattern: "-rg28xx", display_name: "RG28XX", devices: "Anbernic RG28XX" }, + AssetDisplayMapping { pattern: "-rgsp", display_name: "RG SP", devices: "Anbernic RG SP" }, +]; + pub const REPO_OPTIONS: &[RepoOption] = &[ RepoOption { name: "Stable", @@ -315,17 +335,6 @@ pub const REPO_OPTIONS: &[RepoOption] = &[ asset_display_mappings: None, supports_preserve_mode: true, }, - RepoOption { - name: "SprigUI", - url: "spruceUI/sprigUI", - info: "SpruceOS for the Miyoo Mini Flip.", - display_name: None, // Falls back to "SprigUI" - supports_update_mode: true, // Archive-based (.7z) - update_directories: &["Retroarch", "spruce"], - allowed_extensions: Some(&[".7z"]), // Only show 7z archives - asset_display_mappings: None, - supports_preserve_mode: false, - }, RepoOption { name: "TwigUI", url: "spruceUI/twigUI", @@ -337,6 +346,17 @@ pub const REPO_OPTIONS: &[RepoOption] = &[ asset_display_mappings: None, supports_preserve_mode: false, }, + RepoOption { + name: "BaseOS", + url: "pvaibhav/BaseOS", + info: "A minimal base OS for Anbernic RG XX devices. 3 second boot time.\nA third-party project by pvaibhav - not a spruceOS release.\nRaw disk image; erases the entire card.\n[Project page](https://github.com/pvaibhav/BaseOS)", + display_name: Some("BaseOS"), + supports_update_mode: false, // Raw disk images always do a full burn + update_directories: &[], // Not used for raw images + allowed_extensions: Some(&[".img.zip"]), // Excludes .bosupd update packages + asset_display_mappings: Some(BASEOS_DEVICE_MAPPINGS), + supports_preserve_mode: false, + }, ]; /// Index of the default repository selection (0 = first option)