Desktop installer: release channel, saved choices, robust delete, logs
Build / Build (push) Successful in 13s
Build Desktop Installer / Build (win-arm64) (push) Successful in 10s
Build Desktop Installer / Build (win-x64) (push) Successful in 10s

- 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 <Variant>" 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.
This commit is contained in:
niklascfw committed 2026-11-14 13:10:41 +01:00
1 parent 3e0d9c9950
commit 91133f7098
6 files changed
+207 -9

No files matched your search

+143 -6
View File
@@ -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)
@@ -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
@@ -17,9 +17,15 @@ public sealed class GiteaClient
_baseUrl = (baseUrl ?? DefaultBaseUrl).TrimEnd('/');
}
public async Task<ReleaseInfo> 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<ReleaseInfo> 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<GiteaReleaseDto>(url, cancellationToken)
?? throw new InvalidOperationException("Empty release response from Gitea.");
@@ -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<InstallProgressReport> 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<InstallProgressReport> progress, string message, bool isError = false)
{
progress.Report(new InstallProgressReport { Message = message, IsError = isError });
@@ -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)
@@ -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,
};
}