aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-06-22 07:21:10 +0800
committer魏曹先生 <1992414357@qq.com>2026-06-22 07:21:10 +0800
commit5e16ca4f41b2ae0b1e0d57be340cbf28286fe1f7 (patch)
tree72e4afcc6b440e3121af79b245e6bd04c7398b7a
parente6bf6f5091caf3b4366894643671bc4586794cda (diff)
feat(manager): implement GUI dashboard with config management
-rw-r--r--.gitignore1
-rw-r--r--manager/App.axaml16
-rw-r--r--manager/MainWindow.axaml247
-rw-r--r--manager/MainWindow.axaml.cs441
-rw-r--r--manager/MessageBox.axaml25
-rw-r--r--manager/MessageBox.axaml.cs60
-rw-r--r--manager/ProcessWindow.axaml43
-rw-r--r--manager/ProcessWindow.axaml.cs104
-rw-r--r--manager/Program.cs8
-rw-r--r--manager/manager.csproj18
10 files changed, 936 insertions, 27 deletions
diff --git a/.gitignore b/.gitignore
index 336b5e8..6090947 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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>