Files
covenant-setup/ui/Covenant.Setup.Ui/InstallerUiWindow.cs
T

584 lines
18 KiB
C#

using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
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.Graphics;
using WinRT.Interop;
namespace Covenant.Setup.Ui;
internal sealed class InstallerUiWindow : Window
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly string _pipeName;
private readonly TextBlock _statusText;
private readonly ProgressBar _progressBar;
private readonly TextBox _logBox;
private readonly Button _saveErrataButton;
private readonly Button _closeButton;
private readonly StringBuilder _logText = new();
private readonly object _writerLock = new();
private StreamWriter? _writer;
private bool _canClose;
private bool _closeRequested;
private string? _errataJson;
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);
_saveErrataButton = new Button
{
Content = "Save error data to local errata.json file?",
IsEnabled = false,
MinWidth = 320,
Visibility = Visibility.Collapsed
};
_saveErrataButton.Click += async (_, _) => await SaveErrataAsync();
_closeButton = new Button
{
Content = "Close",
IsEnabled = false,
MinWidth = 88
};
_closeButton.Click += (_, _) =>
{
_closeRequested = true;
Close();
};
Content = BuildContent();
ConfigureWindow();
}
public void StartPipeLoop()
{
_ = Task.Run(RunPipeLoop);
}
private Grid BuildContent()
{
var root = new Grid
{
Padding = new Thickness(12),
RowSpacing = 12
};
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
Grid.SetRow(_statusText, 0);
root.Children.Add(_statusText);
Grid.SetRow(_progressBar, 1);
root.Children.Add(_progressBar);
Grid.SetRow(_logBox, 2);
root.Children.Add(_logBox);
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8
};
buttonPanel.Children.Add(_saveErrataButton);
buttonPanel.Children.Add(_closeButton);
Grid.SetRow(buttonPanel, 3);
root.Children.Add(buttonPanel);
return root;
}
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 (!_canClose && !_closeRequested)
{
args.Cancel = true;
}
};
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));
}
private void RunPipeLoop()
{
try
{
UiTrace.Write("pipe_server_create", new { PipeName = _pipeName });
using var pipe = new NamedPipeServerStream(
_pipeName,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.None);
UiTrace.Write("pipe_wait_for_connection", new { PipeName = _pipeName });
pipe.WaitForConnection();
UiTrace.Write("pipe_connected", new { PipeName = _pipeName });
using var reader = new StreamReader(pipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
using var writer = new StreamWriter(pipe, new UTF8Encoding(false), bufferSize: 4096, leaveOpen: true)
{
AutoFlush = true,
NewLine = "\n"
};
lock (_writerLock)
{
_writer = writer;
}
string? line;
while ((line = reader.ReadLine()) is not null)
{
UiTrace.Write("pipe_receive", SafeMessageSummary(line));
if (!HandleMessage(line))
{
break;
}
}
}
catch (Exception ex)
{
UiTrace.Write("pipe_error", new { ex.Message, ex.GetType().FullName, ex.StackTrace });
BeginInvokeSafe(() =>
{
AppendLog("UI pipe error: " + ex.Message);
SetCanClose(true);
});
}
finally
{
lock (_writerLock)
{
_writer = null;
}
UiTrace.Write("pipe_loop_exit");
}
}
private bool HandleMessage(string line)
{
var message = JsonSerializer.Deserialize<UiMessage>(line, JsonOptions);
if (message?.Type is null)
{
return true;
}
switch (message.Type)
{
case "init":
BeginInvokeSafe(() =>
{
Title = message.Title ?? "covenant-setup";
_statusText.Text = message.Message ?? Title;
_progressBar.Value = 0;
});
return true;
case "progress":
BeginInvokeSafe(() => ApplyProgress(message));
return true;
case "log":
BeginInvokeSafe(() => AppendLog(message.Message ?? string.Empty));
return true;
case "finish":
UiTrace.Write("finish_message", new { message.Message });
BeginInvokeSafe(() =>
{
_statusText.Text = message.Message ?? "Complete";
_progressBar.Value = 100;
SetCanClose(true);
_closeRequested = true;
Close();
});
return true;
case "fail":
UiTrace.Write("fail_message", new { message.AppName, message.Operation, message.Message, message.Error });
BeginInvokeSafe(() => ApplyFailure(message));
return true;
case "prompt":
UiTrace.Write("prompt_show_requested", new { message.Id, message.Title, message.Buttons, message.Icon });
var result = ShowPrompt(message);
UiTrace.Write("prompt_response", new { message.Id, Result = result });
WriteResponse(new UiResponse
{
Type = "prompt_response",
Id = message.Id,
Result = result
});
return true;
case "close":
UiTrace.Write("close_message");
BeginInvokeSafe(() =>
{
_closeRequested = true;
Close();
});
return false;
default:
return true;
}
}
private void ApplyProgress(UiMessage message)
{
if (!string.IsNullOrWhiteSpace(message.Message))
{
_statusText.Text = message.Message;
AppendLog(message.Message);
}
var total = Math.Max(1, message.TotalSteps ?? 1);
var current = Math.Max(0, Math.Min(total, message.CurrentStep ?? 0));
_progressBar.Value = Math.Max(0, Math.Min(100, current * 100 / total));
}
private void ApplyFailure(UiMessage message)
{
var operation = string.IsNullOrWhiteSpace(message.Operation) ? "complete" : message.Operation;
var appName = string.IsNullOrWhiteSpace(message.AppName) ? "unknown" : message.AppName;
var failureMessage = string.IsNullOrWhiteSpace(message.Message)
? $"Error: program {appName} failed to {operation} completely!"
: message.Message;
_statusText.Text = failureMessage;
_progressBar.Value = 100;
AppendLog(failureMessage);
if (!string.IsNullOrWhiteSpace(message.Error))
{
AppendLog("Error details: " + message.Error);
}
_errataJson = BuildErrataJson(message);
_saveErrataButton.IsEnabled = !string.IsNullOrWhiteSpace(_errataJson);
_saveErrataButton.Visibility = Visibility.Visible;
SetCanClose(true);
}
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);
}
}
internal static string BuildErrataJson(UiMessage message)
{
if (message.Errata is JsonElement errata &&
errata.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null)
{
return JsonSerializer.Serialize(errata, new JsonSerializerOptions { WriteIndented = true });
}
return JsonSerializer.Serialize(new
{
app_name = message.AppName,
operation = message.Operation,
message = message.Message,
error = message.Error
}, new JsonSerializerOptions { WriteIndented = true });
}
private string ShowPrompt(UiMessage message)
{
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!DispatcherQueue.TryEnqueue(async () =>
{
try
{
tcs.SetResult(await ShowPromptAsync(message));
}
catch (Exception ex)
{
tcs.SetException(ex);
}
}))
{
return "none";
}
return tcs.Task.GetAwaiter().GetResult();
}
private async Task<string> ShowPromptAsync(UiMessage message)
{
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() });
return MapDialogResult(result, buttons);
}
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 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 WriteResponse(UiResponse response)
{
lock (_writerLock)
{
_writer?.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
UiTrace.Write("pipe_send", new { response.Type, response.Id, response.Result });
}
}
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;
_closeButton.IsEnabled = canClose;
}
private void AppendLog(string line)
{
if (string.IsNullOrWhiteSpace(line))
{
return;
}
if (_logText.Length > 0)
{
_logText.AppendLine();
}
_logText.Append(line);
_logBox.Text = _logText.ToString();
_logBox.SelectionStart = _logBox.Text.Length;
}
internal static object SafeMessageSummary(string line)
{
try
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
return new
{
Type = root.TryGetProperty("type", out var type) ? type.GetString() : null,
Id = root.TryGetProperty("id", out var id) ? id.GetString() : null,
Message = root.TryGetProperty("message", out var message) ? message.GetString() : null
};
}
catch
{
return new { RawLength = line.Length };
}
}
}