From 91133f70988be784aa222aefecb88ed6dce62ca0 Mon Sep 17 00:00:00 2001 From: niklascfw Date: Sat, 14 Nov 2026 13:10:41 +0100 Subject: [PATCH] Desktop installer: release channel, saved choices, robust delete, logs - Channel radio (Release / Master (Instabil)); master installs the rolling 'master' release from OmniNX/OmniNX-Master. Preselected from release_channel in the card's manifest.ini. - Variant, RAM (8gb) and Hekate-Pro choice are prefilled from the card (manifest.ini / config.ini) like the payload does, instead of silently switching users back to 4 GB / Standard. - Clear read-only attributes before deleting folders. - Remove leftover "OmniNX " pack folders after installing. - Create empty folders from the pack. - Save every run's log to %LocalAppData%\OmniNX-Desktop-Installer\logs, plus a "Save log" button. --- desktop/OmniNX.DesktopInstaller/MainForm.cs | 149 +++++++++++++++++- .../Models/InstallModels.cs | 7 + .../Services/GiteaClient.cs | 10 +- .../Services/InstallEngine.cs | 25 +++ .../Services/PathUtils.cs | 19 ++- .../Services/VersionDetectService.cs | 6 + 6 files changed, 207 insertions(+), 9 deletions(-) diff --git a/desktop/OmniNX.DesktopInstaller/MainForm.cs b/desktop/OmniNX.DesktopInstaller/MainForm.cs index 20561d8..d8dd9b2 100644 --- a/desktop/OmniNX.DesktopInstaller/MainForm.cs +++ b/desktop/OmniNX.DesktopInstaller/MainForm.cs @@ -9,12 +9,16 @@ public sealed class MainForm : Form private readonly GiteaClient _gitea = new(); private readonly VersionDetectService _versionDetect = new(); private readonly InstallEngine _installEngine = new(); + private readonly ConfigIniService _configIni = new(); private ComboBox _driveCombo = null!; private Button _refreshDrivesButton = null!; private Label _statusLabel = null!; private Label _releaseLabel = null!; private Button _checkReleaseButton = null!; + private RadioButton _channelRelease = null!; + private RadioButton _channelMaster = null!; + private Button _saveLogButton = null!; private RadioButton _variantLight = null!; private RadioButton _variantStandard = null!; private RadioButton _variantOc = null!; @@ -81,10 +85,11 @@ public sealed class MainForm : Form AutoSize = true, Dock = DockStyle.Fill, ColumnCount = 1, - RowCount = 2, + RowCount = 3, }; panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var title = new Label { @@ -115,11 +120,39 @@ public sealed class MainForm : Form releaseRow.Controls.Add(_releaseLabel); releaseRow.Controls.Add(_checkReleaseButton); + // Release channel: stable releases or the master build (internal testing) + _channelRelease = new RadioButton { Text = "Release", AutoSize = true, Checked = true, Margin = new Padding(0, 0, 16, 0) }; + _channelMaster = new RadioButton { Text = "Master (Instabil)", AutoSize = true, ForeColor = Color.DarkOrange }; + _channelRelease.CheckedChanged += OnChannelChanged; + _channelMaster.CheckedChanged += OnChannelChanged; + + var channelRow = new FlowLayoutPanel + { + AutoSize = true, + WrapContents = false, + Dock = DockStyle.Fill, + Margin = new Padding(0, 0, 0, 4), + }; + channelRow.Controls.Add(new Label { Text = "Channel:", AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(0, 3, 8, 0) }); + channelRow.Controls.Add(_channelRelease); + channelRow.Controls.Add(_channelMaster); + panel.Controls.Add(title, 0, 0); panel.Controls.Add(releaseRow, 0, 1); + panel.Controls.Add(channelRow, 0, 2); return panel; } + private ReleaseChannel SelectedChannel => + _channelMaster.Checked ? ReleaseChannel.Master : ReleaseChannel.Release; + + private void OnChannelChanged(object? sender, EventArgs e) + { + // Fires for both radios; react once, on the one that became checked. + if (sender is RadioButton { Checked: true }) + _ = LoadLatestReleaseAsync(); + } + private static Image? LoadEmbeddedImage(string resourceName) { var stream = typeof(MainForm).Assembly.GetManifestResourceStream(resourceName); @@ -157,7 +190,11 @@ public sealed class MainForm : Form layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); _driveCombo = new ComboBox { Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList }; - _driveCombo.SelectedIndexChanged += (_, _) => UpdateInstallationStatus(); + _driveCombo.SelectedIndexChanged += (_, _) => + { + UpdateInstallationStatus(); + PrefillFromDrive(); + }; _refreshDrivesButton = new Button { Text = "Refresh", AutoSize = true }; _refreshDrivesButton.Click += (_, _) => RefreshDrives(); @@ -285,8 +322,12 @@ public sealed class MainForm : Form _progressBar = new ProgressBar { Width = 500, Height = 24, Margin = new Padding(16, 6, 0, 0) }; + _saveLogButton = new Button { Text = "Save log", AutoSize = true, Margin = new Padding(16, 4, 0, 0) }; + _saveLogButton.Click += (_, _) => SaveLogAs(); + panel.Controls.Add(_installButton); panel.Controls.Add(_progressBar); + panel.Controls.Add(_saveLogButton); return panel; } @@ -380,18 +421,67 @@ public sealed class MainForm : Form } } + // Take over what the card already uses: saved RAM / Hekate-Pro choice (config.ini, same as + // the payload) and the release channel from manifest.ini. Unknown values fall back to defaults. + private void PrefillFromDrive() + { + var drive = SelectedDrive; + if (drive is null) + return; + + var root = drive.RootDirectory.FullName; + try + { + var use8Gb = _configIni.TryReadBool(root, "Ram", "8gb", out var ram) && ram; + _ram8Gb.Checked = use8Gb; + _ram4Gb.Checked = !use8Gb; + + var usePro = _configIni.TryReadBool(root, "HekateAms", "pro", out var pro) && pro; + _hekatePro.Checked = usePro; + _hekateStandard.Checked = !usePro; + + var status = _versionDetect.DetectInstallation(root); + if (status.IsInstalled) + { + _variantLight.Checked = status.Variant == OmniNxVariant.Light; + _variantStandard.Checked = status.Variant == OmniNxVariant.Standard; + _variantOc.Checked = status.Variant == OmniNxVariant.Oc; + + var master = status.Channel == ReleaseChannel.Master; + if (_channelMaster.Checked != master) + { + _channelMaster.Checked = master; + _channelRelease.Checked = !master; + } + } + + if (use8Gb || usePro) + Log($"Saved choice on card: RAM {(use8Gb ? "8 GB" : "4 GB")}, Hekate/AMS {(usePro ? "Pro" : "Standard")}"); + } + catch (Exception ex) + { + Log($"Could not read saved settings from {drive.Name}: {ex.Message}", error: true); + } + } + 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}"); + var channel = SelectedChannel; + _latestRelease = await _gitea.GetLatestReleaseAsync(channel); + _releaseLabel.Text = channel == ReleaseChannel.Master + ? $"Master build: {_latestRelease.Name}" + : $"Latest release: {_latestRelease.TagName} ({_latestRelease.Name})"; + Log(channel == ReleaseChannel.Master + ? $"Master build: {_latestRelease.Name}" + : $"Latest release: {_latestRelease.TagName}"); } catch (Exception ex) { + _latestRelease = null; _releaseLabel.Text = "Release: fetch failed"; Log($"Failed to fetch release: {ex.Message}", error: true); } @@ -437,7 +527,7 @@ public sealed class MainForm : Form $"Target drive: {drive.Name}\n" + $"Variant: {VariantHelper.GetDisplayName(variant)}\n" + $"Mode: {modeText}\n" + - $"Release: {_latestRelease.TagName}\n" + + $"Release: {(SelectedChannel == ReleaseChannel.Master ? "Master (Instabil) " + _latestRelease.Name : _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" + @@ -572,10 +662,54 @@ public sealed class MainForm : Form } finally { + SaveLogToAppData(); SetUiEnabled(true); } } + private static string LogDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OmniNX-Desktop-Installer", + "logs"); + + // Every install run is kept as a log file for support requests (called on the UI thread). + private void SaveLogToAppData() + { + try + { + PathUtils.EnsureDirectory(LogDirectory); + var path = Path.Combine(LogDirectory, $"install-{DateTime.Now:yyyyMMdd-HHmmss}.log"); + File.WriteAllText(path, _logBox.Text); + Log($"Log saved: {path}"); + } + catch (Exception ex) + { + Log($"Could not save log: {ex.Message}", error: true); + } + } + + private void SaveLogAs() + { + using var dialog = new SaveFileDialog + { + Title = "Save log", + Filter = "Log files (*.log)|*.log|Text files (*.txt)|*.txt", + FileName = $"omninx-installer-{DateTime.Now:yyyyMMdd-HHmmss}.log", + }; + if (dialog.ShowDialog(this) != DialogResult.OK) + return; + + try + { + File.WriteAllText(dialog.FileName, _logBox.Text); + Log($"Log saved: {dialog.FileName}"); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Save log", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + private void Log(string message, bool error = false) { if (InvokeRequired) @@ -610,6 +744,9 @@ public sealed class MainForm : Form _hekateStandard.Enabled = enabled; _hekatePro.Enabled = enabled; _forceCleanCheck.Enabled = enabled; + _channelRelease.Enabled = enabled; + _channelMaster.Enabled = enabled; + _saveLogButton.Enabled = enabled; } protected override void OnLoad(EventArgs e) diff --git a/desktop/OmniNX.DesktopInstaller/Models/InstallModels.cs b/desktop/OmniNX.DesktopInstaller/Models/InstallModels.cs index 6800780..2af9b64 100644 --- a/desktop/OmniNX.DesktopInstaller/Models/InstallModels.cs +++ b/desktop/OmniNX.DesktopInstaller/Models/InstallModels.cs @@ -8,6 +8,12 @@ public enum OmniNxVariant Oc, } +public enum ReleaseChannel +{ + Release, + Master, +} + public enum InstallMode { Clean, @@ -30,6 +36,7 @@ public sealed class OmniNxStatus public bool IsInstalled { get; init; } public OmniNxVariant Variant { get; init; } public string Version { get; init; } = string.Empty; + public ReleaseChannel Channel { get; init; } = ReleaseChannel.Release; } public sealed class ReleaseInfo diff --git a/desktop/OmniNX.DesktopInstaller/Services/GiteaClient.cs b/desktop/OmniNX.DesktopInstaller/Services/GiteaClient.cs index c46aaca..ad8aef1 100644 --- a/desktop/OmniNX.DesktopInstaller/Services/GiteaClient.cs +++ b/desktop/OmniNX.DesktopInstaller/Services/GiteaClient.cs @@ -17,9 +17,15 @@ public sealed class GiteaClient _baseUrl = (baseUrl ?? DefaultBaseUrl).TrimEnd('/'); } - public async Task GetLatestReleaseAsync(CancellationToken cancellationToken = default) + // Master builds: rolling release "master" in OmniNX/OmniNX-Master (built from main by CI). + private const string MasterReleasePath = "/api/v1/repos/OmniNX/OmniNX-Master/releases/tags/master"; + private const string StableReleasePath = "/api/v1/repos/OmniNX/OmniNX/releases/latest"; + + public async Task GetLatestReleaseAsync( + ReleaseChannel channel = ReleaseChannel.Release, + CancellationToken cancellationToken = default) { - var url = $"{_baseUrl}/api/v1/repos/OmniNX/OmniNX/releases/latest"; + var url = _baseUrl + (channel == ReleaseChannel.Master ? MasterReleasePath : StableReleasePath); var response = await _http.GetFromJsonAsync(url, cancellationToken) ?? throw new InvalidOperationException("Empty release response from Gitea."); diff --git a/desktop/OmniNX.DesktopInstaller/Services/InstallEngine.cs b/desktop/OmniNX.DesktopInstaller/Services/InstallEngine.cs index 2a81f67..57ac443 100644 --- a/desktop/OmniNX.DesktopInstaller/Services/InstallEngine.cs +++ b/desktop/OmniNX.DesktopInstaller/Services/InstallEngine.cs @@ -32,6 +32,8 @@ public sealed class InstallEngine await RunCleanAsync(driveRoot, staging, options, progress, cancellationToken); } + await Task.Run(() => RemovePackFoldersOnDrive(driveRoot, progress), cancellationToken); + Report(progress, options.Mode == InstallMode.Update ? "Update completed successfully." : "Installation completed successfully."); @@ -177,6 +179,29 @@ public sealed class InstallEngine } } + // Same as the payload: pack folders copied to the card for the console installer + // ("OmniNX Light/Standard/OC") are not needed after a desktop install. + private static void RemovePackFoldersOnDrive(string driveRoot, IProgress progress) + { + foreach (var variant in new[] { OmniNxVariant.Light, OmniNxVariant.Standard, OmniNxVariant.Oc }) + { + var folder = VariantHelper.GetStagingFolderName(variant); + var path = Path.Combine(PathUtils.NormalizeDriveRoot(driveRoot), folder); + if (!Directory.Exists(path)) + continue; + + Report(progress, $"Removing leftover pack folder: {folder}"); + try + { + PathUtils.DeletePath(path); + } + catch (Exception ex) + { + Report(progress, $" Could not remove {folder}: {ex.Message}", isError: true); + } + } + } + private static void Report(IProgress progress, string message, bool isError = false) { progress.Report(new InstallProgressReport { Message = message, IsError = isError }); diff --git a/desktop/OmniNX.DesktopInstaller/Services/PathUtils.cs b/desktop/OmniNX.DesktopInstaller/Services/PathUtils.cs index bf992fe..7794969 100644 --- a/desktop/OmniNX.DesktopInstaller/Services/PathUtils.cs +++ b/desktop/OmniNX.DesktopInstaller/Services/PathUtils.cs @@ -91,6 +91,11 @@ public static class PathUtils if (!Directory.Exists(src)) return; + // Folders first, so empty folders from the pack exist on the SD as well. + Directory.CreateDirectory(dst); + foreach (var dir in Directory.GetDirectories(src, "*", SearchOption.AllDirectories)) + Directory.CreateDirectory(Path.Combine(dst, Path.GetRelativePath(src, dir))); + var files = Directory.GetFiles(src, "*", SearchOption.AllDirectories); var total = files.Length; var current = 0; @@ -115,7 +120,19 @@ public static class PathUtils } if (Directory.Exists(path)) - Directory.Delete(path, recursive: true); + { + // Directory.Delete fails on read-only entries (common on cards from other packs or macOS). + var root = new DirectoryInfo(path); + foreach (var entry in root.EnumerateFileSystemInfos("*", SearchOption.AllDirectories)) + { + if (entry.Attributes.HasFlag(FileAttributes.ReadOnly)) + entry.Attributes &= ~FileAttributes.ReadOnly; + } + if (root.Attributes.HasFlag(FileAttributes.ReadOnly)) + root.Attributes &= ~FileAttributes.ReadOnly; + + root.Delete(recursive: true); + } } public static int CountItemsRecursive(string path) diff --git a/desktop/OmniNX.DesktopInstaller/Services/VersionDetectService.cs b/desktop/OmniNX.DesktopInstaller/Services/VersionDetectService.cs index 760ec04..a874e9c 100644 --- a/desktop/OmniNX.DesktopInstaller/Services/VersionDetectService.cs +++ b/desktop/OmniNX.DesktopInstaller/Services/VersionDetectService.cs @@ -14,6 +14,7 @@ public sealed class VersionDetectService var variant = OmniNxVariant.None; var version = string.Empty; + var channel = ReleaseChannel.Release; var inSection = false; foreach (var rawLine in File.ReadLines(manifest)) @@ -36,6 +37,10 @@ public sealed class VersionDetectService variant = VariantHelper.ParsePackName(val); else if (key.Equals("version", StringComparison.Ordinal)) version = val; + else if (key.Equals("release_channel", StringComparison.Ordinal)) + channel = val.Equals("master", StringComparison.OrdinalIgnoreCase) + ? ReleaseChannel.Master + : ReleaseChannel.Release; } return new OmniNxStatus @@ -43,6 +48,7 @@ public sealed class VersionDetectService IsInstalled = variant != OmniNxVariant.None, Variant = variant, Version = version, + Channel = channel, }; }