Files
OmniNX-Installer-Payload/desktop/OmniNX.DesktopInstaller/MainForm.cs
T
niklascfw d573f6cca4 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.
2026-09-13 21:10:58 +02:00

582 lines
20 KiB
C#

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