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.
582 lines
20 KiB
C#
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();
|
|
}
|
|
}
|