(feature): show unhandled UI errors in a copyable dialog and keep the desktop shortcut on reinstall

This commit is contained in:
Maksym Sadovnychyy 2026-09-30 20:26:47 +02:00
parent 296e8c1a47
commit bacaec389e
18 changed files with 517 additions and 25 deletions

View File

@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.3.0] - 2026-09-30
### Added
- Unhandled UI errors open a dialog with the exception details and a **Copy** button. The same text is saved under `%AppData%\MaksIT\UScheduler\logs`.
### Fixed
- Windows setup keeps an existing desktop shortcut when the checkbox is off on reinstall, and removes a leftover duplicate (`UScheduler (2).lnk`) so a checked box does not leave two icons.
## [1.2.0] - 2026-09-21
### Added

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 25.1%">
<title>Branch Coverage: 25.1%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 30.3%">
<title>Branch Coverage: 30.3%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">25.1%</text>
<text x="128.75" y="14" fill="#fff">25.1%</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">30.3%</text>
<text x="128.75" y="14" fill="#fff">30.3%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,21 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="134.5" height="20" role="img" aria-label="Line Coverage: 30%">
<title>Line Coverage: 30%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 34.3%">
<title>Line Coverage: 34.3%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="134.5" height="20" rx="3" fill="#fff"/>
<rect width="137" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="40" height="20" fill="#dfb317"/>
<rect width="134.5" height="20" fill="url(#s)"/>
<rect x="94.5" width="42.5" height="20" fill="#dfb317"/>
<rect width="137" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="114.5" y="15" fill="#010101" fill-opacity=".3">30%</text>
<text x="114.5" y="14" fill="#fff">30%</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">34.3%</text>
<text x="115.75" y="14" fill="#fff">34.3%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 46.3%">
<title>Method Coverage: 46.3%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 51.3%">
<title>Method Coverage: 51.3%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">46.3%</text>
<text x="128.75" y="14" fill="#fff">46.3%</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">51.3%</text>
<text x="128.75" y="14" fill="#fff">51.3%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -39,6 +39,12 @@
<display_length compare="ge">768</display_length>
</requires>
<releases>
<release version="1.3.0" date="2026-09-30">
<url type="details">https://github.com/MAKS-IT-COM/uscheduler/blob/v1.3.0/CHANGELOG.md</url>
<description>
<p>Unhandled UI errors open a copyable dialog and are saved to a crash log. Windows setup keeps an existing desktop shortcut on reinstall.</p>
</description>
</release>
<release version="1.2.0" date="2026-09-21">
<url type="details">https://github.com/MAKS-IT-COM/uscheduler/blob/v1.2.0/CHANGELOG.md</url>
<description>

View File

@ -0,0 +1,78 @@
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using MaksIT.UScheduler.Shared.Helpers;
namespace MaksIT.UScheduler.Shared;
/// <summary>Formats unhandled exceptions for a copyable dialog and a crash log file.</summary>
public static class ErrorReport {
public static string Capture(Exception exception) {
ArgumentNullException.ThrowIfNull(exception);
var body = Format(exception);
var path = TryWrite(body);
if (string.IsNullOrWhiteSpace(path))
return body;
return body + Environment.NewLine + Environment.NewLine + "Log: " + path;
}
public static string Format(Exception exception) {
ArgumentNullException.ThrowIfNull(exception);
var text = new StringBuilder();
text.AppendLine("UScheduler " + Version());
text.AppendLine("MaksIT");
text.AppendLine(DateTimeOffset.UtcNow.ToString("u"));
text.AppendLine(RuntimeInformation.OSDescription);
text.AppendLine(RuntimeInformation.FrameworkDescription);
text.AppendLine((Environment.Is64BitProcess ? "64-bit" : "32-bit") + " process");
text.AppendLine();
AppendException(text, exception);
return text.ToString().TrimEnd();
}
public static string? TryWrite(string report) {
try {
var dir = UserSettingsPath.LogsDirectory(HostPaths.ProductFolder);
Directory.CreateDirectory(dir);
var name = "crash-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Environment.ProcessId + ".txt";
var path = Path.Combine(dir, name);
File.WriteAllText(path, report ?? "");
return path;
}
catch {
return null;
}
}
private static string Version() {
var version = Assembly.GetEntryAssembly()?.GetName().Version;
return version is null ? "" : version.ToString();
}
private static void AppendException(StringBuilder text, Exception exception) {
var seen = new HashSet<Exception>();
var current = exception;
var depth = 0;
while (current is not null && seen.Add(current)) {
if (depth > 0)
text.AppendLine().AppendLine("--- inner ---");
text.AppendLine(current.GetType().FullName);
text.AppendLine(current.Message);
if (!string.IsNullOrWhiteSpace(current.StackTrace))
text.AppendLine(current.StackTrace);
if (current is AggregateException aggregate) {
foreach (var inner in aggregate.InnerExceptions) {
if (inner is null || ReferenceEquals(inner, current.InnerException) || !seen.Add(inner))
continue;
text.AppendLine().AppendLine("--- aggregate ---");
text.AppendLine(inner.ToString());
}
}
current = current.InnerException;
depth++;
}
}
}

