446 lines
13 KiB
C#
446 lines
13 KiB
C#
using System.IO.Pipes;
|
|
using System.Diagnostics;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace Covenant.Setup.Ui;
|
|
|
|
internal static class Program
|
|
{
|
|
[STAThread]
|
|
private static void Main(string[] args)
|
|
{
|
|
var pipeName = ReadPipeName(args);
|
|
UiTrace.Write("process_start", new { ProcessId = Environment.ProcessId, PipeName = pipeName });
|
|
if (string.IsNullOrWhiteSpace(pipeName))
|
|
{
|
|
UiTrace.Write("missing_pipe_argument");
|
|
MessageBox.Show("Missing named pipe argument.", "covenant-setup", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
return;
|
|
}
|
|
|
|
Application.EnableVisualStyles();
|
|
Application.SetCompatibleTextRenderingDefault(false);
|
|
Application.Run(new InstallerUiForm(pipeName));
|
|
}
|
|
|
|
private static string? ReadPipeName(string[] args)
|
|
{
|
|
for (var i = 0; i < args.Length - 1; i++)
|
|
{
|
|
if (string.Equals(args[i], "--pipe", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return args[i + 1];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
internal sealed class InstallerUiForm : Form
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
private readonly string _pipeName;
|
|
private readonly Label _statusLabel;
|
|
private readonly ProgressBar _progressBar;
|
|
private readonly TextBox _logBox;
|
|
private readonly Button _closeButton;
|
|
private StreamWriter? _writer;
|
|
private readonly object _writerLock = new();
|
|
private bool _closeRequested;
|
|
|
|
public InstallerUiForm(string pipeName)
|
|
{
|
|
_pipeName = pipeName;
|
|
|
|
Text = "covenant-setup";
|
|
StartPosition = FormStartPosition.CenterScreen;
|
|
ClientSize = new Size(720, 420);
|
|
MinimumSize = new Size(560, 320);
|
|
Font = new Font("Segoe UI", 9F);
|
|
|
|
_statusLabel = new Label
|
|
{
|
|
AutoEllipsis = true,
|
|
Location = new Point(12, 12),
|
|
Size = new Size(ClientSize.Width - 24, 24),
|
|
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
|
|
Text = "Preparing..."
|
|
};
|
|
|
|
_progressBar = new ProgressBar
|
|
{
|
|
Location = new Point(12, 44),
|
|
Size = new Size(ClientSize.Width - 24, 24),
|
|
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
|
|
Minimum = 0,
|
|
Maximum = 100
|
|
};
|
|
|
|
_logBox = new TextBox
|
|
{
|
|
Location = new Point(12, 80),
|
|
Size = new Size(ClientSize.Width - 24, ClientSize.Height - 128),
|
|
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
|
|
Multiline = true,
|
|
ScrollBars = ScrollBars.Vertical,
|
|
ReadOnly = true,
|
|
Font = new Font("Consolas", 9F)
|
|
};
|
|
|
|
_closeButton = new Button
|
|
{
|
|
Text = "Close",
|
|
Enabled = false,
|
|
Size = new Size(88, 28),
|
|
Location = new Point(ClientSize.Width - 100, ClientSize.Height - 40),
|
|
Anchor = AnchorStyles.Bottom | AnchorStyles.Right
|
|
};
|
|
_closeButton.Click += (_, _) => Close();
|
|
|
|
Controls.Add(_statusLabel);
|
|
Controls.Add(_progressBar);
|
|
Controls.Add(_logBox);
|
|
Controls.Add(_closeButton);
|
|
|
|
Shown += (_, _) => _ = Task.Run(RunPipeLoop);
|
|
FormClosing += (_, args) =>
|
|
{
|
|
if (!_closeButton.Enabled && !_closeRequested)
|
|
{
|
|
args.Cancel = true;
|
|
}
|
|
};
|
|
}
|
|
|
|
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);
|
|
_closeButton.Enabled = 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(() =>
|
|
{
|
|
Text = message.Title ?? "covenant-setup";
|
|
_statusLabel.Text = message.Message ?? Text;
|
|
_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(() =>
|
|
{
|
|
_statusLabel.Text = message.Message ?? "Complete";
|
|
_progressBar.Value = 100;
|
|
_closeButton.Enabled = true;
|
|
_closeRequested = true;
|
|
Close();
|
|
});
|
|
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))
|
|
{
|
|
_statusLabel.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 string ShowPrompt(UiMessage message)
|
|
{
|
|
if (InvokeRequired)
|
|
{
|
|
return (string)Invoke(new Func<string>(() => ShowPrompt(message)));
|
|
}
|
|
|
|
var buttons = message.Buttons switch
|
|
{
|
|
"ok_cancel" => MessageBoxButtons.OKCancel,
|
|
"yes_no" => MessageBoxButtons.YesNo,
|
|
_ => MessageBoxButtons.OK
|
|
};
|
|
var icon = message.Icon switch
|
|
{
|
|
"error" => MessageBoxIcon.Error,
|
|
"warning" => MessageBoxIcon.Warning,
|
|
_ => MessageBoxIcon.Information
|
|
};
|
|
|
|
var result = MessageBox.Show(
|
|
this,
|
|
message.Message ?? string.Empty,
|
|
message.Title ?? "covenant-setup",
|
|
buttons,
|
|
icon);
|
|
UiTrace.Write("prompt_closed", new { message.Id, Result = result.ToString() });
|
|
|
|
return result switch
|
|
{
|
|
DialogResult.OK => "ok",
|
|
DialogResult.Cancel => "cancel",
|
|
DialogResult.Yes => "yes",
|
|
DialogResult.No => "no",
|
|
_ => "none"
|
|
};
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (IsDisposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
BeginInvoke(action);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
}
|
|
}
|
|
|
|
private void AppendLog(string line)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_logBox.TextLength > 0)
|
|
{
|
|
_logBox.AppendText(Environment.NewLine);
|
|
}
|
|
_logBox.AppendText(line);
|
|
_logBox.SelectionStart = _logBox.TextLength;
|
|
_logBox.ScrollToCaret();
|
|
}
|
|
|
|
private 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 };
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class UiTrace
|
|
{
|
|
private static readonly object Lock = new();
|
|
private static readonly string? TracePath = CreateTracePath();
|
|
|
|
public static void Write(string phase, object? detail = null)
|
|
{
|
|
if (TracePath is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var line = JsonSerializer.Serialize(new
|
|
{
|
|
time = DateTimeOffset.UtcNow.ToString("o"),
|
|
pid = Environment.ProcessId,
|
|
process = Process.GetCurrentProcess().ProcessName,
|
|
phase,
|
|
detail
|
|
}) + Environment.NewLine;
|
|
lock (Lock)
|
|
{
|
|
File.AppendAllText(TracePath, line, Encoding.UTF8);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
private static string? CreateTracePath()
|
|
{
|
|
try
|
|
{
|
|
var root = Environment.GetEnvironmentVariable("COVENANT_SETUP_TRACE_DIR");
|
|
if (string.IsNullOrWhiteSpace(root))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Directory.CreateDirectory(root);
|
|
return Path.Combine(root, $"csharp-ui-pipe-{Environment.ProcessId}.jsonl");
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed class UiMessage
|
|
{
|
|
[JsonPropertyName("type")]
|
|
public string? Type { get; set; }
|
|
|
|
[JsonPropertyName("id")]
|
|
public string? Id { get; set; }
|
|
|
|
[JsonPropertyName("title")]
|
|
public string? Title { get; set; }
|
|
|
|
[JsonPropertyName("message")]
|
|
public string? Message { get; set; }
|
|
|
|
[JsonPropertyName("current_step")]
|
|
public int? CurrentStep { get; set; }
|
|
|
|
[JsonPropertyName("total_steps")]
|
|
public int? TotalSteps { get; set; }
|
|
|
|
[JsonPropertyName("buttons")]
|
|
public string? Buttons { get; set; }
|
|
|
|
[JsonPropertyName("icon")]
|
|
public string? Icon { get; set; }
|
|
}
|
|
|
|
internal sealed class UiResponse
|
|
{
|
|
[JsonPropertyName("type")]
|
|
public string? Type { get; set; }
|
|
|
|
[JsonPropertyName("id")]
|
|
public string? Id { get; set; }
|
|
|
|
[JsonPropertyName("result")]
|
|
public string? Result { get; set; }
|
|
}
|