diff --git a/.github/workflows/build-cross-platform.yml b/.github/workflows/build-cross-platform.yml
new file mode 100644
index 0000000..c831d45
--- /dev/null
+++ b/.github/workflows/build-cross-platform.yml
@@ -0,0 +1,142 @@
+name: Build Cross-Platform (Avalonia)
+
+on:
+ push:
+ branches: [ master, main, update-linux ]
+ pull_request:
+ branches: [ master, main ]
+ workflow_dispatch:
+
+jobs:
+ # ── Build Rust native library ────────────────────────────────
+ build-rust:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: nightly
+
+ - name: Build serial_monitor (unix)
+ if: runner.os != 'Windows'
+ run: |
+ cd serial_monitor_rs
+ cargo build --release -p serial_monitor
+
+ - name: Build serial_monitor (windows)
+ if: runner.os == 'Windows'
+ run: |
+ cd serial_monitor_rs
+ cargo build --release -p serial_monitor_hook --target x86_64-pc-windows-msvc
+ cargo build --release -p serial_monitor --target x86_64-pc-windows-msvc
+
+ - name: Copy native lib (Linux)
+ if: runner.os == 'Linux'
+ run: |
+ mkdir -p llcom/native/linux-x64
+ cp serial_monitor_rs/target/release/libserial_monitor.so llcom/native/linux-x64/
+
+ - name: Copy native lib (macOS)
+ if: runner.os == 'macOS'
+ run: |
+ mkdir -p llcom/native/osx-x64
+ cp serial_monitor_rs/target/release/libserial_monitor.dylib llcom/native/osx-x64/
+
+ - name: Copy native lib (Windows)
+ if: runner.os == 'Windows'
+ run: |
+ New-Item -ItemType Directory -Force -Path llcom\native\win-x64
+ Copy-Item serial_monitor_rs\target\x86_64-pc-windows-msvc\release\serial_monitor.dll llcom\native\win-x64\
+
+ - name: Upload Rust artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: native-${{ matrix.os }}
+ path: |
+ llcom/native/**
+
+ # ── Build .NET Avalonia app ──────────────────────────────────
+ build-dotnet:
+ needs: build-rust
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ rid: [linux-x64, win-x64, osx-x64]
+ exclude:
+ - os: ubuntu-latest
+ rid: win-x64
+ - os: ubuntu-latest
+ rid: osx-x64
+ - os: windows-latest
+ rid: linux-x64
+ - os: windows-latest
+ rid: osx-x64
+ - os: macos-latest
+ rid: linux-x64
+ - os: macos-latest
+ rid: win-x64
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Download native lib
+ uses: actions/download-artifact@v4
+ with:
+ name: native-${{ matrix.os }}
+ path: llcom/native/
+
+ - name: Restore
+ run: dotnet restore llcom.Avalonia/llcom.Avalonia.csproj
+
+ - name: Build
+ run: dotnet build llcom.Avalonia/llcom.Avalonia.csproj -c Release --no-restore
+
+ - name: Publish
+ run: dotnet publish llcom.Avalonia/llcom.Avalonia.csproj -c Release -r ${{ matrix.rid }} --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=true -p:TrimMode=partial -o publish/${{ matrix.rid }}
+
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: llcom-${{ matrix.rid }}
+ path: publish/${{ matrix.rid }}/
+
+ # ── Build original WPF app (Windows only) ────────────────────
+ build-wpf:
+ runs-on: windows-latest
+ if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup MSBuild
+ uses: microsoft/setup-msbuild@v2
+
+ - name: Setup NuGet
+ uses: NuGet/setup-nuget@v2
+
+ - name: Restore NuGet packages
+ run: nuget restore llcom.sln
+
+ - name: Build
+ run: msbuild llcom/llcom.csproj /p:Configuration=Release /p:Platform=x64 /v:normal
+
+ - name: Upload WPF artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: llcom-wpf-x64
+ path: llcom/bin/x64/Release/
diff --git a/.gitignore b/.gitignore
index fe2da04..4958aa5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -333,3 +333,9 @@ ASALocalRun/
serial_monitor_rs/target/
serial_monitor_rs/Cargo.lock
**/*.rs.bk
+
+# CodeBuddy
+.codebuddy/
+
+# Solution filter files
+*.slnx
diff --git a/llcom-cross.sln b/llcom-cross.sln
new file mode 100644
index 0000000..58e3362
--- /dev/null
+++ b/llcom-cross.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "llcom.Core", "llcom.Core\llcom.Core.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "llcom.Avalonia", "llcom.Avalonia\llcom.Avalonia.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/llcom.Avalonia/App.axaml b/llcom.Avalonia/App.axaml
new file mode 100644
index 0000000..9954f90
--- /dev/null
+++ b/llcom.Avalonia/App.axaml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/App.axaml.cs b/llcom.Avalonia/App.axaml.cs
new file mode 100644
index 0000000..fa8a74d
--- /dev/null
+++ b/llcom.Avalonia/App.axaml.cs
@@ -0,0 +1,188 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Layout;
+using Avalonia.Markup.Xaml;
+using Avalonia.Markup.Xaml.Styling;
+using Avalonia.Platform.Storage;
+using llcom.Avalonia.Helpers;
+using llcom.Avalonia.ViewModels;
+using llcom.Avalonia.Views;
+using llcom.Tools;
+
+namespace llcom.Avalonia;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ // ── Set PlatformHelper callbacks BEFORE creating view models ──
+ // (sub-VMs may access these during construction)
+
+ // Fallback message callback (will be overridden by MainWindowViewModel)
+ PlatformHelper.ShowMessageCallback = (msg) =>
+ {
+ Console.WriteLine($"[llcom message] {msg}");
+ };
+
+ // Set clipboard callback for EncodingFixViewModel
+ EncodingFixViewModel.CopyToClipboardCallback = async (text) =>
+ {
+ if (desktop.MainWindow is { } window)
+ {
+ var clipboard = TopLevel.GetTopLevel(window)?.Clipboard;
+ if (clipboard != null)
+ await clipboard.SetTextAsync(text);
+ }
+ };
+
+ // ── Create MainWindow with try/catch for resilience ─────────
+ MainWindowViewModel? mainVm = null;
+ try
+ {
+ mainVm = new MainWindowViewModel();
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[llcom FATAL] Failed to create MainWindowViewModel: {ex}");
+ // Create a fallback with minimal state
+ mainVm = new MainWindowViewModel();
+ }
+
+ desktop.MainWindow = new MainWindow
+ {
+ DataContext = mainVm,
+ };
+
+ // Language switching: swap the merged ResourceDictionary at runtime
+ PlatformHelper.LoadLanguageFileCallback = (language) =>
+ {
+ if (desktop.MainWindow is MainWindow mainWindow)
+ {
+ var dict = mainWindow.Resources;
+ dict.MergedDictionaries.Clear();
+ dict.MergedDictionaries.Add(new ResourceInclude(default(Uri))
+ {
+ Source = new Uri($"avares://llcom/languages/{language}.xaml")
+ });
+ }
+ };
+
+ // Input dialog callback (for custom baud rate, list rename, etc.)
+ PlatformHelper.InputDialogCallback = (prompt, defaultInput, title) =>
+ {
+ return ShowInputDialogSync(desktop.MainWindow!, prompt, defaultInput, title);
+ };
+
+ // File picker callbacks
+ PlatformHelper.OpenFilePickerCallback = async (filter) =>
+ {
+ return await OpenFilePickerAsync(desktop.MainWindow!, filter);
+ };
+ PlatformHelper.SaveFilePickerCallback = async (filter, defaultName) =>
+ {
+ return await SaveFilePickerAsync(desktop.MainWindow!, filter, defaultName);
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private static (bool, string) ShowInputDialogSync(Window owner, string prompt, string defaultInput, string title)
+ {
+ var tcs = new TaskCompletionSource<(bool, string)>();
+
+ var window = new Window
+ {
+ Title = title,
+ Width = 350,
+ Height = 150,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ ShowInTaskbar = false,
+ CanResize = false,
+ };
+
+ var panel = new StackPanel { Margin = new global::Avalonia.Thickness(10) };
+ var promptText = new TextBlock { Text = prompt, Margin = new global::Avalonia.Thickness(0, 0, 0, 8) };
+ var inputBox = new TextBox { Text = defaultInput, Margin = new global::Avalonia.Thickness(0, 0, 0, 12) };
+ var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
+
+ var okBtn = new Button { Content = LocaleHelper.Get("InputDialogConfirm"), Width = 70, Margin = new global::Avalonia.Thickness(0, 0, 8, 0) };
+ var cancelBtn = new Button { Content = LocaleHelper.Get("InputDialogCancel"), Width = 70 };
+
+ okBtn.Click += (_, _) => { tcs.TrySetResult((true, inputBox.Text ?? "")); window.Close(); };
+ cancelBtn.Click += (_, _) => { tcs.TrySetResult((false, defaultInput)); window.Close(); };
+ window.Closing += (_, _) => tcs.TrySetResult((false, defaultInput));
+
+ btnPanel.Children.Add(okBtn);
+ btnPanel.Children.Add(cancelBtn);
+ panel.Children.Add(promptText);
+ panel.Children.Add(inputBox);
+ panel.Children.Add(btnPanel);
+ window.Content = panel;
+
+ // Use ShowDialog (nested message pump) instead of Show + .Result (deadlocks on UI thread)
+ window.ShowDialog(owner).GetAwaiter().GetResult();
+ // TCS was completed by button click or window close during ShowDialog
+ return tcs.Task.Result;
+ }
+
+ private static async Task OpenFilePickerAsync(Window owner, string filter)
+ {
+ var topLevel = TopLevel.GetTopLevel(owner);
+ if (topLevel == null) return null;
+
+ var filters = ParseFilters(filter);
+ var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Open File",
+ AllowMultiple = false,
+ FileTypeFilter = filters,
+ });
+
+ return files.FirstOrDefault()?.Path.LocalPath;
+ }
+
+ private static async Task SaveFilePickerAsync(Window owner, string filter, string defaultName)
+ {
+ var topLevel = TopLevel.GetTopLevel(owner);
+ if (topLevel == null) return null;
+
+ var filters = ParseFilters(filter);
+ var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Save File",
+ SuggestedFileName = defaultName,
+ FileTypeChoices = filters,
+ });
+
+ return file?.Path.LocalPath;
+ }
+
+ private static System.Collections.Generic.List ParseFilters(string filter)
+ {
+ var result = new System.Collections.Generic.List();
+ // format: "Description|*.ext;*.ext|Description2|*.ext2"
+ var parts = filter.Split('|');
+ for (int i = 0; i + 1 < parts.Length; i += 2)
+ {
+ var name = parts[i];
+ var patterns = parts[i + 1].Split(';').Select(p => p.Trim().TrimStart('*')).ToList();
+ result.Add(new FilePickerFileType(name)
+ {
+ Patterns = patterns,
+ });
+ }
+ return result;
+ }
+}
diff --git a/llcom.Avalonia/Assets/avalonia-logo.ico b/llcom.Avalonia/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..f7da8bb
Binary files /dev/null and b/llcom.Avalonia/Assets/avalonia-logo.ico differ
diff --git a/llcom.Avalonia/Assets/llcom.ico b/llcom.Avalonia/Assets/llcom.ico
new file mode 100644
index 0000000..b7dcd79
Binary files /dev/null and b/llcom.Avalonia/Assets/llcom.ico differ
diff --git a/llcom.Avalonia/Helpers/LocaleHelper.cs b/llcom.Avalonia/Helpers/LocaleHelper.cs
new file mode 100644
index 0000000..63bf8ea
--- /dev/null
+++ b/llcom.Avalonia/Helpers/LocaleHelper.cs
@@ -0,0 +1,160 @@
+using System.Collections.Generic;
+
+namespace llcom.Avalonia.Helpers;
+
+///
+/// Provides localized strings for C# code (complements XAML DynamicResource bindings).
+/// After SwitchLanguage() is called, all ViewModel properties must be refreshed.
+///
+public static class LocaleHelper
+{
+ public static string CurrentLanguage { get; private set; } = "zh-CN";
+
+ private static readonly Dictionary ZhCn = new()
+ {
+ ["OpenPortButton"] = "打开串口",
+ ["ClosePortButton"] = "关闭串口",
+ ["StatusReady"] = "就绪",
+ ["StatusPortClosed"] = "串口已关闭",
+ ["StatusCloseFailed"] = "关闭失败: {0}",
+ ["StatusSelectPort"] = "请选择串口",
+ ["StatusConnected"] = "已连接 {0} @ {1}",
+ ["StatusOpenFailed"] = "打开失败: {0}",
+ ["StatusSentBytes"] = "已发送 {0} 字节",
+ ["StatusSendFailed"] = "发送失败: {0}",
+ ["StatusLogSaved"] = "日志已保存: {0}",
+ ["StatusSaveFailed"] = "保存失败: {0}",
+ ["StatusLangSwitched"] = "语言已切换为简体中文",
+ ["AppTitle"] = "LLCOM - 能跑Lua脚本的串口调试工具",
+ // WinUSB
+ ["WinUsbConnect"] = "连接",
+ ["WinUsbDisconnect"] = "断开",
+ ["WinUsbConnected"] = "已连接",
+ ["WinUsbDisconnected"] = "已断开",
+ ["WinUsbNoDevice"] = "未发现 USB 设备 (可能需要 sudo 权限)",
+ ["WinUsbFoundDevices"] = "发现 {0} 个 USB 设备",
+ ["WinUsbEnumFailed"] = "枚举失败: {0}",
+ ["WinUsbSelectDevice"] = "请选择一个设备",
+ ["WinUsbSelectEndpoint"] = "请选择 IN/OUT 端点",
+ ["WinUsbOpenFailed"] = "打开失败: {0}",
+ ["WinUsbDeviceNotFound"] = "设备未找到",
+ ["WinUsbSendFailed"] = "发送失败: {0}",
+ ["WinUsbSendPlaceholder"] = "发送数据",
+ ["WinUsbInvalidEndpoint"] = "端点地址格式无效",
+ ["WinUsbEndpointFailed"] = "端点打开失败",
+ // Serial Monitor
+ ["SerialMonitorStart"] = "开始监听",
+ ["SerialMonitorStop"] = "停止监听",
+ ["SerialMonitorStopped"] = "监听已停止",
+ ["SerialMonitorMonitoring"] = "监听中...",
+ ["SerialMonitorStartFailed"] = "启动监听失败: {0}",
+ ["SerialMonitorSelectBoth"] = "请选择进程和串口",
+ ["SerialMonitorInvalidPid"] = "无效的进程ID",
+ // Input dialogs
+ ["InputDialogConfirm"] = "确定",
+ ["InputDialogCancel"] = "取消",
+ // QuickSend messages
+ ["QuickSendListDefault"] = "列表",
+ ["QuickSendImportSuccess"] = "数据导入成功",
+ ["QuickSendImportFailed"] = "导入失败: {0}",
+ ["QuickSendExportSuccess"] = "数据导出成功!",
+ ["QuickSendExportFailed"] = "导出失败: {0}",
+ ["QuickSendSavedToProfile"] = "数据已保存到配置目录",
+ ["QuickSendNeedFilePicker"] = "请在 UI 中启用文件选择器",
+ ["QuickSendImportSSCOMSuccess"] = "已导入 SSCOM {0} 条数据",
+ ["QuickSendImportSSCOMFailed"] = "导入SSCOM失败: {0}",
+ // Online scripts
+ ["LoadingOnlineScripts"] = "正在加载在线脚本...",
+ ["LoadingProgress"] = "加载中... {0}/{1}",
+ ["OnlineScriptFileExists"] = "脚本文件已存在!",
+ ["OnlineScriptSaveSuccess"] = "保存成功!",
+ ["OnlineScriptSaveFailed"] = "保存失败: {0}",
+ // Lua editor
+ ["LuaSaveScript"] = "保存脚本",
+ ["LuaRunOneLine"] = "单行执行...",
+ // About
+ ["AboutSubTitle"] = "能跑Lua脚本的串口调试工具",
+ };
+
+ private static readonly Dictionary EnUs = new()
+ {
+ ["OpenPortButton"] = "Open Port",
+ ["ClosePortButton"] = "Close Port",
+ ["StatusReady"] = "Ready",
+ ["StatusPortClosed"] = "Port closed",
+ ["StatusCloseFailed"] = "Close failed: {0}",
+ ["StatusSelectPort"] = "Please select a port",
+ ["StatusConnected"] = "Connected {0} @ {1}",
+ ["StatusOpenFailed"] = "Open failed: {0}",
+ ["StatusSentBytes"] = "Sent {0} bytes",
+ ["StatusSendFailed"] = "Send failed: {0}",
+ ["StatusLogSaved"] = "Log saved: {0}",
+ ["StatusSaveFailed"] = "Save failed: {0}",
+ ["StatusLangSwitched"] = "Language switched to English",
+ ["AppTitle"] = "LLCOM - Debug serial port with Lua!",
+ // WinUSB
+ ["WinUsbConnect"] = "Connect",
+ ["WinUsbDisconnect"] = "Disconnect",
+ ["WinUsbConnected"] = "Connected",
+ ["WinUsbDisconnected"] = "Disconnected",
+ ["WinUsbNoDevice"] = "No USB device found (try sudo)",
+ ["WinUsbFoundDevices"] = "Found {0} USB devices",
+ ["WinUsbEnumFailed"] = "Enumeration failed: {0}",
+ ["WinUsbSelectDevice"] = "Please select a device",
+ ["WinUsbSelectEndpoint"] = "Please select IN/OUT endpoints",
+ ["WinUsbOpenFailed"] = "Open failed: {0}",
+ ["WinUsbDeviceNotFound"] = "Device not found",
+ ["WinUsbSendFailed"] = "Send failed: {0}",
+ ["WinUsbSendPlaceholder"] = "Send data",
+ ["WinUsbInvalidEndpoint"] = "Invalid endpoint address",
+ ["WinUsbEndpointFailed"] = "Failed to open endpoint",
+ // Serial Monitor
+ ["SerialMonitorStart"] = "Start Monitor",
+ ["SerialMonitorStop"] = "Stop Monitor",
+ ["SerialMonitorStopped"] = "Monitor stopped",
+ ["SerialMonitorMonitoring"] = "Monitoring...",
+ ["SerialMonitorStartFailed"] = "Start monitor failed: {0}",
+ ["SerialMonitorSelectBoth"] = "Please select process and COM port",
+ ["SerialMonitorInvalidPid"] = "Invalid process ID",
+ // Input dialogs
+ ["InputDialogConfirm"] = "OK",
+ ["InputDialogCancel"] = "Cancel",
+ // QuickSend messages
+ ["QuickSendListDefault"] = "List",
+ ["QuickSendImportSuccess"] = "Data imported successfully",
+ ["QuickSendImportFailed"] = "Import failed: {0}",
+ ["QuickSendExportSuccess"] = "Data exported successfully!",
+ ["QuickSendExportFailed"] = "Export failed: {0}",
+ ["QuickSendSavedToProfile"] = "Data saved to profile directory",
+ ["QuickSendNeedFilePicker"] = "Please enable file picker in UI",
+ ["QuickSendImportSSCOMSuccess"] = "Imported {0} items from SSCOM",
+ ["QuickSendImportSSCOMFailed"] = "Import SSCOM failed: {0}",
+ // Online scripts
+ ["LoadingOnlineScripts"] = "Loading online scripts...",
+ ["LoadingProgress"] = "Loading... {0}/{1}",
+ ["OnlineScriptFileExists"] = "Script file already exists!",
+ ["OnlineScriptSaveSuccess"] = "Saved successfully!",
+ ["OnlineScriptSaveFailed"] = "Save failed: {0}",
+ // Lua editor
+ ["LuaSaveScript"] = "Save script",
+ ["LuaRunOneLine"] = "Run one line...",
+ // About
+ ["AboutSubTitle"] = "Debug serial port with Lua!",
+ };
+
+ public static void SetLanguage(string language)
+ {
+ CurrentLanguage = language;
+ }
+
+ public static string Get(string key)
+ {
+ var dict = CurrentLanguage == "zh-CN" ? ZhCn : EnUs;
+ return dict.TryGetValue(key, out var value) ? value : $"<{key}>";
+ }
+
+ public static string Format(string key, params object[] args)
+ {
+ return string.Format(Get(key), args);
+ }
+}
diff --git a/llcom.Avalonia/Program.cs b/llcom.Avalonia/Program.cs
new file mode 100644
index 0000000..adfb0ef
--- /dev/null
+++ b/llcom.Avalonia/Program.cs
@@ -0,0 +1,66 @@
+using Avalonia;
+using System;
+using System.Runtime.InteropServices;
+using System.Threading;
+
+namespace llcom.Avalonia;
+
+sealed class Program
+{
+ [STAThread]
+ public static void Main(string[] args)
+ {
+ // Register legacy encodings (GBK, Shift_JIS, etc.) for EncodingFix feature
+ System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
+
+ // Global unhandled exception handlers for better diagnostics
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ {
+ var ex = e.ExceptionObject as Exception;
+ Console.Error.WriteLine($"[llcom FATAL] Unhandled exception: {ex}");
+#if DEBUG
+ if (ex != null)
+ Console.Error.WriteLine(ex.ToString());
+#endif
+ };
+
+ // Print environment diagnostics on Linux (help troubleshoot X11/Wayland issues)
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ var display = Environment.GetEnvironmentVariable("DISPLAY");
+ var wayland = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY");
+ var session = Environment.GetEnvironmentVariable("XDG_SESSION_TYPE");
+ Console.WriteLine($"[llcom] DISPLAY={display ?? "(not set)"} WAYLAND_DISPLAY={wayland ?? "(not set)"} XDG_SESSION_TYPE={session ?? "(not set)"}");
+ }
+
+ try
+ {
+ BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+ }
+ catch (Exception ex)
+ {
+ var msg = ex.Message;
+ Console.Error.WriteLine($"[llcom FATAL] Startup failed: {msg}");
+
+ // Give actionable hints for common Linux display failures
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) &&
+ (msg.Contains("XOpenDisplay") || msg.Contains("Display") || msg.Contains("X11")))
+ {
+ Console.Error.WriteLine("[llcom HINT] X11 connection failed. Possible fixes:");
+ Console.Error.WriteLine(" 1. Ensure you are running from a desktop environment (not SSH).");
+ Console.Error.WriteLine(" 2. If using Wayland, install XWayland: sudo apt install xwayland");
+ Console.Error.WriteLine(" 3. Try: export DISPLAY=:0 && ./llcom");
+ }
+
+ Console.Error.WriteLine(ex.StackTrace);
+ Environment.Exit(1);
+ }
+ }
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/llcom.Avalonia/ViewLocator.cs b/llcom.Avalonia/ViewLocator.cs
new file mode 100644
index 0000000..98bbfeb
--- /dev/null
+++ b/llcom.Avalonia/ViewLocator.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using llcom.Avalonia.ViewModels;
+
+namespace llcom.Avalonia;
+
+[RequiresUnreferencedCode(
+ "Default implementation of ViewLocator involves reflection which may be trimmed away.",
+ Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
+public class ViewLocator : IDataTemplate
+{
+ public Control? Build(object? param)
+ {
+ if (param is null)
+ return null;
+
+ var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object? data)
+ {
+ return data is ViewModelBase;
+ }
+}
diff --git a/llcom.Avalonia/ViewModels/AboutViewModel.cs b/llcom.Avalonia/ViewModels/AboutViewModel.cs
new file mode 100644
index 0000000..f6f1377
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/AboutViewModel.cs
@@ -0,0 +1,45 @@
+using System.Collections.Generic;
+using System;
+using System.Reflection;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using llcom.Tools;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class AboutViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string _assemblyVersion = "";
+
+ [ObservableProperty]
+ private string _platform = Environment.OSVersion.ToString();
+
+ [ObservableProperty]
+ private string _framework = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription;
+
+ public List ThanksProjects { get; } = new()
+ {
+ "MoonSharp (Lua)",
+ "Avalonia UI",
+ "AvaloniaEdit",
+ "LibUsbDotNet",
+ "CommunityToolkit.Mvvm",
+ "SkiaSharp"
+ };
+
+ public AboutViewModel()
+ {
+ Platform = $"{PlatformHelper.GetPlatformName()} - {Environment.OSVersion}";
+ AssemblyVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0";
+ }
+
+ [RelayCommand]
+ private void OpenGitHub() => PlatformHelper.OpenUrl("https://github.com/chenxuuu/llcom");
+
+ [RelayCommand]
+ private void OpenIssue() => PlatformHelper.OpenUrl("https://github.com/chenxuuu/llcom/issues");
+
+ [RelayCommand]
+ private void OpenApiDoc() => PlatformHelper.OpenUrl("https://github.com/chenxuuu/llcom/blob/master/LuaApi.md");
+}
diff --git a/llcom.Avalonia/ViewModels/ConvertPageViewModel.cs b/llcom.Avalonia/ViewModels/ConvertPageViewModel.cs
new file mode 100644
index 0000000..c2331b3
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/ConvertPageViewModel.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Web;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using llcom.Tools;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class ConvertPageViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string _rawText = "";
+
+ [ObservableProperty]
+ private string _resultText = "";
+
+ [ObservableProperty]
+ private int _selectedConverterIndex = -1;
+
+ public ObservableCollection ConverterNames { get; } = new();
+ public ObservableCollection ConvertJobs { get; } = new();
+
+ private readonly Dictionary> _converters = new()
+ {
+ ["String to Hex(with space)"] = e => Encoding.Default.GetBytes(BitConverter.ToString(e).Replace("-", " ")),
+ ["String to Hex(without space)"] = e => Encoding.Default.GetBytes(BitConverter.ToString(e).Replace("-", "")),
+ ["Hex to String"] = e => Hex2Byte(Encoding.Default.GetString(e)),
+ ["String to Base64"] = e => { try { return Encoding.Default.GetBytes(Convert.ToBase64String(e)); } catch (Exception ex) { return Encoding.Default.GetBytes(ex.Message); } },
+ ["Base64 to String"] = e => { try { return Convert.FromBase64String(Encoding.Default.GetString(e)); } catch (Exception ex) { return Encoding.Default.GetBytes(ex.Message); } },
+ ["URL encode"] = e => Encoding.Default.GetBytes(HttpUtility.UrlEncode(Encoding.Default.GetString(e))),
+ ["URL decode"] = e => Encoding.Default.GetBytes(HttpUtility.UrlDecode(Encoding.Default.GetString(e))),
+ ["HTML encode"] = e => Encoding.Default.GetBytes(HttpUtility.HtmlEncode(Encoding.Default.GetString(e))),
+ ["HTML decode"] = e => Encoding.Default.GetBytes(HttpUtility.HtmlDecode(Encoding.Default.GetString(e))),
+ ["String to Unicode"] = e => Encoding.Default.GetBytes(String2Unicode(Encoding.Default.GetString(e))),
+ ["Unicode to String"] = e => Encoding.Default.GetBytes(Unicode2String(Encoding.Default.GetString(e))),
+ ["String to MD5 (Hex)"] = e => Encoding.Default.GetBytes(BitConverter.ToString(MD5Encrypt(e)).Replace("-", "")),
+ ["String to SHA-1 (Hex)"] = e => Encoding.Default.GetBytes(BitConverter.ToString(SHA1Encrypt(e)).Replace("-", "")),
+ ["String to SHA-256 (Hex)"] = e => Encoding.Default.GetBytes(BitConverter.ToString(SHA256Encrypt(e)).Replace("-", "")),
+ ["String to SHA-512 (Hex)"] = e => Encoding.Default.GetBytes(BitConverter.ToString(SHA512Encrypt(e)).Replace("-", "")),
+ };
+
+ public ConvertPageViewModel()
+ {
+ foreach (var key in _converters.Keys)
+ ConverterNames.Add(key);
+ ConvertJobs.CollectionChanged += (_, _) => DoConvert();
+ }
+
+ partial void OnRawTextChanged(string value) => DoConvert();
+
+ private void DoConvert()
+ {
+ byte[] row = Encoding.Default.GetBytes(RawText);
+ foreach (var job in ConvertJobs)
+ {
+ if (_converters.ContainsKey(job))
+ row = _converters[job](row);
+ }
+ ResultText = Encoding.Default.GetString(row);
+ }
+
+ [RelayCommand]
+ private void AddJob()
+ {
+ if (SelectedConverterIndex < 0 || SelectedConverterIndex >= ConverterNames.Count)
+ return;
+ ConvertJobs.Add(ConverterNames[SelectedConverterIndex]);
+ }
+
+ [RelayCommand]
+ private void RemoveLastJob()
+ {
+ if (ConvertJobs.Count == 0) return;
+ ConvertJobs.RemoveAt(ConvertJobs.Count - 1);
+ }
+
+ // Use shared ByteConvert.Hex2Byte with 1MB input size limit
+ private static byte[] Hex2Byte(string hex) => ByteConvert.Hex2Byte(hex);
+
+ private static byte[] MD5Encrypt(byte[] b) => MD5.HashData(b);
+ private static byte[] SHA1Encrypt(byte[] b) => SHA1.HashData(b);
+ private static byte[] SHA256Encrypt(byte[] b) => SHA256.HashData(b);
+ private static byte[] SHA512Encrypt(byte[] b) => SHA512.HashData(b);
+
+ private static string String2Unicode(string source)
+ {
+ var bytes = Encoding.Unicode.GetBytes(source);
+ var sb = new StringBuilder();
+ for (var i = 0; i < bytes.Length; i += 2)
+ sb.AppendFormat("\\u{0}{1}", bytes[i + 1].ToString("x").PadLeft(2, '0'), bytes[i].ToString("x").PadLeft(2, '0'));
+ return sb.ToString();
+ }
+
+ private static string Unicode2String(string source) =>
+ new Regex(@"\\u([0-9a-fA-F]{4})", RegexOptions.IgnoreCase | RegexOptions.Compiled)
+ .Replace(source, x => Convert.ToChar(Convert.ToUInt16(x.Result("$1"), 16)).ToString());
+}
diff --git a/llcom.Avalonia/ViewModels/DataLineItem.cs b/llcom.Avalonia/ViewModels/DataLineItem.cs
new file mode 100644
index 0000000..1d83055
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/DataLineItem.cs
@@ -0,0 +1,24 @@
+using System;
+using Avalonia.Media;
+
+namespace llcom.Avalonia.ViewModels;
+
+///
+/// 格式化数据显示行:支持时间戳、方向箭头、Hex/文本颜色区分
+///
+public class DataLineItem
+{
+ private static readonly SolidColorBrush ReceivedBrush = new(Color.Parse("#569CD6"));
+ private static readonly SolidColorBrush SentBrush = new(Color.Parse("#6A9955"));
+
+ public DateTime Timestamp { get; set; }
+ public string TimestampText => Timestamp.ToString("HH:mm:ss.fff");
+ public bool IsSent { get; set; }
+ public string Arrow => IsSent ? "→" : "←";
+ public IBrush Foreground => IsSent ? SentBrush : ReceivedBrush;
+ public string Data { get; set; } = "";
+ public bool IsHex { get; set; }
+
+ /// 格式化后的完整显示行
+ public string DisplayText => $"[{TimestampText}] {Arrow} {Data}";
+}
diff --git a/llcom.Avalonia/ViewModels/EncodingFixViewModel.cs b/llcom.Avalonia/ViewModels/EncodingFixViewModel.cs
new file mode 100644
index 0000000..9e854b7
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/EncodingFixViewModel.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Text;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class EncodingFixViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string _rawText = "";
+
+ [ObservableProperty]
+ private FixResultItem? _selectedResult;
+
+ public ObservableCollection FixResults { get; } = new();
+
+ private static readonly string[] EncodingList = { "UTF-8", "GBK", "windows-1252", "Big5", "Shift_Jis", "iso-8859-1" };
+
+ partial void OnRawTextChanged(string value)
+ {
+ FixResults.Clear();
+ if (string.IsNullOrEmpty(value)) return;
+
+ for (int i = 0; i < EncodingList.Length; i++)
+ {
+ for (int j = 0; j < EncodingList.Length; j++)
+ {
+ if (i == j) continue;
+ try
+ {
+ var result = Encoding.GetEncoding(EncodingList[i])
+ .GetString(Encoding.GetEncoding(EncodingList[j]).GetBytes(value));
+ FixResults.Add(new FixResultItem
+ {
+ Raw = EncodingList[i],
+ Target = EncodingList[j],
+ Result = result
+ });
+ }
+ catch
+ {
+ // skip invalid encoding combinations
+ }
+ }
+ }
+ }
+
+ /// Callback for clipboard text copy (set by View layer).
+ public static Func? CopyToClipboardCallback { get; set; }
+
+ [RelayCommand]
+ private async Task CopySelected()
+ {
+ if (SelectedResult == null || string.IsNullOrEmpty(SelectedResult.Result)) return;
+ if (CopyToClipboardCallback != null)
+ await CopyToClipboardCallback(SelectedResult.Result);
+ }
+}
+
+public class FixResultItem
+{
+ public string Raw { get; set; } = "";
+ public string Target { get; set; } = "";
+ public string Result { get; set; } = "";
+}
diff --git a/llcom.Avalonia/ViewModels/LuaScriptViewModel.cs b/llcom.Avalonia/ViewModels/LuaScriptViewModel.cs
new file mode 100644
index 0000000..85c6dca
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/LuaScriptViewModel.cs
@@ -0,0 +1,509 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AvaloniaEdit.Document;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using llcom.Tools;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class LuaScriptViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string _runScriptPath = "user_script_run/example.lua";
+
+ [ObservableProperty]
+ private ObservableCollection _scriptList = new();
+
+ [ObservableProperty]
+ private string? _selectedScript;
+
+ [ObservableProperty]
+ private string _logOutput = "";
+
+ [ObservableProperty]
+ private string _commandInput = "";
+
+ [ObservableProperty]
+ private bool _isRunning;
+
+ [ObservableProperty]
+ private string _runStopText = "▶";
+
+ [ObservableProperty]
+ private bool _isEditorVisible = true;
+
+ [ObservableProperty]
+ private bool _isLogVisible;
+
+ [ObservableProperty]
+ private bool _isEditorEnabled = true;
+
+ [ObservableProperty]
+ private bool _isNewScriptPanelVisible;
+
+ [ObservableProperty]
+ private string _newScriptName = "new script";
+
+ [ObservableProperty]
+ private bool _isEditorModified;
+
+ [ObservableProperty]
+ private string _statusText = "就绪";
+
+ [ObservableProperty]
+ private string _pauseButtonText = "⏸";
+
+ [ObservableProperty]
+ private bool _isLogPaused;
+
+ [ObservableProperty]
+ private TextDocument? _document = new();
+
+ // ── Test hex convert ─────────────────────────────────────────────
+ [ObservableProperty]
+ private bool _testHex = true;
+ [ObservableProperty]
+ private string _testData = "";
+ [ObservableProperty]
+ private string _testResult = "";
+
+ // Auto-save tracking
+ private string _lastLuaFile = "";
+ private DateTime _lastLuaFileTime = DateTime.MinValue;
+ private DateTime _lastLuaChangeTime = DateTime.MinValue;
+
+ // Log buffering
+ private readonly object _logLock = new();
+ private readonly List _logBuffer = new();
+ private readonly EventWaitHandle _logSignal = new(false, EventResetMode.AutoReset);
+ private int _logCount;
+ private bool _isLogTaskRunning;
+
+ public LuaScriptViewModel()
+ {
+ try { RefreshScriptList(); }
+ catch (Exception ex) { StatusText = $"初始化脚本列表失败: {ex.Message}"; }
+
+ try { LuaEnv.LuaApis.PrintLuaLog += OnLuaLog; } catch { }
+ try { LuaEnv.LuaRunEnv.LuaRunError += OnLuaError; } catch { }
+
+ try { StartLogTask(); } catch { }
+ }
+
+ partial void OnSelectedScriptChanged(string? value)
+ {
+ if (value != null)
+ {
+ // Auto-save previous file
+ if (!string.IsNullOrEmpty(_lastLuaFile) && _lastLuaChangeTime > _lastLuaFileTime)
+ SaveLuaFile(_lastLuaFile);
+
+ LoadScriptContent(value);
+ }
+ }
+
+ /// Auto-save when editor loses focus or window deactivates.
+ public void OnEditorLostFocus()
+ {
+ if (!string.IsNullOrEmpty(_lastLuaFile) && _lastLuaChangeTime > _lastLuaFileTime)
+ SaveLuaFile(_lastLuaFile);
+ }
+
+ /// Check for external file changes when window is activated.
+ public void OnWindowActivated()
+ {
+ if (string.IsNullOrEmpty(_lastLuaFile)) return;
+ var fullPath = Path.Combine(PlatformHelper.ProfilePath, "user_script_run", _lastLuaFile + ".lua");
+ try
+ {
+ if (File.Exists(fullPath))
+ {
+ var fileTime = File.GetLastWriteTime(fullPath);
+ if (fileTime > _lastLuaFileTime)
+ {
+ Document = new TextDocument(File.ReadAllText(fullPath));
+ _lastLuaFileTime = fileTime;
+ _lastLuaChangeTime = fileTime;
+ StatusText = $"检测到外部更改,已重新加载: {_lastLuaFile}";
+ }
+ }
+ }
+ catch { }
+ }
+
+ private void LoadScriptContent(string path)
+ {
+ try
+ {
+ // Validate path to prevent directory traversal
+ var fullPath = GetSafeScriptPath(path, "user_script_run");
+ if (fullPath == null)
+ {
+ StatusText = $"无效的脚本路径: {path}";
+ return;
+ }
+ if (File.Exists(fullPath))
+ {
+ Document = new TextDocument(File.ReadAllText(fullPath));
+ StatusText = $"已加载: {path}";
+ IsEditorModified = false;
+ _lastLuaFile = Path.GetFileNameWithoutExtension(fullPath);
+ _lastLuaFileTime = File.GetLastWriteTime(fullPath);
+ _lastLuaChangeTime = _lastLuaFileTime;
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"加载脚本失败: {ex.Message}";
+ }
+ }
+
+ /// Ensure the script path doesn't traverse outside the scripts directory.
+ private static string? GetSafeScriptPath(string path, string subDir)
+ {
+ var baseDir = Path.GetFullPath(Path.Combine(PlatformHelper.ProfilePath, subDir));
+ var fullPath = Path.GetFullPath(Path.Combine(PlatformHelper.ProfilePath, path));
+ return fullPath.StartsWith(baseDir) ? fullPath : null;
+ }
+
+ private void SaveLuaFile(string fileName)
+ {
+ if (Document == null) return;
+ try
+ {
+ var fullPath = Path.Combine(PlatformHelper.ProfilePath, "user_script_run", fileName + ".lua");
+ var dir = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ if (!fullPath.StartsWith(Path.GetFullPath(Path.Combine(PlatformHelper.ProfilePath, "user_script_run"))))
+ return;
+ File.WriteAllText(fullPath, Document.Text);
+ _lastLuaFileTime = File.GetLastWriteTime(fullPath);
+ IsEditorModified = false;
+ }
+ catch { }
+ }
+
+ /// Mark document as modified for auto-save tracking.
+ public void MarkDocumentChanged()
+ {
+ _lastLuaChangeTime = DateTime.Now;
+ IsEditorModified = true;
+ }
+
+ private void OnLuaLog(object? sender, EventArgs e)
+ {
+ if (sender is string msg && msg != null)
+ {
+ lock (_logLock)
+ {
+ if (_logBuffer.Count > 500)
+ {
+ _logBuffer.Clear();
+ _logBuffer.Add("too many logs!");
+ Thread.Sleep(200); // throttle
+ }
+ else
+ _logBuffer.Add(msg);
+ }
+ _logSignal.Set();
+ }
+ }
+
+ private void OnLuaError(object? sender, EventArgs e)
+ {
+ IsRunning = false;
+ RunStopText = "▶";
+ AppendLog("--- Lua stopped ---");
+ }
+
+ private int _maxLogLen = 50000;
+ private void AppendLog(string msg)
+ {
+ var newText = LogOutput + msg + "\n";
+ if (newText.Length > _maxLogLen)
+ newText = newText[^(Math.Min(_maxLogLen, newText.Length))..];
+ LogOutput = newText;
+ }
+
+ private void StartLogTask()
+ {
+ if (_isLogTaskRunning) return;
+ _isLogTaskRunning = true;
+ new Thread(() =>
+ {
+ while (!GlobalState.Instance.IsMainWindowClosed)
+ {
+ _logSignal.WaitOne(200);
+ if (GlobalState.Instance.IsMainWindowClosed) return;
+ if (IsLogPaused) continue;
+
+ string[] logs;
+ lock (_logLock)
+ {
+ logs = _logBuffer.ToArray();
+ _logBuffer.Clear();
+ }
+ if (logs.Length == 0) continue;
+
+ _logCount += logs.Length;
+ foreach (var log in logs)
+ {
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ if (_logCount >= 1000)
+ {
+ LogOutput = "Lua log too long, auto cleared.\nMore logs see lua log file.\n";
+ _logCount = 0;
+ }
+ AppendLog(log);
+ });
+ }
+ Thread.Sleep(10); // throttle
+ }
+ }) { IsBackground = true }.Start();
+ }
+
+ // ── Commands ────────────────────────────────────────────────────
+
+ [RelayCommand]
+ private void RefreshScriptList()
+ {
+ try
+ {
+ var dir = Path.Combine(PlatformHelper.ProfilePath, "user_script_run");
+ if (!Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ var files = Directory.GetFiles(dir, "*.lua")
+ .Select(f => "user_script_run/" + Path.GetFileName(f));
+ ScriptList = new ObservableCollection(files);
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"刷新脚本列表失败: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private async Task ToggleRun()
+ {
+ if (IsRunning)
+ {
+ LuaEnv.LuaRunEnv.StopLua("");
+ IsRunning = false;
+ RunStopText = "▶";
+ AppendLog("--- User stopped ---");
+ }
+ else
+ {
+ // Save current content to temp file before running
+ if (Document != null && IsEditorModified && SelectedScript != null)
+ {
+ SaveCurrentScript();
+ }
+
+ if (string.IsNullOrEmpty(SelectedScript))
+ {
+ StatusText = "请先选择一个脚本";
+ return;
+ }
+
+ IsLogVisible = true;
+ IsEditorVisible = false;
+
+ try
+ {
+ LuaEnv.LuaRunEnv.New(SelectedScript);
+ await Task.Delay(200);
+ IsRunning = true;
+ RunStopText = "■";
+ AppendLog($"--- Running: {SelectedScript} ---");
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Error: {ex.Message}";
+ AppendLog($"Error loading script: {ex.Message}");
+ }
+ }
+ }
+
+ [RelayCommand]
+ private void StopLua()
+ {
+ if (IsRunning)
+ {
+ LuaEnv.LuaRunEnv.StopLua("");
+ IsRunning = false;
+ RunStopText = "▶";
+ AppendLog("--- Stopped ---");
+ }
+ IsLogVisible = false;
+ IsEditorVisible = true;
+ }
+
+ [RelayCommand]
+ private void RunCommand()
+ {
+ if (string.IsNullOrWhiteSpace(CommandInput)) return;
+ if (!IsRunning)
+ {
+ AppendLog("Script not running. Start a script first.");
+ return;
+ }
+ LuaEnv.LuaRunEnv.RunCommand(CommandInput);
+ AppendLog($">> {CommandInput}");
+ CommandInput = "";
+ }
+
+ [RelayCommand]
+ private void SaveScript()
+ {
+ SaveCurrentScript();
+ }
+
+ ///
+ /// Sanitize a file name by removing path separators and invalid characters.
+ ///
+ private static string SanitizeFileName(string name)
+ {
+ if (string.IsNullOrEmpty(name)) return "untitled";
+ // Strip any directory traversal characters
+ name = name.Replace("/", "").Replace("\\", "").Replace("..", "");
+ // Remove other invalid characters
+ foreach (var c in Path.GetInvalidFileNameChars())
+ name = name.Replace(c.ToString(), "");
+ if (string.IsNullOrWhiteSpace(name)) return "untitled";
+ return name.Trim();
+ }
+
+ private void SaveCurrentScript()
+ {
+ if (Document == null || SelectedScript == null) return;
+ try
+ {
+ // Validate path to prevent directory traversal
+ var safePath = GetSafeScriptPath(SelectedScript, "user_script_run");
+ if (safePath == null)
+ {
+ StatusText = $"保存失败: 无效的脚本路径";
+ return;
+ }
+ File.WriteAllText(safePath, Document.Text);
+ IsEditorModified = false;
+ StatusText = "脚本已保存";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"保存失败: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private void NewScript()
+ {
+ IsNewScriptPanelVisible = true;
+ NewScriptName = "new script";
+ }
+
+ [RelayCommand]
+ private void ConfirmNewScript()
+ {
+ if (string.IsNullOrWhiteSpace(NewScriptName))
+ {
+ StatusText = "请输入文件名";
+ return;
+ }
+
+ try
+ {
+ // Sanitize file name to prevent path traversal
+ var safeName = SanitizeFileName(NewScriptName.Trim());
+ var fileName = safeName.EndsWith(".lua") ? safeName : safeName + ".lua";
+ var fullPath = Path.Combine(PlatformHelper.ProfilePath, "user_script_run", fileName);
+
+ if (!fullPath.StartsWith(Path.GetFullPath(Path.Combine(PlatformHelper.ProfilePath, "user_script_run"))))
+ {
+ StatusText = "无效的文件名";
+ return;
+ }
+
+ if (File.Exists(fullPath))
+ {
+ StatusText = "该文件已存在";
+ return;
+ }
+
+ File.WriteAllText(fullPath, "-- New Lua Script\n");
+ IsNewScriptPanelVisible = false;
+ RefreshScriptList();
+
+ var relativePath = "user_script_run/" + fileName;
+ SelectedScript = relativePath;
+ StatusText = $"已创建: {fileName}";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"创建失败: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private void CancelNewScript()
+ {
+ IsNewScriptPanelVisible = false;
+ }
+
+ [RelayCommand]
+ private void OpenScriptFolder()
+ {
+ var dir = Path.Combine(PlatformHelper.ProfilePath, "user_script_run");
+ Directory.CreateDirectory(dir);
+ PlatformHelper.OpenUrl(dir);
+ }
+
+ [RelayCommand]
+ private void OpenApiDoc()
+ {
+ PlatformHelper.OpenUrl("https://github.com/chenxuuu/llcom/blob/master/LuaApi.md");
+ }
+
+ [RelayCommand]
+ private void ScriptShare()
+ {
+ PlatformHelper.OpenUrl("https://github.com/chenxuuu/llcom/discussions");
+ }
+
+ [RelayCommand]
+ private void TogglePauseLog()
+ {
+ IsLogPaused = !IsLogPaused;
+ PauseButtonText = IsLogPaused ? "▶" : "⏸";
+ }
+
+ [RelayCommand]
+ private void TestHexConvert()
+ {
+ try
+ {
+ if (TestHex)
+ TestResult = ByteConvert.Byte2Hex(
+ System.Text.Encoding.UTF8.GetBytes(TestData), " ");
+ else
+ TestResult = ByteConvert.Hex2String(TestData.Replace(" ", ""));
+ StatusText = "转换完成";
+ }
+ catch (Exception ex)
+ {
+ TestResult = $"Error: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private void ClearLog() => LogOutput = "";
+}
diff --git a/llcom.Avalonia/ViewModels/MainWindowViewModel.cs b/llcom.Avalonia/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..a1d9efe
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,429 @@
+using System;
+using System.Collections.ObjectModel;
+using System.IO.Ports;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using llcom.Avalonia.Helpers;
+using llcom.Tools;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class MainWindowViewModel : ViewModelBase
+{
+ // ── Serial port properties ──────────────────────────────────────────
+ [ObservableProperty]
+ private string[] _portNames = SerialPort.GetPortNames();
+ [ObservableProperty]
+ private string? _selectedPort;
+ [ObservableProperty]
+ private ObservableCollection _baudRates = new()
+ {
+ "110", "300", "600", "1200", "2400", "4800", "9600", "14400",
+ "19200", "28800", "38400", "56000", "57600", "115200", "128000",
+ "230400", "256000", "460800", "500000", "512000", "600000",
+ "750000", "921600", "1000000", "1500000", "2000000", "3000000",
+ Helpers.LocaleHelper.Get("OtherRate")
+ };
+ private int _lastBaudRateIndex = -1;
+ [ObservableProperty]
+ private string _selectedBaudRate = "115200";
+
+ partial void OnSelectedBaudRateChanged(string value)
+ {
+ // Skip if index hasn't changed (programmatic set)
+ var idx = BaudRates.IndexOf(value);
+ if (idx == _lastBaudRateIndex) return;
+ _lastBaudRateIndex = idx;
+
+ if (value == LocaleHelper.Get("OtherRate"))
+ {
+ // Custom baud rate — show input dialog
+ var result = PlatformHelper.ShowInputDialog(
+ LocaleHelper.Get("ShowBaudRate"),
+ "115200",
+ LocaleHelper.Get("OtherRate"));
+ if (result.Item1 && int.TryParse(result.Item2, out var customBaud) && customBaud > 0)
+ {
+ BaudRates[BaudRates.Count - 1] = customBaud.ToString();
+ SelectedBaudRate = customBaud.ToString();
+ _lastBaudRateIndex = BaudRates.Count - 1;
+ }
+ else
+ {
+ PlatformHelper.ShowMessage(LocaleHelper.Get("OtherRateFail"));
+ BaudRates[BaudRates.Count - 1] = LocaleHelper.Get("OtherRate");
+ SelectedBaudRate = "115200";
+ _lastBaudRateIndex = BaudRates.IndexOf("115200");
+ }
+ }
+ }
+ [ObservableProperty]
+ private ObservableCollection _dataBitsList = new() { "5", "6", "7", "8" };
+ [ObservableProperty]
+ private string _selectedDataBits = "8";
+ [ObservableProperty]
+ private ObservableCollection _stopBitsList = new() { "1", "1.5", "2" };
+ [ObservableProperty]
+ private string _selectedStopBits = "1";
+ [ObservableProperty]
+ private ObservableCollection _parityList = new() { "None", "Odd", "Even", "Mark", "Space" };
+ [ObservableProperty]
+ private string _selectedParity = "None";
+ [ObservableProperty]
+ private bool _isPortOpen;
+ [ObservableProperty]
+ private string _openCloseButtonText = Helpers.LocaleHelper.Get("OpenPortButton");
+
+ // ── Data display ────────────────────────────────────────────────────
+ [ObservableProperty]
+ private ObservableCollection _receivedLines = new();
+ [ObservableProperty]
+ private string _dataToSend = "";
+ [ObservableProperty]
+ private bool _hexSend;
+ [ObservableProperty]
+ private bool _hexDisplay;
+ [ObservableProperty]
+ private bool _showSend = true;
+ [ObservableProperty]
+ private bool _autoReconnect = true;
+ [ObservableProperty]
+ private string _statusText = Helpers.LocaleHelper.Get("StatusReady");
+ [ObservableProperty]
+ private long _sentCount;
+ [ObservableProperty]
+ private long _receivedCount;
+ [ObservableProperty]
+ private bool _lockScroll;
+ [ObservableProperty]
+ private bool _isReady = true;
+ [ObservableProperty]
+ private bool _showSymbol;
+
+ // ── Terminal mode ────────────────────────────────────────────────────
+ [ObservableProperty]
+ private bool _terminalMode;
+ [ObservableProperty]
+ private string _terminalBorderColor = "Transparent";
+
+ public void HandleTerminalKeyInput(string text)
+ {
+ if (!TerminalMode || !IsPortOpen) return;
+ try
+ {
+ var data = System.Text.Encoding.ASCII.GetBytes(text);
+ UartManager.Instance.SendData(data);
+ }
+ catch { }
+ }
+
+ public void HandleTerminalCtrlKey(int key)
+ {
+ if (!TerminalMode || !IsPortOpen) return;
+ // Ctrl+A..Z sends ASCII control codes 1..26
+ if (key >= 0 && key < 26)
+ {
+ try { UartManager.Instance.SendData(new byte[] { (byte)(key + 1) }); }
+ catch { }
+ }
+ }
+
+ // ── RTS / DTR ───────────────────────────────────────────────────────
+ [ObservableProperty]
+ private bool _rtsEnabled;
+ [ObservableProperty]
+ private bool _dtrEnabled = true;
+
+ partial void OnRtsEnabledChanged(bool value)
+ {
+ if (IsPortOpen) UartManager.Instance.Rts = value;
+ }
+
+ partial void OnDtrEnabledChanged(bool value)
+ {
+ if (IsPortOpen) UartManager.Instance.Dtr = value;
+ }
+
+ // ── Window position persistence ─────────────────────────────────────
+ [ObservableProperty]
+ private double _windowLeft = double.NaN;
+ [ObservableProperty]
+ private double _windowTop = double.NaN;
+ [ObservableProperty]
+ private double _windowWidth = 900;
+ [ObservableProperty]
+ private double _windowHeight = 500;
+
+ // ── Tabs ────────────────────────────────────────────────────────────
+ [ObservableProperty]
+ private int _selectedTabIndex;
+
+ // Sub-page ViewModels
+ public QuickSendViewModel QuickSendPage { get; } = new();
+ public ConvertPageViewModel ConvertPage { get; } = new();
+ public EncodingFixViewModel EncodingFixPage { get; } = new();
+ public MqttViewModel MqttPage { get; } = new();
+ public TcpTestViewModel TcpTestPage { get; } = new();
+ public TcpTestViewModel TcpLocalPage { get; } = new();
+ public TcpTestViewModel UdpLocalPage { get; } = new();
+ public SocketClientViewModel SocketClientPage { get; } = new();
+ public PlotViewModel PlotPage { get; } = new();
+ public LuaScriptViewModel LuaScriptPage { get; } = new();
+ public OnlineScriptsViewModel OnlineScriptsPage { get; } = new();
+ public WinUsbViewModel WinUsbPage { get; } = new();
+ public SerialMonitorViewModel SerialMonitorPage { get; } = new();
+ public AboutViewModel AboutPage { get; } = new();
+
+ [ObservableProperty]
+ private string _title = Helpers.LocaleHelper.Get("AppTitle");
+
+ [ObservableProperty]
+ private string _platformInfo = $"{PlatformHelper.GetPlatformName()} - .NET 8";
+
+ private string _currentLanguage = "zh-CN";
+
+ public MainWindowViewModel()
+ {
+ PlatformHelper.ShowMessageCallback = msg =>
+ {
+ StatusText = msg;
+ };
+ // LoadLanguageFileCallback is set by App.axaml.cs after window creation
+ }
+
+ // ── Commands ────────────────────────────────────────────────────────
+ [RelayCommand]
+ private void RefreshPorts()
+ {
+ PortNames = SerialPort.GetPortNames();
+ }
+
+ [RelayCommand]
+ private void TogglePort()
+ {
+ if (IsPortOpen)
+ {
+ try
+ {
+ UartManager.Instance.Close();
+ IsPortOpen = false;
+ OpenCloseButtonText = LocaleHelper.Get("OpenPortButton");
+ StatusText = LocaleHelper.Get("StatusPortClosed");
+ }
+ catch (Exception ex) { StatusText = LocaleHelper.Format("StatusCloseFailed", ex.Message); }
+ }
+ else
+ {
+ if (string.IsNullOrEmpty(SelectedPort))
+ {
+ StatusText = LocaleHelper.Get("StatusSelectPort");
+ return;
+ }
+ try
+ {
+ var uart = UartManager.Instance;
+ uart.SetName(SelectedPort);
+ if (int.TryParse(SelectedBaudRate, out var baud) && baud > 0)
+ uart.Serial.BaudRate = baud;
+ uart.Serial.DataBits = int.Parse(SelectedDataBits);
+ uart.Serial.StopBits = SelectedStopBits switch
+ {
+ "1" => StopBits.One, "1.5" => StopBits.OnePointFive, "2" => StopBits.Two, _ => StopBits.One
+ };
+ uart.Serial.Parity = SelectedParity switch
+ {
+ "Odd" => Parity.Odd, "Even" => Parity.Even, "Mark" => Parity.Mark, "Space" => Parity.Space, _ => Parity.None
+ };
+ uart.Rts = RtsEnabled;
+ uart.Dtr = DtrEnabled;
+ uart.UartDataReceived += OnDataReceived;
+ uart.UartDataSent += OnDataSent;
+ uart.UartDataRawSent += OnDataRawSent;
+ uart.Open();
+ IsPortOpen = true;
+ OpenCloseButtonText = LocaleHelper.Get("ClosePortButton");
+ StatusText = LocaleHelper.Format("StatusConnected", SelectedPort!, SelectedBaudRate);
+ }
+ catch (Exception ex) { StatusText = LocaleHelper.Format("StatusOpenFailed", ex.Message); }
+ }
+ }
+
+ [RelayCommand]
+ private void SendData()
+ {
+ if (!IsPortOpen || string.IsNullOrEmpty(DataToSend)) return;
+ try
+ {
+ byte[] rawData = HexSend
+ ? ByteConvert.Hex2Byte(DataToSend)
+ : GlobalState.Instance.GetEncoding().GetBytes(DataToSend);
+
+ // Run through send script Lua pipeline (sendScript.lua)
+ byte[] processedData;
+ try
+ {
+ var state = GlobalState.Instance;
+ processedData = LuaEnv.LuaLoader.Run(
+ $"{state.Settings.sendScript}.lua",
+ new System.Collections.ArrayList { "uartData", rawData });
+ if (processedData.Length == 0)
+ processedData = rawData; // fallback if script returns empty
+ }
+ catch
+ {
+ processedData = rawData;
+ }
+
+ // Append CRLF if configured
+ if (GlobalState.Instance.Settings.extraEnter)
+ {
+ var temp = processedData.ToList();
+ temp.Add(0x0d);
+ temp.Add(0x0a);
+ processedData = temp.ToArray();
+ }
+
+ UartManager.Instance.SendData(processedData, rawData);
+ SentCount += rawData.Length;
+ StatusText = LocaleHelper.Format("StatusSentBytes", rawData.Length);
+ }
+ catch (Exception ex) { StatusText = LocaleHelper.Format("StatusSendFailed", ex.Message); }
+ }
+
+ [RelayCommand]
+ private void ClearData()
+ {
+ ReceivedLines.Clear();
+ SentCount = 0;
+ ReceivedCount = 0;
+ }
+
+ [RelayCommand]
+ private void SaveLog()
+ {
+ var fileName = $"llcom_log_{DateTime.Now:yyyyMMdd_HHmmss}.txt";
+ var path = System.IO.Path.Combine(PlatformHelper.ProfilePath, fileName);
+ try
+ {
+ System.IO.Directory.CreateDirectory(PlatformHelper.ProfilePath);
+ var text = string.Join(Environment.NewLine,
+ ReceivedLines.Select(line => line.DisplayText));
+ System.IO.File.WriteAllText(path, text);
+ StatusText = LocaleHelper.Format("StatusLogSaved", fileName);
+ }
+ catch (Exception ex) { StatusText = LocaleHelper.Format("StatusSaveFailed", ex.Message); }
+ }
+
+ [RelayCommand]
+ private void SwitchLanguage()
+ {
+ _currentLanguage = _currentLanguage == "zh-CN" ? "en-US" : "zh-CN";
+ LocaleHelper.SetLanguage(_currentLanguage);
+ PlatformHelper.LoadLanguageFile(_currentLanguage);
+
+ // Refresh all ViewModel text properties
+ Title = LocaleHelper.Get("AppTitle");
+ OpenCloseButtonText = IsPortOpen
+ ? LocaleHelper.Get("ClosePortButton")
+ : LocaleHelper.Get("OpenPortButton");
+ StatusText = IsPortOpen
+ ? LocaleHelper.Format("StatusConnected", SelectedPort ?? "", SelectedBaudRate)
+ : LocaleHelper.Get("StatusReady");
+ }
+
+ /// Open settings window.
+ public static event Action? OpenSettingsRequested;
+ [RelayCommand]
+ private void OpenSettings() { OpenSettingsRequested?.Invoke(); }
+
+ [RelayCommand]
+ private void OpenScriptFolder()
+ {
+ PlatformHelper.OpenUrl(PlatformHelper.ProfilePath);
+ }
+
+ [RelayCommand]
+ private void OpenApiDoc()
+ {
+ PlatformHelper.OpenUrl("https://github.com/chenxuuu/llcom/blob/master/LuaApi.md");
+ }
+
+ // ── Event handlers ──────────────────────────────────────────────────
+ private void OnDataReceived(object? sender, byte[] data)
+ {
+ var text = HexDisplay
+ ? ByteConvert.Byte2Hex(data, " ")
+ : ByteConvert.Byte2Readable(data);
+ var line = new DataLineItem
+ {
+ Timestamp = DateTime.Now,
+ IsSent = false,
+ Data = text,
+ IsHex = HexDisplay
+ };
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ AppendDataLine(line);
+ ReceivedCount += data.Length;
+ });
+ }
+
+ private void OnDataSent(object? sender, byte[] data)
+ {
+ if (!ShowSend) return;
+ var text = HexDisplay
+ ? ByteConvert.Byte2Hex(data, " ")
+ : ByteConvert.Byte2Readable(data);
+ var line = new DataLineItem
+ {
+ Timestamp = DateTime.Now,
+ IsSent = true,
+ Data = text,
+ IsHex = HexDisplay
+ };
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() => AppendDataLine(line));
+ }
+
+ private void OnDataRawSent(object? sender, byte[] data)
+ {
+ // Show raw sent data (before Lua processing) if ShowSendRaw is enabled
+ if (!GlobalState.Instance.Settings.showSendRaw) return;
+ var text = HexDisplay
+ ? ByteConvert.Byte2Hex(data, " ")
+ : ByteConvert.Byte2Readable(data);
+ var line = new DataLineItem
+ {
+ Timestamp = DateTime.Now,
+ IsSent = true,
+ Data = LocaleHelper.Get("RawDataSentTitle") + ": " + text,
+ IsHex = HexDisplay
+ };
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() => AppendDataLine(line));
+ }
+
+ private int _maxLines = 2000;
+ private void AppendDataLine(DataLineItem line)
+ {
+ ReceivedLines.Add(line);
+ while (ReceivedLines.Count > _maxLines)
+ ReceivedLines.RemoveAt(0);
+ }
+
+ public void Cleanup()
+ {
+ var uart = UartManager.Instance;
+ uart.UartDataReceived -= OnDataReceived;
+ uart.UartDataSent -= OnDataSent;
+ uart.UartDataRawSent -= OnDataRawSent;
+ uart.Close();
+ MqttPage.Cleanup();
+ TcpTestPage.Cleanup();
+ SocketClientPage.Cleanup();
+ PlotPage.Cleanup();
+ WinUsbPage.Cleanup();
+ SerialMonitorPage.Cleanup();
+ LuaEnv.LuaRunEnv.StopLua("");
+ LuaEnv.LuaLoader.ClearRun();
+ }
+}
diff --git a/llcom.Avalonia/ViewModels/MqttViewModel.cs b/llcom.Avalonia/ViewModels/MqttViewModel.cs
new file mode 100644
index 0000000..ef978c9
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/MqttViewModel.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using llcom.Tools;
+using MQTTnet;
+using MQTTnet.Client;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class MqttViewModel : ViewModelBase
+{
+ private IMqttClient? _mqttClient;
+ private readonly MqttFactory _factory = new();
+
+ [ObservableProperty]
+ private bool _isConnected;
+
+ [ObservableProperty]
+ private string _server = "broker.emqx.io";
+ [ObservableProperty]
+ private int _port = 1883;
+ [ObservableProperty]
+ private string _clientId = Guid.NewGuid().ToString("N")[..16];
+ [ObservableProperty]
+ private string _userName = "";
+ [ObservableProperty]
+ private string _password = "";
+ [ObservableProperty]
+ private int _keepAlive = 60;
+ [ObservableProperty]
+ private bool _useTls;
+ [ObservableProperty]
+ private string _tlsCaCertPath = "";
+ [ObservableProperty]
+ private string _tlsClientCertPath = "";
+ [ObservableProperty]
+ private string _tlsCertPassword = "";
+ [ObservableProperty]
+ private bool _useWebSocket;
+ [ObservableProperty]
+ private string _wsPath = "/mqtt";
+ [ObservableProperty]
+ private bool _cleanSession = true;
+ [ObservableProperty]
+ private string _subscribeTopic = "";
+ [ObservableProperty]
+ private string _publishTopic = "";
+ [ObservableProperty]
+ private string _publishPayload = "";
+ [ObservableProperty]
+ private int _subscribeQos;
+ [ObservableProperty]
+ private int _publishQos;
+ [ObservableProperty]
+ private bool _hexMode;
+ [ObservableProperty]
+ private string _log = "";
+
+ public ObservableCollection SubscribedTopics { get; } = new();
+ public ObservableCollection QosOptions { get; } = new() { 0, 1, 2 };
+
+ private async Task InitClientAsync()
+ {
+ _mqttClient = _factory.CreateMqttClient();
+ _mqttClient.ConnectedAsync += async e =>
+ {
+ IsConnected = true;
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() => SubscribedTopics.Clear());
+ AppendLog("MQTT: ✔ connected");
+ };
+ _mqttClient.DisconnectedAsync += async e =>
+ {
+ IsConnected = false;
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ SubscribedTopics.Clear();
+ SubscribedTopics.Add("Not connected");
+ });
+ AppendLog("MQTT: ❌ disconnected");
+ };
+ _mqttClient.ApplicationMessageReceivedAsync += e =>
+ {
+ var payload = Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment);
+ AppendLog($"MQTT → {e.ApplicationMessage.Topic}: {payload}");
+ return Task.CompletedTask;
+ };
+ }
+
+ [RelayCommand]
+ private async Task ToggleConnect()
+ {
+ if (_mqttClient == null)
+ await InitClientAsync();
+
+ if (IsConnected && _mqttClient != null)
+ {
+ await _mqttClient.DisconnectAsync();
+ return;
+ }
+
+ try
+ {
+ var optionsBuilder = new MqttClientOptionsBuilder()
+ .WithClientId(ClientId)
+ .WithKeepAlivePeriod(TimeSpan.FromSeconds(KeepAlive));
+
+ if (!string.IsNullOrEmpty(UserName))
+ optionsBuilder.WithCredentials(UserName, Password);
+
+ if (UseTls)
+ {
+ optionsBuilder.WithTlsOptions(o =>
+ {
+ o.WithSslProtocols(SslProtocols.Tls12 | SslProtocols.Tls13);
+
+ // NOTE: Certificate validation is intentionally relaxed for self-signed/
+ // embedded device MQTT brokers commonly used in IoT scenarios.
+ // In production deployments, use WithCertificateValidationHandler with
+ // proper certificate pinning instead of unconditional acceptance.
+ if (!string.IsNullOrEmpty(TlsCaCertPath) && File.Exists(TlsCaCertPath))
+ {
+ o.WithCertificateValidationHandler(ctx => true);
+ }
+
+ o.WithAllowUntrustedCertificates(true);
+ });
+ }
+
+ if (UseWebSocket)
+ optionsBuilder.WithWebSocketServer(b => b.WithUri($"{Server}:{Port}{WsPath}"));
+ else
+ optionsBuilder.WithTcpServer(Server, Port);
+
+ if (CleanSession)
+ optionsBuilder.WithCleanStart();
+
+ var options = optionsBuilder.Build();
+ await _mqttClient!.ConnectAsync(options, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"MQTT error: {ex.Message}");
+ }
+ }
+
+ [RelayCommand]
+ private async Task Subscribe()
+ {
+ if (!IsConnected || _mqttClient == null || string.IsNullOrEmpty(SubscribeTopic)) return;
+ try
+ {
+ await _mqttClient.SubscribeAsync(new MqttTopicFilterBuilder()
+ .WithTopic(SubscribeTopic)
+ .WithQualityOfServiceLevel((MQTTnet.Protocol.MqttQualityOfServiceLevel)SubscribeQos)
+ .Build());
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ if (!SubscribedTopics.Contains(SubscribeTopic))
+ SubscribedTopics.Add(SubscribeTopic);
+ });
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"Subscribe error: {ex.Message}");
+ }
+ }
+
+ [RelayCommand]
+ private async Task Publish()
+ {
+ if (!IsConnected || _mqttClient == null || string.IsNullOrEmpty(PublishTopic)) return;
+ try
+ {
+ var payload = HexMode
+ ? HexToBytes(PublishPayload)
+ : Encoding.UTF8.GetBytes(PublishPayload);
+ await _mqttClient.PublishAsync(new MqttApplicationMessageBuilder()
+ .WithTopic(PublishTopic)
+ .WithPayload(payload)
+ .WithQualityOfServiceLevel((MQTTnet.Protocol.MqttQualityOfServiceLevel)PublishQos)
+ .Build(), CancellationToken.None);
+ AppendLog($"MQTT ← {PublishTopic}: {PublishPayload}");
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"Publish error: {ex.Message}");
+ }
+ }
+
+ private void AppendLog(string msg) => Log += $"[{DateTime.Now:HH:mm:ss}] {msg}\n";
+
+ // Use shared ByteConvert.Hex2Byte with size limit
+ private static byte[] HexToBytes(string hex) => ByteConvert.Hex2Byte(hex);
+
+ public void Cleanup()
+ {
+ _mqttClient?.Dispose();
+ }
+}
diff --git a/llcom.Avalonia/ViewModels/OnlineScriptsViewModel.cs b/llcom.Avalonia/ViewModels/OnlineScriptsViewModel.cs
new file mode 100644
index 0000000..2827a7a
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/OnlineScriptsViewModel.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using llcom.Avalonia.Helpers;
+using llcom.Model;
+using llcom.Tools;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class OnlineScriptsViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private ObservableCollection _scripts = new();
+
+ [ObservableProperty]
+ private bool _isLoading;
+
+ [ObservableProperty]
+ private string _loadingMsg = LocaleHelper.Get("Loading");
+
+ [ObservableProperty]
+ private int _progress;
+
+ [ObservableProperty]
+ private bool _isIndeterminate = true;
+
+ [ObservableProperty]
+ private bool _isInList = true;
+
+ [ObservableProperty]
+ private OnlineScript? _selectedScript;
+
+ public OnlineScriptsViewModel()
+ {
+ _ = RefreshListAsync();
+ }
+
+ [RelayCommand]
+ private async Task RefreshList()
+ {
+ await RefreshListAsync();
+ }
+
+ private async Task RefreshListAsync()
+ {
+ IsLoading = true;
+ LoadingMsg = LocaleHelper.Get("LoadingOnlineScripts");
+ Scripts.Clear();
+
+ var result = await Task.Run(() =>
+ GlobalState.GetOnlineScripts((got, total) =>
+ {
+ LoadingMsg = LocaleHelper.Format("LoadingProgress", got, total);
+ Progress = (int)(got * 100.0 / total);
+ IsIndeterminate = false;
+ }));
+
+ Scripts = new ObservableCollection(result);
+
+ IsLoading = false;
+ IsInList = true;
+ }
+
+ [RelayCommand]
+ private void OpenScriptDetail(OnlineScript? script)
+ {
+ if (script == null) return;
+ SelectedScript = script;
+ IsInList = false;
+ }
+
+ [RelayCommand]
+ private void BackToList()
+ {
+ IsInList = true;
+ }
+
+ [RelayCommand]
+ private void OpenScriptUrl()
+ {
+ if (SelectedScript?.Url != null)
+ PlatformHelper.OpenUrl(SelectedScript.Url);
+ }
+
+ [RelayCommand]
+ private void OpenDiscussionPage()
+ {
+ PlatformHelper.OpenUrl("https://github.com/chenxuuu/llcom/discussions/87");
+ }
+
+ [RelayCommand]
+ private async Task DownloadScript()
+ {
+ if (SelectedScript == null) return;
+
+ // Sanitize the script name to prevent path traversal
+ var name = SelectedScript.Name
+ ?.Replace("/", "").Replace("\\", "").Replace("..", "")
+ ?? "untitled";
+ foreach (var c in Path.GetInvalidFileNameChars())
+ name = name.Replace(c.ToString(), "");
+ if (string.IsNullOrWhiteSpace(name)) name = "untitled";
+
+ var baseDir = Path.GetFullPath(Path.Combine(PlatformHelper.ProfilePath, "user_script_run"));
+ var path = Path.GetFullPath(Path.Combine(baseDir, $"{name}.lua"));
+ if (!path.StartsWith(baseDir))
+ {
+ PlatformHelper.ShowMessage("无法保存: 无效的脚本名称");
+ return;
+ }
+
+ if (File.Exists(path))
+ {
+ PlatformHelper.ShowMessage(LocaleHelper.Get("OnlineScriptFileExists"));
+ return;
+ }
+
+ try
+ {
+ Directory.CreateDirectory(baseDir);
+ await File.WriteAllTextAsync(path, SelectedScript.Script);
+ GlobalState.RefreshLuaScriptList();
+ PlatformHelper.ShowMessage(LocaleHelper.Get("OnlineScriptSaveSuccess"));
+ }
+ catch (Exception ex)
+ {
+ PlatformHelper.ShowMessage(LocaleHelper.Format("OnlineScriptSaveFailed", ex.Message));
+ }
+ }
+}
diff --git a/llcom.Avalonia/ViewModels/PlotViewModel.cs b/llcom.Avalonia/ViewModels/PlotViewModel.cs
new file mode 100644
index 0000000..b8316b4
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/PlotViewModel.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class PlotViewModel : ViewModelBase
+{
+ private const int MaxPoints = 1000;
+ private double[][] _data = new double[10][];
+ private double[] _dataX = new double[MaxPoints];
+ private int _styleIndex;
+ private bool _needsRender;
+
+ [ObservableProperty]
+ private string? _plotImageSource;
+
+ private CancellationTokenSource? _renderCts;
+
+ public PlotViewModel()
+ {
+ for (int i = 0; i < MaxPoints; i++)
+ _dataX[i] = i - MaxPoints + 1;
+ for (int i = 0; i < 10; i++)
+ _data[i] = new double[MaxPoints];
+
+ _renderCts = new CancellationTokenSource();
+ _ = RenderLoop(_renderCts.Token);
+ }
+
+ public void AddPoint(double value, int line)
+ {
+ if (line >= 10 || line < 0) return;
+ var arr = _data[line];
+ for (int i = 0; i < MaxPoints - 1; i++)
+ arr[i] = arr[i + 1];
+ arr[MaxPoints - 1] = value;
+ _needsRender = true;
+ }
+
+ [RelayCommand]
+ private void Fit()
+ {
+ double min = double.MaxValue, max = double.MinValue;
+ for (int i = 0; i < 10; i++)
+ {
+ for (int j = 0; j < MaxPoints; j++)
+ {
+ if (_data[i][j] < min) min = _data[i][j];
+ if (_data[i][j] > max) max = _data[i][j];
+ }
+ }
+ // In Avalonia, we can use ScottPlot.Avalonia's Plot control directly
+ _needsRender = true;
+ }
+
+ [RelayCommand]
+ private void Clear()
+ {
+ for (int i = 0; i < 10; i++)
+ for (int j = 0; j < MaxPoints; j++)
+ _data[i][j] = 0;
+ _needsRender = true;
+ }
+
+ [RelayCommand]
+ private void CycleTheme()
+ {
+ _styleIndex = (_styleIndex + 1) % 5;
+ _needsRender = true;
+ }
+
+ private async Task RenderLoop(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ if (_needsRender)
+ {
+ _needsRender = false;
+ // Plot rendering handled by ScottPlot control
+ }
+ await Task.Delay(100, ct);
+ }
+ }
+
+ public double[][] GetData() => _data;
+ public double[] GetDataX() => _dataX;
+ public bool NeedsRender() => _needsRender;
+ public void MarkRendered() => _needsRender = false;
+
+ public void Cleanup() => _renderCts?.Cancel();
+}
diff --git a/llcom.Avalonia/ViewModels/QuickSendViewModel.cs b/llcom.Avalonia/ViewModels/QuickSendViewModel.cs
new file mode 100644
index 0000000..528d8a3
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/QuickSendViewModel.cs
@@ -0,0 +1,328 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using llcom.Avalonia.Helpers;
+using llcom.Tools;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class QuickSendItem : ObservableObject
+{
+ [ObservableProperty] private int _id;
+ [ObservableProperty] private string _text = "";
+ [ObservableProperty] private bool _hex;
+ [ObservableProperty] private string _commit = LocaleHelper.Get("SendDataButton");
+ [ObservableProperty] private string _recvScriptPath = "";
+ [ObservableProperty] private string _recvScriptPara = "";
+ public ICommand? SendItemCommand { get; set; }
+}
+
+public partial class QuickSendViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private ObservableCollection _items = new();
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CurrentListNameDisplay))]
+ private int _currentListIndex;
+
+ [ObservableProperty]
+ private ObservableCollection _listNames = new(
+ Enumerable.Range(0, 10).Select(i => $"{LocaleHelper.Get("QuickSendListDefault")} {i}"));
+
+ public string CurrentListNameDisplay => $"{ListNames[CurrentListIndex]} ({CurrentListIndex})";
+
+ private static string ListFilePath(int index) =>
+ Path.Combine(PlatformHelper.ProfilePath, $"quicksend_{index}.json");
+
+ partial void OnCurrentListIndexChanged(int value)
+ {
+ SaveCurrentList();
+ LoadList(value);
+ }
+
+ public QuickSendViewModel()
+ {
+ try { System.IO.Directory.CreateDirectory(PlatformHelper.ProfilePath); } catch { }
+ for (int i = 0; i < 15; i++)
+ {
+ var item = new QuickSendItem { Id = i, SendItemCommand = SendItemCommand };
+ Items.Add(item);
+ }
+ try { LoadList(0); } catch { }
+ }
+
+ // ── Core commands ──────────────────────────────────────────────────
+
+ [RelayCommand]
+ private void SendItem(QuickSendItem? item)
+ {
+ if (item == null || string.IsNullOrEmpty(item.Text)) return;
+ try
+ {
+ var state = GlobalState.Instance;
+ byte[] rawData = item.Hex
+ ? ByteConvert.Hex2Byte(item.Text)
+ : state.GetEncoding().GetBytes(item.Text);
+
+ // Switch receive script if item has one configured
+ if (!string.IsNullOrEmpty(item.RecvScriptPath))
+ {
+ // Validate and sanitize recvScriptPath to prevent directory traversal
+ var safeName = item.RecvScriptPath.Replace("/", "").Replace("\\", "").Replace("..", "");
+ if (string.IsNullOrEmpty(safeName) || safeName != item.RecvScriptPath)
+ {
+ item.RecvScriptPath = "";
+ }
+ else
+ {
+ var recvPath = System.IO.Path.Combine(
+ PlatformHelper.ProfilePath,
+ "user_script_recv_convert",
+ safeName + ".lua");
+ if (System.IO.File.Exists(recvPath))
+ state.Settings.recvScript = safeName;
+ else
+ item.RecvScriptPath = "";
+ }
+ }
+
+ // Run through send script Lua pipeline
+ byte[] processedData;
+ try
+ {
+ processedData = LuaEnv.LuaLoader.Run(
+ $"{state.Settings.sendScript}.lua",
+ new System.Collections.ArrayList { "uartData", rawData });
+ if (processedData.Length == 0)
+ processedData = rawData;
+ }
+ catch
+ {
+ processedData = rawData;
+ }
+
+ // Append CRLF if configured
+ if (state.Settings.extraEnter)
+ {
+ var temp = processedData.ToList();
+ temp.Add(0x0d);
+ temp.Add(0x0a);
+ processedData = temp.ToArray();
+ }
+
+ UartManager.Instance.SendData(processedData, rawData);
+ }
+ catch (Exception) { /* handle in UI */ }
+ }
+
+ [RelayCommand]
+ private void SwitchList(string param)
+ {
+ if (int.TryParse(param, out var index) && index >= 0 && index < ListNames.Count)
+ CurrentListIndex = index;
+ }
+
+ [RelayCommand]
+ private void AddItem()
+ {
+ int newId = Items.Count > 0 ? Items.Max(x => x.Id) + 1 : 0;
+ Items.Add(new QuickSendItem { Id = newId, SendItemCommand = SendItemCommand });
+ }
+
+ [RelayCommand]
+ private void RemoveLastItem()
+ {
+ if (Items.Count > 0)
+ Items.RemoveAt(Items.Count - 1);
+ }
+
+ [RelayCommand]
+ private void ClearAll()
+ {
+ Items.Clear();
+ }
+
+ // ── Import / Export ────────────────────────────────────────────────
+
+ [RelayCommand]
+ private async Task ImportData()
+ {
+ try
+ {
+ var callback = PlatformHelper.OpenFilePickerCallback;
+ string? path;
+ if (callback != null)
+ {
+ path = await callback("LLCOM列表文件|*.lclst|所有文件|*.*");
+ }
+ else
+ {
+ path = ListFilePath(CurrentListIndex);
+ if (!File.Exists(path)) return;
+ }
+ if (string.IsNullOrEmpty(path)) return;
+
+ // Read with size limit to prevent DoS
+ var fileInfo = new FileInfo(path);
+ const long maxFileSize = 10 * 1024 * 1024; // 10MB limit
+ if (fileInfo.Exists && fileInfo.Length > maxFileSize)
+ {
+ PlatformHelper.ShowMessage(LocaleHelper.Format("QuickSendImportFailed", "文件过大"));
+ return;
+ }
+ var json = await File.ReadAllTextAsync(path);
+ var data = JsonSerializer.Deserialize>(json, new JsonSerializerOptions { MaxDepth = 32 });
+ if (data != null)
+ {
+ Items.Clear();
+ foreach (var d in data)
+ {
+ Items.Add(new QuickSendItem
+ {
+ Id = d.Id, Text = d.Text ?? "", Hex = d.Hex,
+ Commit = d.Commit ?? "发送",
+ RecvScriptPath = d.RecvScriptPath ?? "",
+ RecvScriptPara = d.RecvScriptPara ?? "",
+ SendItemCommand = SendItemCommand
+ });
+ }
+ SaveCurrentList();
+ PlatformHelper.ShowMessage(LocaleHelper.Get("QuickSendImportSuccess"));
+ }
+ }
+ catch (Exception ex) { PlatformHelper.ShowMessage(LocaleHelper.Format("QuickSendImportFailed", ex.Message)); }
+ }
+
+ [RelayCommand]
+ private async Task ExportData()
+ {
+ try
+ {
+ var data = Items.Select(item => new QuickSendItemData
+ {
+ Id = item.Id, Text = item.Text, Hex = item.Hex, Commit = item.Commit,
+ RecvScriptPath = item.RecvScriptPath, RecvScriptPara = item.RecvScriptPara
+ }).ToList();
+
+ var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
+
+ var callback = PlatformHelper.SaveFilePickerCallback;
+ if (callback != null)
+ {
+ var path = await callback("LLCOM列表文件|*.lclst|所有文件|*.*", $"quicksend_{CurrentListIndex}.lclst");
+ if (!string.IsNullOrEmpty(path))
+ {
+ await File.WriteAllTextAsync(path, json);
+ PlatformHelper.ShowMessage(LocaleHelper.Get("QuickSendExportSuccess"));
+ }
+ }
+ else
+ {
+ await File.WriteAllTextAsync(ListFilePath(CurrentListIndex), json);
+ PlatformHelper.ShowMessage(LocaleHelper.Get("QuickSendSavedToProfile"));
+ }
+ }
+ catch (Exception ex) { PlatformHelper.ShowMessage(LocaleHelper.Format("QuickSendExportFailed", ex.Message)); }
+ }
+
+ [RelayCommand]
+ private async Task ImportSSCOM()
+ {
+ try
+ {
+ var callback = PlatformHelper.OpenFilePickerCallback;
+ string? path;
+ if (callback != null)
+ path = await callback("SSCOM配置文件|sscom51.ini;sscom.ini|所有文件|*.*");
+ else
+ {
+ PlatformHelper.ShowMessage(LocaleHelper.Get("QuickSendNeedFilePicker"));
+ return;
+ }
+ if (string.IsNullOrEmpty(path)) return;
+
+ var lines = await File.ReadAllLinesAsync(path);
+ Items.Clear();
+ int id = 0;
+ foreach (var line in lines)
+ {
+ var parts = line.Split('=', 2);
+ if (parts.Length < 2) continue;
+ var key = parts[0].Trim();
+ var value = parts[1].Trim();
+ if (key.StartsWith("S") && int.TryParse(key[1..], out _) && !string.IsNullOrEmpty(value))
+ {
+ Items.Add(new QuickSendItem
+ {
+ Id = id++, Text = value, Hex = false,
+ Commit = "发送", SendItemCommand = SendItemCommand
+ });
+ }
+ }
+ SaveCurrentList();
+ PlatformHelper.ShowMessage(LocaleHelper.Format("QuickSendImportSSCOMSuccess", Items.Count));
+ }
+ catch (Exception ex) { PlatformHelper.ShowMessage(LocaleHelper.Format("QuickSendImportSSCOMFailed", ex.Message)); }
+ }
+
+ // ── Persistence ────────────────────────────────────────────────────
+
+ private void SaveCurrentList()
+ {
+ try
+ {
+ var data = Items.Select(item => new QuickSendItemData
+ {
+ Id = item.Id, Text = item.Text, Hex = item.Hex, Commit = item.Commit,
+ RecvScriptPath = item.RecvScriptPath, RecvScriptPara = item.RecvScriptPara
+ }).ToList();
+ var json = JsonSerializer.Serialize(data);
+ File.WriteAllText(ListFilePath(CurrentListIndex), json);
+ }
+ catch { /* ignore persistence errors */ }
+ }
+
+ private void LoadList(int index)
+ {
+ try
+ {
+ var path = ListFilePath(index);
+ if (!File.Exists(path)) return;
+ var json = File.ReadAllText(path);
+ var data = JsonSerializer.Deserialize>(json);
+ if (data == null) return;
+
+ Items.Clear();
+ foreach (var d in data)
+ {
+ Items.Add(new QuickSendItem
+ {
+ Id = d.Id, Text = d.Text ?? "", Hex = d.Hex,
+ Commit = d.Commit ?? "发送",
+ RecvScriptPath = d.RecvScriptPath ?? "",
+ RecvScriptPara = d.RecvScriptPara ?? "",
+ SendItemCommand = SendItemCommand
+ });
+ }
+ }
+ catch { /* ignore load errors, use defaults */ }
+ }
+
+ private class QuickSendItemData
+ {
+ public int Id { get; set; }
+ public string Text { get; set; } = "";
+ public bool Hex { get; set; }
+ public string Commit { get; set; } = "发送";
+ public string RecvScriptPath { get; set; } = "";
+ public string RecvScriptPara { get; set; } = "";
+ }
+}
diff --git a/llcom.Avalonia/ViewModels/SerialMonitorViewModel.cs b/llcom.Avalonia/ViewModels/SerialMonitorViewModel.cs
new file mode 100644
index 0000000..456e936
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/SerialMonitorViewModel.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.IO.Ports;
+using System.Linq;
+using System.Runtime.InteropServices;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using llcom.Avalonia.Helpers;
+using llcom.Tools;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class SerialMonitorViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private ObservableCollection _processList = new();
+
+ [ObservableProperty]
+ private string? _selectedProcess;
+
+ [ObservableProperty]
+ private ObservableCollection _comPortList = new();
+
+ [ObservableProperty]
+ private string? _selectedComPort;
+
+ [ObservableProperty]
+ private bool _isMonitoring;
+
+ [ObservableProperty]
+ private string _monitorButtonText = LocaleHelper.Get("SerialMonitorStart");
+
+ [ObservableProperty]
+ private string _statusText = LocaleHelper.Get("StatusReady");
+
+ [ObservableProperty]
+ private string _receivedData = "";
+
+ [ObservableProperty]
+ private bool _isAvailable;
+
+ [ObservableProperty]
+ private string _availabilityMessage = "";
+
+ public SerialMonitorViewModel()
+ {
+ try
+ {
+ IsAvailable = NativeInterop.IsAvailable;
+ AvailabilityMessage = NativeInterop.AvailabilityMessage;
+ if (IsAvailable)
+ {
+ Refresh();
+ }
+ else
+ {
+ StatusText = AvailabilityMessage;
+ }
+ }
+ catch (Exception ex)
+ {
+ IsAvailable = false;
+ AvailabilityMessage = $"初始化失败: {ex.Message}";
+ StatusText = AvailabilityMessage;
+ }
+ }
+
+ [RelayCommand]
+ private void Refresh()
+ {
+ // Refresh process list
+ string lastProc = SelectedProcess ?? "";
+ ProcessList.Clear();
+ var procs = new List();
+ try
+ {
+ foreach (var p in Process.GetProcesses())
+ {
+ try { procs.Add($"{p.ProcessName}[{p.Id}]"); }
+ catch { }
+ }
+ }
+ catch { }
+ procs.Sort();
+ foreach (var p in procs) ProcessList.Add(p);
+
+ if (ProcessList.Count > 0)
+ {
+ SelectedProcess = !string.IsNullOrWhiteSpace(lastProc) && procs.Contains(lastProc)
+ ? lastProc : ProcessList[0];
+ }
+
+ // Refresh COM port list (Windows: only COMx; Linux: all)
+ string lastCom = SelectedComPort ?? "";
+ ComPortList.Clear();
+ try
+ {
+ foreach (var p in SerialPort.GetPortNames())
+ {
+ ComPortList.Add(p);
+ }
+ }
+ catch { }
+
+ if (ComPortList.Count > 0)
+ {
+ SelectedComPort = !string.IsNullOrWhiteSpace(lastCom) && ComPortList.Contains(lastCom)
+ ? lastCom : ComPortList[0];
+ }
+ }
+
+ [RelayCommand]
+ private void ToggleMonitor()
+ {
+ if (!IsAvailable)
+ {
+ StatusText = AvailabilityMessage;
+ return;
+ }
+
+ if (IsMonitoring)
+ {
+ // Stop monitoring
+ NativeInterop.UnMonitorComm();
+ IsMonitoring = false;
+ MonitorButtonText = LocaleHelper.Get("SerialMonitorStart");
+ StatusText = LocaleHelper.Get("SerialMonitorStopped");
+ }
+ else
+ {
+ // Start monitoring
+ if (SelectedProcess == null || SelectedComPort == null)
+ {
+ StatusText = LocaleHelper.Get("SerialMonitorSelectBoth");
+ return;
+ }
+
+ // Parse PID from format "name[pid]"
+ var start = SelectedProcess.IndexOf('[');
+ var end = SelectedProcess.LastIndexOf(']');
+ if (start < 0 || end <= start + 1 ||
+ !uint.TryParse(SelectedProcess.AsSpan(start + 1, end - start - 1), out var pid))
+ {
+ StatusText = LocaleHelper.Get("SerialMonitorInvalidPid");
+ return;
+ }
+
+ // Parse COM index
+ uint comIndex = 1;
+ try
+ {
+ var digits = new string(SelectedComPort.Where(char.IsDigit).ToArray());
+ if (digits.Length > 0) comIndex = uint.Parse(digits);
+ }
+ catch { }
+
+ NativeInterop.MonitorCallback callback = (IntPtr param) =>
+ {
+ var d = Marshal.PtrToStructure(param);
+ byte[] b = new byte[d.DataSize];
+ for (int i = 0; i < d.DataSize; i++) b[i] = d.Data[i];
+
+ string prefix = d.CommState switch
+ {
+ (byte)NativeInterop.CommState.Send => "→",
+ (byte)NativeInterop.CommState.Receive => "←",
+ (byte)NativeInterop.CommState.Disconnect => "❌",
+ _ => "?"
+ };
+ AppendReceived($"monitor COM{d.ComPort} {prefix}: {BitConverter.ToString(b)}\n");
+ return 1;
+ };
+
+ try
+ {
+ IsMonitoring = NativeInterop.MonitorComm(pid, comIndex, callback);
+ if (IsMonitoring)
+ {
+ MonitorButtonText = LocaleHelper.Get("SerialMonitorStop");
+ StatusText = LocaleHelper.Get("SerialMonitorMonitoring");
+ }
+ else
+ {
+ StatusText = LocaleHelper.Get("SerialMonitorStartFailed");
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusText = LocaleHelper.Format("SerialMonitorStartFailed", ex.Message);
+ }
+ }
+ }
+
+ private int _maxLen = 20000;
+ private void AppendReceived(string text)
+ {
+ ReceivedData = (ReceivedData + text)[..Math.Min(ReceivedData.Length + text.Length, _maxLen)];
+ }
+
+ [RelayCommand]
+ private void ClearData()
+ {
+ ReceivedData = "";
+ }
+
+ public void Cleanup()
+ {
+ if (IsMonitoring)
+ {
+ try { NativeInterop.UnMonitorComm(); } catch { }
+ }
+ }
+}
diff --git a/llcom.Avalonia/ViewModels/SettingWindowViewModel.cs b/llcom.Avalonia/ViewModels/SettingWindowViewModel.cs
new file mode 100644
index 0000000..1730aec
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/SettingWindowViewModel.cs
@@ -0,0 +1,167 @@
+using System;
+using System.Collections.ObjectModel;
+using System.IO;
+using AvaloniaEdit.Document;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using llcom.Tools;
+using Newtonsoft.Json;
+
+namespace llcom.Avalonia.ViewModels;
+
+public class DisplayFormatItem
+{
+ public string Name { get; set; } = "";
+ public int Value { get; set; }
+}
+
+public partial class SettingWindowViewModel : ViewModelBase
+{
+ // ── Basic serial settings ────────────────────────────────────────
+ [ObservableProperty] private bool _autoReconnect = true;
+ [ObservableProperty] private int _displayFormat; // 0=both, 1=text, 2=hex
+ [ObservableProperty] private bool _showSendData = true;
+ [ObservableProperty] private bool _showSendRaw;
+ [ObservableProperty] private bool _keepTop;
+ [ObservableProperty] private bool _terminalMode;
+ [ObservableProperty] private int _packetTimeout = 10; // ms
+ [ObservableProperty] private bool _timeoutFromBlank = true;
+ [ObservableProperty] private int _maxPacketSize = 1024;
+ [ObservableProperty] private int _maxShowLen = 4096;
+ [ObservableProperty] private int _autoClearPacks = 500;
+ [ObservableProperty] private bool _lagAutoClear = true;
+
+ [ObservableProperty] private string _selectedDataBits = "8";
+ [ObservableProperty] private string _selectedStopBits = "1";
+ [ObservableProperty] private string _selectedParity = "None";
+ [ObservableProperty] private string _selectedEncoding = "UTF8";
+
+ public ObservableCollection DataBitsList { get; } = new() { "5", "6", "7", "8" };
+ public ObservableCollection StopBitsList { get; } = new() { "1", "1.5", "2" };
+ public ObservableCollection ParityList { get; } = new() { "None", "Odd", "Even", "Mark", "Space" };
+ public ObservableCollection EncodingList { get; } = new()
+ {
+ "UTF8", "ASCII", "GB2312", "BIG5", "Shift_JIS", "EUC-KR", "ISO-8859-1", "Windows-1252"
+ };
+
+ // ── Script editors ──────────────────────────────────────────────
+ [ObservableProperty] private TextDocument? _sendScriptDocument = new();
+ [ObservableProperty] private TextDocument? _recvScriptDocument = new();
+
+ // Script test values
+ [ObservableProperty] private string _sendTestInput = "";
+ [ObservableProperty] private string _sendTestResult = "";
+ [ObservableProperty] private string _recvTestInput = "";
+ [ObservableProperty] private string _recvTestResult = "";
+ [ObservableProperty] private bool _sendTestHex;
+ [ObservableProperty] private bool _recvTestHex;
+ [ObservableProperty] private string _sendTestPara = "";
+ [ObservableProperty] private string _recvTestPara = "0";
+
+ public SettingWindowViewModel()
+ {
+ LoadSettings();
+ }
+
+ [RelayCommand]
+ private void SaveSettings()
+ {
+ try
+ {
+ var state = GlobalState.Instance;
+ var s = state.Settings;
+ var baseDir = PlatformHelper.ProfilePath;
+
+ // Mirror to GlobalState.Settings for immediate use
+ s.autoReconnect = AutoReconnect;
+ s.showHexFormat = DisplayFormat;
+ s.showSend = ShowSendData;
+ s.showSendRaw = ShowSendRaw;
+ s.topmost = KeepTop;
+ s.terminal = TerminalMode;
+
+ // Persist settings.json
+ var json = JsonConvert.SerializeObject(s, Formatting.Indented);
+ File.WriteAllText(Path.Combine(baseDir, "settings.json"), json);
+
+ // Save scripts
+ if (SendScriptDocument != null)
+ File.WriteAllText(Path.Combine(baseDir, "send_script.lua"), SendScriptDocument.Text);
+ if (RecvScriptDocument != null)
+ File.WriteAllText(Path.Combine(baseDir, "recv_script.lua"), RecvScriptDocument.Text);
+
+ PlatformHelper.ShowMessage("设置已保存");
+ }
+ catch (Exception ex) { PlatformHelper.ShowMessage($"保存设置失败: {ex.Message}"); }
+ }
+
+ private void LoadSettings()
+ {
+ try
+ {
+ var state = GlobalState.Instance;
+ var s = state.Settings;
+ var baseDir = PlatformHelper.ProfilePath;
+
+ // Load from GlobalState.Settings
+ AutoReconnect = s.autoReconnect;
+ DisplayFormat = s.showHexFormat;
+ ShowSendData = s.showSend;
+ ShowSendRaw = s.showSendRaw;
+ KeepTop = s.topmost;
+ TerminalMode = s.terminal;
+
+ // Load scripts
+ var sendPath = Path.Combine(baseDir, "send_script.lua");
+ if (File.Exists(sendPath))
+ SendScriptDocument = new TextDocument(File.ReadAllText(sendPath));
+ var recvPath = Path.Combine(baseDir, "recv_script.lua");
+ if (File.Exists(recvPath))
+ RecvScriptDocument = new TextDocument(File.ReadAllText(recvPath));
+ }
+ catch (Exception) { /* ignore */ }
+ }
+
+ [RelayCommand]
+ private void TestSendScript()
+ {
+ try
+ {
+ var raw = SendTestInput ?? "";
+ byte[] data = SendTestHex
+ ? ByteConvert.Hex2Byte(raw)
+ : System.Text.Encoding.UTF8.GetBytes(raw);
+ // pass to Lua engine (placeholder: return hex representation)
+ SendTestResult = BitConverter.ToString(data).Replace("-", " ");
+ }
+ catch (Exception ex)
+ {
+ SendTestResult = $"Error: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private void TestRecvScript()
+ {
+ try
+ {
+ var raw = RecvTestInput ?? "";
+ byte[] data = RecvTestHex
+ ? ByteConvert.Hex2Byte(raw)
+ : System.Text.Encoding.UTF8.GetBytes(raw);
+ RecvTestResult = BitConverter.ToString(data).Replace("-", " ");
+ }
+ catch (Exception ex)
+ {
+ RecvTestResult = $"Error: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private void OpenLogFolder()
+ {
+ var dir = Path.Combine(PlatformHelper.ProfilePath, "log");
+ Directory.CreateDirectory(dir);
+ PlatformHelper.OpenUrl(dir);
+ }
+}
diff --git a/llcom.Avalonia/ViewModels/SocketClientViewModel.cs b/llcom.Avalonia/ViewModels/SocketClientViewModel.cs
new file mode 100644
index 0000000..bf96b66
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/SocketClientViewModel.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using llcom.Tools;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class SocketClientViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string _server = "";
+ [ObservableProperty]
+ private int _port = 8888;
+ [ObservableProperty]
+ private int _selectedProtocolType;
+ [ObservableProperty]
+ private bool _isConnected;
+ [ObservableProperty]
+ private bool _isConnecting;
+ [ObservableProperty]
+ private string _sendText = "";
+ [ObservableProperty]
+ private bool _hexMode;
+ [ObservableProperty]
+ private string _receiveText = "";
+ [ObservableProperty]
+ private bool _autoReconnect;
+ [ObservableProperty]
+ private int _reconnectInterval = 3;
+
+ public ObservableCollection ProtocolTypes { get; } = new() { "TCP", "UDP", "TCP SSL" };
+
+ private TcpClient? _tcpClient;
+ private UdpClient? _udpClient;
+ private CancellationTokenSource? _receiveCts;
+
+ [RelayCommand]
+ private async Task ToggleConnect()
+ {
+ if (IsConnected)
+ {
+ Disconnect();
+ return;
+ }
+
+ IsConnecting = true;
+ try
+ {
+ if (SelectedProtocolType == 1) // UDP
+ {
+ _udpClient = new UdpClient();
+ _udpClient.Connect(Server, Port);
+ IsConnected = true;
+ _receiveCts = new CancellationTokenSource();
+ _ = UdpReceiveLoop(_receiveCts.Token);
+ }
+ else
+ {
+ _tcpClient = new TcpClient();
+ await _tcpClient.ConnectAsync(Server, Port);
+ IsConnected = true;
+ _receiveCts = new CancellationTokenSource();
+ _ = TcpReceiveLoop(_receiveCts.Token);
+ }
+ }
+ catch (Exception ex)
+ {
+ AppendReceive($"Connect error: {ex.Message}");
+ }
+ finally
+ {
+ IsConnecting = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task Send()
+ {
+ if (!IsConnected || string.IsNullOrEmpty(SendText)) return;
+ try
+ {
+ byte[] data = HexMode ? HexToBytes(SendText) : Encoding.UTF8.GetBytes(SendText);
+ if (_tcpClient?.Connected == true)
+ await _tcpClient.GetStream().WriteAsync(data);
+ else if (_udpClient != null)
+ await _udpClient.SendAsync(data);
+ AppendReceive($"← Sent: {SendText}");
+ }
+ catch (Exception ex)
+ {
+ AppendReceive($"Send error: {ex.Message}");
+ }
+ }
+
+ private async Task TcpReceiveLoop(CancellationToken ct)
+ {
+ var buffer = new byte[4096];
+ try
+ {
+ while (!ct.IsCancellationRequested && _tcpClient?.Connected == true)
+ {
+ var count = await _tcpClient.GetStream().ReadAsync(buffer, ct);
+ if (count > 0)
+ {
+ var data = new byte[count];
+ Array.Copy(buffer, data, count);
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ AppendReceive($"→ {Encoding.UTF8.GetString(data)}"));
+ }
+ else // count == 0 means graceful close
+ {
+ IsConnected = false;
+ break;
+ }
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch { IsConnected = false; }
+ }
+
+ private async Task UdpReceiveLoop(CancellationToken ct)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested && _udpClient != null)
+ {
+ var result = await _udpClient.ReceiveAsync(ct);
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ AppendReceive($"→ {Encoding.UTF8.GetString(result.Buffer)}"));
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch { IsConnected = false; }
+ }
+
+ private void Disconnect()
+ {
+ _receiveCts?.Cancel();
+ _tcpClient?.Close();
+ _udpClient?.Close();
+ _tcpClient = null;
+ _udpClient = null;
+ IsConnected = false;
+ }
+
+ private void AppendReceive(string msg) => ReceiveText += $"[{DateTime.Now:HH:mm:ss}] {msg}\n";
+
+ // Use shared ByteConvert.Hex2Byte with size limit
+ private static byte[] HexToBytes(string hex) => ByteConvert.Hex2Byte(hex);
+
+ public void Cleanup() => Disconnect();
+}
diff --git a/llcom.Avalonia/ViewModels/TcpTestViewModel.cs b/llcom.Avalonia/ViewModels/TcpTestViewModel.cs
new file mode 100644
index 0000000..19521af
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/TcpTestViewModel.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace llcom.Avalonia.ViewModels;
+
+public partial class TcpTestViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private bool _isConnected;
+ [ObservableProperty]
+ private bool _isConnecting;
+ [ObservableProperty]
+ private string _address = "loading...";
+ [ObservableProperty]
+ private string _addressV6 = "loading...";
+ [ObservableProperty]
+ private string _sendText = "";
+ [ObservableProperty]
+ private bool _hexMode;
+ [ObservableProperty]
+ private string _selectedClient = "";
+ [ObservableProperty]
+ private string _log = "";
+
+ public ObservableCollection Clients { get; } = new();
+
+ private ClientWebSocket? _ws;
+ private ClientWebSocket? _wsV6;
+ private CancellationTokenSource? _cts;
+
+ private string ConnectionType = "tcp";
+
+ [RelayCommand]
+ private async Task CreateTcp() => await ConnectWebSocket("tcp");
+
+ [RelayCommand]
+ private async Task CreateTcpSsl() => await ConnectWebSocket("ssl", "ssl-tcp");
+
+ [RelayCommand]
+ private async Task CreateUdp() => await ConnectWebSocket("udp");
+
+ [RelayCommand]
+ private async Task CreateTcpIpv6() => await ConnectWebSocket("tcpv6");
+
+ [RelayCommand]
+ private async Task Disconnect()
+ {
+ try
+ {
+ _cts?.Cancel();
+ if (_ws?.State == WebSocketState.Open) await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
+ if (_wsV6?.State == WebSocketState.Open) await _wsV6.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
+ IsConnected = false;
+ Address = "loading...";
+ AddressV6 = "loading...";
+ AppendLog("Server closed.");
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"Disconnect error: {ex.Message}");
+ }
+ }
+
+ [RelayCommand]
+ private async Task Send()
+ {
+ if (!IsConnected || string.IsNullOrEmpty(SendText) || string.IsNullOrEmpty(SelectedClient)) return;
+ try
+ {
+ var ws = (_ws?.State == WebSocketState.Open) ? _ws : _wsV6;
+ var msg = JsonSerializer.Serialize(new
+ {
+ action = "sendc",
+ data = SendText,
+ hex = HexMode,
+ client = SelectedClient
+ });
+ if (ws != null)
+ await ws.SendAsync(Encoding.UTF8.GetBytes(msg), WebSocketMessageType.Text, true, CancellationToken.None);
+ AppendLog($"← send to [{SelectedClient}]: {SendText}");
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"Send error: {ex.Message}");
+ }
+ }
+
+ [RelayCommand]
+ private async Task KickClient()
+ {
+ if (!IsConnected || string.IsNullOrEmpty(SelectedClient)) return;
+ try
+ {
+ var ws = (_ws?.State == WebSocketState.Open) ? _ws : _wsV6;
+ var msg = JsonSerializer.Serialize(new { action = "closec", client = SelectedClient });
+ if (ws != null)
+ await ws.SendAsync(Encoding.UTF8.GetBytes(msg), WebSocketMessageType.Text, true, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"Kick error: {ex.Message}");
+ }
+ }
+
+ private async Task ConnectWebSocket(string ctype, string? stype = null)
+ {
+ if (IsConnecting) return;
+ IsConnecting = true;
+ ConnectionType = ctype switch
+ {
+ "tcpv6" => "tcp",
+ "ssl" => "ssl",
+ _ => ctype
+ };
+ AppendLog("Server is creating...");
+
+ var isV6 = ctype == "tcpv6";
+ var uri = isV6 ? "wss://netlab.luatos.org/ws/netlab" : "wss://gps.openluat.com/netlab/ws/netlab";
+ var ws = new ClientWebSocket();
+ if (isV6) _wsV6 = ws; else _ws = ws;
+
+ try
+ {
+ _cts = new CancellationTokenSource();
+ await ws.ConnectAsync(new Uri(uri), _cts.Token);
+ IsConnected = true;
+ var newAction = JsonSerializer.Serialize(new { action = "newp", type = stype ?? (ctype == "tcpv6" ? "tcp" : ctype) });
+ await ws.SendAsync(Encoding.UTF8.GetBytes(newAction), WebSocketMessageType.Text, true, _cts.Token);
+ _ = ReceiveLoop(ws, isV6, _cts.Token);
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"Create failed: {ex.Message}");
+ }
+ finally
+ {
+ IsConnecting = false;
+ }
+ }
+
+ private async Task ReceiveLoop(ClientWebSocket ws, bool isV6, CancellationToken ct)
+ {
+ var buffer = new byte[8192];
+ try
+ {
+ while (!ct.IsCancellationRequested && ws.State == WebSocketState.Open)
+ {
+ var result = await ws.ReceiveAsync(buffer, ct);
+ if (result.MessageType == WebSocketMessageType.Close) break;
+
+ var text = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ try
+ {
+ var obj = JsonNode.Parse(text);
+ var action = obj?["action"]?.ToString();
+ switch (action)
+ {
+ case "port":
+ var port = obj!["port"]!.ToString();
+ if (isV6)
+ {
+ Address = $"tcp://152.70.80.204:{port}";
+ AddressV6 = $"tcp://[2603:c023:1:5fcc:c028:8ed:49a7:6e08]:{port}";
+ }
+ else
+ Address = $"{ConnectionType}://115.120.239.161:{port}";
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() => { Address = Address; AddressV6 = AddressV6; });
+ AppendLog($"Created a {ConnectionType} server.");
+ break;
+ case "client":
+ case "connected":
+ var clientAddr = $"[{obj!["client"]}]{obj["addr"]}";
+ AppendLog($"✔ {clientAddr} connected.");
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ Clients.Add(obj["client"]!.ToString());
+ if (string.IsNullOrEmpty(SelectedClient) && Clients.Count > 0)
+ SelectedClient = Clients[0];
+ });
+ break;
+ case "closed":
+ AppendLog($"❌ [{obj!["client"]}] disconnected.");
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ Clients.Remove(obj["client"]!.ToString());
+ });
+ break;
+ case "data":
+ AppendLog($" → receive from [{obj!["client"]}]: {obj["data"]}");
+ break;
+ case "error":
+ AppendLog($"❔ error: {obj!["msg"]}");
+ break;
+ }
+ }
+ catch { }
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch { IsConnected = false; }
+ }
+
+ private void AppendLog(string msg) => Log += $"[{DateTime.Now:HH:mm:ss}] {msg}\n";
+
+ public void Cleanup() => _cts?.Cancel();
+}
diff --git a/llcom.Avalonia/ViewModels/ViewModelBase.cs b/llcom.Avalonia/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..3b63b56
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/ViewModelBase.cs
@@ -0,0 +1,7 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace llcom.Avalonia.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject
+{
+}
diff --git a/llcom.Avalonia/ViewModels/WinUsbViewModel.cs b/llcom.Avalonia/ViewModels/WinUsbViewModel.cs
new file mode 100644
index 0000000..f350081
--- /dev/null
+++ b/llcom.Avalonia/ViewModels/WinUsbViewModel.cs
@@ -0,0 +1,342 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LibUsbDotNet;
+using LibUsbDotNet.Main;
+using llcom.LuaEnv;
+using llcom.Tools;
+using llcom.Avalonia.Helpers;
+
+namespace llcom.Avalonia.ViewModels;
+
+///
+/// WinUSB device communication page (cross-platform).
+/// Uses LibUsbDotNet for USB device enumeration and IO.
+/// NOTE: Platform-specific policies may require udev rules on Linux.
+///
+public partial class WinUsbViewModel : ViewModelBase
+{
+ // ── Device info model ───────────────────────────────────────────────
+
+ public class DeviceInfo
+ {
+ public string Name { get; set; } = "";
+ public int Pid { get; set; }
+ public int Vid { get; set; }
+ public string SerialNumber { get; set; } = "";
+ public ReadEndpointID ReadEp { get; set; } = ReadEndpointID.Ep01;
+ public WriteEndpointID WriteEp { get; set; } = WriteEndpointID.Ep01;
+ public bool IsVendorSpec { get; set; }
+
+ public override string ToString()
+ {
+ var vendor = IsVendorSpec ? "[WinUSB]" : "[General]";
+ return string.IsNullOrEmpty(Name)
+ ? $"{vendor} VID:0x{Vid:X04} PID:0x{Pid:X04} {SerialNumber}"
+ : $"{vendor} {Name} VID:0x{Vid:X04} PID:0x{Pid:X04}";
+ }
+ }
+
+ // ── Observable properties ──────────────────────────────────────────
+
+ [ObservableProperty]
+ private ObservableCollection _deviceList = new();
+
+ [ObservableProperty]
+ private DeviceInfo? _selectedDevice;
+
+ [ObservableProperty]
+ private string _inEpText = "0x81";
+
+ [ObservableProperty]
+ private string _outEpText = "0x01";
+
+ [ObservableProperty]
+ private bool _isConnected;
+
+ [ObservableProperty]
+ private string _connectText = Helpers.LocaleHelper.Get("WinUsbConnect");
+
+ [ObservableProperty]
+ private string _sendData = "";
+
+ [ObservableProperty]
+ private bool _hexSend;
+
+ [ObservableProperty]
+ private string _receivedData = "";
+
+ [ObservableProperty]
+ private string _statusText = Helpers.LocaleHelper.Get("StatusReady");
+
+ [ObservableProperty]
+ private bool _isBusy;
+
+ // ── Internal state ──────────────────────────────────────────────────
+
+ private UsbDevice? _device;
+ private UsbEndpointReader? _reader;
+ private UsbEndpointWriter? _writer;
+ private Thread? _readThread;
+ private volatile bool _needClose;
+ private readonly Queue _sendBuffer = new();
+ private readonly object _sendLock = new();
+
+ public WinUsbViewModel()
+ {
+ LuaApis.SendChannelsRegister("winusb", (data, _) =>
+ {
+ if (!IsConnected) return false;
+ lock (_sendLock) _sendBuffer.Enqueue(data);
+ return true;
+ });
+ }
+
+ // ── Commands ────────────────────────────────────────────────────────
+
+ [RelayCommand]
+ private async Task RefreshDevices()
+ {
+ IsBusy = true;
+ DeviceList.Clear();
+ try
+ {
+ var list = await Task.Run(() => GetUsbList());
+ foreach (var dev in list)
+ DeviceList.Add(dev);
+
+ StatusText = DeviceList.Count == 0
+ ? LocaleHelper.Get("WinUsbNoDevice")
+ : LocaleHelper.Format("WinUsbFoundDevices", DeviceList.Count);
+ }
+ catch (Exception ex)
+ {
+ StatusText = LocaleHelper.Format("WinUsbEnumFailed", ex.Message);
+ }
+ finally { IsBusy = false; }
+ }
+
+ private static List GetUsbList()
+ {
+ var list = new List();
+ try
+ {
+ var allDevices = UsbDevice.AllDevices;
+ if (allDevices == null) return list;
+
+ foreach (UsbRegistry reg in allDevices)
+ {
+ try
+ {
+ var info = new DeviceInfo
+ {
+ Vid = reg.Vid,
+ Pid = reg.Pid,
+ Name = reg.Name ?? reg.FullName ?? "USB Device",
+ SerialNumber = "",
+ };
+ list.Add(info);
+ }
+ catch { /* skip problematic devices */ }
+ }
+ }
+ catch { /* enumeration failed */ }
+ return list;
+ }
+
+ [RelayCommand]
+ private void ToggleConnect()
+ {
+ if (IsConnected)
+ {
+ Disconnect();
+ }
+ else
+ {
+ Connect();
+ }
+ }
+
+ private void Connect()
+ {
+ if (SelectedDevice == null)
+ {
+ StatusText = LocaleHelper.Get("WinUsbSelectDevice");
+ return;
+ }
+
+ var target = SelectedDevice;
+ try
+ {
+ // Parse endpoint addresses from hex input
+ byte readEpAddr;
+ byte writeEpAddr;
+ try
+ {
+ readEpAddr = Convert.ToByte(InEpText.Replace("0x", "").Replace("0X", ""), 16);
+ writeEpAddr = Convert.ToByte(OutEpText.Replace("0x", "").Replace("0X", ""), 16);
+ }
+ catch
+ {
+ StatusText = LocaleHelper.Get("WinUsbInvalidEndpoint");
+ return;
+ }
+
+ var readEpId = (ReadEndpointID)readEpAddr;
+ var writeEpId = (WriteEndpointID)writeEpAddr;
+
+ // Find and open the device
+ UsbDeviceFinder finder = new UsbDeviceFinder(target.Vid, target.Pid);
+ _device = UsbDevice.OpenUsbDevice(finder);
+
+ if (_device == null)
+ {
+ StatusText = LocaleHelper.Get("WinUsbDeviceNotFound");
+ return;
+ }
+
+ // If it's a whole USB device, configure it
+ if (_device is IUsbDevice wholeDevice)
+ {
+ wholeDevice.SetConfiguration(1);
+ wholeDevice.ClaimInterface(0);
+ }
+
+ _reader = _device.OpenEndpointReader(readEpId, 1024, EndpointType.Bulk);
+ _writer = _device.OpenEndpointWriter(writeEpId, EndpointType.Bulk);
+
+ if (_reader == null || _writer == null)
+ {
+ _device.Close();
+ _device = null;
+ StatusText = LocaleHelper.Get("WinUsbEndpointFailed");
+ return;
+ }
+
+ _needClose = false;
+ IsConnected = true;
+ ConnectText = LocaleHelper.Get("WinUsbDisconnect");
+ StatusText = LocaleHelper.Get("WinUsbConnected");
+
+ StartReadThread();
+ }
+ catch (Exception ex)
+ {
+ try { _device?.Close(); } catch { }
+ _device = null;
+ StatusText = LocaleHelper.Format("WinUsbOpenFailed", ex.Message);
+ }
+ }
+
+ private void StartReadThread()
+ {
+ _readThread = new Thread(() =>
+ {
+ int timeout = 50;
+ var temp = new byte[1024];
+ int readLen;
+ while (!_needClose)
+ {
+ try
+ {
+ if (_reader == null) break;
+ var err = _reader.Read(temp, timeout, out readLen);
+ if (err == ErrorCode.None || err == ErrorCode.IoTimedOut)
+ {
+ if (readLen > 0)
+ {
+ var data = temp.Take(readLen).ToArray();
+ AppendReceived($"<< Recv {readLen}B: {BitConverter.ToString(data)}\n");
+ LuaApis.SendChannelsReceived("winusb", data);
+ }
+ }
+ else if (err != ErrorCode.None && err != ErrorCode.IoTimedOut)
+ {
+ DisconnectInternal();
+ return;
+ }
+ // Send buffered data
+ lock (_sendLock)
+ {
+ while (_sendBuffer.Count > 0)
+ {
+ var sdata = _sendBuffer.Dequeue();
+ try
+ {
+ if (_writer == null) break;
+ var sr = _writer.Write(sdata, 1000, out int realSent);
+ if (sr != ErrorCode.None)
+ AppendReceived($"Send err: {sr}\n");
+ if (realSent > 0)
+ AppendReceived($">> Sent {realSent}B\n");
+ }
+ catch (Exception sex) { AppendReceived($"Send err: {sex.Message}\n"); }
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ AppendReceived($"IO err: {e.Message}\n");
+ DisconnectInternal();
+ break;
+ }
+ }
+ }) { IsBackground = true, Name = "WinUSB-IO" };
+ _readThread.Start();
+ }
+
+ private void Disconnect()
+ {
+ _needClose = true;
+ DisconnectInternal();
+ }
+
+ private void DisconnectInternal()
+ {
+ _needClose = true;
+ try { _reader?.Dispose(); } catch { }
+ try { _writer?.Dispose(); } catch { }
+ try { _device?.Close(); } catch { }
+ _reader = null;
+ _writer = null;
+ _device = null;
+ IsConnected = false;
+ ConnectText = LocaleHelper.Get("WinUsbConnect");
+ StatusText = LocaleHelper.Get("WinUsbDisconnected");
+ }
+
+ [RelayCommand]
+ private void SendUsbData()
+ {
+ if (!IsConnected || string.IsNullOrEmpty(SendData)) return;
+ try
+ {
+ byte[] data = HexSend
+ ? ByteConvert.Hex2Byte(SendData)
+ : System.Text.Encoding.UTF8.GetBytes(SendData);
+ lock (_sendLock) _sendBuffer.Enqueue(data);
+ }
+ catch (Exception ex)
+ {
+ StatusText = LocaleHelper.Format("WinUsbSendFailed", ex.Message);
+ }
+ }
+
+ // ── Data display ────────────────────────────────────────────────────
+
+ private int _maxLen = 20000;
+ private void AppendReceived(string text)
+ {
+ ReceivedData = (ReceivedData + text)[..Math.Min(ReceivedData.Length + text.Length, _maxLen)];
+ }
+
+ public void Cleanup()
+ {
+ Disconnect();
+ }
+}
diff --git a/llcom.Avalonia/Views/AboutPageView.axaml b/llcom.Avalonia/Views/AboutPageView.axaml
new file mode 100644
index 0000000..b1f0d07
--- /dev/null
+++ b/llcom.Avalonia/Views/AboutPageView.axaml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/AboutPageView.axaml.cs b/llcom.Avalonia/Views/AboutPageView.axaml.cs
new file mode 100644
index 0000000..2f25639
--- /dev/null
+++ b/llcom.Avalonia/Views/AboutPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace llcom.Avalonia.Views;
+
+public partial class AboutPageView : UserControl
+{
+ public AboutPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/llcom.Avalonia/Views/ConvertPageView.axaml b/llcom.Avalonia/Views/ConvertPageView.axaml
new file mode 100644
index 0000000..ba02944
--- /dev/null
+++ b/llcom.Avalonia/Views/ConvertPageView.axaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/ConvertPageView.axaml.cs b/llcom.Avalonia/Views/ConvertPageView.axaml.cs
new file mode 100644
index 0000000..94ac004
--- /dev/null
+++ b/llcom.Avalonia/Views/ConvertPageView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+using llcom.Avalonia.ViewModels;
+
+namespace llcom.Avalonia.Views;
+
+public partial class ConvertPageView : UserControl
+{
+ public ConvertPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/llcom.Avalonia/Views/EncodingFixPageView.axaml b/llcom.Avalonia/Views/EncodingFixPageView.axaml
new file mode 100644
index 0000000..49d245b
--- /dev/null
+++ b/llcom.Avalonia/Views/EncodingFixPageView.axaml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/EncodingFixPageView.axaml.cs b/llcom.Avalonia/Views/EncodingFixPageView.axaml.cs
new file mode 100644
index 0000000..69b662b
--- /dev/null
+++ b/llcom.Avalonia/Views/EncodingFixPageView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+using llcom.Avalonia.ViewModels;
+
+namespace llcom.Avalonia.Views;
+
+public partial class EncodingFixPageView : UserControl
+{
+ public EncodingFixPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/llcom.Avalonia/Views/LuaScriptView.axaml b/llcom.Avalonia/Views/LuaScriptView.axaml
new file mode 100644
index 0000000..7521ce1
--- /dev/null
+++ b/llcom.Avalonia/Views/LuaScriptView.axaml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/LuaScriptView.axaml.cs b/llcom.Avalonia/Views/LuaScriptView.axaml.cs
new file mode 100644
index 0000000..22fc144
--- /dev/null
+++ b/llcom.Avalonia/Views/LuaScriptView.axaml.cs
@@ -0,0 +1,40 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using llcom.Avalonia.ViewModels;
+
+namespace llcom.Avalonia.Views;
+
+public partial class LuaScriptView : UserControl
+{
+ public LuaScriptView()
+ {
+ InitializeComponent();
+ }
+
+ private void RunOneLineTextBox_KeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter && DataContext is LuaScriptViewModel vm)
+ {
+ vm.RunCommandCommand.Execute(null);
+ e.Handled = true;
+ }
+ }
+
+ private void TextEditor_TextChanged(object? sender, EventArgs e)
+ {
+ if (DataContext is LuaScriptViewModel vm)
+ {
+ vm.MarkDocumentChanged();
+ }
+ }
+
+ private void TextEditor_LostFocus(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is LuaScriptViewModel vm)
+ {
+ vm.OnEditorLostFocus();
+ }
+ }
+}
diff --git a/llcom.Avalonia/Views/MainWindow.axaml b/llcom.Avalonia/Views/MainWindow.axaml
new file mode 100644
index 0000000..e5675fc
--- /dev/null
+++ b/llcom.Avalonia/Views/MainWindow.axaml
@@ -0,0 +1,267 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/MainWindow.axaml.cs b/llcom.Avalonia/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..64be86c
--- /dev/null
+++ b/llcom.Avalonia/Views/MainWindow.axaml.cs
@@ -0,0 +1,145 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using llcom.Avalonia.ViewModels;
+using llcom.Tools;
+
+namespace llcom.Avalonia.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ LoadWindowPosition();
+ Closed += SaveWindowPosition;
+ Activated += OnWindowActivatedEvent;
+ Deactivated += OnWindowDeactivatedEvent;
+ MainWindowViewModel.OpenSettingsRequested += ShowSettingsWindow;
+ }
+
+ private void ShowSettingsWindow()
+ {
+ var settingVm = new SettingWindowViewModel();
+ var settingWindow = new SettingWindow { DataContext = settingVm };
+ settingWindow.Show(this);
+ }
+
+ private void OnWindowActivatedEvent(object? sender, EventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm)
+ {
+ vm.LuaScriptPage.OnWindowActivated();
+ }
+ }
+
+ private void OnWindowDeactivatedEvent(object? sender, EventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm)
+ {
+ vm.LuaScriptPage.OnEditorLostFocus();
+ }
+ }
+
+ // ── Terminal mode: forward keyboard input to serial port ─────────────
+
+ private void DataListBox_GotFocus(object? sender, global::Avalonia.Input.GotFocusEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel { TerminalMode: true } vm)
+ {
+ vm.TerminalBorderColor = "#009400";
+ dataShowBorder.BorderBrush = new SolidColorBrush(Color.FromRgb(0, 148, 0));
+ }
+ }
+
+ private void DataListBox_LostFocus(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm)
+ {
+ vm.TerminalBorderColor = "Transparent";
+ dataShowBorder.BorderBrush = Brushes.Transparent;
+ }
+ }
+
+ private void DataListBox_TextInput(object? sender, TextInputEventArgs e)
+ {
+ if (string.IsNullOrEmpty(e.Text) || DataContext is not MainWindowViewModel { TerminalMode: true, IsPortOpen: true } vm) return;
+ vm.HandleTerminalKeyInput(e.Text);
+ e.Handled = true;
+ }
+
+ private void DataListBox_KeyDown(object? sender, KeyEventArgs e)
+ {
+ if (DataContext is not MainWindowViewModel { TerminalMode: true, IsPortOpen: true } vm) return;
+ // Ctrl+A..Z sends ASCII control codes
+ var hasCtrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
+ if (hasCtrl && e.Key >= Key.A && e.Key <= Key.Z)
+ {
+ var asciiCode = (int)e.Key - (int)Key.A;
+ vm.HandleTerminalCtrlKey(asciiCode);
+ e.Handled = true;
+ }
+ }
+
+ private void LoadWindowPosition()
+ {
+ try
+ {
+ var path = Path.Combine(PlatformHelper.ProfilePath, "window_state.json");
+ if (File.Exists(path))
+ {
+ var json = File.ReadAllText(path);
+ var state = JsonSerializer.Deserialize(json);
+ if (state != null && state.Left > 0 && state.Top > 0)
+ {
+ if (state.Width >= 400 && state.Height >= 300)
+ {
+ Position = new global::Avalonia.PixelPoint((int)state.Left, (int)state.Top);
+ Width = state.Width;
+ Height = state.Height;
+ }
+ }
+ }
+ }
+ catch { /* ignore */ }
+ }
+
+ private void SaveWindowPosition(object? sender, EventArgs e)
+ {
+ try
+ {
+ var state = new WindowPosState
+ {
+ Left = Position.X,
+ Top = Position.Y,
+ Width = Width,
+ Height = Height
+ };
+ var path = Path.Combine(PlatformHelper.ProfilePath, "window_state.json");
+ Directory.CreateDirectory(PlatformHelper.ProfilePath);
+ File.WriteAllText(path, JsonSerializer.Serialize(state));
+ }
+ catch { /* ignore */ }
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ base.OnClosed(e);
+ if (DataContext is MainWindowViewModel vm)
+ {
+ vm.LuaScriptPage.OnEditorLostFocus();
+ vm.Cleanup();
+ }
+ }
+
+ private class WindowPosState
+ {
+ public double Left { get; set; }
+ public double Top { get; set; }
+ public double Width { get; set; }
+ public double Height { get; set; }
+ }
+}
diff --git a/llcom.Avalonia/Views/MqttPageView.axaml b/llcom.Avalonia/Views/MqttPageView.axaml
new file mode 100644
index 0000000..8b24712
--- /dev/null
+++ b/llcom.Avalonia/Views/MqttPageView.axaml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/MqttPageView.axaml.cs b/llcom.Avalonia/Views/MqttPageView.axaml.cs
new file mode 100644
index 0000000..feb7949
--- /dev/null
+++ b/llcom.Avalonia/Views/MqttPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace llcom.Avalonia.Views;
+
+public partial class MqttPageView : UserControl
+{
+ public MqttPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/llcom.Avalonia/Views/OnlineScriptsView.axaml b/llcom.Avalonia/Views/OnlineScriptsView.axaml
new file mode 100644
index 0000000..51f3c1d
--- /dev/null
+++ b/llcom.Avalonia/Views/OnlineScriptsView.axaml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/OnlineScriptsView.axaml.cs b/llcom.Avalonia/Views/OnlineScriptsView.axaml.cs
new file mode 100644
index 0000000..39a2084
--- /dev/null
+++ b/llcom.Avalonia/Views/OnlineScriptsView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace llcom.Avalonia.Views;
+
+public partial class OnlineScriptsView : UserControl
+{
+ public OnlineScriptsView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/llcom.Avalonia/Views/PlotPageView.axaml b/llcom.Avalonia/Views/PlotPageView.axaml
new file mode 100644
index 0000000..b6e4d38
--- /dev/null
+++ b/llcom.Avalonia/Views/PlotPageView.axaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/PlotPageView.axaml.cs b/llcom.Avalonia/Views/PlotPageView.axaml.cs
new file mode 100644
index 0000000..1292996
--- /dev/null
+++ b/llcom.Avalonia/Views/PlotPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace llcom.Avalonia.Views;
+
+public partial class PlotPageView : UserControl
+{
+ public PlotPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/llcom.Avalonia/Views/QuickSendView.axaml b/llcom.Avalonia/Views/QuickSendView.axaml
new file mode 100644
index 0000000..1db274e
--- /dev/null
+++ b/llcom.Avalonia/Views/QuickSendView.axaml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/QuickSendView.axaml.cs b/llcom.Avalonia/Views/QuickSendView.axaml.cs
new file mode 100644
index 0000000..0a38bb5
--- /dev/null
+++ b/llcom.Avalonia/Views/QuickSendView.axaml.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Threading;
+using llcom.Avalonia.ViewModels;
+using llcom.Tools;
+
+namespace llcom.Avalonia.Views;
+
+public partial class QuickSendView : UserControl
+{
+ public QuickSendView()
+ {
+ InitializeComponent();
+ }
+
+ /// Handle click on 📜 icon to select recv script.
+ private void ScriptIcon_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not Control icon) return;
+ if (icon.Tag is not QuickSendItem item) return;
+
+ var point = e.GetCurrentPoint(icon);
+ if (point.Properties.IsRightButtonPressed)
+ {
+ // Right-click: clear recv script
+ item.RecvScriptPath = "";
+ e.Handled = true;
+ return;
+ }
+
+ // Left-click: show popup with script selection
+ var scriptsDir = Path.Combine(PlatformHelper.ProfilePath, "user_script_recv_convert");
+ var scripts = new List();
+ if (Directory.Exists(scriptsDir))
+ scripts = Directory.GetFiles(scriptsDir, "*.lua")
+ .Select(Path.GetFileNameWithoutExtension)
+ .OrderBy(s => s)
+ .ToList()!;
+
+ if (scripts.Count == 0)
+ {
+ PlatformHelper.ShowMessage("暂无接收脚本(user_script_recv_convert/ 目录为空)");
+ return;
+ }
+
+ var popup = new Popup
+ {
+ PlacementTarget = icon,
+ Placement = PlacementMode.Bottom,
+ IsLightDismissEnabled = true,
+ };
+
+ var comboBox = new ComboBox
+ {
+ ItemsSource = scripts,
+ SelectedItem = !string.IsNullOrEmpty(item.RecvScriptPath) ? item.RecvScriptPath : null,
+ Width = 180,
+ Margin = new global::Avalonia.Thickness(4),
+ };
+
+ comboBox.SelectionChanged += (_, _) =>
+ {
+ if (comboBox.SelectedItem is string selected)
+ {
+ item.RecvScriptPath = selected;
+ }
+ popup.IsOpen = false;
+ };
+
+ var border = new Border
+ {
+ Background = global::Avalonia.Media.Brushes.White,
+ BorderBrush = global::Avalonia.Media.Brushes.Gray,
+ BorderThickness = new global::Avalonia.Thickness(1),
+ CornerRadius = new CornerRadius(4),
+ Child = comboBox,
+ };
+
+ popup.Child = border;
+ popup.IsOpen = true;
+ e.Handled = true;
+ }
+
+ ///
+ /// Handle click on 🛠 icon to set recv script parameters.
+ /// Uses async pattern to avoid UI thread deadlock.
+ ///
+ private async void ScriptParaIcon_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not Control icon) return;
+ if (icon.Tag is not QuickSendItem item) return;
+
+ var point = e.GetCurrentPoint(icon);
+ if (point.Properties.IsRightButtonPressed)
+ {
+ // Right-click: clear recv script parameters
+ item.RecvScriptPara = "";
+ e.Handled = true;
+ return;
+ }
+
+ // Left-click: show input dialog for script parameters (async, non-blocking)
+ e.Handled = true;
+ var result = await ShowInputDialogAsync(
+ TopLevel.GetTopLevel(this),
+ "设置接收脚本参数:",
+ item.RecvScriptPara ?? "",
+ "脚本参数");
+ if (result.confirmed)
+ {
+ item.RecvScriptPara = result.text;
+ }
+ }
+
+ ///
+ /// Async input dialog that doesn't block the UI thread.
+ /// Uses TaskCompletionSource with Show() instead of blocking .Result.
+ ///
+ private static Task<(bool confirmed, string text)> ShowInputDialogAsync(
+ TopLevel? topLevel, string prompt, string defaultInput, string title)
+ {
+ var tcs = new TaskCompletionSource<(bool, string)>();
+ if (topLevel == null)
+ {
+ tcs.TrySetResult((false, defaultInput));
+ return tcs.Task;
+ }
+
+ var window = new Window
+ {
+ Title = title,
+ Width = 350,
+ Height = 150,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ ShowInTaskbar = false,
+ CanResize = false,
+ };
+
+ var panel = new StackPanel { Margin = new global::Avalonia.Thickness(10) };
+ var promptText = new TextBlock { Text = prompt, Margin = new global::Avalonia.Thickness(0, 0, 0, 8) };
+ var inputBox = new TextBox { Text = defaultInput, Margin = new global::Avalonia.Thickness(0, 0, 0, 12) };
+ var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
+
+ var okBtn = new Button { Content = "确定", Width = 70, Margin = new global::Avalonia.Thickness(0, 0, 8, 0) };
+ var cancelBtn = new Button { Content = "取消", Width = 70 };
+
+ okBtn.Click += (_, _) => { tcs.TrySetResult((true, inputBox.Text ?? "")); window.Close(); };
+ cancelBtn.Click += (_, _) => { tcs.TrySetResult((false, defaultInput)); window.Close(); };
+ window.Closing += (_, _) => tcs.TrySetResult((false, defaultInput));
+
+ btnPanel.Children.Add(okBtn);
+ btnPanel.Children.Add(cancelBtn);
+ panel.Children.Add(promptText);
+ panel.Children.Add(inputBox);
+ panel.Children.Add(btnPanel);
+ window.Content = panel;
+
+ var owner = topLevel as Window;
+ if (owner != null)
+ window.Show(owner);
+ else
+ window.Show();
+ inputBox.Focus();
+ inputBox.SelectAll();
+
+ return tcs.Task;
+ }
+}
diff --git a/llcom.Avalonia/Views/SerialMonitorView.axaml b/llcom.Avalonia/Views/SerialMonitorView.axaml
new file mode 100644
index 0000000..26ef59e
--- /dev/null
+++ b/llcom.Avalonia/Views/SerialMonitorView.axaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/SerialMonitorView.axaml.cs b/llcom.Avalonia/Views/SerialMonitorView.axaml.cs
new file mode 100644
index 0000000..dc3f3a8
--- /dev/null
+++ b/llcom.Avalonia/Views/SerialMonitorView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace llcom.Avalonia.Views;
+
+public partial class SerialMonitorView : UserControl
+{
+ public SerialMonitorView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/llcom.Avalonia/Views/SettingWindow.axaml b/llcom.Avalonia/Views/SettingWindow.axaml
new file mode 100644
index 0000000..b4a02d4
--- /dev/null
+++ b/llcom.Avalonia/Views/SettingWindow.axaml
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/SettingWindow.axaml.cs b/llcom.Avalonia/Views/SettingWindow.axaml.cs
new file mode 100644
index 0000000..ef0370d
--- /dev/null
+++ b/llcom.Avalonia/Views/SettingWindow.axaml.cs
@@ -0,0 +1,17 @@
+using AvWindow = global::Avalonia.Controls.Window;
+using RoutedEventArgs = global::Avalonia.Interactivity.RoutedEventArgs;
+
+namespace llcom.Avalonia.Views;
+
+public partial class SettingWindow : AvWindow
+{
+ public SettingWindow()
+ {
+ InitializeComponent();
+ }
+
+ public void CloseButton_Click(object? sender, RoutedEventArgs e)
+ {
+ Close();
+ }
+}
diff --git a/llcom.Avalonia/Views/SocketClientPageView.axaml b/llcom.Avalonia/Views/SocketClientPageView.axaml
new file mode 100644
index 0000000..cab62e8
--- /dev/null
+++ b/llcom.Avalonia/Views/SocketClientPageView.axaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/SocketClientPageView.axaml.cs b/llcom.Avalonia/Views/SocketClientPageView.axaml.cs
new file mode 100644
index 0000000..f0ef183
--- /dev/null
+++ b/llcom.Avalonia/Views/SocketClientPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace llcom.Avalonia.Views;
+
+public partial class SocketClientPageView : UserControl
+{
+ public SocketClientPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/llcom.Avalonia/Views/TcpTestPageView.axaml b/llcom.Avalonia/Views/TcpTestPageView.axaml
new file mode 100644
index 0000000..b705515
--- /dev/null
+++ b/llcom.Avalonia/Views/TcpTestPageView.axaml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/TcpTestPageView.axaml.cs b/llcom.Avalonia/Views/TcpTestPageView.axaml.cs
new file mode 100644
index 0000000..ccc61eb
--- /dev/null
+++ b/llcom.Avalonia/Views/TcpTestPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace llcom.Avalonia.Views;
+
+public partial class TcpTestPageView : UserControl
+{
+ public TcpTestPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/llcom.Avalonia/Views/WinUsbView.axaml b/llcom.Avalonia/Views/WinUsbView.axaml
new file mode 100644
index 0000000..6648c1b
--- /dev/null
+++ b/llcom.Avalonia/Views/WinUsbView.axaml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/Views/WinUsbView.axaml.cs b/llcom.Avalonia/Views/WinUsbView.axaml.cs
new file mode 100644
index 0000000..89c6bb2
--- /dev/null
+++ b/llcom.Avalonia/Views/WinUsbView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace llcom.Avalonia.Views;
+
+public partial class WinUsbView : UserControl
+{
+ public WinUsbView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/llcom.Avalonia/app.manifest b/llcom.Avalonia/app.manifest
new file mode 100644
index 0000000..f8d34e3
--- /dev/null
+++ b/llcom.Avalonia/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Avalonia/languages/en-US.xaml b/llcom.Avalonia/languages/en-US.xaml
new file mode 100644
index 0000000..fc251d9
--- /dev/null
+++ b/llcom.Avalonia/languages/en-US.xaml
@@ -0,0 +1,315 @@
+
+ LLCOM - Debug serial port with Lua!
+ Open
+ Close
+ Port
+ Serial port open failed!
+ Serial port close failed!
+ Serial port data send failed! Please check connection!
+ Lua script for send data has error, please check:
+ Clear Log
+ Settings
+ Send
+ Refresh List
+ Serial Ports:
+ Baud Rate:
+ Custom
+ Custom baud rate setting failed
+ Status:
+ Data sent:
+ Data received:
+ Run script
+ New script
+ Run script
+ Open script folder
+ Refresh script List
+ See api reference
+ Get scripts on community
+ Stop run
+ Quit
+ Reload and run
+ Pause Log print
+ Continue print log
+ Run Lua command
+ File name:
+ Confirm create
+ Cancel
+ Please enter a file name!
+ File already exists!
+ Failed to create, please check!
+ Server Address
+ Port
+ Protocol
+ Auto reconnect
+ Reconnect interval:
+ Data to send:
+ Append CRLF after send
+ CRLF is appended after script processing
+ Socket Public Server
+ TCP Local Server
+ UDP Local Server
+ Socket Client
+ Test TCP
+ Test TCP SSL
+ Test UDP
+ Test TCP (ipv6)
+ Disconnect
+ Connect
+ Send
+ Kick Client
+ Lock logs and disable auto scroll
+ Click to save received logs
+ Hex format
+ Tools
+ Encoding Converter
+ Jobs
+ Add a job
+ Remove Last one
+ Raw data
+ Result
+ Garbled code fix
+ unfixed Garbled code:
+ Fixed list:
+ Raw
+ Wrong
+ Fix Result
+ Copy selected data
+ Subscribe/Publish
+ Settings
+ Subscribe
+ Publish
+ Subscribed Topics:
+ Not connected
+ Clean session
+ Broker address
+ Broker port
+ Client ID
+ User name
+ password
+ Keep alive period
+ websocket
+ websocket path
+ Chart
+ Fit
+ Clear
+ Theme
+ Serial Monitor
+ Start monitor
+ Stop monitor
+ Monitor stopped
+ Monitoring...
+ Please select process and COM port
+ Serial Monitor is not available on Linux/macOS (relies on Windows API hook injection; use strace -e read,write -p PID or socat relay as alternatives)
+ Refresh process and serial port list
+ Invalid process ID
+ Monitor start failed: {0}
+
+ USB device list
+ IN endpoint (0x81)
+ OUT endpoint (0x01)
+ Send data
+ No USB devices found (try sudo)
+ Please select a device
+ Connect
+ Disconnect
+ Connected
+ Disconnected
+ Enumeration failed: {0}
+ Found {0} devices
+ Invalid endpoint address
+ Device not found
+ Cannot open endpoint
+ Open failed: {0}
+ Send failed: {0}
+ Refresh IP
+ Start listening
+ Stop listening
+ Refresh devices
+ Read Endpoint
+ Write Endpoint
+ Read data
+
+ Save script
+ Run one line...
+
+ Debug serial port with Lua!
+ LLCOM is a serial port debugging tool with reliable connection and auto-reconnect. It features a quick-send panel on the right side.
+ In addition to standard data send/receive capabilities, LLCOM includes a Lua runtime environment. Users can write Lua scripts to automate almost any imaginable task, greatly enhancing the tool's usability.
+ Developers: chenxuuu, whc2001
+ QQ Group: 931546484
+ Translators: chenxuuu
+ Open GitHub
+ Create issue
+ Open source project, star welcome!
+ Found an issue? Click the button below
+ Thanks to these projects:
+ Version
+ Auto check for updates on startup
+ Loading update data...
+
+ Script file already exists!
+ Saved successfully!
+ Save failed: {0}
+ Loading online scripts...
+ Loading... {0}/{1}
+ Community scripts
+ Download this script
+ Online Scripts
+ Refresh
+ View Discussion
+ ← Back
+ Author: {0}
+ Version: {0}
+ Loading online scripts...
+ Download Script
+ Please set the filename to save as:
+ File saved successfully!
+ Stop printing
+ Loading...
+ Confirm Deletion
+ This cannot be undone. Type "YES" to confirm deletion
+ OK
+ Cancel
+ Prompt
+ About
+ 语言/Language
+ More serial port settings
+ Encoding:
+ Send Hex
+
+ Quick Send
+ ID
+ Content
+ HEX
+ Script
+ Param
+ Right click to set display text
+ Right click to change entry position
+ Add New Item
+ Remove Last Item
+ The send processing Lua script also takes effect for data sent here.
+ Import data to this page
+ Export this page's data
+ Clear all data on this page
+ Import SSCOM data
+ Switch to another quick send list
+ Right click name to change list name
+ Change current quick send list name
+ No receive script\nLeft click to select script
+ Current receive script: [{0}]\nRight click to clear script
+ Left click to set script params\nRight click to clear params
+ Send
+ Enter text to display
+ Change button display text
+ Enter target position index (leave empty to delete)
+ Change item index
+ Quick send list data corrupted, all cleared
+ LLCOM list file|*.lclst
+ SSCOM config|sscom51.ini;sscom.ini
+ Data exported successfully!
+ Import successful
+ Import failed: {0}
+ Export successful
+ Export failed: {0}
+ Saved to profile directory
+ File picker needed on this platform
+ Successfully imported {0} items
+ SSCOM import failed: {0}
+ List
+ Right click to clear this counter
+ Press Ctrl+Enter to send data as well
+ Configure serial port parity, stop bits, etc.
+ Replace non-printable characters
+ Enable TLS
+ Specify TLS certificate
+ CA certificate path (select file)
+ Client certificate path (select file)
+ Client certificate password (leave empty if none)
+ Basic Serial Settings
+ Auto-reconnect disconnected serial port
+ Data display format:
+ Show both Hex and text
+ Show text only
+ Show Hex only
+ Show sent data
+ Packet timeout:
+ ms
+ Data bits:
+ Stop bits:
+ Parity:
+ None
+ Odd
+ Even
+ Mark
+ Space
+ Terminal mode (keyboard to serial)
+ Start from blank interval
+ Lua script executed before sending (input: uartData, output: processed data)
+ Lua script executed after receiving (input: uartData, output: display text)
+ Input: uartData. Output: the result your script returns.
+ This script also applies to Quick Send data. Does not affect script output.
+ This script only affects data display. Does not affect data received by Lua APIs.
+ Click the API Reference button to view documentation.
+ Save
+ Close
+ Open serial port log directory
+ Show raw sent data (original data before processing)
+ Keep window on top
+ Send data script
+ Receive data script
+ Test script
+ Test input:
+ Test
+ Parameter:
+ Raw data:
+ Result:
+ Input: uartData. Output: the result your script returns.
+ Input: uartData. Output: the result your script returns.
+ This script also applies to Quick Send data. Does not affect script output.
+ This script only affects data display. Does not affect Lua API data.
+ Click the API Reference button to view documentation.
+ Timeout means blank interval (uncheck for first-byte based timeout)
+ Max packet size
+ Max log display length
+ !!Data too long, truncated. Change limit in settings!!
+ !!Too many packets, cleared. Change limit in settings!!
+ bytes
+ After receiving
+ packets, auto-clear log area
+ Auto-clear when log lags over 250ms
+ Terminal mode (keyboard input sent directly to serial port)
+ Force enter terminal mode
+ Raw data
+ Only effective when using UTF-8 encoding
+ Switch: Hex only / Text only / Both Hex and Text
+ !!Buffer exceeded 100 items, merged to avoid lag!! Check log file for details
+ !!Log refresh lag over 250ms detected, auto-cleared data!!
+
+ Ready
+ Port closed
+ Close failed: {0}
+ Please select a port
+ Connected {0} @ {1}
+ Open failed: {0}
+ Sent {0} bytes
+ Send failed: {0}
+ Log saved: {0}
+ Save failed: {0}
+ Language switched to Simplified Chinese
+ Language switched to English
+
+ MQTT
+ WinUSB
+ 📻 Community Scripts
+
+ topic
+ QOS:
+ Subscribed:
+ Clean session
+ CA certificate path
+ Client certificate path
+ Certificate password (leave empty if none)
+
diff --git a/llcom.Avalonia/languages/zh-CN.xaml b/llcom.Avalonia/languages/zh-CN.xaml
new file mode 100644
index 0000000..d6393cd
--- /dev/null
+++ b/llcom.Avalonia/languages/zh-CN.xaml
@@ -0,0 +1,315 @@
+
+ LLCOM - 能跑Lua脚本的串口调试工具
+ 打开
+ 关闭
+ 串口
+ 串口打开失败!
+ 串口关闭失败!
+ 串口数据发送失败!请检查连接!
+ 处理发送数据的脚本运行错误,请检查发送脚本后再试,错误信息:
+ 清空日志
+ 更多设置
+ 发送
+ 刷新串口
+ 串口:
+ 波特率:
+ 自定义
+ 自定义波特率设置失败
+ 状态:
+ 已发送字节:
+ 已接收字节:
+ 运行脚本
+ 新建脚本
+ 运行脚本
+ 打开脚本目录
+ 刷新脚本列表
+ 查看接口文档
+ 获取社区上分享的脚本
+ 停止运行
+ 退出
+ 重新载入并运行
+ 暂停打印
+ 继续打印
+ 实时执行lua语句
+ 文件名:
+ 确认新建文件
+ 取消
+ 请输入文件名!
+ 该文件已存在!
+ 新建失败,请检查!
+ 服务器地址
+ 服务器端口
+ 协议
+ 自动重连
+ 重连间隔:
+ 待发送数据:
+ 发末尾加回车换行
+ 加回车换行前,同样会经过脚本处理数据
+ socket公共服务端
+ 本机TCP服务端
+ 本机UDP服务端
+ socket客户端
+ 测试TCP
+ 测试TCP SSL
+ 测试UDP
+ 测试TCP (ipv6)
+ 断开
+ 连接
+ 发送
+ 断开客户端
+ 锁定视图禁止滚动
+ 保存接收日志
+ Hex显示
+ 小工具
+ 编码转换工具
+ 转换任务
+ 添加任务
+ 删除末项
+ 原始数据
+ 转换结果
+ 乱码修复
+ 待处理乱码数据:
+ 修复尝试:
+ 原编码
+ 错当成
+ 还原结果
+ 复制选中数据
+ 订阅/推送
+ 连接配置
+ 订阅
+ 推送
+ 服务器地址
+ 服务器端口
+ 设备Client ID
+ 用户名
+ 密码
+ 保活时间
+ websocket
+ websocket路径
+ 曲线
+ 查看全局
+ 清空图像
+ 切换主题
+ 串口监听
+ 开始监听
+ 停止监听
+ 监听已停止
+ 监听中...
+ 请选择进程和串口
+ 串口监听在 Linux/macOS 上暂不支持(依赖 Windows API hook 注入,Linux 下可用 strace -e read,write -p PID 替代,或使用 socat 中继方案)
+ 刷新进程和串口列表
+ 无效的进程ID
+ 监听启动失败: {0}
+
+ USB 设备列表
+ IN 端点 (0x81)
+ OUT 端点 (0x01)
+ 发送数据
+ 未发现 USB 设备 (可能需要 sudo 权限)
+ 请选择设备
+ 连接
+ 断开
+ 已连接
+ 已断开
+ 枚举失败: {0}
+ 发现 {0} 个设备
+ 无效的端点地址
+ 未找到设备
+ 无法打开端点
+ 打开失败: {0}
+ 发送失败: {0}
+ 刷新IP
+ 开始监听
+ 停止监听
+ 刷新设备
+ 入端点
+ 出端点
+ 读取数据
+
+ 保存脚本
+ 单行执行...
+
+ 能跑Lua脚本的串口调试工具
+ LLCOM是一款串口调试工具,工具连接可靠,断线后可自动重连,同时右侧具有快捷发送区的功能。
+ 本工具除了具有与其他同类工具,所包含的数据收发功能外,还添加了Lua运行环境。用户可自行使用Lua,完成几乎所有可以想象到的自动化操作,大大提高了串口工具的可用性。
+ 开发者:chenxuuu、whc2001
+ 交流群:931546484
+ 简体中文为原版语言
+ 前往GitHub查看
+ 提交建议或上报Bug
+ 开源项目,欢迎star
+ 如需反馈,请点击下面的按钮
+ 感谢以下项目:
+ 软件版本
+ 开启软件后,自动检查是否需要更新
+ 更新数据加载中
+
+ 脚本文件已存在!
+ 保存成功!
+ 保存失败: {0}
+ 正在加载在线脚本...
+ 加载中... {0}/{1}
+ 社区脚本
+ 下载此脚本
+ 在线脚本
+ 刷新
+ 查看讨论
+ ← 返回
+ 作者: {0}
+ 版本: {0}
+ 正在获取在线脚本列表...
+ 下载脚本
+ 请设置要保存为的文件名:
+ 文件保存成功!
+ 停止打印
+ 加载中...
+ 删除确认
+ 删除后不可恢复,请手动输入"YES"确认删除
+ 确定
+ 取消
+ 提示
+ 关于
+ 语言/Language
+ 更多串口设置
+ 字符编码:
+ 发Hex
+
+ 快捷发送
+ 编号
+ 内容
+ HEX
+ 脚本
+ 参数
+ 右键设置显示内容
+ 右键序号更改位置
+ 添加新项目
+ 删除最后一项
+ 发送处理的lua脚本,同样对此处发送的数据生效。
+ 导入数据到该页
+ 导出该页数据
+ 一键清空该页数据
+ 一键导入SSCOM数据
+ 切换到其他快捷发送数据列表
+ 右键名称更改列表名
+ 更改当前快捷发送列表名称
+ 无接收脚本
左击选择脚本
+ 当前接收脚本为:[{0}]
右击清除脚本
+ 左击设置脚本参数
右击清除脚本参数
+ 发送
+ 输入你想显示的内容
+ 更改发送按键显示内容
+ 输入你想改到哪个序号的位置(留空表示删除该项目)
+ 更改这个条目的序号
+ 待发送列表,数据损坏,全部清空
+ LLCOM列表文件|*.lclst
+ SSCOM配置文件|sscom51.ini;sscom.ini
+ 数据导出成功!
+ 导入成功
+ 导入失败: {0}
+ 导出成功
+ 导出失败: {0}
+ 已保存到配置目录
+ 此平台需要文件选择器
+ 成功导入 {0} 条数据
+ SSCOM 导入失败: {0}
+ 列表
+ 右键可清空此计数
+ 按Ctrl+Enter同样也可以发送数据
+ 可设置串口校验位停止位等信息
+ 替换不可见字符
+ 已订阅主题:
+ 未连接服务器
+ Clean session
+ 启用TLS
+ 指定TLS证书
+ CA证书路径(选择文件)
+ 客户端证书路径(选择文件)
+ 客户端证书密码(无则留空)
+ 串口基本设置
+ 自动恢复被断开的串口连接
+ 打开串口收发日志目录
+ 串口数据显示方式:
+ 同时显示Hex和文本数据
+ 只显示文本数据
+ 只显示Hex数据
+ 显示串口实际发出去的数据
+ 显示原始发送数据(用户请求发送时的原始数据)
+ 窗口保持置顶显示
+ 分包数据超时时间:
+ 毫秒
+ 数据位:
+ 停止位:
+ 校验位:
+ 无校验
+ 奇校验
+ 偶校验
+ 高电平(Mark)
+ 低电平(Space)
+ 终端模式(键盘直发串口)
+ 空白间隔起始
+ 发送数据前执行的Lua脚本(传入 uartData,返回处理后的数据)
+ 接收数据后执行的处理脚本(传入 uartData,返回处理后的显示文本)
+ 传入数据为uartData,传出数据为你脚本return的结果。
+ 此处脚本对快捷发送区也生效,对脚本输出数据无效。
+ 此处脚本仅对数据显示页面有效,对脚本接收接口的数据无影响。
+ 查看接口文档,可点击接口文档按钮。
+ 保存
+ 关闭
+ 处理发送数据的脚本
+ 处理接收数据的脚本
+ 测试脚本
+ 待测试的值:
+ 参数:
+ 测试输入值
+ 原始数据:
+ 运行结果:
+ 传入数据为uartData,传出数据为你脚本return的结果。
+ 传入数据为uartData,传出数据为你脚本return的结果。
+ 此处脚本对快捷发送区也生效,对脚本输出数据无效。
+ 此处脚本仅对数据显示页面有效,对脚本接收接口的数据无影响。
+ 查看接口文档,可点击接口文档按钮。
+ 超时时间表示空白间隔(不勾选表示从第一个字节开始算超时时间)
+ 串口数据每包最大
+ 日志打印每最大显示长度限制
+ !!数据过长自动截断显示,可前往设置更改长度限制!!
+ !!数据包过多自动清空,可前往设置更改此限制!!
+ 字节
+ 收到
+ 包数据后,自动清空日志区域
+ 日志界面卡顿超过250ms,自动清空
+ 终端模式(选中数据区域可直接敲键盘输出串口数据)
+ 强制进入终端模式
+ 原始数据
+ 仅支持在UTF8编码时生效
+ 切换为:只显示Hex/只显示文本数据/同时显示Hex与文本数据
+ !!缓冲区数据超过100条,为避免卡顿已自动合并!!如需查看,请前往日志文件查看
+ !!检测到日志刷新卡顿超过250毫秒,自动执行清空数据操作!!
+
+ 就绪
+ 串口已关闭
+ 关闭失败: {0}
+ 请选择串口
+ 已连接 {0} @ {1}
+ 打开失败: {0}
+ 已发送 {0} 字节
+ 发送失败: {0}
+ 日志已保存: {0}
+ 保存失败: {0}
+ 语言已切换为简体中文
+ 语言已切换为英文
+
+ MQTT
+ WinUSB
+ 📻 社区脚本
+
+ topic
+ QOS:
+ Subscribed:
+ Clean session
+ CA证书文件路径
+ 客户端证书文件路径
+ 证书密码(无则留空)
+
diff --git a/llcom.Avalonia/llcom.Avalonia.csproj b/llcom.Avalonia/llcom.Avalonia.csproj
new file mode 100644
index 0000000..b620ba7
--- /dev/null
+++ b/llcom.Avalonia/llcom.Avalonia.csproj
@@ -0,0 +1,58 @@
+
+
+ WinExe
+ net8.0
+ enable
+ true
+ app.manifest
+ true
+ llcom
+ Assets\llcom.ico
+ win-x64;linux-x64;osx-x64;osx-arm64
+
+ true
+ partial
+
+ none
+ false
+
+ false
+ $(NoWarn);IL2026;IL2067
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/llcom.Core/LuaEnv/LuaApis.cs b/llcom.Core/LuaEnv/LuaApis.cs
new file mode 100644
index 0000000..e795592
--- /dev/null
+++ b/llcom.Core/LuaEnv/LuaApis.cs
@@ -0,0 +1,90 @@
+using System.Text;
+using llcom.Model;
+
+namespace llcom.LuaEnv;
+
+///
+/// C# API methods exposed to Lua scripts.
+/// Cross-platform: no XLua dependency.
+///
+public static class LuaApis
+{
+ public static event EventHandler? PrintLuaLog;
+ public static event EventHandler? LinePlotAdd;
+
+ /// Print a log message to the Lua log file.
+ public static void PrintLog(string log)
+ {
+ Tools.Logger.AddLuaLog(log);
+ PrintLuaLog?.Invoke(DateTime.Now.ToString("[HH:mm:ss:ffff]") + log, EventArgs.Empty);
+ }
+
+ /// Convert UTF-8 string to GBK Hex encoding.
+ public static string Utf8ToAsciiHex(string input)
+ {
+ return BitConverter.ToString(Encoding.GetEncoding("GB2312").GetBytes(input)).Replace("-", "");
+ }
+
+ /// Convert GBK-encoded bytes to UTF-8 bytes.
+ public static byte[] Ascii2Utf8(byte[] input)
+ {
+ return Encoding.UTF8.GetBytes(Encoding.Default.GetString(input));
+ }
+
+ /// Get the application profile/data directory path.
+ public static string GetPath()
+ {
+ return Tools.PlatformHelper.ProfilePath;
+ }
+
+ /// Get quick-send list entry by index (1-based).
+ public static string QuickSendList(int id)
+ {
+ if (Tools.GlobalState.Instance.Settings.quickSend.Count < id || id <= 0)
+ return "";
+ var item = Tools.GlobalState.Instance.Settings.quickSend[id - 1];
+ return (item.hex ? "H" : "S") + item.text;
+ }
+
+ ///
+ /// Show an input dialog. Returns a tuple of (ok, value).
+ /// The value is stored in a static field for Lua interop.
+ ///
+ public static string? LastInputResult { get; private set; }
+ public static bool InputBox(string prompt, string defaultInput, string? title)
+ {
+ var callback = Tools.PlatformHelper.InputDialogCallback;
+ if (callback == null)
+ {
+ LastInputResult = defaultInput;
+ return true;
+ }
+ var result = callback(prompt, defaultInput, title ?? "");
+ LastInputResult = result.Item2;
+ return result.Item1;
+ }
+
+ /// Add a data point to the plot.
+ public static void AddPoint(double n, int l)
+ {
+ LinePlotAdd?.Invoke(null, new LinePlotPoint { N = n, Line = l });
+ }
+
+ /// Send channels: registered callbacks keyed by channel name.
+ private static readonly Dictionary, bool>> SendChannels = new();
+
+ public static void SendChannelsRegister(string channel, Func, bool> cb) =>
+ SendChannels[channel] = cb;
+
+ /// Send data to a registered channel.
+ public static bool Send(string channel, byte[] data, Dictionary table)
+ {
+ if (SendChannels.TryGetValue(channel, out var cb))
+ return cb(data, table);
+ return false;
+ }
+
+ /// Notify Lua that a channel received data.
+ public static void SendChannelsReceived(string channel, object data) =>
+ LuaRunEnv.ChannelReceived(channel, data);
+}
diff --git a/llcom.Core/LuaEnv/LuaEnv.cs b/llcom.Core/LuaEnv/LuaEnv.cs
new file mode 100644
index 0000000..e2d62f7
--- /dev/null
+++ b/llcom.Core/LuaEnv/LuaEnv.cs
@@ -0,0 +1,357 @@
+using System.Collections.Concurrent;
+using MoonSharp.Interpreter;
+
+namespace llcom.LuaEnv;
+
+///
+/// Lua virtual machine built on MoonSharp (pure C# Lua 5.2 interpreter).
+/// Cross-platform: replaces XLua for Linux/macOS support.
+/// Embeds the LuatOS coroutine scheduling framework (sys module).
+///
+public class LuaEnv : IDisposable
+{
+ public Script lua;
+ public event EventHandler? ErrorEvent;
+ public event EventHandler? PrintEvent;
+ public event EventHandler? StopEvent;
+
+ private bool stop;
+ private readonly ConcurrentDictionary timerPool = new();
+ private readonly ConcurrentBag toRun = new();
+ private readonly object taskLock = new();
+ private DynValue? triggerCB;
+
+ private void Error(string msg) => ErrorEvent?.Invoke(lua, msg);
+
+ public void Print(string msg) => PrintEvent?.Invoke(lua, msg);
+
+ public void AddTrigger(string type, object? data) => AddTask(-1, type, data);
+
+ private void AddTask(int id, string type, object? data)
+ {
+ if (stop) return;
+ toRun.Add(new LuaTaskData { id = id, type = type, data = data });
+ RunTask();
+ }
+
+ private void RunTask()
+ {
+ lock (taskLock)
+ while (toRun.TryTake(out var task))
+ {
+ try
+ {
+ triggerCB?.Function.Call(task.id, task.type, task.data ?? DynValue.Nil);
+ }
+ catch (Exception e)
+ {
+ ErrorEvent?.Invoke(lua, e.Message);
+ }
+ if (stop) return;
+ }
+ }
+
+ public int StartTimer(int id, int time)
+ {
+ var timerToken = new CancellationTokenSource();
+ timerPool.AddOrUpdate(id, timerToken, (_, old) =>
+ {
+ try { old.Cancel(); } catch { }
+ return timerToken;
+ });
+
+ var timer = new System.Timers.Timer(time) { AutoReset = false };
+ timer.Elapsed += (_, _) =>
+ {
+ if (timerToken.IsCancellationRequested || stop) return;
+ timerPool.TryRemove(id, out _);
+ AddTask(id, "timer", null);
+ timer.Dispose();
+ };
+ timer.Start();
+ return 1;
+ }
+
+ public void StopTimer(int id)
+ {
+ if (timerPool.TryRemove(id, out var tc))
+ try { tc.Cancel(); } catch { }
+ }
+
+ public DynValue DoString(string s)
+ {
+ try
+ {
+ lock (taskLock) return lua.DoString(s);
+ }
+ catch (Exception e)
+ {
+ ErrorEvent?.Invoke(lua, e.Message);
+ throw new Exception(e.Message);
+ }
+ }
+
+ public DynValue DoFile(string f)
+ {
+ try
+ {
+ var s = File.ReadAllBytes(f);
+ lock (taskLock) return lua.DoString(System.Text.Encoding.UTF8.GetString(s));
+ }
+ catch (Exception e)
+ {
+ ErrorEvent?.Invoke(lua, e.Message);
+ throw new Exception(e.Message);
+ }
+ }
+
+ /// Initialize Lua VM with LuatOS sys framework.
+ public LuaEnv(object? input = null)
+ {
+ lua = new Script(CoreModules.Preset_Complete);
+ lua.Options.DebugPrint = Print;
+
+ if (input != null)
+ lua.Globals["lua"] = input;
+
+ lock (taskLock) lua.DoString(SysCode);
+
+ var sysTable = lua.Globals.Get("sys").Table;
+ triggerCB = sysTable.Get("tiggerCB");
+
+ lua.Globals["@this"] = this;
+
+ // Set up require paths
+ lua.DoString(@"
+local rootPath = '" + LuaApis.Utf8ToAsciiHex(LuaApis.GetPath()) + @"'
+rootPath = rootPath:gsub('[%s%p]', ''):upper()
+rootPath = rootPath:gsub('%x%x', function(c)
+ return string.char(tonumber(c, 16))
+ end)
+package.path = package.path..
+';'..rootPath..'core_script/?.lua'..
+';'..rootPath..'?.lua'..
+';'..rootPath..'user_script_run/requires/?.lua'
+package.cpath = package.cpath..
+';'..rootPath..'core_script/?.lua'..
+';'..rootPath..'?.lua'..
+';'..rootPath..'user_script_run/requires/?.lua'
+");
+ }
+
+ public void Dispose()
+ {
+ lock (taskLock)
+ {
+ stop = true;
+ lua = null!;
+ foreach (var v in timerPool)
+ try { v.Value.Cancel(); } catch { }
+ timerPool.Clear();
+ while (toRun.TryTake(out _)) ;
+ }
+ StopEvent?.Invoke(null, true);
+ }
+
+ // ---- Embedded LuatOS sys framework ----
+ private static readonly string SysCode = @"
+math.randomseed(tostring(os.time()):reverse():sub(1, 6))
+sys = {}
+local TASK_TIMER_ID_MAX = 0x1FFFFFFF
+local MSG_TIMER_ID_MAX = 0x7FFFFFFF
+local taskTimerId = 0
+local msgId = TASK_TIMER_ID_MAX
+local timerPool = {}
+local taskTimerPool = {}
+local para = {}
+local loop = {}
+function sys.wait(ms)
+ assert(ms > 0, 'The wait time cannot be negative!')
+ while true do
+ if taskTimerId >= TASK_TIMER_ID_MAX - 1 then taskTimerId = 0
+ else taskTimerId = taskTimerId + 1 end
+ if taskTimerPool[taskTimerId] == nil then break end
+ end
+ local timerid = taskTimerId
+ taskTimerPool[coroutine.running()] = timerid
+ timerPool[timerid] = coroutine.running()
+ if 1 ~= _G['@this']:StartTimer(timerid, ms) then print('sys.StartTimer error') return end
+ local message = {coroutine.yield()}
+ if #message ~= 0 then
+ _G['@this']:StopTimer(timerid)
+ taskTimerPool[coroutine.running()] = nil
+ timerPool[timerid] = nil
+ return table.unpack(message)
+ end
+end
+function sys.waitUntil(id, ms)
+ sys.subscribe(id, coroutine.running())
+ local message = ms and {sys.wait(ms)} or {coroutine.yield()}
+ sys.unsubscribe(id, coroutine.running())
+ return message[1] ~= nil, table.unpack(message, 2, #message)
+end
+function sys.waitUntilExt(id, ms)
+ sys.subscribe(id, coroutine.running())
+ local message = ms and {sys.wait(ms)} or {coroutine.yield()}
+ sys.unsubscribe(id, coroutine.running())
+ if message[1] ~= nil then return table.unpack(message) end
+ return false
+end
+function sys.taskInit(fun, ...)
+ local arg = { ... }
+ local co = coroutine.create(fun)
+ assert(coroutine.resume(co, table.unpack(arg)))
+ return co
+end
+local function cmpTable(t1, t2)
+ if not t2 then return #t1 == 0 end
+ if #t1 == #t2 then
+ for i = 1, #t1 do
+ if table.unpack(t1, i, i) ~= table.unpack(t2, i, i) then return false end
+ end
+ return true
+ end
+ return false
+end
+function sys.timerStop(val, ...)
+ local arg = { ... }
+ if type(val) == 'number' then
+ timerPool[val], para[val], loop[val] = nil, nil, nil
+ _G['@this']:StopTimer(val)
+ else
+ for k, v in pairs(timerPool) do
+ if type(v) == 'table' and v.cb == val or v == val then
+ if cmpTable(arg, para[k]) then
+ _G['@this']:StopTimer(k)
+ timerPool[k], para[k], loop[val] = nil, nil, nil
+ break
+ end
+ end
+ end
+ end
+end
+function sys.timerStopAll(fnc)
+ for k, v in pairs(timerPool) do
+ if type(v) == 'table' and v.cb == fnc or v == fnc then
+ _G['@this']:StopTimer(k)
+ timerPool[k], para[k], loop[k] = nil, nil, nil
+ end
+ end
+end
+function sys.timerStart(fnc, ms, ...)
+ local arg = { ... }
+ assert(fnc ~= nil, 'sys.timerStart(first param) is nil !')
+ assert(ms > 0, 'sys.timerStart(Second parameter) is <= zero !')
+ if arg.n == 0 then sys.timerStop(fnc)
+ else sys.timerStop(fnc, table.unpack(arg)) end
+ while true do
+ if msgId >= MSG_TIMER_ID_MAX then msgId = TASK_TIMER_ID_MAX end
+ msgId = msgId + 1
+ if timerPool[msgId] == nil then
+ timerPool[msgId] = fnc
+ break
+ end
+ end
+ if _G['@this']:StartTimer(msgId, ms) ~= 1 then print('@this.StartTimer error') return end
+ if arg.n ~= 0 then para[msgId] = arg end
+ return msgId
+end
+function sys.timerLoopStart(fnc, ms, ...)
+ local arg = { ... }
+ local tid = sys.timerStart(fnc, ms, table.unpack(arg))
+ if tid then loop[tid] = ms end
+ return tid
+end
+function sys.timerIsActive(val, ...)
+ local arg = { ... }
+ if type(val) == 'number' then return timerPool[val]
+ else
+ for k, v in pairs(timerPool) do
+ if v == val then
+ if cmpTable(arg, para[k]) then return true end
+ end
+ end
+ end
+end
+local subscribers = {}
+local messageQueue = {}
+function sys.subscribe(id, callback)
+ if type(id) ~= 'string' or (type(callback) ~= 'function' and type(callback) ~= 'thread') then
+ print('warning: sys.subscribe invalid parameter', id, callback)
+ return
+ end
+ if not subscribers[id] then subscribers[id] = {} end
+ subscribers[id][callback] = true
+end
+function sys.unsubscribe(id, callback)
+ if type(id) ~= 'string' or (type(callback) ~= 'function' and type(callback) ~= 'thread') then
+ print('warning: sys.unsubscribe invalid parameter', id, callback)
+ return
+ end
+ if subscribers[id] then subscribers[id][callback] = nil end
+end
+local function dispatch()
+ while true do
+ if #messageQueue == 0 then break end
+ local message = table.remove(messageQueue, 1)
+ if subscribers[message[1]] then
+ local cbs = {}
+ for callback, _ in pairs(subscribers[message[1]]) do
+ table.insert(cbs,callback)
+ end
+ for _,callback in ipairs(cbs) do
+ if type(callback) == 'function' then
+ callback(table.unpack(message, 2, #message))
+ elseif type(callback) == 'thread' then
+ local r,i = coroutine.resume(callback, table.unpack(message))
+ assert(r,i)
+ end
+ end
+ end
+ end
+end
+function sys.publish(...)
+ local arg = { ... }
+ table.insert(messageQueue, arg)
+ dispatch()
+end
+function sys.tigger(param)
+ if param < TASK_TIMER_ID_MAX then
+ local taskId = timerPool[param]
+ timerPool[param] = nil
+ if taskTimerPool[taskId] == param then
+ taskTimerPool[taskId] = nil
+ local r,i = coroutine.resume(taskId)
+ assert(r,i)
+ end
+ else
+ local cb = timerPool[param]
+ if not loop[param] then timerPool[param] = nil end
+ if not cb then timerPool[param] = nil return end
+ if para[param] ~= nil then
+ cb(table.unpack(para[param]))
+ if not loop[param] then para[param] = nil end
+ else
+ cb()
+ end
+ if loop[param] then _G['@this']:StartTimer(param, loop[param]) end
+ end
+end
+local tiggers = {}
+function sys.tiggerCB(id,t,data)
+ if id >= 0 and t == 'timer' then sys.tigger(id)
+ elseif type(tiggers[t]) == 'function' then tiggers[t](data)
+ end
+end
+function sys.tiggerRegister(t,f)
+ tiggers[t] = f
+end
+";
+}
+
+internal class LuaTaskData
+{
+ public int id { get; set; }
+ public string type { get; set; } = "";
+ public object? data { get; set; }
+}
diff --git a/llcom.Core/LuaEnv/LuaLoader.cs b/llcom.Core/LuaEnv/LuaLoader.cs
new file mode 100644
index 0000000..7135919
--- /dev/null
+++ b/llcom.Core/LuaEnv/LuaLoader.cs
@@ -0,0 +1,146 @@
+using System.Collections;
+using MoonSharp.Interpreter;
+
+namespace llcom.LuaEnv;
+
+///
+/// Lua script loader for the cross-platform Lua environment.
+/// Initializes Lua globals and provides the send/recv script execution pipeline.
+///
+public static class LuaLoader
+{
+ /// Initialize a Lua VM with API bindings.
+ public static void Initial(Script lua, string t = "script")
+ {
+ // Register C# API methods as Lua globals
+ lua.Globals["apiUtf8ToHex"] = (Func)LuaApis.Utf8ToAsciiHex;
+ lua.Globals["apiAscii2Utf8"] = (Func)LuaApis.Ascii2Utf8;
+ lua.Globals["apiGetPath"] = (Func)LuaApis.GetPath;
+ lua.Globals["apiPrintLog"] = (Action)LuaApis.PrintLog;
+ lua.Globals["apiQuickSendList"] = (Func)LuaApis.QuickSendList;
+
+ // InputBox: MoonSharp doesn't support 'out' params well, use Tuple return
+ lua.Globals["apiInputBox"] = (Func)InputBoxWrapper;
+
+ // apiSend: use table param
+ lua.Globals["apiSend"] = (Func)SendWrapper;
+
+ if (t != "send")
+ {
+ lua.Globals["apiStartTimer"] = (Func)LuaRunEnv.StartTimer;
+ lua.Globals["apiStopTimer"] = (Action)LuaRunEnv.StopTimer;
+ }
+
+ // Set up require paths
+ lua.DoString(@"
+local rootPath = '" + LuaApis.Utf8ToAsciiHex(LuaApis.GetPath()) + @"'
+rootPath = rootPath:gsub('[%s%p]', ''):upper()
+rootPath = rootPath:gsub('%x%x', function(c)
+ return string.char(tonumber(c, 16))
+ end)
+package.path = package.path..
+';'..rootPath..'core_script/?.lua'..
+';'..rootPath..'?.lua'..
+';'..rootPath..'user_script_run/requires/?.lua'
+package.cpath = package.cpath..
+';'..rootPath..'core_script/?.lua'..
+';'..rootPath..'?.lua'..
+';'..rootPath..'user_script_run/requires/?.lua'
+");
+
+ // Load head.lua
+ lua.DoString("require 'core_script.head'");
+
+ if (t == "send")
+ {
+ lua.DoString(@"
+local rootPath = apiUtf8ToHex(apiGetPath()):fromHex()
+local script = {}
+_G['!once!'] = function()
+ runLimitStart(3)
+ if not script[_G['!file!']] then
+ script[_G['!file!']] = load(CS.System.IO.File.ReadAllText(_G['!file!']))
+ end
+ local result = script[_G['!file!']]()
+ runLimitStop()
+ return result
+end
+");
+ }
+ }
+
+ private static (bool, string) InputBoxWrapper(string prompt, string defaultInput, string title)
+ {
+ var result = LuaApis.InputBox(prompt, defaultInput, title);
+ return (result, LuaApis.LastInputResult ?? defaultInput);
+ }
+
+ private static bool SendWrapper(string channel, byte[] data, Table table)
+ {
+ var dict = new Dictionary();
+ if (table != null)
+ {
+ foreach (var pair in table.Pairs)
+ {
+ if (pair.Key.Type == DataType.String)
+ dict[pair.Key.String] = pair.Value.ToObject();
+ }
+ }
+ return LuaApis.Send(channel, data, dict);
+ }
+
+ #region Send script runner
+
+ private static Script? luaRunner;
+
+ public static byte[] Run(string file, ArrayList? args = null, string path = "user_script_send_convert/")
+ {
+ var fullPath = llcom.Tools.PlatformHelper.ProfilePath + path + file;
+ if (!File.Exists(fullPath))
+ return Array.Empty();
+
+ if (luaRunner == null)
+ {
+ luaRunner = new Script(CoreModules.Preset_Complete);
+ lock (luaRunner)
+ {
+ luaRunner.Globals["runType"] = "send";
+ Initial(luaRunner, "send");
+ }
+ }
+ lock (luaRunner)
+ {
+ luaRunner.Globals["!file!"] = fullPath;
+ while (luaRunner.Globals.Get("!file!").String != fullPath)
+ luaRunner.Globals["!file!"] = fullPath;
+
+ if (args != null)
+ for (int i = 0; i < args.Count; i += 2)
+ luaRunner.Globals[(string)args[i]!] = args[i + 1];
+
+ try
+ {
+ var f = luaRunner.Globals.Get("!once!");
+ if (f.Type == DataType.Function)
+ {
+ var result = f.Function.Call();
+ if (result.Type == DataType.UserData && result.UserData.Object is byte[] bytes)
+ return bytes;
+ }
+ return Array.Empty();
+ }
+ catch (Exception)
+ {
+ luaRunner = null;
+ throw;
+ }
+ }
+ }
+
+ public static void ClearRun()
+ {
+ luaRunner = null;
+ }
+
+ #endregion
+}
diff --git a/llcom.Core/LuaEnv/LuaRunEnv.cs b/llcom.Core/LuaEnv/LuaRunEnv.cs
new file mode 100644
index 0000000..0530566
--- /dev/null
+++ b/llcom.Core/LuaEnv/LuaRunEnv.cs
@@ -0,0 +1,153 @@
+using System.Collections.Concurrent;
+using MoonSharp.Interpreter;
+
+namespace llcom.LuaEnv;
+
+///
+/// Lua script execution environment for user scripts.
+/// Manages the lifecycle of a user Lua VM instance with channel dispatching.
+///
+public static class LuaRunEnv
+{
+ public static event EventHandler? LuaRunError;
+
+ private static Script? lua;
+ private static CancellationTokenSource? tokenSource;
+ private static readonly ConcurrentDictionary pool = new();
+ private static readonly ConcurrentBag toRun = new();
+ private static DynValue? triggerCB;
+
+ public static bool IsRunning { get; private set; }
+ public static bool CanRun { get; private set; }
+
+ private static void AddTrigger(int id, string type = "timer", byte[]? data = null)
+ {
+ if (!IsRunning) return;
+ toRun.Add(new LuaPool { id = id, type = type, data = data });
+ RunTrigger();
+ }
+
+ /// Execute a Lua code snippet at runtime.
+ public static void RunCommand(string l)
+ {
+ AddTrigger(-1, "cmd", System.Text.Encoding.UTF8.GetBytes(l));
+ }
+
+ /// Dispatch received channel data to Lua.
+ public static void ChannelReceived(string channel, object? data)
+ {
+ if (!IsRunning) return;
+ toRun.Add(new LuaPool { id = -1, type = channel, data = data });
+ RunTrigger();
+ }
+
+ private static void RunTrigger()
+ {
+ if (!CanRun || lua == null) return;
+ lock (lua)
+ {
+ try
+ {
+ while (toRun.TryTake(out var temp))
+ {
+ if (tokenSource?.IsCancellationRequested == true) return;
+ try
+ {
+ triggerCB?.Function.Call(temp.id, temp.type, temp.data ?? DynValue.Nil);
+ }
+ catch (Exception le)
+ {
+ LuaApis.PrintLog("Callback error:\r\n" + le);
+ }
+ if (tokenSource?.IsCancellationRequested == true) return;
+ }
+ }
+ catch (Exception ex)
+ {
+ StopLua(ex.ToString());
+ }
+ }
+ }
+
+ public static int StartTimer(int id, int time)
+ {
+ var timerToken = new CancellationTokenSource();
+ if (pool.TryRemove(id, out var old))
+ try { old.Cancel(); } catch { }
+ pool.TryAdd(id, timerToken);
+
+ var timer = new System.Timers.Timer(time) { AutoReset = false };
+ timer.Elapsed += (_, _) =>
+ {
+ if (timerToken.IsCancellationRequested || !IsRunning) return;
+ pool.TryRemove(id, out _);
+ AddTrigger(id);
+ timer.Dispose();
+ };
+ timer.Start();
+ return 1;
+ }
+
+ public static void StopTimer(int id)
+ {
+ if (pool.TryRemove(id, out var tc))
+ try { tc.Cancel(); } catch { }
+ }
+
+ public static void StopLua(string ex)
+ {
+ LuaRunError?.Invoke(null, EventArgs.Empty);
+ if (!string.IsNullOrEmpty(ex))
+ LuaApis.PrintLog("Lua error:\r\n" + ex);
+ else
+ LuaApis.PrintLog("Lua stopped");
+
+ foreach (var v in pool)
+ try { v.Value.Cancel(); } catch { }
+ IsRunning = false;
+ tokenSource?.Cancel();
+ pool.Clear();
+ lua = null;
+ }
+
+ public static void New(string file)
+ {
+ CanRun = false;
+ IsRunning = true;
+ tokenSource?.Dispose();
+ tokenSource = new CancellationTokenSource();
+
+ var fullPath = llcom.Tools.PlatformHelper.ProfilePath + file;
+ if (!File.Exists(fullPath)) return;
+
+ Task.Run(() =>
+ {
+ while (!CanRun)
+ Task.Delay(100).Wait();
+
+ try
+ {
+ lua = new Script(CoreModules.Preset_Complete);
+ lock (lua)
+ {
+ lua.Globals["runType"] = "script";
+ LuaLoader.Initial(lua);
+ triggerCB = lua.Globals.Get("tiggerCB");
+ var requirePath = file.Replace("/", ".").Substring(0, file.Length - 4);
+ lua.DoString($"require '{requirePath}'");
+ }
+ }
+ catch (Exception ex)
+ {
+ StopLua(ex.ToString());
+ }
+ }, tokenSource.Token);
+ }
+}
+
+internal class LuaPool
+{
+ public int id { get; set; }
+ public string type { get; set; } = "";
+ public object? data { get; set; }
+}
diff --git a/llcom.Core/Model/LinePlotPoint.cs b/llcom.Core/Model/LinePlotPoint.cs
new file mode 100644
index 0000000..a386f95
--- /dev/null
+++ b/llcom.Core/Model/LinePlotPoint.cs
@@ -0,0 +1,7 @@
+namespace llcom.Model;
+
+public class LinePlotPoint
+{
+ public double N { get; set; }
+ public int Line { get; set; }
+}
diff --git a/llcom.Core/Model/OnlineScript.cs b/llcom.Core/Model/OnlineScript.cs
new file mode 100644
index 0000000..60df8c6
--- /dev/null
+++ b/llcom.Core/Model/OnlineScript.cs
@@ -0,0 +1,51 @@
+using System.Text.RegularExpressions;
+
+namespace llcom.Model;
+
+public class OnlineScript
+{
+ /// 作者
+ public string Author { get; set; } = "";
+ /// 脚本名
+ public string Name { get; set; } = "";
+ /// 简介
+ public string Description { get; set; } = "";
+ /// 版本
+ public int Version { get; set; }
+ /// 备注
+ public string Note { get; set; } = "";
+ /// 脚本内容
+ public string Script { get; set; } = "";
+ /// 脚本网址
+ public string? Url { get; set; }
+
+ /// 从GitHub markdown数据解析脚本
+ public OnlineScript(string body)
+ {
+ body = body.Replace("\r", "");
+ var regStr =
+ "- *(?.+?)\n" +
+ "- *(?.+?)\n" +
+ "- *(?.+?)\n" +
+ "- *(?.+?)\n" +
+ "- *(?.+?)\n" +
+ "\n*" +
+ "```lua\n(?