View File

@ -17,6 +17,13 @@ public static class UserSettingsPath {
public static string Get(string product, string fileName = "settings.json") =>
Combine(Environment.SpecialFolder.ApplicationData, product, fileName);
public static string LogsDirectory(string product) =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
Manufacturer,
product,
"logs");
/// <summary>
/// Machine-wide settings shared by every user and the Windows service
/// (LocalSystem). On Windows this is all-users AppData:

View File

@ -0,0 +1,40 @@
using MaksIT.UScheduler.Shared;
namespace MaksIT.UScheduler.Tests;
public class ErrorReportTests {
[Fact]
public void Format_IncludesTypeMessageInnerAndStack() {
Exception thrown;
try {
throw new InvalidOperationException("outer", new ArgumentException("inner"));
}
catch (Exception ex) {
thrown = ex;
}
var text = ErrorReport.Format(thrown);
Assert.Contains("UScheduler", text, StringComparison.Ordinal);
Assert.Contains("MaksIT", text, StringComparison.Ordinal);
Assert.Contains("InvalidOperationException", text, StringComparison.Ordinal);
Assert.Contains("outer", text, StringComparison.Ordinal);
Assert.Contains("--- inner ---", text, StringComparison.Ordinal);
Assert.Contains("ArgumentException", text, StringComparison.Ordinal);
Assert.Contains("inner", text, StringComparison.Ordinal);
Assert.Contains(nameof(Format_IncludesTypeMessageInnerAndStack), text, StringComparison.Ordinal);
}
[Fact]
public void Format_IncludesAggregateInners() {
var error = new AggregateException(
"batch",
new InvalidOperationException("one"),
new ArgumentException("two"));
var text = ErrorReport.Format(error);
Assert.Contains("AggregateException", text, StringComparison.Ordinal);
Assert.Contains("one", text, StringComparison.Ordinal);
Assert.Contains("two", text, StringComparison.Ordinal);
Assert.Contains("--- aggregate ---", text, StringComparison.Ordinal);
}
}

View File

@ -0,0 +1,146 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using MaksIT.UScheduler.Shared;
using MaksIT.UScheduler.UI.ViewModels;
using MaksIT.UScheduler.UI.Windows;
namespace MaksIT.UScheduler.UI.Dialogs;
internal static class ErrorDialog {
private static int _open;
public static void Report(Exception? exception) =>
Present(exception, wait: false);
public static void ReportBlocking(Exception? exception) =>
Present(exception, wait: true);
private static void Present(Exception? exception, bool wait) {
if (exception is null)
return;
var report = ErrorReport.Capture(exception);
var dispatcher = TryDispatcher();
if (dispatcher is null) {
if (wait)
ShowStandalone(report);
return;
}
if (dispatcher.CheckAccess()) {
if (wait)
ShowUntilClosed(report);
else
_ = ShowAsync(report);
return;
}
if (wait)
dispatcher.Invoke(() => ShowUntilClosed(report));
else
dispatcher.Post(() => _ = ShowAsync(report));
}
private static Dispatcher? TryDispatcher() {
try {
return Dispatcher.UIThread;
}
catch {
return null;
}
}
private static async Task ShowAsync(string report) {
if (Interlocked.Exchange(ref _open, 1) != 0)
return;
try {
var window = Create(report);
var owner = ActiveWindow();
if (owner is { IsVisible: true }) {
window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
await window.ShowDialog(owner);
return;
}
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
var closed = new TaskCompletionSource();
window.Closed += (_, _) => closed.TrySetResult();
window.Show();
await closed.Task;
}
catch {
}
finally {
Interlocked.Exchange(ref _open, 0);
}
}
private static void ShowUntilClosed(string report) {
if (Interlocked.Exchange(ref _open, 1) != 0)
return;
try {
var window = Create(report);
var owner = ActiveWindow();
var closed = false;
window.Closed += (_, _) => closed = true;
if (owner is { IsVisible: true }) {
window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
window.Show(owner);
}
else {
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
window.Show();
}
var dispatcher = Dispatcher.UIThread;
while (!closed)
dispatcher.RunJobs();
}
catch {
}
finally {
Interlocked.Exchange(ref _open, 0);
}
}
private static void ShowStandalone(string report) {
if (Interlocked.Exchange(ref _open, 1) != 0)
return;
try {
AppBuilder.Configure<Application>()
.UsePlatformDetect()
.AfterSetup(builder => {
if (builder.Instance?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life)
return;
life.ShutdownMode = ShutdownMode.OnMainWindowClose;
life.MainWindow = Create(report);
})
.StartWithClassicDesktopLifetime([]);
}
catch {
}
finally {
Interlocked.Exchange(ref _open, 0);
}
}
private static ErrorWindow Create(string report) =>
new() {
DataContext = new ErrorReportViewModel(report),
WindowStartupLocation = WindowStartupLocation.CenterScreen
};
private static Window? ActiveWindow() {
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life)
return null;
foreach (var window in life.Windows) {
if (window.IsActive && window is not ErrorWindow)
return window;
}
return life.MainWindow is ErrorWindow ? null : life.MainWindow;
}
}

