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