Add a Windows desktop installer for PC-based OmniNX installs.

This lets mass deployments and modchip setups apply the same install/update flow from a PC, with generated deletion lists kept in sync with the payload headers.
This commit is contained in:
niklascfw committed 2026-09-13 21:10:58 +02:00
1 parent c87fe1f1e8
commit d573f6cca4
21 files changed
+2312

No files matched your search

+44
View File
@@ -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
+5
View File
@@ -23,3 +23,8 @@ Thumbs.db
# Vendored TegraExplorer tree
source/TegraExplorer/
# Desktop installer (.NET)
desktop/**/bin/
desktop/**/obj/
desktop/out/
+18
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,410 @@
// <auto-generated />
// 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",
};
}
}
+581
View File
@@ -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<string>(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<InstallProgressReport>(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();
}
}
@@ -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<ReleaseAsset> 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; }
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<AssemblyName>OmniNX-Desktop-Installer</AssemblyName>
<RootNamespace>OmniNX.DesktopInstaller</RootNamespace>
<Version>1.0.0</Version>
<Authors>OmniNX</Authors>
<Description>PC-based OmniNX installer for mass deployments and modchip installers</Description>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>false</SelfContained>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Assets\logo-128.png" />
</ItemGroup>
</Project>
@@ -0,0 +1,11 @@
namespace OmniNX.DesktopInstaller;
static class Program
{
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}
@@ -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<string>? 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<string>? 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<string>? 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<string>? 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);
}
}
@@ -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;
}
}
@@ -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<string>? 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<string>? 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<string>? 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<string>? 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<string> _paths = new();
public void Load(string driveRoot, IProgress<string>? 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';
}
}
@@ -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<string>? 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<string>? 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);
}
}
@@ -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<ReleaseInfo> GetLatestReleaseAsync(CancellationToken cancellationToken = default)
{
var url = $"{_baseUrl}/api/v1/repos/OmniNX/OmniNX/releases/latest";
var response = await _http.GetFromJsonAsync<GiteaReleaseDto>(url, cancellationToken)
?? throw new InvalidOperationException("Empty release response from Gitea.");
return MapRelease(response);
}
public async Task<string> 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<GiteaAssetDto>? 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; }
}
}
@@ -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<InstallProgressReport> 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<InstallProgressReport> 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<InstallProgressReport> 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<InstallProgressReport> 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<InstallProgressReport> 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<InstallProgressReport> progress)
{
Report(progress, "Copying root files...");
var skipDirs = new HashSet<string>(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<InstallProgressReport> progress, string message, bool isError = false)
{
progress.Report(new InstallProgressReport { Message = message, IsError = isError });
}
private static IProgress<string> StringProgress(IProgress<InstallProgressReport> progress) =>
new Progress<string>(msg => Report(progress, msg));
}
@@ -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;
}
}
@@ -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<DriveItem> 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})";
}
}
@@ -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<string>? 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.");
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="OmniNX.DesktopInstaller"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+76
View File
@@ -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 = """// <auto-generated />
// 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()