From 5e16ca4f41b2ae0b1e0d57be340cbf28286fe1f7 Mon Sep 17 00:00:00 2001
From: 魏曹先生 <1992414357@qq.com>
Date: Mon, 22 Jun 2026 07:21:10 +0800
Subject: feat(manager): implement GUI dashboard with config management
---
manager/App.axaml | 16 +-
manager/MainWindow.axaml | 247 ++++++++++++++++++++++-
manager/MainWindow.axaml.cs | 441 ++++++++++++++++++++++++++++++++++++++++-
manager/MessageBox.axaml | 25 +++
manager/MessageBox.axaml.cs | 60 ++++++
manager/ProcessWindow.axaml | 43 ++++
manager/ProcessWindow.axaml.cs | 104 ++++++++++
manager/Program.cs | 8 +-
manager/manager.csproj | 18 +-
9 files changed, 935 insertions(+), 27 deletions(-)
create mode 100644 manager/MessageBox.axaml
create mode 100644 manager/MessageBox.axaml.cs
create mode 100644 manager/ProcessWindow.axaml
create mode 100644 manager/ProcessWindow.axaml.cs
(limited to 'manager')
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 @@
-
-
-
+
-
+
-
\ No newline at end of file
+
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 @@
-
- Welcome to Avalonia!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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().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 @@
+
+
+
+
+
+
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 TextProperty =
+ AvaloniaProperty.Register(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 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(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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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()
.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 @@
enable
true
app.manifest
- true
+ true
-
+
- None
- All
+ None
+ All
+
+
+
+
+
--
cgit