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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +