diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml
new file mode 100644
index 0000000..a8fca44
--- /dev/null
+++ b/.github/workflows/build-desktop.yml
@@ -0,0 +1,44 @@
+name: Build Desktop Installer
+
+on:
+ push:
+ paths:
+ - 'desktop/**'
+ - 'source/deletion_lists_*.h'
+ - 'tools/generate_deletion_lists.py'
+ - '.github/workflows/build-desktop.yml'
+ workflow_dispatch:
+
+jobs:
+ build:
+ name: Build (${{ matrix.rid }})
+ runs-on: windows-latest
+ strategy:
+ matrix:
+ rid: [win-x64, win-arm64]
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Generate deletion lists
+ run: python tools/generate_deletion_lists.py
+
+ - name: Publish
+ run: >
+ dotnet publish desktop/OmniNX.DesktopInstaller/OmniNX.DesktopInstaller.csproj
+ -c Release
+ -r ${{ matrix.rid }}
+ -p:SelfContained=false
+ -p:PublishSingleFile=true
+ -o desktop/out/${{ matrix.rid }}
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: OmniNX-Desktop-Installer-${{ matrix.rid }}
+ path: desktop/out/${{ matrix.rid }}/OmniNX-Desktop-Installer.exe
diff --git a/.gitignore b/.gitignore
index 4c4d784..a862fca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,8 @@ Thumbs.db
# Vendored TegraExplorer tree
source/TegraExplorer/
+# Desktop installer (.NET)
+desktop/**/bin/
+desktop/**/obj/
+desktop/out/
+
diff --git a/desktop/OmniNX.DesktopInstaller.sln b/desktop/OmniNX.DesktopInstaller.sln
new file mode 100644
index 0000000..71f8e9b
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller.sln
@@ -0,0 +1,18 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmniNX.DesktopInstaller", "OmniNX.DesktopInstaller\OmniNX.DesktopInstaller.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/desktop/OmniNX.DesktopInstaller/Assets/app.ico b/desktop/OmniNX.DesktopInstaller/Assets/app.ico
new file mode 100644
index 0000000..c1db331
Binary files /dev/null and b/desktop/OmniNX.DesktopInstaller/Assets/app.ico differ
diff --git a/desktop/OmniNX.DesktopInstaller/Assets/logo-128.png b/desktop/OmniNX.DesktopInstaller/Assets/logo-128.png
new file mode 100644
index 0000000..a14362b
Binary files /dev/null and b/desktop/OmniNX.DesktopInstaller/Assets/logo-128.png differ
diff --git a/desktop/OmniNX.DesktopInstaller/Data/DeletionLists.Generated.cs b/desktop/OmniNX.DesktopInstaller/Data/DeletionLists.Generated.cs
new file mode 100644
index 0000000..ce98b8d
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Data/DeletionLists.Generated.cs
@@ -0,0 +1,410 @@
+//
+// Generated by tools/generate_deletion_lists.py — do not edit manually.
+
+namespace OmniNX.DesktopInstaller.Data;
+
+public static partial class DeletionLists
+{
+ public static class Clean
+ {
+ public static readonly string[] clean_atmosphere_dirs_to_delete =
+ {
+ @"atmosphere",
+ };
+
+ public static readonly string[] clean_atmosphere_contents_dirs_to_delete =
+ {
+ };
+
+ public static readonly string[] clean_atmosphere_files_to_delete =
+ {
+ };
+
+ public static readonly string[] clean_bootloader_dirs_to_delete =
+ {
+ @"bootloader\boot",
+ @"bootloader\bootlogo",
+ @"bootloader\ini2",
+ @"bootloader\payloads",
+ @"bootloader\reboot",
+ @"bootloader\res",
+ @"bootloader\sys",
+ };
+
+ public static readonly string[] clean_bootloader_files_to_delete =
+ {
+ @"bootloader\ArgonNX.bin",
+ @"bootloader\bootlogo.bmp",
+ @"bootloader\hekate_ipl.ini",
+ @"bootloader\patches.ini",
+ @"bootloader\update.bin",
+ @"bootloader\ini\EmuMMC ohne Mods.ini",
+ };
+
+ public static readonly string[] clean_config_dirs_to_delete =
+ {
+ @"config\aio-switch-updater",
+ @"config\blue_pack_updater",
+ @"config\kefir-updater",
+ @"config\nx-hbmenu",
+ @"config\sys-con",
+ @"config\sys-patch",
+ @"config\uberhand",
+ @"config\ultrahand",
+ };
+
+ public static readonly string[] clean_switch_dirs_to_delete =
+ {
+ @"switch",
+ };
+
+ public static readonly string[] clean_switch_files_to_delete =
+ {
+ };
+
+ public static readonly string[] clean_root_files_to_delete =
+ {
+ @"boot.dat",
+ @"boot.ini",
+ @"exosphere.bin",
+ @"exosphere.ini",
+ @"hbmenu.nro",
+ @"install.bat",
+ @"license",
+ @"loader.bin",
+ @"mc-mitm.log",
+ @"payload.bin",
+ @"update.bin",
+ @"version",
+ };
+
+ public static readonly string[] clean_misc_dirs_to_delete =
+ {
+ @"argon",
+ @"games",
+ @"NSPs (Tools)",
+ @"Patched Apps",
+ @"SaltySD\flags",
+ @"SaltySD\patches",
+ @"scripts",
+ @"TegraExplorer",
+ @"themes\systemData",
+ @"tools",
+ @"warmboot_mariko",
+ };
+
+ public static readonly string[] clean_misc_files_to_delete =
+ {
+ @"fusee-primary.bin",
+ @"fusee.bin",
+ @"SaltySD\exceptions.txt",
+ @"SaltySD\saltysd_bootstrap.elf",
+ @"SaltySD\saltysd_bootstrap32_3k.elf",
+ @"SaltySD\saltysd_bootstrap32_5k.elf",
+ @"SaltySD\saltysd_core.elf",
+ @"SaltySD\saltysd_core32.elf",
+ };
+
+ public static readonly string[] clean_post_restore_dirs_to_delete =
+ {
+ @"switch\tinfoil\db",
+ };
+
+ public static readonly string[] old_version_files_to_delete =
+ {
+ @"1.4.0-pre",
+ @"1.4.0-pre-c",
+ @"1.4.0-pre-d",
+ @"1.4.1",
+ @"1.5.0",
+ @"1.6.0",
+ @"1.6.1",
+ @"1.6.2",
+ };
+
+ }
+
+ public static class Update
+ {
+ public static readonly string[] atmosphere_dirs_to_delete =
+ {
+ @"atmosphere\config",
+ @"atmosphere\crash_reports",
+ @"atmosphere\erpt_reports",
+ @"atmosphere\exefs_patches\audio_mastervolume",
+ @"atmosphere\exefs_patches\CrunchPatch",
+ @"atmosphere\exefs_patches\SaltyNX_Fixes",
+ @"atmosphere\exefs_patches\Crunchyroll Patch 1.10.0",
+ @"atmosphere\exefs_patches\bluetooth_patches",
+ @"atmosphere\exefs_patches\bootlogo",
+ @"atmosphere\exefs_patches\btm_patches",
+ @"atmosphere\exefs_patches\es_patches",
+ @"atmosphere\exefs_patches\hid_patches",
+ @"atmosphere\exefs_patches\logo",
+ @"atmosphere\exefs_patches\nfim_ctest",
+ @"atmosphere\exefs_patches\nim_ctest",
+ @"atmosphere\exefs_patches\nvnflinger_cmu",
+ @"atmosphere\extrazz",
+ @"atmosphere\fatal_errors",
+ @"atmosphere\fatal_reports",
+ @"atmosphere\flags",
+ @"atmosphere\hbl_html",
+ @"atmosphere\hosts",
+ @"atmosphere\kips",
+ @"atmosphere\kip1",
+ @"atmosphere\kip_patches",
+ @"atmosphere\logs",
+ };
+
+ public static readonly string[] atmosphere_contents_dirs_to_delete =
+ {
+ @"atmosphere\contents\0000000000534C56",
+ @"atmosphere\contents\00FF0000B378D640",
+ @"atmosphere\contents\00FF0000636C6BFF",
+ @"atmosphere\contents\00FF0000A53BB665",
+ @"atmosphere\contents\0100000000000008",
+ @"atmosphere\contents\010000000000000D",
+ @"atmosphere\contents\010000000000002B",
+ @"atmosphere\contents\0100000000000032",
+ @"atmosphere\contents\0100000000000034",
+ @"atmosphere\contents\0100000000000036",
+ @"atmosphere\contents\0100000000000037",
+ @"atmosphere\contents\010000000000003C",
+ @"atmosphere\contents\0100000000000042",
+ @"atmosphere\contents\0100000000000895",
+ @"atmosphere\contents\0100000000000F12",
+ @"atmosphere\contents\0100000000001000",
+ @"atmosphere\contents\0100000000001007",
+ @"atmosphere\contents\0100000000001013",
+ @"atmosphere\contents\010000000000DA7A",
+ @"atmosphere\contents\010000000000bd00",
+ @"atmosphere\contents\01006a800016e000",
+ @"atmosphere\contents\01009D901BC56000",
+ @"atmosphere\contents\0100A3900C3E2000",
+ @"atmosphere\contents\0100F43008C44000",
+ @"atmosphere\contents\050000BADDAD0000",
+ @"atmosphere\contents\4200000000000000",
+ @"atmosphere\contents\420000000000000B",
+ @"atmosphere\contents\420000000000000E",
+ @"atmosphere\contents\4200000000000010",
+ @"atmosphere\contents\4200000000000FFF",
+ @"atmosphere\contents\420000000007E51A",
+ @"atmosphere\contents\420000000007E51B",
+ @"atmosphere\contents\690000000000000D",
+ };
+
+ public static readonly string[] atmosphere_files_to_delete =
+ {
+ @"atmosphere\config\exosphere.ini",
+ @"atmosphere\config\stratosphere.ini",
+ @"atmosphere\hbl.nsp",
+ @"atmosphere\package3",
+ @"atmosphere\reboot_payload.bin",
+ @"atmosphere\stratosphere.romfs",
+ @"atmosphere\kips\loader.kip",
+ @"atmosphere\kips\hoc.kip",
+ };
+
+ public static readonly string[] bootloader_dirs_to_delete =
+ {
+ @"bootloader\boot",
+ @"bootloader\bootlogo",
+ @"bootloader\ini2",
+ @"bootloader\payloads",
+ @"bootloader\reboot",
+ @"bootloader\res",
+ @"bootloader\sys",
+ };
+
+ public static readonly string[] bootloader_files_to_delete =
+ {
+ @"bootloader\ArgonNX.bin",
+ @"bootloader\bootlogo.bmp",
+ @"bootloader\hekate_ipl.ini",
+ @"bootloader\nyx.ini",
+ @"bootloader\patches.ini",
+ @"bootloader\update.bin",
+ @"bootloader\ini\EmuMMC ohne Mods.ini",
+ };
+
+ public static readonly string[] config_dirs_to_delete =
+ {
+ @"config\aio-switch-updater",
+ @"config\blue_pack_updater",
+ @"config\kefir-updater",
+ @"config\nx-hbmenu",
+ @"config\quickntp",
+ @"config\sys-con",
+ @"config\sys-patch",
+ @"config\uberhand",
+ @"config\ultrahand",
+ };
+
+ public static readonly string[] switch_dirs_to_delete =
+ {
+ @"switch\.packages",
+ @"switch\.overlays",
+ @"switch\90DNS_tester",
+ @"switch\aio-switch-updater",
+ @"switch\amsPLUS-downloader",
+ @"switch\appstore",
+ @"switch\AtmoXL-Titel-Installer",
+ @"switch\Benchmark-Toolbox",
+ @"switch\breeze",
+ @"switch\cheats-updater",
+ @"switch\chiaki",
+ @"switch\ChoiDujourNX",
+ @"switch\crash_ams",
+ @"switch\Daybreak",
+ @"switch\DNS-Block_Tester",
+ @"switch\DNS_mitm Tester",
+ @"switch\EdiZon",
+ @"switch\Fizeau",
+ @"switch\FTPD",
+ @"switch\Furmark-NX",
+ @"switch\fw-downloader",
+ @"switch\gamecard_installer",
+ @"switch\Goldleaf",
+ @"switch\haze",
+ @"switch\JKSV",
+ @"switch\kefir-updater",
+ @"switch\ldnmitm_config",
+ @"switch\Linkalho",
+ @"switch\Moonlight-Switch",
+ @"switch\Neumann",
+ @"switch\NX-Activity-Log",
+ @"switch\NX-Save-Sync",
+ @"switch\NX-Shell",
+ @"switch\NX-Update-Checker ",
+ @"switch\NXGallery",
+ @"switch\NXRemoteLauncher",
+ @"switch\NXThemesInstaller",
+ @"switch\nxdumptool",
+ @"switch\nxmtp",
+ @"switch\Payload_launcher",
+ @"switch\Reboot",
+ @"switch\reboot_to_argonNX",
+ @"switch\reboot_to_hekate",
+ @"switch\Shutdown_System",
+ @"switch\SimpleModAlchemist",
+ @"switch\SimpleModDownloader",
+ @"switch\SimpleModManager",
+ @"switch\sphaira",
+ @"switch\studious-pancake",
+ @"switch\Switch-Time",
+ @"switch\SwitchIdent",
+ @"switch\Switch_themes_Installer",
+ @"switch\swr-ini-tool",
+ @"switch\Sys-Clk Manager",
+ @"switch\Sys-Con",
+ @"switch\sys-clk-manager",
+ @"switch\themezer-nx",
+ @"switch\ThemezerNX",
+ @"switch\themezernx",
+ @"switch\tinwoo",
+ @"switch\Ultrahand-Reload",
+ };
+
+ public static readonly string[] switch_files_to_delete =
+ {
+ @"switch\90DNS_tester\90DNS_tester.nro",
+ @"switch\Benchmark-Toolbox\Benchmark-Toolbox.nro",
+ @"switch\breeze.nro",
+ @"switch\cheats-updater.nro",
+ @"switch\chiaki.nro",
+ @"switch\ChoiDujourNX.nro",
+ @"switch\daybreak.nro",
+ @"switch\DBI.nro",
+ @"switch\DBI\DBI.nro",
+ @"switch\DBI\DBI_810_DE.nro",
+ @"switch\DBI\DBI_810_EN.nro",
+ @"switch\DBI\DBI_845_DE.nro",
+ @"switch\DBI\DBI_845_EN.nro",
+ @"switch\DBI\DBI_849_DE.nro",
+ @"switch\DBI\DBI_849_EN.nro",
+ @"switch\DBI_810_DE\DBI_810.nro",
+ @"switch\DBI_810_DE\DBI_810_DE.nro",
+ @"switch\DBI_810_EN\DBI_810_EN.nro",
+ @"switch\DBI_RU\DBI_RU.nro",
+ @"switch\DBI\DBI_EN.nro",
+ @"switch\DBI_DE\DBI_DE.nro",
+ @"switch\DNS-Block_Tester\dns_tester.nro",
+ @"switch\DNS_mitm Tester.nro",
+ @"switch\EdiZon.nro",
+ @"switch\Fizeau.nro",
+ @"switch\Furmark-NX\Furmark-NX.nro",
+ @"switch\Goldleaf.nro",
+ @"switch\haze.nro",
+ @"switch\JKSV.nro",
+ @"switch\ldnmitm_config.nro",
+ @"switch\linkalho.nro",
+ @"switch\Moonlight-Switch.nro",
+ @"switch\Neumann.nro",
+ @"switch\NX-Shell.nro",
+ @"switch\NXGallery.nro",
+ @"switch\NXThemesInstaller.nro",
+ @"switch\nxdumptool.nro",
+ @"switch\nxtc.bin",
+ @"switch\reboot_to_payload.nro",
+ @"switch\SimpleModAlchemist\Simple_Mod_Alchemist.nro",
+ @"switch\SimpleModDownloader.nro",
+ @"switch\SimpleModManager.nro",
+ @"switch\sphaira.nro",
+ @"switch\swr-ini-tool\swr-ini-tool.nro",
+ @"switch\SwitchIdent.nro",
+ @"switch\Switch_themes_Installer\NXThemesInstaller.nro",
+ @"switch\Sys-Clk Manager\sys-clk-manager.nro",
+ @"switch\Sys-Con.nro",
+ @"switch\sys-clk-manager.nro",
+ @"switch\ThemezerNX\themezer-nx.nro",
+ @"switch\tinfoil.nro",
+ @"switch\tinfoil\tinfoil.nro",
+ @"switch\tinwoo.nro",
+ @"switch\tinwoo\tinwoo.nro",
+ @"switch\Ultrahand-Reload\Ultrahand-Reload.nro",
+ };
+
+ public static readonly string[] root_files_to_delete =
+ {
+ @"boot.dat",
+ @"boot.ini",
+ @"exosphere.bin",
+ @"exosphere.ini",
+ @"hbmenu.nro",
+ @"install.bat",
+ @"license",
+ @"loader.bin",
+ @"mc-mitm.log",
+ @"payload.bin",
+ @"update.bin",
+ @"version",
+ };
+
+ public static readonly string[] misc_dirs_to_delete =
+ {
+ @"argon",
+ @"games",
+ @"NSPs (Tools)",
+ @"Patched Apps",
+ @"SaltySD\flags",
+ @"scripts",
+ @"switch\tinfoil\db",
+ @"themes\systemData",
+ @"tools",
+ @"warmboot_mariko",
+ };
+
+ public static readonly string[] misc_files_to_delete =
+ {
+ @"fusee-primary.bin",
+ @"fusee.bin",
+ @"SaltySD\exceptions.txt",
+ @"SaltySD\saltysd_bootstrap.elf",
+ @"SaltySD\saltysd_bootstrap32_3k.elf",
+ @"SaltySD\saltysd_bootstrap32_5k.elf",
+ @"SaltySD\saltysd_core.elf",
+ @"SaltySD\saltysd_core32.elf",
+ };
+
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/MainForm.cs b/desktop/OmniNX.DesktopInstaller/MainForm.cs
new file mode 100644
index 0000000..191b5bf
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/MainForm.cs
@@ -0,0 +1,581 @@
+using OmniNX.DesktopInstaller.Models;
+using OmniNX.DesktopInstaller.Services;
+using System.Runtime.InteropServices;
+
+namespace OmniNX.DesktopInstaller;
+
+public sealed class MainForm : Form
+{
+ private readonly GiteaClient _gitea = new();
+ private readonly VersionDetectService _versionDetect = new();
+ private readonly InstallEngine _installEngine = new();
+
+ private ComboBox _driveCombo = null!;
+ private Button _refreshDrivesButton = null!;
+ private Label _statusLabel = null!;
+ private Label _releaseLabel = null!;
+ private Button _checkReleaseButton = null!;
+ private RadioButton _variantLight = null!;
+ private RadioButton _variantStandard = null!;
+ private RadioButton _variantOc = null!;
+ private RadioButton _ram4Gb = null!;
+ private RadioButton _ram8Gb = null!;
+ private RadioButton _hekateStandard = null!;
+ private RadioButton _hekatePro = null!;
+ private CheckBox _forceCleanCheck = null!;
+ private Button _installButton = null!;
+ private ProgressBar _progressBar = null!;
+ private TextBox _logBox = null!;
+
+ private ReleaseInfo? _latestRelease;
+ private CancellationTokenSource? _operationCts;
+
+ public MainForm()
+ {
+ Text = "OmniNX Desktop Installer";
+ MinimumSize = new Size(820, 640);
+ Size = new Size(900, 700);
+ StartPosition = FormStartPosition.CenterScreen;
+ Font = new Font("Segoe UI", 9F);
+ Icon = LoadFormIcon() ?? Icon.ExtractAssociatedIcon(Application.ExecutablePath);
+
+ BuildLayout();
+ RefreshDrives();
+ _ = LoadLatestReleaseAsync();
+ }
+
+ private void BuildLayout()
+ {
+ var root = new TableLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ ColumnCount = 1,
+ RowCount = 8,
+ Padding = new Padding(12),
+ };
+ root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+
+ root.Controls.Add(BuildHeaderPanel(), 0, 0);
+ root.Controls.Add(BuildDrivePanel(), 0, 1);
+ root.Controls.Add(BuildVariantPanel(), 0, 2);
+ root.Controls.Add(BuildRamPanel(), 0, 3);
+ root.Controls.Add(BuildHekatePanel(), 0, 4);
+ root.Controls.Add(BuildOptionsPanel(), 0, 5);
+ root.Controls.Add(BuildActionPanel(), 0, 6);
+ root.Controls.Add(BuildLogPanel(), 0, 7);
+
+ Controls.Add(root);
+ }
+
+ private Control BuildHeaderPanel()
+ {
+ var panel = new TableLayoutPanel
+ {
+ AutoSize = true,
+ Dock = DockStyle.Fill,
+ ColumnCount = 1,
+ RowCount = 2,
+ };
+ panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+
+ var title = new Label
+ {
+ Text = "OmniNX Desktop Installer",
+ Font = new Font(Font.FontFamily, 14F, FontStyle.Bold),
+ AutoSize = true,
+ Anchor = AnchorStyles.Left,
+ Margin = new Padding(0, 0, 16, 4),
+ };
+
+ _releaseLabel = new Label
+ {
+ AutoSize = true,
+ Text = "Release: checking...",
+ Margin = new Padding(0, 0, 8, 8),
+ Anchor = AnchorStyles.Left,
+ };
+ _checkReleaseButton = new Button { Text = "Refresh release", AutoSize = true, Margin = new Padding(0, 0, 0, 8) };
+ _checkReleaseButton.Click += async (_, _) => await LoadLatestReleaseAsync();
+
+ var releaseRow = new FlowLayoutPanel
+ {
+ AutoSize = true,
+ WrapContents = false,
+ Dock = DockStyle.Fill,
+ Margin = new Padding(0),
+ };
+ releaseRow.Controls.Add(_releaseLabel);
+ releaseRow.Controls.Add(_checkReleaseButton);
+
+ panel.Controls.Add(title, 0, 0);
+ panel.Controls.Add(releaseRow, 0, 1);
+ return panel;
+ }
+
+ private static Image? LoadEmbeddedImage(string resourceName)
+ {
+ var stream = typeof(MainForm).Assembly.GetManifestResourceStream(resourceName);
+ return stream is null ? null : Image.FromStream(stream);
+ }
+
+ private static Icon? LoadFormIcon()
+ {
+ using var image = LoadEmbeddedImage("OmniNX.DesktopInstaller.Assets.logo-128.png");
+ if (image is null)
+ return null;
+
+ using var bitmap = new Bitmap(image, new Size(32, 32));
+ var handle = bitmap.GetHicon();
+ try
+ {
+ using var temp = Icon.FromHandle(handle);
+ return (Icon)temp.Clone();
+ }
+ finally
+ {
+ DestroyIcon(handle);
+ }
+ }
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern bool DestroyIcon(IntPtr hIcon);
+
+ private Control BuildDrivePanel()
+ {
+ var group = new GroupBox { Text = "SD Card / Target Drive", Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(10) };
+ var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, AutoSize = true };
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 90));
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+
+ _driveCombo = new ComboBox { Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList };
+ _driveCombo.SelectedIndexChanged += (_, _) => UpdateInstallationStatus();
+
+ _refreshDrivesButton = new Button { Text = "Refresh", AutoSize = true };
+ _refreshDrivesButton.Click += (_, _) => RefreshDrives();
+
+ _statusLabel = new Label
+ {
+ Dock = DockStyle.Fill,
+ AutoSize = true,
+ ForeColor = Color.DimGray,
+ Text = "Select a removable drive (SD card reader).",
+ };
+
+ layout.Controls.Add(new Label { Text = "Drive:", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 0);
+ layout.Controls.Add(_driveCombo, 1, 0);
+ layout.Controls.Add(_refreshDrivesButton, 2, 0);
+ layout.SetColumnSpan(_statusLabel, 3);
+ layout.Controls.Add(_statusLabel, 0, 1);
+
+ group.Controls.Add(layout);
+ return group;
+ }
+
+ private Control BuildVariantPanel()
+ {
+ var group = new GroupBox { Text = "OmniNX Pack Variant", Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(10) };
+ var flow = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true };
+
+ _variantLight = new RadioButton { Text = "Light", AutoSize = true, Margin = new Padding(0, 0, 24, 0) };
+ _variantStandard = new RadioButton { Text = "Standard", AutoSize = true, Checked = true, Margin = new Padding(0, 0, 24, 0) };
+ _variantOc = new RadioButton { Text = "OC", AutoSize = true };
+
+ flow.Controls.AddRange([_variantLight, _variantStandard, _variantOc]);
+ group.Controls.Add(flow);
+ return group;
+ }
+
+ private Control BuildRamPanel()
+ {
+ var group = new GroupBox { Text = "RAM / Hekate Payload", Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(10) };
+ var layout = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, ColumnCount = 1 };
+
+ _ram4Gb = new RadioButton
+ {
+ Text = "4 GB console (default)",
+ AutoSize = true,
+ Checked = true,
+ };
+ _ram8Gb = new RadioButton
+ {
+ Text = "8 GB console (RAM mod) — copies 8GB hekate payload to payload.bin and bootloader/update.bin",
+ AutoSize = true,
+ };
+
+ var warn = new Label
+ {
+ Text = "Warning: Only select 8 GB if the console has an 8 GB RAM mod. Wrong selection can cause boot issues.",
+ AutoSize = true,
+ ForeColor = Color.DarkOrange,
+ MaximumSize = new Size(820, 0),
+ Margin = new Padding(0, 6, 0, 0),
+ };
+
+ layout.Controls.Add(_ram4Gb);
+ layout.Controls.Add(_ram8Gb);
+ layout.Controls.Add(warn);
+ group.Controls.Add(layout);
+ return group;
+ }
+
+ private Control BuildHekatePanel()
+ {
+ var group = new GroupBox { Text = "Hekate / Atmosphere", Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(10) };
+ var layout = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, ColumnCount = 1 };
+
+ _hekateStandard = new RadioButton
+ {
+ Text = "Standard Hekate / Atmosphere",
+ AutoSize = true,
+ Checked = true,
+ };
+ _hekatePro = new RadioButton
+ {
+ Text = "Hekate-Pro / Atmosphere-Pro (only for emuMMC on eMMC)",
+ AutoSize = true,
+ };
+
+ var hint = new Label
+ {
+ Text = "Pro variant replaces bootloader/atmosphere files from extras/hekate-ams-pro/.",
+ AutoSize = true,
+ ForeColor = Color.DimGray,
+ Margin = new Padding(0, 6, 0, 0),
+ };
+
+ layout.Controls.Add(_hekateStandard);
+ layout.Controls.Add(_hekatePro);
+ layout.Controls.Add(hint);
+ group.Controls.Add(layout);
+ return group;
+ }
+
+ private Control BuildOptionsPanel()
+ {
+ _forceCleanCheck = new CheckBox
+ {
+ Text = "Force clean install (full wipe with backup/restore) even if OmniNX is already installed",
+ AutoSize = true,
+ MaximumSize = new Size(820, 0),
+ };
+
+ return _forceCleanCheck;
+ }
+
+ private Control BuildActionPanel()
+ {
+ var panel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = false };
+ _installButton = new Button
+ {
+ Text = "Download && Install",
+ AutoSize = true,
+ Height = 36,
+ Font = new Font(Font.FontFamily, 10F, FontStyle.Bold),
+ };
+ _installButton.Click += async (_, _) => await RunInstallAsync();
+
+ _progressBar = new ProgressBar { Width = 500, Height = 24, Margin = new Padding(16, 6, 0, 0) };
+
+ panel.Controls.Add(_installButton);
+ panel.Controls.Add(_progressBar);
+ return panel;
+ }
+
+ private Control BuildLogPanel()
+ {
+ var group = new GroupBox { Text = "Log", Dock = DockStyle.Fill, Padding = new Padding(10) };
+ _logBox = new TextBox
+ {
+ Dock = DockStyle.Fill,
+ Multiline = true,
+ ScrollBars = ScrollBars.Vertical,
+ ReadOnly = true,
+ Font = new Font("Consolas", 9F),
+ BackColor = Color.FromArgb(24, 24, 24),
+ ForeColor = Color.Gainsboro,
+ };
+ group.Controls.Add(_logBox);
+ return group;
+ }
+
+ private void RefreshDrives()
+ {
+ DriveService.DriveItem? selected = _driveCombo.SelectedItem as DriveService.DriveItem;
+ _driveCombo.Items.Clear();
+
+ foreach (var drive in DriveService.GetCandidateDrives())
+ _driveCombo.Items.Add(drive);
+
+ if (selected is not null)
+ {
+ for (var i = 0; i < _driveCombo.Items.Count; i++)
+ {
+ if (_driveCombo.Items[i] is DriveService.DriveItem d &&
+ d.Drive.Name.Equals(selected.Drive.Name, StringComparison.OrdinalIgnoreCase))
+ {
+ _driveCombo.SelectedIndex = i;
+ return;
+ }
+ }
+ }
+
+ if (_driveCombo.Items.Count > 0)
+ _driveCombo.SelectedIndex = 0;
+ else
+ UpdateInstallationStatus();
+ }
+
+ private DriveInfo? SelectedDrive =>
+ (_driveCombo.SelectedItem as DriveService.DriveItem)?.Drive;
+
+ private OmniNxVariant SelectedVariant
+ {
+ get
+ {
+ if (_variantOc.Checked) return OmniNxVariant.Oc;
+ if (_variantLight.Checked) return OmniNxVariant.Light;
+ return OmniNxVariant.Standard;
+ }
+ }
+
+ private void UpdateInstallationStatus()
+ {
+ var drive = SelectedDrive;
+ if (drive is null)
+ {
+ _statusLabel.Text = "No drive selected.";
+ _statusLabel.ForeColor = Color.DimGray;
+ return;
+ }
+
+ try
+ {
+ var status = _versionDetect.DetectInstallation(drive.RootDirectory.FullName);
+ if (status.IsInstalled)
+ {
+ _statusLabel.Text =
+ $"OmniNX detected: {VariantHelper.GetDisplayName(status.Variant)} v{status.Version} — " +
+ (_forceCleanCheck.Checked ? "will clean install" : "will update");
+ _statusLabel.ForeColor = Color.ForestGreen;
+ }
+ else
+ {
+ _statusLabel.Text = "No OmniNX installation detected — will perform clean install.";
+ _statusLabel.ForeColor = Color.SteelBlue;
+ }
+ }
+ catch (Exception ex)
+ {
+ _statusLabel.Text = $"Could not read drive: {ex.Message}";
+ _statusLabel.ForeColor = Color.Firebrick;
+ }
+ }
+
+ private async Task LoadLatestReleaseAsync()
+ {
+ try
+ {
+ SetUiEnabled(false);
+ _releaseLabel.Text = "Release: fetching...";
+ _latestRelease = await _gitea.GetLatestReleaseAsync();
+ _releaseLabel.Text = $"Latest release: {_latestRelease.TagName} ({_latestRelease.Name})";
+ Log($"Latest release: {_latestRelease.TagName}");
+ }
+ catch (Exception ex)
+ {
+ _releaseLabel.Text = "Release: fetch failed";
+ Log($"Failed to fetch release: {ex.Message}", error: true);
+ }
+ finally
+ {
+ SetUiEnabled(true);
+ }
+ }
+
+ private async Task RunInstallAsync()
+ {
+ var drive = SelectedDrive;
+ if (drive is null)
+ {
+ MessageBox.Show(this, "Please select a target drive.", "OmniNX Installer",
+ MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ if (_latestRelease is null)
+ {
+ MessageBox.Show(this, "Release information is not loaded yet. Click 'Refresh release' first.",
+ "OmniNX Installer", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ var variant = SelectedVariant;
+ var asset = _gitea.FindPackAsset(_latestRelease, variant);
+ if (asset is null)
+ {
+ MessageBox.Show(this,
+ $"No zip asset found for {VariantHelper.GetDisplayName(variant)} in release {_latestRelease.TagName}.",
+ "OmniNX Installer", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return;
+ }
+
+ var driveRoot = drive.RootDirectory.FullName;
+ var status = _versionDetect.DetectInstallation(driveRoot);
+ var mode = _versionDetect.DetermineMode(status, _forceCleanCheck.Checked);
+
+ var modeText = mode == InstallMode.Update ? "UPDATE" : "CLEAN INSTALL";
+ var confirm = MessageBox.Show(this,
+ $"Target drive: {drive.Name}\n" +
+ $"Variant: {VariantHelper.GetDisplayName(variant)}\n" +
+ $"Mode: {modeText}\n" +
+ $"Release: {_latestRelease.TagName}\n" +
+ $"RAM: {(_ram8Gb.Checked ? "8 GB" : "4 GB")}\n" +
+ $"Hekate/AMS: {(_hekatePro.Checked ? "Pro" : "Standard")}\n\n" +
+ "All data on paths targeted by the installer may be modified or deleted.\n" +
+ "Continue?",
+ "Confirm installation",
+ MessageBoxButtons.YesNo,
+ MessageBoxIcon.Warning,
+ MessageBoxDefaultButton.Button2);
+
+ if (confirm != DialogResult.Yes)
+ return;
+
+ _operationCts?.Cancel();
+ _operationCts = new CancellationTokenSource();
+ var token = _operationCts.Token;
+
+ try
+ {
+ SetUiEnabled(false);
+ _progressBar.Value = 0;
+ _logBox.Clear();
+
+ var cacheDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "OmniNX-Desktop-Installer",
+ "cache");
+ PathUtils.EnsureDirectory(cacheDir);
+
+ var zipPath = Path.Combine(cacheDir, asset.Name);
+ if (!File.Exists(zipPath))
+ {
+ Log($"Downloading {asset.Name} ({asset.Size / (1024 * 1024)} MB)...");
+ await _gitea.DownloadAssetAsync(asset, zipPath, new Progress<(long, long)>(p =>
+ {
+ var (downloaded, total) = p;
+ if (total > 0)
+ {
+ var pct = (int)(downloaded * 100 / total);
+ _progressBar.Value = Math.Clamp(pct, 0, 100);
+ }
+ }), token);
+ Log("Download complete.");
+ }
+ else
+ {
+ Log($"Using cached pack: {zipPath}");
+ }
+
+ var extractRoot = Path.Combine(cacheDir, "extract", _latestRelease.TagName);
+ var staging = ZipExtractor.ExtractPack(zipPath, extractRoot, new Progress(msg => Log(msg)));
+ Log($"Pack staging: {staging}");
+
+ var options = new InstallOptions
+ {
+ DriveRoot = driveRoot,
+ Variant = variant,
+ Mode = mode,
+ Use8GbRam = _ram8Gb.Checked,
+ UseHekateAmsPro = _hekatePro.Checked,
+ ForceCleanInstall = _forceCleanCheck.Checked,
+ StagingPath = staging,
+ };
+
+ var progress = new Progress(report =>
+ {
+ Log(report.Message, report.IsError);
+ if (report.Percent is int pct)
+ _progressBar.Value = Math.Clamp(pct, 0, 100);
+ });
+
+ await _installEngine.RunAsync(options, progress, token);
+ _progressBar.Value = 100;
+
+ MessageBox.Show(this,
+ mode == InstallMode.Update
+ ? "OmniNX update completed successfully."
+ : "OmniNX installation completed successfully.",
+ "OmniNX Installer",
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Information);
+
+ UpdateInstallationStatus();
+ }
+ catch (OperationCanceledException)
+ {
+ Log("Operation cancelled.", error: true);
+ }
+ catch (Exception ex)
+ {
+ Log($"Installation failed: {ex.Message}", error: true);
+ MessageBox.Show(this, ex.Message, "Installation failed",
+ MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ finally
+ {
+ SetUiEnabled(true);
+ }
+ }
+
+ private void Log(string message, bool error = false)
+ {
+ if (InvokeRequired)
+ {
+ BeginInvoke(() => Log(message, error));
+ return;
+ }
+
+ var prefix = $"[{DateTime.Now:HH:mm:ss}] ";
+ _logBox.AppendText(prefix + message + Environment.NewLine);
+ if (error)
+ _statusLabel.ForeColor = Color.Firebrick;
+ }
+
+ private void SetUiEnabled(bool enabled)
+ {
+ if (InvokeRequired)
+ {
+ BeginInvoke(() => SetUiEnabled(enabled));
+ return;
+ }
+
+ _installButton.Enabled = enabled;
+ _checkReleaseButton.Enabled = enabled;
+ _refreshDrivesButton.Enabled = enabled;
+ _driveCombo.Enabled = enabled;
+ _variantLight.Enabled = enabled;
+ _variantStandard.Enabled = enabled;
+ _variantOc.Enabled = enabled;
+ _ram4Gb.Enabled = enabled;
+ _ram8Gb.Enabled = enabled;
+ _hekateStandard.Enabled = enabled;
+ _hekatePro.Enabled = enabled;
+ _forceCleanCheck.Enabled = enabled;
+ }
+
+ protected override void OnLoad(EventArgs e)
+ {
+ base.OnLoad(e);
+ _forceCleanCheck.CheckedChanged += (_, _) => UpdateInstallationStatus();
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/Models/InstallModels.cs b/desktop/OmniNX.DesktopInstaller/Models/InstallModels.cs
new file mode 100644
index 0000000..6800780
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Models/InstallModels.cs
@@ -0,0 +1,54 @@
+namespace OmniNX.DesktopInstaller.Models;
+
+public enum OmniNxVariant
+{
+ None = 0,
+ Light,
+ Standard,
+ Oc,
+}
+
+public enum InstallMode
+{
+ Clean,
+ Update,
+}
+
+public sealed class InstallOptions
+{
+ public required string DriveRoot { get; init; }
+ public required OmniNxVariant Variant { get; init; }
+ public InstallMode Mode { get; init; }
+ public bool Use8GbRam { get; init; }
+ public bool UseHekateAmsPro { get; init; }
+ public bool ForceCleanInstall { get; init; }
+ public string? StagingPath { get; init; }
+}
+
+public sealed class OmniNxStatus
+{
+ public bool IsInstalled { get; init; }
+ public OmniNxVariant Variant { get; init; }
+ public string Version { get; init; } = string.Empty;
+}
+
+public sealed class ReleaseInfo
+{
+ public required string TagName { get; init; }
+ public required string Name { get; init; }
+ public required IReadOnlyList Assets { get; init; }
+}
+
+public sealed class ReleaseAsset
+{
+ public required string Name { get; init; }
+ public required string DownloadUrl { get; init; }
+ public long Size { get; init; }
+}
+
+public sealed class InstallProgressReport
+{
+ public required string Message { get; init; }
+ public int? Percent { get; init; }
+ public bool IsError { get; init; }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/OmniNX.DesktopInstaller.csproj b/desktop/OmniNX.DesktopInstaller/OmniNX.DesktopInstaller.csproj
new file mode 100644
index 0000000..0ca6f32
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/OmniNX.DesktopInstaller.csproj
@@ -0,0 +1,25 @@
+
+
+
+ WinExe
+ net8.0-windows
+ true
+ enable
+ enable
+ app.manifest
+ Assets\app.ico
+ OmniNX-Desktop-Installer
+ OmniNX.DesktopInstaller
+ 1.0.0
+ OmniNX
+ PC-based OmniNX installer for mass deployments and modchip installers
+ win-x64;win-arm64
+ true
+ false
+
+
+
+
+
+
+
diff --git a/desktop/OmniNX.DesktopInstaller/Program.cs b/desktop/OmniNX.DesktopInstaller/Program.cs
new file mode 100644
index 0000000..c101f6e
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Program.cs
@@ -0,0 +1,11 @@
+namespace OmniNX.DesktopInstaller;
+
+static class Program
+{
+ [STAThread]
+ static void Main()
+ {
+ ApplicationConfiguration.Initialize();
+ Application.Run(new MainForm());
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/Services/BackupService.cs b/desktop/OmniNX.DesktopInstaller/Services/BackupService.cs
new file mode 100644
index 0000000..f390f2d
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Services/BackupService.cs
@@ -0,0 +1,118 @@
+namespace OmniNX.DesktopInstaller.Services;
+
+public sealed class BackupService
+{
+ private const string TempBackup = @"temp_backup";
+ private const string DbiConfig = @"switch\DBI\dbi.config";
+ private const string ProdKeys = @"switch\prod.keys";
+ private const string CyberFoilDir = @"switch\CyberFoil";
+ private const string CheckpointDir = @"switch\Checkpoint";
+
+ private const string HorizonOcConfig = @"config\horizon-oc\config.ini";
+ private const string HorizonOcBackupDir = @".omninx_oc_update";
+
+ public void BackupUserData(string driveRoot, IProgress? progress = null)
+ {
+ var backupRoot = PathUtils.ToDrivePath(driveRoot, TempBackup);
+ PathUtils.EnsureDirectory(backupRoot);
+ progress?.Report("Backing up CyberFoil, Checkpoint, DBI (dbi.config), prod.keys...");
+
+ CopyIfExists(PathUtils.ToDrivePath(driveRoot, DbiConfig),
+ Path.Combine(backupRoot, "dbi.config"));
+
+ CopyIfExists(PathUtils.ToDrivePath(driveRoot, ProdKeys),
+ Path.Combine(backupRoot, "prod.keys"));
+
+ var cyberfoil = PathUtils.ToDrivePath(driveRoot, CyberFoilDir);
+ if (Directory.Exists(cyberfoil))
+ {
+ var dst = Path.Combine(backupRoot, "CyberFoil");
+ PathUtils.EnsureDirectory(dst);
+ CopyIfExists(Path.Combine(cyberfoil, "cyberfoil.nro"), Path.Combine(dst, "cyberfoil.nro"));
+ CopyIfExists(Path.Combine(cyberfoil, "config.json"), Path.Combine(dst, "config.json"));
+ var remotes = Path.Combine(cyberfoil, "remotes");
+ if (Directory.Exists(remotes))
+ PathUtils.CopyDirectory(remotes, Path.Combine(dst, "remotes"));
+ }
+
+ var checkpoint = PathUtils.ToDrivePath(driveRoot, CheckpointDir);
+ if (Directory.Exists(checkpoint))
+ PathUtils.CopyDirectory(checkpoint, Path.Combine(backupRoot, "Checkpoint"));
+ }
+
+ public void RestoreUserData(string driveRoot, IProgress? progress = null)
+ {
+ var backupRoot = PathUtils.ToDrivePath(driveRoot, TempBackup);
+ if (!Directory.Exists(backupRoot))
+ return;
+
+ progress?.Report("Restoring CyberFoil, Checkpoint, DBI (dbi.config), prod.keys...");
+ PathUtils.EnsureDirectory(PathUtils.ToDrivePath(driveRoot, @"switch"));
+
+ CopyIfExists(Path.Combine(backupRoot, "dbi.config"),
+ PathUtils.ToDrivePath(driveRoot, DbiConfig));
+
+ CopyIfExists(Path.Combine(backupRoot, "prod.keys"),
+ PathUtils.ToDrivePath(driveRoot, ProdKeys));
+
+ var cyberBackup = Path.Combine(backupRoot, "CyberFoil");
+ if (Directory.Exists(cyberBackup))
+ {
+ var dst = PathUtils.ToDrivePath(driveRoot, CyberFoilDir);
+ PathUtils.EnsureDirectory(dst);
+ CopyIfExists(Path.Combine(cyberBackup, "cyberfoil.nro"), Path.Combine(dst, "cyberfoil.nro"));
+ CopyIfExists(Path.Combine(cyberBackup, "config.json"), Path.Combine(dst, "config.json"));
+ var remotes = Path.Combine(cyberBackup, "remotes");
+ if (Directory.Exists(remotes))
+ PathUtils.CopyDirectory(remotes, Path.Combine(dst, "remotes"));
+ }
+
+ var checkpointBackup = Path.Combine(backupRoot, "Checkpoint");
+ if (Directory.Exists(checkpointBackup))
+ PathUtils.CopyDirectory(checkpointBackup, PathUtils.ToDrivePath(driveRoot, @"switch"));
+ }
+
+ public void CleanupBackup(string driveRoot)
+ {
+ var backupRoot = PathUtils.ToDrivePath(driveRoot, TempBackup);
+ if (Directory.Exists(backupRoot))
+ PathUtils.DeletePath(backupRoot);
+ }
+
+ public void BackupHorizonOcConfig(string driveRoot, IProgress? progress = null)
+ {
+ var src = PathUtils.ToDrivePath(driveRoot, HorizonOcConfig);
+ if (!File.Exists(src))
+ return;
+
+ progress?.Report("HorizonOC: backing up config.ini...");
+ var dstDir = PathUtils.ToDrivePath(driveRoot, HorizonOcBackupDir);
+ PathUtils.EnsureDirectory(dstDir);
+ PathUtils.CopyFile(src, Path.Combine(dstDir, "config.ini"));
+ }
+
+ public void RestoreHorizonOcConfig(string driveRoot, IProgress? progress = null)
+ {
+ var src = Path.Combine(PathUtils.ToDrivePath(driveRoot, HorizonOcBackupDir), "config.ini");
+ if (!File.Exists(src))
+ return;
+
+ progress?.Report("HorizonOC: restoring config.ini...");
+ PathUtils.EnsureDirectory(PathUtils.ToDrivePath(driveRoot, @"config\horizon-oc"));
+ PathUtils.CopyFile(src, PathUtils.ToDrivePath(driveRoot, HorizonOcConfig));
+ }
+
+ public void CleanupHorizonOcBackup(string driveRoot)
+ {
+ var dir = PathUtils.ToDrivePath(driveRoot, HorizonOcBackupDir);
+ if (Directory.Exists(dir))
+ PathUtils.DeletePath(dir);
+ }
+
+ private static void CopyIfExists(string src, string dst)
+ {
+ if (!File.Exists(src))
+ return;
+ PathUtils.CopyFile(src, dst);
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/Services/ConfigIniService.cs b/desktop/OmniNX.DesktopInstaller/Services/ConfigIniService.cs
new file mode 100644
index 0000000..c821765
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Services/ConfigIniService.cs
@@ -0,0 +1,132 @@
+namespace OmniNX.DesktopInstaller.Services;
+
+public sealed class ConfigIniService
+{
+ private const string ConfigIniRelative = @"config\omninx\config.ini";
+ private const string LegacyRamConfigRelative = @"config\omninx\ram_config.ini";
+
+ public bool TryReadBool(string driveRoot, string section, string key, out bool value)
+ {
+ value = false;
+ var path = PathUtils.ToDrivePath(driveRoot, ConfigIniRelative);
+ if (!File.Exists(path))
+ return false;
+
+ var inSection = false;
+ foreach (var rawLine in File.ReadLines(path))
+ {
+ var line = rawLine.Trim();
+ if (line.Length == 0)
+ continue;
+
+ if (line.StartsWith('[') && line.EndsWith(']'))
+ {
+ inSection = line[1..^1].Equals(section, StringComparison.Ordinal);
+ continue;
+ }
+
+ if (!inSection)
+ continue;
+
+ var eq = line.IndexOf('=');
+ if (eq <= 0)
+ continue;
+
+ var k = line[..eq].Trim();
+ if (!k.Equals(key, StringComparison.Ordinal))
+ continue;
+
+ var val = line[(eq + 1)..].Trim();
+ if (val == "1")
+ {
+ value = true;
+ return true;
+ }
+
+ if (val == "0")
+ {
+ value = false;
+ return true;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ public void MigrateLegacyRamConfig(string driveRoot)
+ {
+ var legacy = PathUtils.ToDrivePath(driveRoot, LegacyRamConfigRelative);
+ if (!File.Exists(legacy))
+ return;
+
+ if (TryReadLegacyBool(legacy, "Ram", "8gb", out var use8Gb))
+ {
+ var havePro = TryReadBool(driveRoot, "HekateAms", "pro", out var usePro);
+ WriteExtras(driveRoot, needRam: true, use8Gb, needHekateAms: havePro, havePro ? usePro : false);
+ }
+
+ PathUtils.DeletePath(legacy);
+ }
+
+ public void WriteExtras(string driveRoot, bool needRam, bool use8Gb, bool needHekateAms, bool usePro)
+ {
+ var path = PathUtils.ToDrivePath(driveRoot, ConfigIniRelative);
+ PathUtils.EnsureDirectory(Path.GetDirectoryName(path)!);
+
+ using var writer = new StreamWriter(path, append: false);
+ if (needRam)
+ {
+ writer.WriteLine("[Ram]");
+ writer.WriteLine($"8gb={(use8Gb ? 1 : 0)}");
+ writer.WriteLine();
+ }
+
+ if (needHekateAms)
+ {
+ writer.WriteLine("[HekateAms]");
+ writer.WriteLine($"pro={(usePro ? 1 : 0)}");
+ }
+ }
+
+ private static bool TryReadLegacyBool(string path, string section, string key, out bool value)
+ {
+ value = false;
+ var inSection = false;
+ foreach (var rawLine in File.ReadLines(path))
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith('[') && line.EndsWith(']'))
+ {
+ inSection = line[1..^1].Equals(section, StringComparison.Ordinal);
+ continue;
+ }
+
+ if (!inSection)
+ continue;
+
+ var eq = line.IndexOf('=');
+ if (eq <= 0)
+ continue;
+
+ if (!line[..eq].Trim().Equals(key, StringComparison.Ordinal))
+ continue;
+
+ var val = line[(eq + 1)..].Trim();
+ if (val == "1")
+ {
+ value = true;
+ return true;
+ }
+
+ if (val == "0")
+ {
+ value = false;
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/Services/DeletionService.cs b/desktop/OmniNX.DesktopInstaller/Services/DeletionService.cs
new file mode 100644
index 0000000..5ea76f4
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Services/DeletionService.cs
@@ -0,0 +1,187 @@
+using CleanLists = OmniNX.DesktopInstaller.Data.DeletionLists.Clean;
+using UpdateLists = OmniNX.DesktopInstaller.Data.DeletionLists.Update;
+
+namespace OmniNX.DesktopInstaller.Services;
+
+public sealed class DeletionService
+{
+ private readonly PreserveService _preserve = new();
+
+ public void RunCleanDeletion(string driveRoot, IProgress? progress = null)
+ {
+ DeleteGroup(driveRoot, "atmosphere", CleanLists.clean_atmosphere_dirs_to_delete, progress);
+ DeleteGroup(driveRoot, "bootloader", CleanLists.clean_bootloader_dirs_to_delete, progress);
+ DeleteFiles(driveRoot, CleanLists.clean_bootloader_files_to_delete, progress);
+ DeleteGroup(driveRoot, "config", CleanLists.clean_config_dirs_to_delete, progress);
+ DeleteGroup(driveRoot, "switch", CleanLists.clean_switch_dirs_to_delete, progress);
+ DeleteFiles(driveRoot, CleanLists.clean_root_files_to_delete, progress);
+ DeleteGroup(driveRoot, "misc", CleanLists.clean_misc_dirs_to_delete, progress);
+ DeleteFiles(driveRoot, CleanLists.clean_misc_files_to_delete, progress);
+ DeleteFiles(driveRoot, CleanLists.old_version_files_to_delete, progress);
+ PathUtils.EnsureDirectory(PathUtils.ToDrivePath(driveRoot, @"switch"));
+ }
+
+ public void RunUpdateDeletion(string driveRoot, IProgress? progress = null)
+ {
+ _preserve.Load(driveRoot, progress);
+
+ DeleteGroup(driveRoot, "atmosphere", UpdateLists.atmosphere_dirs_to_delete, progress, _preserve);
+ DeleteGroup(driveRoot, "atmosphere/contents", UpdateLists.atmosphere_contents_dirs_to_delete, progress, _preserve);
+ DeleteFiles(driveRoot, UpdateLists.atmosphere_files_to_delete, progress, _preserve);
+ DeleteGroup(driveRoot, "bootloader", UpdateLists.bootloader_dirs_to_delete, progress, _preserve);
+ DeleteFiles(driveRoot, UpdateLists.bootloader_files_to_delete, progress, _preserve);
+ DeleteGroup(driveRoot, "config", UpdateLists.config_dirs_to_delete, progress, _preserve);
+ DeleteGroup(driveRoot, "switch", UpdateLists.switch_dirs_to_delete, progress, _preserve);
+ DeleteFiles(driveRoot, UpdateLists.switch_files_to_delete, progress, _preserve);
+ DeleteFiles(driveRoot, UpdateLists.root_files_to_delete, progress, _preserve);
+ DeleteGroup(driveRoot, "misc", UpdateLists.misc_dirs_to_delete, progress, _preserve);
+ DeleteFiles(driveRoot, UpdateLists.misc_files_to_delete, progress, _preserve);
+
+ _preserve.Clear();
+ }
+
+ public void RunPostRestoreCleanup(string driveRoot)
+ {
+ DeleteGroup(driveRoot, "switch (post-restore)", CleanLists.clean_post_restore_dirs_to_delete, null);
+ }
+
+ private static void DeleteGroup(
+ string driveRoot,
+ string label,
+ string[] paths,
+ IProgress? progress,
+ PreserveService? preserve = null)
+ {
+ if (paths.Length == 0)
+ return;
+
+ progress?.Report($"Deleting {label}...");
+ foreach (var rel in paths)
+ {
+ var full = PathUtils.ToDrivePath(driveRoot, rel);
+ if (!PathUtils.PathExists(full))
+ continue;
+ if (preserve?.IsBlocked(full, driveRoot) == true)
+ {
+ progress?.Report($" Skipped (preserve): {rel}");
+ continue;
+ }
+
+ progress?.Report($" Delete: {rel}");
+ PathUtils.DeletePath(full);
+ }
+ }
+
+ private static void DeleteFiles(
+ string driveRoot,
+ string[] paths,
+ IProgress? progress,
+ PreserveService? preserve = null)
+ {
+ foreach (var rel in paths)
+ {
+ var full = PathUtils.ToDrivePath(driveRoot, rel);
+ if (!File.Exists(full))
+ continue;
+ if (preserve?.IsBlocked(full, driveRoot) == true)
+ {
+ progress?.Report($" Skipped (preserve): {rel}");
+ continue;
+ }
+
+ progress?.Report($" Delete: {rel}");
+ PathUtils.DeletePath(full);
+ }
+ }
+}
+
+public sealed class PreserveService
+{
+ private readonly List _paths = new();
+
+ public void Load(string driveRoot, IProgress? progress = null)
+ {
+ _paths.Clear();
+ var iniPath = PathUtils.ToDrivePath(driveRoot, @"config\omninx\preserve.ini");
+ if (!File.Exists(iniPath))
+ return;
+
+ var inSection = false;
+ foreach (var rawLine in File.ReadLines(iniPath))
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith('[') && line.EndsWith(']'))
+ {
+ inSection = line[1..^1].Equals("Preserve", StringComparison.Ordinal);
+ continue;
+ }
+
+ if (!inSection || !line.Contains('='))
+ continue;
+
+ var eq = line.IndexOf('=');
+ var key = line[..eq].Trim().Trim('\'', '"');
+ var val = line[(eq + 1)..].Trim();
+ if (!IsTruthy(val))
+ continue;
+
+ var normalized = NormalizePreservePath(key, driveRoot);
+ if (normalized is null)
+ continue;
+
+ if (!_paths.Contains(normalized, StringComparer.OrdinalIgnoreCase))
+ _paths.Add(normalized);
+ }
+
+ if (_paths.Count > 0)
+ progress?.Report($"preserve.ini: {_paths.Count} path(s) excluded from update cleanup.");
+ }
+
+ public bool IsBlocked(string deleteFullPath, string driveRoot)
+ {
+ if (_paths.Count == 0)
+ return false;
+
+ var normalizedDelete = Path.GetFullPath(deleteFullPath).TrimEnd(Path.DirectorySeparatorChar);
+ foreach (var preserve in _paths)
+ {
+ var normalizedPreserve = Path.GetFullPath(preserve).TrimEnd(Path.DirectorySeparatorChar);
+ if (normalizedDelete.Equals(normalizedPreserve, StringComparison.OrdinalIgnoreCase))
+ return true;
+ if (IsPrefix(normalizedPreserve, normalizedDelete))
+ return true;
+ if (IsPrefix(normalizedDelete, normalizedPreserve))
+ return true;
+ }
+
+ return false;
+ }
+
+ public void Clear() => _paths.Clear();
+
+ private static string? NormalizePreservePath(string key, string driveRoot)
+ {
+ var path = key.Replace("sd:/", string.Empty).Replace("sd:\\", string.Empty)
+ .Replace('/', Path.DirectorySeparatorChar).TrimEnd(Path.DirectorySeparatorChar);
+ if (path.Length < 2)
+ return null;
+
+ return PathUtils.ToDrivePath(driveRoot, path);
+ }
+
+ private static bool IsPrefix(string prefix, string path)
+ {
+ if (!path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ return false;
+ return path.Length == prefix.Length ||
+ path[prefix.Length] == Path.DirectorySeparatorChar;
+ }
+
+ private static bool IsTruthy(string val)
+ {
+ if (string.IsNullOrEmpty(val))
+ return false;
+ var c = char.ToUpperInvariant(val[0]);
+ return c is '1' or 'T' or 'Y';
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/Services/ExtrasService.cs b/desktop/OmniNX.DesktopInstaller/Services/ExtrasService.cs
new file mode 100644
index 0000000..cdd8841
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Services/ExtrasService.cs
@@ -0,0 +1,97 @@
+namespace OmniNX.DesktopInstaller.Services;
+
+public sealed class ExtrasService
+{
+ private readonly ConfigIniService _config = new();
+
+ public (bool needRam, bool needHekateAms) DetectExtras(string driveRoot)
+ {
+ var extras = PathUtils.ToDrivePath(driveRoot, "extras");
+ var needRam = File.Exists(Path.Combine(extras, "hekate_8gb.bin")) ||
+ File.Exists(Path.Combine(extras, "hekate-pro_8gb.bin"));
+ var needHekateAms = Directory.Exists(Path.Combine(extras, "hekate-ams-pro"));
+ return (needRam, needHekateAms);
+ }
+
+ public void ApplyExtras(
+ string driveRoot,
+ bool use8GbRam,
+ bool useHekateAmsPro,
+ IProgress? progress = null)
+ {
+ _config.MigrateLegacyRamConfig(driveRoot);
+
+ var extras = PathUtils.ToDrivePath(driveRoot, "extras");
+ if (!Directory.Exists(extras))
+ return;
+
+ var (needRam, needHekateAms) = DetectExtras(driveRoot);
+ if (!needRam && !needHekateAms)
+ return;
+
+ progress?.Report("Saving RAM / Hekate-AMS selection to config.ini...");
+ _config.WriteExtras(driveRoot, needRam, use8GbRam, needHekateAms, useHekateAmsPro);
+
+ if (useHekateAmsPro && needHekateAms)
+ {
+ progress?.Report("Installing Hekate-Pro / Atmosphere-Pro...");
+ ApplyHekateAmsPro(extras, driveRoot, progress);
+ }
+ else if (needHekateAms)
+ {
+ progress?.Report("Keeping standard Hekate / Atmosphere.");
+ }
+
+ if (needRam)
+ {
+ progress?.Report("Applying RAM payload setting...");
+ Apply8GbPayload(extras, driveRoot, use8GbRam, useHekateAmsPro);
+ }
+
+ progress?.Report("Removing extras/...");
+ PathUtils.DeletePath(extras);
+ }
+
+ private static void ApplyHekateAmsPro(string extrasDir, string driveRoot, IProgress? progress)
+ {
+ var src = Path.Combine(extrasDir, "hekate-ams-pro");
+ if (!Directory.Exists(src))
+ return;
+
+ foreach (var entry in Directory.GetFileSystemEntries(src))
+ {
+ var name = Path.GetFileName(entry);
+ var dst = Path.Combine(PathUtils.NormalizeDriveRoot(driveRoot), name);
+ if (Directory.Exists(entry))
+ PathUtils.CopyDirectory(entry, dst);
+ else
+ PathUtils.CopyFile(entry, dst);
+ progress?.Report($" Copied: {name}");
+ }
+ }
+
+ private static void Apply8GbPayload(string extrasDir, string driveRoot, bool use8Gb, bool usePro)
+ {
+ var hekate8 = Path.Combine(extrasDir, "hekate_8gb.bin");
+ var hekatePro8 = Path.Combine(extrasDir, "hekate-pro_8gb.bin");
+
+ if (!use8Gb)
+ {
+ if (File.Exists(hekate8)) File.Delete(hekate8);
+ if (File.Exists(hekatePro8)) File.Delete(hekatePro8);
+ return;
+ }
+
+ var src = usePro ? hekatePro8 : hekate8;
+ if (!File.Exists(src))
+ return;
+
+ var payloadDst = PathUtils.ToDrivePath(driveRoot, "payload.bin");
+ var updateDst = PathUtils.ToDrivePath(driveRoot, @"bootloader\update.bin");
+ PathUtils.CopyFile(src, payloadDst);
+ PathUtils.CopyFile(src, updateDst);
+
+ if (File.Exists(hekate8)) File.Delete(hekate8);
+ if (File.Exists(hekatePro8)) File.Delete(hekatePro8);
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/Services/GiteaClient.cs b/desktop/OmniNX.DesktopInstaller/Services/GiteaClient.cs
new file mode 100644
index 0000000..4d9f147
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Services/GiteaClient.cs
@@ -0,0 +1,100 @@
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+using OmniNX.DesktopInstaller.Models;
+
+namespace OmniNX.DesktopInstaller.Services;
+
+public sealed class GiteaClient
+{
+ public const string DefaultBaseUrl = "https://git.niklascfw.de";
+ private readonly HttpClient _http;
+ private readonly string _baseUrl;
+
+ public GiteaClient(HttpClient? http = null, string? baseUrl = null)
+ {
+ _http = http ?? new HttpClient();
+ _http.DefaultRequestHeaders.UserAgent.ParseAdd("OmniNX-Desktop-Installer/1.0");
+ _baseUrl = (baseUrl ?? DefaultBaseUrl).TrimEnd('/');
+ }
+
+ public async Task GetLatestReleaseAsync(CancellationToken cancellationToken = default)
+ {
+ var url = $"{_baseUrl}/api/v1/repos/OmniNX/OmniNX/releases/latest";
+ var response = await _http.GetFromJsonAsync(url, cancellationToken)
+ ?? throw new InvalidOperationException("Empty release response from Gitea.");
+
+ return MapRelease(response);
+ }
+
+ public async Task DownloadAssetAsync(
+ ReleaseAsset asset,
+ string destinationPath,
+ IProgress<(long downloaded, long total)>? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ PathUtils.EnsureDirectory(Path.GetDirectoryName(destinationPath)!);
+
+ using var response = await _http.GetAsync(asset.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var total = response.Content.Headers.ContentLength ?? asset.Size;
+ await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
+ await using var output = File.Create(destinationPath);
+
+ var buffer = new byte[81920];
+ long downloaded = 0;
+ int read;
+ while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0)
+ {
+ await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
+ downloaded += read;
+ progress?.Report((downloaded, total));
+ }
+
+ return destinationPath;
+ }
+
+ public ReleaseAsset? FindPackAsset(ReleaseInfo release, OmniNxVariant variant)
+ {
+ var prefix = VariantHelper.GetZipAssetPrefix(variant);
+ return release.Assets.FirstOrDefault(a =>
+ a.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
+ a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static ReleaseInfo MapRelease(GiteaReleaseDto dto) => new()
+ {
+ TagName = dto.TagName ?? string.Empty,
+ Name = dto.Name ?? dto.TagName ?? string.Empty,
+ Assets = dto.Assets?.Select(a => new ReleaseAsset
+ {
+ Name = a.Name ?? string.Empty,
+ DownloadUrl = a.BrowserDownloadUrl ?? string.Empty,
+ Size = a.Size,
+ }).ToList() ?? [],
+ };
+
+ private sealed class GiteaReleaseDto
+ {
+ [JsonPropertyName("tag_name")]
+ public string? TagName { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("assets")]
+ public List? Assets { get; set; }
+ }
+
+ private sealed class GiteaAssetDto
+ {
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("browser_download_url")]
+ public string? BrowserDownloadUrl { get; set; }
+
+ [JsonPropertyName("size")]
+ public long Size { get; set; }
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/Services/InstallEngine.cs b/desktop/OmniNX.DesktopInstaller/Services/InstallEngine.cs
new file mode 100644
index 0000000..175a037
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Services/InstallEngine.cs
@@ -0,0 +1,186 @@
+using OmniNX.DesktopInstaller.Models;
+
+namespace OmniNX.DesktopInstaller.Services;
+
+public sealed class InstallEngine
+{
+ private readonly DeletionService _deletion = new();
+ private readonly BackupService _backup = new();
+ private readonly ExtrasService _extras = new();
+
+ public async Task RunAsync(
+ InstallOptions options,
+ IProgress progress,
+ CancellationToken cancellationToken = default)
+ {
+ var driveRoot = PathUtils.NormalizeDriveRoot(options.DriveRoot);
+ var staging = options.StagingPath
+ ?? throw new InvalidOperationException("Staging path is required.");
+
+ if (!Directory.Exists(staging))
+ throw new DirectoryNotFoundException($"Staging path not found: {staging}");
+
+ Report(progress, $"Install mode: {options.Mode}");
+ Report(progress, $"Pack variant: {VariantHelper.GetDisplayName(options.Variant)}");
+
+ if (options.Mode == InstallMode.Update)
+ {
+ await RunUpdateAsync(driveRoot, staging, options, progress, cancellationToken);
+ }
+ else
+ {
+ await RunCleanAsync(driveRoot, staging, options, progress, cancellationToken);
+ }
+
+ Report(progress, options.Mode == InstallMode.Update
+ ? "Update completed successfully."
+ : "Installation completed successfully.");
+ }
+
+ private async Task RunUpdateAsync(
+ string driveRoot,
+ string staging,
+ InstallOptions options,
+ IProgress progress,
+ CancellationToken cancellationToken)
+ {
+ if (options.Variant == OmniNxVariant.Oc)
+ {
+ Report(progress, "HorizonOC: backing up config.ini...");
+ _backup.BackupHorizonOcConfig(driveRoot, StringProgress(progress));
+ }
+
+ Report(progress, "Step 1: Cleanup...");
+ await Task.Run(() => _deletion.RunUpdateDeletion(driveRoot, StringProgress(progress)), cancellationToken);
+
+ Report(progress, "Step 2: Copying files...");
+ await Task.Run(() => CopyPackFiles(staging, driveRoot, options.Variant, progress), cancellationToken);
+
+ Report(progress, "Applying RAM / Hekate-AMS extras...");
+ await Task.Run(() => _extras.ApplyExtras(driveRoot, options.Use8GbRam, options.UseHekateAmsPro,
+ StringProgress(progress)), cancellationToken);
+
+ if (options.Variant == OmniNxVariant.Oc)
+ {
+ Report(progress, "HorizonOC: restoring config.ini...");
+ await Task.Run(() =>
+ {
+ _backup.RestoreHorizonOcConfig(driveRoot, StringProgress(progress));
+ _backup.CleanupHorizonOcBackup(driveRoot);
+ }, cancellationToken);
+ }
+ }
+
+ private async Task RunCleanAsync(
+ string driveRoot,
+ string staging,
+ InstallOptions options,
+ IProgress progress,
+ CancellationToken cancellationToken)
+ {
+ Report(progress, "Step 1: Backing up user data...");
+ await Task.Run(() => _backup.BackupUserData(driveRoot, StringProgress(progress)), cancellationToken);
+
+ Report(progress, "Step 2: Cleaning old installation...");
+ await Task.Run(() => _deletion.RunCleanDeletion(driveRoot, StringProgress(progress)), cancellationToken);
+
+ Report(progress, "Step 3: Restoring user data...");
+ await Task.Run(() =>
+ {
+ _backup.RestoreUserData(driveRoot, StringProgress(progress));
+ _deletion.RunPostRestoreCleanup(driveRoot);
+ _backup.CleanupBackup(driveRoot);
+ }, cancellationToken);
+
+ Report(progress, "Step 4: Copying files...");
+ await Task.Run(() => CopyPackFiles(staging, driveRoot, options.Variant, progress), cancellationToken);
+
+ Report(progress, "Applying RAM / Hekate-AMS extras...");
+ await Task.Run(() => _extras.ApplyExtras(driveRoot, options.Use8GbRam, options.UseHekateAmsPro,
+ StringProgress(progress)), cancellationToken);
+ }
+
+ private static void CopyPackFiles(
+ string staging,
+ string driveRoot,
+ OmniNxVariant variant,
+ IProgress progress)
+ {
+ CopyFolderIfExists(staging, driveRoot, "atmosphere", progress);
+ CopyFolderIfExists(staging, driveRoot, "bootloader", progress);
+ CopyFolderIfExists(staging, driveRoot, "config", progress);
+ CopyFolderIfExists(staging, driveRoot, "switch", progress);
+ CopyFolderIfExists(staging, driveRoot, "themes", progress);
+ CopyFolderIfExists(staging, driveRoot, "warmboot_mariko", progress);
+
+ if (variant == OmniNxVariant.Oc)
+ CopyFolderIfExists(staging, driveRoot, "SaltySD", progress);
+
+ CopyFolderIfExists(staging, driveRoot, "extras", progress);
+ CopyRootFiles(staging, driveRoot, progress);
+ }
+
+ private static void CopyFolderIfExists(
+ string staging,
+ string driveRoot,
+ string folder,
+ IProgress progress)
+ {
+ var src = Path.Combine(staging, folder);
+ if (!Directory.Exists(src))
+ return;
+
+ var dst = Path.Combine(PathUtils.NormalizeDriveRoot(driveRoot), folder);
+ Report(progress, $"Copying {folder}/...");
+ PathUtils.CopyDirectory(src, dst, new Progress<(int current, int total, string file)>(copyProgress =>
+ {
+ var (current, total, file) = copyProgress;
+ var pct = total > 0 ? current * 100 / total : 0;
+ progress.Report(new InstallProgressReport
+ {
+ Message = $" {folder}/ {file}",
+ Percent = pct,
+ });
+ }));
+ }
+
+ private static void CopyRootFiles(
+ string staging,
+ string driveRoot,
+ IProgress progress)
+ {
+ Report(progress, "Copying root files...");
+ var skipDirs = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "atmosphere", "bootloader", "config", "switch", "themes",
+ "warmboot_mariko", "SaltySD", "extras",
+ };
+
+ foreach (var file in Directory.GetFiles(staging))
+ {
+ var name = Path.GetFileName(file);
+ if (name.StartsWith('.'))
+ continue;
+
+ var dst = Path.Combine(PathUtils.NormalizeDriveRoot(driveRoot), name);
+ PathUtils.CopyFile(file, dst);
+ Report(progress, $" {name}");
+ }
+
+ foreach (var dir in Directory.GetDirectories(staging))
+ {
+ var name = Path.GetFileName(dir);
+ if (skipDirs.Contains(name))
+ continue;
+ Report(progress, $"Skipping unexpected directory at pack root: {name}");
+ }
+ }
+
+ private static void Report(IProgress progress, string message, bool isError = false)
+ {
+ progress.Report(new InstallProgressReport { Message = message, IsError = isError });
+ }
+
+ private static IProgress StringProgress(IProgress progress) =>
+ new Progress(msg => Report(progress, msg));
+}
diff --git a/desktop/OmniNX.DesktopInstaller/Services/PathUtils.cs b/desktop/OmniNX.DesktopInstaller/Services/PathUtils.cs
new file mode 100644
index 0000000..bf992fe
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Services/PathUtils.cs
@@ -0,0 +1,133 @@
+using OmniNX.DesktopInstaller.Models;
+
+namespace OmniNX.DesktopInstaller.Services;
+
+public static class VariantHelper
+{
+ public static string GetDisplayName(OmniNxVariant variant) => variant switch
+ {
+ OmniNxVariant.Light => "Light",
+ OmniNxVariant.Standard => "Standard",
+ OmniNxVariant.Oc => "OC",
+ _ => "None",
+ };
+
+ public static string GetStagingFolderName(OmniNxVariant variant) => variant switch
+ {
+ OmniNxVariant.Light => "OmniNX Light",
+ OmniNxVariant.Standard => "OmniNX Standard",
+ OmniNxVariant.Oc => "OmniNX OC",
+ _ => string.Empty,
+ };
+
+ public static string GetZipAssetPrefix(OmniNxVariant variant) => variant switch
+ {
+ OmniNxVariant.Light => "OmniNX-Light",
+ OmniNxVariant.Standard => "OmniNX-Standard",
+ OmniNxVariant.Oc => "OmniNX-OC",
+ _ => string.Empty,
+ };
+
+ public static OmniNxVariant ParsePackName(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return OmniNxVariant.None;
+
+ return value.Trim() switch
+ {
+ "Light" => OmniNxVariant.Light,
+ "Standard" => OmniNxVariant.Standard,
+ "OC" => OmniNxVariant.Oc,
+ _ => OmniNxVariant.None,
+ };
+ }
+}
+
+public static class PathUtils
+{
+ public static string NormalizeDriveRoot(string driveRoot)
+ {
+ var root = driveRoot.Trim();
+ if (!root.EndsWith(Path.DirectorySeparatorChar))
+ root += Path.DirectorySeparatorChar;
+ return root;
+ }
+
+ public static string ToDrivePath(string driveRoot, string sdRelative)
+ {
+ var rel = sdRelative.Replace("sd:/", string.Empty).Replace("sd:\\", string.Empty)
+ .Replace('/', Path.DirectorySeparatorChar);
+ return Path.Combine(NormalizeDriveRoot(driveRoot), rel);
+ }
+
+ public static string RelativeFromDrive(string driveRoot, string fullPath)
+ {
+ var root = NormalizeDriveRoot(driveRoot);
+ if (fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase))
+ return fullPath[root.Length..];
+ return fullPath;
+ }
+
+ public static bool PathExists(string path)
+ {
+ return Directory.Exists(path) || File.Exists(path);
+ }
+
+ public static void EnsureDirectory(string path)
+ {
+ Directory.CreateDirectory(path);
+ }
+
+ public static void CopyFile(string src, string dst, bool overwrite = true)
+ {
+ var dir = Path.GetDirectoryName(dst);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+ File.Copy(src, dst, overwrite);
+ }
+
+ public static void CopyDirectory(string src, string dst, IProgress<(int current, int total, string file)>? progress = null)
+ {
+ if (!Directory.Exists(src))
+ return;
+
+ var files = Directory.GetFiles(src, "*", SearchOption.AllDirectories);
+ var total = files.Length;
+ var current = 0;
+
+ foreach (var file in files)
+ {
+ var rel = Path.GetRelativePath(src, file);
+ var target = Path.Combine(dst, rel);
+ CopyFile(file, target);
+ current++;
+ progress?.Report((current, total, rel));
+ }
+ }
+
+ public static void DeletePath(string path)
+ {
+ if (File.Exists(path))
+ {
+ File.SetAttributes(path, FileAttributes.Normal);
+ File.Delete(path);
+ return;
+ }
+
+ if (Directory.Exists(path))
+ Directory.Delete(path, recursive: true);
+ }
+
+ public static int CountItemsRecursive(string path)
+ {
+ if (File.Exists(path))
+ return 1;
+ if (!Directory.Exists(path))
+ return 0;
+
+ var count = 1;
+ foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
+ count++;
+ return count;
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/Services/VersionDetectService.cs b/desktop/OmniNX.DesktopInstaller/Services/VersionDetectService.cs
new file mode 100644
index 0000000..760ec04
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Services/VersionDetectService.cs
@@ -0,0 +1,79 @@
+using OmniNX.DesktopInstaller.Models;
+
+namespace OmniNX.DesktopInstaller.Services;
+
+public sealed class VersionDetectService
+{
+ private const string ManifestRelative = @"config\omninx\manifest.ini";
+
+ public OmniNxStatus DetectInstallation(string driveRoot)
+ {
+ var manifest = PathUtils.ToDrivePath(driveRoot, ManifestRelative);
+ if (!File.Exists(manifest))
+ return new OmniNxStatus { IsInstalled = false, Variant = OmniNxVariant.None };
+
+ var variant = OmniNxVariant.None;
+ var version = string.Empty;
+ var inSection = false;
+
+ foreach (var rawLine in File.ReadLines(manifest))
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith('[') && line.EndsWith(']'))
+ {
+ inSection = line[1..^1].Equals("OmniNX", StringComparison.Ordinal);
+ continue;
+ }
+
+ if (!inSection || !line.Contains('='))
+ continue;
+
+ var eq = line.IndexOf('=');
+ var key = line[..eq].Trim();
+ var val = line[(eq + 1)..].Trim();
+
+ if (key.Equals("current_pack", StringComparison.Ordinal))
+ variant = VariantHelper.ParsePackName(val);
+ else if (key.Equals("version", StringComparison.Ordinal))
+ version = val;
+ }
+
+ return new OmniNxStatus
+ {
+ IsInstalled = variant != OmniNxVariant.None,
+ Variant = variant,
+ Version = version,
+ };
+ }
+
+ public InstallMode DetermineMode(OmniNxStatus status, bool forceClean)
+ {
+ if (forceClean)
+ return InstallMode.Clean;
+ return status.IsInstalled ? InstallMode.Update : InstallMode.Clean;
+ }
+}
+
+public static class DriveService
+{
+ public sealed record DriveItem(DriveInfo Drive)
+ {
+ public override string ToString() => FormatDriveLabel(Drive);
+ }
+
+ public static IReadOnlyList GetCandidateDrives()
+ {
+ return DriveInfo.GetDrives()
+ .Where(d => d.IsReady && d.DriveType == DriveType.Removable)
+ .OrderBy(d => d.Name)
+ .Select(d => new DriveItem(d))
+ .ToList();
+ }
+
+ public static string FormatDriveLabel(DriveInfo drive)
+ {
+ var label = string.IsNullOrWhiteSpace(drive.VolumeLabel) ? "No Label" : drive.VolumeLabel;
+ var sizeGb = drive.TotalSize / (1024.0 * 1024 * 1024);
+ return $"{drive.Name} [{label}] ({sizeGb:F1} GB, {drive.DriveFormat})";
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/Services/ZipExtractor.cs b/desktop/OmniNX.DesktopInstaller/Services/ZipExtractor.cs
new file mode 100644
index 0000000..184ba3d
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/Services/ZipExtractor.cs
@@ -0,0 +1,40 @@
+using System.IO.Compression;
+using OmniNX.DesktopInstaller.Models;
+
+namespace OmniNX.DesktopInstaller.Services;
+
+public static class ZipExtractor
+{
+ public static string ExtractPack(string zipPath, string extractRoot, IProgress? progress = null)
+ {
+ PathUtils.EnsureDirectory(extractRoot);
+ var targetDir = Path.Combine(extractRoot, Path.GetFileNameWithoutExtension(zipPath));
+ if (Directory.Exists(targetDir))
+ Directory.Delete(targetDir, recursive: true);
+
+ progress?.Report($"Extracting {Path.GetFileName(zipPath)}...");
+ ZipFile.ExtractToDirectory(zipPath, targetDir, overwriteFiles: true);
+ return ResolveStagingPath(targetDir);
+ }
+
+ public static string ResolveStagingPath(string extractRoot)
+ {
+ foreach (var variant in new[] { OmniNxVariant.Standard, OmniNxVariant.Light, OmniNxVariant.Oc })
+ {
+ var candidate = Path.Combine(extractRoot, VariantHelper.GetStagingFolderName(variant));
+ if (Directory.Exists(candidate))
+ return candidate;
+ }
+
+ if (Directory.Exists(Path.Combine(extractRoot, "atmosphere")) ||
+ Directory.Exists(Path.Combine(extractRoot, "bootloader")))
+ return extractRoot;
+
+ var subdirs = Directory.GetDirectories(extractRoot);
+ if (subdirs.Length == 1)
+ return ResolveStagingPath(subdirs[0]);
+
+ throw new InvalidOperationException(
+ "Could not locate OmniNX pack root in extracted archive. Expected 'OmniNX Light', 'OmniNX Standard', or 'OmniNX OC' folder.");
+ }
+}
diff --git a/desktop/OmniNX.DesktopInstaller/app.manifest b/desktop/OmniNX.DesktopInstaller/app.manifest
new file mode 100644
index 0000000..f280441
--- /dev/null
+++ b/desktop/OmniNX.DesktopInstaller/app.manifest
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/generate_deletion_lists.py b/tools/generate_deletion_lists.py
new file mode 100644
index 0000000..20eddae
--- /dev/null
+++ b/tools/generate_deletion_lists.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""Generate C# deletion list arrays from payload header files."""
+
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SOURCE = ROOT / "source"
+OUT = ROOT / "desktop" / "OmniNX.DesktopInstaller" / "Data" / "DeletionLists.Generated.cs"
+
+HEADERS = {
+ "Clean": SOURCE / "deletion_lists_clean.h",
+ "Update": SOURCE / "deletion_lists_update.h",
+}
+
+
+def parse_arrays(text: str) -> dict[str, list[str]]:
+ arrays: dict[str, list[str]] = {}
+ pattern = re.compile(
+ r"static const char\*\s+(\w+)\[\]\s*=\s*\{([^}]*)\};",
+ re.MULTILINE | re.DOTALL,
+ )
+ for match in pattern.finditer(text):
+ name = match.group(1)
+ body = match.group(2)
+ paths = []
+ for line in body.splitlines():
+ line = line.strip()
+ if not line or line.startswith("//"):
+ continue
+ m = re.match(r'"([^"]+)"', line)
+ if m:
+ paths.append(m.group(1))
+ arrays[name] = paths
+ return arrays
+
+
+def emit_cs(mode: str, arrays: dict[str, list[str]]) -> str:
+ lines = [f" public static class {mode}"]
+ lines.append(" {")
+ for name, paths in arrays.items():
+ lines.append(f" public static readonly string[] {name} =")
+ lines.append(" {")
+ for p in paths:
+ rel = p.replace("sd:/", "").replace("sd:\\", "").replace("/", "\\")
+ lines.append(f' @"{rel}",')
+ lines.append(" };")
+ lines.append("")
+ lines.append(" }")
+ return "\n".join(lines)
+
+
+def main() -> None:
+ sections = []
+ for mode, header in HEADERS.items():
+ text = header.read_text(encoding="utf-8")
+ arrays = parse_arrays(text)
+ sections.append(emit_cs(mode, arrays))
+
+ OUT.parent.mkdir(parents=True, exist_ok=True)
+ content = """//
+// Generated by tools/generate_deletion_lists.py — do not edit manually.
+
+namespace OmniNX.DesktopInstaller.Data;
+
+public static partial class DeletionLists
+{
+"""
+ content += "\n\n".join(sections)
+ content += "\n}\n"
+ OUT.write_text(content, encoding="utf-8")
+ print(f"Wrote {OUT}")
+
+
+if __name__ == "__main__":
+ main()