Files
covenant-setup/ui/Covenant.Setup.Ui/InstallerUiWindow.cs
T
2026-06-11 12:18:42 -05:00

839 lines
27 KiB
C#

using System.Text;
using System.Text.Json;
using Microsoft.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Windows.ApplicationModel.DataTransfer;
using Windows.Graphics;
using Windows.UI;
using WinRT.Interop;
namespace Covenant.Setup.Ui;
internal sealed class InstallerUiWindow : Window, IInstallerView
{
private readonly string _pipeName;
private readonly TextBlock _statusText;
private readonly ProgressBar _progressBar;
private readonly TextBox _logBox;
private readonly Button _copyErrorButton;
private readonly Button _saveErrataButton;
private readonly Button _cancelButton;
private readonly StringBuilder _logText = new();
private bool _canClose;
private bool _closeRequested;
private string? _errataJson;
private string? _failureMessage;
private string? _errorDetails;
private InstallerSessionController? _sessionController;
private int _currentStep;
private volatile bool _cancelRequested;
// Assigned by BuildContent/BuildWelcomePanel before the constructor returns.
private TextBlock _welcomeHeaderTitle = null!;
private TextBlock _welcomeInfoText = null!;
private TextBlock _welcomePathText = null!;
private Image _brandingImage = null!;
private FrameworkElement _brandingPlaceholder = null!;
private TextBlock _welcomeTitle = null!;
private Grid _welcomePanel = null!;
private Grid _progressPanel = null!;
private TaskCompletionSource<string>? _welcomeTcs;
public InstallerUiWindow(string pipeName)
{
_pipeName = pipeName;
Title = "covenant-setup";
_statusText = new TextBlock
{
Text = "Preparing...",
TextTrimming = TextTrimming.CharacterEllipsis,
VerticalAlignment = VerticalAlignment.Center
};
_progressBar = new ProgressBar
{
Minimum = 0,
Maximum = 100,
Height = 24
};
_logBox = new TextBox
{
AcceptsReturn = true,
FontFamily = new FontFamily("Consolas"),
FontSize = 13,
IsReadOnly = true,
TextWrapping = TextWrapping.NoWrap
};
ScrollViewer.SetVerticalScrollBarVisibility(_logBox, ScrollBarVisibility.Auto);
ScrollViewer.SetHorizontalScrollBarVisibility(_logBox, ScrollBarVisibility.Auto);
_copyErrorButton = new Button
{
Content = "Copy",
IsEnabled = false,
MinWidth = 88,
Visibility = Visibility.Collapsed
};
_copyErrorButton.Click += (_, _) => CopyErrorToClipboard();
_saveErrataButton = new Button
{
Content = "Save error data to local errata.json file?",
IsEnabled = false,
MinWidth = 320,
Visibility = Visibility.Collapsed
};
_saveErrataButton.Click += async (_, _) => await SaveErrataAsync();
_cancelButton = new Button
{
Content = "Cancel",
IsEnabled = true,
MinWidth = 88
};
_cancelButton.Click += (_, _) =>
{
// _canClose tracks which face the button shows: false = "Cancel"
// during the run, true = "Close" after the terminal message.
if (_canClose)
{
_closeRequested = true;
Close();
}
else
{
RequestCancel();
}
};
Content = BuildContent();
ConfigureWindow();
}
public void StartPipeLoop()
{
_sessionController = new InstallerSessionController(_pipeName, this);
_ = Task.Run(_sessionController.Run);
}
private Grid BuildContent()
{
var root = new Grid();
// 1. Progress Panel
_progressPanel = new Grid
{
Padding = new Thickness(12),
RowSpacing = 12,
Visibility = Visibility.Collapsed
};
_progressPanel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
_progressPanel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
_progressPanel.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
_progressPanel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
Grid.SetRow(_statusText, 0);
_progressPanel.Children.Add(_statusText);
Grid.SetRow(_progressBar, 1);
_progressPanel.Children.Add(_progressBar);
Grid.SetRow(_logBox, 2);
_progressPanel.Children.Add(_logBox);
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8
};
buttonPanel.Children.Add(_copyErrorButton);
buttonPanel.Children.Add(_saveErrataButton);
buttonPanel.Children.Add(_cancelButton);
Grid.SetRow(buttonPanel, 3);
_progressPanel.Children.Add(buttonPanel);
// 2. Welcome Panel
_welcomePanel = BuildWelcomePanel();
_welcomePanel.Visibility = Visibility.Collapsed; // Collapsed initially until ShowWelcomeAsync is called
root.Children.Add(_progressPanel);
root.Children.Add(_welcomePanel);
return root;
}
private Grid BuildWelcomePanel()
{
var grid = new Grid
{
Padding = new Thickness(16),
ColumnSpacing = 16
};
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(260) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
// Left Column: Branding Image Frame
_brandingImage = new Image
{
Stretch = Stretch.UniformToFill,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
Visibility = Visibility.Collapsed
};
// Fallback Gradient Placeholder
var placeholderBorder = new Border
{
CornerRadius = new CornerRadius(8),
Background = new LinearGradientBrush
{
StartPoint = new Windows.Foundation.Point(0, 0),
EndPoint = new Windows.Foundation.Point(1, 1),
GradientStops =
{
new GradientStop { Color = Color.FromArgb(255, 99, 102, 241), Offset = 0 }, // Indigo
new GradientStop { Color = Color.FromArgb(255, 168, 85, 247), Offset = 0.5 }, // Purple
new GradientStop { Color = Color.FromArgb(255, 236, 72, 153), Offset = 1 } // Pink
}
}
};
var placeholderContent = new StackPanel
{
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
Spacing = 16
};
placeholderContent.Children.Add(new FontIcon
{
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 56,
Glyph = "\uE7B8", // Package/Box icon
Foreground = new SolidColorBrush(Colors.White)
});
_welcomeTitle = new TextBlock
{
FontSize = 20,
FontWeight = Microsoft.UI.Text.FontWeights.Bold,
Foreground = new SolidColorBrush(Colors.White),
TextAlignment = TextAlignment.Center,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 220
};
placeholderContent.Children.Add(_welcomeTitle);
placeholderBorder.Child = placeholderContent;
_brandingPlaceholder = placeholderBorder;
var brandingBorder = new Border
{
CornerRadius = new CornerRadius(8),
Background = new SolidColorBrush(Color.FromArgb(15, 128, 128, 128)),
BorderThickness = new Thickness(1),
BorderBrush = new SolidColorBrush(Color.FromArgb(30, 128, 128, 128)),
Child = new Grid
{
Children = { _brandingPlaceholder, _brandingImage }
}
};
Grid.SetColumn(brandingBorder, 0);
grid.Children.Add(brandingBorder);
// Right Column: Information Panel
var rightCol = new Grid
{
RowSpacing = 16
};
rightCol.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
rightCol.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
rightCol.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
// Row 0: Title and Subtitle
var titlePanel = new StackPanel { Spacing = 4 };
_welcomeHeaderTitle = new TextBlock
{
Text = "App Installer",
FontSize = 24,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
TextWrapping = TextWrapping.Wrap
};
var subtitleText = new TextBlock
{
Text = "powered by Covenant Setup",
FontSize = 12,
Foreground = new SolidColorBrush(Color.FromArgb(180, 128, 128, 128)),
FontStyle = Windows.UI.Text.FontStyle.Italic
};
titlePanel.Children.Add(_welcomeHeaderTitle);
titlePanel.Children.Add(subtitleText);
Grid.SetRow(titlePanel, 0);
rightCol.Children.Add(titlePanel);
// Row 1: Info and Path box
var infoPanel = new StackPanel { Spacing = 8, VerticalAlignment = VerticalAlignment.Center };
_welcomeInfoText = new TextBlock
{
Text = "This installer will install the application to the folder specified below:",
TextWrapping = TextWrapping.Wrap,
FontSize = 14
};
var pathBorder = new Border
{
Background = new SolidColorBrush(Color.FromArgb(15, 128, 128, 128)),
BorderBrush = new SolidColorBrush(Color.FromArgb(30, 128, 128, 128)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(6),
Padding = new Thickness(12),
Margin = new Thickness(0, 4, 0, 0)
};
_welcomePathText = new TextBlock
{
FontFamily = new FontFamily("Consolas"),
FontSize = 13,
TextWrapping = TextWrapping.Wrap,
IsTextSelectionEnabled = true
};
pathBorder.Child = _welcomePathText;
infoPanel.Children.Add(_welcomeInfoText);
infoPanel.Children.Add(pathBorder);
Grid.SetRow(infoPanel, 1);
rightCol.Children.Add(infoPanel);
// Row 2: Buttons
var welcomeButtons = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8
};
var welcomeCancelBtn = new Button
{
Content = "Cancel",
MinWidth = 88
};
welcomeCancelBtn.Click += (_, _) => OnWelcomeCancelClicked();
var welcomeInstallBtn = new Button
{
Content = "Install",
MinWidth = 88,
Style = (Style)Microsoft.UI.Xaml.Application.Current.Resources["AccentButtonStyle"]
};
welcomeInstallBtn.Click += (_, _) => OnWelcomeInstallClicked();
welcomeButtons.Children.Add(welcomeCancelBtn);
welcomeButtons.Children.Add(welcomeInstallBtn);
Grid.SetRow(welcomeButtons, 2);
rightCol.Children.Add(welcomeButtons);
Grid.SetColumn(rightCol, 1);
grid.Children.Add(rightCol);
return grid;
}
private void ConfigureWindow()
{
var hwnd = WindowNative.GetWindowHandle(this);
var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
var appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Title = Title;
appWindow.Closing += (_, args) =>
{
if (_welcomeTcs != null && !_welcomeTcs.Task.IsCompleted)
{
_welcomeTcs.TrySetResult("cancel");
_closeRequested = true;
return;
}
if (!_canClose && !_closeRequested)
{
// Keep the window open through the rollback; SetCanClose(true)
// on the terminal message makes X work again. RequestCancel
// guards itself against firing twice.
args.Cancel = true;
RequestCancel();
}
};
const int width = 760;
const int height = 480;
var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary);
var workArea = displayArea.WorkArea;
var x = workArea.X + Math.Max(0, (workArea.Width - width) / 2);
var y = workArea.Y + Math.Max(0, (workArea.Height - height) / 2);
appWindow.MoveAndResize(new RectInt32(x, y, width, height));
}
public Task<string> ShowWelcomeAsync(string appName, string installDir, string? brandingImage)
{
_welcomeTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
BeginInvokeSafe(() =>
{
_welcomePanel.Visibility = Visibility.Visible;
_progressPanel.Visibility = Visibility.Collapsed;
_welcomeHeaderTitle.Text = $"{appName} Installer";
_welcomeInfoText.Text = $"This installer will install {appName} to:";
_welcomePathText.Text = installDir;
_welcomeTitle.Text = appName;
if (!string.IsNullOrEmpty(brandingImage) && File.Exists(brandingImage))
{
try
{
_brandingImage.Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(new Uri(brandingImage));
_brandingImage.Visibility = Visibility.Visible;
_brandingPlaceholder.Visibility = Visibility.Collapsed;
}
catch
{
_brandingImage.Visibility = Visibility.Collapsed;
_brandingPlaceholder.Visibility = Visibility.Visible;
}
}
else
{
_brandingImage.Visibility = Visibility.Collapsed;
_brandingPlaceholder.Visibility = Visibility.Visible;
}
});
return _welcomeTcs.Task;
}
private void OnWelcomeInstallClicked()
{
_welcomeTcs?.TrySetResult("install");
BeginInvokeSafe(() =>
{
_welcomePanel.Visibility = Visibility.Collapsed;
_progressPanel.Visibility = Visibility.Visible;
});
}
private void OnWelcomeCancelClicked()
{
_welcomeTcs?.TrySetResult("cancel");
_closeRequested = true;
Close();
}
public void ShowInit(string title, string message)
{
BeginInvokeSafe(() =>
{
Title = title;
_statusText.Text = message;
_progressBar.Value = 0;
_cancelButton.Content = "Cancel";
_cancelButton.IsEnabled = true;
_welcomePanel.Visibility = Visibility.Collapsed;
_progressPanel.Visibility = Visibility.Visible;
});
}
public void ShowProgress(int percent, string? message, int currentStep = 0)
{
BeginInvokeSafe(() =>
{
_currentStep = currentStep;
if (!string.IsNullOrWhiteSpace(message))
{
_statusText.Text = message;
}
_progressBar.Value = percent;
});
}
public void AppendLog(string message)
{
BeginInvokeSafe(() =>
{
if (string.IsNullOrWhiteSpace(message))
{
return;
}
if (_logText.Length > 0)
{
_logText.AppendLine();
}
_logText.Append(message);
_logBox.Text = _logText.ToString();
_logBox.SelectionStart = _logBox.Text.Length;
});
}
public void ShowFinished(string message)
{
BeginInvokeSafe(() =>
{
// The window stays open showing the result; the user dismisses it
// with the Close button (the engine waits for the process to exit).
_statusText.Text = message;
_progressBar.Value = 100;
_cancelButton.Content = "Close";
SetCanClose(true);
});
}
public void ShowFailure(string failureMessage, string? errorDetails, string errataJson)
{
BeginInvokeSafe(() =>
{
var supportContact = ParseSupportContact(errataJson);
_statusText.Text = FormatFailureMessage(failureMessage, supportContact);
_progressBar.Value = 100;
AppendLog(failureMessage);
if (!string.IsNullOrWhiteSpace(errorDetails))
{
AppendLog("Error details: " + errorDetails);
}
if (!string.IsNullOrEmpty(supportContact))
{
AppendLog($"Support contact: {supportContact}");
}
_errataJson = errataJson;
_failureMessage = failureMessage;
_errorDetails = errorDetails;
_saveErrataButton.IsEnabled = !string.IsNullOrWhiteSpace(_errataJson);
_saveErrataButton.Visibility = Visibility.Visible;
_copyErrorButton.IsEnabled = true;
_copyErrorButton.Visibility = Visibility.Visible;
_cancelButton.Content = "Close";
SetCanClose(true);
});
}
public Task<string> ShowPromptAsync(UiMessage message)
{
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
if (_cancelRequested)
{
tcs.SetResult("none");
return tcs.Task;
}
if (!DispatcherQueue.TryEnqueue(async () =>
{
try
{
if (_cancelRequested)
{
tcs.SetResult("none");
return;
}
var buttons = MapButtons(message.Buttons);
var dialog = new ContentDialog
{
Title = message.Title ?? "covenant-setup",
Content = BuildDialogContent(message.Message ?? string.Empty, MapIcon(message.Icon))
};
ConfigureDialogButtons(dialog, buttons);
if (Content is FrameworkElement root)
{
dialog.XamlRoot = root.XamlRoot;
}
var result = ToPromptDialogResult(await dialog.ShowAsync());
UiTrace.Write("prompt_closed", new { message.Id, Result = result.ToString() });
tcs.SetResult(MapDialogResult(result, buttons));
}
catch (Exception ex)
{
tcs.SetException(ex);
}
}))
{
tcs.SetResult("none");
}
return tcs.Task;
}
public void CloseView()
{
BeginInvokeSafe(() =>
{
_closeRequested = true;
Close();
});
}
public void ShowPipeError(string message)
{
BeginInvokeSafe(() =>
{
AppendLog("UI pipe error: " + message);
_cancelButton.Content = "Close";
SetCanClose(true);
});
}
private void RequestCancel()
{
if (_cancelRequested)
{
return;
}
_cancelRequested = true;
// Disable (don't hide) so the button cannot double-fire; the terminal
// finish/fail message re-enables it as "Close" via SetCanClose(true).
_cancelButton.IsEnabled = false;
_statusText.Text = "Cancelling - reverting changes...";
UiTrace.Write("cancel_requested", new { _currentStep });
// The pipe stays open: the engine rolls back over the same session and
// sends rollback progress followed by the terminal message.
_sessionController?.RequestCancel();
}
internal static string BuildCopyText(string? failureMessage, string? errorDetails, string? errataJson)
{
var builder = new StringBuilder();
if (!string.IsNullOrWhiteSpace(failureMessage))
{
builder.AppendLine(failureMessage);
}
if (!string.IsNullOrWhiteSpace(errorDetails))
{
builder.AppendLine("Error details: " + errorDetails);
}
if (!string.IsNullOrWhiteSpace(errataJson))
{
builder.AppendLine("Errata:");
builder.AppendLine(errataJson);
}
return builder.ToString().TrimEnd();
}
private void CopyErrorToClipboard()
{
try
{
var text = BuildCopyText(_failureMessage, _errorDetails, _errataJson);
if (string.IsNullOrEmpty(text))
{
return;
}
var package = new DataPackage();
package.SetText(text);
Clipboard.SetContent(package);
AppendLog("Copied error details to clipboard");
}
catch (Exception ex)
{
UiTrace.Write("copy_error_failed", new { ex.Message, ex.GetType().FullName });
AppendLog("Unable to copy error details: " + ex.Message);
}
}
private async Task SaveErrataAsync()
{
if (string.IsNullOrWhiteSpace(_errataJson))
{
return;
}
try
{
var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(root))
{
root = Environment.CurrentDirectory;
}
var directory = Path.Combine(root, "CovenantSetup");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "errata.json");
File.WriteAllText(path, _errataJson, new UTF8Encoding(false));
AppendLog("Saved error data to " + path);
await ShowNoticeAsync("Error data saved to " + path, PromptIconKind.Information);
}
catch (Exception ex)
{
UiTrace.Write("errata_save_error", new { ex.Message, ex.GetType().FullName, ex.StackTrace });
await ShowNoticeAsync("Unable to save errata.json: " + ex.Message, PromptIconKind.Error);
}
}
private async Task ShowNoticeAsync(string message, PromptIconKind icon)
{
var dialog = new ContentDialog
{
Title = "covenant-setup",
CloseButtonText = "OK",
Content = BuildDialogContent(message, icon)
};
if (Content is FrameworkElement root)
{
dialog.XamlRoot = root.XamlRoot;
}
_ = await dialog.ShowAsync();
}
private static FrameworkElement BuildDialogContent(string message, PromptIconKind icon)
{
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 12
};
panel.Children.Add(new FontIcon
{
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 20,
Glyph = icon switch
{
PromptIconKind.Error => "\uE783",
PromptIconKind.Warning => "\uE7BA",
_ => "\uE946"
},
Foreground = new SolidColorBrush(icon switch
{
PromptIconKind.Error => Colors.Firebrick,
PromptIconKind.Warning => Colors.DarkGoldenrod,
_ => Colors.DodgerBlue
})
});
panel.Children.Add(new TextBlock
{
Text = message,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 480
});
return panel;
}
internal static PromptButtonSet MapButtons(string? buttons) => buttons switch
{
"ok_cancel" => PromptButtonSet.OkCancel,
"yes_no" => PromptButtonSet.YesNo,
_ => PromptButtonSet.Ok
};
internal static PromptIconKind MapIcon(string? icon) => icon switch
{
"error" => PromptIconKind.Error,
"warning" => PromptIconKind.Warning,
_ => PromptIconKind.Information
};
internal static string? ParseSupportContact(string errataJson)
{
try
{
using var doc = JsonDocument.Parse(errataJson);
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
doc.RootElement.TryGetProperty("support_contact", out var contactProp) &&
contactProp.ValueKind == JsonValueKind.String)
{
return contactProp.GetString();
}
}
catch (JsonException)
{
}
return null;
}
internal static string FormatFailureMessage(string failureMessage, string? supportContact) =>
string.IsNullOrEmpty(supportContact)
? failureMessage
: $"{failureMessage}\n\nFor support, please contact: {supportContact}";
internal static string MapDialogResult(PromptDialogResult result, PromptButtonSet buttons) => result switch
{
PromptDialogResult.Primary when buttons == PromptButtonSet.YesNo => "yes",
PromptDialogResult.Primary => "ok",
PromptDialogResult.Secondary when buttons == PromptButtonSet.YesNo => "no",
PromptDialogResult.Secondary => "cancel",
PromptDialogResult.Close when buttons == PromptButtonSet.Ok => "ok",
PromptDialogResult.Close when buttons == PromptButtonSet.OkCancel => "cancel",
_ => "none"
};
private static PromptDialogResult ToPromptDialogResult(ContentDialogResult result) => result switch
{
ContentDialogResult.Primary => PromptDialogResult.Primary,
ContentDialogResult.Secondary => PromptDialogResult.Secondary,
_ => PromptDialogResult.Close
};
private static void ConfigureDialogButtons(ContentDialog dialog, PromptButtonSet buttons)
{
switch (buttons)
{
case PromptButtonSet.OkCancel:
dialog.PrimaryButtonText = "OK";
dialog.CloseButtonText = "Cancel";
dialog.DefaultButton = ContentDialogButton.Primary;
break;
case PromptButtonSet.YesNo:
dialog.PrimaryButtonText = "Yes";
dialog.SecondaryButtonText = "No";
dialog.DefaultButton = ContentDialogButton.Primary;
break;
default:
dialog.CloseButtonText = "OK";
dialog.DefaultButton = ContentDialogButton.Close;
break;
}
}
private void BeginInvokeSafe(Action action)
{
try
{
if (DispatcherQueue.HasThreadAccess)
{
action();
return;
}
_ = DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal, () =>
{
try
{
action();
}
catch (Exception ex)
{
UiTrace.Write("ui_dispatch_error", new { ex.Message, ex.GetType().FullName, ex.StackTrace });
}
});
}
catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException)
{
}
}
private void SetCanClose(bool canClose)
{
_canClose = canClose;
_cancelButton.IsEnabled = canClose;
}
}