View File

@ -4,7 +4,7 @@
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>MaksIT.UScheduler.UI</RootNamespace>
<Version>1.2.0</Version>
<Version>1.3.0</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>

View File

@ -1,18 +1,48 @@
using Avalonia;
using Avalonia.Logging;
using Avalonia.Threading;
using MaksIT.UScheduler.UI.Dialogs;
namespace MaksIT.UScheduler.UI;
internal static class Program {
[STAThread]
public static void Main(string[] args) =>
public static void Main(string[] args) {
AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandled;
TaskScheduler.UnobservedTaskException += OnUnobservedTask;
try {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
catch (Exception ex) {
ErrorDialog.ReportBlocking(ex);
}
}
// Linux: X11/XWayland. Avalonia 12.1.2 native Wayland still hangs on GNOME's
// xdg_toplevel.configure(0, 0) and never maps a window (GNOME app icon).
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace(LogEventLevel.Warning);
.LogToTrace(LogEventLevel.Warning)
.AfterSetup(_ => Dispatcher.UIThread.UnhandledException += OnDispatcherUnhandled);
private static void OnDispatcherUnhandled(object? sender, DispatcherUnhandledExceptionEventArgs e) {
e.Handled = true;
ErrorDialog.Report(e.Exception);
}
private static void OnDomainUnhandled(object? sender, UnhandledExceptionEventArgs e) {
if (e.ExceptionObject is not Exception ex)
return;
if (e.IsTerminating)
ErrorDialog.ReportBlocking(ex);
else
ErrorDialog.Report(ex);
}
private static void OnUnobservedTask(object? sender, UnobservedTaskExceptionEventArgs e) {
e.SetObserved();
ErrorDialog.Report(e.Exception);
}
}

View File

@ -11,6 +11,12 @@
<Setter Property="Foreground" Value="#9aa0a6" />
<Setter Property="FontSize" Value="12" />
</Style>
<Style Selector="TextBox.error-dump">
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Ubuntu Mono, monospace" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Background" Value="#0E1620" />
<Setter Property="Foreground" Value="#E8F4FC" />
</Style>
<Style Selector="TextBlock.warning">
<Setter Property="Foreground" Value="#ff4d6d" />
<Setter Property="FontWeight" Value="SemiBold" />

View File

@ -0,0 +1,19 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace MaksIT.UScheduler.UI.ViewModels;
public sealed partial class ErrorReportViewModel : ObservableObject {
public ErrorReportViewModel(string report) {
Report = report ?? "";
}
public string Report { get; }
[ObservableProperty]
private string copyStatus = "";
public void MarkCopied() =>
CopyStatus = "Copied";
}

View File

@ -0,0 +1,47 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MaksIT.UScheduler.UI.ViewModels"
x:Class="MaksIT.UScheduler.UI.Windows.ErrorWindow"
x:DataType="vm:ErrorReportViewModel"
Width="720"
Height="520"
MinWidth="480"
MinHeight="360"
CanResize="True"
ShowInTaskbar="True"
Topmost="True"
WindowStartupLocation="CenterOwner"
Icon="/Assets/icon.ico"
Title="UScheduler">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Bottom"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,12,0,0">
<TextBlock Text="{Binding CopyStatus}"
Classes="hint"
VerticalAlignment="Center"
Margin="0,0,8,0" />
<Button Content="Copy"
Click="OnCopyClick" />
<Button Content="Close"
Padding="16,8"
Click="OnCloseClick"
IsDefault="True"
IsCancel="True" />
</StackPanel>
<TextBlock DockPanel.Dock="Top"
Text="Something went wrong. The details below are also saved to a crash log."
Classes="hint"
TextWrapping="Wrap"
Margin="0,0,0,12" />
<TextBox Text="{Binding Report}"
Classes="error-dump"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</DockPanel>
</Window>

View File

@ -0,0 +1,31 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using MaksIT.UScheduler.UI.ViewModels;
namespace MaksIT.UScheduler.UI.Windows;
public partial class ErrorWindow : Window {
public ErrorWindow() {
InitializeComponent();
}
private async void OnCopyClick(object? sender, RoutedEventArgs e) {
if (DataContext is not ErrorReportViewModel vm)
return;
var clipboard = Clipboard;
if (clipboard is null)
return;
try {
await clipboard.SetTextAsync(vm.Report);
vm.MarkCopied();
}
catch {
}
}
private void OnCloseClick(object? sender, RoutedEventArgs e) =>
Close();
}

View File

@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<Version>1.2.0</Version>
<Version>1.3.0</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>

View File

@ -323,8 +323,33 @@ function New-WixPackageXml {
$desktopProp = $xml.CreateElement('Property', $ns)
$null = $desktopProp.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT')
$null = $desktopProp.SetAttribute('Value', '0')
$null = $desktopProp.SetAttribute('Secure', 'yes')
$null = $package.AppendChild($desktopProp)
# Present when a previous install wrote the desktop shortcut marker.
# Burn also searches HKCU as the user and passes DESKTOPSHORTCUTFOUND,
# because a per-machine execute sequence runs as SYSTEM.
$foundProp = $xml.CreateElement('Property', $ns)
$null = $foundProp.SetAttribute('Id', 'DESKTOPSHORTCUTFOUND')
$null = $foundProp.SetAttribute('Secure', 'yes')
$foundSearch = $xml.CreateElement('RegistrySearch', $ns)
$null = $foundSearch.SetAttribute('Id', 'DesktopShortcutFoundSearch')
$null = $foundSearch.SetAttribute('Root', 'HKCU')
$null = $foundSearch.SetAttribute('Key', "Software\$Manufacturer\$AppName")
$null = $foundSearch.SetAttribute('Name', 'desktop')
$null = $foundSearch.SetAttribute('Type', 'raw')
$null = $foundProp.AppendChild($foundSearch)
$null = $package.AppendChild($foundProp)
# Checkbox off on reinstall must not drop an icon that is already there.
# Major upgrade removes the previous shortcut; this installs that one again.
$keepShortcut = $xml.CreateElement('SetProperty', $ns)
$null = $keepShortcut.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT')
$null = $keepShortcut.SetAttribute('Value', '1')
$null = $keepShortcut.SetAttribute('Before', 'CostFinalize')
$null = $keepShortcut.SetAttribute('Condition', 'DESKTOPSHORTCUTFOUND = "1"')
$null = $package.AppendChild($keepShortcut)
$productFolder = Get-DesktopInstallFolderName `
-AppName $AppName `
-Manufacturer $Manufacturer `
@ -449,7 +474,8 @@ function New-WixPackageXml {
$desktop = $xml.CreateElement('Component', $ns)
$null = $desktop.SetAttribute('Id', 'DesktopShortcut')
$null = $desktop.SetAttribute('Directory', 'DesktopFolder')
$null = $desktop.SetAttribute('Guid', '*')
$shortcutGuid = Get-DesktopShortcutComponentGuid -Manufacturer $Manufacturer -AppName $AppName
$null = $desktop.SetAttribute('Guid', $shortcutGuid.ToString('D'))
$null = $desktop.SetAttribute('Condition', 'INSTALLDESKTOPSHORTCUT = 1')
$desktopShortcut = $xml.CreateElement('Shortcut', $ns)
$null = $desktopShortcut.SetAttribute('Id', 'AppDesktopShortcut')
@ -461,6 +487,26 @@ function New-WixPackageXml {
}
$null = $desktop.AppendChild($desktopShortcut)
# Same file name is replaced. A second ".lnk" (and the "(2)" copy) is removed
# before CreateShortcuts so a checked box does not leave two desktop icons.
$cleanupNames = [System.Collections.Generic.List[string]]::new()
$cleanupNames.Add($productFolder)
if (-not $cleanupNames.Contains($AppName)) {
$cleanupNames.Add($AppName)
}
$cleanupIndex = 0
foreach ($shortcutName in $cleanupNames) {
foreach ($suffix in @('', ' (2)')) {
$cleanupIndex++
$removeLnk = $xml.CreateElement('RemoveFile', $ns)
$null = $removeLnk.SetAttribute('Id', "DesktopShortcutCleanup$cleanupIndex")
$null = $removeLnk.SetAttribute('Name', "$shortcutName$suffix.lnk")
$null = $removeLnk.SetAttribute('On', 'install')
$null = $desktop.AppendChild($removeLnk)
}
}
$desktopReg = $xml.CreateElement('RegistryValue', $ns)
$null = $desktopReg.SetAttribute('Root', 'HKCU')
$null = $desktopReg.SetAttribute('Key', "Software\$Manufacturer\$AppName")
@ -874,6 +920,27 @@ function Assert-FlatpakAppId {
}
}
function Get-DesktopShortcutComponentGuid {
param(
[Parameter(Mandatory = $true)]
[string]$Manufacturer,
[Parameter(Mandatory = $true)]
[string]$AppName
)
$identity = "maksit-desktop-shortcut|$Manufacturer|$AppName"
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$hash = $md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($identity))
}
finally {
$md5.Dispose()
}
return [guid]::new($hash)
}
function Get-DerivedBundleUpgradeCode {
param(
[Parameter(Mandatory = $true)]
@ -1196,15 +1263,18 @@ function New-WixBundleXml {
$folderRoot + $Manufacturer + '\' + $productFolder
}
$escapedFolder = [System.Security.SecurityElement]::Escape($folderPath)
$escapedShortcutKey = [System.Security.SecurityElement]::Escape("Software\$Manufacturer\$AppName")
# WiX v7 Bundle has no Scope attribute (WIX0004). MSI Package/@Scope plus
# InstallFolder tokens decide per-machine vs per-user; Burn infers bundle scope.
return @"
<?xml version="1.0" encoding="utf-8"?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal">
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal" xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
<Bundle Name="$escapedName" Manufacturer="$escapedMfr" Version="$ProductVersion" UpgradeCode="$($bundleUpgrade.ToString('D'))"$iconAttr>
<Variable Name="InstallFolder" Type="formatted" Value="$escapedFolder" bal:Overridable="yes" />
<Variable Name="InstallDesktopShortcut" Type="numeric" Value="0" bal:Overridable="yes" />
<Variable Name="DesktopShortcutFound" Type="string" Value="" bal:Overridable="yes" />
<util:RegistrySearch Id="DesktopShortcutFoundSearch" Variable="DesktopShortcutFound" Root="HKCU" Key="$escapedShortcutKey" Value="desktop" Result="exists" />
<BootstrapperApplication>
<bal:WixStandardBootstrapperApplication Theme="$theme" LicenseUrl=""$logoAttrs />
</BootstrapperApplication>
@ -1212,6 +1282,7 @@ function New-WixBundleXml {
<MsiPackage SourceFile="$escapedMsi" Compressed="yes" Vital="yes">
<MsiProperty Name="INSTALLFOLDER" Value="[InstallFolder]" />
<MsiProperty Name="INSTALLDESKTOPSHORTCUT" Value="[InstallDesktopShortcut]" />
<MsiProperty Name="DESKTOPSHORTCUTFOUND" Value="[DesktopShortcutFound]" />
</MsiPackage>
</Chain>
</Bundle>

View File

@ -13,7 +13,8 @@
per-machine installs go to `C:\Program Files`, not Program Files (x86).
Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the
OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and
`wix extension add -g WixToolset.BootstrapperApplications.wixext`.
`wix extension add -g WixToolset.BootstrapperApplications.wixext` and
`wix extension add -g WixToolset.Util.wixext`.
Reopen the shell so `%USERPROFILE%\.dotnet\tools` is on PATH.
#>
@ -40,7 +41,7 @@ function Test-WixMissingException {
}
function Get-WixMissingInstallMessage {
return "WiX CLI ('wix') is not on PATH. Install with: dotnet tool install -g wix. For WiX v7 also run: wix eula accept wix7; wix extension add -g WixToolset.BootstrapperApplications.wixext. Reopen the terminal so %USERPROFILE%\.dotnet\tools is on PATH."
return "WiX CLI ('wix') is not on PATH. Install with: dotnet tool install -g wix. For WiX v7 also run: wix eula accept wix7; wix extension add -g WixToolset.BootstrapperApplications.wixext; wix extension add -g WixToolset.Util.wixext. Reopen the terminal so %USERPROFILE%\.dotnet\tools is on PATH."
}
function Get-WixCliVersionText {
@ -280,7 +281,7 @@ function Invoke-Plugin {
Write-Log -Level "STEP" -Message "Building Windows installer exe ($wixArch)..."
try {
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-o', $exePath)) | Out-Null
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-ext', 'WixToolset.Util.wixext', '-o', $exePath)) | Out-Null
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {