diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-06-22 07:21:10 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-06-22 07:21:10 +0800 |
| commit | 5e16ca4f41b2ae0b1e0d57be340cbf28286fe1f7 (patch) | |
| tree | 72e4afcc6b440e3121af79b245e6bd04c7398b7a | |
| parent | e6bf6f5091caf3b4366894643671bc4586794cda (diff) | |
feat(manager): implement GUI dashboard with config management
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | manager/App.axaml | 16 | ||||
| -rw-r--r-- | manager/MainWindow.axaml | 247 | ||||
| -rw-r--r-- | manager/MainWindow.axaml.cs | 441 | ||||
| -rw-r--r-- | manager/MessageBox.axaml | 25 | ||||
| -rw-r--r-- | manager/MessageBox.axaml.cs | 60 | ||||
| -rw-r--r-- | manager/ProcessWindow.axaml | 43 | ||||
| -rw-r--r-- | manager/ProcessWindow.axaml.cs | 104 | ||||
| -rw-r--r-- | manager/Program.cs | 8 | ||||
| -rw-r--r-- | manager/manager.csproj | 18 |
10 files changed, 936 insertions, 27 deletions
@@ -1,3 +1,4 @@ /target /models /build +/.idea diff --git a/manager/App.axaml b/manager/App.axaml index d9eaaee..7b5a966 100644 --- a/manager/App.axaml +++ b/manager/App.axaml @@ -1,10 +1,10 @@ -<Application xmlns="https://github.com/avaloniaui" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - x:Class="manager.App" - RequestedThemeVariant="Default"> - <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. --> - +<Application + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + x:Class="manager.App" + RequestedThemeVariant="Default" +> <Application.Styles> - <FluentTheme /> + <SimpleTheme /> </Application.Styles> -</Application>
\ No newline at end of file +</Application> diff --git a/manager/MainWindow.axaml b/manager/MainWindow.axaml index 1aa61b8..84337ad 100644 --- a/manager/MainWindow.axaml +++ b/manager/MainWindow.axaml @@ -1,9 +1,240 @@ -<Window xmlns="https://github.com/avaloniaui" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="manager.MainWindow" - Title="manager"> - Welcome to Avalonia! +<Window + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d" + d:DesignWidth="800" + d:DesignHeight="450" + x:Class="manager.MainWindow" + Title="DMVOP Manager" + Width="800" + MinWidth="500" +> + <Grid ColumnDefinitions="Auto,*"> + <ScrollViewer Grid.Column="0" Width="300" Background="#2d2d2d"> + <StackPanel Margin="10" Spacing="12"> + <StackPanel Spacing="4"> + <TextBlock + Text="Device" + FontWeight="Bold" + Foreground="White" + /> + <TextBox + x:Name="DeviceBox" + Text="auto" + Watermark="device name or auto" + /> + </StackPanel> + + <StackPanel Spacing="4"> + <TextBlock + Text="Output Format" + FontWeight="Bold" + Foreground="White" + /> + <TextBox + x:Name="FormatBox" + Text="%{vol},%{word}" + Watermark="e.g. %{vol},%{word}" + /> + </StackPanel> + + <StackPanel Spacing="4"> + <TextBlock + Text="Model" + FontWeight="Bold" + Foreground="White" + /> + <Grid ColumnDefinitions="*,Auto" ColumnSpacing="6"> + <ComboBox + x:Name="ModelBox" + Grid.Column="0" + SelectedIndex="0" + > + <ComboBoxItem Content="tiny.en" /> + <ComboBoxItem Content="tiny" /> + <ComboBoxItem Content="base.en" /> + <ComboBoxItem Content="base" /> + <ComboBoxItem Content="small.en" /> + <ComboBoxItem Content="small" /> + <ComboBoxItem Content="medium.en" /> + <ComboBoxItem Content="medium" /> + <ComboBoxItem Content="large_v3_turbo" /> + <ComboBoxItem Content="large_v3" /> + </ComboBox> + <Button + x:Name="DownloadModelButton" + Grid.Column="1" + Content="Download" + Width="60" + /> + </Grid> + </StackPanel> + + <StackPanel Spacing="4"> + <TextBlock + Text="Language" + FontWeight="Bold" + Foreground="White" + /> + <TextBox + x:Name="LangBox" + Text="en" + Watermark="e.g. en, zh, ja" + /> + </StackPanel> + + <StackPanel Spacing="4"> + <TextBlock + Text="Output Modes" + FontWeight="Bold" + Foreground="White" + /> + <CheckBox + x:Name="StdoutCheck" + Content="stdout" + IsChecked="True" + /> + <CheckBox + x:Name="TcpCheck" + Content="tcp" + IsChecked="True" + /> + <CheckBox x:Name="UdpCheck" Content="udp" /> + <CheckBox + x:Name="UdpBroadcastCheck" + Content="udp-broadcast" + /> + <CheckBox x:Name="IpcCheck" Content="ipc (Unix only)" /> + </StackPanel> + + <StackPanel Spacing="4"> + <TextBlock + Text="Port" + FontWeight="Bold" + Foreground="White" + /> + <TextBox + x:Name="PortBox" + Text="5117" + Watermark="port number" + /> + </StackPanel> + + <StackPanel Spacing="4"> + <TextBlock + Text="Instant Mode" + FontWeight="Bold" + Foreground="White" + /> + <CheckBox + x:Name="InstantCheck" + IsChecked="False" + Content="Enable" + /> + </StackPanel> + + <StackPanel Spacing="4"> + <TextBlock + Text="Subnet Mask" + FontWeight="Bold" + Foreground="White" + /> + <TextBox + x:Name="SubnetMaskBox" + Text="255.255.255.0" + Watermark="e.g. 255.255.255.0" + /> + </StackPanel> + + <StackPanel x:Name="SocketFilePanel" Spacing="4"> + <TextBlock + Text="Socket File" + FontWeight="Bold" + Foreground="White" + /> + <TextBox + x:Name="SocketFileBox" + Text="./dmvop.sock" + Watermark="path to socket file" + /> + </StackPanel> + + <StackPanel Spacing="4"> + <TextBlock + Text="Models Directory" + FontWeight="Bold" + Foreground="White" + /> + <TextBox x:Name="ModelsDirBox" Watermark="./models" /> + </StackPanel> + + <StackPanel Spacing="4"> + <TextBlock + Text="Post-processor" + FontWeight="Bold" + Foreground="White" + /> + <TextBox x:Name="PostBox" Watermark="e.g. +pinyin(tone)" /> + </StackPanel> + + <TextBlock + x:Name="StatusLabel" + Text="Ready" + Foreground="#aaa" + FontSize="12" + /> + + <TextBlock Height="8" /> + </StackPanel> + </ScrollViewer> + + <Grid Grid.Column="1" Background="#1e1e1e" RowDefinitions="Auto,*"> + <Border Grid.Row="0" Background="#252525" Padding="12,8"> + <Grid ColumnDefinitions="Auto,Auto,*"> + <Ellipse + x:Name="StatusLight" + Width="14" + Height="14" + Fill="Red" + VerticalAlignment="Center" + Margin="0,0,8,0" + /> + + <TextBlock + x:Name="StatusTextRight" + Grid.Column="1" + Text="Stopped" + Foreground="#888" + FontSize="14" + VerticalAlignment="Center" + /> + + <Button + x:Name="ActionButton" + Grid.Column="2" + Content="Start" + Width="90" + Height="28" + FontSize="13" + HorizontalAlignment="Right" + VerticalAlignment="Center" + /> + </Grid> + </Border> + + <ScrollViewer Grid.Row="1" Background="#1a1a1a" Margin="0,1,0,0"> + <TextBlock + x:Name="OutputArea" + FontFamily="Consolas, Courier New, monospace" + FontSize="12" + Foreground="#aaa" + Text="" + TextWrapping="Wrap" + Margin="8" + /> + </ScrollViewer> + </Grid> + </Grid> </Window> diff --git a/manager/MainWindow.axaml.cs b/manager/MainWindow.axaml.cs index 82d8dc2..6abbd0e 100644 --- a/manager/MainWindow.axaml.cs +++ b/manager/MainWindow.axaml.cs @@ -1,11 +1,450 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using Tomlyn; +using Tomlyn.Model; namespace manager; public partial class MainWindow : Window { + private readonly string _configDir; + private readonly string _sourceToml; + private readonly string _guiConfigPath; + private bool _dirty; + private bool _loading; + private bool _promptingClose; + private bool _dmvopRunning; + private Process? _dmvopProcess; + private readonly StringBuilder _dmvopOutput = new(); + private const string BaseTitle = "DumbVoiceProtocol"; + public MainWindow() { InitializeComponent(); + + // Parent directory of the program location = build/ + _configDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..")); + _sourceToml = Path.Combine(_configDir, "dmvop.toml"); + _guiConfigPath = Path.Combine(_configDir, "dmvop-gui.toml"); + + DownloadModelButton.Click += OnDownloadModel; + KeyDown += OnKeyDown; + + // Hide Unix-specific items on Windows + if (OperatingSystem.IsWindows()) + { + IpcCheck.IsVisible = false; + SocketFilePanel.IsVisible = false; + } + + // Mark dirty on all input control changes + SubscribeChanges(); + + Closing += OnClosing; + + ActionButton.Click += OnActionButton; + _ = StartDashboardTimer(); + + LoadConfig(); + } + + private void SubscribeChanges() + { + // TextBoxes + DeviceBox.TextChanged += (_, _) => MarkDirty(); + FormatBox.TextChanged += (_, _) => MarkDirty(); + LangBox.TextChanged += (_, _) => MarkDirty(); + PortBox.TextChanged += (_, _) => MarkDirty(); + SubnetMaskBox.TextChanged += (_, _) => MarkDirty(); + SocketFileBox.TextChanged += (_, _) => MarkDirty(); + ModelsDirBox.TextChanged += (_, _) => MarkDirty(); + PostBox.TextChanged += (_, _) => MarkDirty(); + + // ComboBox + ModelBox.SelectionChanged += (_, _) => MarkDirty(); + + // CheckBoxes + StdoutCheck.IsCheckedChanged += (_, _) => MarkDirty(); + TcpCheck.IsCheckedChanged += (_, _) => MarkDirty(); + UdpCheck.IsCheckedChanged += (_, _) => MarkDirty(); + UdpBroadcastCheck.IsCheckedChanged += (_, _) => MarkDirty(); + IpcCheck.IsCheckedChanged += (_, _) => MarkDirty(); + InstantCheck.IsCheckedChanged += (_, _) => MarkDirty(); + } + + private void MarkDirty() + { + if (_loading || _dirty) return; + _dirty = true; + UpdateTitle(); + } + + private void ClearDirty() + { + if (!_dirty) return; + _dirty = false; + UpdateTitle(); + } + + private void UpdateTitle() + { + Title = _dirty ? $"{BaseTitle} *" : BaseTitle; + } + + // ---- Dashboard ---- + + private async Task StartDashboardTimer() + { + while (true) + { + await Task.Delay(2000); + CheckDmvopStatus(); + } + } + + private bool IsDmvopRunning() + { + return Process.GetProcessesByName("dmvop").Any(p => p.Id != Environment.ProcessId); + } + + private void CheckDmvopStatus() + { + var running = IsDmvopRunning(); + if (running == _dmvopRunning) return; + _dmvopRunning = running; + Dispatcher.UIThread.Post(() => + { + UpdateDashboard(); + if (!_dmvopRunning) + { + _dmvopProcess = null; + _dmvopOutput.Clear(); + OutputArea.Text = ""; + } + }); + } + + private void UpdateDashboard() + { + if (_dmvopRunning) + { + StatusLight.Fill = new SolidColorBrush(Colors.LimeGreen); + StatusTextRight.Text = "Running"; + StatusTextRight.Foreground = new SolidColorBrush(Colors.LimeGreen); + ActionButton.Content = "Stop"; + } + else + { + StatusLight.Fill = new SolidColorBrush(Colors.Red); + StatusTextRight.Text = "Stopped"; + StatusTextRight.Foreground = new SolidColorBrush(Colors.Gray); + ActionButton.Content = "Start"; + } + } + + private void OnActionButton(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (_dmvopRunning) + { + // Stop + try { _dmvopProcess?.Kill(); } catch { } + foreach (var proc in Process.GetProcessesByName("dmvop")) + { + if (proc.Id == Environment.ProcessId) continue; + try { proc.Kill(); } catch { } + } + _dmvopProcess = null; + } + else + { + // Start: launch from dmvop.exe directory, no window + var dmvopPath = Path.Combine(_configDir, "dmvop.exe"); + if (!File.Exists(dmvopPath)) + { + StatusLabel.Text = "Cannot find dmvop.exe"; + return; + } + + _dmvopOutput.Clear(); + OutputArea.Text = ""; + + var psi = new ProcessStartInfo + { + FileName = dmvopPath, + Arguments = "--config=\"./dmvop-gui.toml\" --verbose", + WorkingDirectory = _configDir, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + + try + { + var proc = Process.Start(psi); + if (proc == null) + { + StatusLabel.Text = "Failed to start"; + return; + } + _dmvopProcess = proc; + + // Read stdout and stderr in parallel + _ = ReadStreamAsync(proc.StandardOutput); + _ = ReadStreamAsync(proc.StandardError); + + proc.Exited += (_, _) => + { + proc.WaitForExit(); + _dmvopRunning = false; + Dispatcher.UIThread.Post(() => + { + UpdateDashboard(); + // Don't clear output immediately so the user can see the last content + }); + }; + proc.EnableRaisingEvents = true; + } + catch (Exception ex) + { + StatusLabel.Text = $"Failed to start: {ex.Message}"; + } + } + } + + private async Task ReadStreamAsync(StreamReader reader) + { + var buffer = new char[4096]; + int charsRead; + while ((charsRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + var segment = new string(buffer, 0, charsRead); + lock (_dmvopOutput) + { + _dmvopOutput.Append(segment); + // Limit max length to avoid memory blowup + if (_dmvopOutput.Length > 100_000) + _dmvopOutput.Remove(0, _dmvopOutput.Length - 50_000); + } + Dispatcher.UIThread.Post(() => + { + OutputArea.Text = _dmvopOutput.ToString(); + // OutputScroll is the ScrollViewer x:Name? Use parent ScrollViewer + // Find the parent ScrollViewer of OutputArea directly + if (OutputArea.Parent is ScrollViewer sv) + sv.ScrollToEnd(); + }); + } + } + + private void OnClosing(object? sender, WindowClosingEventArgs e) + { + if (!_dirty || _promptingClose) return; + + e.Cancel = true; + _promptingClose = true; + + Dispatcher.UIThread.Post(async () => + { + var result = await MessageBox.Show(this, + "There are unsaved changes. Do you want to save?", "DumbVoiceProtocol", + ("Save", MessageBoxResult.Yes), + ("Don't Save", MessageBoxResult.No), + ("Cancel", MessageBoxResult.Cancel)); + + _promptingClose = false; + + switch (result) + { + case MessageBoxResult.Yes: + OnSave(); + if (!_dirty) + Close(); + break; + case MessageBoxResult.No: + _dirty = false; + Close(); + break; + } + }); + } + + private void LoadConfig() + { + _loading = true; + + if (!File.Exists(_guiConfigPath)) + { + if (File.Exists(_sourceToml)) + { + File.Copy(_sourceToml, _guiConfigPath); + } + else + { + MessageBox.Show(this, "dmvop.toml does not exist", "Configuration Error", + ("OK", MessageBoxResult.Ok)); + StatusLabel.Text = "dmvop.toml does not exist"; + return; + } + } + + try + { + var text = File.ReadAllText(_guiConfigPath); + var model = Toml.ToModel(text); + + DeviceBox.Text = GetString(model, "device") ?? "auto"; + FormatBox.Text = GetString(model, "format") ?? "%{vol},%{word}"; + + var modelName = GetString(model, "model") ?? "base.en"; + SelectComboBoxItem(ModelBox, modelName); + + LangBox.Text = GetString(model, "lang") ?? "en"; + + if (model.TryGetValue("output", out var outputVal) && outputVal is TomlArray outputArray) + { + var modes = outputArray.OfType<string>().ToList(); + StdoutCheck.IsChecked = modes.Contains("stdout"); + TcpCheck.IsChecked = modes.Contains("tcp"); + UdpCheck.IsChecked = modes.Contains("udp"); + UdpBroadcastCheck.IsChecked = modes.Contains("udp-broadcast"); + IpcCheck.IsChecked = modes.Contains("ipc"); + } + + if (model.TryGetValue("port", out var portVal) && portVal is long portLong) + PortBox.Text = portLong.ToString(); + + if (model.TryGetValue("instant", out var instantVal) && instantVal is bool instant) + InstantCheck.IsChecked = instant; + + SubnetMaskBox.Text = GetString(model, "subnet_mask") ?? "255.255.255.0"; + SocketFileBox.Text = GetString(model, "socket_file") ?? "./dmvop.sock"; + ModelsDirBox.Text = GetString(model, "models_dir") ?? ""; + PostBox.Text = GetString(model, "post") ?? ""; + + StatusLabel.Text = $"Loaded: {Path.GetFileName(_guiConfigPath)}"; + } + catch (Exception ex) + { + StatusLabel.Text = $"Load error: {ex.Message}"; + } + finally + { + _loading = false; + ClearDirty(); + } + } + + private void OnDownloadModel(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + var modelName = GetComboBoxText(ModelBox); + if (string.IsNullOrEmpty(modelName)) + { + StatusLabel.Text = "Please select a model first"; + return; + } + + var dmvopPath = Path.Combine(_configDir, "dmvop.exe"); + if (!File.Exists(dmvopPath)) + { + StatusLabel.Text = "Cannot find dmvop.exe"; + return; + } + + var procWin = new ProcessWindow(dmvopPath, $"--download-model={modelName}", _configDir); + procWin.ShowDialog(this); } -}
\ No newline at end of file + + private void OnKeyDown(object? sender, Avalonia.Input.KeyEventArgs e) + { + if (e.Key == Avalonia.Input.Key.S && e.KeyModifiers.HasFlag(Avalonia.Input.KeyModifiers.Control)) + { + OnSave(); + } + } + + private void OnSave() + { + try + { + var model = new TomlTable(); + + model["device"] = DeviceBox.Text ?? "auto"; + model["format"] = FormatBox.Text ?? "%{vol},%{word}"; + model["model"] = GetComboBoxText(ModelBox) ?? "base.en"; + model["lang"] = LangBox.Text ?? "en"; + + var outputArray = new TomlArray(); + if (StdoutCheck.IsChecked == true) outputArray.Add("stdout"); + if (TcpCheck.IsChecked == true) outputArray.Add("tcp"); + if (UdpCheck.IsChecked == true) outputArray.Add("udp"); + if (UdpBroadcastCheck.IsChecked == true) outputArray.Add("udp-broadcast"); + if (IpcCheck.IsChecked == true) outputArray.Add("ipc"); + model["output"] = outputArray; + + if (int.TryParse(PortBox.Text, out var port)) + model["port"] = port; + + model["instant"] = InstantCheck.IsChecked == true; + + model["subnet_mask"] = SubnetMaskBox.Text ?? "255.255.255.0"; + model["socket_file"] = SocketFileBox.Text ?? "./dmvop.sock"; + + if (!string.IsNullOrWhiteSpace(ModelsDirBox.Text)) + model["models_dir"] = ModelsDirBox.Text; + + if (!string.IsNullOrWhiteSpace(PostBox.Text)) + model["post"] = PostBox.Text; + + var toml = Toml.FromModel(model); + var header = """ + ## DMVOP Config File + ## Generated by DMVOP Manager + ## + ## Name this file `dmvop.toml` and place it in the working dir to be auto-detected! + ## DMVOP will auto-load this config file to avoid repeated CLI args + + """; + + File.WriteAllText(_guiConfigPath, header + toml); + + StatusLabel.Text = $"Saved: {Path.GetFileName(_guiConfigPath)}"; + ClearDirty(); + } + catch (Exception ex) + { + StatusLabel.Text = $"Save error: {ex.Message}"; + } + } + + // ---- Helpers ---- + + private static string? GetString(TomlTable table, string key) + => table.TryGetValue(key, out var val) ? val?.ToString() : null; + + private static void SelectComboBoxItem(ComboBox comboBox, string text) + { + for (var i = 0; i < comboBox.Items.Count; i++) + { + if (comboBox.Items[i] is ComboBoxItem cbi && cbi.Content?.ToString() == text) + { + comboBox.SelectedIndex = i; + return; + } + } + } + + private static string? GetComboBoxText(ComboBox comboBox) + => comboBox.SelectedItem is ComboBoxItem cbi + ? cbi.Content?.ToString() + : null; +} diff --git a/manager/MessageBox.axaml b/manager/MessageBox.axaml new file mode 100644 index 0000000..0ca35d4 --- /dev/null +++ b/manager/MessageBox.axaml @@ -0,0 +1,25 @@ +<Window + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d" + d:DesignWidth="400" + d:DesignHeight="150" + x:Class="manager.MessageBox" + Title="Prompt" + SizeToContent="WidthAndHeight" + WindowStartupLocation="CenterOwner" + CanResize="False" + ShowInTaskbar="False" +> + <StackPanel Margin="20" Spacing="16"> + <TextBlock x:Name="MessageText" Text="" FontSize="14" /> + <StackPanel + x:Name="ButtonPanel" + Orientation="Horizontal" + HorizontalAlignment="Right" + Spacing="8" + /> + </StackPanel> +</Window> diff --git a/manager/MessageBox.axaml.cs b/manager/MessageBox.axaml.cs new file mode 100644 index 0000000..3a6868b --- /dev/null +++ b/manager/MessageBox.axaml.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; + +namespace manager; + +public enum MessageBoxResult +{ + Ok, + Yes, + No, + Cancel, +} + +public partial class MessageBox : Window +{ + public static readonly StyledProperty<string> TextProperty = + AvaloniaProperty.Register<MessageBox, string>(nameof(Text)); + + public string Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public MessageBox() + { + InitializeComponent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == TextProperty) + MessageText.Text = change.NewValue as string ?? ""; + } + + public void AddButtons(params (string text, MessageBoxResult result)[] buttons) + { + foreach (var (text, result) in buttons) + { + var btn = new Button { Content = text, Width = 80 }; + btn.Click += (_, _) => Close(result); + ButtonPanel.Children.Add(btn); + } + } + + public static Task<MessageBoxResult> Show(Window owner, string text, string title, + params (string text, MessageBoxResult result)[] buttons) + { + var msgBox = new MessageBox + { + Title = title, + Text = text, + }; + msgBox.AddButtons(buttons); + return msgBox.ShowDialog<MessageBoxResult>(owner); + } +} diff --git a/manager/ProcessWindow.axaml b/manager/ProcessWindow.axaml new file mode 100644 index 0000000..482397b --- /dev/null +++ b/manager/ProcessWindow.axaml @@ -0,0 +1,43 @@ +<Window + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d" + d:DesignWidth="700" + d:DesignHeight="450" + x:Class="manager.ProcessWindow" + Title="Process" + Width="700" + Height="450" + WindowStartupLocation="CenterOwner" +> + <Grid RowDefinitions="*,Auto" Margin="8"> + <ScrollViewer + x:Name="OutputScroll" + Grid.Row="0" + Background="#1e1e1e" + Margin="0,0,0,8" + > + <TextBlock + x:Name="OutputText" + FontFamily="Consolas, Courier New, monospace" + FontSize="13" + Foreground="#d4d4d4" + Text="" + TextWrapping="Wrap" + Margin="8" + /> + </ScrollViewer> + + <StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="12"> + <TextBlock + x:Name="StatusText" + Text="Ready" + Foreground="#888" + VerticalAlignment="Center" + /> + <Button x:Name="CloseButton" Content="Close" Width="80" /> + </StackPanel> + </Grid> +</Window> diff --git a/manager/ProcessWindow.axaml.cs b/manager/ProcessWindow.axaml.cs new file mode 100644 index 0000000..c11759c --- /dev/null +++ b/manager/ProcessWindow.axaml.cs @@ -0,0 +1,104 @@ +using System; +using System.Diagnostics; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Threading; + +namespace manager; + +public partial class ProcessWindow : Window +{ + private readonly Process _process = new(); + private readonly StringBuilder _output = new(); + + public ProcessWindow() + { + InitializeComponent(); + } + + public ProcessWindow(string fileName, string? arguments = null, string? workingDir = null) + : this() + { + Title = $"{fileName} - Process Output"; + CloseButton.Click += (_, _) => Close(); + + _process.StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments ?? "", + WorkingDirectory = workingDir ?? "", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + + _process.EnableRaisingEvents = true; + _process.Exited += (_, _) => + { + _process.WaitForExit(); + Dispatcher.UIThread.Post(() => + { + StatusText.Text = $"Exited (Code: {_process.ExitCode})"; + AppendOutput($"[Process exited, code: {_process.ExitCode}]"); + }); + }; + + StartProcess(); + } + + private async void StartProcess() + { + try + { + StatusText.Text = "Starting..."; + _process.Start(); + + StatusText.Text = $"Running (PID: {_process.Id})"; + + var stdoutTask = ReadStreamAsync(_process.StandardOutput); + var stderrTask = ReadStreamAsync(_process.StandardError); + await Task.WhenAll(stdoutTask, stderrTask); + } + catch (Exception ex) + { + Dispatcher.UIThread.Post(() => + { + AppendOutput($"[Start failed] {ex.Message}"); + StatusText.Text = "Start failed"; + }); + } + } + + private async Task ReadStreamAsync(System.IO.StreamReader reader) + { + var buffer = new char[4096]; + int charsRead; + while ((charsRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + var segment = new string(buffer, 0, charsRead); + Dispatcher.UIThread.Post(() => AppendOutput(segment)); + } + } + + private void AppendOutput(string text) + { + _output.Append(text); + OutputText.Text = _output.ToString(); + + OutputScroll.ScrollToEnd(); + } + + protected override void OnClosed(EventArgs e) + { + base.OnClosed(e); + if (!_process.HasExited) + { + try { _process.Kill(); } catch { } + } + _process.Dispose(); + } +} diff --git a/manager/Program.cs b/manager/Program.cs index c2f6e74..fddd9fc 100644 --- a/manager/Program.cs +++ b/manager/Program.cs @@ -3,17 +3,13 @@ using System; namespace manager; -class Program +static class Program { - // Initialization code. Don't use any Avalonia, third-party APIs or any - // SynchronizationContext-reliant code before AppMain is called: things aren't initialized - // yet and stuff might break. [STAThread] public static void Main(string[] args) => BuildAvaloniaApp() .StartWithClassicDesktopLifetime(args); - // Avalonia configuration, don't remove; also used by visual designer. - public static AppBuilder BuildAvaloniaApp() + private static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>() .UsePlatformDetect() .WithInterFont() diff --git a/manager/manager.csproj b/manager/manager.csproj index e21a361..753edc8 100644 --- a/manager/manager.csproj +++ b/manager/manager.csproj @@ -5,18 +5,28 @@ <Nullable>enable</Nullable> <BuiltInComInteropSupport>true</BuiltInComInteropSupport> <ApplicationManifest>app.manifest</ApplicationManifest> - <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> + <AvaloniaUseCompiledBindingsByDefault + >true</AvaloniaUseCompiledBindingsByDefault> </PropertyGroup> <ItemGroup> <PackageReference Include="Avalonia" Version="11.3.2" /> <PackageReference Include="Avalonia.Desktop" Version="11.3.2" /> - <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" /> + <PackageReference Include="Avalonia.Themes.Simple" Version="11.3.2" /> <PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.2" /> <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.--> <PackageReference Include="Avalonia.Diagnostics" Version="11.3.2"> - <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> - <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> + <IncludeAssets + Condition="'$(Configuration)' != 'Debug'" + >None</IncludeAssets> + <PrivateAssets + Condition="'$(Configuration)' != 'Debug'" + >All</PrivateAssets> </PackageReference> </ItemGroup> + + <ItemGroup> + <PackageReference Include="Tomlyn" Version="0.17.0" /> + </ItemGroup> + </Project> |
