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:
21 files changed
+2312
No files matched your search
@@ -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
|
||||
@@ -23,3 +23,8 @@ Thumbs.db
|
||||
# Vendored TegraExplorer tree
|
||||
source/TegraExplorer/
|
||||
|
||||
# Desktop installer (.NET)
|
||||
desktop/**/bin/
|
||||
desktop/**/obj/
|
||||
desktop/out/
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user