using OmniNX.DesktopInstaller.Models; using OmniNX.DesktopInstaller.Services; using System.Runtime.InteropServices; namespace OmniNX.DesktopInstaller; public sealed class MainForm : Form { private readonly GiteaClient _gitea = new(); private readonly VersionDetectService _versionDetect = new(); private readonly InstallEngine _installEngine = new(); private ComboBox _driveCombo = null!; private Button _refreshDrivesButton = null!; private Label _statusLabel = null!; private Label _releaseLabel = null!; private Button _checkReleaseButton = null!; private RadioButton _variantLight = null!; private RadioButton _variantStandard = null!; private RadioButton _variantOc = null!; private RadioButton _ram4Gb = null!; private RadioButton _ram8Gb = null!; private RadioButton _hekateStandard = null!; private RadioButton _hekatePro = null!; private CheckBox _forceCleanCheck = null!; private Button _installButton = null!; private ProgressBar _progressBar = null!; private TextBox _logBox = null!; private ReleaseInfo? _latestRelease; private CancellationTokenSource? _operationCts; public MainForm() { Text = "OmniNX Desktop Installer"; MinimumSize = new Size(820, 640); Size = new Size(900, 700); StartPosition = FormStartPosition.CenterScreen; Font = new Font("Segoe UI", 9F); Icon = LoadFormIcon() ?? Icon.ExtractAssociatedIcon(Application.ExecutablePath); BuildLayout(); RefreshDrives(); _ = LoadLatestReleaseAsync(); } private void BuildLayout() { var root = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 8, Padding = new Padding(12), }; root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); root.Controls.Add(BuildHeaderPanel(), 0, 0); root.Controls.Add(BuildDrivePanel(), 0, 1); root.Controls.Add(BuildVariantPanel(), 0, 2); root.Controls.Add(BuildRamPanel(), 0, 3); root.Controls.Add(BuildHekatePanel(), 0, 4); root.Controls.Add(BuildOptionsPanel(), 0, 5); root.Controls.Add(BuildActionPanel(), 0, 6); root.Controls.Add(BuildLogPanel(), 0, 7); Controls.Add(root); } private Control BuildHeaderPanel() { var panel = new TableLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2, }; panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var title = new Label { Text = "OmniNX Desktop Installer", Font = new Font(Font.FontFamily, 14F, FontStyle.Bold), AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(0, 0, 16, 4), }; _releaseLabel = new Label { AutoSize = true, Text = "Release: checking...", Margin = new Padding(0, 0, 8, 8), Anchor = AnchorStyles.Left, }; _checkReleaseButton = new Button { Text = "Refresh release", AutoSize = true, Margin = new Padding(0, 0, 0, 8) }; _checkReleaseButton.Click += async (_, _) => await LoadLatestReleaseAsync(); var releaseRow = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Dock = DockStyle.Fill, Margin = new Padding(0), }; releaseRow.Controls.Add(_releaseLabel); releaseRow.Controls.Add(_checkReleaseButton); panel.Controls.Add(title, 0, 0); panel.Controls.Add(releaseRow, 0, 1); return panel; } private static Image? LoadEmbeddedImage(string resourceName) { var stream = typeof(MainForm).Assembly.GetManifestResourceStream(resourceName); return stream is null ? null : Image.FromStream(stream); } private static Icon? LoadFormIcon() { using var image = LoadEmbeddedImage("OmniNX.DesktopInstaller.Assets.logo-128.png"); if (image is null) return null; using var bitmap = new Bitmap(image, new Size(32, 32)); var handle = bitmap.GetHicon(); try { using var temp = Icon.FromHandle(handle); return (Icon)temp.Clone(); } finally { DestroyIcon(handle); } } [DllImport("user32.dll", SetLastError = true)] private static extern bool DestroyIcon(IntPtr hIcon); private Control BuildDrivePanel() { var group = new GroupBox { Text = "SD Card / Target Drive", Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(10) }; var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, AutoSize = true }; layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 90)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); _driveCombo = new ComboBox { Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList }; _driveCombo.SelectedIndexChanged += (_, _) => UpdateInstallationStatus(); _refreshDrivesButton = new Button { Text = "Refresh", AutoSize = true }; _refreshDrivesButton.Click += (_, _) => RefreshDrives(); _statusLabel = new Label { Dock = DockStyle.Fill, AutoSize = true, ForeColor = Color.DimGray, Text = "Select a removable drive (SD card reader).", }; layout.Controls.Add(new Label { Text = "Drive:", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 0); layout.Controls.Add(_driveCombo, 1, 0); layout.Controls.Add(_refreshDrivesButton, 2, 0); layout.SetColumnSpan(_statusLabel, 3); layout.Controls.Add(_statusLabel, 0, 1); group.Controls.Add(layout); return group; } private Control BuildVariantPanel() { var group = new GroupBox { Text = "OmniNX Pack Variant", Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(10) }; var flow = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true }; _variantLight = new RadioButton { Text = "Light", AutoSize = true, Margin = new Padding(0, 0, 24, 0) }; _variantStandard = new RadioButton { Text = "Standard", AutoSize = true, Checked = true, Margin = new Padding(0, 0, 24, 0) }; _variantOc = new RadioButton { Text = "OC", AutoSize = true }; flow.Controls.AddRange([_variantLight, _variantStandard, _variantOc]); group.Controls.Add(flow); return group; } private Control BuildRamPanel() { var group = new GroupBox { Text = "RAM / Hekate Payload", Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(10) }; var layout = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, ColumnCount = 1 }; _ram4Gb = new RadioButton { Text = "4 GB console (default)", AutoSize = true, Checked = true, }; _ram8Gb = new RadioButton { Text = "8 GB console (RAM mod) — copies 8GB hekate payload to payload.bin and bootloader/update.bin", AutoSize = true, }; var warn = new Label { Text = "Warning: Only select 8 GB if the console has an 8 GB RAM mod. Wrong selection can cause boot issues.", AutoSize = true, ForeColor = Color.DarkOrange, MaximumSize = new Size(820, 0), Margin = new Padding(0, 6, 0, 0), }; layout.Controls.Add(_ram4Gb); layout.Controls.Add(_ram8Gb); layout.Controls.Add(warn); group.Controls.Add(layout); return group; } private Control BuildHekatePanel() { var group = new GroupBox { Text = "Hekate / Atmosphere", Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(10) }; var layout = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, ColumnCount = 1 }; _hekateStandard = new RadioButton { Text = "Standard Hekate / Atmosphere", AutoSize = true, Checked = true, }; _hekatePro = new RadioButton { Text = "Hekate-Pro / Atmosphere-Pro (only for emuMMC on eMMC)", AutoSize = true, }; var hint = new Label { Text = "Pro variant replaces bootloader/atmosphere files from extras/hekate-ams-pro/.", AutoSize = true, ForeColor = Color.DimGray, Margin = new Padding(0, 6, 0, 0), }; layout.Controls.Add(_hekateStandard); layout.Controls.Add(_hekatePro); layout.Controls.Add(hint); group.Controls.Add(layout); return group; } private Control BuildOptionsPanel() { _forceCleanCheck = new CheckBox { Text = "Force clean install (full wipe with backup/restore) even if OmniNX is already installed", AutoSize = true, MaximumSize = new Size(820, 0), }; return _forceCleanCheck; } private Control BuildActionPanel() { var panel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = false }; _installButton = new Button { Text = "Download && Install", AutoSize = true, Height = 36, Font = new Font(Font.FontFamily, 10F, FontStyle.Bold), }; _installButton.Click += async (_, _) => await RunInstallAsync(); _progressBar = new ProgressBar { Width = 500, Height = 24, Margin = new Padding(16, 6, 0, 0) }; panel.Controls.Add(_installButton); panel.Controls.Add(_progressBar); return panel; } private Control BuildLogPanel() { var group = new GroupBox { Text = "Log", Dock = DockStyle.Fill, Padding = new Padding(10) }; _logBox = new TextBox { Dock = DockStyle.Fill, Multiline = true, ScrollBars = ScrollBars.Vertical, ReadOnly = true, Font = new Font("Consolas", 9F), BackColor = Color.FromArgb(24, 24, 24), ForeColor = Color.Gainsboro, }; group.Controls.Add(_logBox); return group; } private void RefreshDrives() { DriveService.DriveItem? selected = _driveCombo.SelectedItem as DriveService.DriveItem; _driveCombo.Items.Clear(); foreach (var drive in DriveService.GetCandidateDrives()) _driveCombo.Items.Add(drive); if (selected is not null) { for (var i = 0; i < _driveCombo.Items.Count; i++) { if (_driveCombo.Items[i] is DriveService.DriveItem d && d.Drive.Name.Equals(selected.Drive.Name, StringComparison.OrdinalIgnoreCase)) { _driveCombo.SelectedIndex = i; return; } } } if (_driveCombo.Items.Count > 0) _driveCombo.SelectedIndex = 0; else UpdateInstallationStatus(); } private DriveInfo? SelectedDrive => (_driveCombo.SelectedItem as DriveService.DriveItem)?.Drive; private OmniNxVariant SelectedVariant { get { if (_variantOc.Checked) return OmniNxVariant.Oc; if (_variantLight.Checked) return OmniNxVariant.Light; return OmniNxVariant.Standard; } } private void UpdateInstallationStatus() { var drive = SelectedDrive; if (drive is null) { _statusLabel.Text = "No drive selected."; _statusLabel.ForeColor = Color.DimGray; return; } try { var status = _versionDetect.DetectInstallation(drive.RootDirectory.FullName); if (status.IsInstalled) { _statusLabel.Text = $"OmniNX detected: {VariantHelper.GetDisplayName(status.Variant)} v{status.Version} — " + (_forceCleanCheck.Checked ? "will clean install" : "will update"); _statusLabel.ForeColor = Color.ForestGreen; } else { _statusLabel.Text = "No OmniNX installation detected — will perform clean install."; _statusLabel.ForeColor = Color.SteelBlue; } } catch (Exception ex) { _statusLabel.Text = $"Could not read drive: {ex.Message}"; _statusLabel.ForeColor = Color.Firebrick; } } private async Task LoadLatestReleaseAsync() { try { SetUiEnabled(false); _releaseLabel.Text = "Release: fetching..."; _latestRelease = await _gitea.GetLatestReleaseAsync(); _releaseLabel.Text = $"Latest release: {_latestRelease.TagName} ({_latestRelease.Name})"; Log($"Latest release: {_latestRelease.TagName}"); } catch (Exception ex) { _releaseLabel.Text = "Release: fetch failed"; Log($"Failed to fetch release: {ex.Message}", error: true); } finally { SetUiEnabled(true); } } private async Task RunInstallAsync() { var drive = SelectedDrive; if (drive is null) { MessageBox.Show(this, "Please select a target drive.", "OmniNX Installer", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } if (_latestRelease is null) { MessageBox.Show(this, "Release information is not loaded yet. Click 'Refresh release' first.", "OmniNX Installer", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var variant = SelectedVariant; var asset = _gitea.FindPackAsset(_latestRelease, variant); if (asset is null) { MessageBox.Show(this, $"No zip asset found for {VariantHelper.GetDisplayName(variant)} in release {_latestRelease.TagName}.", "OmniNX Installer", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } var driveRoot = drive.RootDirectory.FullName; var status = _versionDetect.DetectInstallation(driveRoot); var mode = _versionDetect.DetermineMode(status, _forceCleanCheck.Checked); var modeText = mode == InstallMode.Update ? "UPDATE" : "CLEAN INSTALL"; var confirm = MessageBox.Show(this, $"Target drive: {drive.Name}\n" + $"Variant: {VariantHelper.GetDisplayName(variant)}\n" + $"Mode: {modeText}\n" + $"Release: {_latestRelease.TagName}\n" + $"RAM: {(_ram8Gb.Checked ? "8 GB" : "4 GB")}\n" + $"Hekate/AMS: {(_hekatePro.Checked ? "Pro" : "Standard")}\n\n" + "All data on paths targeted by the installer may be modified or deleted.\n" + "Continue?", "Confirm installation", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2); if (confirm != DialogResult.Yes) return; _operationCts?.Cancel(); _operationCts = new CancellationTokenSource(); var token = _operationCts.Token; try { SetUiEnabled(false); _progressBar.Value = 0; _logBox.Clear(); var cacheDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OmniNX-Desktop-Installer", "cache"); PathUtils.EnsureDirectory(cacheDir); var zipPath = Path.Combine(cacheDir, asset.Name); if (!File.Exists(zipPath)) { Log($"Downloading {asset.Name} ({asset.Size / (1024 * 1024)} MB)..."); await _gitea.DownloadAssetAsync(asset, zipPath, new Progress<(long, long)>(p => { var (downloaded, total) = p; if (total > 0) { var pct = (int)(downloaded * 100 / total); _progressBar.Value = Math.Clamp(pct, 0, 100); } }), token); Log("Download complete."); } else { Log($"Using cached pack: {zipPath}"); } var extractRoot = Path.Combine(cacheDir, "extract", _latestRelease.TagName); var staging = ZipExtractor.ExtractPack(zipPath, extractRoot, new Progress(msg => Log(msg))); Log($"Pack staging: {staging}"); var options = new InstallOptions { DriveRoot = driveRoot, Variant = variant, Mode = mode, Use8GbRam = _ram8Gb.Checked, UseHekateAmsPro = _hekatePro.Checked, ForceCleanInstall = _forceCleanCheck.Checked, StagingPath = staging, }; var progress = new Progress(report => { Log(report.Message, report.IsError); if (report.Percent is int pct) _progressBar.Value = Math.Clamp(pct, 0, 100); }); await _installEngine.RunAsync(options, progress, token); _progressBar.Value = 100; MessageBox.Show(this, mode == InstallMode.Update ? "OmniNX update completed successfully." : "OmniNX installation completed successfully.", "OmniNX Installer", MessageBoxButtons.OK, MessageBoxIcon.Information); UpdateInstallationStatus(); } catch (OperationCanceledException) { Log("Operation cancelled.", error: true); } catch (Exception ex) { Log($"Installation failed: {ex.Message}", error: true); MessageBox.Show(this, ex.Message, "Installation failed", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { SetUiEnabled(true); } } private void Log(string message, bool error = false) { if (InvokeRequired) { BeginInvoke(() => Log(message, error)); return; } var prefix = $"[{DateTime.Now:HH:mm:ss}] "; _logBox.AppendText(prefix + message + Environment.NewLine); if (error) _statusLabel.ForeColor = Color.Firebrick; } private void SetUiEnabled(bool enabled) { if (InvokeRequired) { BeginInvoke(() => SetUiEnabled(enabled)); return; } _installButton.Enabled = enabled; _checkReleaseButton.Enabled = enabled; _refreshDrivesButton.Enabled = enabled; _driveCombo.Enabled = enabled; _variantLight.Enabled = enabled; _variantStandard.Enabled = enabled; _variantOc.Enabled = enabled; _ram4Gb.Enabled = enabled; _ram8Gb.Enabled = enabled; _hekateStandard.Enabled = enabled; _hekatePro.Enabled = enabled; _forceCleanCheck.Enabled = enabled; } protected override void OnLoad(EventArgs e) { base.OnLoad(e); _forceCleanCheck.CheckedChanged += (_, _) => UpdateInstallationStatus(); } }