diff --git a/KitX Dashboard Core/KitX.Dashboard.Core.csproj b/KitX Dashboard Core/KitX.Dashboard.Core.csproj deleted file mode 100644 index 890ca316..00000000 --- a/KitX Dashboard Core/KitX.Dashboard.Core.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - - Exe - KitX.Dashboard.Core - net8.0 - enable - enable - - - \ No newline at end of file diff --git a/KitX Dashboard Core/Program.cs b/KitX Dashboard Core/Program.cs deleted file mode 100644 index 83fa4f4d..00000000 --- a/KitX Dashboard Core/Program.cs +++ /dev/null @@ -1,2 +0,0 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); diff --git a/KitX Dashboard/App.axaml b/KitX Dashboard/App.axaml index b6524624..6992febd 100644 --- a/KitX Dashboard/App.axaml +++ b/KitX Dashboard/App.axaml @@ -1,75 +1,79 @@ - - - - - - - - - - - - - - - - - - - - - - - - - avares://KitX.Dashboard.Fonts/sarasa-mono-cl-regular.ttf#Sarasa Mono CL - avares://KitX.Dashboard.Fonts/SourceHanSans-VF.ttf#Source Han Sans VF - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + avares://KitX.Dashboard.Fonts/sarasa-mono-cl-regular.ttf#Sarasa Mono CL + avares://KitX.Dashboard.Fonts/SourceHanSans-VF.ttf#Source Han Sans VF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KitX Dashboard/App.axaml.cs b/KitX Dashboard/App.axaml.cs index b2c31810..c8fce341 100644 --- a/KitX Dashboard/App.axaml.cs +++ b/KitX Dashboard/App.axaml.cs @@ -10,23 +10,115 @@ using Avalonia.Media.Imaging; using Avalonia.Styling; using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Managers; +using KitX.Core.Announcement; +using KitX.Core.Contract.Announcement; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Event; using KitX.Dashboard.Services; using KitX.Dashboard.ViewModels; +using KitX.Dashboard.ViewModels.Pages.Controls; using KitX.Dashboard.Views; using LiveChartsCore; using LiveChartsCore.SkiaSharpView; +using Microsoft.Extensions.DependencyInjection; using Serilog; +using ServiceHost = KitX.Core.DI.ServiceHost; + namespace KitX.Dashboard; public partial class App : Application { + /// + /// Initialize DI container before UI framework starts + /// This should be called from AppFramework.RunFramework() before any UI code runs + /// Note: This is now called BEFORE Logger initialization (Phase 2 refactoring). + /// Log.Debug() calls in service constructors (e.g., ConfigManager) are no-ops + /// until Serilog Logger is configured later in RunFramework(). + /// + internal static void InitializeServiceProvider() + { + if (ServiceHost.IsInitialized) + return; + + Log.Information("Initializing service provider..."); + + // Initialize service provider with Core services + var services = new ServiceCollection(); + + // Register Core services from KitX.Core + services.AddCoreServices(); + + // Register Dashboard-specific services + services.AddSingleton(); + + // Register SignalTasksManager for signal-based coordination + services.AddSingleton(); + + // Register Dashboard ViewModels (for DI auto-resolution without ActivatorUtilities fallback) + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Build the SINGLE IServiceProvider — no duplicate BuildServiceProvider calls + var provider = services.BuildServiceProvider(); + + // Initialize ServiceHost with the single provider (centralized service access) + ServiceHost.Initialize(provider); + + // Initialize the workflow library's own service locator with the same provider, + // so workflow code created outside DI (builtin functions, lazy singletons) can + // resolve shared services (IPluginService, IDeviceServer, workflow services, ...). + KitX.Workflow.Hosting.ServiceLocator.Initialize(provider); + + // Pre-resolve the plugin manager bridge to force eager singleton construction + // (the concrete RealPluginManager subscribes to plugin events in its ctor). + var rpm = provider.GetRequiredService(); + Log.Information("RealPluginManager pre-resolved. HashCode: {HashCode}", rpm.GetHashCode()); + + // Initialize TriggerManager from persisted workflow configurations + var triggerManager = provider.GetRequiredService(); + triggerManager.InitializeFromPersistedWorkflows(); + + Log.Information("Service provider initialized."); + } + + /// + /// Gets service from DI container. + /// Throws if the service is not registered — all types must be explicitly registered. + /// + public static T GetService() where T : class + { + Log.Debug($"Getting service: {typeof(T).Name}"); + + if (!ServiceHost.IsInitialized) + { + Log.Warning("ServiceHost not initialized, initializing now (this should not happen in normal flow)..."); + InitializeServiceProvider(); + } + + var service = ServiceHost.ServiceProvider.GetService(typeof(T)); + if (service != null) + return (T)service; + + // Service not registered — throw to make missing registrations visible at runtime + throw new InvalidOperationException( + $"Service '{typeof(T).Name}' is not registered in the DI container. " + + "Ensure it is added via services.AddSingleton/AddTransient/AddScoped in InitializeServiceProvider()." + ); + } + public static Bitmap? DefaultIcon { get { - var path = Path.Combine(ConstantTable.AssetsPath, ConfigManager.Instance.AppConfig.App.CoverIconFileName).GetFullPath(); + var configService = GetService(); + var path = Path.Combine(ConstantTable.AssetsPath, configService.AppConfig.App.CoverIconFileName).GetFullPath(); if (Design.IsDesignMode) return null; @@ -57,7 +149,8 @@ public override void Initialize() private void LoadTheme() { - RequestedThemeVariant = ConfigManager.Instance.AppConfig.App.Theme switch + var configService = GetService(); + RequestedThemeVariant = configService.AppConfig.App.Theme switch { "Light" => ThemeVariant.Light, "Dark" => ThemeVariant.Dark, @@ -68,7 +161,8 @@ private void LoadTheme() private void LoadLanguage() { - var config = ConfigManager.Instance.AppConfig; + var configService = GetService(); + var config = configService.AppConfig; var lang = config.App.AppLanguage; var backup_lang = config.App.SurpportLanguages.Keys.First(); var path = $"{ConstantTable.LanguageFilePath}/{lang}.axaml".GetFullPath(); @@ -106,7 +200,8 @@ private void LoadLanguage() try { - EventService.Invoke(nameof(EventService.LanguageChanged)); + var eventService = GetService(); + eventService.Publish(EventNames.LanguageChanged, EventArgs.Empty); } catch (Exception e) { @@ -116,7 +211,8 @@ private void LoadLanguage() private static void CalculateThemeColor() { - Color c = Color.Parse(ConfigManager.Instance.AppConfig.App.ThemeColor); + var configService = GetService(); + Color c = Color.Parse(configService.AppConfig.App.ThemeColor); if (Current is not null) { @@ -147,7 +243,8 @@ private static void InitializeLiveCharts() ); } - EventService.ThemeConfigChanged += () => + var eventService = GetService(); + eventService.Subscribe(EventNames.ThemeConfigChanged, (s, e) => { var usingLightTheme = Current?.ActualThemeVariant == ThemeVariant.Light; @@ -155,7 +252,7 @@ private static void InitializeLiveCharts() { config = usingLightTheme ? config.AddLightTheme() : config.AddDarkTheme(); }); - }; + }); } public override void OnFrameworkInitializationCompleted() @@ -167,8 +264,12 @@ public override void OnFrameworkInitializationCompleted() desktop.MainWindow = new MainWindow { DataContext = new MainWindowViewModel() }; } - if (ConfigManager.Instance.AppConfig.App.ShowAnnouncementWhenStart) - new Thread(async () => await AnnouncementManager.CheckNewAnnouncements()).Start(); + var configService = GetService(); + if (configService.AppConfig.App.ShowAnnouncementWhenStart) + { + var announcementService = GetService(); + new Thread(async () => await announcementService.CheckNewAnnouncementsAsync()).Start(); + } base.OnFrameworkInitializationCompleted(); } diff --git a/KitX Dashboard/AppFramework.cs b/KitX Dashboard/AppFramework.cs index caa0586a..3d7540df 100644 --- a/KitX Dashboard/AppFramework.cs +++ b/KitX Dashboard/AppFramework.cs @@ -11,13 +11,24 @@ using CommandLine; using Common.BasicHelper.IO; using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Managers; +using KitX.Core.Activity; +using KitX.Core.Configuration; +using KitX.Core.Contract.Activity; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Device; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Statistics; +using KitX.Core.Plugin; +using KitX.Core.Statistics; +using KitX.Core.Contract.Tasks; using KitX.Dashboard.Names; using KitX.Dashboard.Options; -using KitX.Dashboard.Views; +using KitX.Dashboard.Services; using LiteDB; using ReactiveUI; using Serilog; +using Serilog.Events; +using System.Text.Json; namespace KitX.Dashboard; @@ -25,6 +36,11 @@ public static class AppFramework { private static readonly Queue actionsInInitialization = []; + /// + /// Signal event for graceful exit — replaces busy-wait loop in EnsureExit. + /// + private static readonly ManualResetEventSlim _exitCompleteEvent = new(false); + public static void ProcessStartupArguments() { Parser @@ -35,14 +51,13 @@ public static void ProcessStartupArguments() ConstantTable.EnabledConfigFileHotReload = !opt.DisableConfigHotReload; ConstantTable.SkipNetworkSystemOnStartup = opt.DisableNetworkSystemOnStartup; - TasksManager.RunTask( + App.GetService().RunTask( () => { if (opt.PluginPath is not null) ImportPlugin(opt.PluginPath); }, - $"{nameof(ImportPlugin)}", - catchException: true + taskName: $"{nameof(ImportPlugin)}" ); }); } @@ -52,6 +67,78 @@ public static void RunFramework() if (Design.IsDesignMode) return; + // Step 1: Initialize DI container first + App.InitializeServiceProvider(); + + // Step 2: Read LogLevel directly from config file (before full load, + // so the logger can capture any deserialization errors during Load). + var logLevel = LogEventLevel.Information; + try + { + var cfgPath = Path.GetFullPath(Path.Combine("./Config/", "AppConfig.json")); + if (File.Exists(cfgPath)) + { + var finfo = new FileInfo(cfgPath); + var trailPath = Path.Combine(finfo.DirectoryName!, "ConfigLoadTrail.log"); + File.AppendAllText(trailPath, $"[{DateTime.Now:O}] RunFramework START: file size={finfo.Length}, lastWrite={finfo.LastWriteTime:O}\n"); + + using var doc = JsonDocument.Parse(File.ReadAllText(cfgPath)); + if (doc.RootElement.TryGetProperty("Log", out var log) && + log.TryGetProperty("LogLevel", out var level)) + logLevel = (LogEventLevel)level.GetInt32(); + } + } + catch { } + + // Step 3: Configure logger before Load() so Load errors are visible + var logdir = "./Log/".GetFullPath(); + if (!Directory.Exists(logdir)) Directory.CreateDirectory(logdir); + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Is(logLevel) + .WriteTo.File( + $"{logdir}Log_.log", + outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}", + rollingInterval: RollingInterval.Hour, + fileSizeLimitBytes: 10 * 1024 * 1024, + buffered: true, + flushToDiskInterval: new(0, 0, 30), + restrictedToMinimumLevel: logLevel, + rollOnFileSizeLimit: true, + retainedFileCountLimit: 50 + ) + .CreateLogger(); + + // Step 4: Full config load (with logger now active — errors are visible) + Log.Information($"[AppFramework] About to call configService.Load(), temp LogLevel={logLevel}"); + var configService = App.GetService(); + configService.Load(); + var config = (AppConfig)configService.AppConfig; + Log.Information($"[AppFramework] Load complete, LogLevel={config.Log.LogLevel}"); + + // Step 5: Reconfigure logger with full settings from loaded config + var configuredLogDir = config.Log.LogFilePath.GetFullPath(); + if (!Directory.Exists(configuredLogDir)) + Directory.CreateDirectory(configuredLogDir); + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Is(config.Log.LogLevel) + .WriteTo.Console(outputTemplate: config.Log.LogTemplate, restrictedToMinimumLevel: config.Log.LogLevel) + .WriteTo.File( + $"{configuredLogDir}Log_.log", + outputTemplate: config.Log.LogTemplate, + rollingInterval: RollingInterval.Hour, + fileSizeLimitBytes: config.Log.LogFileSingleMaxSize, + buffered: true, + flushToDiskInterval: new(0, 0, config.Log.LogFileFlushInterval), + restrictedToMinimumLevel: config.Log.LogLevel, + rollOnFileSizeLimit: true, + retainedFileCountLimit: config.Log.LogFileMaxCount + ) + .CreateLogger(); + + Log.Information("KitX Dashboard Started."); + // If dump file exists, delete it. if (File.Exists("./dump.log".GetFullPath())) File.Delete("./dump.log".GetFullPath()); @@ -73,9 +160,7 @@ public static void RunFramework() File.Delete("restart.lock"); } - ConfigManager.Instance.AppConfig.App.RanTime++; - - var config = ConfigManager.Instance.AppConfig; + ((AppConfig)configService.AppConfig).App.RanTime++; ProcessStartupArguments(); @@ -90,34 +175,6 @@ public static void RunFramework() LoadResource(); - #region Initialize log system - - var logdir = config.Log.LogFilePath.GetFullPath(); - - if (!Directory.Exists(logdir)) - Directory.CreateDirectory(logdir); - - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() - .WriteTo.File( - $"{logdir}Log_.log", - outputTemplate: config.Log.LogTemplate, - rollingInterval: RollingInterval.Hour, - fileSizeLimitBytes: config.Log.LogFileSingleMaxSize, - buffered: true, - flushToDiskInterval: new(0, 0, config.Log.LogFileFlushInterval), - restrictedToMinimumLevel: config.Log.LogLevel, - rollOnFileSizeLimit: true, - retainedFileCountLimit: config.Log.LogFileMaxCount - ) - .CreateLogger(); - - Log.Information("KitX Dashboard Started."); - - #endregion - - Instances.Initialize(); - #region Initialize global exception catching AppDomain.CurrentDomain.UnhandledException += (sender, e) => @@ -140,24 +197,64 @@ public static void RunFramework() #region Initialize DataBase + // TODO: [Architecture] Database initialization should be moved to Core Activity module. + // Currently kept here because LiteDatabase instance needs to be set on both + // ActivityManager.ActivitiesDatabase before DI services use it. InitDataBase(); #endregion #region Initialize WebManager - Instances.SignalTasksManager!.SignalRun( + // TODO: [Architecture] Network service startup should be managed through a Core-level + // INetworkService or similar unified service, rather than orchestrating individual servers here. + var signalTasksManager = App.GetService(); + signalTasksManager.SignalRun( nameof(SignalsNames.MainWindowInitSignal), () => { new Thread(async () => { - Thread.Sleep(Convert.ToInt32(config.Web.DelayStartSeconds * 1000)); - - if (ConstantTable.SkipNetworkSystemOnStartup) - Instances.WebManager = new(); - else - Instances.WebManager = await new WebManager().RunAsync(new()); + try + { + Thread.Sleep(Convert.ToInt32(config.Web.DelayStartSeconds * 1000)); + + if (!ConstantTable.SkipNetworkSystemOnStartup) + { + // Use DI services instead of WebManager + var discoveryServer = App.GetService() as KitX.Core.Device.DevicesDiscoveryServer; + var devicesServer = App.GetService() as KitX.Core.Device.DevicesServer; + var pluginsServer = App.GetService() as KitX.Core.Device.PluginsServer; + + if (discoveryServer != null) + { + discoveryServer.ConfigurePort((int)(config.Web.UserSpecifiedDevicesServerPort ?? 0)); + discoveryServer.Run(); + + // DevicesOrganizer is now a DI-registered singleton, auto-initialized via constructor + // No need to call Run() - it starts observing on construction + var organizer = App.GetService(); + } + + if (devicesServer != null) + { + devicesServer.ConfigurePort((int)(config.Web.UserSpecifiedPluginsServerPort ?? 0)); + devicesServer.Run(); + } + + if (pluginsServer != null) + { + // ServiceHost ensures all resolution paths return the same singleton + Log.Information("[AppFramework] About to call PluginsServer.Run(). PluginsServer HashCode: {HashCode}", pluginsServer.GetHashCode()); + pluginsServer.ConfigurePort((int)(config.Web.UserSpecifiedPluginsServerPort ?? 0)); + pluginsServer.Run(); + } + } + } + catch (Exception ex) + { + Log.Error(ex, $"In {nameof(AppFramework)}.NetworkStartup: {ex.Message}"); + } }).Start(); } ); @@ -166,19 +263,19 @@ public static void RunFramework() #region Initialize StatisticsManager - StatisticsManager.Start(); + App.GetService().Start(); #endregion #region Initialize persistent windows - Instances.SignalTasksManager.SignalRun( + signalTasksManager.SignalRun( nameof(SignalsNames.MainWindowInitSignal), () => { Dispatcher.UIThread.Post(() => { - ViewInstances.PluginsLaunchWindow = new(); + UIStateService.PluginsLaunchWindow = new(); }); } ); @@ -203,9 +300,10 @@ private static void InitDataBase() var db = new LiteDatabase(dbfile); - Instances.ActivitiesDataBase = db; + // Set the database for Core ActivityManager + KitX.Core.Activity.ActivityManager.ActivitiesDatabase = db; - ActivityManager.RecordAppStart(); + App.GetService().RecordAppStart(); } catch (Exception ex) { @@ -231,7 +329,7 @@ private static async void LoadResource() public static void AfterInitailization(Action action) => actionsInInitialization.Enqueue(action); - private static void ImportPlugin(string kxpPath) + private static async void ImportPlugin(string kxpPath) { const string location = $"{nameof(AppFramework)}.{nameof(ImportPlugin)}"; @@ -245,7 +343,7 @@ private static void ImportPlugin(string kxpPath) } else { - PluginsManager.ImportPlugin([kxpPath]); + await App.GetService().ImportPluginAsync(kxpPath); } } catch (Exception ex) @@ -262,24 +360,28 @@ public static void EnsureExit() const string location = $"{nameof(AppFramework)}.{nameof(EnsureExit)}"; ConstantTable.EnsureExiting = true; + _exitCompleteEvent.Reset(); new Thread(async () => { try { - ActivityManager.RecordAppExit(); + App.GetService().RecordAppExit(); - Instances.FileWatcherManager?.Clear(); + App.GetService()?.Clear(); - ConfigManager.Instance.SaveAll(); + App.GetService().SaveAll(); Log.CloseAndFlush(); - if (Instances.WebManager is not null) - await Instances.WebManager.CloseAsync(new()); + // Use DI services instead of WebManager + var pluginsServer = App.GetService() as KitX.Core.Device.PluginsServer; + var devicesDiscoveryServer = App.GetService() as KitX.Core.Device.DevicesDiscoveryServer; + var devicesServer = App.GetService() as KitX.Core.Device.DevicesServer; - Instances.ActivitiesDataBase?.Commit(); - Instances.ActivitiesDataBase?.Dispose(); + pluginsServer?.Stop(); + devicesServer?.Stop(); + devicesDiscoveryServer?.Stop(); ConstantTable.Running = false; @@ -293,18 +395,19 @@ public static void EnsureExit() Process.Start(path); } - Thread.Sleep(ConfigManager.Instance.AppConfig.App.LastBreakAfterExit); + Thread.Sleep(App.GetService().AppConfig.App.LastBreakAfterExit); ConstantTable.EnsureExiting = false; + _exitCompleteEvent.Set(); } catch (Exception ex) { Log.Error(ex, $"In {location}: {ex.Message}"); + _exitCompleteEvent.Set(); } }).Start(); - while (ConstantTable.EnsureExiting) - ; + _exitCompleteEvent.Wait(); Environment.Exit(0); } diff --git a/KitX Dashboard/Configuration/AnnouncementConfig.cs b/KitX Dashboard/Configuration/AnnouncementConfig.cs deleted file mode 100644 index 24865c04..00000000 --- a/KitX Dashboard/Configuration/AnnouncementConfig.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Collections.Generic; - -namespace KitX.Dashboard.Configuration; - -public class AnnouncementConfig : ConfigBase -{ - public List Accepted { get; set; } = []; -} diff --git a/KitX Dashboard/Configuration/AppConfig.cs b/KitX Dashboard/Configuration/AppConfig.cs deleted file mode 100644 index c7a0cd47..00000000 --- a/KitX Dashboard/Configuration/AppConfig.cs +++ /dev/null @@ -1,247 +0,0 @@ -using System.Collections.Generic; -using Avalonia.Controls; -using Common.BasicHelper.Graphics.Screen; -using FluentAvalonia.UI.Controls; -using KitX.Dashboard.Configuration.Interfaces; -using Serilog.Events; - -namespace KitX.Dashboard.Configuration; - -public class AppConfig : ConfigBase -{ - public Config_App App { get; set; } = new(); - - public Config_Windows Windows { get; set; } = new(); - - public Config_Pages Pages { get; set; } = new(); - - public Config_Web Web { get; set; } = new(); - - public Config_Log Log { get; set; } = new(); - - public Config_IO IO { get; set; } = new(); - - public Config_Activity Activity { get; set; } = new(); - - public Config_Loaders Loaders { get; set; } = new(); - - public class Config_App - { - public string IconFileName { get; set; } = "KitX-Icon-1920x-margin-2x.png"; - - public string CoverIconFileName { get; set; } = "KitX-Icon-Background.png"; - - public string AppLanguage { get; set; } = "zh-cn"; - - public string Theme { get; set; } = "Follow"; - - public string ThemeColor { get; set; } = "#FF3873D9"; - - public Dictionary SurpportLanguages { get; set; } = - new() - { - { "zh-cn", "中文 (简体)" }, - { "zh-tw", "中文 (繁體)" }, - { "ru-ru", "Русский" }, - { "en-us", "English (US)" }, - { "fr-fr", "Français" }, - { "ja-jp", "日本語" }, - { "ko-kr", "한국어" }, - }; - - public string LocalPluginsFileFolder { get; set; } = "./Plugins/"; - - public string LocalPluginsDataFolder { get; set; } = "./PluginsDatas/"; - - public bool DeveloperSetting { get; set; } = false; - - public bool ShowAnnouncementWhenStart { get; set; } = true; - - public ulong RanTime { get; set; } = 0; - - public int LastBreakAfterExit { get; set; } = 2000; - } - - public class Config_Windows - { - public Config_MainWindow MainWindow { get; set; } = new(); - - public Config_AnnouncementWindow AnnouncementWindow { get; set; } = new(); - - public class Config_MainWindow : IWindowConfig - { - public Resolution Size { get; set; } = Resolution.Parse("1280x720"); - - public Distances Location { get; set; } = new(left: -1, top: -1); - - public WindowState WindowState { get; set; } = WindowState.Normal; - - public bool IsHidden { get; set; } = false; - - public Dictionary Tags { get; set; } = new() { { "SelectedPage", "Page_Home" } }; - - public bool EnabledMica { get; set; } = true; - - public int GreetingTextCount_Morning { get; set; } = 5; - - public int GreetingTextCount_Noon { get; set; } = 3; - - public int GreetingTextCount_AfterNoon { get; set; } = 3; - - public int GreetingTextCount_Evening { get; set; } = 2; - - public int GreetingTextCount_Night { get; set; } = 4; - - public int GreetingUpdateInterval { get; set; } = 10; - } - - public class Config_AnnouncementWindow : IWindowConfig - { - public Resolution Size { get; set; } = Resolution.Parse("1280x720"); - - public Distances Location { get; set; } = new(left: -1, top: -1); - } - } - - public class Config_Pages - { - public Config_HomePage Home { get; set; } = new(); - - public Config_DevicePage Device { get; set; } = new(); - - public Config_MarketPage Market { get; set; } = new(); - - public Config_SettingsPage Settings { get; set; } = new(); - - public class Config_HomePage - { - public NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { get; set; } = NavigationViewPaneDisplayMode.Auto; - - public string SelectedViewName { get; set; } = "View_Recent"; - - public bool IsNavigationViewPaneOpened { get; set; } = true; - - public bool UseAreaExpanded { get; set; } = true; - } - - public class Config_DevicePage { } - - public class Config_MarketPage { } - - public class Config_SettingsPage - { - public NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { get; set; } = NavigationViewPaneDisplayMode.Auto; - - public string SelectedViewName { get; set; } = "View_General"; - - public bool PaletteAreaExpanded { get; set; } = false; - - public bool WebRelatedAreaExpanded { get; set; } = true; - - public bool WebRelatedAreaOfNetworkInterfacesExpanded { get; set; } = false; - - public bool LogRelatedAreaExpanded { get; set; } = true; - - public bool UpdateRelatedAreaExpanded { get; set; } = true; - - public bool AboutAreaExpanded { get; set; } = false; - - public bool AuthorsAreaExpanded { get; set; } = false; - - public bool LinksAreaExpanded { get; set; } = false; - - public bool ThirdPartyLicensesAreaExpanded { get; set; } = false; - - public bool IsNavigationViewPaneOpened { get; set; } = true; - } - } - - public class Config_Web - { - public double DelayStartSeconds { get; set; } = 0.5; - - public string ApiServer { get; set; } = "api.catrol.cn"; - - public string ApiPath { get; set; } = "/apps/kitx/"; - - public int DevicesViewRefreshDelay { get; set; } = 1000; - - public List? AcceptedNetworkInterfaces { get; set; } = null; - - public int? UserSpecifiedDevicesServerPort { get; set; } = null; - - public int? UserSpecifiedPluginsServerPort { get; set; } = null; - - public int UdpPortSend { get; set; } = 23404; - - public int UdpPortReceive { get; set; } = 24040; - - public int UdpSendFrequency { get; set; } = 1000; - - public string UdpBroadcastAddress { get; set; } = "224.0.0.0"; - - public string IPFilter { get; set; } = "192.168"; - - public int SocketBufferSize { get; set; } = 1024 * 100; - - public int DeviceInfoTTLSeconds { get; set; } = 7; - - public bool DisableRemovingOfflineDeviceCard { get; set; } = false; - - public string UpdateServer { get; set; } = "api.catrol.cn"; - - public string UpdatePath { get; set; } = "/apps/kitx/%platform%/"; - - public string UpdateDownloadPath { get; set; } = "/apps/kitx/update/%platform%/"; - - /// - /// %channel% - Stable, Beta, Alpha (stable, beta, alpha) - /// - - public string UpdateChannel { get; set; } = "stable"; - - public string UpdateSource { get; set; } = "latest-components.json"; - - public int DebugServicesServerPort { get; set; } = 7777; - } - - public class Config_Log - { - public long LogFileSingleMaxSize { get; set; } = 1024 * 1024 * 10; // 10MB - - public string LogFilePath { get; set; } = "./Log/"; - - public string LogTemplate { get; set; } = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}"; - - public int LogFileMaxCount { get; set; } = 50; - - public int LogFileFlushInterval { get; set; } = 30; - -#if DEBUG - - public LogEventLevel LogLevel { get; set; } = LogEventLevel.Information; - -#else - - public LogEventLevel LogLevel { get; set; } = LogEventLevel.Warning; - -#endif - } - - public class Config_IO - { - public int UpdatingCheckPerThreadFilesCount { get; set; } = 20; - - public int OperatingSystemVersionUpdateInterval { get; set; } = 60; - } - - public class Config_Activity - { - public int TotalRecorded { get; set; } = 0; - } - - public class Config_Loaders - { - public string InstallPath { get; set; } = "./Loaders/"; - } -} diff --git a/KitX Dashboard/Configuration/ConfigBase.cs b/KitX Dashboard/Configuration/ConfigBase.cs deleted file mode 100644 index 24f379c8..00000000 --- a/KitX Dashboard/Configuration/ConfigBase.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; -using System.IO; -using System.Text.Json; -using System.Text.Json.Serialization; -using Common.BasicHelper.Utils.Extensions; - -namespace KitX.Dashboard.Configuration; - -[JsonPolymorphic(UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)] -[JsonDerivedType(typeof(ConfigBase), typeDiscriminator: nameof(ConfigBase))] -[JsonDerivedType(typeof(AppConfig), typeDiscriminator: nameof(AppConfig))] -[JsonDerivedType(typeof(PluginsConfig), typeDiscriminator: nameof(PluginsConfig))] -[JsonDerivedType(typeof(MarketConfig), typeDiscriminator: nameof(MarketConfig))] -[JsonDerivedType(typeof(AnnouncementConfig), typeDiscriminator: nameof(AnnouncementConfig))] -[JsonDerivedType(typeof(SecurityConfig), typeDiscriminator: nameof(SecurityConfig))] -public class ConfigBase -{ - public string? ConfigFileLocation { get; set; } - - public string? ConfigFileWatcherName { get; set; } - - public DateTime? ConfigGeneratedTime { get; set; } = DateTime.Now; -} - -public static class ConfigBaseExtensions -{ - private static readonly object _configReadWriteLock = new(); - - private static readonly JsonSerializerOptions serializationOptions = new() - { - WriteIndented = true, - IncludeFields = true, - PropertyNameCaseInsensitive = true, - }; - - public static T Load(this string path) - where T : ConfigBase, new() - { - path = path.GetFullPath(); - - if (!File.Exists(path)) - { - var dir = Path.GetDirectoryName(path); - - if (!Directory.Exists(dir) && dir is not null) - Directory.CreateDirectory(dir); - - var conf = new T().Save(path); - - return conf; - } - - string text; - - lock (_configReadWriteLock) - { - text = File.ReadAllText(path); - } - - var result = JsonSerializer.Deserialize(text, serializationOptions); - - return result as T ?? throw new Exception("Can not deserialize config file."); - } - - public static T Save(this T config, string path) - where T : ConfigBase - { - path = path.GetFullPath(); - - lock (_configReadWriteLock) - { - var text = JsonSerializer.Serialize(config, serializationOptions); - - File.WriteAllText(path, text); - } - - return config; - } - - public static T SetConfigFileLocation(this T config, string path) - where T : ConfigBase - { - config.ConfigFileLocation = path; - - return config; - } -} diff --git a/KitX Dashboard/Configuration/ConfigFetcher.cs b/KitX Dashboard/Configuration/ConfigFetcher.cs deleted file mode 100644 index 223ae304..00000000 --- a/KitX Dashboard/Configuration/ConfigFetcher.cs +++ /dev/null @@ -1,16 +0,0 @@ -using KitX.Dashboard.Managers; - -namespace KitX.Dashboard.Configuration; - -public class ConfigFetcher -{ - public static AppConfig AppConfig => ConfigManager.Instance.AppConfig; - - public static AnnouncementConfig AnnouncementConfig => ConfigManager.Instance.AnnouncementConfig; - - public static MarketConfig MarketConfig => ConfigManager.Instance.MarketConfig; - - public static PluginsConfig PluginsConfig => ConfigManager.Instance.PluginsConfig; - - public static SecurityConfig SecurityConfig => ConfigManager.Instance.SecurityConfig; -} diff --git a/KitX Dashboard/Configuration/Interfaces/IWindowConfig.cs b/KitX Dashboard/Configuration/Interfaces/IWindowConfig.cs deleted file mode 100644 index 167adb19..00000000 --- a/KitX Dashboard/Configuration/Interfaces/IWindowConfig.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Common.BasicHelper.Graphics.Screen; - -namespace KitX.Dashboard.Configuration.Interfaces; - -public interface IWindowConfig -{ - public Resolution Size { get; set; } - - public Distances Location { get; set; } -} diff --git a/KitX Dashboard/Configuration/MarketConfig.cs b/KitX Dashboard/Configuration/MarketConfig.cs deleted file mode 100644 index 5c4945cb..00000000 --- a/KitX Dashboard/Configuration/MarketConfig.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; - -namespace KitX.Dashboard.Configuration; - -public class MarketConfig : ConfigBase -{ - public Dictionary Sources { get; set; } = - new() { { "KitX Official Market Source", "https://cget.catrol.cn/KitX/v1/index.json" } }; -} diff --git a/KitX Dashboard/Configuration/PluginsConfig.cs b/KitX Dashboard/Configuration/PluginsConfig.cs deleted file mode 100644 index 9e22b4da..00000000 --- a/KitX Dashboard/Configuration/PluginsConfig.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; -using KitX.Dashboard.Models; - -namespace KitX.Dashboard.Configuration; - -public class PluginsConfig : ConfigBase -{ - public List Plugins { get; set; } = []; -} diff --git a/KitX Dashboard/Configuration/SecurityConfig.cs b/KitX Dashboard/Configuration/SecurityConfig.cs deleted file mode 100644 index 7b283ed8..00000000 --- a/KitX Dashboard/Configuration/SecurityConfig.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; -using KitX.Shared.CSharp.Device; - -namespace KitX.Dashboard.Configuration; - -public class SecurityConfig : ConfigBase -{ - public List DeviceKeys { get; set; } = []; -} diff --git a/KitX Dashboard/ConstantTable.cs b/KitX Dashboard/ConstantTable.cs index 704ad454..e53574f5 100644 --- a/KitX Dashboard/ConstantTable.cs +++ b/KitX Dashboard/ConstantTable.cs @@ -1,65 +1,133 @@ -using System; +using System; using Common.BasicHelper.Utils.Extensions; namespace KitX.Dashboard; +/// +/// Dashboard-specific constants. +/// General constants are delegated to . +/// internal static class ConstantTable { - internal const string AppName = "KitX"; - - internal const string AppFullName = "KitX Dashboard"; - - internal const string DataPath = "./Data/"; - - internal const string LanguageFilePath = "./Languages/"; - - internal const string AssetsPath = "./Assets/"; - - internal const string UpdateSavePath = "./Update/"; - - internal const string IconBase64FileName = "KitX.Base64.txt"; - - private const string activitiesDataBaseFilePath = $"{DataPath}Activities.db"; - - private const string thirdPartyLicenseFilePath = $"{AssetsPath}ThirdPartyLicense.md"; - - internal static string ActivitiesDataBaseFilePath => activitiesDataBaseFilePath.GetFullPath(); - - internal static string ThirdPartyLicenseFilePath => thirdPartyLicenseFilePath.GetFullPath(); - - internal static bool IsExchangingDeviceKey = false; - - internal static string? ExchangeDeviceKeyCode; - - internal static int DevicesServerPort = -1; - - internal static int PluginsServerPort = -1; - - internal static bool Running = true; - - internal static bool Exiting = false; - - internal static bool Restarting = false; - - internal static bool EnsureExiting = false; - - internal static bool IsMainMachine = false; - - internal static string? MainMachineAddress; - - internal static int MainMachinePort = -1; - - internal static bool SkipNetworkSystemOnStartup = false; - - internal static DateTime ServerBuildTime = new(); - - internal const string ApiGetAnnouncements = "get-announcements.php"; - - internal const string ApiGetAnnouncement = "get-announcement.php"; - - internal static string KitXIconBase64 = string.Empty; - - internal static bool IsSingleProcessStartMode = true; - - internal static bool EnabledConfigFileHotReload = true; + // ────────────────────────────────────────────── + // Delegated to KitX.Core.ConstantTable + // ────────────────────────────────────────────── + + internal const string AppName = KitX.Core.ConstantTable.AppName; + + internal const string AppFullName = KitX.Core.ConstantTable.AppFullName; + + internal const string DataPath = KitX.Core.ConstantTable.DataPath; + + internal const string LanguageFilePath = KitX.Core.ConstantTable.LanguageFilePath; + + internal const string AssetsPath = KitX.Core.ConstantTable.AssetsPath; + + internal const string UpdateSavePath = KitX.Core.ConstantTable.UpdateSavePath; + + internal const string IconBase64FileName = KitX.Core.ConstantTable.IconBase64FileName; + + internal static string ActivitiesDataBaseFilePath => KitX.Core.ConstantTable.ActivitiesDataBaseFilePath; + + internal static string ThirdPartyLicenseFilePath => KitX.Core.ConstantTable.ThirdPartyLicenseFilePath; + + internal static bool IsExchangingDeviceKey + { + get => KitX.Core.ConstantTable.IsExchangingDeviceKey; + set => KitX.Core.ConstantTable.IsExchangingDeviceKey = value; + } + + internal static string? ExchangeDeviceKeyCode + { + get => KitX.Core.ConstantTable.ExchangeDeviceKeyCode; + set => KitX.Core.ConstantTable.ExchangeDeviceKeyCode = value; + } + + internal static int DevicesServerPort + { + get => KitX.Core.ConstantTable.DevicesServerPort; + set => KitX.Core.ConstantTable.DevicesServerPort = value; + } + + internal static int PluginsServerPort + { + get => KitX.Core.ConstantTable.PluginsServerPort; + set => KitX.Core.ConstantTable.PluginsServerPort = value; + } + + internal static bool Running + { + get => KitX.Core.ConstantTable.Running; + set => KitX.Core.ConstantTable.Running = value; + } + + internal static bool Exiting + { + get => KitX.Core.ConstantTable.Exiting; + set => KitX.Core.ConstantTable.Exiting = value; + } + + internal static bool Restarting + { + get => KitX.Core.ConstantTable.Restarting; + set => KitX.Core.ConstantTable.Restarting = value; + } + + internal static bool EnsureExiting + { + get => KitX.Core.ConstantTable.EnsureExiting; + set => KitX.Core.ConstantTable.EnsureExiting = value; + } + + internal static bool IsMainMachine + { + get => KitX.Core.ConstantTable.IsMainMachine; + set => KitX.Core.ConstantTable.IsMainMachine = value; + } + + internal static string? MainMachineAddress + { + get => KitX.Core.ConstantTable.MainMachineAddress; + set => KitX.Core.ConstantTable.MainMachineAddress = value; + } + + internal static int MainMachinePort + { + get => KitX.Core.ConstantTable.MainMachinePort; + set => KitX.Core.ConstantTable.MainMachinePort = value; + } + + internal static bool SkipNetworkSystemOnStartup + { + get => KitX.Core.ConstantTable.SkipNetworkSystemOnStartup; + set => KitX.Core.ConstantTable.SkipNetworkSystemOnStartup = value; + } + + internal static DateTime ServerBuildTime + { + get => KitX.Core.ConstantTable.ServerBuildTime; + set => KitX.Core.ConstantTable.ServerBuildTime = value; + } + + internal const string ApiGetAnnouncements = KitX.Core.ConstantTable.ApiGetAnnouncements; + + internal const string ApiGetAnnouncement = KitX.Core.ConstantTable.ApiGetAnnouncement; + + internal static string KitXIconBase64 + { + get => KitX.Core.ConstantTable.KitXIconBase64; + set => KitX.Core.ConstantTable.KitXIconBase64 = value; + } + + internal static bool IsSingleProcessStartMode + { + get => KitX.Core.ConstantTable.IsSingleProcessStartMode; + set => KitX.Core.ConstantTable.IsSingleProcessStartMode = value; + } + + internal static bool EnabledConfigFileHotReload + { + get => KitX.Core.ConstantTable.EnabledConfigFileHotReload; + set => KitX.Core.ConstantTable.EnabledConfigFileHotReload = value; + } } diff --git a/KitX Dashboard/Controls/PinProperties.cs b/KitX Dashboard/Controls/PinProperties.cs new file mode 100644 index 00000000..72c28c87 --- /dev/null +++ b/KitX Dashboard/Controls/PinProperties.cs @@ -0,0 +1,32 @@ +using Avalonia; + +namespace KitX.Dashboard.Controls; + +/// +/// Attached properties for Blueprint pin styling on NodeEditor Pin controls. +/// These allow the Pin ControlTheme template to access pin type information +/// without modifying the NodeEditorAvalonia library. +/// +public class PinProperties +{ + /// Whether this pin is an execution flow pin (triangle) vs data pin (circle) + public static readonly AttachedProperty IsExecutionProperty = + AvaloniaProperty.RegisterAttached("IsExecution"); + + /// Hex color string for this pin's type (e.g., "#32CD32" for execution) + public static readonly AttachedProperty PinTypeColorProperty = + AvaloniaProperty.RegisterAttached("PinTypeColor", "#FFFFFF"); + + /// Whether this pin currently has a connection + public static readonly AttachedProperty IsConnectedProperty = + AvaloniaProperty.RegisterAttached("IsConnected"); + + public static bool GetIsExecution(AvaloniaObject obj) => obj.GetValue(IsExecutionProperty); + public static void SetIsExecution(AvaloniaObject obj, bool value) => obj.SetValue(IsExecutionProperty, value); + + public static string GetPinTypeColor(AvaloniaObject obj) => obj.GetValue(PinTypeColorProperty); + public static void SetPinTypeColor(AvaloniaObject obj, string value) => obj.SetValue(PinTypeColorProperty, value); + + public static bool GetIsConnected(AvaloniaObject obj) => obj.GetValue(IsConnectedProperty); + public static void SetIsConnected(AvaloniaObject obj, bool value) => obj.SetValue(IsConnectedProperty, value); +} diff --git a/KitX Dashboard/Controls/ScopeBlockControl.cs b/KitX Dashboard/Controls/ScopeBlockControl.cs new file mode 100644 index 00000000..e2273de3 --- /dev/null +++ b/KitX Dashboard/Controls/ScopeBlockControl.cs @@ -0,0 +1,120 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Threading; +using Avalonia.VisualTree; +using KitX.Dashboard.ViewModels; +using NodifyM.Avalonia.Controls; +using NodifyM.Avalonia.Events; + +namespace KitX.Dashboard.Controls; + +/// +/// Custom scope block control inheriting from NodeGroup. +/// Renders as a large bordered container behind regular nodes (ZIndex=-1). +/// +/// Key behavior: When the scope block is dragged, directly sets child node controls' +/// Location via the BaseNode CLR setter (not through ViewModel binding) so that +/// BaseNode.LocationChangedEvent fires, which triggers Connector.UpdateAnchor(), +/// which ensures connections redraw correctly. +/// +public class ScopeBlockControl : NodeGroup +{ +#pragma warning disable CS0649 + private bool _suppressZIndexManagement; +#pragma warning restore CS0649 + private bool _isPropagatingDrag; + + static ScopeBlockControl() + { + // Register a class handler for Location changes to propagate drag to child nodes. + // This fires whether Location is set via CLR setter or SetValue (binding). + LocationProperty.Changed.AddClassHandler((ctrl, e) => + ctrl.OnControlLocationChanged(e)); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + EnsureLowZIndex(); + } + + protected override void OnSelectChanged(NodeSelectEventArgs e) + { + base.OnSelectChanged(e); + + // NodifyEditor.SelectItem sets parent ContentPresenter.ZIndex = 1 AFTER + // this event fires. Use Dispatcher to reset it back to -1 afterward. + if (!_suppressZIndexManagement) + { + Dispatcher.UIThread.Post(EnsureLowZIndex); + } + } + + /// + /// Called when this scope block's Location AvaloniaProperty changes. + /// Propagates the movement delta directly to child node controls via their + /// BaseNode.Location CLR setter, which fires LocationChangedEvent and + /// causes Connectors to update their Anchors and redraw connections. + /// + private void OnControlLocationChanged(AvaloniaPropertyChangedEventArgs e) + { + if (_isPropagatingDrag) return; + + if (DataContext is not BlueprintScopeBlockVM scopeVm + || scopeVm.SuppressChildMove + || scopeVm.Editor == null) + return; + + var newLocation = (Point)e.NewValue!; + var oldLocation = e.OldValue != null ? (Point)e.OldValue : default; + var delta = new Point(newLocation.X - oldLocation.X, newLocation.Y - oldLocation.Y); + + if (delta.X == 0 && delta.Y == 0) return; + + var editor = this.FindAncestorOfType(); + if (editor == null) return; + + // Suppress ViewModel-level propagation and bounds recalculation to avoid + // double-move and chain reactions between scope blocks during drag + scopeVm.SuppressChildMove = true; + scopeVm.SuppressBoundsRecalc = true; + _isPropagatingDrag = true; + try + { + foreach (var nodeId in scopeVm.ContainedNodeIds) + { + var nodeVm = scopeVm.Editor.FindNodeById(nodeId); + if (nodeVm == null) continue; + + var container = editor.ContainerFromItem(nodeVm); + if (container is ContentPresenter cp && cp.Child is BaseNode nodeControl) + { + // Set Location through CLR setter to fire LocationChangedEvent + // which triggers Connector.UpdateAnchor → connection redraw + nodeControl.Location = new Point( + nodeControl.Location.X + delta.X, + nodeControl.Location.Y + delta.Y); + } + } + } + finally + { + _isPropagatingDrag = false; + scopeVm.SuppressChildMove = false; + scopeVm.SuppressBoundsRecalc = false; + } + } + + /// + /// Ensures this scope block's parent ContentPresenter always has ZIndex = -1, + /// so the scope block renders behind regular nodes regardless of selection state. + /// + private void EnsureLowZIndex() + { + if (Parent is ContentPresenter cp) + { + cp.ZIndex = -1; + } + } +} diff --git a/KitX Dashboard/Converters/BlueprintConverters.cs b/KitX Dashboard/Converters/BlueprintConverters.cs new file mode 100644 index 00000000..0dee7c1a --- /dev/null +++ b/KitX Dashboard/Converters/BlueprintConverters.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Media; +using KitX.Dashboard.ViewModels; + +namespace KitX.Dashboard.Converters; + +/// +/// Converts IsConnected (bool) + ColorHex (string) to a fill brush. +/// Connected: solid fill with the color. Disconnected: transparent. +/// Used as MultiConverter with bindings [IsConnected, ColorHex]. +/// +public class PinFillConverter : IMultiValueConverter +{ + public static readonly PinFillConverter Instance = new(); + + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count < 2) + return Brushes.Transparent; + + var isConnected = values[0] is bool b && b; + var colorHex = values[1] as string ?? "#FFFFFF"; + + if (!isConnected) + return Brushes.Transparent; + + try + { + var color = Color.Parse(colorHex); + return new SolidColorBrush(color); + } + catch + { + return Brushes.Transparent; + } + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => BindingOperations.DoNothing; +} + +/// +/// Converts IsConnected (bool) + ColorHex (string) to a stroke brush. +/// Connected: no stroke (transparent). Disconnected: stroke with the color. +/// +public class PinStrokeConverter : IMultiValueConverter +{ + public static readonly PinStrokeConverter Instance = new(); + + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count < 2) + return Brushes.White; + + var isConnected = values[0] is bool b && b; + var colorHex = values[1] as string ?? "#FFFFFF"; + + if (isConnected) + return Brushes.Transparent; + + try + { + var color = Color.Parse(colorHex); + return new SolidColorBrush(color); + } + catch + { + return Brushes.White; + } + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => BindingOperations.DoNothing; +} + +/// +/// Converts a hex color string to a SolidColorBrush. +/// +public class HexToBrushConverter : IValueConverter +{ + public static readonly HexToBrushConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string hex) + { + try + { + return new SolidColorBrush(Color.Parse(hex)); + } + catch + { + return Brushes.Gray; + } + } + return Brushes.Gray; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => BindingOperations.DoNothing; +} + +/// +/// Converts bool (IsExecution) to Visibility for the triangle port shape. +/// +public class ExecutionPinVisibilityConverter : IValueConverter +{ + public static readonly ExecutionPinVisibilityConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isExec) + return isExec; + return false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => BindingOperations.DoNothing; +} diff --git a/KitX Dashboard/Converters/PluginInfoConverters.cs b/KitX Dashboard/Converters/PluginInfoConverters.cs index ecc9f89b..9e3fee52 100644 --- a/KitX Dashboard/Converters/PluginInfoConverters.cs +++ b/KitX Dashboard/Converters/PluginInfoConverters.cs @@ -3,7 +3,7 @@ using System.Globalization; using System.Linq; using Avalonia.Data.Converters; -using KitX.Dashboard.Managers; +using KitX.Core.Contract.Configuration; namespace KitX.Dashboard.Converters; @@ -14,9 +14,10 @@ public class PluginMultiLanguagePropertyConverter : IValueConverter if (value is null) return null; - if (value is Dictionary dict) + if (value is Dictionary dict && dict.Count > 0) { - var result = dict.TryGetValue(ConfigManager.Instance.AppConfig.App.AppLanguage, out var lang) ? lang : dict.Values.First(); + var appLanguage = App.GetService().AppConfig.App.AppLanguage; + var result = dict.TryGetValue(appLanguage, out var lang) ? lang : dict.Values.FirstOrDefault() ?? string.Empty; return result; } diff --git a/KitX Dashboard/Converters/WindowStateConverter.cs b/KitX Dashboard/Converters/WindowStateConverter.cs new file mode 100644 index 00000000..0f773b48 --- /dev/null +++ b/KitX Dashboard/Converters/WindowStateConverter.cs @@ -0,0 +1,40 @@ +using Avalonia.Controls; +using KWindowState = KitX.Core.Contract.Configuration.WindowState; + +namespace KitX.Dashboard.Converters; + +/// +/// Converts between KitX.Core.WindowState and Avalonia.Controls.WindowState +/// +public static class WindowStateConverter +{ + /// + /// Converts KitX.Core.Contract.Configuration.WindowState to Avalonia.Controls.WindowState + /// + public static WindowState ToAvalonia(this KWindowState state) + { + return state switch + { + KWindowState.Normal => WindowState.Normal, + KWindowState.Minimized => WindowState.Minimized, + KWindowState.Maximized => WindowState.Maximized, + KWindowState.FullScreen => WindowState.FullScreen, + _ => WindowState.Normal + }; + } + + /// + /// Converts Avalonia.Controls.WindowState to KitX.Core.Contract.Configuration.WindowState + /// + public static KWindowState ToCore(this WindowState state) + { + return state switch + { + WindowState.Normal => KWindowState.Normal, + WindowState.Minimized => KWindowState.Minimized, + WindowState.Maximized => KWindowState.Maximized, + WindowState.FullScreen => KWindowState.FullScreen, + _ => KWindowState.Normal + }; + } +} diff --git a/KitX Dashboard/Converters/WorkflowStatusConverter.cs b/KitX Dashboard/Converters/WorkflowStatusConverter.cs new file mode 100644 index 00000000..0a5161a6 --- /dev/null +++ b/KitX Dashboard/Converters/WorkflowStatusConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using KitX.Core.Contract.Workflow; + +namespace KitX.Dashboard.Converters; + +/// +/// Converts an IWorkflowCase to a visibility bool based on ConverterParameter state string. +/// Parameter values: "Stopped" (!IsRunning && !IsError), "Running" (IsRunning && !IsError), "Error" (IsError) +/// IsRunning controls buttons (STOP when running), IsError controls light color (yellow when error). +/// +public class WorkflowStatusConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not IWorkflowCase wf || parameter is not string state) + return false; + + return state switch + { + "Stopped" => !wf.IsRunning && !wf.IsError, + "Running" => wf.IsRunning && !wf.IsError, + "Error" => wf.IsError, + _ => false + }; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotImplementedException(); +} diff --git "a/KitX Dashboard/Docs/KitX-Dashboard-API\346\226\207\346\241\243.md" "b/KitX Dashboard/Docs/KitX-Dashboard-API\346\226\207\346\241\243.md" new file mode 100644 index 00000000..5884c3d6 --- /dev/null +++ "b/KitX Dashboard/Docs/KitX-Dashboard-API\346\226\207\346\241\243.md" @@ -0,0 +1,1156 @@ +# KitX Dashboard API 文档 + +## 文档信息 + +- **项目名称**: KitX Dashboard +- **文档版本**: v1.0 +- **创建日期**: 2026-02-19 +- **目的**: 描述 KitX Dashboard 的公共 API + +--- + +## 1. 配置管理 + +### 1.1 IConfigService + +配置管理服务接口 + +```csharp +namespace KitX.Core.Contract.Configuration; + +public interface IConfigService +{ + /// + /// Gets the application configuration + /// + IAppConfig AppConfig { get; } + + /// + /// Gets the plugins configuration + /// + IPluginsConfig PluginsConfig { get; } + + /// + /// Gets the security configuration + /// + ISecurityConfig SecurityConfig { get; } + + /// + /// Loads all configurations from files + /// + void Load(); + + /// + /// Saves all configurations to files + /// + void SaveAll(); + + /// + /// Reloads all configurations from files + /// + void Reload(); + + /// + /// Event raised when configuration changes + /// + event EventHandler? ConfigChanged; +} +``` + +### 1.2 IAppConfig + +完整应用配置接口 (包含 8 个子配置) + +```csharp +public interface IAppConfig +{ + IAppConf App { get; set; } + IWindowsConf Windows { get; set; } + IPagesConf Pages { get; set; } + IWebConf Web { get; set; } + ILogConf Log { get; set; } + IIOConf IO { get; set; } + IActivityConf Activity { get; set; } + ILoadersConf Loaders { get; set; } +} +``` + +### 1.3 IAppConf + +应用基础配置接口 + +```csharp +public interface IAppConf +{ + string IconFileName { get; set; } + string CoverIconFileName { get; set; } + string AppLanguage { get; set; } + string Theme { get; set; } + string ThemeColor { get; set; } + Dictionary SurpportLanguages { get; set; } + string LocalPluginsFileFolder { get; set; } + string LocalPluginsDataFolder { get; set; } + bool DeveloperSetting { get; set; } + bool ShowAnnouncementWhenStart { get; set; } + ulong RanTime { get; set; } + int LastBreakAfterExit { get; set; } +} +``` + +### 1.4 IWindowsConf + +窗口配置接口 + +```csharp +public interface IWindowsConf +{ + IMainWindowConf MainWindow { get; set; } + IAnnouncementWindowConf AnnouncementWindow { get; set; } +} +``` + +### 1.5 IMainWindowConf + +主窗口配置接口 + +```csharp +public interface IMainWindowConf +{ + Resolution Size { get; set; } + Distances Location { get; set; } + WindowState WindowState { get; set; } + bool IsHidden { get; set; } + Dictionary Tags { get; set; } + bool EnabledMica { get; set; } + int GreetingTextCount_Morning { get; set; } + int GreetingTextCount_Noon { get; set; } + int GreetingTextCount_AfterNoon { get; set; } + int GreetingTextCount_Evening { get; set; } + int GreetingTextCount_Night { get; set; } + int GreetingUpdateInterval { get; set; } +} +``` + +### 1.6 IPagesConf / ISettingsPageConf + +页面配置接口 + +```csharp +public interface IPagesConf +{ + IHomePageConf Home { get; set; } + IDevicePageConf Device { get; set; } + IMarketPageConf Market { get; set; } + ISettingsPageConf Settings { get; set; } +} + +public interface ISettingsPageConf +{ + NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { get; set; } + string SelectedViewName { get; set; } + bool PaletteAreaExpanded { get; set; } + bool WebRelatedAreaExpanded { get; set; } + bool WebRelatedAreaOfNetworkInterfacesExpanded { get; set; } + bool LogRelatedAreaExpanded { get; set; } + bool UpdateRelatedAreaExpanded { get; set; } + bool AboutAreaExpanded { get; set; } + bool AuthorsAreaExpanded { get; set; } + bool LinksAreaExpanded { get; set; } + bool ThirdPartyLicensesAreaExpanded { get; set; } + bool IsNavigationViewPaneOpened { get; set; } +} +``` + +### 1.7 IWebConf + +网络配置接口 + +```csharp +public interface IWebConf +{ + double DelayStartSeconds { get; set; } + string ApiServer { get; set; } + string ApiPath { get; set; } + int DevicesViewRefreshDelay { get; set; } + List? AcceptedNetworkInterfaces { get; set; } + int? UserSpecifiedDevicesServerPort { get; set; } + int? UserSpecifiedPluginsServerPort { get; set; } + int UdpPortSend { get; set; } + int UdpPortReceive { get; set; } + int UdpSendFrequency { get; set; } + string UdpBroadcastAddress { get; set; } + string IPFilter { get; set; } + int SocketBufferSize { get; set; } + int DeviceInfoTTLSeconds { get; set; } + bool DisableRemovingOfflineDeviceCard { get; set; } + string UpdateServer { get; set; } + string UpdatePath { get; set; } + string UpdateDownloadPath { get; set; } + string UpdateChannel { get; set; } + string UpdateSource { get; set; } + int DebugServicesServerPort { get; set; } +} +``` + +### 1.8 其他配置接口 + +```csharp +public interface ILogConf +{ + long LogFileSingleMaxSize { get; set; } + string LogFilePath { get; set; } + string LogTemplate { get; set; } + int LogFileMaxCount { get; set; } + int LogFileFlushInterval { get; set; } + LogEventLevel LogLevel { get; set; } +} + +public interface IIOConf +{ + int UpdatingCheckPerThreadFilesCount { get; set; } + int OperatingSystemVersionUpdateInterval { get; set; } +} + +public interface IActivityConf +{ + int TotalRecorded { get; set; } +} + +public interface ILoadersConf +{ + string InstallPath { get; set; } +} + +public interface IAnnouncementConfig +{ + List Accepted { get; set; } + string? ConfigFileLocation { get; set; } +} +``` + +### 1.9 枚举类型 + +```csharp +public enum WindowState +{ + Normal, + Minimized, + Maximized, + FullScreen, + NonInteractive +} + +public enum NavigationViewPaneDisplayMode +{ + Auto = 0, + Left = 1, + Top = 2, + LeftCompact = 3, + LeftMinimal = 4 +} +``` + +--- + +## 2. 设备管理 + +### 2.1 IDeviceService + +设备管理服务接口 + +```csharp +namespace KitX.Core.Contract.Device; + +public interface IDeviceService +{ + /// + /// Gets the discovered devices list + /// + IReadOnlyList DiscoveredDevices { get; } + + /// + /// Gets the authorized devices list + /// + IReadOnlyList AuthorizedDevices { get; } + + /// + /// Gets the self device information + /// + DeviceInfo SelfDeviceInfo { get; } + + /// + /// Gets a value indicating whether this device is the main device + /// + bool IsMainDevice { get; } + + /// + /// Authorizes a device + /// + Task AuthorizeDeviceAsync(string deviceId, string deviceKey); + + /// + /// Unauthorizes a device + /// + Task UnauthorizeDeviceAsync(string deviceId); + + /// + /// Connects to a device + /// + Task ConnectToDeviceAsync(string deviceId); + + /// + /// Event raised when a device is discovered + /// + event EventHandler? DeviceDiscovered; + + /// + /// Event raised when a device goes offline + /// + event EventHandler? DeviceOffline; + + /// + /// Event raised when the main device changes + /// + event EventHandler? MainDeviceChanged; +} +``` + +### 2.2 IDeviceDiscoveryService + +设备发现服务接口 + +```csharp +public interface IDeviceDiscoveryService +{ + /// + /// Gets the port the discovery service is running on + /// + int? Port { get; } + + /// + /// Starts the device discovery service + /// + IDeviceDiscoveryService Run(); + + /// + /// Stops the device discovery service + /// + void Stop(); + + /// + /// Event raised when a device is discovered + /// + event EventHandler? DeviceDiscovered; + + /// + /// Event raised when a device goes offline + /// + event EventHandler? DeviceOffline; +} +``` + +### 2.3 IDeviceServer + +设备服务器接口 + +```csharp +public interface IDeviceServer +{ + /// + /// Gets the port the server is running on + /// + int? Port { get; } + + /// + /// Starts the device server + /// + IDeviceServer Run(); + + /// + /// Stops the device server + /// + void Stop(); +} +``` + +--- + +## 3. 插件管理 + +### 3.1 IPluginService + +插件管理服务接口 + +```csharp +namespace KitX.Core.Contract.Plugin; + +public interface IPluginService +{ + /// + /// Gets all installed plugins + /// + IReadOnlyList GetInstalledPlugins(); + + /// + /// Gets a plugin by its ID + /// + IPluginInstallation? GetPlugin(Guid pluginId); + + /// + /// Imports a plugin package (.kxp file) + /// + Task ImportPluginAsync(string kxpFilePath); + + /// + /// Removes a plugin + /// + Task RemovePluginAsync(Guid pluginId); + + /// + /// Starts a plugin + /// + Task StartPluginAsync(Guid pluginId); + + /// + /// Stops a plugin + /// + Task StopPluginAsync(Guid pluginId); + + /// + /// Calls a plugin function + /// + Task CallPluginFunctionAsync(Guid pluginId, string functionName, Dictionary? parameters = null); + + /// + /// Event raised when plugin status changes + /// + event EventHandler? PluginStatusChanged; +} +``` + +### 3.2 IPluginServer + +插件服务器接口 + +```csharp +public interface IPluginServer +{ + /// + /// Gets the port the server is running on + /// + int? Port { get; } + + /// + /// Starts the plugin server + /// + IPluginServer Run(); + + /// + /// Stops the plugin server + /// + void Stop(); + + /// + /// Finds a connector for a specific plugin + /// + IPluginConnector? FindConnector(PluginInfo pluginInfo); + + /// + /// Event raised when server port changes + /// + event EventHandler? PortChanged; + + /// + /// Event raised when a plugin registers with the server + /// + event EventHandler? PluginRegistered; + + /// + /// Event raised when a plugin unregisters/disconnects from the server + /// + event EventHandler? PluginUnregistered; +} +``` + +### 3.3 IPluginConnector + +插件连接器接口 + +```csharp +public interface IPluginConnector +{ + /// + /// Gets the connection ID + /// + string ConnectionId { get; } + + /// + /// Gets the plugin info + /// + PluginInfo? PluginInfo { get; } + + /// + /// Sends a request to the plugin + /// + void Request(object request); + + /// + /// Event raised when a plugin response is received + /// + event EventHandler? PluginResponse; + + /// + /// Event raised when plugin reports status + /// + event EventHandler? StatusReport; +} +``` + +--- + +## 4. 安全管理 + +### 4.1 IDeviceKeyService + +设备密钥管理服务接口 + +```csharp +namespace KitX.Core.Contract.Security; + +public interface IDeviceKeyService +{ + IReadOnlyList GetDeviceKeys(); + bool AddDeviceKey(string macAddress, string deviceName, string publicKey); + bool RemoveDeviceKey(string macAddress); + DeviceKey? SearchDeviceKey(DeviceLocator locator); + bool IsDeviceKeyCorrect(DeviceLocator locator, DeviceKey key); + bool IsDeviceAuthorized(DeviceLocator device); + DeviceKey? GetPrivateDeviceKey(); +} +``` + +### 4.2 IEncryptionService + +加密服务接口 + +```csharp +namespace KitX.Core.Contract.Security; + +public interface IEncryptionService +{ + Task EncryptStringAsync(string content, string targetDeviceMacAddress); + Task DecryptStringAsync(string encryptedContent, string sourceDeviceMacAddress); + string? RsaEncryptString(DeviceKey key, string data); + string? RsaDecryptString(DeviceKey key, string encryptedData); + EncryptedContent RsaEncryptContent(DeviceKey key, string content); + string RsaDecryptContent(DeviceKey key, EncryptedContent content); + string AesEncrypt(string source, string key); + string AesDecrypt(string source, string key, bool isSourceInBase64 = true); + string GetSHA1(string data); +} +``` + +**注意**: `ISecurityService` 接口已被拆分为 `IDeviceKeyService` 和 `IEncryptionService` 两个独立接口。 + +--- + +## 5. 活动记录 + +### 5.1 IActivityService + +活动记录服务接口 + +```csharp +namespace KitX.Core.Contract.Activity; + +public interface IActivityService +{ + /// + /// Records an activity + /// + void RecordActivity(string type, Dictionary? details = null); + + /// + /// Gets activities + /// + IList GetActivities(DateTime? startDate = null, DateTime? endDate = null, int limit = 100); + + /// + /// Gets activity statistics + /// + IActivityStatistics GetStatistics(DateTime startDate, DateTime endDate); + + /// + /// Event raised when activities are updated + /// + event EventHandler? ActivitiesUpdated; +} +``` + +--- + +## 6. 统计服务 + +### 6.1 IStatisticsService + +统计服务接口 + +```csharp +namespace KitX.Core.Contract.Statistics; + +public interface IStatisticsService +{ + /// + /// Starts statistics collection + /// + void Start(); + + /// + /// Stops statistics collection + /// + void Stop(); + + /// + /// Gets usage statistics + /// + IUsageStatistics GetUsageStatistics(DateTime startDate, DateTime endDate); +} +``` + +--- + +## 7. 工作流 + +**注意**: `IWorkflowService` 接口已被拆分为以下四个独立接口,由 `WorkflowScriptService` 实现。 + +### 7.1 IWorkflowManagementService + +工作流管理接口 + +```csharp +namespace KitX.Core.Contract.Workflow; + +public interface IWorkflowManagementService +{ + IReadOnlyList GetWorkflows(); + void AddWorkflow(IWorkflowCase workflow); + void RemoveWorkflow(string workflowId); + Task RunWorkflowAsync(string workflowId); + Task StopWorkflowAsync(string workflowId); + Task CompileAndPersistWorkflowAsync(string workflowId); +} +``` + +### 7.2 IScriptExecutionService + +脚本执行接口 + +```csharp +namespace KitX.Core.Contract.Workflow; + +public interface IScriptExecutionService +{ + Task ExecuteScriptAsync(string script, Dictionary? parameters = null); + Task ExecuteCodesAsync(string code, List? requiredPlugins = null, + bool includeTimestamp = true, System.Threading.CancellationToken cancellationToken = default); + Task ExecuteKcsCodesAsync(string mainCode, List helperFunctions, + List constants, List? requiredPlugins = null, + bool includeTimestamp = true, System.Threading.CancellationToken cancellationToken = default); +} +``` + +### 7.3 IWorkflowPluginService + +工作流插件集成接口 + +```csharp +namespace KitX.Core.Contract.Workflow; + +public interface IWorkflowPluginService +{ + void UpdateAvailablePlugins(List plugins); + List ParseConstantsFromCode(string code); + string ApplyConstantsToCode(string code, List constants); + string MergeHelperFunctions(string mainCode, List helperFunctions); +} +``` + +### 7.4 IBlockScriptService + +BlockScript 服务接口 + +```csharp +namespace KitX.Core.Contract.Workflow; + +public interface IBlockScriptService +{ + BlockScriptParseResult ParseBlockScript(string sourceCode); + Task ParseBlockScriptAsync(string sourceCode); + BlockScriptValidationResult ValidateBlockScript(string sourceCode); + List ParseConstantsFromBlockScript(string sourceCode); + Task ExecuteBlockScriptAsync(BlockScript script, + Dictionary? parameters = null, + System.Threading.CancellationToken cancellationToken = default); + Task ExecuteBlockScriptAsync(string sourceCode, + Dictionary? parameters = null, + System.Threading.CancellationToken cancellationToken = default); + Task ExecuteBlockScriptAsync(string sourceCode, + List helperFunctions, + System.Threading.CancellationToken cancellationToken = default); + Task ExecuteBlockScriptAsync(string sourceCode, + List helperFunctions, + Dictionary? constantOverrides, + System.Threading.CancellationToken cancellationToken = default); +} +``` + +### 7.5 IPluginServiceProvider + +插件服务提供者接口 + +```csharp +public interface IPluginServiceProvider +{ + /// + /// Gets running plugins + /// + IEnumerable GetRunningPlugins(); + + /// + /// Finds a plugin by name + /// + PluginInfo? FindPlugin(string pluginName); + + /// + /// Finds a connector for a plugin + /// + object? FindConnector(PluginInfo pluginInfo); + + /// + /// Sends a request asynchronously + /// + Task SendRequestAsync(object connector, object request); + + /// + /// Subscribes to plugin responses + /// + void SubscribeToResponses(Action responseHandler); +} +``` + +### 7.6 IWorkflowCase + +工作流实例接口 + +```csharp +namespace KitX.Core.Contract.Workflow; + +public interface IWorkflowCase +{ + /// + /// Gets the workflow ID + /// + string Id { get; } + + /// + /// Gets the workflow name + /// + string Name { get; } + + /// + /// Gets the workflow description + /// + string Description { get; } + + /// + /// Gets the icon path + /// + string IconPath { get; } + + /// + /// Gets or sets a value indicating whether the workflow is running + /// + bool IsRunning { get; set; } + + /// + /// Gets or sets the script file path + /// + string? ScriptPath { get; set; } +} +``` + +--- + +## 8. 事件系统 + +### 8.1 IEventService + +事件服务接口 + +```csharp +namespace KitX.Core.Contract.Event; + +public interface IEventService +{ + /// + /// Subscribes to an event + /// + void Subscribe(string eventName, EventHandler handler); + + /// + /// Unsubscribes from an event + /// + void Unsubscribe(string eventName, EventHandler handler); + + /// + /// Publishes an event + /// + void Publish(string eventName, EventArgs args); + + /// + /// Subscribes to a typed event + /// + void Subscribe(string eventName, EventHandler handler) + where TEventArgs : EventArgs; + + /// + /// Unsubscribes from a typed event + /// + void Unsubscribe(string eventName, EventHandler handler) + where TEventArgs : EventArgs; + + /// + /// Publishes a typed event + /// + void Publish(string eventName, TEventArgs args) + where TEventArgs : EventArgs; +} +``` + +### 8.2 EventNames + +事件名称常量 + +```csharp +namespace KitX.Core.Event; + +public static class EventNames +{ + public const string LanguageChanged = "LanguageChanged"; + public const string GreetingTextIntervalUpdated = "GreetingTextIntervalUpdated"; + public const string AppConfigChanged = "AppConfigChanged"; + public const string PluginsConfigChanged = "PluginsConfigChanged"; + public const string MicaOpacityChanged = "MicaOpacityChanged"; + public const string DevelopSettingsChanged = "DevelopSettingsChanged"; + public const string LogConfigUpdated = "LogConfigUpdated"; + public const string ThemeConfigChanged = "ThemeConfigChanged"; + public const string UseStatisticsChanged = "UseStatisticsChanged"; + public const string DevicesServerPortChanged = "DevicesServerPortChanged"; + public const string PluginsServerPortChanged = "PluginsServerPortChanged"; + public const string OnActivitiesUpdated = "OnActivitiesUpdated"; + public const string OnReceiveCancelExchangingDeviceKey = "OnReceiveCancelExchangingDeviceKey"; + public const string OnExiting = "OnExiting"; + public const string OnReceivingDeviceInfo = "OnReceivingDeviceInfo"; + public const string OnConfigHotReloaded = "OnConfigHotReloaded"; + public const string OnAcceptingDeviceKey = "OnAcceptingDeviceKey"; +} +``` + +### 8.3 过时的 API (Legacy / Obsolete) + +以下 API 已标记为 `[Obsolete]`,**不建议使用**,仅用于向后兼容: + +#### EventService 静态方法 + +```csharp +[Obsolete("Use IEventService.Publish with event names instead")] +public static void Invoke(string eventName, object[]? objects = null) +``` + +#### 静态事件 (已过时,使用 IEventService 替代) + +| 静态事件 | 过时替代方案 | +|----------|--------------| +| `EventService.LanguageChanged` | `IEventService.Publish(EventNames.LanguageChanged, args)` | +| `EventService.GreetingTextIntervalUpdated` | `IEventService.Publish(EventNames.GreetingTextIntervalUpdated, args)` | +| `EventService.AppConfigChanged` | `IEventService.Publish(EventNames.AppConfigChanged, args)` | +| `EventService.PluginsConfigChanged` | `IEventService.Publish(EventNames.PluginsConfigChanged, args)` | +| `EventService.MicaOpacityChanged` | `IEventService.Publish(EventNames.MicaOpacityChanged, args)` | +| `EventService.DevelopSettingsChanged` | `IEventService.Publish(EventNames.DevelopSettingsChanged, args)` | +| `EventService.LogConfigUpdated` | `IEventService.Publish(EventNames.LogConfigUpdated, args)` | +| `EventService.ThemeConfigChanged` | `IEventService.Publish(EventNames.ThemeConfigChanged, args)` | +| `EventService.UseStatisticsChanged` | `IEventService.Publish(EventNames.UseStatisticsChanged, args)` | +| `EventService.DevicesServerPortChanged` | `IEventService.Publish(EventNames.DevicesServerPortChanged, args)` | +| `EventService.PluginsServerPortChanged` | `IEventService.Publish(EventNames.PluginsServerPortChanged, args)` | +| `EventService.OnActivitiesUpdated` | `IEventService.Publish(EventNames.OnActivitiesUpdated, args)` | +| `EventService.OnReceiveCancelExchangingDeviceKey` | `IEventService.Publish(EventNames.OnReceiveCancelExchangingDeviceKey, args)` | +| `EventService.OnExiting` | `IEventService.Publish(EventNames.OnExiting, args)` | +| `EventService.OnReceivingDeviceInfo` | `IEventService.Publish(EventNames.OnReceivingDeviceInfo, args)` | +| `EventService.OnConfigHotReloaded` | `IEventService.Publish(EventNames.OnConfigHotReloaded, args)` | +| `EventService.OnAcceptingDeviceKey` | `IEventService.Publish(EventNames.OnAcceptingDeviceKey, args)` | + +**迁移建议**: +- 使用 `IEventService` 接口代替 `EventService` 静态类 +- 通过依赖注入获取 `IEventService` 实例 +- 使用 `EventNames` 常量定义事件名称 + +--- + +## 9. 任务调度 + +### 9.1 ITasksService + +任务服务接口 + +```csharp +namespace KitX.Core.Contract.Tasks; + +public interface ITasksService +{ + /// + /// Runs a synchronous task + /// + void RunTask(Action task, string? taskName = null); + + /// + /// Runs an asynchronous task + /// + Task RunTaskAsync(Func task, string? taskName = null); +} +``` + +--- + +## 10. 文件监控 + +### 10.1 IFileWatcherService + +文件监控服务接口 + +```csharp +namespace KitX.Core.Contract.FileWatcher; + +public interface IFileWatcherService +{ + /// + /// Registers a file watcher + /// + void RegisterWatcher(string filePath, FileSystemEventHandler onChanged); + + /// + /// Unregisters a file watcher + /// + void UnregisterWatcher(string filePath); + + /// + /// Clears all file watchers + /// + void Clear(); +} +``` + +--- + +## 11. 全局热键 + +### 11.1 IKeyHookService + +热键服务接口 + +```csharp +namespace KitX.Core.Contract.Hotkey; + +public interface IKeyHookService +{ + /// + /// Starts the key hook + /// + void StartHook(); + + /// + /// Stops the key hook + /// + void StopHook(); + + /// + /// Registers a hotkey handler + /// + void RegisterHotKeyHandler(string keysSequence, Action handler); + + /// + /// Unregisters a hotkey handler + /// + void UnregisterHotKeyHandler(string keysSequence); +} +``` + +--- + +## 12. 公告系统 + +### 12.1 IAnnouncementService + +公告服务接口 + +```csharp +namespace KitX.Core.Contract.Announcement; + +public interface IAnnouncementService +{ + /// + /// Checks for new announcements + /// + Task> CheckNewAnnouncementsAsync(); + + /// + /// Marks an announcement as read + /// + void MarkAsRead(string announcementId); + + /// + /// Gets all read announcement IDs + /// + IReadOnlyList GetReadAnnouncementIds(); + + /// + /// Event raised when new announcements are available + /// + event EventHandler? NewAnnouncementsAvailable; +} +``` + +--- + +## 13. 事件参数 + +### 13.1 配置变更事件 + +```csharp +public class ConfigChangedEventArgs : EventArgs +{ + /// + /// Gets or sets the configuration type (e.g., "App", "Plugins", "Security") + /// + public string ConfigType { get; set; } + + /// + /// Gets or sets the property name that changed + /// + public string PropertyName { get; set; } + + /// + /// Gets or sets the old value + /// + public object? OldValue { get; set; } + + /// + /// Gets or sets the new value + /// + public object? NewValue { get; set; } +} +``` + +### 13.2 设备发现事件 + +```csharp +public class DeviceDiscoveredEventArgs : EventArgs +{ + /// + /// Gets or sets the device information + /// + public DeviceInfo? DeviceInfo { get; set; } +} +``` + +### 13.3 设备离线事件 + +```csharp +public class DeviceOfflineEventArgs : EventArgs +{ + /// + /// Gets or sets the device ID + /// + public string DeviceId { get; set; } +} +``` + +### 13.4 主设备变更事件 + +```csharp +public class MainDeviceChangedEventArgs : EventArgs +{ + /// + /// Gets or sets the old main device ID + /// + public string OldMainDeviceId { get; set; } + + /// + /// Gets or sets the new main device ID + /// + public string NewMainDeviceId { get; set; } +} +``` + +### 13.5 插件状态变更事件 + +```csharp +public class PluginStatusChangedEventArgs : EventArgs +{ + /// + /// Gets or sets the plugin ID + /// + public Guid PluginId { get; set; } + + /// + /// Gets or sets the plugin name + /// + public string PluginName { get; set; } + + /// + /// Gets or sets the old status + /// + public PluginStatus OldStatus { get; set; } + + /// + /// Gets or sets the new status + /// + public PluginStatus NewStatus { get; set; } +} + +public enum PluginStatus +{ + Unknown, + Installed, + Running, + Stopped, + Error +} +``` + +--- + +## 14. 依赖注入扩展 + +### 14.1 AddCoreServices + +在 DI 容器中注册所有核心服务 + +```csharp +namespace KitX.Core.DI; + +public static class CoreServiceCollectionExtensions +{ + /// + /// Adds all KitX Core services to the dependency injection container + /// + /// The service collection to add services to + /// The service collection for chaining + public static IServiceCollection AddCoreServices(this IServiceCollection services); +} +``` + +--- + +**文档结束** + +*本文档描述了 KitX Dashboard 的公共 API。* diff --git "a/KitX Dashboard/Docs/KitX-Dashboard-\345\274\200\345\217\221\350\200\205\346\226\207\346\241\243.md" "b/KitX Dashboard/Docs/KitX-Dashboard-\345\274\200\345\217\221\350\200\205\346\226\207\346\241\243.md" new file mode 100644 index 00000000..33cae599 --- /dev/null +++ "b/KitX Dashboard/Docs/KitX-Dashboard-\345\274\200\345\217\221\350\200\205\346\226\207\346\241\243.md" @@ -0,0 +1,302 @@ +# KitX Dashboard 开发者文档 + +## 文档信息 + +- **项目名称**: KitX Dashboard +- **文档版本**: v1.0 +- **创建日期**: 2026-02-19 +- **目的**: 为开发者提供 KitX Dashboard 开发指南 + +--- + +## 1. 项目概述 + +### 1.1 项目简介 + +KitX Dashboard 是 KitX 项目的桌面客户端,采用 Avalonia UI 框架构建。KitX Dashboard 采用了 Core-UI 分离架构,业务逻辑封装在独立的 `KitX.Core` 项目中,通过接口(`KitX.Core.Contract`)与 UI 层进行通信。 + +### 1.2 技术栈 + +| 类别 | 技术 | 版本 | +|------|------|------| +| 运行时 | .NET | 10.0+ | +| UI 框架 | Avalonia UI | 11.0+ | +| MVVM 框架 | ReactiveUI | 20.1+ | +| 依赖注入 | Microsoft.Extensions.DependencyInjection | 8.0+ | +| 日志框架 | Serilog | - | + +--- + +## 2. 项目结构 + +### 2.1 整体架构 + +``` +KitX/ +├── KitX Clients/ +│ ├── KitX Dashboard/ # UI 层 (Avalonia UI) +│ │ ├── ViewModels/ # 视图模型 +│ │ └── Views/ # 视图 +│ └── KitX Core/ # 业务逻辑层 +│ ├── Configuration/ # 配置管理 +│ ├── Device/ # 设备管理 +│ ├── Plugin/ # 插件管理 +│ ├── Security/ # 安全管理 +│ ├── Activity/ # 活动记录 +│ ├── Statistics/ # 统计服务 +│ ├── Workflow/ # 工作流 +│ ├── Event/ # 事件系统 +│ ├── Task/ # 任务调度 +│ ├── FileWatcher/ # 文件监控 +│ ├── Hotkey/ # 全局热键 +│ ├── Announcement/ # 公告系统 +│ └── DI/ # 依赖注入 +├── KitX Standard/ +│ └── KitX Core Contracts/ # 接口定义层 +└── KitX.sln +``` + +### 2.2 项目引用关系 + +``` +KitX Dashboard (UI 层 - 入口点程序) + ├── KitX.Core (业务逻辑实现) + ├── KitX.Core.Contract (接口定义) + ├── KitX.Shared.CSharp (共享数据模型) + └── KitX.Contract.CSharp (插件契约) + +KitX.Core (业务逻辑项目) + ├── KitX.Core.Contract (接口定义) + ├── KitX.Shared.CSharp (共享数据模型) + └── KitX.Contract.CSharp (插件契约) + +KitX.Core.Contract (接口定义项目) + └── KitX.Shared.CSharp (共享数据模型) +``` + +--- + +## 3. 开发环境搭建 + +### 3.1 环境要求 + +| 项目 | 要求 | +|------|------| +| 操作系统 | Windows 10/11, macOS, Linux | +| .NET SDK | 10.0+ | +| IDE | Visual Studio 2022, Rider, VS Code | +| Git | 2.0+ | + +### 3.2 克隆项目 + +```bash +# 克隆主仓库 +git clone git@github.com:Crequency/KitX.git + +cd KitX + +# 初始化子模块 +git submodule update --init --recursive + +# 设置引用 +cheese reference --setup +``` + +### 3.3 编译项目 + +```bash +# 进入 Dashboard 目录 +cd "KitX Clients/KitX Dashboard/KitX Dashboard" + +# 编译 +dotnet build + +# 运行 +dotnet run +``` + +--- + +## 4. 核心概念 + +### 4.1 依赖注入 + +KitX Dashboard 使用 Microsoft.Extensions.DependencyInjection 进行依赖注入。所有核心服务都通过接口暴露,ViewModel 通过构造函数注入接口。 + +```csharp +// 在 ViewModel 中使用依赖注入 +public class MainWindowViewModel : ViewModelBase +{ + private readonly IConfigService _configService; + private readonly IPluginService _pluginService; + private readonly IDeviceService _deviceService; + + public MainWindowViewModel( + IConfigService configService, + IPluginService pluginService, + IDeviceService deviceService) + { + _configService = configService; + _pluginService = pluginService; + _deviceService = deviceService; + } +} +``` + +### 4.2 服务注册 + +在 `KitX.Core.DI.CoreServiceCollectionExtensions` 中注册核心服务: + +```csharp +public static IServiceCollection AddCoreServices(this IServiceCollection services) +{ + services.AddSingleton(ConfigManager.Instance); + services.AddSingleton(SecurityManager.Instance); + services.AddSingleton(SecurityManager.Instance); + services.AddSingleton(PluginsManager.Instance); + services.AddSingleton(WorkflowScriptService.Instance); + services.AddSingleton(WorkflowScriptService.Instance); + services.AddSingleton(WorkflowScriptService.Instance); + services.AddSingleton(WorkflowScriptService.Instance); + // ... 其他服务 + return services; +} +``` + +### 4.3 事件驱动 + +Core 层通过事件向 UI 层推送状态变化: + +```csharp +// 订阅设备发现事件 +_deviceService.DeviceDiscovered += OnDeviceDiscovered; + +private void OnDeviceDiscovered(object? sender, DeviceDiscoveredEventArgs e) +{ + // 更新 UI +} +``` + +### 4.4 UI 状态管理 + +Dashboard 项目使用 `UIStateService` 管理 UI 相关状态。这是一个静态服务,**不属于 Core 层**: + +```csharp +// 访问 UI 状态 +var devices = UIStateService.DeviceCases; +var plugins = UIStateService.PluginInfos; +var workflows = UIStateService.WorkflowCases; + +// 设置窗口引用 +UIStateService.MainWindow = this; + +// 显示窗口 +UIStateService.ShowWindow(new MyWindow()); +``` + +**注意**: `UIStateService` 包含 Avalonia UI 特定类型,不适合放在 Core 项目中。 + +--- + +## 5. 开发指南 + +### 5.1 添加新服务 + +1. 在 `KitX.Core.Contract` 中定义接口 +2. 在 `KitX.Core` 中实现接口 +3. 在 `CoreServiceCollectionExtensions` 中注册服务 +4. 在 ViewModel 中注入接口 + +### 5.2 添加新页面 + +1. 在 `KitX Dashboard/Views/Pages/` 创建 AXAML 视图 +2. 在 `KitX Dashboard/ViewModels/` 创建对应的 ViewModel +3. 在 `App.axaml` 中注册路由 + +### 5.3 添加新功能 + +1. 在 Core 层实现业务逻辑 +2. 通过事件或接口暴露功能 +3. 在 ViewModel 中调用接口 +4. 在 View 中展示结果 + +--- + +## 6. 测试 + +### 6.1 单元测试 + +```bash +# 运行所有测试 +dotnet test +``` + +### 6.2 集成测试 + +请参阅 `KitX-Dashboard-集成测试指导手册.md` + +--- + +## 7. 调试 + +### 7.1 日志调试 + +日志文件位置: +- Windows: `%LOCALAPPDATA%\KitX\logs\` +- macOS: `~/Library/Logs/KitX/` +- Linux: `~/.local/share/KitX/logs/` + +### 7.2 断点调试 + +在 Visual Studio 或 Rider 中设置断点,然后启动调试。 + +--- + +## 8. 贡献代码 + +### 8.1 代码规范 + +- 遵循 C# 编码规范 +- 所有公共接口和类必须有 XML 注释 +- 使用 ReactiveUI 进行响应式编程 + +### 8.2 提交规范 + +请使用 conventional commit 格式: +- `feat:` 新功能 +- `fix:` 修复 bug +- `docs:` 文档更新 +- `refactor:` 代码重构 + +--- + +## 9. 常见问题 + +### 9.1 编译错误 + +**问题**: 子模块未正确初始化 + +**解决**: +```bash +git submodule update --init --recursive +``` + +### 9.2 运行时错误 + +**问题**: 服务未注册 + +**解决**: 检查 `CoreServiceCollectionExtensions` 中的服务注册 + +--- + +## 10. 参考资料 + +- [Avalonia UI 文档](https://docs.avaloniaui.net/) +- [ReactiveUI 文档](https://reactiveui.net/) +- [Microsoft.Extensions.DependencyInjection 文档](https://docs.microsoft.com/dotnet/core/extensions/dependency-injection) + +--- + +**文档结束** + +*本文档为开发者提供 KitX Dashboard 开发指南。* diff --git "a/KitX Dashboard/Docs/KitX-Dashboard-\346\236\266\346\236\204\346\226\207\346\241\243.md" "b/KitX Dashboard/Docs/KitX-Dashboard-\346\236\266\346\236\204\346\226\207\346\241\243.md" new file mode 100644 index 00000000..948afba1 --- /dev/null +++ "b/KitX Dashboard/Docs/KitX-Dashboard-\346\236\266\346\236\204\346\226\207\346\241\243.md" @@ -0,0 +1,592 @@ +# KitX Dashboard 架构文档 + +## 文档信息 + +- **项目名称**: KitX Dashboard +- **文档版本**: v1.0 +- **创建日期**: 2026-02-19 +- **目的**: 描述 KitX Dashboard 的系统架构 + +--- + +## 1. 架构概述 + +### 架构核心(重要!务必遵循!) + +1. 与KitX Core有关的业务逻辑放在Core中,接口放在Core.Contract中,前端(Dashboard)中只能放置与UI直接相关的逻辑 +2. Dashboard中的对Core的调用必须使用DI容器通过接口调用 +3. 尽可能使用全局EventService中的事件系统,以避免私有孤立事件造成逻辑冗余或是漏洞(如造成事件触发的无限循环) +4. **Task.Run 与 ITasksService 的使用原则:** + - `KitX.Core.Tasks.TasksManager`(通过 `ITasksService` 接口访问)用于**业务逻辑任务**,提供统一的日志记录和异常处理 + - `System.Threading.Tasks.Task.Run()` 用于**内部基础设施任务**(如网络I/O、线程管理)和 **UI 辅助任务**(如动画) + - **Core 内部**:可以使用 `TasksManager.Instance` 直接访问单例 + - **UI 层(Dashboard)**:必须通过 DI 容器获取 `ITasksService` 实例,**禁止直接使用 `TasksManager.Instance`** + - 示例: + ```csharp + // ✅ Dashboard 中正确用法(通过 DI) + _tasksService.RunTaskAsync(async () => { ... }, nameof(MyTask)); + + // ❌ Dashboard 中错误用法(直接访问单例) + TasksManager.Instance.RunTaskAsync(...); + + // ✅ Core 内部正确用法(可直接访问) + TasksManager.Instance.RunTaskAsync(...); + ``` + +### 1.1 设计目标 + +KitX Dashboard 采用 Core-UI 分离架构,主要目标如下: + +1. **业务逻辑独立** - 将业务逻辑从 Dashboard 项目抽离到独立的 `KitX.Core` 项目 +2. **接口定义清晰** - 在 `KitX.Core.Contract` 中定义所有业务接口 +3. **依赖注入** - 使用 DI 容器管理依赖关系 +4. **事件驱动** - 使用事件总线进行组件间通信 +5. **可测试性** - Core 业务逻辑可独立测试 + +### 1.2 架构分层 + +```mermaid +flowchart TB + subgraph Client["KitX Client 进程 (Dashboard 或 CLI,单一进程)"] + subgraph UI["前端层 (UI)"] + Dashboard["Dashboard"] + CLI["CLI (Future)"] + + Dashboard --> VM_D["ViewModels"] + Dashboard --> V["Views"] + CLI --> C["Commands"] + CLI --> P["Presenters"] + end + + subgraph Core["Core 层"] + CM["ConfigManager"] + SM["SecurityManager"] + PM["PluginsManager"] + DDS["DevicesDiscoveryServer"] + AM["ActivityManager"] + etc["..."] + end + + Contracts["KitX.Core.Contract
(接口定义层)"] + + UI -->|"通过接口调用"| Contracts + Contracts -->|"实现接口"| Core + + Contracts -->|依赖接口| UI + Core -->|实现接口| Contracts + end + + style Client fill:#f9f,stroke:#333,stroke-width:2px + style UI fill:#bbf,stroke:#333,stroke-width:1px + style Core fill:#bfb,stroke:#333,stroke-width:1px + style Contracts fill:#fbb,stroke:#333,stroke-width:1px +``` + +--- + +## 2. 项目结构 + +### 2.1 解决方案结构 + +``` +KitX.sln +├── KitX Clients/ +│ ├── KitX Dashboard/ # UI 层 (Avalonia UI) +│ └── KitX Core/ # 业务逻辑层 +├── KitX Standard/ +│ └── KitX Core Contracts/ # 接口定义层 +└── KitX SDK/ # SDK 和工具 +``` + +### 2.2 Dashboard 项目结构 + +``` +KitX Dashboard/ +├── ViewModels/ # 视图模型层 +│ ├── MainWindowViewModel.cs +│ ├── HomePageViewModel.cs +│ ├── DevicesPageViewModel.cs +│ ├── PluginsPageViewModel.cs +│ └── SettingsPageViewModel.cs +├── Views/ # 视图层 +│ ├── MainWindow.axaml +│ ├── Pages/ +│ │ ├── HomePage.axaml +│ │ ├── DevicesPage.axaml +│ │ ├── PluginsPage.axaml +│ │ └── SettingsPage.axaml +│ └── Controls/ +├── Services/ # UI 层服务 +│ ├── UIStateService.cs # UI 状态管理服务 (静态服务) +│ └── ServiceAdapters.cs # 服务适配器 +├── Converters/ # 值转换器 +├── App.axaml # 应用程序定义 +└── App.axaml.cs # 应用程序代码 +``` + +### 2.3 Core 项目结构 + +``` +KitX.Core/ +├── Configuration/ # 配置管理 +│ ├── ConfigManager.cs # 实现 IConfigService, 委托给 ConfigLoader/ConfigSaver +│ ├── ConfigLoader.cs # 实现 IConfigLoader +│ ├── ConfigSaver.cs # 实现 IConfigSaver +│ └── AppConfig.cs +├── Device/ # 设备管理 +│ ├── DeviceService.cs # 实现 IDeviceService +│ ├── DevicesDiscoveryServer.cs # 实现 IDeviceDiscoveryService +│ ├── DevicesServer.cs # 实现 IDeviceServer +│ ├── DevicesOrganizer.cs +│ ├── PluginsServer.cs # 实现 IPluginServer (位于 Device 目录) +│ ├── DeviceCase.cs +│ ├── ServerStatus.cs # 服务器状态枚举 +│ ├── NetworkHelper.cs # 网络辅助类 +│ ├── OperatingSystemHelper.cs # 操作系统辅助类 +│ └── ExchangeKeyRequest.cs # 设备密钥交换请求模型 +├── Plugin/ # 插件管理 +│ ├── PluginsManager.cs # 实现 IPluginService +│ ├── PluginInstallation.cs # 插件安装记录实现 +│ └── PluginsServer.cs # 实现 IPluginServer +├── Security/ # 安全管理 +│ └── SecurityManager.cs # 实现 IDeviceKeyService, IEncryptionService +├── Activity/ # 活动记录 +│ └── ActivityManager.cs # 实现 IActivityService +├── Statistics/ # 统计服务 +│ └── StatisticsManager.cs # 实现 IStatisticsService +├── Workflow/ # 工作流 +│ └── WorkflowScriptService.cs # 实现 IWorkflowManagementService, IScriptExecutionService, IWorkflowPluginService, IBlockScriptService +├── Event/ # 事件系统 +│ ├── EventService.cs # 实现 IEventService +│ ├── EventNames.cs # 事件名称常量 +│ └── EventArgs.cs # 事件参数类 +├── Task/ # 任务调度 +│ └── TasksManager.cs # 实现 ITasksService +├── FileWatcher/ # 文件监控 +│ └── FileWatcherManager.cs # 实现 IFileWatcherService +├── Hotkey/ # 全局热键 +│ └── KeyHookManager.cs # 实现 IKeyHookService +├── Announcement/ # 公告系统 +│ └── AnnouncementManager.cs # 实现 IAnnouncementService +└── DI/ # 依赖注入 + └── CoreServiceCollectionExtensions.cs +``` + +--- + +## 3. 核心组件 + +### 3.1 配置管理 (Configuration) + +**组件**: `ConfigManager`, `ConfigLoader`, `ConfigSaver` +**接口**: `IConfigService`, `IConfigLoader`, `IConfigSaver` (位于 `KitX.Core.Contract`) + +**功能**: +- `ConfigManager` 协调者: 负责配置加载/保存调度、FileSystemWatcher 热重载、事件管理 +- `ConfigLoader`: 负责从 JSON 文件加载配置, 包含 `SecurityConfig` 特殊反序列化逻辑 +- `ConfigSaver`: 负责将配置序列化为 JSON 并写入文件, 更新元数据 +- 应用程序配置的加载、保存、热重载 +- 插件配置、安全配置、市场配置管理 +- 窗口、页面、Web、日志、IO、活动记录等完整配置体系 + +**数据结构**: +- `IAppConfig` - 完整应用配置 (包含 App, Windows, Pages, Web, Log, IO, Activity, Loaders) +- `IAppConf` - 应用基础配置 +- `IWindowsConf` / `IMainWindowConf` - 窗口配置 +- `IPagesConf` / `ISettingsPageConf` - 页面配置 +- `IWebConf` - 网络配置 +- `ILogConf` - 日志配置 +- `IIOConf` - IO 配置 +- `IActivityConf` - 活动记录配置 +- `ILoadersConf` - 加载器配置 +- `IAnnouncementConfig` - 公告配置 +- `IPluginsConfig` - 插件配置 +- `ISecurityConfig` - 安全配置 +- `WindowState` 枚举 - 窗口状态 +- `NavigationViewPaneDisplayMode` 枚举 - 导航显示模式 + +### 3.2 设备管理 (Device) + +**组件**: +- `DeviceService` - 设备管理服务 +- `DevicesDiscoveryServer` - UDP 发现服务 +- `DevicesServer` - HTTP API 服务 +- `PluginsServer` - WebSocket 插件服务器 +- `DevicesOrganizer` - 设备组织器 +- `DeviceCase` - 设备实例 +- `ServerStatus` - 服务器状态枚举 +- `NetworkHelper` - 网络辅助类 +- `OperatingSystemHelper` - 操作系统辅助类 + +**接口**: +- `IDeviceService` +- `IDeviceDiscoveryService` +- `IDeviceServer` +- `IPluginServer` + +**功能**: +- 设备发现 (UDP 广播) +- 设备认证和授权 +- 设备间通信 (HTTP API) +- 插件 WebSocket 通信 + +### 3.3 插件管理 (Plugin) + +**组件**: +- `PluginsManager` - 插件管理器 +- `PluginsServer` - WebSocket 服务 +- `PluginConnector` - 插件连接器 + +**接口**: +- `IPluginService` +- `IPluginServer` +- `IPluginConnector` + +**功能**: +- 插件包 (KXP) 导入和安装 +- 插件生命周期管理 +- 插件通信桥接 + +### 3.4 安全管理 (Security) + +**组件**: `SecurityManager` +**接口**: `IDeviceKeyService`, `IEncryptionService` + +**功能**: +- 设备密钥管理 (`IDeviceKeyService`) +- RSA/AES 加密解密 (`IEncryptionService`) +- 设备认证 + +### 3.5 活动记录 (Activity) + +**组件**: `ActivityManager` +**接口**: `IActivityService` + +**功能**: +- 应用活动记录 +- 用户行为追踪 + +### 3.6 统计服务 (Statistics) + +**组件**: `StatisticsManager` +**接口**: `IStatisticsService` + +**功能**: +- 应用使用时长统计 + +--- + +## 4. 依赖注入 + +### 4.1 DI 容器选择 + +使用 **Microsoft.Extensions.DependencyInjection** (MS.DI): + +**优点**: +- .NET 官方 DI 容器 +- 轻量级、高性能 +- 与 ASP.NET Core 生态集成良好 + +### 4.2 服务生命周期 + +| 服务类型 | 生命周期 | 说明 | +|----------|----------|------| +| IConfigService | Singleton | 全局配置,整个应用生命周期 | +| IDeviceKeyService | Singleton | 设备密钥管理,整个应用生命周期 | +| IEncryptionService | Singleton | 加密服务,整个应用生命周期 | +| IPluginService | Singleton | 插件管理,整个应用生命周期 | +| IDeviceService | Singleton | 设备管理,整个应用生命周期 | +| IEventService | Singleton | 事件总线,整个应用生命周期 | +| ITasksService | Singleton | 任务调度,整个应用生命周期 | +| IWorkflowManagementService | Singleton | 工作流管理,整个应用生命周期 | +| IScriptExecutionService | Singleton | 脚本执行,整个应用生命周期 | +| IWorkflowPluginService | Singleton | 工作流插件集成,整个应用生命周期 | +| IBlockScriptService | Singleton | BlockScript 服务,整个应用生命周期 | + +### 4.3 服务注册 + +```csharp +public static IServiceCollection AddCoreServices(this IServiceCollection services) +{ + // 单例服务 + services.AddSingleton(ConfigManager.Instance); + services.AddSingleton(SecurityManager.Instance); + services.AddSingleton(SecurityManager.Instance); + services.AddSingleton(PluginsManager.Instance); + services.AddSingleton(DeviceService.Instance); + services.AddSingleton(WorkflowScriptService.Instance); + services.AddSingleton(WorkflowScriptService.Instance); + services.AddSingleton(WorkflowScriptService.Instance); + services.AddSingleton(WorkflowScriptService.Instance); + // ... + return services; +} +``` + +--- + +## 5. 事件系统 + +### 5.1 事件驱动架构 + +Core 层通过事件向 UI 层推送状态变化: + +```csharp +// Core 层触发事件 +DeviceDiscovered?.Invoke(this, new DeviceDiscoveredEventArgs +{ + DeviceInfo = deviceInfo +}); + +// UI 层订阅事件 +_deviceService.DeviceDiscovered += OnDeviceDiscovered; +``` + +### 5.2 服务事件 (IService Events) + +以下事件由各服务接口定义,直接订阅即可: + +| 事件 | 说明 | 事件参数 | +|------|------|----------| +| DeviceDiscovered | 设备发现 | DeviceDiscoveredEventArgs | +| DeviceOffline | 设备离线 | DeviceOfflineEventArgs | +| MainDeviceChanged | 主设备变更 | MainDeviceChangedEventArgs | +| PluginStatusChanged | 插件状态变更 | PluginStatusChangedEventArgs | +| ConfigChanged | 配置变更 | ConfigChangedEventArgs | + +### 5.3 事件总线事件 (IEventService Events) + +通过 `IEventService.Publish(EventNames.XXX, args)` 发布的事件: + +| 事件名称 | 说明 | +|----------|------| +| LanguageChanged | 语言变更 | +| GreetingTextIntervalUpdated | 问候文本间隔更新 | +| AppConfigChanged | 应用配置变更 | +| PluginsConfigChanged | 插件配置变更 | +| MicaOpacityChanged | Mica 透明度变更 | +| DevelopSettingsChanged | 开发者设置变更 | +| LogConfigUpdated | 日志配置更新 | +| ThemeConfigChanged | 主题配置变更 | +| UseStatisticsChanged | 使用统计变更 | +| DevicesServerPortChanged | 设备服务器端口变更 | +| PluginsServerPortChanged | 插件服务器端口变更 | +| OnActivitiesUpdated | 活动记录更新 | +| OnReceiveCancelExchangingDeviceKey | 取消交换设备密钥 | +| OnExiting | 退出事件 | +| OnReceivingDeviceInfo | 接收设备信息 | +| OnConfigHotReloaded | 配置热重载 | +| OnAcceptingDeviceKey | 接受设备密钥 | + +### 5.4 过时的 API (Legacy / Obsolete) + +> **注意**: 以下 API 已标记为 `[Obsolete]`,不建议在新代码中使用。 + +**EventService 静态类** (已过时): +- 旧的 `EventService` 静态类及其静态事件 (如 `EventService.LanguageChanged`, `EventService.AppConfigChanged` 等) 已过时 +- 静态方法 `EventService.Invoke(string eventName, object[]? objects)` 已标记为废弃 + +**推荐的替代方案**: +1. 通过依赖注入获取 `IEventService` 实例 +2. 使用 `EventNames` 常量类定义事件名称 +3. 使用 `IEventService.Publish(EventNames.XXX, args)` 发布事件 + +**迁移示例**: +```csharp +// ❌ 过时的写法 (不要使用) +EventService.LanguageChanged.Invoke(); + +// ✅ 推荐的写法 +var eventService = App.GetService(); +eventService.Publish(EventNames.LanguageChanged, EventArgs.Empty); +``` + +--- + +## 6. 网络架构 + +### 6.1 通信协议 + +> ⚠️ 注意:以下端口为 Legacy 实际值,与旧文档(5231/5232/5233)不符,已按实际代码修正。 + +| 服务 | 协议 | 默认端口 | 说明 | +|------|------|----------|------| +| 设备发现(发送) | UDP | 23404 | AppConfig.Web.UdpPortSend | +| 设备发现(接收) | UDP | 24040 | AppConfig.Web.UdpPortReceive | +| 设备服务器 | HTTP | 动态(0) | 运行时分配可用端口;AppConfig.Web.UserSpecifiedDevicesServerPort | +| 插件服务器 | WebSocket | 动态(0) | 运行时分配可用端口;AppConfig.Web.UserSpecifiedPluginsServerPort | + +### 6.2 网络拓扑 + +```mermaid +flowchart LR + subgraph DeviceA[Device A] + A_UDP[UDP:23404/24040] + A_HTTP[HTTP:动态] + A_WS[WS:动态] + end + + subgraph DeviceB[Device B] + B_UDP[UDP:23404/24040] + B_HTTP[HTTP:动态] + B_WS[WS:动态] + end + + A_UDP <-->|UDP Broadcast| B_UDP + A_HTTP <-->|HTTP API| B_HTTP + A_WS <-->|WebSocket| B_WS + + style DeviceA fill:#e1f5fe,stroke:#01579b + style DeviceB fill:#e1f5fe,stroke:#01579b +``` + +--- + +## 7. 数据流 + +### 7.1 用户交互数据流 + +```mermaid +flowchart TB + A[用户操作
点击按钮] --> B[View
AXAML] + B --> C[ViewModel
处理逻辑] + C --> D[Core Service
业务逻辑] + D --> E[事件通知] + E --> F[ViewModel
更新数据] + F --> G[View
更新界面] + + style A fill:#e8f5e9 + style G fill:#e8f5e9 +``` + +### 7.2 设备发现数据流 + +```mermaid +flowchart TB + A[DevicesDiscoveryServer
UDP 广播] --> B[发现新设备] + B --> C[DeviceService 处理] + C --> D[触发 DeviceDiscovered 事件] + D --> E[ViewModel 订阅事件] + E --> F[更新 UI 设备列表] + + style A fill:#fff3e0 + style F fill:#fff3e0 +``` + +--- + +## 8. 安全机制 + +### 8.1 加密通信 + +- 使用 RSA 进行密钥交换 +- 使用 AES 进行数据加密 +- 设备间通信全部加密 + +### 8.2 设备认证 + +- 基于公钥基础设施 (PKI) +- 设备密钥管理 +- 授权设备列表 + +--- + +## 9. 配置管理 + +### 9.1 配置文件 + +| 配置文件 | 说明 | +|----------|------| +| AppConfig.json | 应用配置 | +| PluginsConfig.json | 插件配置 | +| SecurityConfig.json | 安全配置 | + +### 9.2 配置加载流程 + +```mermaid +flowchart TB + A([应用启动]) --> B[ConfigManager.Load] + B --> C[读取 JSON 文件] + C --> D[反序列化为对象] + D --> E[发布 ConfigChanged 事件] + E --> F([UI 更新]) + + style A fill:#fce4ec + style F fill:#fce4ec +``` + +--- + +## 10. UI 状态管理 + +### 10.1 UIStateService + +`UIStateService` 是 Dashboard 项目特有的 UI 状态管理服务,**不属于 Core 层**。它负责管理 UI 相关的共享状态: + +**功能**: +- 设备列表状态 (`DeviceCases`) +- 工作流列表状态 (`WorkflowCases`) +- 插件列表状态 (`PluginInfos`) +- 窗口引用管理 (`MainWindow`, `PluginsLaunchWindow`, `Windows`) +- 窗口显示功能 (`ShowWindow`) + +**设计说明**: +- 使用静态类实现,因为需要被 ViewModels 和 Code-behind 共同访问 +- 包含 Avalonia UI 特定类型(如 `Window`),不适合放在 Core 项目中 +- 是一个临时方案,未来可能考虑重构为 DI 单例服务 + +**使用示例**: +```csharp +// ViewModel 中访问 UI 状态 +var devices = UIStateService.DeviceCases; +var plugins = UIStateService.PluginInfos; + +// Code-behind 中设置主窗口引用 +UIStateService.MainWindow = this; + +// 显示窗口 +UIStateService.ShowWindow(new PluginDetailWindow()); +``` + +--- + +## 11. 扩展性 + +### 10.1 添加新服务 + +1. 在 `KitX.Core.Contract` 定义接口 +2. 在 `KitX.Core` 实现接口 +3. 在 `CoreServiceCollectionExtensions` 注册 + +### 10.2 添加新前端 + +未来可支持 CLI 版本: +- 引用 `KitX.Core.Contract` +- 引用 `KitX.Core` +- 使用相同的服务接口 + +--- + +## 11. 附录 + +### 11.1 术语表 + +| 术语 | 说明 | +|------|------| +| Core 层 | 业务逻辑层 | +| UI 层 | 用户界面层 | +| DI | 依赖注入 | +| 接口隔离 | UI 层只依赖接口 | +| 进程内通信 | 同一进程内方法调用 | + +### 11.2 参考资料 + +- [Microsoft.Extensions.DependencyInjection](https://docs.microsoft.com/dotnet/core/extensions/dependency-injection) +- [Avalonia UI](https://avaloniaui.net/) +- [ReactiveUI](https://reactiveui.net/) + +--- + +**文档结束** + +*本文档描述了 KitX Dashboard 的系统架构。* diff --git a/KitX Dashboard/Generators/GreetingTextGenerator.cs b/KitX Dashboard/Generators/GreetingTextGenerator.cs index 888939b0..c3e0f131 100644 --- a/KitX Dashboard/Generators/GreetingTextGenerator.cs +++ b/KitX Dashboard/Generators/GreetingTextGenerator.cs @@ -1,14 +1,18 @@ -using System; -using KitX.Dashboard.Configuration; +using KitX.Core.Contract.Configuration; +using System; namespace KitX.Dashboard.Generators; -internal class GreetingTextGenerator : ConfigFetcher +internal class GreetingTextGenerator { private static int PreviousIndex = 0; private static readonly Random random = new(); + private static IConfigService? _configService; + + private static IConfigService ConfigService => _configService ??= App.GetService(); + internal static string GetKey() { var key = $"Text_Greeting_%Step%_%Index%"; @@ -44,22 +48,23 @@ internal static int GenerateRandomIndex(Step step) while (result == PreviousIndex) { + var windows = ConfigService.AppConfig.Windows; switch (step) { case Step.Morning: - result = random.Next(1, AppConfig.Windows.MainWindow.GreetingTextCount_Morning + 1); + result = random.Next(1, windows.MainWindow.GreetingTextCount_Morning + 1); break; case Step.Noon: - result = random.Next(1, AppConfig.Windows.MainWindow.GreetingTextCount_Noon + 1); + result = random.Next(1, windows.MainWindow.GreetingTextCount_Noon + 1); break; case Step.AfterNoon: - result = random.Next(1, AppConfig.Windows.MainWindow.GreetingTextCount_AfterNoon + 1); + result = random.Next(1, windows.MainWindow.GreetingTextCount_AfterNoon + 1); break; case Step.Evening: - result = random.Next(1, AppConfig.Windows.MainWindow.GreetingTextCount_Evening + 1); + result = random.Next(1, windows.MainWindow.GreetingTextCount_Evening + 1); break; case Step.Night: - result = random.Next(1, AppConfig.Windows.MainWindow.GreetingTextCount_Night + 1); + result = random.Next(1, windows.MainWindow.GreetingTextCount_Night + 1); break; } } diff --git a/KitX Dashboard/Instances.cs b/KitX Dashboard/Instances.cs deleted file mode 100644 index 23cbe4d1..00000000 --- a/KitX Dashboard/Instances.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Linq; -using Common.BasicHelper.Core.TaskSystem; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Managers; -using LiteDB; - -namespace KitX.Dashboard; - -public static class Instances -{ - public static SignalTasksManager? SignalTasksManager { get; set; } - - public static WebManager? WebManager { get; set; } - - public static FileWatcherManager? FileWatcherManager { get; set; } - - public static LiteDatabase? ActivitiesDataBase { get; set; } - - public static KeyHookManager? KeyHookManager { get; set; } - - public static SecurityManager? SecurityManager { get; set; } - - internal static void Initialize() - { - const string location = $"{nameof(Instances)}.{nameof(Initialize)}"; - - TasksManager.RunTask( - () => - { - TasksManager.RunTask( - () => SignalTasksManager = new(), - location.Append("." + nameof(SignalTasksManager)), - catchException: true - ); - - //TasksManager.RunTask( - // () => KeyHookManager = new KeyHookManager().Hook(), - // location.Append("." + nameof(KeyHookManager)), - // catchException: true - //); - - TasksManager.RunTask( - () => SecurityManager = SecurityManager.Instance, - location.Append("." + nameof(SecurityManager)), - catchException: true - ); - - TasksManager.RunTask( - () => - { - if (ConstantTable.EnabledConfigFileHotReload) - FileWatcherManager = new(); - }, - location.Append("." + nameof(FileWatcherManager)), - catchException: true - ); - }, - location, - catchException: true - ); - } -} diff --git a/KitX Dashboard/KitX.Dashboard.csproj b/KitX Dashboard/KitX.Dashboard.csproj index 513fbd80..f361b3cb 100644 --- a/KitX Dashboard/KitX.Dashboard.csproj +++ b/KitX Dashboard/KitX.Dashboard.csproj @@ -1,139 +1,149 @@ - - - WinExe - net8.0 - enable - true - Assets\KitX-Icon-256x.ico - en - app.manifest - false - KitX.Dashboard - - - - $(Version) - $(Version) - - 3.24.10.$([System.DateTime]::UtcNow.Date.Subtract($([System.DateTime]::Parse("2020-10-01"))).TotalDays) - - - - - - False - - ../Libraries - ./Libraries - - - - - - False - - False - - - - True - - - Info - - - - - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - - PreserveNewest - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - AnnouncementsWindow.axaml - - - DevicesPage.axaml - - - \ No newline at end of file + + + WinExe + net10.0 + enable + true + Assets\KitX-Icon-256x.ico + en + app.manifest + false + KitX.Dashboard + + + + $(Version) + $(Version) + + 3.24.10.$([System.DateTime]::UtcNow.Date.Subtract($([System.DateTime]::Parse("2020-10-01"))).TotalDays) + + + + + + False + + ../Libraries + ./Libraries + + + + + + False + + False + + + + True + + + Info + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AnnouncementsWindow.axaml + + + DevicesPage.axaml + + + + + diff --git a/KitX Dashboard/Languages/en-us.axaml b/KitX Dashboard/Languages/en-us.axaml index cbcbff07..8cd9f732 100644 --- a/KitX Dashboard/Languages/en-us.axaml +++ b/KitX Dashboard/Languages/en-us.axaml @@ -113,6 +113,81 @@ Authorize the device and exchange device key Revoke the authorization for this device (generate a new local key and exchange it with other devices) $count workflows owned + No workflows yet, create your first one! + Search workflows... + New Workflow + Refresh + Run + Stop + Rename + Delete + Stopped + Running + New Workflow + Delete Workflow + Are you sure you want to delete "$name"? + Error + + Workflow Editor + Back to Dashboard + Dashboard + Run + Stop + Description and Author + Workflow description... + Author name... + Ready + Running... + Code Sections + + Add Helper Function + Main Program + X + Variable Constants + Reset All + Helper Function Settings + Reset to default + Return: + + + Add Parameter + Type + Name + Remove + Node Palette + Entry + Flow Control + Branch + Loop + Break + Data + Const + Variable + Actions + Plugin Functions + Call (Plugin) + Helper Functions + Call Helper + Print + Pause + Output + Clear Output + Type: + Value: + value... + Rename + Delete + Move to Scope: + Remove from Scope + Helper Function: $name + Nodes: $nodes Connections: $connections + + Rename Const + Enter new constant name: + Rename Variable + Enter new variable name: + Rename Get Node + Rename Set Node + Rename Scope Block + Enter new scope name: General Local Plugins Program Files Path Local Plugins Program Data Path diff --git a/KitX Dashboard/Languages/fr-fr.axaml b/KitX Dashboard/Languages/fr-fr.axaml index fa362dcc..597cbe71 100644 --- a/KitX Dashboard/Languages/fr-fr.axaml +++ b/KitX Dashboard/Languages/fr-fr.axaml @@ -113,6 +113,81 @@ Autoriser le dispositif et échanger les clés Révoquer l'autorisation de ce dispositif (générer une nouvelle clé locale et l'échanger avec d'autres appareils) Vous avez $count workflows + Aucun workflow, créez votre premier ! + Rechercher des workflows... + Nouveau workflow + Actualiser + Exécuter + Arrêter + Renommer + Supprimer + Arrêté + En cours + Nouveau workflow + Supprimer le workflow + Voulez-vous vraiment supprimer « $name » ? + Erreur + + Éditeur de workflow + Retour au tableau de bord + Tableau de bord + Exécuter + Arrêter + Description et auteur + Description du workflow... + Nom de l'auteur... + Prêt + En cours d'exécution... + Sections de code + + Ajouter une fonction d'assistance + Programme principal + X + Constantes de variable + Tout réinitialiser + Paramètres de fonction d'assistance + Réinitialiser par défaut + Retour : + + + Ajouter un paramètre + Type + Nom + Supprimer + Palette de nœuds + Entrée + Contrôle de flux + Branche + Boucle + Arrêt + Données + Constante + Variable + Actions + Fonctions de plugin + Appel (Plugin) + Fonctions d'assistance + Appeler assistant + Imprimer + Pause + Sortie + Effacer la sortie + Type : + Valeur : + valeur... + Renommer + Supprimer + Déplacer vers la portée : + Retirer de la portée + Fonction d'assistance : $name + Nœuds : $nodes Connexions : $connections + + Renommer la constante + Entrez le nouveau nom de la constante : + Renommer la variable + Entrez le nouveau nom de la variable : + Renommer le nœud Get + Renommer le nœud Set + Renommer le bloc de portée + Entrez le nouveau nom de la portée : Universel répertoire local des plugins Répertoire de données du plug-in local diff --git a/KitX Dashboard/Languages/ja-jp.axaml b/KitX Dashboard/Languages/ja-jp.axaml index 2100c53f..2926101c 100644 --- a/KitX Dashboard/Languages/ja-jp.axaml +++ b/KitX Dashboard/Languages/ja-jp.axaml @@ -113,6 +113,81 @@ デバイスを承認し、キーを交換する このデバイスの認可を取消し(新しいローカルキーを生成して他のデバイスと交換する) ワークフローが $count あります + ワークフローがありません。最初のものを作成しましょう! + ワークフローを検索... + 新規ワークフロー + 更新 + 実行 + 停止 + 名前変更 + 削除 + 停止中 + 実行中 + 新規ワークフロー + ワークフローを削除 + 「$name」を削除してもよろしいですか? + エラー + + ワークフローエディタ + ダッシュボードに戻る + ダッシュボード + 実行 + 停止 + 説明と作者 + ワークフローの説明... + 作者名... + 準備完了 + 実行中... + コードセクション + + ヘルパー関数を追加 + メインプログラム + X + 変数定数 + すべてリセット + ヘルパー関数設定 + デフォルトに戻す + 戻り値: + + + パラメータを追加 + + 名前 + 削除 + ノードパレット + エントリ + フロー制御 + 分岐 + ループ + 中断 + データ + 定数 + 変数 + アクション + プラグイン関数 + 呼び出し (プラグイン) + ヘルパー関数 + ヘルパーを呼び出し + 印刷 + 一時停止 + 出力 + 出力をクリア + 型: + 値: + 値... + 名前変更 + 削除 + スコープに移動: + スコープから削除 + ヘルパー関数: $name + ノード: $nodes 接続: $connections + + 定数の名前変更 + 新しい定数名を入力: + 変数の名前変更 + 新しい変数名を入力: + Get ノードの名前変更 + Set ノードの名前変更 + スコープブロックの名前変更 + 新しいスコープ名を入力: ユニバーサル ローカル プラグイン ディレクトリ ローカル プラグイン データ ディレクトリ diff --git a/KitX Dashboard/Languages/ko-kr.axaml b/KitX Dashboard/Languages/ko-kr.axaml index b603e423..4798484f 100644 --- a/KitX Dashboard/Languages/ko-kr.axaml +++ b/KitX Dashboard/Languages/ko-kr.axaml @@ -113,6 +113,81 @@ 장치를 권한을 부여하고 키를 교환하세요 이 장치의 권한을 취소하고(새로운 로컬 키를 생성하여 다른 장치와 교환) 합니다 워크플로가 $count개 있습니다 + 워크플로가 없습니다. 첫 번째를 만들어 보세요! + 워크플로 검색... + 새 워크플로 + 새로고침 + 실행 + 중지 + 이름 변경 + 삭제 + 중지됨 + 실행 중 + 새 워크플로 + 워크플로 삭제 + "$name"을(를) 삭제하시겠습니까? + 오류 + + 워크플로 편집기 + 대시보드로 돌아가기 + 대시보드 + 실행 + 중지 + 설명 및 작성자 + 워크플로 설명... + 작성자 이름... + 준비 + 실행 중... + 코드 섹션 + + 헬퍼 함수 추가 + 메인 프로그램 + X + 변수 상수 + 모두 재설정 + 헬퍼 함수 설정 + 기본값으로 재설정 + 반환: + + + 매개변수 추가 + 유형 + 이름 + 제거 + 노드 팔레트 + 진입 + 흐름 제어 + 분기 + 루프 + 중단 + 데이터 + 상수 + 변수 + 작업 + 플러그인 함수 + 호출 (플러그인) + 헬퍼 함수 + 헬퍼 호출 + 인쇄 + 일시정지 + 출력 + 출력 지우기 + 유형: + 값: + 값... + 이름 변경 + 삭제 + 스코프로 이동: + 스코프에서 제거 + 헬퍼 함수: $name + 노드: $nodes 연결: $connections + + 상수 이름 변경 + 새 상수 이름 입력: + 변수 이름 변경 + 새 변수 이름 입력: + Get 노드 이름 변경 + Set 노드 이름 변경 + 스코프 블록 이름 변경 + 새 스코프 이름 입력: 만능인 로컬 플러그인 디렉토리 로컬 플러그인 데이터 디렉토리 diff --git a/KitX Dashboard/Languages/ru-ru.axaml b/KitX Dashboard/Languages/ru-ru.axaml index 1d8055c6..51bf8a4a 100644 --- a/KitX Dashboard/Languages/ru-ru.axaml +++ b/KitX Dashboard/Languages/ru-ru.axaml @@ -113,6 +113,81 @@ Аутентифицируйте устройство и обменивайте ключи Отозвать авторизацию для этого устройства (сгенерировать новый локальный ключ и обменять его с другими устройствами) У вас есть $count рабочих процессов + Нет рабочих процессов, создайте первый! + Поиск рабочих процессов... + Новый процесс + Обновить + Запустить + Остановить + Переименовать + Удалить + Остановлен + Выполняется + Новый процесс + Удалить рабочий процесс + Вы уверены, что хотите удалить «$name»? + Ошибка + + Редактор рабочих процессов + Вернуться к панели управления + Панель управления + Запустить + Остановить + Описание и автор + Описание процесса... + Имя автора... + Готов + Выполняется... + Секции кода + + Добавить вспомогательную функцию + Основная программа + X + Переменные константы + Сбросить всё + Настройки вспомогательной функции + Сбросить по умолчанию + Возврат: + + + Добавить параметр + Тип + Имя + Удалить + Палитра узлов + Вход + Управление потоком + Ветвление + Цикл + Прерывание + Данные + Константа + Переменная + Действия + Функции плагинов + Вызов (плагин) + Вспомогательные функции + Вызов вспомогательной + Печать + Пауза + Вывод + Очистить вывод + Тип: + Значение: + значение... + Переименовать + Удалить + Переместить в область: + Удалить из области + Вспомогательная функция: $name + Узлы: $nodes Соединения: $connections + + Переименовать константу + Введите новое имя константы: + Переименовать переменную + Введите новое имя переменной: + Переименовать узел Get + Переименовать узел Set + Переименовать блок области + Введите новое имя области: Универсальный локальный каталог плагинов Локальный каталог данных плагина diff --git a/KitX Dashboard/Languages/zh-cn.axaml b/KitX Dashboard/Languages/zh-cn.axaml index f9bc1cb6..d3e1ea95 100644 --- a/KitX Dashboard/Languages/zh-cn.axaml +++ b/KitX Dashboard/Languages/zh-cn.axaml @@ -113,6 +113,81 @@ 授权该设备并交换密钥 取消授权该设备 (生成新的本机密钥并与其他设备交换) 您有 $count 个工作流 + 还没有工作流,创建你的第一个吧! + 搜索工作流... + 新建工作流 + 刷新 + 运行 + 停止 + 重命名 + 删除 + 已停止 + 运行中 + 新建工作流 + 删除工作流 + 确定要删除"$name"吗? + 错误 + + 工作流编辑器 + 返回仪表板 + 仪表板 + 运行 + 停止 + 简介和作者 + 工作流简介... + 作者名... + 就绪 + 运行中... + 代码区段 + + 添加辅助函数 + 主程序 + X + 变量常量 + 全部重置 + 辅助函数设置 + 重置为默认 + 返回: + + + 添加参数 + 类型 + 名称 + 移除 + 节点面板 + 入口 + 流程控制 + 分支 + 循环 + 中断 + 数据 + 常量 + 变量 + 操作 + 插件函数 + 调用 (插件) + 辅助函数 + 调用辅助 + 打印 + 暂停 + 输出 + 清空输出 + 类型: + 值: + 值... + 重命名 + 删除 + 移动到作用域: + 从作用域移除 + 辅助函数: $name + 节点: $nodes 连接: $connections + + 重命名常量 + 输入新的常量名: + 重命名变量 + 输入新的变量名: + 重命名 Get 节点 + 重命名 Set 节点 + 重命名作用域块 + 输入新的作用域名: 通用 本地插件程序目录 本地插件数据目录 diff --git a/KitX Dashboard/Languages/zh-tw.axaml b/KitX Dashboard/Languages/zh-tw.axaml index dc15c11c..93bcbd8b 100644 --- a/KitX Dashboard/Languages/zh-tw.axaml +++ b/KitX Dashboard/Languages/zh-tw.axaml @@ -113,6 +113,81 @@ 授權該裝置並交換金鑰 取消此裝置的授權(產生新的本機金鑰並與其他裝置進行交換) 您有 $count 個工作流 + 還沒有工作流,建立你的第一個吧! + 搜尋工作流... + 新建工作流 + 重新整理 + 執行 + 停止 + 重新命名 + 刪除 + 已停止 + 執行中 + 新建工作流 + 刪除工作流 + 確定要刪除「$name」嗎? + 錯誤 + + 工作流編輯器 + 返回儀表板 + 儀表板 + 執行 + 停止 + 簡介和作者 + 工作流簡介... + 作者名... + 就緒 + 執行中... + 程式碼區段 + + 新增輔助函式 + 主程式 + X + 變數常量 + 全部重設 + 輔助函式設定 + 重設為預設 + 傳回: + + + 新增參數 + 類型 + 名稱 + 移除 + 節點面板 + 入口 + 流程控制 + 分支 + 迴圈 + 中斷 + 資料 + 常數 + 變數 + 操作 + 外掛函式 + 呼叫 (外掛) + 輔助函式 + 呼叫輔助 + 列印 + 暫停 + 輸出 + 清空輸出 + 類型: + 值: + 值... + 重新命名 + 刪除 + 移動到作用域: + 從作用域移除 + 輔助函式: $name + 節點: $nodes 連接: $connections + + 重新命名常數 + 輸入新的常數名稱: + 重新命名變數 + 輸入新的變數名稱: + 重新命名 Get 節點 + 重新命名 Set 節點 + 重新命名作用域塊 + 輸入新的作用域名稱: 通用 本機插件的程序路徑 本機插件的文件路徑 diff --git a/KitX Dashboard/Managers/ActivityManager.cs b/KitX Dashboard/Managers/ActivityManager.cs deleted file mode 100644 index a06a21e9..00000000 --- a/KitX Dashboard/Managers/ActivityManager.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using Common.Activity; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Names; -using LiteDB; - -namespace KitX.Dashboard.Managers; - -internal class ActivityManager -{ - private static readonly object _activityRecordLock = new(); - - public static string CollectionName => DateTime.UtcNow.ToString("yyyy_MM").Num2UpperChar(); - - private static Activity? _appActivity; - - public static List ReadActivities() - { - if (Instances.ActivitiesDataBase is LiteDatabase db) - { - var col = db.GetCollection(CollectionName); - - return col.FindAll().ToList(); - } - else - return []; - } - - public static void Record(Activity activity, Expression> keySelector) - { - const string location = $"{nameof(ActivityManager)}.{nameof(Record)}"; - - TasksManager.RunTask( - () => - { - lock (_activityRecordLock) - { - if (Instances.ActivitiesDataBase is LiteDatabase db) - { - var col = db.GetCollection(CollectionName); - - col?.Insert(activity); - - col?.EnsureIndex(keySelector); - - ConfigManager.Instance.AppConfig.Activity.TotalRecorded += col is null ? 0 : 1; - - db.Commit(); - } - } - }, - location, - catchException: true - ); - } - - public static void Update(Activity activity) - { - const string location = $"{nameof(ActivityManager)}.{nameof(Update)}"; - - TasksManager.RunTask( - () => - { - lock (_activityRecordLock) - { - if (Instances.ActivitiesDataBase is LiteDatabase db) - { - var col = db.GetCollection(CollectionName); - - col?.Update(activity); - - db.Commit(); - } - } - }, - location, - catchException: true - ); - } - - public static void RecordAppStart() - { - var activity = new Activity() - { - Id = ConfigManager.Instance.AppConfig.Activity.TotalRecorded, - Name = nameof(ActivityNames.AppLifetime), - Author = ConstantTable.AppFullName, - Title = ActivityTitles.AppStart, - Category = nameof(ActivitySortNames.DashboardEvent), - IconKind = Material.Icons.MaterialIconKind.RocketLaunch, - }.Open(ConstantTable.AppFullName); - - _appActivity = activity; - - Record(activity, x => x.Id); - } - - public static void RecordAppExit() - { - if (_appActivity is Activity activity) - { - activity.Close(ConstantTable.AppFullName); - - Update(activity); - } - } -} diff --git a/KitX Dashboard/Managers/AnnouncementManager.cs b/KitX Dashboard/Managers/AnnouncementManager.cs deleted file mode 100644 index 3f325ae9..00000000 --- a/KitX Dashboard/Managers/AnnouncementManager.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Avalonia.Threading; -using KitX.Dashboard.Views; -using MsBox.Avalonia; -using MsBox.Avalonia.Enums; -using Serilog; - -namespace KitX.Dashboard.Managers; - -internal class AnnouncementManager -{ - public static async Task CheckNewAnnouncements() - { - const string location = $"{nameof(AnnouncementsWindow)}.{nameof(CheckNewAnnouncements)}"; - - var appConfig = ConfigManager.Instance.AppConfig; - - var linkBase = new StringBuilder().Append("https://").Append(appConfig.Web.ApiServer).Append(appConfig.Web.ApiPath).ToString(); - - var link = new StringBuilder().Append(linkBase).Append(ConstantTable.ApiGetAnnouncements).ToString(); - - try - { - using var client = new HttpClient(); - - client.DefaultRequestHeaders.Accept.Clear(); - - var msg = await client.GetStringAsync(link); - - var list = JsonSerializer.Deserialize>(msg); - - var accepted = ConfigManager.Instance.AnnouncementConfig.Accepted; - - if (list is null) - return; - - var unreads = (from item in list where !accepted.Contains(item) select DateTime.Parse(item)).ToList(); - - var src = new Dictionary(); - - foreach (var item in unreads) - { - var apiLink = new StringBuilder() - .Append($"{linkBase}{ConstantTable.ApiGetAnnouncement}") - .Append('?') - .Append($"lang={ConfigManager.Instance.AppConfig.App.AppLanguage}") - .Append('&') - .Append($"date={item:yyyy-MM-dd HH-mm}") - .ToString(); - - var md = JsonSerializer.Deserialize(await client.GetStringAsync(apiLink)); - - if (md is not null) - src.Add(item.ToString("yyyy-MM-dd HH:mm"), md); - } - - if (unreads.Count > 0) - { - Dispatcher.UIThread.Post(() => - { - var toast = new AnnouncementsWindow().UpdateSource(src); - - ViewInstances.ShowWindow(toast); - }); - } - } - catch (Exception ex) - { - Log.Error(ex, $"In {location}: {ex.Message}"); - - Dispatcher.UIThread.Post(() => - { - var content = new StringBuilder().AppendLine($"GET: {link}").AppendLine().AppendLine(ex.StackTrace).ToString(); - - var box = MessageBoxManager.GetMessageBoxStandard(ex.Message, content, icon: Icon.Error).ShowWindowAsync(); - }); - } - } -} diff --git a/KitX Dashboard/Managers/ConfigManager.cs b/KitX Dashboard/Managers/ConfigManager.cs deleted file mode 100644 index 069c3b6b..00000000 --- a/KitX Dashboard/Managers/ConfigManager.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Configuration; -using KitX.Dashboard.Names; -using KitX.Dashboard.Services; -using Serilog; - -namespace KitX.Dashboard.Managers; - -public class ConfigManager -{ - private static ConfigManager? _instance; - - public static ConfigManager Instance => _instance ??= new ConfigManager().SetLocation("./Config/").Load(); - - internal class ConfigManagerInfo - { - private string? _location; - - internal string? Location - { - get => _location; - set - { - ArgumentNullException.ThrowIfNull(value, nameof(Location)); - - _location = Path.GetFullPath(value); - - if (!Directory.Exists(_location)) - Directory.CreateDirectory(_location); - } - } - } - - private readonly Dictionary _configs; - - internal ConfigManagerInfo? Infos; - - public ConfigManager() - { - Infos = new(); - - _configs = []; - - InitEvents(); - } - - private void InitEvents() - { - const string location = $"{nameof(ConfigManager)}.{nameof(InitEvents)}"; - - TasksManager.RunTask( - () => - { - EventService.AppConfigChanged += () => - { - Instances.FileWatcherManager!.IncreaseExceptCount(AppConfig.ConfigFileWatcherName!); - - AppConfig.Save(AppConfig.ConfigFileLocation!); - }; - - EventService.PluginsConfigChanged += () => - { - Instances.FileWatcherManager!.IncreaseExceptCount(PluginsConfig.ConfigFileWatcherName!); - - PluginsConfig.Save(PluginsConfig.ConfigFileLocation!); - }; - }, - location - ); - } - - public ConfigManager SetLocation(string location) - { - if (Infos is not null) - Infos.Location = location; - - return this; - } - - private void RegisterFileWatcher(T config) - where T : ConfigBase, new() - { - var name = "ConfigFileWatcher".Append(typeof(T).Name); - - var path = config.ConfigFileLocation!; - - config.ConfigFileWatcherName = name; - - config.Save(config.ConfigFileLocation!); - - Instances.FileWatcherManager!.RegisterWatcher( - name, - path, - (_, y) => - { - const string location = $"{nameof(ConfigManager)}.{nameof(RegisterFileWatcher)}"; - - Log.Information($"FileChanged: {name} | {y.Name}, {y.ChangeType}"); - - try - { - _configs[typeof(T).Name] = path.Load(); - } - catch (Exception e) - { - Log.Error(e, $"In {location}: {e.Message}"); - } - } - ); - } - - public ConfigManager LoadConfigFile() - where T : ConfigBase, new() - { - var name = typeof(T).Name; - - ArgumentNullException.ThrowIfNull(name, nameof(name)); - - var path = $"{Infos?.Location}{name}.json".GetFullPath(); - - var config = path.Load().SetConfigFileLocation(path).Save(path); - - _configs.Add(name, config); - - if (ConstantTable.EnabledConfigFileHotReload) - AppFramework.AfterInitailization(() => - { - Instances.SignalTasksManager!.SignalRun( - nameof(SignalsNames.FileWatcherManagerInitializedSignal), - () => RegisterFileWatcher(config) - ); - }); - - return this; - } - - public ConfigManager Load() - { - const string location = $"{nameof(ConfigManager)}.{nameof(Load)}"; - - TasksManager.RunTask( - () => - { - LoadConfigFile(); - LoadConfigFile(); - LoadConfigFile(); - LoadConfigFile(); - LoadConfigFile(); - }, - location, - catchException: false - ); - - return this; - } - - public ConfigManager SaveAll() - { - const string location = $"{nameof(ConfigManager)}.{nameof(SaveAll)}"; - - TasksManager.RunTask( - () => - { - foreach (var config in _configs.Values) - config.Save( - config.ConfigFileLocation - ?? throw new InvalidOperationException( - $"Saving config requires `{nameof(ConfigBase.ConfigFileLocation)}` property not null." - ) - ); - }, - location, - catchException: true - ); - - return this; - } - - private T GetConfig() - where T : ConfigBase - { - var name = typeof(T).Name; - - return _configs[name] as T ?? throw new Exception($"Can not find config: {name}"); - } - - public AppConfig AppConfig => GetConfig(); - - public PluginsConfig PluginsConfig => GetConfig(); - - public MarketConfig MarketConfig => GetConfig(); - - public AnnouncementConfig AnnouncementConfig => GetConfig(); - - public SecurityConfig SecurityConfig => GetConfig(); -} diff --git a/KitX Dashboard/Managers/FileWatcherManager.cs b/KitX Dashboard/Managers/FileWatcherManager.cs deleted file mode 100644 index c92717f9..00000000 --- a/KitX Dashboard/Managers/FileWatcherManager.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Names; - -namespace KitX.Dashboard.Managers; - -public class FileWatcherManager : ManagerBase -{ - private static FileWatcherManager? _instance; - - public static FileWatcherManager Instance => _instance ??= new(); - - private readonly Dictionary Watchers = []; - - public FileWatcherManager() - { - AppFramework.AfterInitailization(() => - { - Instances.SignalTasksManager!.RaiseSignal(nameof(SignalsNames.FileWatcherManagerInitializedSignal)); - }); - } - - public FileWatcherManager RegisterWatcher(string name, string filePath, Action onchange) - { - const string location = $"{nameof(FileWatcherManager)}.{nameof(RegisterWatcher)}"; - - if (!Watchers.ContainsKey(name)) - { - var watcher = new FileWatcher(filePath, onchange); - - Watchers.Add(name, watcher); - } - else - throw new InvalidOperationException($"FileWatcher {name} already exists."); - - return this; - } - - public FileWatcherManager UnregisterWatcher(string name) - { - if (Watchers.TryGetValue(name, out var watcher)) - { - watcher?.Dispose(); - Watchers.Remove(name); - } - - return this; - } - - public FileWatcherManager IncreaseExceptCount(string name, int count = 1) - { - if (Watchers.TryGetValue(name, out var watcher)) - watcher?.IncreaseExceptCount(count); - - return this; - } - - public FileWatcherManager DecreaseExceptCount(string name, int count = 1) - { - if (Watchers.TryGetValue(name, out var watcher)) - watcher?.DecreaseExceptCount(count); - - return this; - } - - public FileWatcherManager Clear() - { - foreach (KeyValuePair item in Watchers) - item.Value.Dispose(); - - Watchers.Clear(); - - return this; - } -} - -internal class FileWatcher : IDisposable -{ - private int ExceptCounts = 0; - - private FileSystemWatcher? watcher = null; - - public FileWatcher( - string filename, - Action onchanged, - NotifyFilters? notifyFilters = NotifyFilters.LastWrite - ) - { - const string location = $"{nameof(FileWatcherManager)}.{nameof(FileWatcher)}"; - - var filepath = filename.GetFullPath(); - - var path = - Path.GetDirectoryName(filepath) - ?? throw new NullReferenceException($"In {location}._ctor: Failed in {nameof(Path.GetDirectoryName)}"); - - watcher = new() - { - NotifyFilter = notifyFilters ?? NotifyFilters.LastWrite, - Path = path, - Filter = Path.GetFileName(filepath.GetFullPath()), - }; - - watcher.Changed += (x, y) => - { - if (ExceptCounts > 0) - --ExceptCounts; - else - onchanged(x, y); - }; - - watcher.EnableRaisingEvents = true; - } - - public void IncreaseExceptCount(int count) => ExceptCounts += count; - - public void DecreaseExceptCount(int count) => ExceptCounts -= count; - - public void Dispose() - { - watcher?.Dispose(); - - watcher = null; - } -} diff --git a/KitX Dashboard/Managers/KeyHookManager.cs b/KitX Dashboard/Managers/KeyHookManager.cs deleted file mode 100644 index aaa144f1..00000000 --- a/KitX Dashboard/Managers/KeyHookManager.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Collections.Generic; -using Common.BasicHelper.Utils.Extensions; -using SharpHook; -using SharpHook.Native; - -namespace KitX.Dashboard.Managers; - -public class KeyHookManager : ManagerBase -{ - private const int keysLimitation = 5; - - private readonly Queue? keyPressed; - - private readonly Dictionary>? hotKeyHandlers; - - public KeyHookManager() - { - keyPressed = new(); - - hotKeyHandlers = []; - } - - public KeyHookManager Hook() - { - var hook = new TaskPoolGlobalHook(); - - hook.KeyPressed += (_, args) => - { - keyPressed!.Enqueue(args.Data.KeyCode); - - if (keyPressed!.Count > keysLimitation) - _ = keyPressed.Dequeue(); - - VerifyKeys(); - }; - - hook.RunAsync(); - - return this; - } - - private void VerifyKeys() - { - var index = 0; - - var tmpList = new KeyCode[keysLimitation]; - - keyPressed!.ForEach( - x => - { - tmpList[index] = x; - - ++index; - }, - true - ); - - foreach (var handler in hotKeyHandlers!.Values) - handler.Invoke(tmpList); - } - - public KeyHookManager RegisterHotKeyHandler(string name, Action handler) - { - hotKeyHandlers!.Add(name, handler); - - return this; - } - - public KeyHookManager UnregisterHotKeyHandler(string name) - { - if (hotKeyHandlers!.TryGetValue(name, out _)) - { - hotKeyHandlers.Remove(name); - } - - return this; - } -} diff --git a/KitX Dashboard/Managers/LoadersManager.cs b/KitX Dashboard/Managers/LoadersManager.cs deleted file mode 100644 index db78d792..00000000 --- a/KitX Dashboard/Managers/LoadersManager.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace KitX.Dashboard.Managers; - -internal class LoadersManager { } diff --git a/KitX Dashboard/Managers/ManagerBase.cs b/KitX Dashboard/Managers/ManagerBase.cs deleted file mode 100644 index 566e7643..00000000 --- a/KitX Dashboard/Managers/ManagerBase.cs +++ /dev/null @@ -1,5 +0,0 @@ -using KitX.Dashboard.Configuration; - -namespace KitX.Dashboard.Managers; - -public class ManagerBase : ConfigFetcher { } diff --git a/KitX Dashboard/Managers/PluginsManager.cs b/KitX Dashboard/Managers/PluginsManager.cs deleted file mode 100644 index baf63691..00000000 --- a/KitX Dashboard/Managers/PluginsManager.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Text.Json; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Models; -using KitX.Dashboard.Services; -using KitX.Shared.CSharp.Loader; -using KitX.Shared.CSharp.Plugin; -using Serilog; -using Decoder = KitX.FileFormats.CSharp.ExtensionsPackage.Decoder; - -namespace KitX.Dashboard.Managers; - -internal class PluginsManager -{ - internal static List Plugins => ConfigManager.Instance.PluginsConfig.Plugins; - - internal static void ImportPlugin(string[] kxpfiles, bool inGraphic = false) - { - const string location = $"{nameof(PluginsManager)}.{nameof(ImportPlugin)}"; - - var processPath = Environment.ProcessPath ?? throw new Exception("Can not get path of `KitX.Dashboard` process."); - - var workbase = Path.GetDirectoryName(processPath) ?? throw new Exception("Can not get work base of `KitX`."); - - foreach (var item in kxpfiles) - { - try - { - var decoder = new Decoder(item); - - var rst = decoder.GetLoaderAndPluginInfo(); - - var loaderInfo = JsonSerializer.Deserialize(rst.Item1); - - var pluginInfo = JsonSerializer.Deserialize(rst.Item2); - - if (pluginInfo is null) - continue; - - var config = ConfigManager.Instance.AppConfig; - - var pluginsavedir = config?.App?.LocalPluginsFileFolder.GetFullPath(); - - var thisPluginDir = new StringBuilder() - .Append(pluginsavedir) - .Append('/') - .Append($"{pluginInfo.PublisherName}_{pluginInfo.AuthorName}") - .Append('/') - .Append(pluginInfo.Name) - .Append('/') - .Append(pluginInfo.Version) - .ToString() - .GetFullPath(); - - if (Directory.Exists(thisPluginDir)) - Directory.Delete(thisPluginDir, true); - - _ = Directory.CreateDirectory(thisPluginDir); - - _ = decoder.Decode(thisPluginDir); - - if (!Plugins.Exists(x => x.InstallPath?.Equals(thisPluginDir) ?? false)) - Plugins.Add(new() { InstallPath = thisPluginDir }); - } - catch (Exception e) - { - var msg = $"In {location}: Processing {item} occurs error -> {e.Message}"; - - Console.WriteLine(msg); - - if (inGraphic) - { - Log.Error(e, msg); - - throw; // If called in graphic mode, throw again for better tip - } - } - } - - EventService.Invoke(nameof(EventService.PluginsConfigChanged)); - } -} diff --git a/KitX Dashboard/Managers/SecurityManager.cs b/KitX Dashboard/Managers/SecurityManager.cs deleted file mode 100644 index 752b2ca2..00000000 --- a/KitX Dashboard/Managers/SecurityManager.cs +++ /dev/null @@ -1,274 +0,0 @@ -using System; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using Common.BasicHelper.Utils; -using Common.BasicHelper.Utils.Extensions; -using DynamicData; -using KitX.Dashboard.Configuration; -using KitX.Dashboard.Network.DevicesNetwork; -using KitX.Shared.CSharp.Device; -using KitX.Shared.CSharp.Security; - -namespace KitX.Dashboard.Managers; - -public class SecurityManager : ManagerBase, IDisposable -{ - private static SecurityManager? _instance; - - public static SecurityManager Instance => _instance ??= new(); - - private DeviceKey? localDeviceKey; - - public DeviceKey? LocalDeviceKey - { - get => localDeviceKey; - set => localDeviceKey = value; - } - - private RSA? RsaInstance; - - public SecurityManager() - { - Initialize(); - } - - private void Initialize() - { - var local = DevicesDiscoveryServer.Instance.DefaultDeviceInfo; - - var device = - local.Device ?? throw new ArgumentNullException(nameof(local.Device), "It seems that you didn't run Devices Discovery System."); - - LocalDeviceKey = SecurityConfig.DeviceKeys.FirstOrDefault(x => x.Device.IsSameDevice(device)); - - if (LocalDeviceKey is not null) - { - if (LocalDeviceKey.RsaPublicKeyPem is null || LocalDeviceKey.RsaPrivateKeyPem is null) - AddLocalDevice(device); - - RsaInstance = RSA.Create(2048); - - RsaInstance.ImportFromPem(LocalDeviceKey.RsaPublicKeyPem); - RsaInstance.ImportFromPem(LocalDeviceKey.RsaPrivateKeyPem); - - return; - } - - AddLocalDevice(device); - } - - private void AddLocalDevice(DeviceLocator device) - { - var rsa = RSA.Create(2048); - - RsaInstance = rsa; - - AddDeviceKey( - new() - { - Device = device, - RsaPrivateKeyPem = rsa.ExportRSAPrivateKeyPem(), - RsaPublicKeyPem = rsa.ExportRSAPublicKeyPem(), - } - ); - } - - public SecurityManager AddDeviceKey(DeviceKey deviceKey) - { - SecurityConfig.DeviceKeys.Add(deviceKey); - - SecurityConfig.Save(SecurityConfig.ConfigFileLocation!); - - return this; - } - - public SecurityManager RemoveDeviceKey(DeviceInfo deviceInfo) - { - SecurityConfig.DeviceKeys.RemoveMany(SecurityConfig.DeviceKeys.Where(x => x.Device.IsSameDevice(deviceInfo.Device))); - - SecurityConfig.Save(SecurityConfig.ConfigFileLocation!); - - return this; - } - - public static DeviceKey? SearchDeviceKey(DeviceLocator locator) => - SecurityConfig.DeviceKeys.FirstOrDefault(x => x.Device.IsSameDevice(locator)); - - public static bool IsDeviceKeyCorrect(DeviceLocator locator, DeviceKey key) - { - var existing = SearchDeviceKey(locator); - - if (existing is null) - return false; - - return existing.IsSameKey(key); - } - - public static bool IsDeviceAuthorized(DeviceLocator device) => SecurityConfig.DeviceKeys.Any(x => x.Device.IsSameDevice(device)); - - public string? EncryptString(string data) - { - if (RsaInstance is null) - return null; - - if (data.Length >= 90) - { /* ToDo: Split data */ - } - - var dataBytes = data.FromUTF8(); - - var encrypted = RsaInstance.Encrypt(dataBytes, RSAEncryptionPadding.OaepSHA256); - - return Convert.ToBase64String(encrypted); - } - - public string? DecryptString(string encryptedData) - { - if (RsaInstance is null) - return null; - - if (encryptedData.Length >= 90) - { /* ToDo: Split data */ - } - - var encryptedDataBytes = Convert.FromBase64String(encryptedData); - - return RsaInstance.Decrypt(encryptedDataBytes, RSAEncryptionPadding.OaepSHA256).ToUTF8(); - } - - public DeviceKey? GetPrivateDeviceKey() => - LocalDeviceKey is null - ? null - : new DeviceKey() { Device = LocalDeviceKey.Device, RsaPrivateKeyPem = LocalDeviceKey.RsaPrivateKeyPem }; - - public static string? RsaEncryptString(DeviceKey key, string data) - { - if (data.Length >= 90) - throw new ArgumentOutOfRangeException(nameof(data), "Data length is too long."); - - using var rsa = RSA.Create(2048); - - rsa.ImportFromPem(key.RsaPublicKeyPem); - - var dataBytes = data.FromUTF8(); - - var encrypted = rsa.Encrypt(dataBytes, RSAEncryptionPadding.OaepSHA256); - - return Convert.ToBase64String(encrypted); - } - - public static string? RsaDecryptString(DeviceKey key, string encryptedData) - { - using var rsa = RSA.Create(2048); - - rsa.ImportFromPem(key.RsaPrivateKeyPem); - - var dataBytes = Convert.FromBase64String(encryptedData); - - return rsa.Decrypt(dataBytes, RSAEncryptionPadding.OaepSHA256).ToUTF8(); - } - - public static EncryptedContent RsaEncryptContent(DeviceKey key, string content) - { - var aesKey = Password.GeneratePassword(length: 16); - - var encryptedAesKey = RsaEncryptString(key, aesKey); - - var encryptedContent = AesEncrypt(content, aesKey); - - return new EncryptedContent - { - Device = key.Device, - RsaEncryptedAesKeyBase64 = encryptedAesKey, - AesEncryptedContentBase64 = encryptedContent, - }; - } - - public static string RsaDecryptContent(DeviceKey key, EncryptedContent content) - { - ArgumentNullException.ThrowIfNull(content.RsaEncryptedAesKeyBase64, nameof(content.RsaEncryptedAesKeyBase64)); - - ArgumentNullException.ThrowIfNull(content.AesEncryptedContentBase64, nameof(content.AesEncryptedContentBase64)); - - var aesKey = RsaDecryptString(key, content.RsaEncryptedAesKeyBase64); - - return AesDecrypt(content.AesEncryptedContentBase64, aesKey!); - } - - public static string GetSHA1(string data) - { - var hash = SHA1.HashData(data.FromUTF8()); - - var sb = new StringBuilder(); - - foreach (var item in hash) - sb.Append(item.ToString("x2")); - - return sb.ToString(); - } - - private static byte[] ExpandKey(string key, int length) - { - var expandedKey = key.Length <= length ? key : key[..length]; - - var expandIndex = 0; - - while (expandedKey.Length < length) - { - if (expandIndex == key.Length) - expandIndex = 0; - - expandedKey += key[expandIndex]; - - expandIndex++; - } - - return expandedKey.FromASCII(); - } - - public static string AesEncrypt(string source, string key) - { - var data = source.FromUTF8(); - - var expandedKey = ExpandKey(key, 16); - - var keyData = expandedKey; - var iv = expandedKey; - - using var aes = Aes.Create(); - - aes.Key = keyData; - aes.IV = iv; - - var result = aes.EncryptCbc(data, iv, PaddingMode.ISO10126); - - return Convert.ToBase64String(result); - } - - public static string AesDecrypt(string source, string key, bool isSourceInBase64 = true) - { - var data = isSourceInBase64 ? Convert.FromBase64String(source) : source.FromUTF8(); - - var expandedKey = ExpandKey(key, 16); - - var keyData = expandedKey; - var iv = expandedKey; - - using var aes = Aes.Create(); - - aes.Key = keyData; - aes.IV = iv; - - var result = aes.DecryptCbc(data, iv, PaddingMode.ISO10126); - - return result.ToUTF8(); - } - - public void Dispose() - { - RsaInstance?.Dispose(); - - GC.SuppressFinalize(this); - } -} diff --git a/KitX Dashboard/Managers/StatisticsManager.cs b/KitX Dashboard/Managers/StatisticsManager.cs deleted file mode 100644 index 77520622..00000000 --- a/KitX Dashboard/Managers/StatisticsManager.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Timers; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Services; -using Serilog; - -namespace KitX.Dashboard.Managers; - -internal class StatisticsManager -{ - internal static Dictionary? UseStatistics = []; - - internal static void Start() - { - InitEvents(); - - RecoverPreviousStatistics(); - - BeginRecord(); - } - - internal static void InitEvents() - { - EventService.UseStatisticsChanged += async () => - { - try - { - var dataDir = ConstantTable.DataPath.GetFullPath(); - if (!Directory.Exists(dataDir)) - Directory.CreateDirectory(dataDir); - - var useFile = "UseCount.json"; - var usePath = $"{dataDir}/{useFile}".GetFullPath(); - var json = JsonSerializer.Serialize(UseStatistics); - - await File.WriteAllTextAsync(usePath, json); - } - catch (Exception ex) - { - Log.Warning(ex, $"On UseStatisticsChanged: {ex.Message}"); - } - }; - } - - internal static async void RecoverPreviousStatistics() - { - var dataDir = ConstantTable.DataPath.GetFullPath(); - if (!Directory.Exists(dataDir)) - Directory.CreateDirectory(dataDir); - - try - { - var useFile = "UseCount.json"; - var usePath = $"{dataDir}/{useFile}".GetFullPath(); - - if (File.Exists(usePath)) - { - var useCountJson = await File.ReadAllTextAsync(usePath); - - UseStatistics = JsonSerializer.Deserialize>(useCountJson); - - if (UseStatistics is not null) - { - var lastDT = DateTime.Parse(UseStatistics.Keys.Last()); - while (!lastDT.ToString("MM.dd").Equals(DateTime.Now.ToString("MM.dd"))) - { - lastDT = lastDT.AddDays(1); - - UseStatistics.Add(lastDT.ToString("MM.dd"), 0); - } - } - } - else - { - var today = DateTime.Now.ToString("MM.dd"); - - UseStatistics?.Add(today, 0); - - var json = JsonSerializer.Serialize(UseStatistics); - await File.WriteAllTextAsync(usePath, json); - } - } - catch (Exception e) - { - Log.Warning(e, e.Message); - } - } - - internal static void BeginRecord() - { - const string location = $"{nameof(StatisticsManager)}.{nameof(BeginRecord)}"; - - var use_timer = new Timer() - { - Interval = 1000 * 60 * 0.6, // Update per 0.6 minutes - }; - use_timer.Elapsed += (_, _) => - { - try - { - var today = DateTime.Now.ToString("MM.dd"); - - if (UseStatistics is null) - return; - - if (!UseStatistics.TryAdd(today, 0.01)) - { - UseStatistics[today] += 0.01; - UseStatistics[today] = Math.Round(UseStatistics[today], 2); - } - - EventService.Invoke(nameof(EventService.UseStatisticsChanged)); - } - catch (Exception ex) - { - Log.Error(ex, $"In {location}: {ex.Message}"); - } - }; - use_timer.Start(); - } -} diff --git a/KitX Dashboard/Managers/TasksManager.cs b/KitX Dashboard/Managers/TasksManager.cs deleted file mode 100644 index bad8b12b..00000000 --- a/KitX Dashboard/Managers/TasksManager.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Threading.Tasks; -using Serilog; - -namespace KitX.Dashboard.Managers; - -internal class TasksManager -{ - public static void RunTask( - Action action, - string name = nameof(Action), - string prompt = ">>> ", - bool catchException = false, - bool logIt = true - ) - { - if (logIt) - Log.Information($"{prompt}Task `{name}` began."); - - if (catchException) - { - try - { - action(); - } - catch (Exception e) - { - if (logIt) - Log.Error(e, $"{prompt}Task `{name}` failed: {e.Message}"); - } - } - else - action(); - - if (logIt) - Log.Information($"{prompt}Task `{name}` done."); - } - - public static async Task RunTaskAsync( - Action action, - string name = nameof(Action), - string prompt = ">>> ", - bool catchException = false, - bool logIt = true - ) - { - if (logIt) - Log.Information($"{prompt}Task `{name}` began."); - - if (catchException) - { - try - { - await Task.Run(action); - } - catch (Exception e) - { - if (logIt) - Log.Error(e, $"{prompt}Task `{name}` failed: {e.Message}"); - } - } - else - await Task.Run(action); - - if (logIt) - Log.Information($"{prompt}Task `{name}` done."); - } -} diff --git a/KitX Dashboard/Managers/WebManager.cs b/KitX Dashboard/Managers/WebManager.cs deleted file mode 100644 index b10ceb97..00000000 --- a/KitX Dashboard/Managers/WebManager.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.Text.Json; -using System.Threading.Tasks; -using KitX.Dashboard.Network.DevicesNetwork; -using KitX.Dashboard.Network.PluginsNetwork; -using Serilog; - -namespace KitX.Dashboard.Managers; - -public class WebManager -{ - private static WebManager? _instance; - - public static WebManager Instance => _instance ??= new(); - - internal readonly ObservableCollection NetworkInterfaceRegistered = []; - - public async Task RunAsync(WebManagerOperationInfo info) - { - const string location = $"{nameof(WebManager)}.{nameof(RunAsync)}"; - - await TasksManager.RunTaskAsync( - async () => - { - try - { - if (info.RunAll || info.RunPluginsServer) - PluginsServer.Instance.Run(); - - if (info.RunAll || info.RunDevicesDiscoveryServer) - await DevicesDiscoveryServer.Instance.RunAsync(); - - if (info.RunAll || info.RunDevicesServer) - await DevicesServer.Instance.RunAsync(); - } - catch (Exception ex) - { - Log.Error(ex, $"In {location}: {JsonSerializer.Serialize(info)}"); - } - }, - location - ); - - return this; - } - - public async Task CloseAsync(WebManagerOperationInfo info) - { - const string location = $"{nameof(WebManager)}.{nameof(CloseAsync)}"; - - try - { - if (info.CloseAll || info.CloseDevicesServer) - await DevicesServer.Instance.CloseAsync(); - - if (info.CloseAll || info.CloseDevicesDiscoveryServer) - { - await DevicesDiscoveryServer - .Instance.CloseAsync() - .ContinueWith(async server => - { - await Task.Delay(ConfigManager.Instance.AppConfig.Web.UdpSendFrequency + 500); - - server.Dispose(); - }); - - while (DevicesDiscoveryServer.Instance.CloseDevicesDiscoveryServerRequest) { } - } - - if (info.CloseAll || info.ClosePluginsServer) - await PluginsServer.Instance.Close(); - } - catch (Exception ex) - { - Log.Warning(ex, $"In {location}: {ex.Message}"); - } - - return this; - } - - public async Task RestartAsync(WebManagerOperationInfo info, Action? actionBeforeStarting = null) - { - await CloseAsync(info); - - actionBeforeStarting?.Invoke(); - - await RunAsync(info); - - return this; - } -} - -public struct WebManagerOperationInfo -{ - public bool RunPluginsServer = true; - - public bool RunDevicesServer = true; - - public bool RunDevicesDiscoveryServer = true; - - public bool RunAll - { - readonly get => RunPluginsServer && RunDevicesServer && RunDevicesDiscoveryServer; - set - { - RunPluginsServer = value; - RunDevicesServer = value; - RunDevicesDiscoveryServer = value; - } - } - - public bool ClosePluginsServer = true; - - public bool CloseDevicesServer = true; - - public bool CloseDevicesDiscoveryServer = true; - - public bool CloseAll - { - readonly get => ClosePluginsServer && CloseDevicesServer && CloseDevicesDiscoveryServer; - set - { - ClosePluginsServer = value; - CloseDevicesServer = value; - CloseDevicesDiscoveryServer = value; - } - } - - public WebManagerOperationInfo() { } -} diff --git a/KitX Dashboard/Models/DeviceCase.cs b/KitX Dashboard/Models/DeviceCase.cs deleted file mode 100644 index 886e3f8a..00000000 --- a/KitX Dashboard/Models/DeviceCase.cs +++ /dev/null @@ -1,276 +0,0 @@ -using System; -using System.Net.Http; -using System.Reactive; -using System.Text; -using System.Text.Json; -using Avalonia.Threading; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Managers; -using KitX.Dashboard.Network.DevicesNetwork; -using KitX.Dashboard.Network.DevicesNetwork.DevicesServerControllers.V1; -using KitX.Dashboard.Services; -using KitX.Dashboard.ViewModels; -using KitX.Dashboard.Views; -using KitX.Shared.CSharp.Device; -using MsBox.Avalonia; -using MsBox.Avalonia.Enums; -using ReactiveUI; -using Serilog; - -namespace KitX.Dashboard.Models; - -public class DeviceCase : ViewModelBase -{ - public DeviceCase(DeviceInfo deviceInfo) - { - this.deviceInfo = deviceInfo; - - InitCommands(); - - InitEvents(); - } - - public sealed override void InitCommands() - { - AuthorizeAndExchangeDeviceKeyCommand = ReactiveCommand.Create( - async info => - { - if (ConstantTable.IsExchangingDeviceKey) - { - var box = MessageBoxManager.GetMessageBoxStandard( - Translate("Text_Log_Warning") ?? "Null", - Translate("Text_Device_Tip_ExchangingDeviceKey") ?? "Null", - ButtonEnum.Ok, - Icon.Warning - ); - - return; - } - - ConstantTable.IsExchangingDeviceKey = true; - - var keyData = new byte[8]; - - var random = new Random(); - - for (var i = 0; i < keyData.Length; ++i) - keyData[i] = (byte)(random.Next(1, 10) + '0'); - - var key = keyData.ToASCII(); - - if (SecurityManager.Instance.LocalDeviceKey is null) - { - var box = MessageBoxManager.GetMessageBoxStandard( - Translate("Text_Log_Error") ?? "Null", - Translate("Text_Device_Tip_SecuritySystemFailed") ?? "Null", - ButtonEnum.Ok, - Icon.Error - ); - - await box.ShowWindowAsync(); - - ConstantTable.IsExchangingDeviceKey = false; - - return; - } - - var localKey = SecurityManager.Instance.GetPrivateDeviceKey(); - - var localKeyJson = JsonSerializer.Serialize(localKey); - - var localKeyEncrypted = SecurityManager.AesEncrypt(localKeyJson, key); - - var sender = DevicesDiscoveryServer.Instance.DefaultDeviceInfo; - - var address = $"{sender.Device.IPv4}:{sender.DevicesServerPort}"; - - var target = $"{info.Device.IPv4}:{info.DevicesServerPort}"; - - var sha1 = SecurityManager.GetSHA1(key); - - var url = $"http://{target}/Api/V1/Device/{nameof(DeviceController.ExchangeKey)}?verifyCodeSHA1={sha1}&address={address}"; - - ConstantTable.ExchangeDeviceKeyCode = key; - - var window = new ExchangeDeviceKeyWindow().DisplayVerificationCode(key); - - window.OnCancel(async () => - { - window.Canceled(); - - ConstantTable.IsExchangingDeviceKey = false; - - var url = $"http://{target}/Api/V1/Device/{nameof(DeviceController.CancelExchangingKey)}"; - - using var http = new HttpClient(); - - var response = await http.PostAsync(url, null); - - Log.Information($"In {nameof(DeviceController)}: Requested {url} with responsed {response.StatusCode} - {response}"); - }); - - ViewInstances.ShowWindow(window); - - EventService.OnReceiveCancelExchangingDeviceKey += () => Dispatcher.UIThread.Post(() => window.Canceled()); - - using var http = new HttpClient(); - - var response = await http.PostAsync( - url, - new StringContent(JsonSerializer.Serialize(localKeyEncrypted), Encoding.UTF8, "application/json") - ); - - if (response.IsSuccessStatusCode) { } - else - { - var box = MessageBoxManager.GetMessageBoxStandard( - Translate("Text_Log_Error") ?? "Null", - new StringBuilder() - .AppendLine($"Requested: {url}") - .AppendLine($"Responsed: {response.StatusCode} - {response.RequestMessage}") - .ToString(), - ButtonEnum.Ok, - Icon.Error - ); - - await box.ShowWindowAsync(); - - window.Close(); - - ConstantTable.IsExchangingDeviceKey = false; - } - }, - this.WhenAnyValue(x => x.IsAuthorized, y => y == false) - ); - - UnAuthorizeCommand = ReactiveCommand.Create( - async info => - { - if (info.IsCurrentDevice()) - { - var box = MessageBoxManager.GetMessageBoxStandard( - Translate("Text_Log_Error") ?? "Null", - Translate("Text_Device_Tip_DeleteYourSelfError") ?? "Null", - ButtonEnum.Ok, - Icon.Forbidden - ); - - await box.ShowWindowAsync(); - - return; - } - - SecurityManager.Instance.RemoveDeviceKey(info); - }, - this.WhenAnyValue(x => x.IsAuthorized) - ); - } - - public sealed override void InitEvents() { } - - private DeviceInfo deviceInfo; - - public DeviceInfo DeviceInfo - { - get => deviceInfo; - set - { - this.RaiseAndSetIfChanged(ref deviceInfo, value); - - Update(); - - if (IsAuthorized && (IsConnected == false) && (IsCurrentDevice == false)) - Connect(); - } - } - - private void Update() - { - this.RaisePropertyChanged(nameof(IsAuthorized)); - this.RaisePropertyChanged(nameof(IsConnected)); - this.RaisePropertyChanged(nameof(IsCurrentDevice)); - this.RaisePropertyChanged(nameof(IsMainDevice)); - } - - private void Connect() - { - TasksManager.RunTask( - async () => - { - using var http = new HttpClient(); - - var targetKey = SecurityManager.SearchDeviceKey(DeviceInfo.Device); - - if (targetKey is null) - return; - - var local = SecurityManager.Instance.GetPrivateDeviceKey(); - - if (local is null) - return; - - var deviceName = local.Device.DeviceName; - - var deviceNameEncrypted = SecurityManager.Instance.EncryptString(deviceName); - - var address = $"{DeviceInfo.Device.IPv4}:{DeviceInfo.DevicesServerPort}"; - - var deviceJson = JsonSerializer.Serialize(local.Device); - - deviceJson = Convert.ToBase64String(deviceJson.FromUTF8()); - - var url = $"http://{address}/Api/V1/Device/{nameof(DeviceController.Connect)}?deviceBase64={deviceJson}"; - - var response = await http.PostAsync( - url, - new StringContent(JsonSerializer.Serialize(deviceNameEncrypted), Encoding.UTF8, "application/json") - ); - - if (response.IsSuccessStatusCode) - { - Log.Information($"Connected to {DeviceInfo.Device.DeviceName} with response {response}"); - - var body = await response.Content.ReadAsStringAsync(); - - if (body is null) - return; - - ConnectionToken = SecurityManager.RsaDecryptString(targetKey, body); - - Update(); - } - else - { - Log.Warning( - new StringBuilder() - .AppendLine($"Requested: {url}") - .AppendLine($"Responsed: {response.StatusCode} - {response.ReasonPhrase}") - .AppendLine(response.RequestMessage?.ToString()) - .ToString() - ); - } - }, - $"Connecting {DeviceInfo.Device.DeviceName}" - ); - } - - public bool IsAuthorized => SecurityManager.IsDeviceAuthorized(DeviceInfo.Device); - - public bool IsConnected => DevicesServer.Instance.IsDeviceSignedIn(DeviceInfo.Device) || ConnectionToken is not null; - - public bool IsCurrentDevice => DeviceInfo.IsCurrentDevice(); - - public bool IsMainDevice => DeviceInfo.IsMainDevice; - - private string? connectionToken; - - public string? ConnectionToken - { - get => connectionToken; - set => this.RaiseAndSetIfChanged(ref connectionToken, value); - } - - internal ReactiveCommand? AuthorizeAndExchangeDeviceKeyCommand { get; set; } - - internal ReactiveCommand? UnAuthorizeCommand { get; set; } -} diff --git a/KitX Dashboard/Models/PluginInstallation.cs b/KitX Dashboard/Models/PluginInstallation.cs deleted file mode 100644 index ebe9fb5b..00000000 --- a/KitX Dashboard/Models/PluginInstallation.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; -using KitX.Shared.CSharp.Device; -using KitX.Shared.CSharp.Loader; -using KitX.Shared.CSharp.Plugin; - -namespace KitX.Dashboard.Models; - -public class PluginInstallation -{ - [JsonInclude] - public string? InstallPath { get; set; } - - [JsonIgnore] - public PluginInfo PluginInfo { get; set; } = new(); - - [JsonIgnore] - public LoaderInfo LoaderInfo { get; set; } = new(); - - [JsonIgnore] - public List InstalledDevices { get; set; } = []; -} diff --git a/KitX Dashboard/Models/SupportedLanguage.cs b/KitX Dashboard/Models/SupportedLanguage.cs index ea09c81e..11e74d57 100644 --- a/KitX Dashboard/Models/SupportedLanguage.cs +++ b/KitX Dashboard/Models/SupportedLanguage.cs @@ -1,8 +1,31 @@ -namespace KitX.Dashboard.Models; +using System.ComponentModel; +using System.Runtime.CompilerServices; -internal class SupportedLanguage +namespace KitX.Dashboard.Models; + +internal class SupportedLanguage : INotifyPropertyChanged { - internal string LanguageName { get; set; } = string.Empty; + private string languageName = string.Empty; + + internal string LanguageName + { + get => languageName; + set + { + if (languageName != value) + { + languageName = value; + OnPropertyChanged(); + } + } + } internal string LanguageCode { get; set; } = string.Empty; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } } diff --git a/KitX Dashboard/Models/SupportedTheme.cs b/KitX Dashboard/Models/SupportedTheme.cs index 8d5306b2..358d3fb6 100644 --- a/KitX Dashboard/Models/SupportedTheme.cs +++ b/KitX Dashboard/Models/SupportedTheme.cs @@ -1,8 +1,31 @@ -namespace KitX.Dashboard.Models; +using System.ComponentModel; +using System.Runtime.CompilerServices; -internal class SupportedTheme +namespace KitX.Dashboard.Models; + +internal class SupportedTheme : INotifyPropertyChanged { + private string themeDisplayName = string.Empty; + internal string ThemeName { get; set; } = string.Empty; - internal string ThemeDisplayName { get; set; } = string.Empty; + internal string ThemeDisplayName + { + get => themeDisplayName; + set + { + if (themeDisplayName != value) + { + themeDisplayName = value; + OnPropertyChanged(); + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } } diff --git a/KitX Dashboard/Models/WorkflowCase.cs b/KitX Dashboard/Models/WorkflowCase.cs deleted file mode 100644 index 6296f96f..00000000 --- a/KitX Dashboard/Models/WorkflowCase.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace KitX.Dashboard.Models -{ - public class WorkflowCase - { - public required string Name { get; set; } // 工作流名称 - public required string Description { get; set; } // 简介信息 - public required string IconPath { get; set; } // 图标路径 - public bool IsRunning { get; set; } // 运行状态 - } -} diff --git a/KitX Dashboard/Network/DevicesNetwork/DevicesDiscoveryServer.cs b/KitX Dashboard/Network/DevicesNetwork/DevicesDiscoveryServer.cs deleted file mode 100644 index dc8589c0..00000000 --- a/KitX Dashboard/Network/DevicesNetwork/DevicesDiscoveryServer.cs +++ /dev/null @@ -1,415 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Managers; -using KitX.Dashboard.Names; -using KitX.Dashboard.Services; -using KitX.Dashboard.Views; -using KitX.Shared.CSharp.Device; -using Serilog; - -namespace KitX.Dashboard.Network.DevicesNetwork; - -public class DevicesDiscoveryServer -{ - private static DevicesDiscoveryServer? _instance; - - public static DevicesDiscoveryServer Instance => _instance ??= new(); - - private UdpClient? UdpSender = null; - - private UdpClient? UdpReceiver = null; - - private System.Timers.Timer? UdpSendTimer = null; - - private int DeviceInfoUpdatedTimes = 0; - - private int LastTimeToOSVersionUpdated = 0; - - private readonly List SupportedNetworkInterfacesIndexes = []; - - private bool disposed = false; - - internal bool CloseDevicesDiscoveryServerRequest = false; - - internal readonly Queue Messages2BroadCast = new(); - - internal DeviceInfo DefaultDeviceInfo = NetworkHelper.GetDeviceInfo(); - - private ServerStatus status = ServerStatus.Pending; - - internal ServerStatus Status - { - get => status; - set { status = value; } - } - - public DevicesDiscoveryServer() - { - DevicesOrganizer.Run(); - - Initialize(); - } - - private void Initialize() - { - disposed = false; - - DeviceInfoUpdatedTimes = 0; - - LastTimeToOSVersionUpdated = 0; - - CloseDevicesDiscoveryServerRequest = false; - - SupportedNetworkInterfacesIndexes.Clear(); - - Messages2BroadCast.Clear(); - - DefaultDeviceInfo = NetworkHelper.GetDeviceInfo(); - } - - public async Task RunAsync() - { - if (Status != ServerStatus.Pending) - return this; - - Status = ServerStatus.Starting; - - Initialize(); - - UdpSender = new(ConfigManager.Instance.AppConfig.Web.UdpPortSend, AddressFamily.InterNetwork) - { - EnableBroadcast = true, - MulticastLoopback = true, - }; - - UdpReceiver = new(new IPEndPoint(IPAddress.Any, ConfigManager.Instance.AppConfig.Web.UdpPortReceive)); - - await TasksManager.RunTaskAsync( - () => - { - try - { - FindSupportNetworkInterfaces( - [UdpSender, UdpReceiver], - IPAddress.Parse(ConfigManager.Instance.AppConfig.Web.UdpBroadcastAddress) - ); // 寻找所有支持的网络适配器 - } - catch (Exception ex) - { - const string location = $"{nameof(DevicesServer)}.{nameof(RunAsync)}"; - Log.Warning(ex, $"In {location}: {ex.Message}"); - } - }, - nameof(FindSupportNetworkInterfaces) - ); - - await TasksManager.RunTaskAsync(MultiDevicesBroadCastSend, nameof(MultiDevicesBroadCastSend)); - - await TasksManager.RunTaskAsync(MultiDevicesBroadCastReceive, nameof(MultiDevicesBroadCastReceive)); - - Status = ServerStatus.Running; - - return this; - } - - public async Task CloseAsync() - { - if (Status != ServerStatus.Running) - return this; - - await Task.Run(() => - { - Status = ServerStatus.Stopping; - - CloseDevicesDiscoveryServerRequest = true; - }); - - return this; - } - - public async Task Restart() - { - await Task.Run(async () => - { - await CloseAsync(); - - await RunAsync(); - }); - - return this; - } - - private void FindSupportNetworkInterfaces(List clients, IPAddress multicastAddress) - { - var multicastGroupJoinedInterfacesCount = 0; - - foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces()) - { - var adapterProperties = adapter.GetIPProperties(); - - if (adapterProperties is null) - continue; - - if (!NetworkHelper.CheckNetworkInterface(adapter, adapterProperties)) - continue; - - var unicastIPAddresses = adapterProperties.UnicastAddresses; - - if (unicastIPAddresses is null) - continue; - - var p = adapterProperties.GetIPv4Properties(); - - if (p is null) - continue; // IPv4 is not configured on this adapter - - SupportedNetworkInterfacesIndexes.Add(IPAddress.HostToNetworkOrder(p.Index)); - - foreach (var ipAddress in unicastIPAddresses.Select(x => x.Address).Where(x => x.AddressFamily == AddressFamily.InterNetwork)) - { - try - { - foreach (var udpClient in clients) - udpClient?.JoinMulticastGroup(multicastAddress, ipAddress); - - Instances.WebManager?.NetworkInterfaceRegistered?.Add(adapter.Name); - - ++multicastGroupJoinedInterfacesCount; - } - catch (Exception ex) - { - const string location = $"{nameof(DevicesServer)}.{nameof(FindSupportNetworkInterfaces)}"; - - Log.Error(ex, $"In {location}: {ex.Message}"); - } - } - } - - Instances.SignalTasksManager?.RaiseSignal(nameof(SignalsNames.FinishedFindingNetworkInterfacesSignal)); - - Log.Information($"Find {SupportedNetworkInterfacesIndexes.Count} supported network interfaces."); - - Log.Information($"Joined {multicastGroupJoinedInterfacesCount} multicast groups."); - } - - private void UpdateDefaultDeviceInfo() - { - DefaultDeviceInfo.IsMainDevice = ConstantTable.IsMainMachine; - DefaultDeviceInfo.SendTime = DateTime.UtcNow; - DefaultDeviceInfo.Device.ResetIPv4(NetworkHelper.GetInterNetworkIPv4()).ResetIPv6(NetworkHelper.GetInterNetworkIPv6()); - DefaultDeviceInfo.PluginsServerPort = ConstantTable.PluginsServerPort; - DefaultDeviceInfo.PluginsCount = ViewInstances.PluginInfos.Count; - DefaultDeviceInfo.IsMainDevice = ConstantTable.IsMainMachine; - DefaultDeviceInfo.DevicesServerPort = ConstantTable.DevicesServerPort; - DefaultDeviceInfo.DevicesServerBuildTime = ConstantTable.ServerBuildTime; - - if (LastTimeToOSVersionUpdated > ConfigManager.Instance.AppConfig.IO.OperatingSystemVersionUpdateInterval) - { - LastTimeToOSVersionUpdated = 0; - DefaultDeviceInfo.DeviceOSVersion = NetworkHelper.TryGetOsVersionString() ?? ""; - } - - ++DeviceInfoUpdatedTimes; - ++LastTimeToOSVersionUpdated; - - if (DeviceInfoUpdatedTimes < 0) - DeviceInfoUpdatedTimes = 0; - } - - private void MultiDevicesBroadCastSend() - { - const string location = $"{nameof(DevicesDiscoveryServer)}.{nameof(MultiDevicesBroadCastSend)}"; - - var multicast = new IPEndPoint( - IPAddress.Parse(ConfigManager.Instance.AppConfig.Web.UdpBroadcastAddress), - ConfigManager.Instance.AppConfig.Web.UdpPortReceive - ); - - UdpSender?.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - - var erroredInterfacesIndexes = new List(); - var erroredInterfacesIndexesTTL = 60; - - UdpSendTimer = new() { Interval = ConfigManager.Instance.AppConfig.Web.UdpSendFrequency, AutoReset = true }; - - UdpSendTimer.Elapsed += (_, _) => - { - var closingRequest = CloseDevicesDiscoveryServerRequest; - - --erroredInterfacesIndexesTTL; - - if (erroredInterfacesIndexesTTL <= 0) - { - erroredInterfacesIndexesTTL = 60; - erroredInterfacesIndexes.Clear(); - } - - UpdateDefaultDeviceInfo(); - - if (closingRequest) - DefaultDeviceInfo.SendTime -= TimeSpan.FromSeconds(20); - - var sendText = JsonSerializer.Serialize(DefaultDeviceInfo); - var sendBytes = sendText.FromUTF8(); - - foreach (var item in SupportedNetworkInterfacesIndexes) - { - if (!ConstantTable.Running) - break; - - // 如果错误网络适配器中存在当前项的记录, 跳过 - if (erroredInterfacesIndexes.Contains(item)) - continue; - - try - { - UdpSender?.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, item); - UdpSender?.Send(sendBytes, sendBytes.Length, multicast); - - // 将自定义广播消息全部发送 - while (Messages2BroadCast.Count > 0) - { - var messageBytes = Messages2BroadCast.Dequeue().FromUTF8(); - UdpSender?.Send(messageBytes, messageBytes.Length, multicast); - } - } - catch (Exception ex) - { - // 该网络适配器存在异常, 暂时记录到错误网络适配器中 - if (!erroredInterfacesIndexes.Contains(item)) - erroredInterfacesIndexes.Add(item); - - Log.Warning(ex, $"In {location}: Errored interface index: {item}, recorded."); - } - } - - if (closingRequest) - { - UdpSendTimer?.Stop(); - UdpSendTimer?.Close(); - - UdpSender?.Close(); - - UdpReceiver?.Close(); - - CloseDevicesDiscoveryServerRequest = false; - } - }; - - UdpSendTimer.Start(); - } - - private void MultiDevicesBroadCastReceive() - { - const string location = $"{nameof(DevicesDiscoveryServer)}.{nameof(MultiDevicesBroadCastReceive)}"; - - var multicast = new IPEndPoint(IPAddress.Any, 0); - - UdpReceiver?.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - - new Thread(async () => - { - try - { - while (ConstantTable.Running && !CloseDevicesDiscoveryServerRequest) - { - var bytes = UdpReceiver?.Receive(ref multicast); - var client = $"{multicast.Address}:{multicast.Port}"; - - if (bytes is null) - continue; // null byte[] cause exception in next line. - - var result = bytes.ToUTF8(); - - Log.Information($"UDP From: {client, -21}, Receive: {result}"); - - try - { - var info = JsonSerializer.Deserialize(result); - - if (info is not null) - EventService.Invoke(nameof(EventService.OnReceivingDeviceInfo), [info]); - } - catch (Exception ex) - { - Log.Warning(ex, $"When trying to deserialize `{result}`"); - } - } - - Status = ServerStatus.Pending; - } - catch (Exception e) - { - Log.Error(e, $"In {location}: {e.Message}"); - - Status = ServerStatus.Errored; - } - - await CloseAsync(); - }).Start(); - } - - public void Dispose() - { - if (disposed) - return; - - disposed = true; - - CloseDevicesDiscoveryServerRequest = false; - - UdpSender?.Dispose(); - UdpReceiver?.Dispose(); - - GC.Collect(); - } -} - -public static class DevicesDiscoveryServerExtensions -{ - public static bool IsOffline(this DeviceInfo info) => - DateTime.UtcNow - info.SendTime.ToUniversalTime() > new TimeSpan(0, 0, ConfigManager.Instance.AppConfig.Web.DeviceInfoTTLSeconds); - - public static bool IsCurrentDevice(this DeviceInfo info) => info.IsSameDevice(DevicesDiscoveryServer.Instance.DefaultDeviceInfo); - - public static bool IsSameDevice(this DeviceInfo info, DeviceInfo target) => info.Device.IsSameDevice(target.Device); - - public static void UpdateTo(this DeviceInfo info, DeviceInfo target) - { - var type = typeof(DeviceInfo); - - var fields = type.GetFields(); - - foreach (var field in fields) - { - var firstValue = field.GetValue(info); - var secondValue = field.GetValue(target); - - if (firstValue?.Equals(secondValue) ?? false) - continue; - - field.SetValue(info, secondValue); - } - - var properties = type.GetProperties(); - - foreach (var property in properties) - { - object? firstValue = property.GetValue(info); - object? secondValue = property.GetValue(target); - - if (firstValue?.Equals(secondValue) ?? false) - continue; - - property.SetValue(info, secondValue); - } - } -} diff --git a/KitX Dashboard/Network/DevicesNetwork/DevicesOrganizer.cs b/KitX Dashboard/Network/DevicesNetwork/DevicesOrganizer.cs deleted file mode 100644 index 55e2c2b8..00000000 --- a/KitX Dashboard/Network/DevicesNetwork/DevicesOrganizer.cs +++ /dev/null @@ -1,275 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using KitX.Dashboard.Configuration; -using KitX.Dashboard.Models; -using KitX.Dashboard.Services; -using KitX.Dashboard.Views; -using KitX.Shared.CSharp.Device; -using Serilog; -using Timer = System.Timers.Timer; - -namespace KitX.Dashboard.Network.DevicesNetwork; - -internal class DevicesOrganizer : ConfigFetcher -{ - private static DevicesOrganizer? _instance; - - public static DevicesOrganizer Instance => _instance ??= new(); - - private readonly object _receivedDeviceInfo4WatchLock = new(); - - internal List? receivedDeviceInfo4Watch; - - internal readonly Queue deviceInfosQueue = new(); - - private readonly object AddDeviceCard2ViewLock = new(); - - private bool KeepCheckAndRemoveTaskRunning = false; - - public static void Run() - { - _instance = Instance; - } - - public DevicesOrganizer() - { - Initialize(); - } - - public void Initialize() - { - InitEvents(); - - KeepCheckAndRemove(); - - ObserveMainDevice(); - } - - private void InitEvents() - { - EventService.OnReceivingDeviceInfo += deviceInfo => - { - deviceInfosQueue.Enqueue(deviceInfo); - - lock (_receivedDeviceInfo4WatchLock) - { - receivedDeviceInfo4Watch?.Add(deviceInfo); - } - - if (deviceInfo.IsMainDevice && deviceInfo.DevicesServerBuildTime < ConstantTable.ServerBuildTime) - { - ConstantTable.IsMainMachine = false; - - ObserveMainDevice(); - - Log.Information( - new StringBuilder() - .AppendLine("Watched earlier built server.") - .AppendLine($"DevicesServerAddress: {deviceInfo.Device.IPv4}:{deviceInfo.DevicesServerPort} ") - .AppendLine($"DevicesServerBuildTime: {deviceInfo.DevicesServerBuildTime}") - .ToString() - ); - } - }; - } - - private void UpdateSourceAndAddCards() - { - var thisTurnAdded = new List(); - - while (deviceInfosQueue.Count > 0) - { - var info = deviceInfosQueue.Dequeue(); - - var hashCode = info.GetHashCode(); - - var findThis = thisTurnAdded.Contains(hashCode); - - if (findThis) - continue; - - foreach (var item in ViewInstances.DeviceCases) - { - if (item.DeviceInfo.IsSameDevice(info)) - { - item.DeviceInfo = info; - findThis = true; - break; - } - } - - if (!findThis) - { - thisTurnAdded.Add(hashCode); - - ViewInstances.DeviceCases.Add(new(info)); - } - } - } - - private static void RemoveOfflineCards() - { - var devicesNeedToBeRemoved = new List(); - - foreach (var item in ViewInstances.DeviceCases) - if (item.DeviceInfo.IsOffline()) - devicesNeedToBeRemoved.Add(item); - - foreach (var item in devicesNeedToBeRemoved) - ViewInstances.DeviceCases.Remove(item); - } - - private static void MoveSelfCardToFirst() - { - var index = 0; - - foreach (var item in ViewInstances.DeviceCases) - { - if (item.DeviceInfo.IsCurrentDevice()) - { - if (index != 0) - ViewInstances.DeviceCases.Move(index, 0); - - break; - } - - ++index; - } - } - - private void KeepCheckAndRemove() - { - const string location = $"{nameof(DevicesOrganizer)}.{nameof(KeepCheckAndRemove)}"; - - var timer = new Timer() { Interval = AppConfig.Web.DevicesViewRefreshDelay, AutoReset = true }; - - timer.Elapsed += (_, _) => - { - try - { - if (KeepCheckAndRemoveTaskRunning) - Log.Information($"In {location}: Timer elapsed and skip task."); - else - { - KeepCheckAndRemoveTaskRunning = true; - - UpdateSourceAndAddCards(); - - if (AppConfig.Web.DisableRemovingOfflineDeviceCard == false) - RemoveOfflineCards(); - - MoveSelfCardToFirst(); - - KeepCheckAndRemoveTaskRunning = false; - } - } - catch (Exception ex) - { - Log.Error(ex, $"In {location}: {ex.Message}"); - } - }; - - timer.Start(); - - EventService.AppConfigChanged += () => - { - timer.Interval = AppConfig.Web.DevicesViewRefreshDelay; - }; - } - - internal void ObserveMainDevice(CancellationToken token = default) - { - const string location = $"{nameof(DevicesOrganizer)}.{nameof(ObserveMainDevice)}"; - - new Thread(() => - { - receivedDeviceInfo4Watch = []; - - var checkedTime = 0; - var hadMainDevice = false; - var earliestBuiltServerTime = DateTime.UtcNow; - var serverPort = 0; - var serverAddress = string.Empty; - - while (checkedTime < 7 && token.IsCancellationRequested == false) - { - try - { - if (receivedDeviceInfo4Watch is null) - continue; - - lock (_receivedDeviceInfo4WatchLock) - { - foreach (var item in receivedDeviceInfo4Watch) - { - if (item.IsMainDevice) - { - if (item.DevicesServerBuildTime.ToUniversalTime() < earliestBuiltServerTime) - { - serverPort = item.DevicesServerPort; - serverAddress = item.Device.IPv4; - } - hadMainDevice = true; - } - } - } - - ++checkedTime; - - Log.Information($"In {location}: Watched for {checkedTime} times."); - - if (checkedTime == 7) - { - receivedDeviceInfo4Watch?.Clear(); - receivedDeviceInfo4Watch = null; - - if (token.IsCancellationRequested == false) - WatchingOver(hadMainDevice, serverAddress, serverPort); - } - - Thread.Sleep(1 * 1000); // Sleep 1 second. - } - catch (Exception e) - { - receivedDeviceInfo4Watch?.Clear(); - receivedDeviceInfo4Watch = null; - - Log.Error(e, $"In {location}: {e.Message} Rewatch."); - - if (token.IsCancellationRequested == false) - ObserveMainDevice(); - - break; - } - } - }).Start(); - } - - private void WatchingOver(bool foundMainDevice, string serverAddress, int serverPort) - { - const string location = $"{nameof(DevicesOrganizer)}.{nameof(WatchingOver)}"; - - Log.Information( - new StringBuilder() - .Append($"In {location}: ") - .Append($"{nameof(foundMainDevice)} -> {foundMainDevice}") - .Append(", ") - .Append($"{nameof(serverAddress)} -> {serverAddress}") - .Append(", ") - .Append($"{nameof(serverPort)} -> {serverPort}") - .ToString() - ); - - if (foundMainDevice) - { - ConstantTable.MainMachineAddress = serverAddress; - ConstantTable.MainMachinePort = serverPort; - } - else - { - ConstantTable.IsMainMachine = true; - } - } -} diff --git a/KitX Dashboard/Network/DevicesNetwork/DevicesServer.cs b/KitX Dashboard/Network/DevicesNetwork/DevicesServer.cs deleted file mode 100644 index 25c75fcc..00000000 --- a/KitX Dashboard/Network/DevicesNetwork/DevicesServer.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using KitX.Dashboard.Configuration; -using KitX.Dashboard.Services; -using KitX.Shared.CSharp.Device; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.OpenApi.Models; -using Serilog; - -namespace KitX.Dashboard.Network.DevicesNetwork; - -public class DevicesServer : ConfigFetcher -{ - private static DevicesServer? _devicesServer; - - public static DevicesServer Instance => _devicesServer ??= new(); - - private readonly int port = AppConfig.Web.UserSpecifiedDevicesServerPort ?? 0; - - private IHost? _host; - - private Dictionary SignedDeviceTokens { get; } = []; - - public async Task RunAsync() - { - var host = CreateHostBuilder([]).Build(); - - _host = host; - - new Thread(host.Run).Start(); - - var addresses = host.Services.GetService()?.Features.Get()?.Addresses; - - while (addresses is null || addresses.Count == 0) - { - await Task.Delay(500); - - addresses = host.Services.GetService()?.Features.Get()?.Addresses; - } - - if (addresses is not null && addresses.Count != 0) - EventService.Invoke(nameof(EventService.DevicesServerPortChanged), [new Uri(addresses.First()).Port]); - - return this; - } - - public async Task CloseAsync() - { - if (_host is not null) - await _host.StopAsync(); - - return this; - } - - private IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .UseSerilog() - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - webBuilder.UseUrls($"http://0.0.0.0:{port}"); - }); - - internal bool IsDeviceTokenExist(string token) => SignedDeviceTokens.ContainsValue(token); - - internal DeviceLocator? SearchDeviceByToken(string token) - { - if (IsDeviceTokenExist(token) == false) - return null; - - return SignedDeviceTokens.First(x => x.Value.Equals(token)).Key; - } - - internal bool IsDeviceSignedIn(DeviceLocator locator) => SignedDeviceTokens.ContainsKey(locator); - - internal void AddDeviceToken(DeviceLocator locator, string token) => SignedDeviceTokens.Add(locator, token); - - internal string SignInDevice(DeviceLocator locator) - { - var token = Guid.NewGuid().ToString(); - - while (SignedDeviceTokens.ContainsValue(token)) - token = Guid.NewGuid().ToString(); - - if (SignedDeviceTokens.TryAdd(locator, token) == false) - SignedDeviceTokens[locator] = token; - - return token; - } -} - -public class Startup -{ - private readonly List apiVersions = [.. typeof(DevicesServerApiVersions).GetEnumNames()]; - - public void ConfigureServices(IServiceCollection services) - { - services.AddControllers(); - - services.AddEndpointsApiExplorer(); - - services.AddSwaggerGen(options => - { - apiVersions.ForEach(version => - { - options.SwaggerDoc( - version, - new OpenApiInfo - { - Title = "KitX Dashboard DevicesServer API", - Version = version, - Description = $"Version: {version}", - } - ); - }); - }); - } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseSwagger(); - - app.UseSwaggerUI(options => - { - apiVersions.ForEach(version => - { - options.SwaggerEndpoint($"/swagger/{version}/swagger.json", version); - }); - }); - - app.UseRouting(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }); - } -} - -public enum DevicesServerApiVersions -{ - V1 = 1, -} diff --git a/KitX Dashboard/Network/DevicesNetwork/DevicesServerControllers/V1/DeviceController.cs b/KitX Dashboard/Network/DevicesNetwork/DevicesServerControllers/V1/DeviceController.cs deleted file mode 100644 index dafbad94..00000000 --- a/KitX Dashboard/Network/DevicesNetwork/DevicesServerControllers/V1/DeviceController.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using Avalonia.Threading; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Managers; -using KitX.Dashboard.Services; -using KitX.Dashboard.Views; -using KitX.Shared.CSharp.Device; -using Microsoft.AspNetCore.Mvc; -using Serilog; - -namespace KitX.Dashboard.Network.DevicesNetwork.DevicesServerControllers.V1; - -[ApiController] -[Route("Api/V1/[controller]")] -[ApiExplorerSettings(GroupName = "V1")] -public class DeviceController : ControllerBase -{ - [ApiExplorerSettings(GroupName = "V1")] - [HttpGet("", Name = nameof(GetDeviceInfo))] - public IActionResult GetDeviceInfo([FromQuery] string token) - { - if (DevicesServer.Instance.IsDeviceTokenExist(token)) - return Ok(DevicesDiscoveryServer.Instance.DefaultDeviceInfo); - else - return BadRequest("You should connect to this device first."); - } - - [ApiExplorerSettings(GroupName = "V1")] - [HttpPost(nameof(ExchangeKey), Name = nameof(ExchangeKey))] - public IActionResult ExchangeKey([FromQuery] string verifyCodeSHA1, [FromQuery] string address, [FromBody] string deviceKey) - { - if (ConstantTable.IsExchangingDeviceKey) - return BadRequest("Remote device is exchanging device key."); - - if (SecurityManager.Instance.LocalDeviceKey is null) - return BadRequest("Remote device didn't set up device key."); - - ConstantTable.IsExchangingDeviceKey = true; - - Dispatcher.UIThread.Post(() => - { - var window = new ExchangeDeviceKeyWindow(); - - EventService.OnReceiveCancelExchangingDeviceKey += () => Dispatcher.UIThread.Post(() => window.Canceled()); - - ViewInstances.ShowWindow( - window - .OnVerificationCodeEntered(async code => - { - if (verifyCodeSHA1.Equals(SecurityManager.GetSHA1(code))) - { - var deviceKeyDecrypted = SecurityManager.AesDecrypt(deviceKey, code); - - var deviceKeyInstance = JsonSerializer.Deserialize(deviceKeyDecrypted); - - if (deviceKeyInstance is null) - await window.OnErrorDecodeAsync(); - else - { - var url = $"http://{address}/Api/V1/Device/{nameof(ExchangeKeyBack)}"; - - if (SecurityManager.Instance.LocalDeviceKey is null) - { - await window.OnErrorDecodeAsync(); - - ConstantTable.IsExchangingDeviceKey = false; - - return; - } - - var currentKey = SecurityManager.Instance.GetPrivateDeviceKey(); - - if (currentKey is null) - { - await window.OnErrorDecodeAsync(); - - ConstantTable.IsExchangingDeviceKey = false; - - return; - } - - var currentKeyJson = JsonSerializer.Serialize(currentKey); - - var currentKeyEncrypted = SecurityManager.AesEncrypt(currentKeyJson, code); - - using var http = new HttpClient(); - - var response = await http.PostAsync( - url, - new StringContent(JsonSerializer.Serialize(currentKeyEncrypted), Encoding.UTF8, "application/json") - ); - - if (response.IsSuccessStatusCode) - { - SecurityManager.Instance.AddDeviceKey(deviceKeyInstance); - - window.Success(); - - ConstantTable.IsExchangingDeviceKey = false; - } - else - await window.OnErrorDecodeAsync( - new StringBuilder() - .AppendLine($"Requested: {url}") - .AppendLine($"Responsed: {response.StatusCode} - {response.ReasonPhrase}") - .AppendLine($"Content: {await response.Content.ReadAsStringAsync()}") - .AppendLine(response.RequestMessage?.ToString()) - .ToString() - ); - } - } - else - { - await window.OnErrorDecodeAsync(); - } - }) - .OnCancel(async () => - { - window.Canceled(); - - ConstantTable.IsExchangingDeviceKey = false; - - var url = $"http://{address}/Api/V1/Device/{nameof(CancelExchangingKey)}"; - - using var http = new HttpClient(); - - var response = await http.PostAsync(url, null); - - Log.Information( - $"In {nameof(DeviceController)}: Requested {url} with responsed {response.StatusCode} - {response}" - ); - }), - ViewInstances.MainWindow, - false - ); - }); - - return Ok(); - } - - [ApiExplorerSettings(GroupName = "V1")] - [HttpPost(nameof(ExchangeKeyBack), Name = nameof(ExchangeKeyBack))] - public IActionResult ExchangeKeyBack([FromBody] string deviceKey) - { - if (ConstantTable.ExchangeDeviceKeyCode is null) - return BadRequest(); - - var deviceKeyDecrypted = SecurityManager.AesDecrypt(deviceKey, ConstantTable.ExchangeDeviceKeyCode); - - var deviceKeyInstance = JsonSerializer.Deserialize(deviceKeyDecrypted); - - if (deviceKeyInstance is null) - return BadRequest(); - - SecurityManager.Instance.AddDeviceKey(deviceKeyInstance); - - EventService.Invoke(nameof(EventService.OnAcceptingDeviceKey), [ConstantTable.ExchangeDeviceKeyCode]); - - return Ok(); - } - - [ApiExplorerSettings(GroupName = "V1")] - [HttpPost(nameof(CancelExchangingKey), Name = nameof(CancelExchangingKey))] - public IActionResult CancelExchangingKey() - { - if (ConstantTable.IsExchangingDeviceKey == false) - return BadRequest("Remote device isn't exchanging device key."); - - EventService.Invoke(nameof(EventService.OnReceiveCancelExchangingDeviceKey)); - - ConstantTable.IsExchangingDeviceKey = false; - - return Ok(); - } - - [ApiExplorerSettings(GroupName = "V1")] - [HttpPost(nameof(Connect), Name = nameof(Connect))] - public IActionResult Connect([FromQuery] string deviceBase64, [FromBody] string deviceNameEncrypted) - { - var device = JsonSerializer.Deserialize(Convert.FromBase64String(deviceBase64).ToUTF8()); - - if (device is null) - return BadRequest($"You provided wrong {nameof(deviceBase64)} which is not a type of `{nameof(DeviceLocator)}`."); - - var key = SecurityManager.SearchDeviceKey(device); - - if (key is null) - return BadRequest("You are not authorized by remote device."); - - var deviceNameDecrypted = SecurityManager.RsaDecryptString(key, deviceNameEncrypted); - - if (deviceNameDecrypted is null) - return StatusCode(500, "Remote crashed when decrypting device name."); - - if (device.DeviceName.Equals(deviceNameDecrypted) == false) - return BadRequest("You provided incorrect encrypted device name."); - - var token = DevicesServer.Instance.SignInDevice(device); - - return Ok(SecurityManager.Instance.EncryptString(token)); - } -} - -public static class DeviceControllerExtensions { } diff --git a/KitX Dashboard/Network/DevicesNetwork/DevicesServerControllers/V1/PluginController.cs b/KitX Dashboard/Network/DevicesNetwork/DevicesServerControllers/V1/PluginController.cs deleted file mode 100644 index c24dc45f..00000000 --- a/KitX Dashboard/Network/DevicesNetwork/DevicesServerControllers/V1/PluginController.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Managers; -using KitX.Dashboard.Network.PluginsNetwork; -using KitX.Shared.CSharp.Device; -using KitX.Shared.CSharp.Security; -using KitX.Shared.CSharp.WebCommand; -using Microsoft.AspNetCore.Mvc; - -namespace KitX.Dashboard.Network.DevicesNetwork.DevicesServerControllers.V1; - -[ApiController] -[Route("Api/V1/[controller]")] -[ApiExplorerSettings(GroupName = "V1")] -public class PluginController : ControllerBase -{ - [ApiExplorerSettings(GroupName = "V1")] - [HttpPost("Invoke", Name = nameof(Invoke))] - public IActionResult Invoke([FromQuery] string token, [FromBody] string requestJsonBase64) - { - if (DevicesServer.Instance.IsDeviceTokenExist(token)) - { - var requestJson = Convert.FromBase64String(requestJsonBase64).ToUTF8(); - - var request = JsonSerializer.Deserialize(requestJson); - - if (request is null) - return BadRequest($"Wrong format of {nameof(requestJson)}"); - - var noTarget = request.Target is null; - - var isMe = request.Target?.IsSameDevice(DevicesDiscoveryServer.Instance.DefaultDeviceInfo.Device) ?? false; - - var isNotMe = !isMe; - - if (noTarget || isNotMe) - return BadRequest(noTarget ? "Provide target field please." : "Please send to actual target."); - - var content = request.GetContent(toDecrypt => - { - if (request.EncryptionInfo.IsEncrypted) - { - switch (request.EncryptionInfo.EncryptionMethod) - { - case Shared.CSharp.WebCommand.Infos.EncryptionMethods.Custom: - // ToDo: Add custom encryption method support - throw new NotImplementedException(); - - case Shared.CSharp.WebCommand.Infos.EncryptionMethods.RSA: - - var device = DevicesServer.Instance.SearchDeviceByToken(token); - - ArgumentNullException.ThrowIfNull(device, nameof(device)); - - var key = SecurityManager.SearchDeviceKey(device); - - ArgumentNullException.ThrowIfNull(key, nameof(key)); - - var toDecryptContent = JsonSerializer.Deserialize(toDecrypt); - - ArgumentNullException.ThrowIfNull(toDecryptContent, nameof(toDecryptContent)); - - return SecurityManager.RsaDecryptContent(key, toDecryptContent); - - case Shared.CSharp.WebCommand.Infos.EncryptionMethods.AES: - // ToDo: Add AES encryption method support - throw new NotImplementedException(); - } - - throw new InvalidOperationException("Invalid encryption method."); - } - else - return toDecrypt; - }); - - request.Match( - content, - matchCommand: command => - { - var kwc = JsonSerializer.Deserialize(command); - - var connector = PluginsServer.Instance.FindConnector(kwc.PluginConnectionId); - - if (connector is null) - return; - - connector.Request( - request.Rebuild(request => - { - request.Content = command; - - return request; - }) - ); - } - ); - - return Ok(); - } - else - { - return BadRequest("You should connect to this device first."); - } - } -} - -public static class PluginControllerExtensions -{ - public static async Task RemoteInvoke(this string targetAddress, string token, Request request) - { - var requestJson = JsonSerializer.Serialize(request); - - var requestJsonBase64 = Convert.ToBase64String(requestJson.FromUTF8()); - - var toSend = JsonSerializer.Serialize(requestJsonBase64); - - var url = $"http://{targetAddress}/Api/V1/Plugin/Invoke?token={token}"; - - using var http = new HttpClient(); - - var response = await http.PostAsync(url, new StringContent(toSend, Encoding.UTF8, "application/json")); - - return response; - } -} diff --git a/KitX Dashboard/Network/NetworkHelper.cs b/KitX Dashboard/Network/NetworkHelper.cs deleted file mode 100644 index 4348bb10..00000000 --- a/KitX Dashboard/Network/NetworkHelper.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; -using Common.BasicHelper.Core.Shell; -using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Converters; -using KitX.Dashboard.Managers; -using KitX.Dashboard.Views; -using KitX.Shared.CSharp.Device; -using Serilog; - -namespace KitX.Dashboard.Network; - -internal static class NetworkHelper -{ - internal static bool CheckNetworkInterface(NetworkInterface adapter, IPInterfaceProperties adapterProperties) - { - var userPointed = ConfigManager.Instance.AppConfig.Web.AcceptedNetworkInterfaces; - - if (userPointed is not null) - return userPointed.Contains(adapter.Name); - - var noNetworkConnection = - adapter.NetworkInterfaceType != NetworkInterfaceType.Ethernet - && adapter.NetworkInterfaceType != NetworkInterfaceType.Wireless80211 - && ( - adapterProperties.MulticastAddresses.Count == 0 - || - // most of VPN adapters will be skipped - !adapter.SupportsMulticast - || - // multicast is meaningless for this type of connection - OperationalStatus.Up != adapter.OperationalStatus - || - // this adapter is off or not connected - !adapter.Supports(NetworkInterfaceComponent.IPv4) - ); - - return !noNetworkConnection; - } - - internal static bool IsInterNetworkAddressV4(IPAddress address) - { - var bytes = address.GetAddressBytes(); - - return bytes[0] switch - { - 10 => true, - 172 => bytes[1] <= 31 && bytes[1] >= 16, - 192 => bytes[1] == 168, - _ => false, - }; - } - - internal static string GetInterNetworkIPv4() - { - const string location = $"{nameof(NetworkHelper)}.{nameof(GetInterNetworkIPv4)}"; - - try - { - var search = - from ip in Dns.GetHostEntry(Dns.GetHostName()).AddressList - where - ip.AddressFamily == AddressFamily.InterNetwork - && IsInterNetworkAddressV4(ip) - && !ip.ToString().Equals("127.0.0.1") - && ip.ToString().StartsWith(ConfigManager.Instance.AppConfig.Web.IPFilter) - select ip; - - Log.Information($"IPv4 addresses: {search.Print(print: false, separateWithNewLine: false)}"); - - var result = search.FirstOrDefault()?.ToString(); - - return result ?? string.Empty; - } - catch (Exception ex) - { - Log.Warning(ex, $"In {location}: {ex.Message}"); - - return string.Empty; - } - } - - internal static string GetInterNetworkIPv6() - { - const string location = $"{nameof(NetworkHelper)}.{nameof(GetInterNetworkIPv6)}"; - - try - { - var search = - from ip in Dns.GetHostEntry(Dns.GetHostName()).AddressList - where ip.AddressFamily == AddressFamily.InterNetworkV6 && !ip.ToString().Equals("::1") - select ip; - - Log.Information($"IPv6 addresses: {search.Print(print: false, separateWithNewLine: false)}"); - - var result = search.FirstOrDefault()?.ToString(); - - return result ?? string.Empty; - } - catch (Exception ex) - { - Log.Warning(ex, $"In {location}: {ex.Message}"); - - return string.Empty; - } - } - - internal static string? TryGetDeviceMacAddress() - { - const string location = $"{nameof(NetworkHelper)}.{nameof(TryGetDeviceMacAddress)}"; - - try - { - var mac = - from nic in NetworkInterface.GetAllNetworkInterfaces() - where - CheckNetworkInterface(nic, nic.GetIPProperties()) - && nic.GetIPProperties().UnicastAddresses.Any(x => x.Address.ToString().Equals(GetInterNetworkIPv4())) - select nic.GetPhysicalAddress().ToString(); - - var result = mac.FirstOrDefault()?.SeparateGroup(2, sb => sb.Append(':')); - - return result; - } - catch (Exception ex) - { - Log.Warning(ex, $"In {location}: {ex.Message}"); - - return string.Empty; - } - } - - internal static string? TryGetOsVersionString() - { - const string location = $"{nameof(NetworkHelper)}.{nameof(TryGetOsVersionString)}"; - - var result = Environment.OSVersion.VersionString; - - try - { - switch (OperatingSystemUtils.GetOSType()) - { - case OperatingSystems.Linux: - - const string versionFilePath = "/etc/os-release"; - const string versionSegment = "PRETTY_NAME"; - - var needFindInIssue = false; - - if (File.Exists(versionFilePath)) - { - var osRelease = File.ReadAllLines(versionFilePath) - .Select(line => line.Split('=')) - .ToDictionary(parts => parts[0], parts => parts[1].Trim('"')); - - if (osRelease.TryGetValue(versionSegment, out var version)) - result = version; - else - needFindInIssue = true; - } - - if (needFindInIssue) - { - const string issueFilePath = "/etc/issue"; - - if (File.Exists(issueFilePath)) - { - var issue = File.ReadAllText(issueFilePath); - var lines = issue.Split('\n'); - result = lines.First(x => !x.Equals(string.Empty)); - } - } - - break; - - case OperatingSystems.MacOS: - const string command = "sw_vers"; - - var productName = command.ExecuteAsCommand("-productName"); - var productVersion = command.ExecuteAsCommand("-productVersion"); - var buildVersion = command.ExecuteAsCommand("-buildVersion"); - - result = $"{productName} {productVersion} {buildVersion}".Replace("\n", ""); - - break; - } - } - catch (Exception ex) - { - Log.Error(ex, $"In {location}: {ex.Message}"); - } - - return result; - } - - internal static DeviceInfo GetDeviceInfo() => - new() - { - Device = new() - { - DeviceName = Environment.MachineName, - MacAddress = TryGetDeviceMacAddress() ?? "", - IPv4 = GetInterNetworkIPv4(), - IPv6 = GetInterNetworkIPv6(), - }, - IsMainDevice = ConstantTable.IsMainMachine, - SendTime = DateTime.UtcNow, - DeviceOSType = OperatingSystemUtils.GetOSType(), - DeviceOSVersion = TryGetOsVersionString() ?? "", - PluginsServerPort = ConstantTable.PluginsServerPort, - DevicesServerPort = ConstantTable.DevicesServerPort, - DevicesServerBuildTime = new(), - PluginsCount = ViewInstances.PluginInfos.Count, - }; -} diff --git a/KitX Dashboard/Network/NetworkStatus.cs b/KitX Dashboard/Network/NetworkStatus.cs deleted file mode 100644 index 8ee7d775..00000000 --- a/KitX Dashboard/Network/NetworkStatus.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace KitX.Dashboard.Network; - -public enum ServerStatus -{ - Unknown = 0, - - Starting = 1, - - Running = 2, - - Stopping = 3, - - Pending = 4, - - Errored = 5, -} - -public enum ClientStatus -{ - Unknown = 0, - - Connecting = 1, - - Running = 2, - - Disconnecting = 3, - - Pending = 4, - - Errored = 5, -} diff --git a/KitX Dashboard/Network/PluginsNetwork/PluginConnector.cs b/KitX Dashboard/Network/PluginsNetwork/PluginConnector.cs deleted file mode 100644 index 9ed015fd..00000000 --- a/KitX Dashboard/Network/PluginsNetwork/PluginConnector.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System; -using System.Text.Json; -using System.Threading.Tasks; -using Common.BasicHelper.Utils.Extensions; -using Fleck; -using KitX.Dashboard.Names; -using KitX.Dashboard.Views; -using KitX.Shared.CSharp.Plugin; -using KitX.Shared.CSharp.WebCommand; -using KitX.Shared.CSharp.WebCommand.Details; -using KitX.Shared.CSharp.WebCommand.Infos; -using Serilog; - -namespace KitX.Dashboard.Network.PluginsNetwork; - -public class PluginConnector -{ - private readonly IWebSocketConnection? _connection; - - private readonly IWebSocketConnectionInfo? _connectionInfo; - - private string? _path; - - private bool _initialized = false; - - private PluginInfo? _pluginInfo; - - private ServerStatus connectorStatus = ServerStatus.Pending; - - private readonly JsonSerializerOptions serializerOptions = new() - { - WriteIndented = true, - IncludeFields = true, - PropertyNameCaseInsensitive = true, - }; - - public delegate void PluginStatusUpdatedHandler(); - - public event PluginStatusUpdatedHandler PluginStatusUpdated = new(() => { }); - - public PluginConnector() { } - - public PluginConnector(IWebSocketConnection socket) - { - _connection = socket; - _connectionInfo = socket.ConnectionInfo; - } - - public string? Path - { - get => _path; - set - { - _path = value; - - PluginStatusUpdated.Invoke(); - } - } - - public string? ConnectionId => Path; - - public PluginInfo? PluginInfo - { - get => _pluginInfo; - set - { - _pluginInfo = value; - - PluginStatusUpdated.Invoke(); - } - } - - public bool PluginInfoAvailable => PluginInfo is null; - - public ServerStatus ConnectorStatus - { - get => connectorStatus; - set - { - connectorStatus = value; - - PluginStatusUpdated.Invoke(); - } - } - - public PluginConnector Initialize() - { - _initialized = true; - - Path = _connectionInfo!.Path.Trim('/'); - - return this; - } - - public PluginConnector Run() - { - if (_initialized == false) - Initialize(); - - const string location = $"{nameof(PluginConnector)}.{nameof(Run)}"; - - _connection!.OnOpen = () => { }; - - _connection.OnClose = () => - { - try - { - if (PluginInfo is not null) - ViewInstances.PluginInfos.Remove(PluginInfo); - } - catch (Exception e) - { - Log.Warning(e, $"In {location}: {e.Message}"); - } - - PluginsServer.Instance.PluginConnectors.Remove(this); - }; - - _connection.OnMessage = message => - { - var kwc = JsonSerializer.Deserialize(message, serializerOptions); - - if (kwc is null) - return; - - var command = JsonSerializer.Deserialize(kwc.Content, serializerOptions); - - switch (command.Request) - { - case CommandRequestInfo.RegisterPlugin: - - var body = command.Body.ToUTF8(count: command.BodyLength); - - PluginInfo = JsonSerializer.Deserialize(body, serializerOptions); - - ArgumentNullException.ThrowIfNull(PluginInfo, nameof(PluginInfo)); - - ArgumentNullException.ThrowIfNull(PluginInfo.Tags, nameof(PluginInfo.Tags)); - - PluginInfo.Tags.Add(nameof(ConnectionId), ConnectionId ?? string.Empty); - PluginInfo.Tags.Add(nameof(PluginTagsNames.JoinTime), DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss(FF)")); - - ViewInstances.PluginInfos.Add(PluginInfo); - - Log.Information($"In {location}: New plugin registered with {body.Replace("\r", "").Replace("\n", "")}"); - - break; - case CommandRequestInfo.RequestWorkingDetail: - SendWorkingDetail(); - break; - case CommandRequestInfo.ReportStatus: - break; - case CommandRequestInfo.RequestCommand: - break; - } - ; - }; - - _connection.OnError = ex => - { - try - { - if (PluginInfo is not null) - ViewInstances.PluginInfos.Remove(PluginInfo); - } - catch (Exception e) - { - Log.Warning(e, $"In {location}: {e.Message}"); - } - - PluginsServer.Instance.PluginConnectors.Remove(this); - - Log.Error(ex, $"In {location}: {ex.Message}"); - }; - - return this; - } - - private void SendMessage(T content) => _connection!.Send(JsonSerializer.Serialize(content, serializerOptions)); - - private void SendWorkingDetail() - { - if (_path.IsNullOrWhiteSpace()) - SendMessage(new PluginWorkingDetail() { PluginDataDirectory = null, PluginSaveDirectory = null }); - else { } - } - - internal async void Request(Request request) - { - await _connection!.Send(JsonSerializer.Serialize(request, serializerOptions)); - } - - public async Task CloseAsync() - { - await Task.Run(() => - { - _connection!.Close(); - }); - - return this; - } -} diff --git a/KitX Dashboard/Network/PluginsNetwork/PluginsServer.cs b/KitX Dashboard/Network/PluginsNetwork/PluginsServer.cs deleted file mode 100644 index b107be5a..00000000 --- a/KitX Dashboard/Network/PluginsNetwork/PluginsServer.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Fleck; -using KitX.Dashboard.Configuration; -using KitX.Dashboard.Services; -using KitX.Shared.CSharp.Plugin; - -namespace KitX.Dashboard.Network.PluginsNetwork; - -public partial class PluginsServer : ConfigFetcher -{ - private static PluginsServer? _pluginsServer; - - public static PluginsServer Instance => _pluginsServer ??= new(); - - private WebSocketServer? _server; - - private readonly List _connectors = []; - - public List PluginConnectors => _connectors; - - public PluginsServer() - { - InitializeServer(); - } - - private void InitializeServer() - { - var port = AppConfig.Web.UserSpecifiedPluginsServerPort ?? 0; - - port = port is >= 0 and <= 65535 ? port : 0; - - _server ??= new WebSocketServer($"ws://0.0.0.0:{port}"); - } - - public PluginsServer Run() - { - InitializeServer(); - - _server!.Start(socket => - { - if (RegexToVerifyConnectionId().IsMatch(socket.ConnectionInfo.Path.Trim('/')) == false) - { - socket.Send("Invalid connection id."); - - socket.Close(); - - return; - } - - var connector = new PluginConnector(socket).Run(); - - _connectors.Add(connector); - }); - - EventService.Invoke(nameof(EventService.PluginsServerPortChanged), [_server!.Port]); - - return this; - } - - public PluginConnector? FindConnector(PluginInfo info) - { - var query = PluginConnectors.Where(x => x.PluginInfo is not null && x.PluginInfo.Equals(info)); - - if (query.Any()) - return query.First(); - else - return null; - } - - public PluginConnector? FindConnector(string connectionId) => - PluginConnectors.FirstOrDefault(x => x.ConnectionId?.Equals(connectionId) ?? false); - - public async Task Close() - { - await Task.Run(() => - { - Task.WaitAll(_connectors.Select(c => c.CloseAsync()).ToArray()); - - _connectors.Clear(); - - _server?.Dispose(); - - _server = null; - }); - - return this; - } - - [GeneratedRegex(@"^[{]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[}]?$")] - private static partial Regex RegexToVerifyConnectionId(); -} diff --git a/KitX Dashboard/Program.cs b/KitX Dashboard/Program.cs index 58325c20..21b67851 100644 --- a/KitX Dashboard/Program.cs +++ b/KitX Dashboard/Program.cs @@ -1,8 +1,8 @@ using System; using System.IO; using Avalonia; -using Avalonia.ReactiveUI; using Common.BasicHelper.Utils.Extensions; +using ReactiveUI.Avalonia; namespace KitX.Dashboard; diff --git a/KitX Dashboard/Services/EventService.cs b/KitX Dashboard/Services/EventService.cs deleted file mode 100644 index 041bbba6..00000000 --- a/KitX Dashboard/Services/EventService.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using System.Reflection; -using KitX.Shared.CSharp.Device; - -namespace KitX.Dashboard.Services; - -public static class EventService -{ - public static void Invoke(string eventName, object[]? objects = null) - { - var type = typeof(EventService); - - var eventField = type.GetField(eventName, BindingFlags.Static | BindingFlags.NonPublic); - - if (eventField is null || !typeof(Delegate).IsAssignableFrom(eventField.FieldType)) - { - throw new ArgumentException($"No event found with the name '{eventName}'.", nameof(eventName)); - } - - var @delegate = eventField.GetValue(null) as Delegate; - - @delegate?.DynamicInvoke(objects); - } - -#pragma warning disable CS0067 // Event is never used - - public delegate void LanguageChangedHandler(); - - public static event LanguageChangedHandler LanguageChanged = () => { }; - - public delegate void GreetingTextIntervalUpdatedHandler(); - - public static event GreetingTextIntervalUpdatedHandler GreetingTextIntervalUpdated = () => { }; - - public delegate void AppConfigChangedHandler(); - - public static event AppConfigChangedHandler AppConfigChanged = () => { }; - - public delegate void PluginsConfigChangedHandler(); - - public static event PluginsConfigChangedHandler PluginsConfigChanged = () => { }; - - public delegate void MicaOpacityChangedHandler(); - - public static event MicaOpacityChangedHandler MicaOpacityChanged = () => { }; - - public delegate void DevelopSettingsChangedHandler(); - - public static event DevelopSettingsChangedHandler DevelopSettingsChanged = () => { }; - - public delegate void LogConfigUpdatedHandler(); - - public static event LogConfigUpdatedHandler LogConfigUpdated = () => { }; - - public delegate void ThemeConfigChangedHandler(); - - public static event ThemeConfigChangedHandler ThemeConfigChanged = () => { }; - - public delegate void UseStatisticsChangedHandler(); - - public static event UseStatisticsChangedHandler UseStatisticsChanged = () => { }; - - public delegate void DevicesServerPortChangedHandler(int port); - - public static event DevicesServerPortChangedHandler DevicesServerPortChanged = port => ConstantTable.DevicesServerPort = port; - - public delegate void PluginsServerPortChangedHandler(int port); - - public static event PluginsServerPortChangedHandler PluginsServerPortChanged = port => ConstantTable.PluginsServerPort = port; - - public delegate void OnActivitiesUpdatedHandler(); - - public static event OnActivitiesUpdatedHandler OnActivitiesUpdated = () => { }; - - public delegate void OnReceiveCancelExchangingDeviceKeyHandler(); - - public static event OnReceiveCancelExchangingDeviceKeyHandler OnReceiveCancelExchangingDeviceKey = () => - ConstantTable.IsExchangingDeviceKey = false; - - public delegate void OnExitingHandler(); - - public static event OnExitingHandler OnExiting = () => { }; - - public delegate void OnReceivingDeviceInfoHandler(DeviceInfo dis); - - public static event OnReceivingDeviceInfoHandler OnReceivingDeviceInfo = _ => { }; - - public delegate void OnConfigHotReloadedHandler(); - - public static event OnConfigHotReloadedHandler OnConfigHotReloaded = () => { }; - - public delegate void OnAcceptingDeviceKeyHandler(string code); - - public static event OnAcceptingDeviceKeyHandler OnAcceptingDeviceKey = _ => { }; - -#pragma warning restore CS0067 // Event is never used -} diff --git a/KitX Dashboard/Services/FileDialogService.cs b/KitX Dashboard/Services/FileDialogService.cs new file mode 100644 index 00000000..b5ea05bd --- /dev/null +++ b/KitX Dashboard/Services/FileDialogService.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Platform.Storage; + +namespace KitX.Dashboard.Services; + +/// +/// Avalonia implementation of file dialog service using modern IStorageProvider API. +/// For text input/output dialogs, uses simple inline window construction. +/// +public class FileDialogService : IFileDialogService +{ + public async Task ShowOpenDialogAsync(string title, List? filters = null) + { + var window = GetMainWindow(); + if (window == null) return null; + + var options = new FilePickerOpenOptions + { + Title = title, + AllowMultiple = false + }; + + if (filters != null) + { + options.FileTypeFilter = filters.Select(f => new FilePickerFileType(f.Name) + { + Patterns = f.Extensions.Select(ext => $"*.{ext}").ToList() + }).ToList(); + } + + var result = await window.StorageProvider.OpenFilePickerAsync(options); + return result.FirstOrDefault()?.TryGetLocalPath(); + } + + public async Task ShowSaveDialogAsync(string title, string defaultExtension, List? filters = null, string? suggestedFileName = null) + { + var window = GetMainWindow(); + if (window == null) return null; + + var options = new FilePickerSaveOptions + { + Title = title, + DefaultExtension = defaultExtension, + SuggestedFileName = suggestedFileName + }; + + if (filters != null) + { + options.FileTypeChoices = filters.Select(f => new FilePickerFileType(f.Name) + { + Patterns = f.Extensions.Select(ext => $"*.{ext}").ToList() + }).ToList(); + } + + var result = await window.StorageProvider.SaveFilePickerAsync(options); + return result?.TryGetLocalPath(); + } + + public async Task ShowTextInputDialogAsync(string title, string prompt, string? initialText = null) + { + var owner = GetMainWindow(); + if (owner == null) return null; + + var tcs = new TaskCompletionSource(); + + var dialog = new Window + { + Title = title, + Width = 500, + Height = 400, + WindowStartupLocation = WindowStartupLocation.CenterOwner + }; + + var textBox = new TextBox + { + AcceptsReturn = true, + AcceptsTab = true, + TextWrapping = TextWrapping.Wrap, + Height = 300, + Margin = new Thickness(10), + Text = initialText ?? string.Empty + }; + + var cancelButton = new Button { Content = "Cancel", Width = 80, Margin = new Thickness(0, 0, 10, 0) }; + var okButton = new Button { Content = "Import", Width = 80 }; + + cancelButton.Click += (_, _) => + { + tcs.TrySetResult(null); + dialog.Close(); + }; + okButton.Click += (_, _) => + { + tcs.TrySetResult(textBox.Text); + dialog.Close(); + }; + + var panel = new StackPanel(); + panel.Children.Add(new TextBlock { Text = prompt, Margin = new Thickness(10, 10, 10, 5) }); + panel.Children.Add(textBox); + + var buttonPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(10, 0, 10, 10), + Children = { cancelButton, okButton } + }; + panel.Children.Add(buttonPanel); + + dialog.Content = panel; + + await dialog.ShowDialog(owner); + return await tcs.Task; + } + + public async Task ShowTextOutputDialogAsync(string title, string content) + { + var owner = GetMainWindow(); + if (owner == null) return; + + var dialog = new Window + { + Title = title, + Width = 600, + Height = 500, + WindowStartupLocation = WindowStartupLocation.CenterOwner + }; + + var textBox = new TextBox + { + Text = content, + IsReadOnly = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + FontFamily = new FontFamily("Consolas, Courier New"), + Margin = new Thickness(10) + }; + + var closeButton = new Button + { + Content = "Close", + Width = 80, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(10) + }; + closeButton.Click += (_, _) => dialog.Close(); + + var panel = new StackPanel(); + panel.Children.Add(textBox); + panel.Children.Add(closeButton); + + dialog.Content = panel; + await dialog.ShowDialog(owner); + } + + private static Window? GetMainWindow() + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + return desktop.MainWindow; + return null; + } +} diff --git a/KitX Dashboard/Services/IFileDialogService.cs b/KitX Dashboard/Services/IFileDialogService.cs new file mode 100644 index 00000000..b80aa5d1 --- /dev/null +++ b/KitX Dashboard/Services/IFileDialogService.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace KitX.Dashboard.Services; + +/// +/// Abstraction for file dialog operations, enabling MVVM-compatible file picking. +/// +public interface IFileDialogService +{ + /// + /// Shows an open file dialog and returns the selected file path, or null if cancelled. + /// + Task ShowOpenDialogAsync(string title, List? filters = null); + + /// + /// Shows a save file dialog and returns the selected file path, or null if cancelled. + /// + Task ShowSaveDialogAsync(string title, string defaultExtension, List? filters = null, string? suggestedFileName = null); + + /// + /// Shows a modal dialog with a text input and returns the entered text, or null if cancelled. + /// + Task ShowTextInputDialogAsync(string title, string prompt, string? initialText = null); + + /// + /// Shows a modal dialog displaying read-only text content. + /// + Task ShowTextOutputDialogAsync(string title, string content); +} + +/// +/// Represents a file dialog filter. +/// +public class FileDialogFilter +{ + public string Name { get; set; } = string.Empty; + public List Extensions { get; set; } = []; +} diff --git a/KitX Dashboard/Services/UIStateService.cs b/KitX Dashboard/Services/UIStateService.cs new file mode 100644 index 00000000..70c0aaf9 --- /dev/null +++ b/KitX Dashboard/Services/UIStateService.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Controls; +using KitX.Core.Contract.Device; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Workflow; +using KitX.Core.Event; +using KitX.Dashboard; +using KitX.Dashboard.Views; +using KitX.Shared.CSharp.Plugin; + +namespace KitX.Dashboard.Services; + +/// +/// UI State Service - Manages shared UI state across ViewModels +/// +public static class UIStateService +{ + public static ObservableCollection DeviceCases { get; set; } = []; + + public static ObservableCollection WorkflowCases { get; set; } = []; + + public static ObservableCollection PluginInfos { get; set; } = []; + + public static MainWindow? MainWindow { get; set; } + + public static PluginsLaunchWindow? PluginsLaunchWindow { get; set; } + + public static List Windows { get; set; } = []; + + /// + /// Tracks open workflow editor windows by workflow ID. + /// Key: workflowId, Value: editor window instance. + /// Used to prevent opening duplicate editors for the same workflow. + /// + public static Dictionary WorkflowEditorWindows { get; set; } = []; + + public static void ShowWindow(T window, Window? owner = null, bool showDialog = false, bool onlyOneInSameTime = false) + where T : Window + { + if (onlyOneInSameTime && Windows.Any(x => x.Title?.Equals(window.Title) ?? window.Title is null)) + return; + + var eventService = App.GetService(); + eventService.Subscribe(EventNames.OnExiting, (s, e) => window.Close()); + + Windows.Add(window); + + window.Closed += (_, _) => Windows.Remove(window); + + if (showDialog && owner is not null) + window.ShowDialog(owner); + else if (owner is null || owner.IsVisible == false) + window.Show(); + else + window.Show(owner); + } +} diff --git a/KitX Dashboard/Styles/BlueprintNodeStyles.axaml b/KitX Dashboard/Styles/BlueprintNodeStyles.axaml new file mode 100644 index 00000000..9854ec79 --- /dev/null +++ b/KitX Dashboard/Styles/BlueprintNodeStyles.axaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + diff --git a/KitX Dashboard/Styles/ScopeBlockStyles.axaml b/KitX Dashboard/Styles/ScopeBlockStyles.axaml new file mode 100644 index 00000000..e09739d0 --- /dev/null +++ b/KitX Dashboard/Styles/ScopeBlockStyles.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + diff --git a/KitX Dashboard/Utils/Events.cs b/KitX Dashboard/Utils/Events.cs new file mode 100644 index 00000000..b59685fa --- /dev/null +++ b/KitX Dashboard/Utils/Events.cs @@ -0,0 +1,73 @@ +using System; +using KitX.Core.Contract.Event; +using KitX.Core.Event; +using KitX.Dashboard; + +namespace KitX.Dashboard.Utils; + +/// +/// Event bus helper for simplified event publishing and subscribing +/// +public static class Events +{ + private static IEventService? _eventService; + + /// + /// Gets the event service instance + /// + public static IEventService Service => _eventService ??= App.GetService(); + + /// + /// Publishes an event + /// + /// The event name + /// The event arguments + public static void Publish(string eventName, EventArgs args = null!) + => Service.Publish(eventName, args ?? EventArgs.Empty); + + /// + /// Publishes a typed event + /// + /// The event args type + /// The event name + /// The event arguments + public static void Publish(string eventName, TEventArgs args) + where TEventArgs : EventArgs + => Service.Publish(eventName, args); + + /// + /// Subscribes to an event + /// + /// The event name + /// The event handler + public static void Subscribe(string eventName, EventHandler handler) + => Service.Subscribe(eventName, handler); + + /// + /// Subscribes to a typed event + /// + /// The event args type + /// The event name + /// The event handler + public static void Subscribe(string eventName, EventHandler handler) + where TEventArgs : EventArgs + => Service.Subscribe(eventName, handler); + + /// + /// Unsubscribes from an event + /// + /// The event name + /// The event handler + public static void Unsubscribe(string eventName, EventHandler handler) + => Service.Unsubscribe(eventName, handler); + + /// + /// Unsubscribes from a typed event + /// + /// The event args type + /// The event name + /// The event handler + public static void Unsubscribe(string eventName, EventHandler handler) + where TEventArgs : EventArgs + => Service.Unsubscribe(eventName, handler); +} diff --git a/KitX Dashboard/ViewModels/AnnouncementsWindowViewModel.cs b/KitX Dashboard/ViewModels/AnnouncementsWindowViewModel.cs index b3fda21a..eaa57832 100644 --- a/KitX Dashboard/ViewModels/AnnouncementsWindowViewModel.cs +++ b/KitX Dashboard/ViewModels/AnnouncementsWindowViewModel.cs @@ -2,7 +2,8 @@ using System.Linq; using System.Reactive; using FluentAvalonia.UI.Controls; -using KitX.Dashboard.Configuration; +using KitX.Core.Contract.Announcement; +using KitX.Core.Contract.Configuration; using KitX.Dashboard.Views; using ReactiveUI; @@ -21,7 +22,7 @@ public sealed override void InitCommands() { ConfirmReceivedCommand = ReactiveCommand.Create(() => { - var config = AnnouncementConfig; + var config = AnnouncementService.AnnouncementConfig; var accepted = config.Accepted; @@ -36,7 +37,7 @@ public sealed override void InitCommands() if (!accepted.Contains(key)) accepted.Add(key); - config.Save(config.ConfigFileLocation!); + AnnouncementService.SaveAnnouncementConfig(); var found = false; @@ -60,7 +61,7 @@ public sealed override void InitCommands() ConfirmReceivedAllCommand = ReactiveCommand.Create(() => { - var config = AnnouncementConfig; + var config = AnnouncementService.AnnouncementConfig; var accepted = config.Accepted; @@ -80,7 +81,7 @@ public sealed override void InitCommands() accepted.Add(key); } - config.Save(config.ConfigFileLocation!); + AnnouncementService.SaveAnnouncementConfig(); Window?.Close(); }); @@ -90,14 +91,14 @@ public sealed override void InitEvents() { } internal static double Window_Width { - get => AppConfig.Windows.AnnouncementWindow.Size.Width!.Value; - set => AppConfig.Windows.AnnouncementWindow.Size.Width = value; + get => App.GetService().AppConfig.Windows.AnnouncementWindow.Size.Width!.Value; + set => App.GetService().AppConfig.Windows.AnnouncementWindow.Size.Width = value; } internal static double Window_Height { - get => AppConfig.Windows.AnnouncementWindow.Size.Height!.Value; - set => AppConfig.Windows.AnnouncementWindow.Size.Height = value; + get => App.GetService().AppConfig.Windows.AnnouncementWindow.Size.Height!.Value; + set => App.GetService().AppConfig.Windows.AnnouncementWindow.Size.Height = value; } private NavigationViewItem? _selectedMenuItem; diff --git a/KitX Dashboard/ViewModels/AppViewModel.cs b/KitX Dashboard/ViewModels/AppViewModel.cs index 198f1726..e17b7e82 100644 --- a/KitX Dashboard/ViewModels/AppViewModel.cs +++ b/KitX Dashboard/ViewModels/AppViewModel.cs @@ -1,19 +1,36 @@ -using System.Reactive; +using System; +using System.Linq; +using System.Reactive; using System.Reflection; using System.Text; using System.Threading.Tasks; using Avalonia.Controls; -using KitX.Dashboard.Managers; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Announcement; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Plugin.Events; +using KitX.Core.Event; +using KitX.Dashboard; using KitX.Dashboard.Services; using KitX.Dashboard.Views; using ReactiveUI; +using Serilog; +using WindowState = Avalonia.Controls.WindowState; namespace KitX.Dashboard.ViewModels; internal class AppViewModel : ViewModelBase { + private readonly IConfigService _configService; + private readonly IAnnouncementService _announcementService; + public AppViewModel() { + // Get services from DI container + _configService = ConfigService; + _announcementService = AnnouncementService; + InitCommands(); InitEvents(); @@ -25,7 +42,7 @@ public sealed override void InitCommands() { TrayIconClickedCommand = ReactiveCommand.Create(() => { - var win = ViewInstances.MainWindow; + var win = UIStateService.MainWindow; if (win?.WindowState == WindowState.Minimized) win.WindowState = WindowState.Normal; @@ -34,26 +51,26 @@ public sealed override void InitCommands() win?.Activate(); - ConfigManager.Instance.AppConfig.Windows.MainWindow.IsHidden = false; + _configService.AppConfig.Windows.MainWindow.IsHidden = false; - SaveAppConfigChanges(); + _configService.SaveAll(); }); ViewLatestAnnouncementsCommand = ReactiveCommand.Create(async () => { - await AnnouncementManager.CheckNewAnnouncements(); + await _announcementService.CheckNewAnnouncementsAsync(); }); OpenDebugToolCommand = ReactiveCommand.Create(() => { - ViewInstances.ShowWindow(new DebugWindow()); + UIStateService.ShowWindow(new DebugWindow()); }); PluginLauncherCommand = ReactiveCommand.Create(() => { - ViewInstances.PluginsLaunchWindow ??= new(); + UIStateService.PluginsLaunchWindow ??= new(); - var win = ViewInstances.PluginsLaunchWindow; + var win = UIStateService.PluginsLaunchWindow; if (win.IsVisible) { @@ -79,13 +96,81 @@ public sealed override void InitCommands() public sealed override void InitEvents() { - ViewInstances.DeviceCases.CollectionChanged += (_, _) => UpdateTrayIconText(); + UIStateService.DeviceCases.CollectionChanged += (_, _) => UpdateTrayIconText(); + + UIStateService.PluginInfos.CollectionChanged += (_, _) => UpdateTrayIconText(); + + var eventService = App.GetService(); - ViewInstances.PluginInfos.CollectionChanged += (_, _) => UpdateTrayIconText(); + // Subscribe to port changes via EventService to update tray icon + eventService.Subscribe(EventNames.DevicesServerPortChanged, (s, e) => UpdateTrayIconText()); - EventService.DevicesServerPortChanged += _ => UpdateTrayIconText(); + eventService.Subscribe(EventNames.PluginsServerPortChanged, (s, e) => UpdateTrayIconText()); - EventService.PluginsServerPortChanged += _ => UpdateTrayIconText(); + // Subscribe to plugin events via EventService to update UIStateService.PluginInfos + eventService.Subscribe(EventNames.PluginRegistered, (s, e) => + { + Log.Information($"[AppViewModel] Received PluginRegistered event for: {e.PluginInfo?.Name}"); + if (e.PluginInfo is not null && !UIStateService.PluginInfos.Any(x => x.Name == e.PluginInfo.Name)) + { + UIStateService.PluginInfos.Add(e.PluginInfo); + Log.Information($"[AppViewModel] Added plugin: {e.PluginInfo.Name}, count: {UIStateService.PluginInfos.Count}"); + } + }); + + eventService.Subscribe(EventNames.PluginUnregistered, (s, e) => + { + Log.Information($"[AppViewModel] Received PluginUnregistered event for: {e.PluginInfo?.Name}"); + if (e.PluginInfo is not null) + { + var existing = UIStateService.PluginInfos.FirstOrDefault(x => x.Name == e.PluginInfo.Name); + if (existing is not null) + { + UIStateService.PluginInfos.Remove(existing); + Log.Information($"[AppViewModel] Removed plugin: {e.PluginInfo.Name}, count: {UIStateService.PluginInfos.Count}"); + } + else + { + Log.Warning($"[AppViewModel] Plugin not found in list: {e.PluginInfo.Name}"); + } + } + }); + + // Subscribe to plugin disconnected events to update UIStateService.PluginInfos + eventService.Subscribe(EventNames.PluginDisconnected, (s, e) => + { + Log.Information($"[AppViewModel] Received PluginDisconnected event for: {e.PluginInfo?.Name}, connection: {e.ConnectionId}"); + if (e.PluginInfo is not null) + { + var existing = UIStateService.PluginInfos.FirstOrDefault(x => x.Name == e.PluginInfo.Name); + if (existing is not null) + { + UIStateService.PluginInfos.Remove(existing); + Log.Information($"[AppViewModel] Removed disconnected plugin: {e.PluginInfo.Name}, count: {UIStateService.PluginInfos.Count}"); + } + else + { + Log.Warning($"[AppViewModel] Disconnected plugin not found in list: {e.PluginInfo.Name}"); + } + } + }); + + // Subscribe to announcement events to show announcement window + _announcementService.NewAnnouncementsAvailable += (_, e) => + { + // Convert IAnnouncement list to Dictionary format (Legacy compatible) + var src = new System.Collections.Generic.Dictionary(); + foreach (var announcement in e.Announcements) + { + src[announcement.PublishDate.ToString("yyyy-MM-dd HH:mm")] = $"# {announcement.Title}\n\n{announcement.Content}"; + } + + if (src.Count > 0) + { + var window = new AnnouncementsWindow().UpdateSource(src); + UIStateService.ShowWindow(window); + } + }; } private void UpdateTrayIconText() @@ -99,9 +184,9 @@ private void UpdateTrayIconText() .Append(Translate("Text_Settings_Performence_Web_PluginsServerPort")) .AppendLine(": " + ConstantTable.PluginsServerPort) .AppendLine() - .Append(ViewInstances.DeviceCases.Count + " ") + .Append(UIStateService.DeviceCases.Count + " ") .AppendLine(Translate("Text_Device_Tip_Detected")) - .Append(ViewInstances.PluginInfos.Count + " ") + .Append(UIStateService.PluginInfos.Count + " ") .AppendLine(Translate("Text_Lib_Tip_Connected")) .AppendLine() .Append("Hello, World!"); @@ -111,15 +196,16 @@ private void UpdateTrayIconText() public static void Exit() { - ViewInstances.DeviceCases.Clear(); + UIStateService.DeviceCases.Clear(); - ViewInstances.PluginInfos.Clear(); + UIStateService.PluginInfos.Clear(); ConstantTable.Exiting = true; - EventService.Invoke(nameof(EventService.OnExiting)); + var eventService = App.GetService(); + eventService.Publish(EventNames.OnExiting, EventArgs.Empty); - var win = ViewInstances.MainWindow; + var win = UIStateService.MainWindow; win?.Close(); } diff --git a/KitX Dashboard/ViewModels/BlueprintConnectionVM.cs b/KitX Dashboard/ViewModels/BlueprintConnectionVM.cs new file mode 100644 index 00000000..0bab636c --- /dev/null +++ b/KitX Dashboard/ViewModels/BlueprintConnectionVM.cs @@ -0,0 +1,45 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using NodifyM.Avalonia.ViewModelBase; + +namespace KitX.Dashboard.ViewModels; + +/// +/// Replaces ConnectorViewModel + _connectorPinTypes dictionary. +/// StrokeColorHex is bound directly to Connection.Stroke via DataTemplate. +/// Subscribes to source connector's PinType changes for dynamic color updates. +/// +public partial class BlueprintConnectionVM : ConnectionViewModelBase +{ + /// Stroke color hex derived from source connector's PinType + [ObservableProperty] + private string _strokeColorHex = "#FFFFFF"; + + private readonly BlueprintConnectorVM? _sourceConnector; + + public BlueprintConnectionVM(NodifyEditorViewModelBase editor, + BlueprintConnectorVM source, BlueprintConnectorVM target) + : base(editor, source, target) + { + _sourceConnector = source; + StrokeColorHex = source.PinTypeColorHex; + source.PropertyChanged += OnSourceConnectorPropertyChanged; + } + + public BlueprintConnectionVM(NodifyEditorViewModelBase editor, + BlueprintConnectorVM source, BlueprintConnectorVM target, string text) + : base(editor, source, target, text) + { + _sourceConnector = source; + StrokeColorHex = source.PinTypeColorHex; + source.PropertyChanged += OnSourceConnectorPropertyChanged; + } + + private void OnSourceConnectorPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(BlueprintConnectorVM.PinTypeColorHex) && _sourceConnector != null) + { + StrokeColorHex = _sourceConnector.PinTypeColorHex; + } + } +} diff --git a/KitX Dashboard/ViewModels/BlueprintConnectorVM.cs b/KitX Dashboard/ViewModels/BlueprintConnectorVM.cs new file mode 100644 index 00000000..d2ab564a --- /dev/null +++ b/KitX Dashboard/ViewModels/BlueprintConnectorVM.cs @@ -0,0 +1,65 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using KitX.Core.Contract.Workflow; +using NodifyM.Avalonia.ViewModelBase; + +namespace KitX.Dashboard.ViewModels; + +/// +/// Replaces PinViewModel + _pinTypes dictionary. +/// Each connector carries its own metadata as bindable properties. +/// +public partial class BlueprintConnectorVM : ConnectorViewModelBase +{ + /// Pin type for visual rendering and connection validation + [ObservableProperty] + private PinType _pinType = PinType.Any; + + /// Original BlueprintPin.Id for round-trip export + [ObservableProperty] + private string? _originalPinId; + + /// Default value for data pins + [ObservableProperty] + private string? _defaultValue; + + /// Runtime value set during debug execution, shown on hover + [ObservableProperty] + private string? _runtimeValue; + + /// Whether this is an execution flow pin (triangle shape) + public bool IsExecution => PinType == PinType.Execution; + + /// Hex color derived from PinType for binding + public string PinTypeColorHex => GetHexColorForPinType(PinType); + + /// Human-readable pin type name, for tooltips. + public string PinTypeText => PinType.ToString(); + + /// Show default value editor: only for disconnected non-execution data pins + public bool ShowDefaultValue => !IsConnected && !IsExecution; + + /// Maps PinType to hex color for visual rendering + public static string GetHexColorForPinType(PinType pinType) => pinType switch + { + PinType.Execution => "#32CD32", // LimeGreen + PinType.Boolean => "#00FFFF", // Cyan + PinType.Integer => "#FFA500", // Orange + PinType.Double => "#9370DB", // MediumPurple + PinType.String => "#FFFF00", // Yellow + _ => "#FFFFFF" // White (Any) + }; + + partial void OnPinTypeChanged(PinType value) + { + OnPropertyChanged(nameof(IsExecution)); + OnPropertyChanged(nameof(PinTypeColorHex)); + OnPropertyChanged(nameof(ShowDefaultValue)); + } + + protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e) + { + base.OnPropertyChanged(e); + if (e.PropertyName == nameof(IsConnected)) + OnPropertyChanged(nameof(ShowDefaultValue)); + } +} diff --git a/KitX Dashboard/ViewModels/BlueprintEditorViewModel.cs b/KitX Dashboard/ViewModels/BlueprintEditorViewModel.cs new file mode 100644 index 00000000..ad1aab15 --- /dev/null +++ b/KitX Dashboard/ViewModels/BlueprintEditorViewModel.cs @@ -0,0 +1,2253 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Tasks; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Plugin.Events; +using KitX.Core.Tasks; +using KitX.Dashboard.Services; +using KitX.Shared.CSharp.Plugin; +using KitX.Workflow.Abstractions; +using NodifyM.Avalonia.ViewModelBase; +using Serilog; +using BlueprintPinDirection = KitX.Core.Contract.Workflow.PinDirection; + +namespace KitX.Dashboard.ViewModels; + +/// +/// Main ViewModel for the Blueprint Editor, inheriting from NodifyM's editor base. +/// Replaces the old EditorViewModel + DrawingNodeViewModel wrapper approach. +/// +public partial class BlueprintEditorViewModel : NodifyEditorViewModelBase +{ + private readonly IBlueprintService _blueprintService; + private readonly ITasksService _tasksService; + private readonly INodeRegistry _nodeRegistry; + private readonly IBlueprintRenderDataService _renderDataService; + private readonly IFileDialogService _fileDialogService; + private readonly IBlockScriptExecutor _executor; + private CancellationTokenSource? _cancellationTokenSource; + + private IBlueprintDebugController? _debugController; + private Dictionary _statementToNodeId = new(); + private Dictionary _variableNameToConnector = new(); + + private Blueprint? _currentBlueprint; + private string _statusText = ViewModelBase.TranslateTextWithSuffix("WorkflowEditor", "Ready") ?? "Ready"; + private bool _isExecuting; + private string _executionResult = string.Empty; + private bool _isDebugging; + private bool _isPaused; + private double _executionSpeed = 1.0; + + public Blueprint? CurrentBlueprint + { + get => _currentBlueprint; + set => SetProperty(ref _currentBlueprint, value); + } + + public string StatusText + { + get => _statusText; + set => SetProperty(ref _statusText, value); + } + + public bool IsExecuting + { + get => _isExecuting; + set => SetProperty(ref _isExecuting, value); + } + + /// + /// Execution output displayed in the Output panel + /// + public string ExecutionResult + { + get => _executionResult; + set => SetProperty(ref _executionResult, value); + } + + public bool IsDebugging + { + get => _isDebugging; + set => SetProperty(ref _isDebugging, value); + } + + public bool IsPaused + { + get => _isPaused; + set => SetProperty(ref _isPaused, value); + } + + public double ExecutionSpeed + { + get => _executionSpeed; + set + { + if (SetProperty(ref _executionSpeed, value) && _debugController != null) + _debugController.SetSpeed(value >= 1.0 ? KitX.Core.Contract.Workflow.ExecutionSpeed.RealTime : KitX.Core.Contract.Workflow.ExecutionSpeed.Slow); + } + } + + private int _nodeCount; + public int NodeCount + { + get => _nodeCount; + set => SetProperty(ref _nodeCount, value); + } + + private int _connectionCount; + public int ConnectionCount + { + get => _connectionCount; + set => SetProperty(ref _connectionCount, value); + } + + /// + /// Scope blocks in this blueprint — each corresponds to a #Block in BlockScript. + /// Created automatically when adding Branch/Loop nodes. + /// + public ObservableCollection ScopeBlocks { get; } = []; + + /// + /// Maps node BlueprintNodeId → scope ScopeId for tracking scope membership. + /// Nodes not in this map belong to MainBlock. + /// + public Dictionary NodeToScopeMap { get; } = []; + + private IPluginService? _pluginService; + + /// + /// Plugin functions available for dynamic node creation. + /// Populated from connected plugins via IPluginService. + /// + public ObservableCollection PluginFunctions { get; } = []; + + /// + /// Helper functions available for dynamic node creation. + /// Populated by WorkflowEditorViewModel from the BS-mode helper list. + /// + public ObservableCollection HelperFunctions { get; } = []; + + /// Whether any plugin functions are available (controls UI visibility) + public bool HasPluginFunctions => PluginFunctions.Count > 0; + + /// Whether any helper functions are available (controls UI visibility) + public bool HasHelperFunctions => HelperFunctions.Count > 0; + + /// + /// Plugin triggers available for dynamic node creation. + /// Populated from connected plugins' SupportedTriggers. + /// + public ObservableCollection PluginTriggers { get; } = []; + + /// Whether any plugin triggers are available (controls UI visibility) + public bool HasPluginTriggers => PluginTriggers.Count > 0; + + /// + /// Refreshes the PluginFunctions collection from installed plugins. + /// Called on init and when plugin status changes. + /// + private void RefreshPluginFunctions() + { + PluginFunctions.Clear(); + + var installedPlugins = _pluginService?.GetInstalledPlugins(); + if (installedPlugins == null || installedPlugins.Count == 0) + { + Log.Debug("[BlueprintPalette] RefreshPluginFunctions: no installed plugins"); + OnPropertyChanged(nameof(HasPluginFunctions)); + return; + } + + Log.Debug("[BlueprintPalette] RefreshPluginFunctions: {PluginCount} installed plugins", installedPlugins.Count); + + foreach (var plugin in installedPlugins) + { + if (plugin.PluginInfo?.Functions == null) continue; + + Log.Debug("[BlueprintPalette] Installed plugin: {Name}, FunctionsCount={FuncCount}", + plugin.PluginInfo.Name, plugin.PluginInfo.Functions.Count); + + foreach (var func in plugin.PluginInfo.Functions) + { + PluginFunctions.Add(new PluginFunctionPaletteItem + { + PluginName = plugin.PluginInfo.Name, + FunctionName = func.Name, + DisplayName = $"{plugin.PluginInfo.Name}.{func.Name}", + Parameters = func.Parameters ?? [], + ReturnValueType = func.ReturnValueType ?? "void" + }); + } + } + + Log.Debug("[BlueprintPalette] RefreshPluginFunctions: added {Count} plugin functions", PluginFunctions.Count); + OnPropertyChanged(nameof(HasPluginFunctions)); + + // Also refresh trigger list from the same installed plugins + RefreshPluginTriggers(); + } + + /// + /// Refreshes the PluginTriggers collection from installed plugins' SupportedTriggers. + /// Called alongside RefreshPluginFunctions. + /// + private void RefreshPluginTriggers() + { + PluginTriggers.Clear(); + + var installedPlugins = _pluginService?.GetInstalledPlugins(); + if (installedPlugins == null || installedPlugins.Count == 0) + { + OnPropertyChanged(nameof(HasPluginTriggers)); + return; + } + + foreach (var plugin in installedPlugins) + { + if (plugin.PluginInfo?.SupportedTriggers == null) continue; + foreach (var trigger in plugin.PluginInfo.SupportedTriggers) + { + PluginTriggers.Add(new PluginTriggerPaletteItem + { + PluginName = plugin.PluginInfo.Name, + TriggerName = trigger, + DisplayName = $"{plugin.PluginInfo.Name}.{trigger}" + }); + } + } + + Log.Debug("[BlueprintPalette] RefreshPluginTriggers: added {Count} plugin triggers", PluginTriggers.Count); + OnPropertyChanged(nameof(HasPluginTriggers)); + } + + private void OnPluginStatusChanged(object? sender, PluginStatusChangedEventArgs e) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + RefreshPluginFunctions(); + }); + } + + /// + /// Detaches event handlers to prevent memory leaks. + /// Called by WorkflowEditorWindow.OnClosed. + /// + public void Cleanup() + { + if (_pluginService != null) + _pluginService.PluginStatusChanged -= OnPluginStatusChanged; + } + + /// + /// Constructor with DI injection + /// + public BlueprintEditorViewModel( + IBlueprintService blueprintService, + ITasksService tasksService, + INodeRegistry nodeRegistry, + IBlueprintRenderDataService renderDataService, + IFileDialogService fileDialogService, + IBlockScriptExecutor executor) + { + _blueprintService = blueprintService; + _tasksService = tasksService; + _nodeRegistry = nodeRegistry; + _renderDataService = renderDataService; + _fileDialogService = fileDialogService; + _executor = executor; + + // Initialize PendingConnection so drag-to-connect works + PendingConnection = new PendingConnectionViewModelBase(this); + + // Initialize dynamic node palette from plugin service + _pluginService = App.GetService(); + if (_pluginService != null) + _pluginService.PluginStatusChanged += OnPluginStatusChanged; + RefreshPluginFunctions(); + + Log.Information("BlueprintEditorViewModel initialized (NodifyM)"); + } + + // ─── Connection Creation (NodifyM override) ───────────────────────── + + /// + /// Overrides NodifyM's Connect() to validate and create BlueprintConnectionVM + /// with correct StrokeColorHex. Without this override, connections vanish after drag. + /// + public override void Connect(ConnectorViewModelBase source, ConnectorViewModelBase target) + { + if (source is not BlueprintConnectorVM src || target is not BlueprintConnectorVM tgt) + return; + + if (!ValidateConnection(src, tgt)) + return; + + // Check if already connected + var alreadyConnected = Connections.OfType() + .Any(c => c.Source == src && c.Target == tgt || c.Source == tgt && c.Target == src); + if (alreadyConnected) + return; + + // Exec output pins may only have one outgoing connection. + // Auto-disconnect any existing connection from an Exec output before creating the new one. + var execOutput = (src.PinType == PinType.Execution && src.Flow == ConnectorViewModelBase.ConnectorFlow.Output) ? src + : (tgt.PinType == PinType.Execution && tgt.Flow == ConnectorViewModelBase.ConnectorFlow.Output) ? tgt + : null; + if (execOutput != null) + { + var existing = Connections.OfType() + .FirstOrDefault(c => c.Source == execOutput); + if (existing != null) + { + // Update IsConnected on the counterpart connector + var counterpart = existing.Source == execOutput ? existing.Target as BlueprintConnectorVM + : existing.Source as BlueprintConnectorVM; + if (counterpart != null) + counterpart.IsConnected = Connections.OfType() + .Any(c => c != existing && (c.Source == counterpart || c.Target == counterpart)); + Connections.Remove(existing); + Log.Debug("Auto-disconnected existing Exec output connection from {Title}", execOutput.Title); + } + } + + var connection = new BlueprintConnectionVM(this, src, tgt); + Connections.Add(connection); + + src.IsConnected = true; + tgt.IsConnected = true; + + // Variadic pin expansion: when a node declares a variadic input/output group and the + // last pin of that group is connected, auto-append a fresh pin so the user can chain + // more inputs/outputs (e.g. StringConcat inputs, Switch output arms) without manual adding. + TryExpandVariadicPins(src, tgt); + + RefreshCounts(); + Log.Debug("Connection created: {SrcTitle} -> {TgtTitle}", src.Title, tgt.Title); + } + + /// + /// Generic variadic-pin expansion. For each connected connector that belongs to a node + /// declaring a variadic group on its side (input/target or output/source), if the connector + /// is the last pin of that group, append one fresh pin of the group's type. + /// Replaces the former StringConcat-name-matched TryExpandStringConcatPins. + /// + private void TryExpandVariadicPins(BlueprintConnectorVM src, BlueprintConnectorVM tgt) + { + // Output side grows when an output connector is the drag source; input side grows + // when an input connector is the drop target. + TryExpandVariadicSide(src, isOutput: true); + TryExpandVariadicSide(tgt, isOutput: false); + } + + private void TryExpandVariadicSide(BlueprintConnectorVM connector, bool isOutput) + { + var node = FindParentNode(connector); + if (node == null) return; + + var spec = GetVariadicSpec(node, isOutput); + if (spec == null) return; + + // Collect the node's pins that belong to this variadic group (matching PinType). + var pins = (isOutput ? node.Output : node.Input).OfType() + .Where(c => c.PinType == spec.PinType) + .ToList(); + if (pins.Count == 0) return; + // Only expand when the LAST pin of the group is the one just connected. + if (connector != pins[^1]) return; + + // Derive the new pin's index from how many pins of this group the descriptor declares + // statically (base) vs how many exist now. Next appended = StartIndex + (now - base). + var baseCount = GetBaseGroupPinCount(node, spec, isOutput); + var nextIndex = spec.StartIndex + (pins.Count - baseCount); + var name = string.IsNullOrEmpty(spec.BasePinName) + ? nextIndex.ToString() + : $"{spec.BasePinName}{nextIndex}"; + + (isOutput ? node.Output : node.Input).Add(new BlueprintConnectorVM + { + Title = name, + Flow = isOutput ? ConnectorViewModelBase.ConnectorFlow.Output : ConnectorViewModelBase.ConnectorFlow.Input, + PinType = spec.PinType, + OriginalPinId = Guid.NewGuid().ToString() + }); + } + + /// + /// Looks up the variadic-growth spec declared on a node's descriptor. For builtin-function + /// nodes the descriptor is rebuilt from the registry by function name (cheap; cached in the + /// registry). Returns null for non-variadic nodes. + /// + private VariadicPinSpec? GetVariadicSpec(BlueprintNodeVM node, bool isOutput) + { + var descriptor = GetBuiltinDescriptor(node); + return isOutput ? descriptor?.OutputVariadic : descriptor?.InputVariadic; + } + + /// + /// Number of pins of the variadic group's type that the descriptor statically declares + /// (before any editor-driven expansion). Used to keep appended pin numbering sequential. + /// + private int GetBaseGroupPinCount(BlueprintNodeVM node, VariadicPinSpec spec, bool isOutput) + { + var descriptor = GetBuiltinDescriptor(node); + if (descriptor == null) return 0; + var basePins = isOutput ? descriptor.OutputPins : descriptor.InputPins; + return basePins.Count(p => p.Type == spec.PinType); + } + + private NodeDescriptor? GetBuiltinDescriptor(BlueprintNodeVM node) + { + if (string.IsNullOrEmpty(node.BuiltinFunctionName)) return null; + // Rebuild the descriptor for this builtin function (registry-driven, cheap). + var tmp = _nodeRegistry.CreateBuiltinFunctionNode(node.BuiltinFunctionName); + return tmp.GetDescriptor(); + } + + // ─── Connection Disconnection (NodifyM override) ──────────────────── + + /// + /// Overrides NodifyM's DisconnectConnector to properly update IsConnected + /// and refresh counts. Triggered by Alt+click on a connector. + /// + public override void DisconnectConnector(ConnectorViewModelBase connector) + { + if (connector is not BlueprintConnectorVM bpConn) return; + + var attached = Connections.OfType() + .Where(c => c.Source == bpConn || c.Target == bpConn) + .ToList(); + + foreach (var conn in attached) + { + // Update IsConnected on counterpart connectors + if (conn.Source is BlueprintConnectorVM src) + src.IsConnected = Connections.OfType() + .Any(c => c != conn && (c.Source == src || c.Target == src)); + if (conn.Target is BlueprintConnectorVM tgt) + tgt.IsConnected = Connections.OfType() + .Any(c => c != conn && (c.Source == tgt || c.Target == tgt)); + + Connections.Remove(conn); + } + + RefreshCounts(); + Log.Debug("Disconnected all connections from connector: {Title}", bpConn.Title); + } + + // ─── Node Deletion ────────────────────────────────────────────────── + + [RelayCommand] + private void DeleteSelectedNodes() + { + var toRemove = SelectedNodes.ToList(); + if (toRemove.Count == 0) return; + + // Remove connections attached to deleted nodes + foreach (var node in toRemove.OfType()) + { + var connectors = node.Input.OfType() + .Concat(node.Output.OfType()) + .ToHashSet(); + + var attachedConnections = Connections.OfType() + .Where(c => connectors.Contains(c.Source) || connectors.Contains(c.Target)) + .ToList(); + + foreach (var conn in attachedConnections) + { + // Update IsConnected on the other end + if (conn.Source is BlueprintConnectorVM src) + src.IsConnected = Connections.OfType() + .Any(c => c != conn && (c.Source == src || c.Target == src)); + if (conn.Target is BlueprintConnectorVM tgt) + tgt.IsConnected = Connections.OfType() + .Any(c => c != conn && (c.Source == tgt || c.Target == tgt)); + + Connections.Remove(conn); + } + + // Remove node from any scope block's ContainedNodeIds + foreach (var scope in ScopeBlocks) + { + scope.ContainedNodeIds.Remove(node.BlueprintNodeId); + } + + Nodes.Remove(node); + } + + // Also remove any selected scope blocks (e.g., if user selects and deletes them) + foreach (var scope in toRemove.OfType()) + { + scope.UnsubscribeFromChildNodes(); + ScopeBlocks.Remove(scope); + Nodes.Remove(scope); + } + + SelectedNodes.Clear(); + RefreshCounts(); + Log.Information("Deleted {Count} nodes", toRemove.Count); + } + + // ─── Scope Block Membership ───────────────────────────────────────── + + /// + /// Moves all selected BlueprintNodeVMs into the specified scope block. + /// + [RelayCommand] + private void MoveSelectedNodesToScope(string scopeId) + { + var scope = ScopeBlocks.FirstOrDefault(s => s.ScopeId == scopeId); + if (scope == null) return; + + foreach (var node in SelectedNodes.OfType().ToList()) + { + // Remove from any existing scope first + RemoveNodeFromAnyScope(node.BlueprintNodeId); + + // Add to target scope + if (!scope.ContainedNodeIds.Contains(node.BlueprintNodeId)) + { + scope.ContainedNodeIds.Add(node.BlueprintNodeId); + NodeToScopeMap[node.BlueprintNodeId] = scopeId; + } + } + + scope.RecalculateBounds(); + Log.Information("Moved {Count} nodes to scope '{ScopeId}'", SelectedNodes.Count, scopeId); + } + + /// + /// Removes all selected BlueprintNodeVMs from their current scope block. + /// + [RelayCommand] + private void RemoveSelectedNodesFromScope() + { + foreach (var node in SelectedNodes.OfType().ToList()) + { + RemoveNodeFromAnyScope(node.BlueprintNodeId); + } + + // Recalculate bounds for all affected scopes + foreach (var scope in ScopeBlocks) + scope.RecalculateBounds(); + + Log.Information("Removed {Count} nodes from their scope blocks", SelectedNodes.Count); + } + + // ─── Rename Commands ──────────────────────────────────────────────── + + /// + /// Renames a Const, Variable, Get, or Set node via a text input dialog. + /// For Variable renames, propagates the new name to all referencing Get/Set nodes. + /// + [RelayCommand] + private async Task RenameSelectedNodeAsync(BlueprintNodeVM nodeVm) + { + string dialogTitle; + string dialogPrompt; + string currentName; + + switch (nodeVm.NodeType) + { + case BlueprintNodeType.Const: + dialogTitle = ViewModelBase.TranslateTextWithSuffix("Blueprint", "RenameConst") ?? "Rename Const"; + dialogPrompt = ViewModelBase.TranslateTextWithSuffix("Blueprint", "RenameConstPrompt") ?? "Enter new constant name:"; + currentName = nodeVm.Metadata.TryGetValue("ConstName", out var cn) + ? cn + : nodeVm.DisplayTitle.StartsWith("Const:") + ? nodeVm.DisplayTitle["Const:".Length..].Trim() + : nodeVm.DisplayTitle; + break; + + case BlueprintNodeType.Variable: + dialogTitle = ViewModelBase.TranslateTextWithSuffix("Blueprint", "RenameVariable") ?? "Rename Variable"; + dialogPrompt = ViewModelBase.TranslateTextWithSuffix("Blueprint", "RenameVariablePrompt") ?? "Enter new variable name:"; + currentName = nodeVm.Metadata.TryGetValue("VarName", out var vn) + ? vn + : nodeVm.VarName; + break; + + case BlueprintNodeType.BuiltinFunction when nodeVm.BuiltinFunctionName == "Get": + dialogTitle = ViewModelBase.TranslateTextWithSuffix("Blueprint", "RenameGetNode") ?? "Rename Get Node"; + dialogPrompt = ViewModelBase.TranslateTextWithSuffix("Blueprint", "RenameVariablePrompt") ?? "Enter new variable name:"; + currentName = nodeVm.DisplayTitle.StartsWith("Get:") + ? nodeVm.DisplayTitle["Get:".Length..].Trim() + : nodeVm.DisplayTitle; + break; + + case BlueprintNodeType.BuiltinFunction when nodeVm.BuiltinFunctionName == "Set": + dialogTitle = ViewModelBase.TranslateTextWithSuffix("Blueprint", "RenameSetNode") ?? "Rename Set Node"; + dialogPrompt = ViewModelBase.TranslateTextWithSuffix("Blueprint", "RenameVariablePrompt") ?? "Enter new variable name:"; + currentName = nodeVm.DisplayTitle.StartsWith("Set:") + ? nodeVm.DisplayTitle["Set:".Length..].Trim() + : nodeVm.DisplayTitle; + break; + + default: + return; + } + + var newName = await _fileDialogService.ShowTextInputDialogAsync(dialogTitle, dialogPrompt, currentName); + if (string.IsNullOrWhiteSpace(newName) || newName == currentName) + return; + + switch (nodeVm.NodeType) + { + case BlueprintNodeType.Const: + nodeVm.DisplayTitle = $"Const: {newName}"; + nodeVm.Metadata["ConstName"] = newName; + break; + + case BlueprintNodeType.Variable: + var oldVarName = currentName; + nodeVm.VarName = newName; + nodeVm.DisplayTitle = $"Var: {newName}"; + nodeVm.Metadata["VarName"] = newName; + // Propagate rename to all Get/Set nodes referencing this variable + PropagateVariableRename(oldVarName, newName); + break; + + case BlueprintNodeType.BuiltinFunction when nodeVm.BuiltinFunctionName == "Get": + nodeVm.DisplayTitle = $"Get: {newName}"; + nodeVm.Metadata["VarName"] = newName; + break; + + case BlueprintNodeType.BuiltinFunction when nodeVm.BuiltinFunctionName == "Set": + nodeVm.DisplayTitle = $"Set: {newName}"; + nodeVm.Metadata["VarName"] = newName; + break; + } + + Log.Information("Renamed {NodeType} node '{OldName}' to '{NewName}'", + nodeVm.NodeType, currentName, newName); + } + + /// + /// Propagates a variable rename to all Get/Set nodes that reference the old name. + /// Updates display titles and re-resolves pin types. + /// + private void PropagateVariableRename(string oldName, string newName) + { + foreach (var n in Nodes.OfType()) + { + if (n.NodeType != BlueprintNodeType.BuiltinFunction + || (n.BuiltinFunctionName != "Get" && n.BuiltinFunctionName != "Set")) + continue; + + var prefix = n.BuiltinFunctionName == "Get" ? "Get: " : "Set: "; + var currentRef = n.DisplayTitle.StartsWith(prefix) + ? n.DisplayTitle[prefix.Length..].Trim() + : ""; + + if (currentRef != oldName) + continue; + + n.DisplayTitle = $"{prefix}{newName}"; + n.Metadata["VarName"] = newName; + + // Re-resolve pin type for the new variable name + var pinType = ResolveVariablePinType(newName); + UpdateNodeValuePinType(n, pinType); + } + } + + /// + /// Renames a scope block via a text input dialog. + /// + [RelayCommand] + private async Task RenameScopeBlockAsync(BlueprintScopeBlockVM scopeVm) + { + var newName = await _fileDialogService.ShowTextInputDialogAsync( + ViewModelBase.TranslateTextWithSuffix("Blueprint", "RenameScopeBlock") ?? "Rename Scope Block", + ViewModelBase.TranslateTextWithSuffix("Blueprint", "RenameScopePrompt") ?? "Enter new scope name:", + scopeVm.DisplayName); + + if (string.IsNullOrWhiteSpace(newName) || newName == scopeVm.DisplayName) + return; + + scopeVm.DisplayName = newName; + Log.Information("Renamed scope block to '{NewName}'", newName); + } + + // ─── Scope Block Membership ───────────────────────────────────────── + + /// + /// Gets the scope ID that a node currently belongs to, or null if none. + /// + public string? GetNodeScopeId(string nodeId) + { + return NodeToScopeMap.TryGetValue(nodeId, out var scopeId) ? scopeId : null; + } + + private void RemoveNodeFromAnyScope(string nodeId) + { + if (!NodeToScopeMap.TryGetValue(nodeId, out var currentScopeId)) + return; + + var currentScope = ScopeBlocks.FirstOrDefault(s => s.ScopeId == currentScopeId); + if (currentScope != null) + { + currentScope.ContainedNodeIds.Remove(nodeId); + currentScope.RecalculateBounds(); + } + + NodeToScopeMap.Remove(nodeId); + } + + // ─── Connection Validation ─────────────────────────────────────────── + + /// + /// Validates whether two connectors can be connected. + /// Rules: opposite flows, execution↔execution only, data type compatibility. + /// + private bool ValidateConnection(BlueprintConnectorVM source, BlueprintConnectorVM target) + { + // Rule 1: Must be opposite flows (Output → Input) + if (source.Flow == target.Flow) return false; + + // Normalize: ensure source is Output, target is Input + var outputPin = source.Flow == ConnectorViewModelBase.ConnectorFlow.Output ? source : target; + var inputPin = source.Flow == ConnectorViewModelBase.ConnectorFlow.Input ? source : target; + + var startIsExec = outputPin.PinType == PinType.Execution; + var endIsExec = inputPin.PinType == PinType.Execution; + + // Rule 2: Execution pins only connect to execution pins + if (startIsExec != endIsExec) return false; + + // Rule 3: For data pins, check type compatibility + if (!startIsExec) + { + if (outputPin.PinType == PinType.Any || inputPin.PinType == PinType.Any) + return true; + if (outputPin.PinType != inputPin.PinType) return false; + } + + return true; + } + + // ─── Blueprint Loading ─────────────────────────────────────────────── + + /// + /// Loads a Blueprint into the editor using three-phase rendering. + /// Phase 1: Create all nodes with connectors + /// Phase 2: Create execution flow connections + /// Phase 3: Create data flow connections + /// + public void LoadBlueprintIntoDrawing(Blueprint blueprint) + { + if (blueprint == null) return; + + // Clear existing + Nodes.Clear(); + Connections.Clear(); + ScopeBlocks.Clear(); + NodeToScopeMap.Clear(); + + var connectorMap = new Dictionary(); + var pinTypeMap = new Dictionary(); + + var renderData = _renderDataService.GetRenderData(blueprint); + + Log.Information("Loading blueprint: {NodeCount} nodes, {ExecCount} exec, {DataCount} data connections", + renderData.AllNodes.Count, renderData.ExecConnections.Count, renderData.DataConnections.Count); + + // === Phase 1: Create all nodes === + foreach (var blueprintNode in renderData.AllNodes) + { + var nodeVm = ConvertBlueprintNodeToViewModel(blueprintNode); + Nodes.Add(nodeVm); + + // Map each connector by original pin ID + foreach (var connector in nodeVm.Input.OfType()) + { + if (connector.OriginalPinId != null) + connectorMap[connector.OriginalPinId] = connector; + } + foreach (var connector in nodeVm.Output.OfType()) + { + if (connector.OriginalPinId != null) + connectorMap[connector.OriginalPinId] = connector; + } + + Log.Debug(" Added node: Name={Name}, Id={Id}, Type={Type}", + blueprintNode.Name, blueprintNode.Id, blueprintNode.NodeType); + } + + // === Phase 2: Create execution flow connections === + foreach (var connection in renderData.ExecConnections) + { + CreateConnectionFromBlueprintConnection(connection, connectorMap, blueprint); + } + + // === Phase 3: Create data flow connections === + foreach (var connection in renderData.DataConnections) + { + CreateConnectionFromBlueprintConnection(connection, connectorMap, blueprint); + } + + // Update IsConnected on all connectors + UpdateAllConnectorStates(); + + // === Phase 4: Rebuild ScopeBlocks from BlockScopes === + RebuildScopeBlocksFromBlockScopes(blueprint); + + // === Phase 5: Resolve dynamic pin types for Get/Set nodes === + ResolveAllGetSetPinTypes(); + + RefreshCounts(); + Log.Information("Loaded blueprint: {NodeCount} nodes, {ConnCount} connections, {ScopeCount} scope blocks", + Nodes.Count, Connections.Count, ScopeBlocks.Count); + } + + /// + /// Rebuilds ScopeBlocks from the loaded blueprint's BlockScopes. + /// Only processes non-MainBlock scopes that have an OwnerNodeId. + /// + private void RebuildScopeBlocksFromBlockScopes(Blueprint blueprint) + { + if (blueprint.BlockScopes == null || blueprint.BlockScopes.Count == 0) + { + Log.Debug("No BlockScopes to rebuild"); + return; + } + + // Calculate bounding boxes for each scope to position the NodeGroups + var nodePositions = new Dictionary(); + foreach (var nodeVm in Nodes.OfType()) + nodePositions[nodeVm.BlueprintNodeId] = nodeVm; + + foreach (var scope in blueprint.BlockScopes) + { + // Skip MainBlock — it has no visual container + if (scope.IsMainBlock) continue; + if (string.IsNullOrEmpty(scope.OwnerNodeId)) continue; + + var scopeId = $"{scope.OwnerArmName}_{scope.OwnerNodeId}"; + var displayName = scope.Name; + var armName = scope.OwnerArmName ?? string.Empty; + var headerColor = BlueprintScopeBlockVM.GetHeaderColor(armName); + + // Calculate bounding box from contained nodes + var bounds = CalculateScopeBounds(scope.NodeIds, nodePositions); + + var scopeBlock = new BlueprintScopeBlockVM + { + ScopeId = scopeId, + DisplayName = displayName, + ArmName = armName, + OwnerNodeId = scope.OwnerNodeId, + Location = bounds.Location, + GroupSize = bounds.Size, + HeaderColor = headerColor, + }; + + foreach (var nodeId in scope.NodeIds) + { + scopeBlock.ContainedNodeIds.Add(nodeId); + NodeToScopeMap[nodeId] = scopeId; + } + + ScopeBlocks.Add(scopeBlock); + // Also add to Nodes so NodifyEditor renders the NodeGroup + Nodes.Add(scopeBlock); + + // Set Editor reference for drag propagation and auto-sizing + scopeBlock.Editor = this; + scopeBlock.SubscribeToChildNodes(); + + Log.Debug("Rebuilt scope block '{Name}' with {Count} nodes, owner={OwnerId}", + displayName, scope.NodeIds.Count, scope.OwnerNodeId); + } + + Log.Information("Rebuilt {Count} scope blocks from BlockScopes", ScopeBlocks.Count); + } + + /// + /// Calculates the bounding rectangle for a set of nodes, + /// with padding to create the scope block visual container. + /// + private static (Avalonia.Point Location, Avalonia.Size Size) CalculateScopeBounds( + List nodeIds, + Dictionary nodePositions) + { + if (nodeIds.Count == 0) + return (new Avalonia.Point(300, 200), new Avalonia.Size(400, 250)); + + double minX = double.MaxValue, minY = double.MaxValue; + double maxX = double.MinValue, maxY = double.MinValue; + + foreach (var nodeId in nodeIds) + { + if (nodePositions.TryGetValue(nodeId, out var node)) + { + minX = Math.Min(minX, node.Location.X); + minY = Math.Min(minY, node.Location.Y); + maxX = Math.Max(maxX, node.Location.X + 200); // approximate node width + maxY = Math.Max(maxY, node.Location.Y + 100); // approximate node height + } + } + + if (minX == double.MaxValue) + return (new Avalonia.Point(300, 200), new Avalonia.Size(400, 250)); + + const double padding = 30; + return ( + new Avalonia.Point(minX - padding, minY - padding - 30), // extra top for header + new Avalonia.Size(maxX - minX + padding * 2, maxY - minY + padding * 2 + 30) + ); + } + + /// + /// Creates a BlueprintNodeVM from a domain BlueprintNode + /// + private BlueprintNodeVM ConvertBlueprintNodeToViewModel(BlueprintNode blueprintNode) + { + var descriptor = blueprintNode.GetDescriptor(); + var displayTitle = blueprintNode.GetDisplayTitle(); + var (primaryColor, lightColor) = BlueprintNodeVM.GetCategoryColors(blueprintNode.NodeType); + + var nodeVm = new BlueprintNodeVM + { + Location = new Avalonia.Point(blueprintNode.X, blueprintNode.Y), + BlueprintNodeId = blueprintNode.Id, + NodeType = blueprintNode.NodeType, + Name = blueprintNode.Name, + DisplayTitle = displayTitle, + CategoryColor = primaryColor, + CategoryColorLight = lightColor, + Title = displayTitle, + Input = new ObservableCollection(), + Output = new ObservableCollection() + }; + + // Build RelativeY lookup from descriptor for correct pin ordering + var inputRelativeY = descriptor.InputPins + .ToDictionary(p => p.Name, p => p.RelativeY); + var outputRelativeY = descriptor.OutputPins + .ToDictionary(p => p.Name, p => p.RelativeY); + + // Add input connectors (execution pins first, then data pins, ordered by RelativeY) + foreach (var pin in blueprintNode.InputPins + .OrderByDescending(p => p.Type == PinType.Execution) + .ThenBy(p => inputRelativeY.GetValueOrDefault(p.Name, 0))) + { + var connector = new BlueprintConnectorVM + { + Title = pin.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Input, + PinType = pin.Type, + OriginalPinId = pin.Id, + DefaultValue = pin.DefaultValue + }; + nodeVm.Input.Add(connector); + } + + // Add output connectors (ordered by RelativeY to match descriptor layout) + foreach (var pin in blueprintNode.OutputPins + .OrderByDescending(p => p.Type == PinType.Execution) + .ThenBy(p => outputRelativeY.GetValueOrDefault(p.Name, 0))) + { + var connector = new BlueprintConnectorVM + { + Title = pin.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Output, + PinType = pin.Type, + OriginalPinId = pin.Id, + DefaultValue = pin.DefaultValue + }; + nodeVm.Output.Add(connector); + } + + // Special handling: ConstNode → set ConstType + ConstValue on the VM + if (blueprintNode is ConstNode constNode) + { + // Initialize ConstType from domain model (triggers OnConstTypeChanged → Metadata + PinType) + if (!string.IsNullOrEmpty(constNode.ConstType)) + nodeVm.ConstType = constNode.ConstType; + // Initialize ConstValue (triggers OnConstValueChanged → output connector DefaultValue) + if (constNode.ConstValue != null) + nodeVm.ConstValue = constNode.ConstValue; + // Preserve ConstName in Metadata + if (!string.IsNullOrEmpty(constNode.ConstName)) + nodeVm.Metadata["ConstName"] = constNode.ConstName; + // Wire type propagation callback + nodeVm.ConstTypeChangedCallback = OnNodeTypeChanged; + // Explicitly update output connector PinType + foreach (var conn in nodeVm.Output.OfType()) + { + if (conn.Title == "Value") + { + conn.PinType = BlueprintNodeVM.ConstTypeToPinType(nodeVm.ConstType); + break; + } + } + // Register for debug hover: map const name to output connector + var constName = !string.IsNullOrEmpty(constNode.ConstName) ? constNode.ConstName : nodeVm.DisplayTitle; + foreach (var conn in nodeVm.Output.OfType()) + _variableNameToConnector[constName] = conn; + } + + // Special handling: VariableNode → set VarType + VarName on the VM + if (blueprintNode is VariableNode varNode) + { + if (!string.IsNullOrEmpty(varNode.VarType)) + nodeVm.VarType = varNode.VarType; + if (!string.IsNullOrEmpty(varNode.VarName)) + nodeVm.VarName = varNode.VarName; + nodeVm.Metadata["VarName"] = varNode.VarName; + // Wire type propagation callback + nodeVm.VarTypeChangedCallback = OnNodeTypeChanged; + // Register for debug hover: map variable name to output connector + foreach (var conn in nodeVm.Output.OfType()) + _variableNameToConnector[varNode.VarName] = conn; + } + + // Preserve CallNode metadata for round-trip + if (blueprintNode is CallNode callNode) + { + if (!string.IsNullOrEmpty(callNode.PluginName)) + nodeVm.Metadata["PluginName"] = callNode.PluginName; + if (!string.IsNullOrEmpty(callNode.FunctionName)) + nodeVm.Metadata["FunctionName"] = callNode.FunctionName; + } + + // Preserve CallHelperNode metadata for round-trip + infer return type + if (blueprintNode is CallHelperNode helperNode) + { + if (!string.IsNullOrEmpty(helperNode.HelperFunctionName)) + { + nodeVm.Metadata["HelperFunctionName"] = helperNode.HelperFunctionName; + + // Infer return type for the Return output connector + var helper = _currentBlueprint?.HelperFunctions + .FirstOrDefault(h => h.Name == helperNode.HelperFunctionName); + if (helper != null && !string.IsNullOrEmpty(helper.ReturnType)) + { + var returnPinType = TypeStringToPinType(helper.ReturnType); + foreach (var conn in nodeVm.Output.OfType()) + { + if (conn.Title == "Return") + { + conn.PinType = returnPinType; + break; + } + } + } + } + } + + // Preserve BuiltinFunctionNode metadata for round-trip + if (blueprintNode is BuiltinFunctionNode bfNode && !string.IsNullOrEmpty(bfNode.FunctionName)) + { + nodeVm.BuiltinFunctionName = bfNode.FunctionName; + nodeVm.Metadata["BuiltinFunctionName"] = bfNode.FunctionName; + + // For Get/Set, build display title from VarName pin's default value + if (bfNode.FunctionName is "Get" or "Set") + { + var varPin = bfNode.InputPins.FirstOrDefault(p => p.Name == "VarName"); + var varName = varPin?.DefaultValue; + if (!string.IsNullOrEmpty(varName)) + { + nodeVm.DisplayTitle = $"{bfNode.FunctionName}: {varName}"; + nodeVm.Title = nodeVm.DisplayTitle; + // Use function-specific colors + var (pc, lc) = BlueprintNodeVM.GetBuiltinFunctionColors(bfNode.FunctionName); + nodeVm.CategoryColor = pc; + nodeVm.CategoryColorLight = lc; + } + } + } + + // Preserve PluginTriggerNode metadata for round-trip + if (blueprintNode is PluginTriggerNode ptNode) + { + if (!string.IsNullOrEmpty(ptNode.PluginName)) + nodeVm.Metadata["PluginName"] = ptNode.PluginName; + if (!string.IsNullOrEmpty(ptNode.TriggerName)) + nodeVm.Metadata["TriggerName"] = ptNode.TriggerName; + } + + return nodeVm; + } + + /// + /// Creates a connection from a BlueprintConnection using the connector map + /// + private void CreateConnectionFromBlueprintConnection( + BlueprintConnection connection, + Dictionary connectorMap, + Blueprint blueprint) + { + var sourceFound = connectorMap.TryGetValue(connection.SourcePinId, out var sourceConnector); + var targetFound = connectorMap.TryGetValue(connection.TargetPinId, out var targetConnector); + + if (sourceFound && targetFound && sourceConnector != null && targetConnector != null) + { + var connectionVm = new BlueprintConnectionVM(this, sourceConnector, targetConnector); + Connections.Add(connectionVm); + + // Register PubVar source connector for debug hover + if (!string.IsNullOrEmpty(connection.PubVarName)) + _variableNameToConnector[connection.PubVarName] = sourceConnector; + + Log.Debug(" Connection: {Source} -> {Target}", connection.SourcePinId, connection.TargetPinId); + } + else + { + Log.Warning("Could not find connectors for connection: Source={Src} (found={SrcFound}), Target={Tgt} (found={TgtFound})", + connection.SourcePinId, sourceFound, connection.TargetPinId, targetFound); + } + } + + /// + /// Updates IsConnected state on all connectors based on current connections + /// + private void UpdateAllConnectorStates() + { + var allConnectors = Nodes.OfType() + .SelectMany(n => n.Input.OfType() + .Concat(n.Output.OfType())); + + foreach (var connector in allConnectors) + { + connector.IsConnected = Connections.OfType() + .Any(c => c.Source == connector || c.Target == connector); + } + } + + // ─── Blueprint Export ──────────────────────────────────────────────── + + /// + /// Exports the current editor state to a Blueprint domain model + /// + public Blueprint ExportDrawingToBlueprint() + { + var blueprint = _blueprintService.CreateBlueprint(); + blueprint.Name = CurrentBlueprint?.Name ?? "Untitled"; + + // Preserve HelperFunctions from the original blueprint (imported from BlockScript) + if (CurrentBlueprint?.HelperFunctions != null && CurrentBlueprint.HelperFunctions.Count > 0) + { + blueprint.HelperFunctions = new List(CurrentBlueprint.HelperFunctions); + } + + // Rebuild ConstValues from current ConstNode and VariableNode VMs + foreach (var node in Nodes.OfType()) + { + if (node.NodeType == BlueprintNodeType.Const) + { + var constName = node.Metadata.TryGetValue("ConstName", out var cn) ? cn + : node.DisplayTitle.StartsWith("Const:") ? node.DisplayTitle["Const:".Length..].Trim() : node.DisplayTitle; + blueprint.ConstValues.Add(new VariableConstant + { + Name = constName, + DefaultValue = !string.IsNullOrEmpty(node.ConstValue) ? node.ConstValue : null, + Type = node.ConstType ?? "string" + }); + } + else if (node.NodeType == BlueprintNodeType.Variable) + { + var varName = node.Metadata.TryGetValue("VarName", out var vn) ? vn + : node.DisplayTitle.StartsWith("Var:") ? node.DisplayTitle["Var:".Length..].Trim() : node.DisplayTitle; + blueprint.ConstValues.Add(new VariableConstant + { + Name = varName, + DefaultValue = null, + Type = node.VarType ?? "int" + }); + } + } + + // Convert nodes + foreach (var node in Nodes) + { + if (node is BlueprintNodeVM nodeVm) + { + var blueprintNode = ConvertViewModelToBlueprintNode(nodeVm); + blueprint.AddNode(blueprintNode); + } + } + + // Convert connections + foreach (var connection in Connections) + { + if (connection is BlueprintConnectionVM connVm && + connVm.Source is BlueprintConnectorVM sourceConn && + connVm.Target is BlueprintConnectorVM targetConn) + { + // Find parent nodes + var sourceParent = FindParentNode(sourceConn); + var targetParent = FindParentNode(targetConn); + + if (sourceParent != null && targetParent != null) + { + var bpConnection = new BlueprintConnection + { + SourceNodeId = sourceParent.BlueprintNodeId, + SourcePinId = sourceConn.OriginalPinId ?? sourceConn.Title, + TargetNodeId = targetParent.BlueprintNodeId, + TargetPinId = targetConn.OriginalPinId ?? targetConn.Title + }; + blueprint.AddConnection(bpConnection); + } + } + } + + Log.Information("Exported drawing: {NodeCount} nodes, {ConnectionCount} connections", + blueprint.Nodes.Count, blueprint.Connections.Count); + + // Build BlockScopes from ScopeBlocks + BuildBlockScopesFromScopeBlocks(blueprint); + + return blueprint; + } + + /// + /// Builds BlockScopes from the current ScopeBlocks state. + /// MainBlock contains all nodes not assigned to any scope block. + /// Named blocks contain nodes from their respective ScopeBlocks. + /// + private void BuildBlockScopesFromScopeBlocks(Blueprint blueprint) + { + var assignedNodeIds = new HashSet(); + + // Build named scope blocks + foreach (var scope in ScopeBlocks) + { + var blockScope = new BlueprintBlockScope + { + Name = scope.DisplayName, + NodeIds = scope.ContainedNodeIds.ToList(), + OwnerNodeId = scope.OwnerNodeId, + OwnerArmName = scope.ArmName, + IsMainBlock = false + }; + blueprint.BlockScopes.Add(blockScope); + + foreach (var nodeId in scope.ContainedNodeIds) + assignedNodeIds.Add(nodeId); + } + + // Build MainBlock scope from unassigned nodes + var mainBlockNodeIds = new List(); + foreach (var node in Nodes.OfType()) + { + if (!assignedNodeIds.Contains(node.BlueprintNodeId)) + mainBlockNodeIds.Add(node.BlueprintNodeId); + } + + if (mainBlockNodeIds.Count > 0 || blueprint.Nodes.Count > 0) + { + var mainScope = new BlueprintBlockScope + { + Name = "MainBlock", + NodeIds = mainBlockNodeIds, + IsMainBlock = true + }; + // Insert at beginning so MainBlock is first + blueprint.BlockScopes.Insert(0, mainScope); + } + + Log.Information("Built {ScopeCount} block scopes from ScopeBlocks ({MainNodes} main, {Assigned} assigned)", + blueprint.BlockScopes.Count, mainBlockNodeIds.Count, assignedNodeIds.Count); + } + + /// + /// Converts a BlueprintNodeVM back to a domain BlueprintNode + /// + private BlueprintNode ConvertViewModelToBlueprintNode(BlueprintNodeVM nodeVm) + { + // Use CreateBuiltinFunctionNode for builtin function nodes to get proper pins + BlueprintNode blueprintNode; + if (nodeVm.NodeType == BlueprintNodeType.BuiltinFunction + && nodeVm.Metadata.TryGetValue("BuiltinFunctionName", out var funcName) + && !string.IsNullOrEmpty(funcName) + && funcName is "Get" or "Set" or "Print" or "Pause" or "Branch" or "Loop" or "Break" or "ToLoopCond" or "StringConcat") + { + blueprintNode = _nodeRegistry.CreateBuiltinFunctionNode(funcName); + } + else + { + blueprintNode = _nodeRegistry.Create(nodeVm.NodeType); + } + + // Preserve original node ID so connections can reference it + blueprintNode.Id = nodeVm.BlueprintNodeId; + + blueprintNode.Name = !string.IsNullOrEmpty(nodeVm.Name) + ? nodeVm.Name + : nodeVm.DisplayTitle; + blueprintNode.X = nodeVm.Location.X; + blueprintNode.Y = nodeVm.Location.Y; + + ApplyDisplayTitleToNode(blueprintNode, nodeVm.DisplayTitle, nodeVm); + + // Special handling: VariableNode → preserve VarType from VM + if (blueprintNode is VariableNode vNode && !string.IsNullOrEmpty(nodeVm.VarType)) + { + vNode.VarType = nodeVm.VarType; + } + + // Special handling: PluginTriggerNode → restore PluginName/TriggerName from Metadata + if (blueprintNode is PluginTriggerNode ptNode) + { + if (nodeVm.Metadata.TryGetValue("PluginName", out var pluginName)) + ptNode.PluginName = pluginName ?? string.Empty; + if (nodeVm.Metadata.TryGetValue("TriggerName", out var triggerName)) + ptNode.TriggerName = triggerName ?? string.Empty; + } + + // Clear auto-generated pins from constructor's InitializePinsFromDescriptor() + // to prevent duplicates — we add pins from UI connectors instead. + blueprintNode.InputPins.Clear(); + blueprintNode.OutputPins.Clear(); + + // Convert input connectors + foreach (var input in nodeVm.Input.OfType()) + { + var pin = new BlueprintPin + { + Name = input.Title ?? string.Empty, + Type = input.PinType, + Direction = BlueprintPinDirection.Input, + DefaultValue = input.DefaultValue + }; + if (input.OriginalPinId != null) + pin.Id = input.OriginalPinId; + blueprintNode.InputPins.Add(pin); + } + + // Convert output connectors + foreach (var output in nodeVm.Output.OfType()) + { + var pin = new BlueprintPin + { + Name = output.Title ?? string.Empty, + Type = output.PinType, + Direction = BlueprintPinDirection.Output, + DefaultValue = output.DefaultValue + }; + if (output.OriginalPinId != null) + pin.Id = output.OriginalPinId; + blueprintNode.OutputPins.Add(pin); + } + + return blueprintNode; + } + + /// + /// Finds the BlueprintNodeVM that contains a given connector + /// + private BlueprintNodeVM? FindParentNode(BlueprintConnectorVM connector) + { + return Nodes.OfType() + .FirstOrDefault(n => n.Input.Contains(connector) || n.Output.Contains(connector)); + } + + /// + /// Finds a BlueprintNodeVM by its BlueprintNodeId. + /// Used by scope blocks to look up contained child nodes. + /// + public BlueprintNodeVM? FindNodeById(string nodeId) + => Nodes.OfType().FirstOrDefault(n => n.BlueprintNodeId == nodeId); + + private void ApplyDisplayTitleToNode(BlueprintNode node, string title, BlueprintNodeVM? nodeVm = null) + { + switch (node) + { + case ConstNode constNode when title.StartsWith("Const:"): + constNode.ConstName = title["Const:".Length..].Trim(); + if (nodeVm != null) + { + // Restore ConstValue from the VM's ConstValue property + if (!string.IsNullOrEmpty(nodeVm.ConstValue)) + constNode.ConstValue = nodeVm.ConstValue; + // Restore ConstType from the VM's ConstType property + constNode.ConstType = nodeVm.ConstType; + } + break; + case CallNode callNode when title.StartsWith("Call:"): + var callParts = title["Call:".Length..].Trim().Split('.'); + if (callParts.Length >= 2) + { + callNode.PluginName = callParts[0]; + callNode.FunctionName = string.Join(".", callParts.Skip(1)); + } + else + { + callNode.FunctionName = callParts[0]; + } + break; + case CallHelperNode helperNode when title.StartsWith("Helper:"): + helperNode.HelperFunctionName = title["Helper:".Length..].Trim(); + break; + case BuiltinFunctionNode bfn when title.StartsWith("Get:"): + bfn.Properties["VarName"] = title["Get:".Length..].Trim(); + if (nodeVm != null) + { + var pinType = ResolveVariablePinType(bfn.Properties["VarName"]); + UpdateNodeValuePinType(nodeVm, pinType); + } + break; + case BuiltinFunctionNode bfn2 when title.StartsWith("Set:"): + bfn2.Properties["VarName"] = title["Set:".Length..].Trim(); + if (nodeVm != null) + { + var pinType = ResolveVariablePinType(bfn2.Properties["VarName"]); + UpdateNodeValuePinType(nodeVm, pinType); + } + break; + case VariableNode vNode when title.StartsWith("Var:"): + vNode.VarName = title["Var:".Length..].Trim(); + if (nodeVm != null) + nodeVm.VarName = vNode.VarName; + break; + case PluginTriggerNode ptNode when title.StartsWith("Trigger:"): + var triggerParts = title["Trigger:".Length..].Trim().Split('.'); + if (triggerParts.Length >= 2) + { + ptNode.PluginName = triggerParts[0]; + ptNode.TriggerName = string.Join(".", triggerParts.Skip(1)); + } + else + { + ptNode.TriggerName = triggerParts[0]; + } + break; + } + } + + // ─── Pin Type Helpers ──────────────────────────────────────────────── + + private static PinType TypeStringToPinType(string typeStr) => typeStr?.ToLowerInvariant() switch + { + "int" or "integer" => PinType.Integer, + "bool" or "boolean" => PinType.Boolean, + "double" or "float" or "number" => PinType.Double, + "string" => PinType.String, + _ => PinType.Any + }; + + // ─── Dynamic Type Inference & Propagation ──────────────────────────── + + /// + /// Resolves the PinType for a named variable by searching ConstNodes, VariableNodes, + /// and CurrentBlueprint.ConstValues. + /// + private PinType ResolveVariablePinType(string varName) + { + if (string.IsNullOrEmpty(varName)) return PinType.Any; + + // 1. Search ConstNode VMs in canvas + foreach (var node in Nodes.OfType()) + { + if (node.NodeType == BlueprintNodeType.Const) + { + var constName = node.Metadata.TryGetValue("ConstName", out var cn) ? cn + : node.DisplayTitle.StartsWith("Const:") ? node.DisplayTitle["Const:".Length..].Trim() : ""; + if (constName == varName) + return BlueprintNodeVM.ConstTypeToPinType(node.ConstType); + } + } + + // 2. Search VariableNode VMs in canvas + foreach (var node in Nodes.OfType()) + { + if (node.NodeType == BlueprintNodeType.Variable) + { + var vName = node.Metadata.TryGetValue("VarName", out var vn) ? vn + : node.DisplayTitle.StartsWith("Var:") ? node.DisplayTitle["Var:".Length..].Trim() : ""; + if (vName == varName) + return BlueprintNodeVM.ConstTypeToPinType(node.VarType); + } + } + + // 3. Search CurrentBlueprint.ConstValues + if (CurrentBlueprint?.ConstValues != null) + { + var constVal = CurrentBlueprint.ConstValues.FirstOrDefault(cv => cv.Name == varName); + if (constVal != null) + return TypeStringToPinType(constVal.Type); + } + + return PinType.Any; + } + + /// + /// Propagates a resolved type to all Get/Set node VMs that reference the given variable. + /// Updates pin types and connection colors. + /// + private void PropagateTypeToGetSetNodes(string varName, PinType pinType) + { + if (string.IsNullOrEmpty(varName)) return; + + foreach (var node in Nodes.OfType()) + { + if (node.NodeType != BlueprintNodeType.BuiltinFunction + || (node.BuiltinFunctionName != "Get" && node.BuiltinFunctionName != "Set")) continue; + + // Extract VarName from display title ("Get: myVar" or "Set: myVar") + var nodeVarName = node.DisplayTitle.Contains(':') + ? node.DisplayTitle[(node.DisplayTitle.IndexOf(':') + 1)..].Trim() + : ""; + + if (nodeVarName != varName) continue; + + // Find the Value connector and update its PinType + UpdateNodeValuePinType(node, pinType); + } + } + + /// + /// Updates the Value pin's PinType on a Get/Set node VM and refreshes connection colors. + /// + private void UpdateNodeValuePinType(BlueprintNodeVM node, PinType pinType) + { + // For GetNode: Value is an output connector + // For SetNode: Value is an input connector + var connectors = node.BuiltinFunctionName == "Get" + ? node.Output.OfType() + : node.Input.OfType(); + + foreach (var conn in connectors) + { + if (conn.Title == "Value") + { + conn.PinType = pinType; + // Connection colors will update automatically if BlueprintConnectionVM + // subscribes to PinType changes (see Issue 2.2) + break; + } + } + } + + /// + /// Callback invoked when a ConstNode or VariableNode changes its type. + /// Propagates the new type to all dependent Get/Set nodes. + /// + private void OnNodeTypeChanged(BlueprintNodeVM changedNode) + { + string varName; + PinType pinType; + + if (changedNode.NodeType == BlueprintNodeType.Const) + { + varName = changedNode.Metadata.TryGetValue("ConstName", out var cn) ? cn + : changedNode.DisplayTitle.StartsWith("Const:") ? changedNode.DisplayTitle["Const:".Length..].Trim() : ""; + pinType = BlueprintNodeVM.ConstTypeToPinType(changedNode.ConstType); + } + else if (changedNode.NodeType == BlueprintNodeType.Variable) + { + varName = changedNode.Metadata.TryGetValue("VarName", out var vn) ? vn + : changedNode.DisplayTitle.StartsWith("Var:") ? changedNode.DisplayTitle["Var:".Length..].Trim() : ""; + pinType = BlueprintNodeVM.ConstTypeToPinType(changedNode.VarType); + } + else return; + + PropagateTypeToGetSetNodes(varName, pinType); + } + + /// + /// Performs an initial type resolution pass on all Get/Set nodes. + /// Called after loading a blueprint. + /// + private void ResolveAllGetSetPinTypes() + { + foreach (var node in Nodes.OfType()) + { + if (node.NodeType != BlueprintNodeType.BuiltinFunction + || (node.BuiltinFunctionName != "Get" && node.BuiltinFunctionName != "Set")) continue; + + var varName = node.DisplayTitle.Contains(':') + ? node.DisplayTitle[(node.DisplayTitle.IndexOf(':') + 1)..].Trim() + : ""; + + if (string.IsNullOrEmpty(varName)) continue; + + var pinType = ResolveVariablePinType(varName); + UpdateNodeValuePinType(node, pinType); + } + } + + // ─── Node Creation ─────────────────────────────────────────────────── + + /// + /// Creates a scope block for a Branch/Loop node's output arm. + /// The scope block is positioned to the right of the owner node. + /// + private void CreateScopeBlockForNode(BlueprintNodeVM ownerNode, string armName) + { + var scopeId = $"{armName}_{ownerNode.BlueprintNodeId}"; + var displayName = BlueprintScopeBlockVM.GetDefaultDisplayName(armName); + var headerColor = BlueprintScopeBlockVM.GetHeaderColor(armName); + + // Position scope blocks to the right of the owner node + var yOffset = armName is "True" or "LoopBody" ? -200 : 200; + var scopeBlock = new BlueprintScopeBlockVM + { + ScopeId = scopeId, + DisplayName = displayName, + ArmName = armName, + OwnerNodeId = ownerNode.BlueprintNodeId, + Location = new Avalonia.Point( + ownerNode.Location.X + 300, + ownerNode.Location.Y + yOffset), + GroupSize = new Avalonia.Size(400, 250), + HeaderColor = headerColor, + }; + + ScopeBlocks.Add(scopeBlock); + // Also add to Nodes collection so NodifyEditor renders it + Nodes.Add(scopeBlock); + + // Set Editor reference for drag propagation and auto-sizing + scopeBlock.Editor = this; + scopeBlock.SubscribeToChildNodes(); + + Log.Information("Created scope block '{DisplayName}' for node {NodeId}", displayName, ownerNode.BlueprintNodeId); + } + + /// + /// Computes a spawn location for a newly added node so that consecutive additions + /// don't all stack on top of each other at (100,100). Cascades in an 8-step ring + /// around the origin point, then grows outward. + /// + private Avalonia.Point GetNextNodeLocation() + { + const double originX = 100; + const double originY = 100; + const double step = 30; + var ring = Nodes.Count % 8; + var lap = Nodes.Count / 8; + return new Avalonia.Point( + originX + (ring + lap) * step, + originY + ring * step); + } + + /// + /// Adds a node to the canvas from a descriptor + /// + private void AddNodeFromTemplate(BlueprintNodeType type, string? contentTitle = null) + { + var descriptor = _nodeRegistry.GetDescriptor(type); + var title = contentTitle ?? descriptor.DisplayName; + var (primaryColor, lightColor) = BlueprintNodeVM.GetCategoryColors(type); + + var node = new BlueprintNodeVM + { + Location = GetNextNodeLocation(), + BlueprintNodeId = Guid.NewGuid().ToString(), + NodeType = type, + DisplayTitle = title, + CategoryColor = primaryColor, + CategoryColorLight = lightColor, + Title = title, + Name = descriptor.DisplayName, + Input = new ObservableCollection(), + Output = new ObservableCollection() + }; + + // Add input connectors (with stable pin IDs for round-trip export) + foreach (var pinDesc in descriptor.InputPins) + { + var pinId = Guid.NewGuid().ToString(); + node.Input.Add(new BlueprintConnectorVM + { + Title = pinDesc.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Input, + PinType = pinDesc.Type, + OriginalPinId = pinId + }); + } + + // Add output connectors (with stable pin IDs for round-trip export) + foreach (var pinDesc in descriptor.OutputPins) + { + var pinId = Guid.NewGuid().ToString(); + node.Output.Add(new BlueprintConnectorVM + { + Title = pinDesc.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Output, + PinType = pinDesc.Type, + OriginalPinId = pinId + }); + } + + Nodes.Add(node); + RefreshCounts(); + Log.Information("Added {NodeType} node", type); + } + + private void RefreshCounts() + { + NodeCount = Nodes?.OfType().Count() ?? 0; + ConnectionCount = Connections?.Count ?? 0; + } + + [RelayCommand] + public void AddEntryNode() => AddNodeFromTemplate(BlueprintNodeType.Entry); + + /// + /// Creates a builtin function node from the registry with proper descriptor and UI setup. + /// + private void AddBuiltinFunctionNode(string functionName) + { + var builtinNode = _nodeRegistry.CreateBuiltinFunctionNode(functionName); + var descriptor = builtinNode.GetDescriptor(); + var title = $"{(functionName == "Get" ? "Get: " : functionName == "Set" ? "Set: " : "")}{functionName}"; + var (primaryColor, lightColor) = BlueprintNodeVM.GetBuiltinFunctionColors(functionName); + + var node = new BlueprintNodeVM + { + Location = GetNextNodeLocation(), + BlueprintNodeId = Guid.NewGuid().ToString(), + NodeType = BlueprintNodeType.BuiltinFunction, + BuiltinFunctionName = functionName, + DisplayTitle = title, + CategoryColor = primaryColor, + CategoryColorLight = lightColor, + Title = title, + Name = functionName, + Input = new ObservableCollection(), + Output = new ObservableCollection() + }; + + foreach (var pinDesc in descriptor.InputPins) + { + var pinId = Guid.NewGuid().ToString(); + node.Input.Add(new BlueprintConnectorVM + { + Title = pinDesc.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Input, + PinType = pinDesc.Type, + OriginalPinId = pinId + }); + } + + foreach (var pinDesc in descriptor.OutputPins) + { + var pinId = Guid.NewGuid().ToString(); + node.Output.Add(new BlueprintConnectorVM + { + Title = pinDesc.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Output, + PinType = pinDesc.Type, + OriginalPinId = pinId + }); + } + + Nodes.Add(node); + RefreshCounts(); + Log.Information("Added BuiltinFunction node: {FunctionName}", functionName); + } + + [RelayCommand] + public void AddBranchNode() + { + AddBuiltinFunctionNode("Branch"); + var branchNode = Nodes.OfType().Last(); + CreateScopeBlockForNode(branchNode, "True"); + CreateScopeBlockForNode(branchNode, "False"); + } + + [RelayCommand] + public void AddLoopNode() + { + AddBuiltinFunctionNode("Loop"); + var loopNode = Nodes.OfType().Last(); + CreateScopeBlockForNode(loopNode, "LoopBody"); + CreateScopeBlockForNode(loopNode, "LoopEnd"); + } + + [RelayCommand] + public void AddBreakNode() => AddBuiltinFunctionNode("Break"); + + [RelayCommand] + public void AddConstNode() + { + AddNodeFromTemplate(BlueprintNodeType.Const, "Const: NewConst"); + // Initialize output connector PinType from default ConstType + var constNode = Nodes.OfType().Last(n => n.NodeType == BlueprintNodeType.Const); + foreach (var conn in constNode.Output.OfType()) + { + if (conn.Title == "Value") + { + conn.PinType = BlueprintNodeVM.ConstTypeToPinType(constNode.ConstType); + break; + } + } + constNode.ConstTypeChangedCallback = OnNodeTypeChanged; + } + + [RelayCommand] + public void AddVariableNode() + { + AddNodeFromTemplate(BlueprintNodeType.Variable, "Var: NewVar"); + var varNode = Nodes.OfType().Last(); + varNode.VarTypeChangedCallback = OnNodeTypeChanged; + } + + [RelayCommand] + public void AddCallNode() => AddNodeFromTemplate(BlueprintNodeType.Call, "Call: Plugin.Function"); + + [RelayCommand] + public void AddCallHelperNode() => AddNodeFromTemplate(BlueprintNodeType.CallHelper, "Helper: Func"); + + /// + /// Creates a CallNode from a dynamic palette item with correct PluginName/FunctionName + /// and pre-populated parameter pins. + /// + [RelayCommand] + private void AddPluginCallNode(PluginFunctionPaletteItem item) + { + if (item == null) return; + + var displayTitle = $"Call: {item.PluginName}.{item.FunctionName}"; + var descriptor = _nodeRegistry.GetDescriptor(BlueprintNodeType.Call); + var (primaryColor, lightColor) = BlueprintNodeVM.GetCategoryColors(BlueprintNodeType.Call); + + var node = new BlueprintNodeVM + { + Location = GetNextNodeLocation(), + BlueprintNodeId = Guid.NewGuid().ToString(), + NodeType = BlueprintNodeType.Call, + DisplayTitle = displayTitle, + CategoryColor = primaryColor, + CategoryColorLight = lightColor, + Title = displayTitle, + Name = descriptor.DisplayName, + Input = new ObservableCollection(), + Output = new ObservableCollection() + }; + + node.Metadata["PluginName"] = item.PluginName; + node.Metadata["FunctionName"] = item.FunctionName; + + // Input: Exec pin + parameter pins + foreach (var pinDesc in descriptor.InputPins) + node.Input.Add(new BlueprintConnectorVM + { + Title = pinDesc.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Input, + PinType = pinDesc.Type, + OriginalPinId = Guid.NewGuid().ToString() + }); + + foreach (var param in item.Parameters) + { + node.Input.Add(new BlueprintConnectorVM + { + Title = param.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Input, + PinType = TypeStringToPinType(param.Type), + OriginalPinId = Guid.NewGuid().ToString() + }); + } + + // Output: Exec pin from descriptor, skip "Return" (we add our own based on actual return type) + foreach (var pinDesc in descriptor.OutputPins) + { + if (pinDesc.Name == "Return") continue; + node.Output.Add(new BlueprintConnectorVM + { + Title = pinDesc.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Output, + PinType = pinDesc.Type, + OriginalPinId = Guid.NewGuid().ToString() + }); + } + + if (!string.IsNullOrEmpty(item.ReturnValueType) + && !item.ReturnValueType.Equals("void", StringComparison.OrdinalIgnoreCase)) + { + node.Output.Add(new BlueprintConnectorVM + { + Title = "Return", + Flow = ConnectorViewModelBase.ConnectorFlow.Output, + PinType = TypeStringToPinType(item.ReturnValueType), + OriginalPinId = Guid.NewGuid().ToString() + }); + } + + Nodes.Add(node); + RefreshCounts(); + Log.Information("Added CallNode: {Plugin}.{Function} with {ParamCount} params", + item.PluginName, item.FunctionName, item.Parameters.Count); + } + + /// + /// Creates a PluginTriggerNode from a dynamic palette item. + /// Trigger nodes are alternative entry points with 0 input pins and 1 Exec output pin. + /// + [RelayCommand] + private void AddPluginTriggerNode(PluginTriggerPaletteItem item) + { + if (item == null) return; + + var displayTitle = $"Trigger: {item.PluginName}.{item.TriggerName}"; + var (primaryColor, lightColor) = BlueprintNodeVM.GetCategoryColors(BlueprintNodeType.Entry); + + var node = new BlueprintNodeVM + { + Location = GetNextNodeLocation(), + BlueprintNodeId = Guid.NewGuid().ToString(), + NodeType = BlueprintNodeType.PluginTrigger, + DisplayTitle = displayTitle, + CategoryColor = primaryColor, + CategoryColorLight = lightColor, + Title = displayTitle, + Name = "PluginTrigger", + Input = new ObservableCollection(), + Output = new ObservableCollection() + }; + + node.Metadata["PluginName"] = item.PluginName; + node.Metadata["TriggerName"] = item.TriggerName; + + // Single Exec output pin (same structure as Entry) + node.Output.Add(new BlueprintConnectorVM + { + Title = "Exec", + Flow = ConnectorViewModelBase.ConnectorFlow.Output, + PinType = PinType.Execution, + OriginalPinId = Guid.NewGuid().ToString() + }); + + Nodes.Add(node); + RefreshCounts(); + Log.Information("Added PluginTriggerNode: {Plugin}.{Trigger}", + item.PluginName, item.TriggerName); + } + + /// + /// Creates a CallHelperNode from a dynamic palette item with correct function name + /// and pre-populated parameter pins. + /// + [RelayCommand] + private void AddHelperCallNode(HelperFunctionPaletteItem item) + { + if (item == null) return; + + var displayTitle = $"Helper: {item.FunctionName}"; + var descriptor = _nodeRegistry.GetDescriptor(BlueprintNodeType.CallHelper); + var (primaryColor, lightColor) = BlueprintNodeVM.GetCategoryColors(BlueprintNodeType.CallHelper); + + var node = new BlueprintNodeVM + { + Location = GetNextNodeLocation(), + BlueprintNodeId = Guid.NewGuid().ToString(), + NodeType = BlueprintNodeType.CallHelper, + DisplayTitle = displayTitle, + CategoryColor = primaryColor, + CategoryColorLight = lightColor, + Title = displayTitle, + Name = descriptor.DisplayName, + Input = new ObservableCollection(), + Output = new ObservableCollection() + }; + + node.Metadata["HelperFunctionName"] = item.FunctionName; + + // Input: Exec pin + parameter pins + foreach (var pinDesc in descriptor.InputPins) + node.Input.Add(new BlueprintConnectorVM + { + Title = pinDesc.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Input, + PinType = pinDesc.Type, + OriginalPinId = Guid.NewGuid().ToString() + }); + + foreach (var param in item.Parameters) + { + node.Input.Add(new BlueprintConnectorVM + { + Title = param.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Input, + PinType = TypeStringToPinType(param.Type), + OriginalPinId = Guid.NewGuid().ToString() + }); + } + + // Output: Exec pin from descriptor, skip "Return" (we add our own based on actual return type) + foreach (var pinDesc in descriptor.OutputPins) + { + if (pinDesc.Name == "Return") continue; + node.Output.Add(new BlueprintConnectorVM + { + Title = pinDesc.Name, + Flow = ConnectorViewModelBase.ConnectorFlow.Output, + PinType = pinDesc.Type, + OriginalPinId = Guid.NewGuid().ToString() + }); + } + + if (!string.IsNullOrEmpty(item.ReturnType) + && !item.ReturnType.Equals("void", StringComparison.OrdinalIgnoreCase) + && !item.ReturnType.Equals("object", StringComparison.OrdinalIgnoreCase)) + { + node.Output.Add(new BlueprintConnectorVM + { + Title = "Return", + Flow = ConnectorViewModelBase.ConnectorFlow.Output, + PinType = TypeStringToPinType(item.ReturnType), + OriginalPinId = Guid.NewGuid().ToString() + }); + } + + Nodes.Add(node); + RefreshCounts(); + Log.Information("Added CallHelperNode: {Function} with {ParamCount} params", + item.FunctionName, item.Parameters.Count); + } + + [RelayCommand] + public void AddPrintNode() => AddBuiltinFunctionNode("Print"); + + [RelayCommand] + public void AddStringConcatNode() => AddBuiltinFunctionNode("StringConcat"); + + [RelayCommand] + public void AddSwitchNode() => AddBuiltinFunctionNode("Switch"); + + [RelayCommand] + public void AddPauseNode() => AddBuiltinFunctionNode("Pause"); + + // ─── Blueprint Commands ────────────────────────────────────────────── + + [RelayCommand] + private void CancelExecution() + { + _cancellationTokenSource?.Cancel(); + StatusText = "Execution cancelled"; + + _executor.SetDebugger(null); + IsDebugging = false; + IsPaused = false; + IsExecuting = false; + CleanupDebugController(); + Log.Information("Blueprint execution cancelled"); + } + + [RelayCommand] + private async Task RunWithDebugAsync() + { + if (CurrentBlueprint == null) return; + + if (IsDebugging) + { + CancelExecution(); + return; + } + + IsDebugging = true; + IsPaused = true; + _executionSpeed = 1.0; + ExecutionResult = string.Empty; + + Log.Information("[BlueprintDebug] Starting debug execution"); + _debugController = new KitX.Workflow.BlockScripting.BlueprintDebugger(); + _debugController.SetSpeed(KitX.Core.Contract.Workflow.ExecutionSpeed.StepByStep); + _debugController.NodeExecuting += OnDebugNodeExecuting; + _debugController.NodeExecuted += OnDebugNodeExecuted; + _debugController.VariableChanged += OnDebugVariableChanged; + _debugController.ExecutionPaused += () => + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + IsPaused = true; + Log.Debug("[BlueprintDebug] UI: IsPaused=true"); + }); + _debugController.ExecutionResumed += () => + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + IsPaused = false; + Log.Debug("[BlueprintDebug] UI: IsPaused=false"); + }); + + _executor.SetDebugger(_debugController); + + // Map statement IDs to blueprint node IDs BEFORE execution starts + // (execution may pause at checkpoints before the background task returns). + var mapping = _blueprintService.GetDebugNodeMapping(CurrentBlueprint!); + SetDebugNodeMapping(mapping); + Log.Information("[BlueprintDebug] Debug node mapping: {Count} entries", mapping.Count); + + IsExecuting = true; + StatusText = "Debugging..."; + + _ = System.Threading.Tasks.Task.Run(async () => + { + try + { + Log.Information("[BlueprintDebug] Executing with debugger"); + var result = await _blueprintService.ExecuteBlueprintAsync(CurrentBlueprint); + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + if (result.IsSuccess) + { + StatusText = $"Debug done: {result.ExecutedBlockCount} blocks"; + ExecutionResult = $"Debug complete.\nBlocks: {result.ExecutedBlockCount}\nTime: {result.ExecutionTimeMs}ms"; + } + else + { + StatusText = $"Debug failed: {result.ErrorMessage}"; + ExecutionResult = $"Debug error: {result.ErrorMessage}"; + } + }); + } + catch (Exception ex) + { + Log.Error(ex, "[BlueprintDebug] Debug execution failed"); + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + StatusText = $"Debug error: {ex.Message}"; + ExecutionResult = $"Debug error: {ex.Message}"; + }); + } + finally + { + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + IsExecuting = false; + IsDebugging = false; + IsPaused = false; + // Clear all runtime values from connectors + foreach (var conn in _variableNameToConnector.Values) + conn.RuntimeValue = null; + Log.Information("[BlueprintDebug] Debug execution complete, cleanup"); + }); + _executor.SetDebugger(null); + CleanupDebugController(); + } + }); + } + + [RelayCommand] + private void DebugPause() + { + Log.Debug("[BlueprintDebug] UI: Pause clicked"); + _debugController?.Pause(); + StatusText = "Paused"; + } + + [RelayCommand] + private void DebugStep() + { + Log.Debug("[BlueprintDebug] UI: Step clicked"); + _debugController?.StepNext(); + StatusText = "Step"; + } + + [RelayCommand] + private void DebugContinue() + { + Log.Debug("[BlueprintDebug] UI: Continue clicked"); + _debugController?.Continue(); + StatusText = "Debugging..."; + } + + private void OnDebugNodeExecuting(string statementId) + { + // Capture output from the PREVIOUS node's execution + var output = KitX.Workflow.Services.WorkflowOutput.GetAndClear(); + if (output.Length > 0) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + ExecutionResult += output; + }); + } + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + Log.Debug("[BlueprintDebug] NodeExecuting: stmtId={StmtId}", statementId); + if (_statementToNodeId.TryGetValue(statementId, out var nodeId)) + { + Log.Debug("[BlueprintDebug] NodeExecuting: mapped to nodeId={NodeId}", nodeId); + var nodeVm = Nodes.OfType() + .FirstOrDefault(n => n.BlueprintNodeId == nodeId); + if (nodeVm != null) + { + Log.Debug("[BlueprintDebug] NodeExecuting: found nodeVm, setting IsExecuting=true. Title={Title}", nodeVm.Title); + nodeVm.IsExecuting = true; + Log.Debug("[BlueprintDebug] NodeExecuting: IsExecuting={IsExec}, BorderBrush={Brush}", nodeVm.IsExecuting, nodeVm.BorderBrushOverride); + } + else + { + Log.Warning("[BlueprintDebug] NodeExecuting: nodeVm NOT FOUND for BlueprintNodeId={NodeId}. Node count={Count}", nodeId, Nodes.Count); + } + } + else + { + Log.Debug("[BlueprintDebug] NodeExecuting: no mapping for stmtId={StmtId}. Mapping count={Count}", statementId, _statementToNodeId.Count); + } + }); + } + + private void OnDebugNodeExecuted(string statementId) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + if (_statementToNodeId.TryGetValue(statementId, out var nodeId)) + { + var nodeVm = Nodes.OfType() + .FirstOrDefault(n => n.BlueprintNodeId == nodeId); + if (nodeVm != null) + { + nodeVm.IsExecuting = false; + nodeVm.ExecutionCompleted = true; + } + } + }); + } + + private void OnDebugVariableChanged(string name, object? value) + { + var valStr = value?.ToString() ?? "null"; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var line = $"[{DateTime.Now:HH:mm:ss.fff}] {name} = {valStr}\n"; + ExecutionResult += line; + + // Update connector RuntimeValue for hover display + if (_variableNameToConnector.TryGetValue(name, out var conn)) + conn.RuntimeValue = valStr; + + // Also propagate to connected input connectors + foreach (var c in Connections.OfType()) + { + if (c.Source == conn) + { + if (c.Target is BlueprintConnectorVM target) + target.RuntimeValue = valStr; + } + } + }); + } + + private void CleanupDebugController() + { + if (_debugController == null) return; + _debugController.NodeExecuting -= OnDebugNodeExecuting; + _debugController.NodeExecuted -= OnDebugNodeExecuted; + _debugController.VariableChanged -= OnDebugVariableChanged; + _debugController = null; + } + + internal void SetDebugNodeMapping(Dictionary mapping) + { + _statementToNodeId = new Dictionary(mapping); + } +} diff --git a/KitX Dashboard/ViewModels/BlueprintNodeVM.cs b/KitX Dashboard/ViewModels/BlueprintNodeVM.cs new file mode 100644 index 00000000..47f180c1 --- /dev/null +++ b/KitX Dashboard/ViewModels/BlueprintNodeVM.cs @@ -0,0 +1,210 @@ +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; +using KitX.Core.Contract.Workflow; +using NodifyM.Avalonia.ViewModelBase; + +namespace KitX.Dashboard.ViewModels; + +/// +/// Replaces NodeViewModel + _nodeIdMap + BlueprintNodeContentViewModel. +/// Input/Output collections inherited from NodeViewModelBase hold BlueprintConnectorVM instances. +/// +public partial class BlueprintNodeVM : NodeViewModelBase +{ + /// Original BlueprintNode.Id for round-trip export + [ObservableProperty] + private string _blueprintNodeId = string.Empty; + + /// Node type for category color rendering + [ObservableProperty] + private BlueprintNodeType _nodeType = BlueprintNodeType.Entry; + + /// Header color hex (category primary color) + [ObservableProperty] + private string _categoryColor = "#607D8B"; + + /// Body color hex (category lighter color) + [ObservableProperty] + private string _categoryColorLight = "#455A64"; + + /// Display title shown in node header + [ObservableProperty] + private string _displayTitle = string.Empty; + + /// Node name used for type inference and round-trip export + [ObservableProperty] + private string _name = string.Empty; + + /// + /// Selected constant type for Const nodes. Bound to a ComboBox in the node body. + /// Changing this also updates the output connector's PinType and Metadata. + /// + [ObservableProperty] + private string _constType = "int"; + + /// + /// Constant value for Const nodes. Bound to a TextBox in the node body. + /// Synced bidirectionally with the output connector's DefaultValue. + /// + [ObservableProperty] + private string _constValue = string.Empty; + + /// Available constant type options for the dropdown + public static List ConstTypeOptions { get; } = ["int", "double", "string", "bool"]; + + /// Whether this node should show the ConstType selector (only Const nodes) + public bool ShowConstTypeSelector => NodeType == BlueprintNodeType.Const; + + /// Whether this node should show the VariableType selector (only Variable nodes) + public bool ShowVarTypeSelector => NodeType == BlueprintNodeType.Variable; + + /// + /// Builtin function name, if this node is a BuiltinFunction node. + /// Used for logic that previously checked specific BlueprintNodeType values. + /// + public string? BuiltinFunctionName { get; set; } + + /// + /// Variable type for Variable nodes. Bound to a ComboBox in the node body. + /// Changing this propagates the type to all Get/Set nodes referencing this variable. + /// + [ObservableProperty] + private string _varType = "int"; + + /// + /// Variable name for Variable nodes (and Get/Set nodes for type resolution). + /// + [ObservableProperty] + private string _varName = string.Empty; + + /// True while this node is the current execution point during debug + [ObservableProperty] + private bool _isExecuting; + + /// True after this node has been executed during debug + [ObservableProperty] + private bool _executionCompleted; + + /// True if a debug breakpoint is set on this node + [ObservableProperty] + private bool _isBreakpoint; + + /// Border brush override for debug highlighting + public Avalonia.Media.IBrush? BorderBrushOverride => + IsExecuting ? new Avalonia.Media.SolidColorBrush(Avalonia.Media.Colors.LimeGreen, 0.9) : + IsBreakpoint ? new Avalonia.Media.SolidColorBrush(Avalonia.Media.Colors.Red, 0.7) : + ExecutionCompleted ? new Avalonia.Media.SolidColorBrush(Avalonia.Media.Colors.Gray, 0.4) : + null; + + /// Border thickness override for debug highlighting + public double BorderThicknessOverride => + IsExecuting ? 3.0 : + IsBreakpoint ? 2.0 : + 0.0; + + /// + /// Callback invoked when VarType changes — used by BlueprintEditorViewModel + /// to propagate the type to all Get/Set nodes referencing this variable. + /// + internal System.Action? VarTypeChangedCallback { get; set; } + + /// + /// Extra metadata for round-trip preservation. + /// Keyed by property name, e.g. "ConstType" → "int", "ConstValue" → "5". + /// Used by ConstNode, CallNode, etc. to preserve domain-specific fields + /// that aren't represented in connectors or display title. + /// + public Dictionary Metadata { get; } = new(); + + /// + /// Callback invoked when ConstType changes — used by BlueprintEditorViewModel + /// to update the output connector's PinType. + /// + internal System.Action? ConstTypeChangedCallback { get; set; } + + partial void OnConstTypeChanged(string value) + { + Metadata["ConstType"] = value; + + // Update output connector PinType to match + var outputConnector = Output?.GetEnumerator(); + if (outputConnector?.MoveNext() == true && outputConnector.Current is BlueprintConnectorVM conn) + { + conn.PinType = ConstTypeToPinType(value); + } + + ConstTypeChangedCallback?.Invoke(this); + } + + partial void OnConstValueChanged(string value) + { + // Sync to output connector's DefaultValue for round-trip export + var outputConnector = Output?.GetEnumerator(); + if (outputConnector?.MoveNext() == true && outputConnector.Current is BlueprintConnectorVM conn) + { + conn.DefaultValue = string.IsNullOrEmpty(value) ? null : value; + } + } + + partial void OnVarTypeChanged(string value) + { + Metadata["VarType"] = value; + VarTypeChangedCallback?.Invoke(this); + } + + partial void OnIsExecutingChanged(bool value) + { + OnPropertyChanged(nameof(BorderBrushOverride)); + OnPropertyChanged(nameof(BorderThicknessOverride)); + } + + partial void OnExecutionCompletedChanged(bool value) + { + OnPropertyChanged(nameof(BorderBrushOverride)); + } + + partial void OnIsBreakpointChanged(bool value) + { + OnPropertyChanged(nameof(BorderBrushOverride)); + OnPropertyChanged(nameof(BorderThicknessOverride)); + } + + /// Converts a ConstType string to the corresponding PinType + public static PinType ConstTypeToPinType(string constType) => constType?.ToLowerInvariant() switch + { + "int" or "integer" => PinType.Integer, + "bool" or "boolean" => PinType.Boolean, + "double" or "float" or "number" => PinType.Double, + "string" => PinType.String, + _ => PinType.Any + }; + + protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e) + { + base.OnPropertyChanged(e); + if (e.PropertyName == nameof(NodeType)) + { + OnPropertyChanged(nameof(ShowConstTypeSelector)); + OnPropertyChanged(nameof(ShowVarTypeSelector)); + } + } + + /// Returns (Primary, Light) hex color pair for a node category + public static (string Primary, string Light) GetCategoryColors(BlueprintNodeType type) => type switch + { + BlueprintNodeType.Entry or BlueprintNodeType.PluginTrigger => ("#4CAF50", "#2E7D32"), // Green + BlueprintNodeType.Const => ("#2196F3", "#1565C0"), // Blue + BlueprintNodeType.Variable => ("#009688", "#00796B"), // Teal + BlueprintNodeType.Call or BlueprintNodeType.CallHelper => ("#9C27B0", "#7B1FA2"), // Purple + BlueprintNodeType.BuiltinFunction => ("#FF9800", "#BF6E00"), // Orange + _ => ("#607D8B", "#455A64") // Gray fallback + }; + + /// Returns (Primary, Light) hex color pair based on builtin function name + public static (string Primary, string Light) GetBuiltinFunctionColors(string functionName) => functionName switch + { + "Print" or "Pause" => ("#9C27B0", "#7B1FA2"), // Purple - I/O + "Get" or "Set" => ("#2196F3", "#1565C0"), // Blue - data + _ => ("#FF9800", "#BF6E00") // Orange - control flow + }; +} diff --git a/KitX Dashboard/ViewModels/BlueprintScopeBlockVM.cs b/KitX Dashboard/ViewModels/BlueprintScopeBlockVM.cs new file mode 100644 index 00000000..65b945f2 --- /dev/null +++ b/KitX Dashboard/ViewModels/BlueprintScopeBlockVM.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace KitX.Dashboard.ViewModels; + +/// +/// Represents a scope block in the blueprint editor. +/// Corresponds to a #Block Name in BlockScript. +/// Created automatically when adding a Branch or Loop node. +/// All scope blocks are at the same level (no nesting) — +/// Branch/Loop nodes inside a scope block use cross-scope connections +/// to reach nodes in other scope blocks. +/// +public partial class BlueprintScopeBlockVM : ObservableObject +{ + /// + /// Unique scope identifier (format: ArmName_OwnerNodeId) + /// + [ObservableProperty] + private string _scopeId = string.Empty; + + /// + /// Display name (defaults to ArmName, user can customize, e.g. "SuccessLogic") + /// + [ObservableProperty] + private string _displayName = string.Empty; + + /// + /// Semantic arm name ("True", "False", "LoopBody", "LoopEnd") + /// + [ObservableProperty] + private string _armName = string.Empty; + + /// + /// BlueprintNodeId of the Branch/Loop node that owns this scope + /// + [ObservableProperty] + private string _ownerNodeId = string.Empty; + + /// + /// Ordered list of node BlueprintNodeIds contained in this scope + /// + public ObservableCollection ContainedNodeIds { get; } = []; + + /// + /// Whether this scope block is collapsed (hides contained nodes) + /// + [ObservableProperty] + private bool _isCollapsed = false; + + /// + /// Position of the NodeGroup on canvas + /// + [ObservableProperty] + private Avalonia.Point _location = new(0, 0); + + /// + /// Size of the NodeGroup + /// + [ObservableProperty] + private Avalonia.Size _groupSize = new(400, 300); + + /// + /// Header background color hex + /// + [ObservableProperty] + private string _headerColor = "#333333"; + + // ─── Drag Propagation & Auto-Sizing ─────────────────────────────── + + /// + /// Reference to the editor ViewModel for looking up child nodes. + /// Set when the scope block is created or loaded. + /// + public BlueprintEditorViewModel? Editor { get; set; } + + /// + /// Previous location, used to calculate drag delta. + /// + private Avalonia.Point _previousLocation; + + /// + /// When true, OnLocationChanged does NOT propagate movement to child nodes. + /// Set by ScopeBlockControl during drag propagation (control-level moves children + /// directly via BaseNode CLR setter) and during auto-sizing recalculation. + /// + private bool _suppressChildMove; + + public bool SuppressChildMove + { + get => _suppressChildMove; + set => _suppressChildMove = value; + } + + /// + /// When true, RecalculateBounds does NOT execute. + /// Used during drag propagation to prevent bounds recalculation + /// triggered by child node location changes (prevents chain reactions + /// between scope blocks sharing visual updates). + /// + private bool _suppressBoundsRecalc; + + public bool SuppressBoundsRecalc + { + get => _suppressBoundsRecalc; + set => _suppressBoundsRecalc = value; + } + + /// + /// Tracks subscribed child nodes for PropertyChanged (Location changes). + /// + private readonly Dictionary _subscribedNodes = []; + + /// + /// Called by CommunityToolkit source generator when Location changes. + /// Drag propagation is handled by ScopeBlockControl (control layer) which + /// directly sets BaseNode.Location to fire LocationChangedEvent. + /// This method only updates _previousLocation for tracking. + /// + partial void OnLocationChanged(Avalonia.Point value) + { + _previousLocation = value; + } + + /// + /// Recalculates the scope block's Location and GroupSize from the + /// bounding box of all contained nodes. + /// Called when a child node's Location changes (not during scope drag). + /// + public void RecalculateBounds() + { + if (_suppressBoundsRecalc || Editor == null || ContainedNodeIds.Count == 0) + return; + + double minX = double.MaxValue, minY = double.MaxValue; + double maxX = double.MinValue, maxY = double.MinValue; + + foreach (var nodeId in ContainedNodeIds) + { + var node = Editor.FindNodeById(nodeId); + if (node != null) + { + minX = Math.Min(minX, node.Location.X); + minY = Math.Min(minY, node.Location.Y); + maxX = Math.Max(maxX, node.Location.X + 200); // approximate node width + maxY = Math.Max(maxY, node.Location.Y + 100); // approximate node height + } + } + + if (minX == double.MaxValue) + return; + + const double padding = 30; + const double headerHeight = 40; + + _suppressChildMove = true; + try + { + Location = new Avalonia.Point(minX - padding, minY - padding - headerHeight); + GroupSize = new Avalonia.Size( + maxX - minX + padding * 2, + maxY - minY + padding * 2 + headerHeight); + } + finally + { + _suppressChildMove = false; + } + + // Update previousLocation to match new Location + _previousLocation = Location; + } + + /// + /// Subscribes to Location changes on all currently contained child nodes. + /// When a child moves, the scope block recalculates its bounds. + /// + public void SubscribeToChildNodes() + { + foreach (var nodeId in ContainedNodeIds) + { + SubscribeToNode(nodeId); + } + + ContainedNodeIds.CollectionChanged += OnContainedNodeIdsChanged; + } + + /// + /// Unsubscribes from all child node Location changes. + /// + public void UnsubscribeFromChildNodes() + { + ContainedNodeIds.CollectionChanged -= OnContainedNodeIdsChanged; + + foreach (var kvp in _subscribedNodes) + { + kvp.Value.PropertyChanged -= OnChildNodePropertyChanged; + } + _subscribedNodes.Clear(); + } + + private void OnContainedNodeIdsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems != null) + { + foreach (string nodeId in e.OldItems) + { + UnsubscribeFromNode(nodeId); + } + } + + if (e.NewItems != null) + { + foreach (string nodeId in e.NewItems) + { + SubscribeToNode(nodeId); + } + } + + // Recalculate bounds after membership changes + RecalculateBounds(); + } + + private void SubscribeToNode(string nodeId) + { + if (_subscribedNodes.ContainsKey(nodeId) || Editor == null) + return; + + var node = Editor.FindNodeById(nodeId); + if (node != null) + { + _subscribedNodes[nodeId] = node; + node.PropertyChanged += OnChildNodePropertyChanged; + } + } + + private void UnsubscribeFromNode(string nodeId) + { + if (_subscribedNodes.TryGetValue(nodeId, out var node)) + { + node.PropertyChanged -= OnChildNodePropertyChanged; + _subscribedNodes.Remove(nodeId); + } + } + + private void OnChildNodePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(BlueprintNodeVM.Location)) + { + RecalculateBounds(); + } + } + + // ─── Static Helpers ─────────────────────────────────────────────── + + /// + /// Returns the header color based on arm name + /// + public static string GetHeaderColor(string armName) => armName switch + { + "True" => "#2E7D32", // Green + "False" => "#6A1B9A", // Purple + "LoopBody" => "#1565C0", // Blue + "LoopEnd" => "#BF360C", // Deep Orange + _ => "#333333" // Gray fallback + }; + + /// + /// Returns the default display name suffix based on arm name + /// + public static string GetDefaultDisplayName(string armName) => armName switch + { + "True" => "True", + "False" => "False", + "LoopBody" => "LoopBody", + "LoopEnd" => "LoopEnd", + _ => armName + }; +} diff --git a/KitX Dashboard/ViewModels/DebugWindowViewModel.cs b/KitX Dashboard/ViewModels/DebugWindowViewModel.cs index b7ef4ebf..5c34046c 100644 --- a/KitX Dashboard/ViewModels/DebugWindowViewModel.cs +++ b/KitX Dashboard/ViewModels/DebugWindowViewModel.cs @@ -3,6 +3,8 @@ using System.Threading.Tasks; using Avalonia.Threading; using AvaloniaEdit.Document; +using KitX.Core.Contract.Tasks; +using KitX.Core.Tasks; using KitX.Dashboard.Services; using ReactiveUI; @@ -12,8 +14,12 @@ internal class DebugWindowViewModel : ViewModelBase { private CancellationTokenSource? _cancellationTokenSource; - public DebugWindowViewModel() + private readonly ITasksService _tasksService; + + public DebugWindowViewModel(ITasksService tasksService) { + _tasksService = tasksService; + InitCommands(); InitEvents(); @@ -38,7 +44,7 @@ internal void SubmitCodes(IDocument doc) _cancellationTokenSource = tokenSource; - Task.Run( + _tasksService.RunTaskAsync( async () => { var result = await DebugService.ExecuteCodesAsync(code, cancellationToken: tokenSource.Token); @@ -54,7 +60,8 @@ internal void SubmitCodes(IDocument doc) IsExecuting = false; }); }, - tokenSource.Token + tokenSource.Token, + nameof(SubmitCodes) ); } diff --git a/KitX Dashboard/ViewModels/HelperFunctionPaletteItem.cs b/KitX Dashboard/ViewModels/HelperFunctionPaletteItem.cs new file mode 100644 index 00000000..7d781c31 --- /dev/null +++ b/KitX Dashboard/ViewModels/HelperFunctionPaletteItem.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using KitX.Core.Contract.Workflow; + +namespace KitX.Dashboard.ViewModels; + +/// +/// Represents a helper function entry in the BlueprintEditor node palette. +/// Used as a bindable item in the "Helper Functions" dynamic list. +/// +public class HelperFunctionPaletteItem +{ + public string FunctionName { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public List Parameters { get; init; } = []; + public string ReturnType { get; init; } = "object"; +} diff --git a/KitX Dashboard/ViewModels/MainWindowViewModel.cs b/KitX Dashboard/ViewModels/MainWindowViewModel.cs index 2668f740..879bb461 100644 --- a/KitX Dashboard/ViewModels/MainWindowViewModel.cs +++ b/KitX Dashboard/ViewModels/MainWindowViewModel.cs @@ -1,5 +1,4 @@ using System.Reactive; -using KitX.Dashboard.Configuration; using KitX.Dashboard.Views; using ReactiveUI; diff --git a/KitX Dashboard/ViewModels/Maintain/DebugOptionsWindowViewModel.cs b/KitX Dashboard/ViewModels/Maintain/DebugOptionsWindowViewModel.cs index 9dcdcf07..5663a67e 100644 --- a/KitX Dashboard/ViewModels/Maintain/DebugOptionsWindowViewModel.cs +++ b/KitX Dashboard/ViewModels/Maintain/DebugOptionsWindowViewModel.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace KitX.Dashboard.ViewModels.Maintain; +namespace KitX.Dashboard.ViewModels.Maintain; internal class DebugOptionsWindowViewModel : ViewModelBase { diff --git a/KitX Dashboard/ViewModels/Pages/Controls/Home_ActivityLogViewModel.cs b/KitX Dashboard/ViewModels/Pages/Controls/Home_ActivityLogViewModel.cs index f0804216..0638aefd 100644 --- a/KitX Dashboard/ViewModels/Pages/Controls/Home_ActivityLogViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/Controls/Home_ActivityLogViewModel.cs @@ -1,6 +1,6 @@ using System.Collections.ObjectModel; using Common.Activity; -using KitX.Dashboard.Managers; +using KitX.Core.Activity; using ReactiveUI; namespace KitX.Dashboard.ViewModels.Pages.Controls; diff --git a/KitX Dashboard/ViewModels/Pages/Controls/Home_CountViewModel.cs b/KitX Dashboard/ViewModels/Pages/Controls/Home_CountViewModel.cs index 459d9224..8744a1e4 100644 --- a/KitX Dashboard/ViewModels/Pages/Controls/Home_CountViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/Controls/Home_CountViewModel.cs @@ -1,7 +1,11 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; -using KitX.Dashboard.Managers; -using KitX.Dashboard.Services; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Event; +using KitX.Core.Event; +using KitX.Core.Statistics; +using KitX.Dashboard; using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using ReactiveUI; @@ -10,8 +14,12 @@ namespace KitX.Dashboard.ViewModels.Pages.Controls; internal class Home_CountViewModel : ViewModelBase { + private readonly IConfigService _configService; + public Home_CountViewModel() { + _configService = ConfigService; + RecoveryUseCount(); InitEvents(); @@ -23,7 +31,8 @@ public Home_CountViewModel() public sealed override void InitEvents() { - EventService.UseStatisticsChanged += RecoveryUseCount; + var eventService = App.GetService(); + eventService.Subscribe(EventNames.UseStatisticsChanged, (s, e) => RecoveryUseCount()); } internal void RecoveryUseCount() @@ -53,14 +62,14 @@ internal double NoCount_TipHeight internal bool UseAreaExpanded { - get => ConfigManager.Instance.AppConfig.Pages.Home.UseAreaExpanded; + get => _configService.AppConfig.Pages.Home.UseAreaExpanded; set { - ConfigManager.Instance.AppConfig.Pages.Home.UseAreaExpanded = value; + _configService.AppConfig.Pages.Home.UseAreaExpanded = value; this.RaisePropertyChanged(nameof(UseAreaExpanded)); - SaveAppConfigChanges(); + _configService.SaveAll(); } } diff --git a/KitX Dashboard/ViewModels/Pages/Controls/Home_RecentUseViewModel.cs b/KitX Dashboard/ViewModels/Pages/Controls/Home_RecentUseViewModel.cs index dd477a77..5440a763 100644 --- a/KitX Dashboard/ViewModels/Pages/Controls/Home_RecentUseViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/Controls/Home_RecentUseViewModel.cs @@ -1,5 +1,5 @@ using System.Collections.ObjectModel; -using KitX.Dashboard.Models; +using KitX.Core.Plugin; using KitX.Dashboard.Views; namespace KitX.Dashboard.ViewModels.Pages.Controls; diff --git a/KitX Dashboard/ViewModels/Pages/Controls/PluginBarViewModel.cs b/KitX Dashboard/ViewModels/Pages/Controls/PluginBarViewModel.cs index 14181876..8f9a37f5 100644 --- a/KitX Dashboard/ViewModels/Pages/Controls/PluginBarViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/Controls/PluginBarViewModel.cs @@ -1,14 +1,21 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; +using System.Linq; using System.Reactive; using System.Threading; using Avalonia.Controls; using Avalonia.Media.Imaging; using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Models; -using KitX.Dashboard.Network.DevicesNetwork; +using KitX.Core.Contract.Device; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Plugin; +using KitX.Core.Device; +using KitX.Core.Event; +using KitX.Core.Plugin; +using KitX.Dashboard; using KitX.Dashboard.Services; using KitX.Dashboard.Views; using KitX.Dashboard.Views.Pages.Controls; @@ -29,10 +36,10 @@ public sealed override void InitCommands() { ViewDetailsCommand = ReactiveCommand.Create(() => { - if (Plugin is not null && ViewInstances.MainWindow is not null) + if (Plugin is not null && UIStateService.MainWindow is not null) new PluginDetailWindow() { WindowStartupLocation = WindowStartupLocation.CenterOwner } - .SetPluginInfo(Plugin.PluginInfo) - .Show(ViewInstances.MainWindow); + .SetPluginInfo(Plugin.PluginInfo!) + .Show(UIStateService.MainWindow); }); RemoveCommand = ReactiveCommand.Create(() => @@ -41,7 +48,9 @@ public sealed override void InitCommands() { PluginBars?.Remove(PluginBar); - //PluginsNetwork.RequireRemovePlugin(PluginDetail); + // Also remove from PluginsManager - use Id directly from installation + var pluginService = App.GetService(); + _ = pluginService.RemovePluginAsync(Plugin.Id); } }); @@ -49,8 +58,12 @@ public sealed override void InitCommands() { if (Plugin is not null && PluginBar is not null) { + // First remove from PluginsManager (which also deletes files) - use Id directly + var pluginService = App.GetService(); + _ = pluginService.RemovePluginAsync(Plugin.Id); + + // Then remove from UI PluginBars?.Remove(PluginBar); - //PluginsNetwork.RequireDeletePlugin(PluginDetail); } }); @@ -69,33 +82,75 @@ public sealed override void InitCommands() var loaderVersion = Plugin?.LoaderInfo.LoaderVersion; var pd = Plugin?.PluginInfo; + // InstallPath is already an absolute path from PluginsManager var pluginPath = $"{Plugin?.InstallPath}/{pd?.RootStartupFileName}"; - var pluginFile = pluginPath.GetFullPath(); + + var deviceService = App.GetService(); + + // Get actual port from PluginsServer instead of using ConstantTable + var pluginsServer = App.GetService() as KitX.Core.Device.PluginsServer; + var actualPort = pluginsServer?.Port; + if (actualPort is null or 0) + { + Log.Error("Cannot launch plugin: PluginsServer is not running"); + return; + } + + // Generate a unique connection ID (GUID) for this plugin instance + var connectionId = Guid.NewGuid().ToString(); + // Use 127.0.0.1 instead of LAN IP since plugin and Dashboard run on the same machine var connectStr = - "ws://" - + $"{DevicesDiscoveryServer.Instance.DefaultDeviceInfo.Device.IPv4}" - + $":" - + $"{ConstantTable.PluginsServerPort}/"; + "ws://127.0.0.1:" + + $"{actualPort}/" + + $"{connectionId}/"; if (Plugin is null) return; + Log.Information($"Launch: {pluginPath}"); + if (Plugin.LoaderInfo.SelfLoad) - Process.Start(pluginFile, $"--connect {connectStr}"); + { + Process.Start(pluginPath, $"--connect {connectStr}"); + } else { - var loaderFile = $"{AppConfig.Loaders.InstallPath}/" + $"{loaderName}/{loaderVersion}/{loaderName}"; + // Loader path - relative to app directory + var appDir = AppDomain.CurrentDomain.BaseDirectory; + var loaderPath = ConfigService.AppConfig.Loaders.InstallPath.TrimStart(new[] { '.', '/', '\\' }); + var loaderFile = Path.Combine(appDir, loaderPath, loaderName ?? "", loaderVersion ?? "", loaderName ?? ""); if (OperatingSystem.IsWindows()) loaderFile += ".exe"; - loaderFile = loaderFile.GetFullPath(); + Log.Information($"Launch through loader: {loaderFile}"); - Log.Information($"Launch: {pluginFile} through {loaderFile}"); + // Get the actual plugin file - must use RootStartupFileName from PluginInfo + var pluginFile = pd?.RootStartupFileName; + if (string.IsNullOrEmpty(pluginFile)) + { + Log.Error("RootStartupFileName is not specified in PluginInfo. Please ensure the plugin package includes this field."); + return; + } + + // Build the full path to the plugin file + var pluginFilePath = Path.Combine(Plugin?.InstallPath ?? "", pluginFile); + + if (!File.Exists(loaderFile)) + { + Log.Error($"Loader not found: {loaderFile}. Please ensure the loader is installed."); + return; + } + + if (!File.Exists(pluginFilePath)) + { + Log.Error($"Plugin file not found: {pluginFilePath}. Please check RootStartupFileName in PluginInfo.json."); + return; + } - if (File.Exists(loaderFile) && File.Exists(pluginFile)) + if (File.Exists(loaderFile) && File.Exists(pluginFilePath)) { - var arg = $"--load \"{pluginFile}\" --connect {connectStr}"; + var arg = $"--load \"{pluginFilePath}\" --connect {connectStr}"; Log.Information($"Launch Argument: {arg}"); @@ -113,7 +168,8 @@ public sealed override void InitCommands() public sealed override void InitEvents() { - EventService.LanguageChanged += () => this.RaisePropertyChanged(nameof(DisplayName)); + var eventService = App.GetService(); + eventService.Subscribe(EventNames.LanguageChanged, (s, e) => this.RaisePropertyChanged(nameof(DisplayName))); } internal PluginBar? PluginBar { get; set; } @@ -127,15 +183,15 @@ internal string? DisplayName if (Plugin is null) return null; - return Plugin.PluginInfo.DisplayName.TryGetValue(AppConfig.App.AppLanguage, out var lang) + return Plugin.PluginInfo!.DisplayName.TryGetValue(ConfigService.AppConfig.App.AppLanguage, out var lang) ? lang : Plugin.PluginInfo.DisplayName.Values.GetEnumerator().Current; } } - internal string? AuthorName => Plugin?.PluginInfo.AuthorName; + internal string? AuthorName => Plugin?.PluginInfo?.AuthorName; - internal string? Version => Plugin?.PluginInfo.Version; + internal string? Version => Plugin?.PluginInfo?.Version; internal ObservableCollection? PluginBars { get; set; } @@ -148,9 +204,9 @@ internal Bitmap IconDisplay try { if (Plugin is null) - return App.DefaultIcon; + return App.DefaultIcon!; - var src = Convert.FromBase64String(Plugin.PluginInfo.IconInBase64); + var src = Convert.FromBase64String(Plugin.PluginInfo!.IconInBase64); using var ms = new MemoryStream(src); @@ -165,7 +221,7 @@ internal Bitmap IconDisplay + $"or create bitmap from `MemoryStream`. {e.Message}" ); - return App.DefaultIcon; + return App.DefaultIcon!; } } } diff --git a/KitX Dashboard/ViewModels/Pages/Controls/Settings_AboutViewModel.cs b/KitX Dashboard/ViewModels/Pages/Controls/Settings_AboutViewModel.cs index 79aac992..b6d8a714 100644 --- a/KitX Dashboard/ViewModels/Pages/Controls/Settings_AboutViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/Controls/Settings_AboutViewModel.cs @@ -2,7 +2,8 @@ using System.Reflection; using System.Threading.Tasks; using Common.BasicHelper.IO; -using KitX.Dashboard.Managers; +using KitX.Core.Configuration; +using KitX.Core.Contract.Configuration; using KitX.Dashboard.Views.Pages.Controls; using ReactiveUI; @@ -10,10 +11,14 @@ namespace KitX.Dashboard.ViewModels.Pages.Controls; internal class Settings_AboutViewModel : ViewModelBase { + private readonly IConfigService _configService; + internal AppLogo? AppLogo { get; set; } internal Settings_AboutViewModel() { + _configService = ConfigService; + InitCommands(); } @@ -52,41 +57,41 @@ internal string ThirdPartyLicenseString public static bool AboutAreaExpanded { - get => ConfigManager.Instance.AppConfig.Pages.Settings.AboutAreaExpanded; + get => App.GetService().AppConfig.Pages.Settings.AboutAreaExpanded; set { - ConfigManager.Instance.AppConfig.Pages.Settings.AboutAreaExpanded = value; - SaveAppConfigChanges(); + App.GetService().AppConfig.Pages.Settings.AboutAreaExpanded = value; + App.GetService().SaveAll(); } } public static bool AuthorsAreaExpanded { - get => ConfigManager.Instance.AppConfig.Pages.Settings.AuthorsAreaExpanded; + get => App.GetService().AppConfig.Pages.Settings.AuthorsAreaExpanded; set { - ConfigManager.Instance.AppConfig.Pages.Settings.AuthorsAreaExpanded = value; - SaveAppConfigChanges(); + App.GetService().AppConfig.Pages.Settings.AuthorsAreaExpanded = value; + App.GetService().SaveAll(); } } public static bool LinksAreaExpanded { - get => ConfigManager.Instance.AppConfig.Pages.Settings.LinksAreaExpanded; + get => App.GetService().AppConfig.Pages.Settings.LinksAreaExpanded; set { - ConfigManager.Instance.AppConfig.Pages.Settings.LinksAreaExpanded = value; - SaveAppConfigChanges(); + App.GetService().AppConfig.Pages.Settings.LinksAreaExpanded = value; + App.GetService().SaveAll(); } } public static bool ThirdPartyLicensesAreaExpanded { - get => ConfigManager.Instance.AppConfig.Pages.Settings.ThirdPartyLicensesAreaExpanded; + get => App.GetService().AppConfig.Pages.Settings.ThirdPartyLicensesAreaExpanded; set { - ConfigManager.Instance.AppConfig.Pages.Settings.ThirdPartyLicensesAreaExpanded = value; - SaveAppConfigChanges(); + App.GetService().AppConfig.Pages.Settings.ThirdPartyLicensesAreaExpanded = value; + App.GetService().SaveAll(); } } diff --git a/KitX Dashboard/ViewModels/Pages/Controls/Settings_GeneralViewModel.cs b/KitX Dashboard/ViewModels/Pages/Controls/Settings_GeneralViewModel.cs index 4003afdb..9bb16f33 100644 --- a/KitX Dashboard/ViewModels/Pages/Controls/Settings_GeneralViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/Controls/Settings_GeneralViewModel.cs @@ -1,18 +1,31 @@ using System; using System.Reactive; using System.Threading.Tasks; -using KitX.Dashboard.Managers; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Announcement; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Tasks; +using KitX.Core.Event; +using KitX.Core.Tasks; using KitX.Dashboard.Services; +using KitX.Dashboard.Utils; using KitX.Dashboard.Views; using ReactiveUI; -using Serilog; namespace KitX.Dashboard.ViewModels.Pages.Controls; internal class Settings_GeneralViewModel : ViewModelBase { - internal Settings_GeneralViewModel() + private readonly IConfigService _configService; + private readonly IAnnouncementService _announcementService; + private readonly ITasksService _tasksService; + + public Settings_GeneralViewModel(IConfigService configService, IAnnouncementService announcementService, ITasksService tasksService) { + _configService = configService; + _announcementService = announcementService; + _tasksService = tasksService; + InitCommands(); InitEvents(); @@ -22,63 +35,66 @@ public sealed override void InitCommands() { ShowAnnouncementsInstantlyCommand = ReactiveCommand.Create(() => { - Task.Run(async () => await AnnouncementManager.CheckNewAnnouncements()); + _tasksService.RunTaskAsync( + async () => await _announcementService.CheckNewAnnouncementsAsync(), + nameof(ShowAnnouncementsInstantlyCommand) + ); }); OpenDebugToolCommand = ReactiveCommand.Create(() => { - ViewInstances.ShowWindow(new DebugWindow(), ViewInstances.MainWindow); + UIStateService.ShowWindow(new DebugWindow(), UIStateService.MainWindow); }); } public sealed override void InitEvents() { - EventService.DevelopSettingsChanged += () => this.RaisePropertyChanged(nameof(DeveloperSettingEnabled)); + Events.Subscribe(EventNames.DevelopSettingsChanged, (s, e) => this.RaisePropertyChanged(nameof(DeveloperSettingEnabled))); } - internal static string LocalPluginsFileDirectory + internal string LocalPluginsFileDirectory { - get => ConfigManager.Instance.AppConfig.App.LocalPluginsFileFolder; + get => _configService.AppConfig.App.LocalPluginsFileFolder; set { - ConfigManager.Instance.AppConfig.App.LocalPluginsFileFolder = value; - SaveAppConfigChanges(); + _configService.AppConfig.App.LocalPluginsFileFolder = value; + _configService.SaveAll(); } } - internal static string LocalPluginsDataDirectory + internal string LocalPluginsDataDirectory { - get => ConfigManager.Instance.AppConfig.App.LocalPluginsDataFolder; + get => _configService.AppConfig.App.LocalPluginsDataFolder; set { - ConfigManager.Instance.AppConfig.App.LocalPluginsDataFolder = value; - SaveAppConfigChanges(); + _configService.AppConfig.App.LocalPluginsDataFolder = value; + _configService.SaveAll(); } } - internal static int ShowAnnouncementsStatus + internal int ShowAnnouncementsStatus { - get => ConfigManager.Instance.AppConfig.App.ShowAnnouncementWhenStart ? 0 : 1; + get => _configService.AppConfig.App.ShowAnnouncementWhenStart ? 0 : 1; set { - ConfigManager.Instance.AppConfig.App.ShowAnnouncementWhenStart = value == 0; - SaveAppConfigChanges(); + _configService.AppConfig.App.ShowAnnouncementWhenStart = value == 0; + _configService.SaveAll(); } } - internal static bool DeveloperSettingEnabled + internal bool DeveloperSettingEnabled { - get => ConfigManager.Instance.AppConfig.App.DeveloperSetting; + get => _configService.AppConfig.App.DeveloperSetting; } - internal static int DeveloperSettingStatus + internal int DeveloperSettingStatus { - get => ConfigManager.Instance.AppConfig.App.DeveloperSetting ? 0 : 1; + get => _configService.AppConfig.App.DeveloperSetting ? 0 : 1; set { - ConfigManager.Instance.AppConfig.App.DeveloperSetting = value == 0; - EventService.Invoke(nameof(EventService.DevelopSettingsChanged)); - SaveAppConfigChanges(); + _configService.AppConfig.App.DeveloperSetting = value == 0; + Events.Publish(EventNames.DevelopSettingsChanged); + _configService.SaveAll(); } } diff --git a/KitX Dashboard/ViewModels/Pages/Controls/Settings_PerformenceViewModel.cs b/KitX Dashboard/ViewModels/Pages/Controls/Settings_PerformenceViewModel.cs index ddb9a748..4a808c4a 100644 --- a/KitX Dashboard/ViewModels/Pages/Controls/Settings_PerformenceViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/Controls/Settings_PerformenceViewModel.cs @@ -1,15 +1,23 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using System.Linq; +using System.Net.NetworkInformation; using System.Reactive; using System.Text; using System.Threading.Tasks; using Avalonia.Threading; +using Common.BasicHelper.Core.TaskSystem; using Common.BasicHelper.Utils.Extensions; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Tasks; +using KitX.Core.Event; +using KitX.Core.Tasks; +using KitX.Dashboard; using KitX.Dashboard.Models; using KitX.Dashboard.Names; -using KitX.Dashboard.Services; using ReactiveUI; using Serilog; using Serilog.Events; @@ -18,8 +26,15 @@ namespace KitX.Dashboard.ViewModels.Pages.Controls; internal class Settings_PerformenceViewModel : ViewModelBase { - internal Settings_PerformenceViewModel() + private readonly ITasksService _tasksService; + private readonly SignalTasksManager _signalTasksManager; + + public Settings_PerformenceViewModel(ITasksService tasksService) { + _tasksService = tasksService; + + _signalTasksManager = App.GetService(); + InitCommands(); InitEvents(); @@ -31,9 +46,9 @@ public sealed override void InitCommands() { const string location = $"{nameof(Settings_PerformenceViewModel)}.{nameof(EmptyLogsCommand)}"; - Task.Run(() => + _tasksService.RunTask(() => { - var dir = new DirectoryInfo(AppConfig.Log.LogFilePath.GetFullPath()); + var dir = new DirectoryInfo(ConfigService.AppConfig.Log.LogFilePath.GetFullPath()); foreach (var file in dir.GetFiles()) { @@ -48,7 +63,7 @@ public sealed override void InitCommands() } this.RaisePropertyChanged(nameof(LogFileSizeUsage)); - }); + }, nameof(EmptyLogsCommand)); }); RefreshLogsUsageCommand = ReactiveCommand.Create(() => this.RaisePropertyChanged(nameof(LogFileSizeUsage))); @@ -56,39 +71,40 @@ public sealed override void InitCommands() public sealed override void InitEvents() { - EventService.LogConfigUpdated += () => + var eventService = App.GetService(); + eventService.Subscribe(EventNames.LogConfigUpdated, (s, e) => { - var logdir = AppConfig.Log.LogFilePath.GetFullPath(); + var logdir = ConfigService.AppConfig.Log.LogFilePath.GetFullPath(); Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() + .MinimumLevel.Is(ConfigService.AppConfig.Log.LogLevel) .WriteTo.File( $"{logdir}Log_.log", - outputTemplate: AppConfig.Log.LogTemplate, + outputTemplate: ConfigService.AppConfig.Log.LogTemplate, rollingInterval: RollingInterval.Hour, - fileSizeLimitBytes: AppConfig.Log.LogFileSingleMaxSize, + fileSizeLimitBytes: ConfigService.AppConfig.Log.LogFileSingleMaxSize, buffered: true, - flushToDiskInterval: new(0, 0, AppConfig.Log.LogFileFlushInterval), - restrictedToMinimumLevel: AppConfig.Log.LogLevel, + flushToDiskInterval: new(0, 0, ConfigService.AppConfig.Log.LogFileFlushInterval), + restrictedToMinimumLevel: ConfigService.AppConfig.Log.LogLevel, rollOnFileSizeLimit: true, - retainedFileCountLimit: AppConfig.Log.LogFileMaxCount + retainedFileCountLimit: ConfigService.AppConfig.Log.LogFileMaxCount ) .CreateLogger(); - }; + }); - EventService.LanguageChanged += () => + eventService.Subscribe(EventNames.LanguageChanged, (s, e) => { foreach (var item in SupportedLogLevels) item.LogLevelDisplayName = GetLogLevelDisplayText(item.LogLevelName ?? ""); this.RaisePropertyChanged(nameof(SupportedLogLevels)); - }; + }); - EventService.DevicesServerPortChanged += _ => this.RaisePropertyChanged(nameof(DevicesServerPort)); + eventService.Subscribe(EventNames.DevicesServerPortChanged, (s, e) => this.RaisePropertyChanged(nameof(DevicesServerPort))); - EventService.PluginsServerPortChanged += _ => this.RaisePropertyChanged(nameof(PluginsServerPort)); + eventService.Subscribe(EventNames.PluginsServerPortChanged, (s, e) => this.RaisePropertyChanged(nameof(PluginsServerPort))); - Instances.SignalTasksManager?.SignalRun( + _signalTasksManager.SignalRun( nameof(SignalsNames.FinishedFindingNetworkInterfacesSignal), () => { @@ -134,17 +150,17 @@ public sealed override void InitEvents() this.RaisePropertyChanged(nameof(AcceptedNetworkInterfacesNames)); - SaveAppConfigChanges(); + ConfigService.SaveAll(); }; } internal static double DelayedWebStartSeconds { - get => AppConfig.Web.DelayStartSeconds; + get => ConfigService.AppConfig.Web.DelayStartSeconds; set { - AppConfig.Web.DelayStartSeconds = value; - SaveAppConfigChanges(); + ConfigService.AppConfig.Web.DelayStartSeconds = value; + ConfigService.SaveAll(); } } @@ -152,17 +168,17 @@ internal static double DelayedWebStartSeconds internal int PluginsServerPortType { - get => AppConfig.Web.UserSpecifiedPluginsServerPort is null ? 0 : 1; + get => ConfigService.AppConfig.Web.UserSpecifiedPluginsServerPort is null ? 0 : 1; set { if (value == 0) - AppConfig.Web.UserSpecifiedPluginsServerPort = null; + ConfigService.AppConfig.Web.UserSpecifiedPluginsServerPort = null; else - AppConfig.Web.UserSpecifiedPluginsServerPort = PluginsServerPort; + ConfigService.AppConfig.Web.UserSpecifiedPluginsServerPort = PluginsServerPort; this.RaisePropertyChanged(nameof(PluginsServerPortEditable)); - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } @@ -172,7 +188,7 @@ internal static int PluginsServerPort set { if (value >= 0 && value <= 65535) - AppConfig.Web.UserSpecifiedPluginsServerPort = value; + ConfigService.AppConfig.Web.UserSpecifiedPluginsServerPort = value; } } @@ -180,12 +196,12 @@ internal static int PluginsServerPort internal static string LocalIPFilter { - get => AppConfig.Web.IPFilter; + get => ConfigService.AppConfig.Web.IPFilter; set { - AppConfig.Web.IPFilter = value; + ConfigService.AppConfig.Web.IPFilter = value; - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } @@ -193,7 +209,7 @@ internal static string AcceptedNetworkInterfacesNames { get { - var userPointed = AppConfig.Web.AcceptedNetworkInterfaces; + var userPointed = ConfigService.AppConfig.Web.AcceptedNetworkInterfaces; if (userPointed is null) return "Auto"; @@ -203,137 +219,146 @@ internal static string AcceptedNetworkInterfacesNames set { if (value.ToLower().Equals("auto")) - AppConfig.Web.AcceptedNetworkInterfaces = null; + ConfigService.AppConfig.Web.AcceptedNetworkInterfaces = null; else { var userInput = value.Split(';'); - AppConfig.Web.AcceptedNetworkInterfaces = [.. userInput]; + ConfigService.AppConfig.Web.AcceptedNetworkInterfaces = [.. userInput]; } } } - internal static ObservableCollection? AvailableNetworkInterfaces => Instances.WebManager?.NetworkInterfaceRegistered; + internal static ObservableCollection? AvailableNetworkInterfaces => + new(NetworkInterface.GetAllNetworkInterfaces() + .Where(nic => nic.OperationalStatus == OperationalStatus.Up && + (nic.NetworkInterfaceType == NetworkInterfaceType.Ethernet || + nic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211)) + .Select(x => x.Name)); internal static ObservableCollection? SelectedNetworkInterfaces { get; } = []; internal static int DevicesListRefreshDelay { - get => AppConfig.Web.DevicesViewRefreshDelay; + get => ConfigService.AppConfig.Web.DevicesViewRefreshDelay; set { - AppConfig.Web.DevicesViewRefreshDelay = value; + ConfigService.AppConfig.Web.DevicesViewRefreshDelay = value; - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } internal static int GreetingTextUpdateInterval { - get => AppConfig.Windows.MainWindow.GreetingUpdateInterval; + get => ConfigService.AppConfig.Windows.MainWindow.GreetingUpdateInterval; set { - AppConfig.Windows.MainWindow.GreetingUpdateInterval = value; + ConfigService.AppConfig.Windows.MainWindow.GreetingUpdateInterval = value; - EventService.Invoke(nameof(EventService.GreetingTextIntervalUpdated)); + var eventService = App.GetService(); + eventService.Publish(EventNames.GreetingTextIntervalUpdated, EventArgs.Empty); - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } internal static bool WebRelatedAreaExpanded { - get => AppConfig.Pages.Settings.WebRelatedAreaExpanded; + get => ConfigService.AppConfig.Pages.Settings.WebRelatedAreaExpanded; set { - AppConfig.Pages.Settings.WebRelatedAreaExpanded = value; + ConfigService.AppConfig.Pages.Settings.WebRelatedAreaExpanded = value; - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } internal static bool WebRelatedAreaOfNetworkInterfacesExpanded { - get => AppConfig.Pages.Settings.WebRelatedAreaOfNetworkInterfacesExpanded; + get => ConfigService.AppConfig.Pages.Settings.WebRelatedAreaOfNetworkInterfacesExpanded; set { - AppConfig.Pages.Settings.WebRelatedAreaOfNetworkInterfacesExpanded = value; + ConfigService.AppConfig.Pages.Settings.WebRelatedAreaOfNetworkInterfacesExpanded = value; - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } internal static bool LogRelatedAreaExpanded { - get => AppConfig.Pages.Settings.LogRelatedAreaExpanded; + get => ConfigService.AppConfig.Pages.Settings.LogRelatedAreaExpanded; set { - AppConfig.Pages.Settings.LogRelatedAreaExpanded = value; + ConfigService.AppConfig.Pages.Settings.LogRelatedAreaExpanded = value; - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } internal static bool UpdateRelatedAreaExpanded { - get => AppConfig.Pages.Settings.UpdateRelatedAreaExpanded; + get => ConfigService.AppConfig.Pages.Settings.UpdateRelatedAreaExpanded; set { - AppConfig.Pages.Settings.UpdateRelatedAreaExpanded = value; + ConfigService.AppConfig.Pages.Settings.UpdateRelatedAreaExpanded = value; - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } - internal static int LogFileSizeUsage => (int)(AppConfig.Log.LogFilePath.GetTotalSize() / 1000 / 1024); + internal static int LogFileSizeUsage => (int)(ConfigService.AppConfig.Log.LogFilePath.GetTotalSize() / 1000 / 1024); internal static int LogFileSizeLimit { - get => (int)(AppConfig.Log.LogFileSingleMaxSize / 1024 / 1024); + get => (int)(ConfigService.AppConfig.Log.LogFileSingleMaxSize / 1024 / 1024); set { - AppConfig.Log.LogFileSingleMaxSize = value * 1024 * 1024; + ConfigService.AppConfig.Log.LogFileSingleMaxSize = value * 1024 * 1024; - EventService.Invoke(nameof(EventService.LogConfigUpdated)); + var eventService = App.GetService(); + eventService.Publish(EventNames.LogConfigUpdated, EventArgs.Empty); - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } internal static int LogFileMaxCount { - get => AppConfig.Log.LogFileMaxCount; + get => ConfigService.AppConfig.Log.LogFileMaxCount; set { - AppConfig.Log.LogFileMaxCount = value; + ConfigService.AppConfig.Log.LogFileMaxCount = value; - EventService.Invoke(nameof(EventService.LogConfigUpdated)); + var eventService = App.GetService(); + eventService.Publish(EventNames.LogConfigUpdated, EventArgs.Empty); - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } internal static int LogFileFlushInterval { - get => AppConfig.Log.LogFileFlushInterval; + get => ConfigService.AppConfig.Log.LogFileFlushInterval; set { - AppConfig.Log.LogFileFlushInterval = value; + ConfigService.AppConfig.Log.LogFileFlushInterval = value; - EventService.Invoke(nameof(EventService.LogConfigUpdated)); + var eventService = App.GetService(); + eventService.Publish(EventNames.LogConfigUpdated, EventArgs.Empty); - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } internal static int CheckerPerThreadFilesCountLimit { - get => AppConfig.IO.UpdatingCheckPerThreadFilesCount; + get => ConfigService.AppConfig.IO.UpdatingCheckPerThreadFilesCount; set { - AppConfig.IO.UpdatingCheckPerThreadFilesCount = value; + ConfigService.AppConfig.IO.UpdatingCheckPerThreadFilesCount = value; - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } @@ -379,7 +404,7 @@ internal static int CheckerPerThreadFilesCountLimit }, ]; - private SupportedLogLevel? _currentLogLevel = SupportedLogLevels.Find(x => x.LogEventLevel == AppConfig.Log.LogLevel); + private SupportedLogLevel? _currentLogLevel = SupportedLogLevels.Find(x => x.LogEventLevel == ConfigService.AppConfig.Log.LogLevel); internal SupportedLogLevel? CurrentLogLevel { @@ -390,11 +415,12 @@ internal SupportedLogLevel? CurrentLogLevel if (value is not null) { - AppConfig.Log.LogLevel = value.LogEventLevel; + ConfigService.AppConfig.Log.LogLevel = value.LogEventLevel; - EventService.Invoke(nameof(EventService.LogConfigUpdated)); + var eventService = App.GetService(); + eventService.Publish(EventNames.LogConfigUpdated, EventArgs.Empty); - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } } diff --git a/KitX Dashboard/ViewModels/Pages/Controls/Settings_PersonaliseViewModel.cs b/KitX Dashboard/ViewModels/Pages/Controls/Settings_PersonaliseViewModel.cs index f413bdac..3385c4d9 100644 --- a/KitX Dashboard/ViewModels/Pages/Controls/Settings_PersonaliseViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/Controls/Settings_PersonaliseViewModel.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IO; +using System.Linq; using System.Reactive; using System.Threading.Tasks; using Avalonia; @@ -10,9 +12,11 @@ using Avalonia.Styling; using Avalonia.Threading; using FluentAvalonia.Styling; -using FluentAvalonia.UI.Media; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Event; +using KitX.Core.Event; +using KitX.Dashboard; using KitX.Dashboard.Models; -using KitX.Dashboard.Services; using MsBox.Avalonia; using ReactiveUI; using Serilog; @@ -21,8 +25,12 @@ namespace KitX.Dashboard.ViewModels.Pages.Controls; internal class Settings_PersonaliseViewModel : ViewModelBase { + private readonly IConfigService _configService; + internal Settings_PersonaliseViewModel() { + _configService = ConfigService; + InitCommands(); InitEvents(); @@ -54,40 +62,49 @@ await Dispatcher.UIThread.InvokeAsync(() => ); }); - AppConfig.App.ThemeColor = themeColor.ToHexString(); + _configService.AppConfig.App.ThemeColor = themeColor.ToString(); - SaveAppConfigChanges(); + _configService.SaveAll(); }); } public sealed override void InitEvents() { - EventService.LanguageChanged += () => + var eventService = App.GetService(); + eventService.Subscribe(EventNames.LanguageChanged, (s, e) => { + // Update theme display names foreach (var item in SupportedThemes) item.ThemeDisplayName = GetThemeDisplayText(item.ThemeName); - _currentAppTheme = SupportedThemes.Find(x => x.ThemeName.Equals(AppConfig.App.Theme)); + _currentAppTheme = SupportedThemes.FirstOrDefault(x => x.ThemeName.Equals(_configService.AppConfig.App.Theme)); - this.RaisePropertyChanged(nameof(CurrentAppTheme)); + // Update language display names + foreach (var item in SupportedLanguages) + item.LanguageName = GetLanguageDisplayText(item.LanguageCode); - this.RaisePropertyChanged(nameof(SupportedThemes)); - }; + this.RaisePropertyChanged(nameof(CurrentAppTheme)); + }); } private void InitData() { SupportedLanguages.Clear(); - foreach (var item in AppConfig.App.SurpportLanguages) + foreach (var item in _configService.AppConfig.App.SurpportLanguages) SupportedLanguages.Add(new SupportedLanguage() { LanguageCode = item.Key, LanguageName = item.Value }); - LanguageSelected = SupportedLanguages.FindIndex(x => x.LanguageCode.Equals(AppConfig.App.AppLanguage)); + var selectedLanguage = SupportedLanguages.FirstOrDefault(x => x.LanguageCode.Equals(_configService.AppConfig.App.AppLanguage)); + LanguageSelected = selectedLanguage != null ? SupportedLanguages.IndexOf(selectedLanguage) : 0; + + // Initialize current theme - must explicitly set to show in ComboBox + _currentAppTheme = SupportedThemes.FirstOrDefault(x => x.ThemeName.Equals(_configService.AppConfig.App.Theme)) + ?? SupportedThemes.FirstOrDefault(); // Fallback to first theme if not found } - private Color2 themeColor = new(); + private Color themeColor = new(); - internal Color2 ThemeColor + internal Color ThemeColor { get { @@ -96,33 +113,39 @@ internal Color2 ThemeColor if (obj is not SolidColorBrush brush) return new(); - return new(brush.Color); + return brush.Color; } set => themeColor = value; } private static string GetThemeDisplayText(string key) => Translate(key, prefix: "Text_Settings_Personalise_Theme_") ?? string.Empty; - internal static List SupportedThemes => - [ - new() - { - ThemeName = FluentAvaloniaTheme.LightModeString, - ThemeDisplayName = GetThemeDisplayText(FluentAvaloniaTheme.LightModeString), - }, - new() - { - ThemeName = FluentAvaloniaTheme.DarkModeString, - ThemeDisplayName = GetThemeDisplayText(FluentAvaloniaTheme.DarkModeString), - }, - new() { ThemeName = "Follow", ThemeDisplayName = GetThemeDisplayText("Follow") }, - ]; + private string GetLanguageDisplayText(string languageCode) => + _configService.AppConfig.App.SurpportLanguages.TryGetValue(languageCode, out var name) ? name : string.Empty; + + // Use static field instead of property to ensure consistent object references for ComboBox binding + private static readonly List _supportedThemes = + [ + new() + { + ThemeName = FluentAvaloniaTheme.LightModeString, + ThemeDisplayName = GetThemeDisplayText(FluentAvaloniaTheme.LightModeString), + }, + new() + { + ThemeName = FluentAvaloniaTheme.DarkModeString, + ThemeDisplayName = GetThemeDisplayText(FluentAvaloniaTheme.DarkModeString), + }, + new() { ThemeName = "Follow", ThemeDisplayName = GetThemeDisplayText("Follow") }, + ]; + + internal static List SupportedThemes => _supportedThemes; - private SupportedTheme? _currentAppTheme = SupportedThemes.Find(x => x.ThemeName.Equals(AppConfig.App.Theme)); + private SupportedTheme? _currentAppTheme; internal SupportedTheme? CurrentAppTheme { - get => _currentAppTheme; + get => _currentAppTheme ??= SupportedThemes.Find(x => x.ThemeName.Equals(_configService.AppConfig.App.Theme)); set { _currentAppTheme = value; @@ -130,7 +153,7 @@ internal SupportedTheme? CurrentAppTheme if (value is null) return; - AppConfig.App.Theme = value.ThemeName; + _configService.AppConfig.App.Theme = value.ThemeName; if (Application.Current is null) return; @@ -142,19 +165,21 @@ internal SupportedTheme? CurrentAppTheme _ => ThemeVariant.Default, }; - EventService.Invoke(nameof(EventService.ThemeConfigChanged)); + var eventService = App.GetService(); + eventService.Publish(EventNames.ThemeConfigChanged, EventArgs.Empty); - SaveAppConfigChanges(); + _configService.SaveAll(); } } - internal List SupportedLanguages { get; } = []; + internal ObservableCollection SupportedLanguages { get; } = []; internal static void LoadLanguage() { const string location = $"{nameof(Settings_PersonaliseViewModel)}.{nameof(LoadLanguage)}"; - var lang = AppConfig.App.AppLanguage; + var configService = App.GetService(); + var lang = configService.AppConfig.App.AppLanguage; if (Application.Current is null) return; @@ -177,7 +202,8 @@ internal static void LoadLanguage() Log.Warning(ex, $"In {location}: Language File {lang}.axaml not found."); } - EventService.Invoke(nameof(EventService.LanguageChanged)); + var eventService = App.GetService(); + eventService.Publish(EventNames.LanguageChanged, EventArgs.Empty); } internal int languageSelected = -1; @@ -189,14 +215,14 @@ internal int LanguageSelected { try { - AppConfig.App.AppLanguage = SupportedLanguages[value].LanguageCode; + _configService.AppConfig.App.AppLanguage = SupportedLanguages[value].LanguageCode; if (languageSelected != -1) LoadLanguage(); languageSelected = value; - SaveAppConfigChanges(); + _configService.SaveAll(); } catch { @@ -205,14 +231,14 @@ internal int LanguageSelected } } - internal static bool PaletteAreaExpanded + internal bool PaletteAreaExpanded { - get => AppConfig.Pages.Settings.PaletteAreaExpanded; + get => _configService.AppConfig.Pages.Settings.PaletteAreaExpanded; set { - AppConfig.Pages.Settings.PaletteAreaExpanded = value; + _configService.AppConfig.Pages.Settings.PaletteAreaExpanded = value; - SaveAppConfigChanges(); + _configService.SaveAll(); } } diff --git a/KitX Dashboard/ViewModels/Pages/Controls/Settings_UpdateViewModel.cs b/KitX Dashboard/ViewModels/Pages/Controls/Settings_UpdateViewModel.cs index 6eb6e0d3..92c65430 100644 --- a/KitX Dashboard/ViewModels/Pages/Controls/Settings_UpdateViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/Controls/Settings_UpdateViewModel.cs @@ -12,9 +12,11 @@ using Avalonia.Threading; using Common.BasicHelper.Utils.Extensions; using Common.Update.Checker; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Device; +using KitX.Core.Device; using KitX.Dashboard.Converters; using KitX.Dashboard.Models; -using KitX.Dashboard.Network.DevicesNetwork; using KitX.Shared.CSharp.Device; using MsBox.Avalonia; using MsBox.Avalonia.Enums; @@ -89,7 +91,7 @@ internal string DiskUseStatus public static int UpdateChannel { get => - AppConfig.Web.UpdateChannel switch + ConfigService.AppConfig.Web.UpdateChannel switch { "stable" => 0, "beta" => 1, @@ -98,7 +100,7 @@ public static int UpdateChannel }; set { - AppConfig.Web.UpdateChannel = value switch + ConfigService.AppConfig.Web.UpdateChannel = value switch { 0 => "stable", 1 => "beta", @@ -106,7 +108,7 @@ public static int UpdateChannel _ => "stable", }; - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } @@ -146,7 +148,7 @@ private Checker ScanComponents(string workbase) var checker = new Checker() .SetRootDirectory(wd) - .SetPerThreadFilesCount(AppConfig.IO.UpdatingCheckPerThreadFilesCount) + .SetPerThreadFilesCount(ConfigService.AppConfig.IO.UpdatingCheckPerThreadFilesCount) .SetTransHash2String(true) .AppendIgnoreFolder("Config") .AppendIgnoreFolder("Core") @@ -156,10 +158,10 @@ private Checker ScanComponents(string workbase) .AppendIgnoreFolder("Update") .AppendIgnoreFolder("Loaders") .AppendIgnoreFolder("Plugins") - .AppendIgnoreFolder(AppConfig.App.LocalPluginsFileFolder) - .AppendIgnoreFolder(AppConfig.App.LocalPluginsDataFolder); + .AppendIgnoreFolder(ConfigService.AppConfig.App.LocalPluginsFileFolder) + .AppendIgnoreFolder(ConfigService.AppConfig.App.LocalPluginsDataFolder); - foreach (var item in AppConfig.App.SurpportLanguages) + foreach (var item in ConfigService.AppConfig.App.SurpportLanguages) _ = checker.AppendIncludeFile($"{ld}/{item.Key}.axaml"); Tip = GetUpdateTip("Scan"); @@ -210,12 +212,15 @@ private void CalculateComponentsHash(Checker checker) { client.DefaultRequestHeaders.Accept.Clear(); // 清除请求头部 + var deviceService = App.GetService(); + var deviceOSType = deviceService.DefaultDeviceInfo.DeviceOSType; + var link = "https://" - + AppConfig.Web.UpdateServer - + AppConfig.Web.UpdatePath.Replace( + + ConfigService.AppConfig.Web.UpdateServer + + ConfigService.AppConfig.Web.UpdatePath.Replace( "%platform%", - DevicesDiscoveryServer.Instance.DefaultDeviceInfo.DeviceOSType switch + deviceOSType switch { OperatingSystems.Windows => "win", OperatingSystems.Linux => "linux", @@ -223,8 +228,8 @@ private void CalculateComponentsHash(Checker checker) _ => "", } ) - + $"{AppConfig.Web.UpdateChannel}/" - + AppConfig.Web.UpdateSource; + + $"{ConfigService.AppConfig.Web.UpdateChannel}/" + + ConfigService.AppConfig.Web.UpdateSource; var json = await client.GetStringAsync(link); @@ -392,12 +397,15 @@ private void DownloadNewComponents(ref Dictionary updatedComponent Tip = GetUpdateTip("Download"); //TODO: 下载有变更的文件 + var deviceService = App.GetService(); + var deviceOSType = deviceService.DefaultDeviceInfo.DeviceOSType; + var downloadLinkBase = "https://" - + AppConfig.Web.UpdateServer - + AppConfig.Web.UpdateDownloadPath.Replace( + + ConfigService.AppConfig.Web.UpdateServer + + ConfigService.AppConfig.Web.UpdateDownloadPath.Replace( "%platform%", - DevicesDiscoveryServer.Instance.DefaultDeviceInfo.DeviceOSType switch + deviceOSType switch { OperatingSystems.Windows => "win", OperatingSystems.Linux => "linux", @@ -405,7 +413,7 @@ private void DownloadNewComponents(ref Dictionary updatedComponent _ => "", } ) - + $"{AppConfig.Web.UpdateChannel}/"; + + $"{ConfigService.AppConfig.Web.UpdateChannel}/"; if (!Directory.Exists(ConstantTable.UpdateSavePath.GetFullPath())) Directory.CreateDirectory(ConstantTable.UpdateSavePath.GetFullPath()); diff --git a/KitX Dashboard/ViewModels/Pages/DevicesPageViewModel.cs b/KitX Dashboard/ViewModels/Pages/DevicesPageViewModel.cs index 3b7badb6..9fe535cd 100644 --- a/KitX Dashboard/ViewModels/Pages/DevicesPageViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/DevicesPageViewModel.cs @@ -1,16 +1,31 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; +using System.Linq; using System.Reactive; using System.Threading.Tasks; -using KitX.Dashboard.Models; -using KitX.Dashboard.Views; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Device; +using KitX.Core.Device; +using KitX.Dashboard.Services; +using KitX.Shared.CSharp.Device; +using Microsoft.Extensions.DependencyInjection; using ReactiveUI; namespace KitX.Dashboard.ViewModels.Pages; internal class DevicesPageViewModel : ViewModelBase { + private readonly IConfigService _configService; + private readonly IDeviceDiscoveryService? _discoveryService; + private readonly IDeviceServer? _deviceServer; + public DevicesPageViewModel() { + _configService = ConfigService; + + // Get services from DI + _discoveryService = App.GetService(); + _deviceServer = App.GetService(); + InitCommands(); InitEvents(); @@ -20,29 +35,31 @@ public sealed override void InitCommands() { RestartDevicesServerCommand = ReactiveCommand.Create(async () => { - if (Instances.WebManager is null) + if (_discoveryService is null && _deviceServer is null) return; - await Instances.WebManager.RestartAsync( - new() - { - ClosePluginsServer = false, - RunPluginsServer = false, - CloseDevicesServer = false, - RunDevicesServer = false, - }, - actionBeforeStarting: () => DeviceCases.Clear() - ); + // Stop servers + _deviceServer?.Stop(); + _discoveryService?.Stop(); + + await Task.Delay(_configService.AppConfig.Web.UdpSendFrequency + 200); + + DeviceCases.Clear(); + + // Restart servers + _discoveryService?.Run(); + _deviceServer?.Run(); }); StopDevicesServerCommand = ReactiveCommand.Create(async () => { - if (Instances.WebManager is null) + if (_discoveryService is null && _deviceServer is null) return; - await Instances.WebManager.CloseAsync(new() { ClosePluginsServer = false, CloseDevicesServer = false }); + _deviceServer?.Stop(); + _discoveryService?.Stop(); - await Task.Delay(AppConfig.Web.UdpSendFrequency + 200); + await Task.Delay(_configService.AppConfig.Web.UdpSendFrequency + 200); DeviceCases.Clear(); }); @@ -50,6 +67,30 @@ await Instances.WebManager.RestartAsync( public sealed override void InitEvents() { + // Subscribe to device discovery events + if (_discoveryService is not null) + _discoveryService.DeviceDiscovered += (_, e) => + { + if (e.DeviceInfo is null) return; + + // Check if device already exists using IsSameDevice + var existingDevice = DeviceCases + .OfType() + .FirstOrDefault(x => x.DeviceInfo.Device.IsSameDevice(e.DeviceInfo.Device)); + if (existingDevice is null) + { + // Add new device case via DI - ActivatorUtilities injects IConfigService, IDeviceKeyService, etc. + var serviceProvider = KitX.Core.DI.ServiceHost.ServiceProvider; + var deviceCase = ActivatorUtilities.CreateInstance(serviceProvider, e.DeviceInfo); + DeviceCases.Add(deviceCase); + } + else + { + // Update existing device info + existingDevice.DeviceInfo = e.DeviceInfo; + } + }; + DeviceCases.CollectionChanged += (_, _) => { NoDevice_TipHeight = DeviceCases.Count == 0 ? 300 : 0; @@ -75,7 +116,7 @@ internal double NoDevice_TipHeight set => this.RaiseAndSetIfChanged(ref noDevice_TipHeight, value); } - internal static ObservableCollection DeviceCases => ViewInstances.DeviceCases; + internal static ObservableCollection DeviceCases => UIStateService.DeviceCases; internal ReactiveCommand? RestartDevicesServerCommand { get; set; } diff --git a/KitX Dashboard/ViewModels/Pages/HomePageViewModel.cs b/KitX Dashboard/ViewModels/Pages/HomePageViewModel.cs index 87bf51ad..fc044f78 100644 --- a/KitX Dashboard/ViewModels/Pages/HomePageViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/HomePageViewModel.cs @@ -1,72 +1,77 @@ using System.Reactive; using Avalonia; -using FluentAvalonia.UI.Controls; -using KitX.Dashboard.Managers; +using KitX.Core.Contract.Configuration; using ReactiveUI; namespace KitX.Dashboard.ViewModels.Pages; internal class HomePageViewModel : ViewModelBase { + private readonly IConfigService _configService; + public HomePageViewModel() { + _configService = ConfigService; + InitCommands(); + + InitEvents(); } public sealed override void InitCommands() { ResetToAutoCommand = ReactiveCommand.Create(() => { - NavigationViewPaneDisplayMode = NavigationViewPaneDisplayMode.Auto; + NavigationViewPaneDisplayMode = FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Auto; }); MoveToLeftCommand = ReactiveCommand.Create(() => { - NavigationViewPaneDisplayMode = NavigationViewPaneDisplayMode.Left; + NavigationViewPaneDisplayMode = FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Left; }); MoveToTopCommand = ReactiveCommand.Create(() => { - NavigationViewPaneDisplayMode = NavigationViewPaneDisplayMode.Top; + NavigationViewPaneDisplayMode = FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Top; }); } - public override void InitEvents() => throw new System.NotImplementedException(); + public override void InitEvents() { } - internal static bool IsPaneOpen + internal bool IsPaneOpen { - get => ConfigManager.Instance.AppConfig.Pages.Home.IsNavigationViewPaneOpened; + get => _configService.AppConfig.Pages.Home.IsNavigationViewPaneOpened; set { - ConfigManager.Instance.AppConfig.Pages.Home.IsNavigationViewPaneOpened = value; + _configService.AppConfig.Pages.Home.IsNavigationViewPaneOpened = value; - SaveAppConfigChanges(); + _configService.SaveAll(); } } internal Thickness FirstItemMargin => NavigationViewPaneDisplayMode switch { - NavigationViewPaneDisplayMode.Auto => new(0, 5, 0, 0), - NavigationViewPaneDisplayMode.Left => new(0, 5, 0, 0), - NavigationViewPaneDisplayMode.LeftCompact => new(0, 5, 0, 0), - NavigationViewPaneDisplayMode.LeftMinimal => new(0, 5, 0, 0), - NavigationViewPaneDisplayMode.Top => new(0, 0, 0, 0), + FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Auto => new(0, 5, 0, 0), + FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Left => new(0, 5, 0, 0), + FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.LeftCompact => new(0, 5, 0, 0), + FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.LeftMinimal => new(0, 5, 0, 0), + FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Top => new(0, 0, 0, 0), _ => new(0, 0, 0, 0), }; - internal NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode + internal FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { - get => ConfigManager.Instance.AppConfig.Pages.Home.NavigationViewPaneDisplayMode; + get => (FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode)(int)ConfigService.AppConfig.Pages.Home.NavigationViewPaneDisplayMode; set { - ConfigManager.Instance.AppConfig.Pages.Home.NavigationViewPaneDisplayMode = value; + ConfigService.AppConfig.Pages.Home.NavigationViewPaneDisplayMode = (KitX.Core.Contract.Configuration.NavigationViewPaneDisplayMode)(int)value; this.RaisePropertyChanged(nameof(NavigationViewPaneDisplayMode)); this.RaisePropertyChanged(nameof(FirstItemMargin)); - SaveAppConfigChanges(); + ConfigService.SaveAll(); } } diff --git a/KitX Dashboard/ViewModels/Pages/LibPageViewModel.cs b/KitX Dashboard/ViewModels/Pages/LibPageViewModel.cs index 99a394b1..ee379b0f 100644 --- a/KitX Dashboard/ViewModels/Pages/LibPageViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/LibPageViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Reactive; using Avalonia.Controls; +using KitX.Dashboard.Services; using KitX.Dashboard.Views; using KitX.Shared.CSharp.Plugin; using ReactiveUI; @@ -20,10 +21,10 @@ public sealed override void InitCommands() { ViewDetailsCommand = ReactiveCommand.Create(info => { - if (ViewInstances.MainWindow is not null) + if (UIStateService.MainWindow is not null) new PluginDetailWindow() { WindowStartupLocation = WindowStartupLocation.CenterOwner } .SetPluginInfo(info) - .Show(ViewInstances.MainWindow); + .Show(UIStateService.MainWindow); }); } @@ -54,7 +55,7 @@ public double NoPlugins_TipHeight public string? SearchingText { get; set; } - public static ObservableCollection PluginInfos => ViewInstances.PluginInfos; + public static ObservableCollection PluginInfos => UIStateService.PluginInfos; internal ReactiveCommand? ViewDetailsCommand { get; set; } } diff --git a/KitX Dashboard/ViewModels/Pages/RepoPageViewModel.cs b/KitX Dashboard/ViewModels/Pages/RepoPageViewModel.cs index f123e8e0..34f7d0e5 100644 --- a/KitX Dashboard/ViewModels/Pages/RepoPageViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/RepoPageViewModel.cs @@ -6,9 +6,13 @@ using System.Text.Json; using System.Threading; using Avalonia.Controls; -using KitX.Dashboard.Managers; -using KitX.Dashboard.Models; -using KitX.Dashboard.Services; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Plugin; +using KitX.Core.Contract.Plugin.Events; +using KitX.Core.Event; +using KitX.Core.Plugin; +using KitX.Dashboard; using KitX.Dashboard.Views.Pages; using KitX.Dashboard.Views.Pages.Controls; using KitX.Shared.CSharp.Loader; @@ -20,10 +24,13 @@ namespace KitX.Dashboard.ViewModels.Pages; internal class RepoPageViewModel : ViewModelBase { + private readonly IConfigService _configService; private RepoPage? CurrentPage { get; set; } public RepoPageViewModel() { + _configService = ConfigService; + InitCommands(); InitEvents(); @@ -31,21 +38,34 @@ public RepoPageViewModel() SearchingText = ""; PluginsCount = PluginBars.Count.ToString(); - - RefreshPluginsCommand?.Execute(new()); } public sealed override void InitCommands() { - ImportPluginCommand = ReactiveCommand.Create(async win => + ImportPluginCommand = ReactiveCommand.Create(async obj => { - if (win is not Window window) - return; + // Try to get TopLevel from the current page first, then from the command parameter + var topLevel = CurrentPage is not null + ? TopLevel.GetTopLevel(CurrentPage) + : null; - var topLevel = TopLevel.GetTopLevel(CurrentPage!); + // If CurrentPage doesn't work, try the obj parameter + if (topLevel is null && obj is Window window) + { + topLevel = TopLevel.GetTopLevel(window); + } + + // Last resort: try to get the main window from Application + if (topLevel is null && Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) + { + topLevel = TopLevel.GetTopLevel(desktop.MainWindow); + } if (topLevel is null) + { + Log.Warning("Cannot get TopLevel for file picker"); return; + } var files = ( await topLevel.StorageProvider.OpenFilePickerAsync( @@ -57,56 +77,25 @@ await topLevel.StorageProvider.OpenFilePickerAsync( .ToArray(); if (files is not null && files?.Length > 0) - { - new Thread(() => - { - try - { - PluginsManager.ImportPlugin(files, true); - } - catch (Exception ex) - { - Log.Error(ex, "In RepoPageViewModel.ImportPlugin()"); - } - }).Start(); - } - - RefreshPluginsCommand?.Execute(new()); - }); - - RefreshPluginsCommand = ReactiveCommand.Create(() => - { - PluginBars.Clear(); - - //lock (PluginsNetwork.PluginsListOperationLock) - //{ - - //} - - foreach (var item in PluginsManager.Plugins) { try { - var plugin = new PluginInstallation() + var pluginService = App.GetService(); + foreach (var file in files!) { - InstallPath = item.InstallPath, - PluginInfo = JsonSerializer.Deserialize( - File.ReadAllText(Path.GetFullPath($"{item.InstallPath}/PluginInfo.json")) - ), - LoaderInfo = JsonSerializer.Deserialize( - File.ReadAllText(Path.GetFullPath($"{item.InstallPath}/LoaderInfo.json")) - ), - InstalledDevices = [], - }; - - PluginBars.Add(new(plugin, ref pluginBars)); + await pluginService.ImportPluginAsync(file); + } + // Import completed, refresh the list + RefreshPluginsCommand?.Execute(new()); } catch (Exception ex) { - Log.Error(ex, "In RefreshPlugins()"); + Log.Error(ex, "In RepoPageViewModel.ImportPlugin()"); } } }); + + RefreshPluginsCommand = ReactiveCommand.Create(PerformRefresh); } internal RepoPageViewModel SetControl(RepoPage control) @@ -117,7 +106,13 @@ internal RepoPageViewModel SetControl(RepoPage control) public sealed override void InitEvents() { - EventService.AppConfigChanged += () => ImportButtonVisibility = ConfigManager.Instance.AppConfig.App.DeveloperSetting; + var eventService = App.GetService(); + eventService.Subscribe(EventNames.AppConfigChanged, (s, e) => ImportButtonVisibility = _configService.AppConfig.App.DeveloperSetting); + + // Subscribe to plugin status changes for runtime auto-refresh + var pluginService = App.GetService(); + if (pluginService != null) + pluginService.PluginStatusChanged += OnPluginStatusChanged; PluginBars.CollectionChanged += (_, _) => { @@ -126,6 +121,67 @@ public sealed override void InitEvents() }; } + private DateTime _lastRefreshTime = DateTime.MinValue; + private static readonly TimeSpan RefreshDebounceInterval = TimeSpan.FromMilliseconds(300); + + private void OnPluginStatusChanged(object? sender, PluginStatusChangedEventArgs e) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + if (DateTime.Now - _lastRefreshTime < RefreshDebounceInterval) + return; + _lastRefreshTime = DateTime.Now; + PerformRefresh(); + }); + } + + /// + /// Unsubscribes event handlers to prevent memory leaks. + /// Called from RepoPage.Unloaded. + /// + internal void Cleanup() + { + var pluginService = App.GetService(); + if (pluginService != null) + pluginService.PluginStatusChanged -= OnPluginStatusChanged; + } + + /// + /// Synchronously refreshes the plugin list from the plugin service. + /// Called directly from Loaded (bypasses ReactiveCommand scheduling) + /// and also from RefreshPluginsCommand. + /// + internal void PerformRefresh() + { + PluginBars.Clear(); + + var pluginService = App.GetService(); + foreach (var item in pluginService.GetInstalledPlugins()) + { + try + { + var plugin = new PluginInstallation() + { + Id = item.Id, + InstallPath = item.InstallPath, + PluginInfo = JsonSerializer.Deserialize( + File.ReadAllText(Path.GetFullPath($"{item.InstallPath}/PluginInfo.json")) + ), + LoaderInfo = JsonSerializer.Deserialize( + File.ReadAllText(Path.GetFullPath($"{item.InstallPath}/LoaderInfo.json")) + ), + InstalledDevices = [], + }; + + PluginBars.Add(new(plugin, ref pluginBars)); + } + catch (Exception ex) + { + Log.Error(ex, "In RefreshPlugins()"); + } + } + } + internal string SearchingText { get; set; } private string pluginsCount = "0"; @@ -156,12 +212,14 @@ internal double NoPlugins_TipHeight internal bool ImportButtonVisibility { - get => ConfigManager.Instance.AppConfig.App.DeveloperSetting; + get => _configService.AppConfig.App.DeveloperSetting; set { - ConfigManager.Instance.AppConfig.App.DeveloperSetting = value; + _configService.AppConfig.App.DeveloperSetting = value; this.RaisePropertyChanged(nameof(ImportButtonVisibility)); + + _configService.SaveAll(); } } diff --git a/KitX Dashboard/ViewModels/Pages/SettingsPageViewModel.cs b/KitX Dashboard/ViewModels/Pages/SettingsPageViewModel.cs index 083eb6cf..81960e15 100644 --- a/KitX Dashboard/ViewModels/Pages/SettingsPageViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/SettingsPageViewModel.cs @@ -1,14 +1,18 @@ using System.Reactive; using Avalonia; -using FluentAvalonia.UI.Controls; +using KitX.Core.Contract.Configuration; using ReactiveUI; namespace KitX.Dashboard.ViewModels.Pages; internal class SettingsPageViewModel : ViewModelBase { + private readonly IConfigService _configService; + internal SettingsPageViewModel() { + _configService = ConfigService; + InitCommands(); } @@ -16,56 +20,56 @@ public sealed override void InitCommands() { ResetToAutoCommand = ReactiveCommand.Create(() => { - NavigationViewPaneDisplayMode = NavigationViewPaneDisplayMode.Auto; + NavigationViewPaneDisplayMode = FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Auto; }); MoveToLeftCommand = ReactiveCommand.Create(() => { - NavigationViewPaneDisplayMode = NavigationViewPaneDisplayMode.Left; + NavigationViewPaneDisplayMode = FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Left; }); MoveToTopCommand = ReactiveCommand.Create(() => { - NavigationViewPaneDisplayMode = NavigationViewPaneDisplayMode.Top; + NavigationViewPaneDisplayMode = FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Top; }); } public override void InitEvents() => throw new System.NotImplementedException(); - internal static bool IsPaneOpen + internal bool IsPaneOpen { - get => AppConfig.Pages.Settings.IsNavigationViewPaneOpened; + get => _configService.AppConfig.Pages.Settings.IsNavigationViewPaneOpened; set { - AppConfig.Pages.Settings.IsNavigationViewPaneOpened = value; + _configService.AppConfig.Pages.Settings.IsNavigationViewPaneOpened = value; - SaveAppConfigChanges(); + _configService.SaveAll(); } } internal Thickness FirstItemMargin => NavigationViewPaneDisplayMode switch { - NavigationViewPaneDisplayMode.Auto => new(0, 5, 0, 0), - NavigationViewPaneDisplayMode.Left => new(0, 5, 0, 0), - NavigationViewPaneDisplayMode.LeftCompact => new(0, 5, 0, 0), - NavigationViewPaneDisplayMode.LeftMinimal => new(0, 5, 0, 0), - NavigationViewPaneDisplayMode.Top => new(0, 0, 0, 0), + FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Auto => new(0, 5, 0, 0), + FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Left => new(0, 5, 0, 0), + FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.LeftCompact => new(0, 5, 0, 0), + FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.LeftMinimal => new(0, 5, 0, 0), + FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode.Top => new(0, 0, 0, 0), _ => new(0, 0, 0, 0), }; - internal NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode + internal FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode NavigationViewPaneDisplayMode { - get => AppConfig.Pages.Settings.NavigationViewPaneDisplayMode; + get => (FluentAvalonia.UI.Controls.NavigationViewPaneDisplayMode)(int)_configService.AppConfig.Pages.Settings.NavigationViewPaneDisplayMode; set { - AppConfig.Pages.Settings.NavigationViewPaneDisplayMode = value; + _configService.AppConfig.Pages.Settings.NavigationViewPaneDisplayMode = (KitX.Core.Contract.Configuration.NavigationViewPaneDisplayMode)(int)value; this.RaisePropertyChanged(nameof(NavigationViewPaneDisplayMode)); this.RaisePropertyChanged(nameof(FirstItemMargin)); - SaveAppConfigChanges(); + _configService.SaveAll(); } } diff --git a/KitX Dashboard/ViewModels/Pages/WorkflowPageViewModel.cs b/KitX Dashboard/ViewModels/Pages/WorkflowPageViewModel.cs index 78b682f8..9cdde454 100644 --- a/KitX Dashboard/ViewModels/Pages/WorkflowPageViewModel.cs +++ b/KitX Dashboard/ViewModels/Pages/WorkflowPageViewModel.cs @@ -1,108 +1,577 @@ -using System.Collections.ObjectModel; +using System; +using System.Collections.ObjectModel; +using System.Linq; using System.Reactive; using System.Threading.Tasks; -using Avalonia.Metadata; -using KitX.Dashboard.Models; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Plugin.Events; +using KitX.Core.Contract.Workflow; +using KitX.Core.DI; +using KitX.Core.Event; +using KitX.Dashboard; using KitX.Dashboard.Services; using KitX.Dashboard.Views; using MsBox.Avalonia; using MsBox.Avalonia.Enums; using ReactiveUI; -namespace KitX.Dashboard.ViewModels.Pages +namespace KitX.Dashboard.ViewModels.Pages; + +internal class WorkflowPageViewModel : ViewModelBase { - internal class WorkflowPageViewModel : ViewModelBase + private readonly IWorkflowStorageService _storageService; + private readonly IWorkflowManagementService _workflowService; + private readonly IEventService _eventService; + + /// + /// Real-time activity log shown at the bottom of the Workflow page. Only populated + /// in Debug builds so Release users get a clean UI. Each entry is a timestamped line. + /// Capped at 500 entries (older trimmed) to bound memory. + /// +#if DEBUG + public ObservableCollection ExecutionLog { get; } = new(); + public bool IsDebugLogVisible => true; + + // Log panel height cycles through these three sizes on each button click. + private static readonly double[] _logHeights = { 160, 300, 80 }; + private int _logHeightIndex = 0; + private double _logPanelHeight = _logHeights[0]; + public double LogPanelHeight + { + get => _logPanelHeight; + set => this.RaiseAndSetIfChanged(ref _logPanelHeight, value); + } +#else + public ObservableCollection ExecutionLog { get; } = new(); + public bool IsDebugLogVisible => false; + public double LogPanelHeight => 160; +#endif + + private const int MaxLogEntries = 500; + + /// + /// Appends a timestamped line to . Safe to call from any + /// thread; marshals onto the UI thread. No-op unless DEBUG. + /// + private void AppendLog(string message) { - public WorkflowPageViewModel() +#if DEBUG + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var line = $"[{DateTime.Now:HH:mm:ss.fff}] {message}"; + ExecutionLog.Add(line); + while (ExecutionLog.Count > MaxLogEntries) + ExecutionLog.RemoveAt(0); + }); +#endif + } + + public WorkflowPageViewModel() + { + _storageService = App.GetService(); + _workflowService = App.GetService(); + _eventService = App.GetService(); + + InitCommands(); + InitEvents(); + + // Load workflows from storage on construction + _ = LoadWorkflowsAsync(); + } + + public sealed override void InitCommands() + { + CreateWorkflowCommand = ReactiveCommand.CreateFromTask(async () => + { + var workflow = await _storageService.CreateWorkflowAsync( + TranslateTextWithSuffix("Workflow", "NewWorkflow") ?? "New Workflow"); + WorkflowCases.Add(workflow); + _eventService.Publish(EventNames.WorkflowCreated, EventArgs.Empty); + }); + + OpenWorkflowCommand = ReactiveCommand.Create(workflow => + { + OpenWorkflowEditorAsync(workflow); + }); + + DeleteWorkflowCommand = ReactiveCommand.Create(workflow => + { + DeleteWorkflowAsync(workflow); + }); + + RunWorkflowCommand = ReactiveCommand.Create(workflow => + { + RunWorkflowAsync(workflow); + }); + + StopWorkflowCommand = ReactiveCommand.Create(workflow => + { + StopWorkflowAsync(workflow); + }); + + RefreshWorkflowsCommand = ReactiveCommand.CreateFromTask(async () => + { + await LoadWorkflowsAsync(); + }); + + ClearLogCommand = ReactiveCommand.Create(() => + { + ExecutionLog.Clear(); + }); + + ToggleLogHeightCommand = ReactiveCommand.Create(() => + { + _logHeightIndex = (_logHeightIndex + 1) % _logHeights.Length; + LogPanelHeight = _logHeights[_logHeightIndex]; + }); + } + + public sealed override void InitEvents() + { + WorkflowCases.CollectionChanged += (_, _) => + { + NoWorkflow_TipHeight = WorkflowCases.Count == 0 ? 300 : 0; + WorkflowCount = WorkflowCases.Count; + this.RaisePropertyChanged(nameof(WorkflowCountTip)); + }; + + _eventService.Subscribe(EventNames.LanguageChanged, (s, e) => + { + this.RaisePropertyChanged(nameof(WorkflowCountTip)); + }); + + // Listen for rename events from editor windows + _eventService.Subscribe(EventNames.WorkflowRenamed, (s, e) => + { + if (e is WorkflowRenamedEventArgs args) + SyncWorkflowMetadata(args.WorkflowId, args.NewName, null, null); + }); + + // Listen for save events to sync metadata changes + _eventService.Subscribe(EventNames.WorkflowDataSaved, (s, e) => { - InitCommands(); - InitEvents(); + if (e is WorkflowSavedEventArgs args) + SyncWorkflowMetadata(args.WorkflowId, args.WorkflowName, args.Description, args.Author); + }); + + // Listen for workflow execution results + _eventService.Subscribe(EventNames.WorkflowExecutionResult, OnWorkflowExecutionResult); + + // Listen for plugin registration to auto-recover error workflows + _eventService.Subscribe(EventNames.PluginRegistered, OnPluginRegistered); - testInit(); + // Listen for plugin unregistration to set error state on dependent workflows + _eventService.Subscribe(EventNames.PluginUnregistered, OnPluginUnregistered); + } + + private async Task LoadWorkflowsAsync() + { + // Snapshot running and error state before clearing — these are in-memory only, + // not persisted to disk. Refresh creates new instances, so we must restore. + var runningIds = new System.Collections.Generic.HashSet(); + var errorStates = new System.Collections.Generic.Dictionary(); + foreach (var w in WorkflowCases) + { + if (w.IsRunning) runningIds.Add(w.Id); + if (w.IsError) errorStates[w.Id] = w.ErrorMessage; } - private void testInit() + var workflows = await _storageService.DiscoverWorkflowsAsync(); + WorkflowCases.Clear(); + + foreach (var w in workflows) { - WorkflowCases.Add( - new WorkflowCase - { - Name = "Test", - Description = "Test", - IconPath = "Test", - IsRunning = false, - } - ); + // Restore running state from snapshot + if (runningIds.Contains(w.Id)) + w.IsRunning = true; + // Restore error state from snapshot + if (errorStates.TryGetValue(w.Id, out var errMsg)) + { + w.IsError = true; + w.ErrorMessage = errMsg; + } - WorkflowCases.Add( - new WorkflowCase - { - Name = "Test", - Description = "Test", - IconPath = "Test", - IsRunning = true, - } - ); + WorkflowCases.Add(w); + AppendLog($"Mounted workflow '{w.Name}' (id={w.Id}, trigger={w.TriggerConfig?.TriggerType ?? "Manual"}" + + (w.TriggerConfig?.PluginName is { } pn && !string.IsNullOrEmpty(pn) + ? $":{pn}/{w.TriggerConfig.TriggerName}" : "") + ")"); } + AppendLog($"Loaded {workflows.Count} workflow(s)"); + } - public sealed override void InitCommands() + /// + /// Refreshes a single workflow item in the list by removing and re-inserting it. + /// WorkflowCase is a POCO without INotifyPropertyChanged, so property changes + /// (like IsRunning) won't propagate to bindings unless we trigger CollectionChanged. + /// + private static void RefreshWorkflowInList(IWorkflowCase workflow) + { + for (int i = 0; i < WorkflowCases.Count; i++) { - RunWorkflowCommand = ReactiveCommand.Create(static () => + if (WorkflowCases[i].Id == workflow.Id) { - // 运行工作流的逻辑 - // 临时调试用,弹出消息框 - var messageBoxStandardWindow = MessageBoxManager.GetMessageBoxStandard("FU", "CK", icon: Icon.Error).ShowWindowAsync(); - return Task.CompletedTask; - }); + WorkflowCases.RemoveAt(i); + WorkflowCases.Insert(i, workflow); + return; + } + } + } - StopWorkflowCommand = ReactiveCommand.Create(static () => + /// + /// Syncs workflow metadata in the collection by replacing the item + /// to trigger ObservableCollection.CollectionChanged and refresh UI bindings. + /// WorkflowCase is a POCO without INotifyPropertyChanged, so simply setting + /// properties won't update the card text. + /// + private static void SyncWorkflowMetadata(string workflowId, string? newName, string? description, string? author) + { + for (int i = 0; i < WorkflowCases.Count; i++) + { + if (WorkflowCases[i].Id == workflowId) { - // 停止工作流的逻辑 - // 临时调试用,弹出消息框 - var messageBoxStandardWindow = MessageBoxManager.GetMessageBoxStandard("C", "XK", icon: Icon.Error).ShowWindowAsync(); - return Task.CompletedTask; - }); + var existing = WorkflowCases[i]; + bool changed = false; + if (newName != null && existing.Name != newName) { existing.Name = newName; changed = true; } + if (description != null && existing.Description != description) { existing.Description = description; changed = true; } + if (author != null && existing.Author != author) { existing.Author = author; changed = true; } + if (changed) + { + // Remove and re-add at same index to trigger CollectionChanged + binding refresh + WorkflowCases.RemoveAt(i); + WorkflowCases.Insert(i, existing); + } + return; + } } + } - public sealed override void InitEvents() + private async void OpenWorkflowEditorAsync(IWorkflowCase workflow) + { + try { - WorkflowCases.CollectionChanged += (_, _) => + // Check if an editor window is already open for this workflow + if (UIStateService.WorkflowEditorWindows.TryGetValue(workflow.Id, out var existingWindow)) { - NoWorkflow_TipHeight = WorkflowCases.Count == 0 ? 300 : 0; - WorkflowCount = WorkflowCases.Count; - }; + existingWindow.Activate(); + return; + } - EventService.LanguageChanged += () => + // Create new unified editor window + var editorWindow = new WorkflowEditorWindow(); + await editorWindow.LoadWorkflowAsync(workflow.Id); + + // Track the window + UIStateService.WorkflowEditorWindows[workflow.Id] = editorWindow; + editorWindow.Closed += (_, _) => { - this.RaisePropertyChanged(nameof(WorkflowCountTip)); + UIStateService.WorkflowEditorWindows.Remove(workflow.Id); }; + + UIStateService.ShowWindow(editorWindow); } + catch (Exception ex) + { + await MessageBoxManager.GetMessageBoxStandard( + TranslateTextWithSuffix("Workflow", "Error") ?? "Error", ex.Message, icon: Icon.Error) + .ShowWindowAsync(); + } + } - internal string? SearchingText { get; set; } + private async void DeleteWorkflowAsync(IWorkflowCase workflow) + { + try + { + var result = await MessageBoxManager.GetMessageBoxStandard( + TranslateTextWithSuffix("Workflow", "DeleteWorkflow") ?? "Delete Workflow", + (TranslateTextWithSuffix("Workflow", "DeleteConfirm") ?? "Are you sure you want to delete \"$name\"?") + .Replace("$name", workflow.Name), + ButtonEnum.YesNo, + Icon.Warning + ).ShowWindowAsync(); - internal int workflowCount = 0; + if (result == ButtonResult.Yes) + { + await _storageService.DeleteWorkflowAsync(workflow.Id); + WorkflowCases.Remove(workflow); + _eventService.Publish(EventNames.WorkflowDeleted, EventArgs.Empty); + AppendLog($"[Delete] Removed workflow '{workflow.Name}' (id={workflow.Id})"); + } + } + catch (Exception ex) + { + await MessageBoxManager.GetMessageBoxStandard( + TranslateTextWithSuffix("Workflow", "Error") ?? "Error", ex.Message, icon: Icon.Error) + .ShowWindowAsync(); + } + } + + private async void RunWorkflowAsync(IWorkflowCase workflow) + { + try + { + // Always enter Running state on click — button becomes STOP + workflow.IsRunning = true; + + if (workflow.TriggerConfig?.TriggerType == "PluginEvent" + && !string.IsNullOrEmpty(workflow.TriggerConfig.PluginName)) + { + // Pre-check: is the required plugin connected? + var pluginServer = KitX.Core.DI.ServiceHost.GetRequiredService(); + bool pluginConnected = pluginServer?.Connections + .Any(c => c.PluginInfo?.Name == workflow.TriggerConfig.PluginName) ?? false; - internal double noWorkflow_TipHeight = 0; + if (!pluginConnected) + { + // Plugin offline: still Running, but Error (yellow light + STOP button) + workflow.IsError = true; + workflow.ErrorMessage = $"Plugin '{workflow.TriggerConfig.PluginName}' is not connected"; + RefreshWorkflowInList(workflow); + AppendLog($"[Trigger] '{workflow.Name}' requires plugin " + + $"'{workflow.TriggerConfig.PluginName}' which is NOT connected — trigger not armed"); + return; + } - internal int WorkflowCount + // Plugin online: clear old errors, register trigger + workflow.IsError = false; + workflow.ErrorMessage = null; + + try + { + var triggerManager = KitX.Core.DI.ServiceHost.GetRequiredService(); + triggerManager?.RegisterWorkflowTrigger(workflow.Id, workflow.TriggerConfig); + AppendLog($"[Trigger] '{workflow.Name}' armed — fires on " + + $"'{workflow.TriggerConfig.PluginName}/{workflow.TriggerConfig.TriggerName}'"); + } + catch (Exception trigEx) + { + AppendLog($"[Trigger] '{workflow.Name}' failed to register trigger: {trigEx.Message}"); + /* non-critical */ + } + + RefreshWorkflowInList(workflow); + } + else + { + // Manual: clear old errors, then run once. + workflow.IsError = false; + workflow.ErrorMessage = null; + RefreshWorkflowInList(workflow); + AppendLog($"[Run] Manual run of '{workflow.Name}' (id={workflow.Id}) started"); + + // Offload to thread pool to prevent UI deadlock — script execution + // may synchronously wait for plugin responses (PluginCall uses + // TaskCompletionSource.Result), which would deadlock the UI thread + // if the response callback needs the same SynchronizationContext. + await Task.Run(async () => + { + var runResult = await _workflowService.RunWorkflowWithDetailsAsync(workflow.Id); + _eventService.Publish(EventNames.WorkflowExecutionResult, + new WorkflowExecutionResultEventArgs(workflow.Id, runResult.IsSuccess, + runResult.IsSuccess ? null : runResult.ErrorMessage ?? "Workflow execution failed", + runResult.Output)); + }); + } + } + catch (Exception ex) { - get => workflowCount; - set => this.RaiseAndSetIfChanged(ref workflowCount, value); + await MessageBoxManager.GetMessageBoxStandard( + TranslateTextWithSuffix("Workflow", "Error") ?? "Error", ex.Message, icon: Icon.Error) + .ShowWindowAsync(); } + } - internal double NoWorkflow_TipHeight + private async void StopWorkflowAsync(IWorkflowCase workflow) + { + try { - get => noWorkflow_TipHeight; - set => this.RaiseAndSetIfChanged(ref noWorkflow_TipHeight, value); + if (workflow.TriggerConfig?.TriggerType == "PluginEvent") + { + // PluginEvent: unregister trigger and mark as stopped + try + { + var triggerManager = KitX.Core.DI.ServiceHost.GetRequiredService(); + triggerManager?.UnregisterWorkflowTrigger(workflow.Id); + } + catch { /* non-critical */ } + } + else + { + // Manual: stop the running execution + await _workflowService.StopWorkflowAsync(workflow.Id); + } + + workflow.IsError = false; + workflow.ErrorMessage = null; + workflow.IsRunning = false; + RefreshWorkflowInList(workflow); + AppendLog($"[Stop] '{workflow.Name}' stopped" + + (workflow.TriggerConfig?.TriggerType == "PluginEvent" ? " (trigger disarmed)" : "")); + } + catch (Exception ex) + { + await MessageBoxManager.GetMessageBoxStandard( + TranslateTextWithSuffix("Workflow", "Error") ?? "Error", ex.Message, icon: Icon.Error) + .ShowWindowAsync(); } + } - [DependsOn(nameof(WorkflowCount))] - internal string WorkflowCountTip => - TranslateTextWithSuffix("Workflow", "Count")?.Replace("$count", WorkflowCount.ToString()) ?? "Language key not found"; + internal string? SearchingText { get; set; } - internal static ObservableCollection WorkflowCases => ViewInstances.WorkflowCases; + /// + /// Handles workflow execution result events — updates error state on the workflow card. + /// + private void OnWorkflowExecutionResult(object? sender, EventArgs e) + { + if (e is not WorkflowExecutionResultEventArgs args) return; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var workflow = WorkflowCases.FirstOrDefault(w => w.Id == args.WorkflowId); + if (workflow is null) return; - internal ReactiveCommand? RunWorkflowCommand { get; set; } + if (args.IsSuccess) + { + // Success: clear error. Manual workflows return to Stopped. + workflow.IsError = false; + workflow.ErrorMessage = null; + if (workflow.TriggerConfig?.TriggerType != "PluginEvent") + workflow.IsRunning = false; + RefreshWorkflowInList(workflow); + AppendLog($"[Done] '{workflow.Name}' completed successfully"); - internal ReactiveCommand? StopWorkflowCommand { get; set; } + // Surface Print() output lines — indented for readability. + if (args.Output is { Count: > 0 }) + { + foreach (var line in args.Output) + AppendLog($" │ {line}"); + } + } + else + { + // Failure: keep Running (STOP button) but mark Error (yellow light). + // User can click Stop to dismiss the error. + workflow.IsError = true; + workflow.ErrorMessage = args.ErrorMessage; + if (workflow.TriggerConfig?.TriggerType != "PluginEvent") + workflow.IsRunning = false; + RefreshWorkflowInList(workflow); + AppendLog($"[Error] '{workflow.Name}' failed: {args.ErrorMessage}"); + + // Show output even on failure — it may contain diagnostic prints. + if (args.Output is { Count: > 0 }) + { + foreach (var line in args.Output) + AppendLog($" │ {line}"); + } + } + }); } + + /// + /// Handles plugin registration events — auto-recovers error workflows + /// whose required plugin just came online. + /// + private void OnPluginRegistered(object? sender, EventArgs e) + { + if (e is not PluginRegisteredEventArgs pa || pa.PluginInfo?.Name is null) return; + var pluginName = pa.PluginInfo.Name; + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + foreach (var workflow in WorkflowCases.ToList()) + { + if (workflow.IsError + && workflow.TriggerConfig?.TriggerType == "PluginEvent" + && workflow.TriggerConfig.PluginName == pluginName) + { + // Plugin came online → re-register trigger → clear error (green light) + try + { + var tm = KitX.Core.DI.ServiceHost.GetRequiredService(); + tm?.RegisterWorkflowTrigger(workflow.Id, workflow.TriggerConfig); + } + catch { /* non-critical */ } + + workflow.IsError = false; + workflow.ErrorMessage = null; + // IsRunning stays true (already was), light turns green + RefreshWorkflowInList(workflow); + } + } + }); + } + + /// + /// Handles plugin unregistration events — sets error state on running workflows + /// that depend on the disconnected plugin, and unregisters their triggers. + /// + private void OnPluginUnregistered(object? sender, EventArgs e) + { + if (e is not PluginUnregisteredEventArgs pa || pa.PluginInfo?.Name is null) return; + var pluginName = pa.PluginInfo.Name; + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + foreach (var workflow in WorkflowCases.ToList()) + { + if (workflow.IsRunning + && !workflow.IsError + && workflow.TriggerConfig?.TriggerType == "PluginEvent" + && workflow.TriggerConfig.PluginName == pluginName) + { + // Plugin went offline → unregister trigger → mark error (yellow light) + try + { + var tm = KitX.Core.DI.ServiceHost.GetRequiredService(); + tm?.UnregisterWorkflowTrigger(workflow.Id); + } + catch { /* non-critical */ } + + workflow.IsError = true; + workflow.ErrorMessage = $"Plugin '{pluginName}' disconnected"; + // IsRunning stays true → STOP button, yellow light + RefreshWorkflowInList(workflow); + } + } + }); + } + + internal int workflowCount = 0; + + internal double noWorkflow_TipHeight = 0; + + internal int WorkflowCount + { + get => workflowCount; + set => this.RaiseAndSetIfChanged(ref workflowCount, value); + } + + internal double NoWorkflow_TipHeight + { + get => noWorkflow_TipHeight; + set => this.RaiseAndSetIfChanged(ref noWorkflow_TipHeight, value); + } + + internal string WorkflowCountTip => + TranslateTextWithSuffix("Workflow", "Count") + ?.Replace("$count", WorkflowCount.ToString()) ?? "Language key not found"; + + internal static ObservableCollection WorkflowCases => UIStateService.WorkflowCases; + + internal ReactiveCommand? CreateWorkflowCommand { get; set; } + + internal ReactiveCommand? OpenWorkflowCommand { get; set; } + + internal ReactiveCommand? DeleteWorkflowCommand { get; set; } + + internal ReactiveCommand? RunWorkflowCommand { get; set; } + + internal ReactiveCommand? StopWorkflowCommand { get; set; } + + internal ReactiveCommand? RefreshWorkflowsCommand { get; set; } + + /// Clears the Debug activity log. Bound from the broom button in the log panel. + internal ReactiveCommand? ClearLogCommand { get; set; } + + /// Cycles the log panel height through 3 sizes (160/300/80). + internal ReactiveCommand? ToggleLogHeightCommand { get; set; } } diff --git a/KitX Dashboard/ViewModels/PluginDetailWindowViewModel.cs b/KitX Dashboard/ViewModels/PluginDetailWindowViewModel.cs index e9c8cfa7..ea46348c 100644 --- a/KitX Dashboard/ViewModels/PluginDetailWindowViewModel.cs +++ b/KitX Dashboard/ViewModels/PluginDetailWindowViewModel.cs @@ -1,11 +1,15 @@ -using System.Collections.ObjectModel; +using System; +using System.Collections.ObjectModel; using System.Reactive; using System.Text; using Avalonia; using Avalonia.Controls; using Avalonia.Media; using Avalonia.Styling; -using KitX.Dashboard.Services; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Event; +using KitX.Core.Event; +using KitX.Dashboard; using KitX.Shared.CSharp.Plugin; using ReactiveUI; @@ -13,8 +17,12 @@ namespace KitX.Dashboard.ViewModels; internal class PluginDetailWindowViewModel : ViewModelBase { + private readonly IConfigService _configService; + public PluginDetailWindowViewModel() { + _configService = ConfigService; + InitCommands(); InitEvents(); @@ -27,7 +35,8 @@ public sealed override void InitCommands() public sealed override void InitEvents() { - EventService.ThemeConfigChanged += () => this.RaisePropertyChanged(nameof(TintColor)); + var eventService = App.GetService(); + eventService.Subscribe(EventNames.ThemeConfigChanged, (s, e) => this.RaisePropertyChanged(nameof(TintColor))); } private PluginInfo? pluginDetail; @@ -42,13 +51,13 @@ internal PluginInfo? PluginDetail internal string? LastUpdateDate => PluginDetail?.LastUpdateDate.ToLocalTime().ToString("yyyy.MM.dd"); - internal static Color TintColor => - AppConfig.App.Theme switch + internal Color TintColor => + _configService.AppConfig.App.Theme switch { "Light" => Colors.WhiteSmoke, "Dark" => Colors.Black, "Follow" => Application.Current?.ActualThemeVariant == ThemeVariant.Light ? Colors.WhiteSmoke : Colors.Black, - _ => Color.Parse(AppConfig.App.ThemeColor), + _ => Color.Parse(_configService.AppConfig.App.ThemeColor), }; internal void InitFunctionsAndTags() @@ -85,6 +94,13 @@ internal void InitFunctionsAndTags() foreach (var tag in PluginDetail.Tags) Tags.Add($"{{ {tag.Key}: {tag.Value} }}"); + + // 展示插件支持的触发器 + if (PluginDetail.SupportedTriggers?.Count > 0) + { + foreach (var trigger in PluginDetail.SupportedTriggers) + Tags.Add($"{{ Trigger: {trigger} }}"); + } } internal ObservableCollection Functions { get; set; } = []; diff --git a/KitX Dashboard/ViewModels/PluginFunctionPaletteItem.cs b/KitX Dashboard/ViewModels/PluginFunctionPaletteItem.cs new file mode 100644 index 00000000..4343e281 --- /dev/null +++ b/KitX Dashboard/ViewModels/PluginFunctionPaletteItem.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using KitX.Shared.CSharp.Plugin; + +namespace KitX.Dashboard.ViewModels; + +/// +/// Represents a plugin function entry in the BlueprintEditor node palette. +/// Used as a bindable item in the "Plugin Functions" dynamic list. +/// +public class PluginFunctionPaletteItem +{ + public string PluginName { get; init; } = string.Empty; + public string FunctionName { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public List Parameters { get; init; } = []; + public string ReturnValueType { get; init; } = "void"; +} diff --git a/KitX Dashboard/ViewModels/PluginTriggerPaletteItem.cs b/KitX Dashboard/ViewModels/PluginTriggerPaletteItem.cs new file mode 100644 index 00000000..b3f186bc --- /dev/null +++ b/KitX Dashboard/ViewModels/PluginTriggerPaletteItem.cs @@ -0,0 +1,12 @@ +namespace KitX.Dashboard.ViewModels; + +/// +/// Represents a plugin trigger entry in the BlueprintEditor node palette. +/// Used as a bindable item in the "Plugin Triggers" dynamic list within the Entry group. +/// +public class PluginTriggerPaletteItem +{ + public string PluginName { get; init; } = string.Empty; + public string TriggerName { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; +} diff --git a/KitX Dashboard/ViewModels/PluginsLaunchWindowViewModel.cs b/KitX Dashboard/ViewModels/PluginsLaunchWindowViewModel.cs index 7ab6f50f..109585d1 100644 --- a/KitX Dashboard/ViewModels/PluginsLaunchWindowViewModel.cs +++ b/KitX Dashboard/ViewModels/PluginsLaunchWindowViewModel.cs @@ -5,8 +5,9 @@ using System.Text.Json; using Avalonia; using Common.BasicHelper.Utils.Extensions; -using KitX.Dashboard.Network.PluginsNetwork; -using KitX.Dashboard.Views; +using KitX.Core.Contract.Plugin; +using KitX.Core.Device; +using KitX.Dashboard.Services; using KitX.Shared.CSharp.Plugin; using KitX.Shared.CSharp.WebCommand; using ReactiveUI; @@ -98,7 +99,7 @@ public Function? SelectedFunction } } - public static ObservableCollection PluginInfos => ViewInstances.PluginInfos; + public static ObservableCollection PluginInfos => UIStateService.PluginInfos; private bool isSelectingPlugin = true; @@ -341,7 +342,8 @@ internal void SubmitSearchingText() if (SelectedPluginInfo is not null && SelectedFunction is not null && (HavingParameters == false)) { - var plugConnector = PluginsServer.Instance.FindConnector(SelectedPluginInfo); + var pluginServer = KitX.Core.DI.ServiceHost.GetRequiredService(); + var plugConnector = pluginServer.FindConnector(SelectedPluginInfo); if (plugConnector is not null) { diff --git a/KitX Dashboard/ViewModels/ViewModelBase.cs b/KitX Dashboard/ViewModels/ViewModelBase.cs index 05377434..bad4528b 100644 --- a/KitX Dashboard/ViewModels/ViewModelBase.cs +++ b/KitX Dashboard/ViewModels/ViewModelBase.cs @@ -1,15 +1,30 @@ -using Avalonia; +using System; +using Avalonia; using Avalonia.Controls; -using KitX.Dashboard.Configuration; -using KitX.Dashboard.Managers; -using KitX.Dashboard.Services; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Announcement; +using KitX.Core.Contract.Event; using ReactiveUI; +using KitX.Core.Event; +using KitX.Core.Configuration; namespace KitX.Dashboard.ViewModels; public abstract class ViewModelBase : ReactiveObject { - protected static string? Translate( + /// + /// Gets the config service from DI container + /// + protected static IConfigService ConfigService => + App.GetService(); + + /// + /// Gets the announcement service from DI container + /// + protected static IAnnouncementService AnnouncementService => + App.GetService(); + + public static string? Translate( string key = "", string prefix = "", string suffix = "", @@ -33,18 +48,12 @@ public abstract class ViewModelBase : ReactiveObject return null; } - protected static string? TranslateText(string key = "", Application? app = null) => Translate(key, "Text", separator: "_", app: app); + public static string? TranslateText(string key = "", Application? app = null) => Translate(key, "Text", separator: "_", app: app); - protected static string? TranslateTextWithSuffix(string key = "", string suffix = "", Application? app = null) => + public static string? TranslateTextWithSuffix(string key = "", string suffix = "", Application? app = null) => Translate(key, "Text", suffix, "_", app); - protected static void SaveAppConfigChanges() => EventService.Invoke(nameof(EventService.AppConfigChanged)); - public abstract void InitCommands(); public abstract void InitEvents(); - - internal static AppConfig AppConfig => ConfigManager.Instance.AppConfig; - - internal static AnnouncementConfig AnnouncementConfig => ConfigManager.Instance.AnnouncementConfig; } diff --git a/KitX Dashboard/ViewModels/WorkflowEditorViewModel.cs b/KitX Dashboard/ViewModels/WorkflowEditorViewModel.cs new file mode 100644 index 00000000..2d6bd457 --- /dev/null +++ b/KitX Dashboard/ViewModels/WorkflowEditorViewModel.cs @@ -0,0 +1,552 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Workflow; +using KitX.Core.Contract.Tasks; +using KitX.Core.Event; +using KitX.Core.Tasks; +using KitX.Dashboard; +using KitX.Dashboard.Services; +using KitX.Dashboard.ViewModels; +using Serilog; + +namespace KitX.Dashboard.ViewModels; + +/// +/// Unified workflow editor ViewModel that coordinates BS and BP editing modes. +/// Uses composition: holds references to existing ScriptVM and BlueprintVM. +/// +internal partial class WorkflowEditorViewModel : ObservableObject +{ + public enum EditorMode { BlockScript, Blueprint } + + private readonly IWorkflowStorageService _storageService; + private readonly IBlueprintService _blueprintService; + private readonly ITasksService _tasksService; + private readonly IEventService _eventService; + + private EditorMode _mode = EditorMode.BlockScript; + private string? _workflowId; + private string _workflowName = "Untitled Workflow"; + private string _workflowDescription = string.Empty; + private string _workflowAuthor = string.Empty; + private bool _isDirty; + private string _executionOutput = string.Empty; + private bool _isExecuting; + private bool _isDebugging; + private bool _isPaused; + + // ─── Trigger Configuration ────────────────────────────────────────── + private string _triggerType = "Manual"; + private string? _triggerPluginName; + private string? _triggerName; + + /// Available trigger types for ComboBox binding + public string[] TriggerTypeOptions { get; } = ["Manual", "PluginEvent"]; + + /// Available plugin names from connected plugins + public ObservableCollection AvailablePlugins { get; } = []; + + /// Available trigger names for the selected plugin + public ObservableCollection AvailableTriggers { get; } = []; + + /// Trigger type: "Manual" (default) or "PluginEvent" + public string TriggerType + { + get => _triggerType; + set + { + if (SetProperty(ref _triggerType, value)) + { + OnPropertyChanged(nameof(IsPluginEventTrigger)); + if (value == "PluginEvent") + RefreshAvailablePlugins(); + IsDirty = true; + } + } + } + + /// Whether the trigger type is PluginEvent + public bool IsPluginEventTrigger => _triggerType == "PluginEvent"; + + /// Plugin name for PluginEvent triggers + public string? TriggerPluginName + { + get => _triggerPluginName; + set + { + if (SetProperty(ref _triggerPluginName, value)) + { + RefreshAvailableTriggers(); + IsDirty = true; + } + } + } + + /// Trigger name for PluginEvent triggers + public string? TriggerName + { + get => _triggerName; + set { if (SetProperty(ref _triggerName, value)) IsDirty = true; } + } + + /// + /// Refreshes the list of available plugins from connected plugin server. + /// + private void RefreshAvailablePlugins() + { + AvailablePlugins.Clear(); + var pluginServer = KitX.Core.DI.ServiceHost.GetRequiredService(); + if (pluginServer == null) return; + + foreach (var conn in pluginServer.Connections) + { + if (!string.IsNullOrEmpty(conn.PluginInfo?.Name)) + AvailablePlugins.Add(conn.PluginInfo.Name); + } + } + + /// + /// Refreshes the list of available triggers for the selected plugin. + /// + private void RefreshAvailableTriggers() + { + AvailableTriggers.Clear(); + if (string.IsNullOrEmpty(_triggerPluginName)) return; + + var pluginServer = KitX.Core.DI.ServiceHost.GetRequiredService(); + if (pluginServer == null) return; + + var conn = pluginServer.Connections + .FirstOrDefault(c => c.PluginInfo?.Name == _triggerPluginName); + if (conn?.PluginInfo?.SupportedTriggers == null) return; + + foreach (var trigger in conn.PluginInfo.SupportedTriggers) + AvailableTriggers.Add(trigger); + } + + /// + /// The BS editor sub-ViewModel + /// + public WorkflowScriptEditorWindowViewModel ScriptVM { get; } + + /// + /// The BP editor sub-ViewModel + /// + public BlueprintEditorViewModel BlueprintVM { get; } + + public EditorMode Mode + { + get => _mode; + set + { + if (SetProperty(ref _mode, value)) + { + OnPropertyChanged(nameof(IsBlockScriptMode)); + OnPropertyChanged(nameof(IsBlueprintMode)); + } + } + } + + public bool IsBlockScriptMode => _mode == EditorMode.BlockScript; + public bool IsBlueprintMode => _mode == EditorMode.Blueprint; + + public string WorkflowName + { + get => _workflowName; + set => SetProperty(ref _workflowName, value); + } + + public string WorkflowDescription + { + get => _workflowDescription; + set => SetProperty(ref _workflowDescription, value); + } + + public string WorkflowAuthor + { + get => _workflowAuthor; + set => SetProperty(ref _workflowAuthor, value); + } + + public bool IsDirty + { + get => _isDirty; + set => SetProperty(ref _isDirty, value); + } + + public string ExecutionOutput + { + get => _executionOutput; + set => SetProperty(ref _executionOutput, value); + } + + public bool IsExecuting + { + get => _isExecuting; + set => SetProperty(ref _isExecuting, value); + } + + public bool IsDebugging + { + get => _isDebugging; + set => SetProperty(ref _isDebugging, value); + } + + public bool IsPaused + { + get => _isPaused; + set => SetProperty(ref _isPaused, value); + } + + public WorkflowEditorViewModel( + IWorkflowStorageService storageService, + IBlueprintService blueprintService, + ITasksService tasksService, + WorkflowScriptEditorWindowViewModel scriptVM, + BlueprintEditorViewModel blueprintVM) + { + _storageService = storageService; + _blueprintService = blueprintService; + _tasksService = tasksService; + _eventService = App.GetService(); + ScriptVM = scriptVM; + BlueprintVM = blueprintVM; + + // Forward execution output from sub-VMs + ScriptVM.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(ScriptVM.ExecutionResult)) + ExecutionOutput = ScriptVM.ExecutionResult; + if (e.PropertyName == nameof(ScriptVM.IsExecuting)) + IsExecuting = ScriptVM.IsExecuting; + }; + + BlueprintVM.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(BlueprintVM.ExecutionResult)) + ExecutionOutput = BlueprintVM.ExecutionResult; + if (e.PropertyName == nameof(BlueprintVM.IsExecuting)) + IsExecuting = BlueprintVM.IsExecuting; + if (e.PropertyName == nameof(BlueprintVM.IsDebugging)) + IsDebugging = BlueprintVM.IsDebugging; + if (e.PropertyName == nameof(BlueprintVM.IsPaused)) + IsPaused = BlueprintVM.IsPaused; + }; + + // Publish metadata changes immediately so management panel syncs + PropertyChanged += (s, e) => + { + if (e.PropertyName is nameof(WorkflowName) or nameof(WorkflowDescription) or nameof(WorkflowAuthor)) + { + IsDirty = true; + _eventService.Publish(EventNames.WorkflowDataSaved, + new WorkflowSavedEventArgs( + _workflowId ?? string.Empty, + WorkflowName, + WorkflowDescription, + WorkflowAuthor)); + } + }; + } + + /// + /// Loads a workflow from storage by ID + /// + public async Task LoadWorkflowAsync(string workflowId) + { + _workflowId = workflowId; + + var data = await _storageService.LoadWorkflowDataAsync(workflowId); + if (data == null) + { + Log.Warning("[WorkflowEditorVM] No workflow data found for ID: {Id}", workflowId); + return; + } + + WorkflowName = data.Name; + WorkflowDescription = data.Description ?? string.Empty; + WorkflowAuthor = data.Author ?? string.Empty; + + // Load into BS editor + ScriptVM.UseBlockMode = data.UseBlockMode; + ScriptVM.MainProgramCode = data.UseBlockMode + ? (data.BlockScriptSource ?? string.Empty) + : data.MainProgram; + + ScriptVM.HelperFunctions.Clear(); + foreach (var func in data.HelperFunctions) + ScriptVM.HelperFunctions.Add(func); + + // Mirror helpers into the Blueprint palette so BP mode's Helper Functions + // panel is populated (replaces the legacy bridge plumbing). + SyncBlueprintHelperFunctions(); + + // Load into BP editor if blueprint data exists + if (data.BlueprintData != null) + { + BlueprintVM.CurrentBlueprint = data.BlueprintData; + } + + // Load trigger configuration + if (data.TriggerConfig != null) + { + TriggerType = data.TriggerConfig.TriggerType ?? "Manual"; + TriggerPluginName = data.TriggerConfig.PluginName; + TriggerName = data.TriggerConfig.TriggerName; + } + } + + /// + /// + /// Saves the current workflow to storage. + /// If in BP mode, first exports BP→BS so the BlockScript source stays in sync + /// (runtime executor only reads BlockScript). + /// + public async Task SaveAsync() + { + if (_workflowId == null) return; + + // If in BP mode, sync BP→BS so runtime executor has up-to-date BlockScript + if (IsBlueprintMode && BlueprintVM.Nodes.Count > 0) + { + try + { + var blueprint = BlueprintVM.ExportDrawingToBlueprint(); + + // Handle trigger node → Entry replacement for conversion (same as SwitchToBlockScriptAsync) + var triggerNode = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.PluginTrigger); + if (triggerNode is PluginTriggerNode ptNode) + { + var entryReplacement = new EntryNode + { + Id = ptNode.Id, + X = ptNode.X, + Y = ptNode.Y + }; + if (ptNode.OutputPins.Count > 0 && entryReplacement.OutputPins.Count > 0) + entryReplacement.OutputPins[0].Id = ptNode.OutputPins[0].Id; + var idx = blueprint.Nodes.IndexOf(ptNode); + blueprint.Nodes[idx] = entryReplacement; + } + + var sourceCode = _blueprintService.ExportToBlockScript(blueprint); + ScriptVM.MainProgramCode = sourceCode; + ScriptVM.UseBlockMode = true; + } + catch (Exception ex) + { + Log.Error(ex, "[WorkflowEditorVM] BP→BS sync during save failed"); + } + } + + var data = new KcsFileFormat + { + Id = _workflowId, + Name = WorkflowName, + Description = WorkflowDescription, + Author = WorkflowAuthor, + UseBlockMode = ScriptVM.UseBlockMode, + BlockScriptSource = ScriptVM.UseBlockMode ? ScriptVM.MainProgramCode : null, + MainProgram = ScriptVM.UseBlockMode ? string.Empty : (ScriptVM.MainProgramCode ?? string.Empty), + HelperFunctions = new System.Collections.Generic.List(ScriptVM.HelperFunctions), + VariableConstants = ScriptVM.UseBlockMode + ? new System.Collections.Generic.Dictionary() + : new System.Collections.Generic.Dictionary(), + BlueprintData = BlueprintVM.CurrentBlueprint, + TriggerConfig = new TriggerConfig + { + TriggerType = TriggerType, + PluginName = TriggerPluginName, + TriggerName = TriggerName + }, + }; + + await _storageService.SaveWorkflowDataAsync(_workflowId, data); + IsDirty = false; + + // Notify management panel of metadata changes + _eventService.Publish(EventNames.WorkflowDataSaved, + new WorkflowSavedEventArgs(_workflowId, WorkflowName, WorkflowDescription, WorkflowAuthor)); + } + + /// + /// Switches to Blueprint mode by converting BS→BP + /// + [RelayCommand] + private async Task SwitchToBlueprintAsync() + { + if (Mode == EditorMode.Blueprint) return; + + // Save current BS state first + await SaveAsync(); + + // Convert BS → BP + var sourceCode = ScriptVM.MainProgramCode ?? string.Empty; + var helpers = new System.Collections.Generic.List(ScriptVM.HelperFunctions); + + // Refresh the BP palette with the latest helpers before switching mode. + SyncBlueprintHelperFunctions(); + + if (!string.IsNullOrWhiteSpace(sourceCode)) + { + try + { + var blueprint = _blueprintService.ImportFromBlockScript(sourceCode, helpers); + if (blueprint != null) + { + // BS → BP trigger conversion: replace Entry with PluginTriggerNode + if (TriggerType == "PluginEvent" && !string.IsNullOrEmpty(TriggerPluginName)) + { + var entryNode = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.Entry); + if (entryNode != null) + { + var triggerNode = new PluginTriggerNode + { + Id = entryNode.Id, + X = entryNode.X, + Y = entryNode.Y, + PluginName = TriggerPluginName ?? string.Empty, + TriggerName = TriggerName ?? string.Empty + }; + // Constructor already calls InitializePinsFromDescriptor() + + // Preserve output pin IDs to maintain connections + if (entryNode.OutputPins.Count > 0 && triggerNode.OutputPins.Count > 0) + triggerNode.OutputPins[0].Id = entryNode.OutputPins[0].Id; + + var idx = blueprint.Nodes.IndexOf(entryNode); + blueprint.Nodes[idx] = triggerNode; + } + } + + BlueprintVM.CurrentBlueprint = blueprint; + BlueprintVM.LoadBlueprintIntoDrawing(blueprint); + } + } + catch (Exception ex) + { + Log.Error(ex, "[WorkflowEditorVM] BS→BP conversion failed"); + ExecutionOutput = $"Conversion error: {ex.Message}"; + return; + } + } + + Mode = EditorMode.Blueprint; + } + + /// + /// Mirrors the BS-mode helper function list into the Blueprint editor's palette. + /// Replaces the legacy SetBridge → RefreshHelperFunctions path: the unified editor + /// holds both sub-VMs directly, so we copy from ScriptVM whenever helpers change. + /// + private void SyncBlueprintHelperFunctions() + { + BlueprintVM.HelperFunctions.Clear(); + foreach (var helper in ScriptVM.HelperFunctions) + { + BlueprintVM.HelperFunctions.Add(new HelperFunctionPaletteItem + { + FunctionName = helper.Name, + DisplayName = helper.Name, + Parameters = helper.Parameters ?? [], + ReturnType = helper.ReturnType ?? "object" + }); + } + } + + /// + /// Switches to BlockScript mode by converting BP→BS + /// + [RelayCommand] + private async Task SwitchToBlockScriptAsync() + { + if (Mode == EditorMode.BlockScript) return; + + // Convert BP → BS + if (BlueprintVM.Nodes.Count > 0) + { + try + { + var blueprint = BlueprintVM.ExportDrawingToBlueprint(); + + // BP → BS trigger conversion: extract trigger info from PluginTriggerNode + var triggerNode = blueprint.Nodes.FirstOrDefault(n => n.NodeType == BlueprintNodeType.PluginTrigger); + if (triggerNode is PluginTriggerNode ptNode) + { + TriggerType = "PluginEvent"; + TriggerPluginName = ptNode.PluginName; + TriggerName = ptNode.TriggerName; + + // Replace PluginTriggerNode with EntryNode for BS conversion compatibility + var entryReplacement = new EntryNode + { + Id = ptNode.Id, + X = ptNode.X, + Y = ptNode.Y + }; + // Constructor already calls InitializePinsFromDescriptor() + + // Preserve output pin IDs to maintain connections + if (ptNode.OutputPins.Count > 0 && entryReplacement.OutputPins.Count > 0) + entryReplacement.OutputPins[0].Id = ptNode.OutputPins[0].Id; + + var idx = blueprint.Nodes.IndexOf(ptNode); + blueprint.Nodes[idx] = entryReplacement; + } + else + { + // No PluginTriggerNode → reset to Manual + TriggerType = "Manual"; + TriggerPluginName = null; + TriggerName = null; + } + + var sourceCode = _blueprintService.ExportToBlockScript(blueprint); + + ScriptVM.MainProgramCode = sourceCode; + ScriptVM.UseBlockMode = true; + } + catch (Exception ex) + { + Log.Error(ex, "[WorkflowEditorVM] BP→BS conversion failed"); + ExecutionOutput = $"Conversion error: {ex.Message}"; + return; + } + } + + await SaveAsync(); + Mode = EditorMode.BlockScript; + } + + /// + /// Shows the Dashboard main window + /// + [RelayCommand] + private void ShowDashboard() + { + Services.UIStateService.MainWindow?.Activate(); + } + + [RelayCommand] + private void DebugRun() => BlueprintVM.RunWithDebugCommand.Execute(null); + + [RelayCommand] + private void DebugPause() => BlueprintVM.DebugPauseCommand.Execute(null); + + [RelayCommand] + private void DebugStep() => BlueprintVM.DebugStepCommand.Execute(null); + + [RelayCommand] + private void DebugContinue() => BlueprintVM.DebugContinueCommand.Execute(null); + + /// + /// Clears the shared execution output panel. + /// + [RelayCommand] + private void ClearOutput() => ExecutionOutput = string.Empty; +} diff --git a/KitX Dashboard/ViewModels/WorkflowScriptEditorWindowViewModel.cs b/KitX Dashboard/ViewModels/WorkflowScriptEditorWindowViewModel.cs new file mode 100644 index 00000000..1880c91e --- /dev/null +++ b/KitX Dashboard/ViewModels/WorkflowScriptEditorWindowViewModel.cs @@ -0,0 +1,454 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using AvaloniaEdit.Document; +using ReactiveUI; +using KitX.Core.Contract.Tasks; +using KitX.Core.Contract.Workflow; +using KitX.Core.Tasks; +using KitX.Dashboard.Services; +using KitX.Shared.CSharp.Plugin; +using Serilog; + +namespace KitX.Dashboard.ViewModels; + +internal class WorkflowScriptEditorWindowViewModel : ViewModelBase +{ + private CancellationTokenSource? _cancellationTokenSource; + + private readonly IDisposable _codeDocumentSubscription; + + private readonly IBlockScriptService _blockScriptService; + private readonly IWorkflowPluginService _workflowPluginService; + + private readonly ITasksService _tasksService; + + /// + /// 构造函数,通过DI注入 + /// + /// 通过DI注入的BlockScript服务 + /// 通过DI注入的Workflow插件服务 + /// 通过DI注入的脚本执行服务 + /// 通过DI注入的KCS文件服务 + /// 通过DI注入的主程序分析器 + /// 通过DI注入的任务服务 + public WorkflowScriptEditorWindowViewModel( + IBlockScriptService blockScriptService, + IWorkflowPluginService workflowPluginService, + ITasksService tasksService) + { + _blockScriptService = blockScriptService; + _workflowPluginService = workflowPluginService; + _tasksService = tasksService; + + InitCommands(); + InitEvents(); + + // 如果是块脚本模式,初始化默认的 HelperFuncCompare 辅助函数 + if (UseBlockMode) + { + InitializeDefaultHelperFunctions(); + } + + // 订阅CodeDocument属性变化 + _codeDocumentSubscription = this.WhenAnyValue(x => x.CodeDocument) + .Subscribe(document => + { + if (document != null) + { + // 根据模式选择不同的常量解析方式 + var constants = UseBlockMode + ? _blockScriptService.ParseConstantsFromBlockScript(document.Text) + : _workflowPluginService.ParseConstantsFromCode(document.Text); + UpdateVariableConstants(constants); + } + }); + } + + /// + /// 初始化默认的辅助函数(块脚本模式) + /// + private void InitializeDefaultHelperFunctions() + { + // 添加 HelperFuncCompare - 比较两个数,返回 bool + // op: "BEQ"(==), "BNE"(!=), "BLT"(<), "BGT"(>), "BLE"(<=), "BGE"(>=) + var helperFuncCompare = new HelperFunction + { + Name = "HelperFuncCompare", + ReturnType = "bool", + Parameters = new List + { + new() { Name = "op", Type = "string" }, + new() { Name = "value1", Type = "object?" }, + new() { Name = "value2", Type = "object?" } + }, + Code = @"var v1 = Convert.ToDouble(value1); +var v2 = Convert.ToDouble(value2); +return op switch +{ + ""BEQ"" => v1 == v2, + ""BNE"" => v1 != v2, + ""BLT"" => v1 < v2, + ""BGT"" => v1 > v2, + ""BLE"" => v1 <= v2, + ""BGE"" => v1 >= v2, + _ => false +};" + }; + HelperFunctions.Add(helperFuncCompare); + + // 添加 HelperFuncAdd - 将两个数相加 + var helperFuncAdd = new HelperFunction + { + Name = "HelperFuncAdd", + ReturnType = "int", + Parameters = new List + { + new() { Name = "value1", Type = "object?" }, + new() { Name = "value2", Type = "object?" } + }, + Code = @"var v1 = Convert.ToInt32(value1); +var v2 = Convert.ToInt32(value2); +return v1 + v2;" + }; + HelperFunctions.Add(helperFuncAdd); + } + + public sealed override void InitCommands() + { + CancelExecutionCommand = ReactiveCommand.Create(() => _cancellationTokenSource?.Cancel()); + + // 添加辅助函数命令 + AddHelperFunctionCommand = ReactiveCommand.Create(() => + { + var newFunction = new HelperFunction + { + Name = $"HelperFunction{HelperFunctions.Count + 1}", + ReturnType = "object", + Parameters = new List(), + Code = "// Helper function body\nreturn null;" + }; + HelperFunctions.Add(newFunction); + SelectedHelperFunction = newFunction; + }); + + // 删除辅助函数命令 + RemoveHelperFunctionCommand = ReactiveCommand.Create((helperFunction) => + { + if (helperFunction != null) + { + HelperFunctions.Remove(helperFunction); + if (SelectedHelperFunction == helperFunction) + { + SelectedHelperFunction = HelperFunctions.FirstOrDefault(); + } + } + }); + + // 恢复常量默认值命令 + ResetConstantCommand = ReactiveCommand.Create((constant) => + { + if (constant != null) + { + constant.UserValue = constant.DefaultValue; + // 触发UI更新 + this.RaisePropertyChanged(nameof(VariableConstants)); + } + }); + + // 恢复所有常量命令 + ResetAllConstantsCommand = ReactiveCommand.Create(() => + { + foreach (var constant in VariableConstants) + { + constant.UserValue = constant.DefaultValue; + } + this.RaisePropertyChanged(nameof(VariableConstants)); + }); + } + + public sealed override void InitEvents() { } + + /// + /// 解析代码中的常量(自动根据当前模式选择解析方式) + /// + /// 代码内容 + public void ParseConstantsFromCode(string code) + { + var constants = UseBlockMode + ? _blockScriptService.ParseConstantsFromBlockScript(code) + : _workflowPluginService.ParseConstantsFromCode(code); + UpdateVariableConstants(constants); + } + + /// + /// 更新可变常量列表 + /// + private void UpdateVariableConstants(List newConstants) + { + // 保留用户已修改的值 + foreach (var newConstant in newConstants) + { + var existing = VariableConstants.FirstOrDefault(c => c.Name == newConstant.Name); + if (existing != null) + { + newConstant.UserValue = existing.UserValue; + } + } + + // 更新列表 + VariableConstants.Clear(); + foreach (var constant in newConstants) + { + VariableConstants.Add(constant); + } + } + + /// + /// Builds a dictionary of user-edited constant values that differ from defaults. + /// Used to sync UI edits into the execution pipeline. + /// + private Dictionary? GetUserConstantOverrides() + { + if (VariableConstants.Count == 0) return null; + + var overrides = new Dictionary(); + foreach (var constant in VariableConstants) + { + // Only include values that the user has changed + if (!object.Equals(constant.UserValue, constant.DefaultValue)) + { + overrides[constant.Name] = constant.UserValue; + } + } + + return overrides.Count > 0 ? overrides : null; + } + + /// + /// 获取值的类型名称 + /// + private string GetTypeName(object? value) + { + return value switch + { + null => "object", + int => "int", + double => "double", + float => "float", + bool => "bool", + string => "string", + _ => value.GetType().Name + }; + } + + /// + /// 提交代码执行 + /// + internal void SubmitCodes(IDocument doc) + { + // 首先在UI线程获取代码文本,避免跨线程访问 + string codeText; + try + { + codeText = doc.Text; + } + catch (InvalidOperationException) + { + ExecutionResult = "Error: Cannot access document from background thread."; + return; + } + + // 非Block模式:不做语法限制,直接执行 + if (UseBlockMode) + { + // BlockScript模式:验证块脚本 + var validationResult = _blockScriptService.ValidateBlockScript(codeText); + if (!validationResult.IsValid) + { + Log.Error("[WorkflowScriptEditorWindowViewModel] Block script validation failed: {Errors}", string.Join("; ", validationResult.Errors)); + ExecutionResult = $"Block script validation failed: {string.Join("; ", validationResult.Errors)}"; + return; + } + } + + IsExecuting = true; + + // 获取已连接的插件列表 + var connectedPlugins = UIStateService.PluginInfos?.ToList() ?? new List(); + + var tokenSource = new CancellationTokenSource(); + + _cancellationTokenSource = tokenSource; + + _tasksService.RunTaskAsync( + async () => + { + string? result; + + var constantOverrides = GetUserConstantOverrides(); + var executionResult = await _blockScriptService.ExecuteBlockScriptAsync( + codeText, + HelperFunctions.ToList(), + constantOverrides, + tokenSource.Token + ); + + if (executionResult.IsSuccess) + { + var output = string.Join("\n", executionResult.Output); + result = $"Blocks executed: {executionResult.ExecutedBlockCount}\nExecution time: {executionResult.ExecutionTimeMs}ms\nOutput:\n{output}"; + } + else + { + result = $"Error: {executionResult.ErrorMessage}"; + } + + tokenSource.Dispose(); + + _cancellationTokenSource = null; + + Dispatcher.UIThread.Invoke(() => + { + ExecutionResult = result ?? string.Empty; + + IsExecuting = false; + }); + }, + tokenSource.Token, + nameof(SubmitCodes) + ); + } + + internal void CancelExecution() + { + _cancellationTokenSource?.Cancel(); + } + + #region Properties + + private string _executionResult = string.Empty; + + public string ExecutionResult + { + get => _executionResult; + set => this.RaiseAndSetIfChanged(ref _executionResult, value); + } + + private bool _isExecuting; + + public bool IsExecuting + { + get => _isExecuting; + set => this.RaiseAndSetIfChanged(ref _isExecuting, value); + } + + internal IDocument? CodeDocument { get; set; } + + /// + /// 主程序代码 + /// + private string? _mainProgramCode; + + public string? MainProgramCode + { + get => _mainProgramCode; + set => this.RaiseAndSetIfChanged(ref _mainProgramCode, value); + } + + /// + /// 辅助函数文档 + /// + internal IDocument? HelperFunctionDocument { get; set; } + + /// + /// 辅助函数列表 + /// + public ObservableCollection HelperFunctions { get; set; } = []; + + /// + /// 当前选中的辅助函数 + /// + private HelperFunction? _selectedHelperFunction; + + public HelperFunction? SelectedHelperFunction + { + get => _selectedHelperFunction; + set + { + this.RaiseAndSetIfChanged(ref _selectedHelperFunction, value); + // 通知UI更新辅助函数代码编辑器 + this.RaisePropertyChanged(nameof(HelperFunctionDocument)); + // 通知UI更新是否正在编辑辅助函数 + this.RaisePropertyChanged(nameof(IsEditingHelperFunction)); + // 更新参数列表 + Parameters.Clear(); + if (value?.Parameters != null) + { + foreach (var param in value.Parameters) + { + Parameters.Add(param); + } + } + } + } + + /// + /// 是否正在编辑辅助函数 + /// + public bool IsEditingHelperFunction => _selectedHelperFunction != null; + + /// + /// 当前选中辅助函数的参数列表(用于UI绑定) + /// + public ObservableCollection Parameters { get; set; } = []; + + /// + /// 可变常量列表 + /// + public ObservableCollection VariableConstants { get; set; } = []; + + /// + /// 是否使用块脚本模式 + /// + private bool _useBlockMode = true; // 默认开启块脚本模式 + + public bool UseBlockMode + { + get => _useBlockMode; + set => this.RaiseAndSetIfChanged(ref _useBlockMode, value); + } + + #endregion + + #region Commands + + internal ReactiveCommand? CancelExecutionCommand { get; set; } + + /// + /// 添加辅助函数命令 + /// + internal ReactiveCommand? AddHelperFunctionCommand { get; set; } + + /// + /// 删除辅助函数命令 + /// + internal ReactiveCommand? RemoveHelperFunctionCommand { get; set; } + + /// + /// 恢复常量默认值命令 + /// + internal ReactiveCommand? ResetConstantCommand { get; set; } + + /// + /// 恢复所有常量命令 + /// + internal ReactiveCommand? ResetAllConstantsCommand { get; set; } + + #endregion +} diff --git a/KitX Dashboard/Views/AnnouncementsWindow.axaml.cs b/KitX Dashboard/Views/AnnouncementsWindow.axaml.cs index 2cd40dc7..4eebbcb3 100644 --- a/KitX Dashboard/Views/AnnouncementsWindow.axaml.cs +++ b/KitX Dashboard/Views/AnnouncementsWindow.axaml.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; using Avalonia.Controls; -using Common.BasicHelper.Graphics.Screen; -using KitX.Dashboard.Configuration; +using KitX.Core.Contract.Configuration; using KitX.Dashboard.Converters; -using KitX.Dashboard.Managers; using KitX.Dashboard.Utils; using KitX.Dashboard.ViewModels; @@ -14,7 +12,7 @@ public partial class AnnouncementsWindow : Window, IView { private readonly AnnouncementsWindowViewModel _viewModel = new(); - private static AppConfig AppConfig => ConfigManager.Instance.AppConfig; + private static IAppConfig AppConfig => App.GetService().AppConfig; public AnnouncementsWindow() { @@ -44,13 +42,13 @@ protected override void OnOpened(EventArgs e) SizeChanged += (_, _) => { - if (WindowState != WindowState.Maximized) + if (WindowState != Avalonia.Controls.WindowState.Maximized) config.Size = new(Width, Height); }; PositionChanged += (_, _) => { - if (WindowState == WindowState.Normal) + if (WindowState == Avalonia.Controls.WindowState.Normal) config.Location = new(left: Position.X, top: Position.Y); }; diff --git a/KitX Dashboard/Views/DebugWindow.axaml.cs b/KitX Dashboard/Views/DebugWindow.axaml.cs index b1038137..fb40e53c 100644 --- a/KitX Dashboard/Views/DebugWindow.axaml.cs +++ b/KitX Dashboard/Views/DebugWindow.axaml.cs @@ -1,8 +1,11 @@ -using Avalonia.Controls; +using System; +using Avalonia.Controls; using Avalonia.Styling; using AvaloniaEdit; using AvaloniaEdit.TextMate; -using KitX.Dashboard.Services; +using KitX.Core.Contract.Event; +using KitX.Core.Event; +using KitX.Dashboard; using KitX.Dashboard.ViewModels; using TextMateSharp.Grammars; @@ -10,7 +13,7 @@ namespace KitX.Dashboard.Views; public partial class DebugWindow : Window, IView { - private readonly DebugWindowViewModel viewModel = new(); + private readonly DebugWindowViewModel viewModel = App.GetService(); public DebugWindow() { @@ -25,7 +28,8 @@ private void Initialize() { InitializeEditor(); - EventService.ThemeConfigChanged += InitializeEditor; + var eventService = App.GetService(); + eventService.Subscribe(EventNames.ThemeConfigChanged, (s, e) => InitializeEditor()); } private void InitializeEditor() diff --git a/KitX Dashboard/Views/ExchangeDeviceKeyWindow.axaml.cs b/KitX Dashboard/Views/ExchangeDeviceKeyWindow.axaml.cs index 8b3bcb36..654657df 100644 --- a/KitX Dashboard/Views/ExchangeDeviceKeyWindow.axaml.cs +++ b/KitX Dashboard/Views/ExchangeDeviceKeyWindow.axaml.cs @@ -4,8 +4,11 @@ using System.Timers; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Input.Platform; using Avalonia.Threading; -using KitX.Dashboard.Services; +using KitX.Core.Contract.Event; +using KitX.Core.Event; +using KitX.Dashboard; using KitX.Dashboard.ViewModels; using MsBox.Avalonia; @@ -25,7 +28,8 @@ public ExchangeDeviceKeyWindow() DataContext = viewModel; - EventService.OnExiting += Close; + var eventService = App.GetService(); + eventService.Subscribe(EventNames.OnExiting, (s, e) => Close()); } public ExchangeDeviceKeyWindow OnVerificationCodeEntered(Action action) @@ -63,15 +67,16 @@ public ExchangeDeviceKeyWindow DisplayVerificationCode(string code) viewModel.VerificationCodeString = code; - EventService.OnAcceptingDeviceKey += keyCode => + var eventService = App.GetService(); + eventService.Subscribe(EventNames.OnAcceptingDeviceKey, (s, e) => { - if (code.Equals(keyCode)) + if (code.Equals(e.Key)) { ConstantTable.ExchangeDeviceKeyCode = null; Dispatcher.UIThread.Post(Close); } - }; + }); waittingAcceptingDeviceKeyTimer = new() { Interval = 60 * 1000, AutoReset = false }; @@ -167,7 +172,7 @@ protected override async void OnKeyDown(KeyEventArgs e) if (clipboard is null) return; - var text = await clipboard.GetTextAsync(); + var text = await clipboard.TryGetTextAsync(); var regex = @"[1-9]{8}"; diff --git a/KitX Dashboard/Views/IView.cs b/KitX Dashboard/Views/IView.cs index f671033d..7e744394 100644 --- a/KitX Dashboard/Views/IView.cs +++ b/KitX Dashboard/Views/IView.cs @@ -1,4 +1,7 @@ -using KitX.Dashboard.Services; +using System; +using KitX.Core.Contract.Event; +using KitX.Core.Event; +using KitX.Dashboard; namespace KitX.Dashboard.Views; @@ -6,6 +9,7 @@ internal interface IView { internal static void SaveAppConfigChanges() { - EventService.Invoke(nameof(EventService.AppConfigChanged)); + var eventService = App.GetService(); + eventService.Publish(EventNames.AppConfigChanged, EventArgs.Empty); } } diff --git a/KitX Dashboard/Views/MainWindow.axaml.cs b/KitX Dashboard/Views/MainWindow.axaml.cs index 52e1699a..2951f965 100644 --- a/KitX Dashboard/Views/MainWindow.axaml.cs +++ b/KitX Dashboard/Views/MainWindow.axaml.cs @@ -1,13 +1,15 @@ -using System; +using System; using System.Timers; using Avalonia; using Avalonia.Controls; using Avalonia.Threading; +using Common.BasicHelper.Core.TaskSystem; using FluentAvalonia.UI.Controls; -using KitX.Dashboard.Configuration; +using KitX.Core.Contract.Configuration; +using KitX.Core.Contract.Event; +using KitX.Core.Event; using KitX.Dashboard.Converters; using KitX.Dashboard.Generators; -using KitX.Dashboard.Managers; using KitX.Dashboard.Names; using KitX.Dashboard.Services; using KitX.Dashboard.Utils; @@ -19,8 +21,9 @@ namespace KitX.Dashboard.Views; public partial class MainWindow : Window, IView { private readonly MainWindowViewModel viewModel = new(); + private readonly SignalTasksManager _signalTasksManager; - private static AppConfig AppConfig => ConfigManager.Instance.AppConfig; + private static IAppConfig AppConfig => App.GetService().AppConfig; public MainWindow() { @@ -28,11 +31,13 @@ public MainWindow() InitializeComponent(); - ViewInstances.MainWindow = this; + UIStateService.MainWindow = this; DataContext = viewModel; - Instances.SignalTasksManager?.SignalRun( + _signalTasksManager = App.GetService(); + + _signalTasksManager.SignalRun( nameof(SignalsNames.MainWindowOpenedSignal), () => { @@ -46,13 +51,13 @@ public MainWindow() try { - Instances.SignalTasksManager.SignalRun( + _signalTasksManager.SignalRun( nameof(SignalsNames.MainWindowOpenedSignal), - () => WindowState = config.WindowState + () => WindowState = config.WindowState.ToAvalonia() ); if (config.IsHidden) - Instances.SignalTasksManager.SignalRun(nameof(SignalsNames.MainWindowOpenedSignal), Hide); + _signalTasksManager.SignalRun(nameof(SignalsNames.MainWindowOpenedSignal), Hide); } catch (Exception e) { @@ -61,32 +66,23 @@ public MainWindow() SizeChanged += (_, _) => { - if (WindowState == WindowState.Maximized) + if (WindowState == Avalonia.Controls.WindowState.Maximized) return; config.Size.Width = ClientSize.Width; config.Size.Height = ClientSize.Height; }; - //ClientSizeProperty.Changed.Subscribe(_ => - //{ - // if (WindowState == WindowState.Maximized) - // return; - - // config.Size.Width = ClientSize.Width; - // config.Size.Height = ClientSize.Height; - //}); - PositionChanged += (_, _) => { - if (WindowState != WindowState.Normal) + if (WindowState != Avalonia.Controls.WindowState.Normal) return; config.Location.Left = Position.X; config.Location.Top = Position.Y; }; - if (WindowState != WindowState.Normal) + if (WindowState != Avalonia.Controls.WindowState.Normal) return; ClientSize = new(config.Size.Width!.Value, config.Size.Height!.Value); @@ -104,9 +100,10 @@ private void InitMainWindow() UpdateGreetingText(); - EventService.LanguageChanged += UpdateGreetingText; + var eventService = App.GetService(); + eventService.Subscribe(EventNames.LanguageChanged, (s, e) => UpdateGreetingText()); - EventService.GreetingTextIntervalUpdated += UpdateGreetingText; + eventService.Subscribe(EventNames.GreetingTextIntervalUpdated, (s, e) => UpdateGreetingText()); var timer = new Timer() { AutoReset = true, Interval = 1000 * 60 * AppConfig.Windows.MainWindow.GreetingUpdateInterval }; @@ -114,7 +111,7 @@ private void InitMainWindow() timer.Start(); - Instances.SignalTasksManager?.RaiseSignal(nameof(SignalsNames.MainWindowInitSignal)); + _signalTasksManager.RaiseSignal(nameof(SignalsNames.MainWindowInitSignal)); } internal void UpdateGreetingText() @@ -201,7 +198,7 @@ protected override void OnOpened(EventArgs e) { base.OnOpened(e); - Instances.SignalTasksManager?.RaiseSignal(nameof(SignalsNames.MainWindowOpenedSignal)); + _signalTasksManager.RaiseSignal(nameof(SignalsNames.MainWindowOpenedSignal)); } protected override void OnClosing(WindowClosingEventArgs e) diff --git a/KitX Dashboard/Views/Maintain/DebugOptionsWindow.axaml.cs b/KitX Dashboard/Views/Maintain/DebugOptionsWindow.axaml.cs index 8f56b65d..59df0084 100644 --- a/KitX Dashboard/Views/Maintain/DebugOptionsWindow.axaml.cs +++ b/KitX Dashboard/Views/Maintain/DebugOptionsWindow.axaml.cs @@ -1,6 +1,4 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Markup.Xaml; +using Avalonia.Controls; using KitX.Dashboard.ViewModels.Maintain; namespace KitX.Dashboard; diff --git a/KitX Dashboard/Views/Pages/Controls/PluginBar.axaml.cs b/KitX Dashboard/Views/Pages/Controls/PluginBar.axaml.cs index 3d9fb637..ab342593 100644 --- a/KitX Dashboard/Views/Pages/Controls/PluginBar.axaml.cs +++ b/KitX Dashboard/Views/Pages/Controls/PluginBar.axaml.cs @@ -1,6 +1,6 @@ using System.Collections.ObjectModel; using Avalonia.Controls; -using KitX.Dashboard.Models; +using KitX.Core.Plugin; using KitX.Dashboard.ViewModels.Pages.Controls; namespace KitX.Dashboard.Views.Pages.Controls; diff --git a/KitX Dashboard/Views/Pages/Controls/Settings_General.axaml.cs b/KitX Dashboard/Views/Pages/Controls/Settings_General.axaml.cs index 2d01f900..b1aea8f7 100644 --- a/KitX Dashboard/Views/Pages/Controls/Settings_General.axaml.cs +++ b/KitX Dashboard/Views/Pages/Controls/Settings_General.axaml.cs @@ -5,7 +5,7 @@ namespace KitX.Dashboard.Views.Pages.Controls; public partial class Settings_General : UserControl { - private readonly Settings_GeneralViewModel viewModel = new(); + private readonly Settings_GeneralViewModel viewModel = App.GetService(); public Settings_General() { diff --git a/KitX Dashboard/Views/Pages/Controls/Settings_Performence.axaml.cs b/KitX Dashboard/Views/Pages/Controls/Settings_Performence.axaml.cs index 4a06e9b0..c8c3381d 100644 --- a/KitX Dashboard/Views/Pages/Controls/Settings_Performence.axaml.cs +++ b/KitX Dashboard/Views/Pages/Controls/Settings_Performence.axaml.cs @@ -5,7 +5,7 @@ namespace KitX.Dashboard.Views.Pages.Controls; public partial class Settings_Performence : UserControl { - private readonly Settings_PerformenceViewModel viewModel = new(); + private readonly Settings_PerformenceViewModel viewModel = App.GetService(); public Settings_Performence() { diff --git a/KitX Dashboard/Views/Pages/Controls/Settings_Personalise.axaml b/KitX Dashboard/Views/Pages/Controls/Settings_Personalise.axaml index 165f0418..dcb69fd7 100644 --- a/KitX Dashboard/Views/Pages/Controls/Settings_Personalise.axaml +++ b/KitX Dashboard/Views/Pages/Controls/Settings_Personalise.axaml @@ -84,16 +84,8 @@ - + ConfigManager.Instance.AppConfig.Pages.Home.SelectedViewName; + get => App.GetService().AppConfig.Pages.Home.SelectedViewName; set { - ConfigManager.Instance.AppConfig.Pages.Home.SelectedViewName = value; + App.GetService().AppConfig.Pages.Home.SelectedViewName = value; IView.SaveAppConfigChanges(); } diff --git a/KitX Dashboard/Views/Pages/RepoPage.axaml.cs b/KitX Dashboard/Views/Pages/RepoPage.axaml.cs index 3e1dd337..3837651a 100644 --- a/KitX Dashboard/Views/Pages/RepoPage.axaml.cs +++ b/KitX Dashboard/Views/Pages/RepoPage.axaml.cs @@ -4,7 +4,8 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Threading; -using KitX.Dashboard.Managers; +using KitX.Core.Contract.Plugin; +using KitX.Core.Plugin; using KitX.Dashboard.ViewModels.Pages; using Serilog; @@ -28,29 +29,40 @@ private void InitHandlers() AddHandler(DragDrop.DropEvent, Drop); AddHandler(DragDrop.DragOverEvent, DragOver); + + // Refresh plugin list when page loads. Uses direct synchronous call + // instead of ReactiveCommand.Execute() which schedules asynchronously + // and may not complete before the UI renders. + Loaded += (_, _) => viewModel.PerformRefresh(); + + Unloaded += (_, _) => viewModel.Cleanup(); } private void Drop(object? sender, DragEventArgs e) { const string location = $"{nameof(RepoPage)}.{nameof(Drop)}"; - var files = e.Data?.GetFiles()?.Select(x => x.Path.LocalPath).ToArray(); + var files = e.DataTransfer.TryGetFiles()?.Select(x => x.Path.LocalPath).ToArray(); if (files is not null && files?.Length > 0) { - new Thread(() => + _ = System.Threading.Tasks.Task.Run(async () => { try { - PluginsManager.ImportPlugin(files, true); + var pluginService = App.GetService(); + foreach (var file in files!) + { + await pluginService.ImportPluginAsync(file); + } - Dispatcher.UIThread.Post(() => viewModel.RefreshPluginsCommand?.Execute()); + Dispatcher.UIThread.Post(() => viewModel.PerformRefresh()); } catch (Exception ex) { Log.Error(ex, $"In {location}: {ex.Message}"); } - }).Start(); + }); } } @@ -60,7 +72,7 @@ private void DragOver(object? sender, DragEventArgs e) e.DragEffects &= (DragDropEffects.Copy | DragDropEffects.Link); // Only allow if the dragged data's type is file. - if (!e.Data.Contains(DataFormats.Files)) + if (!e.DataTransfer.Formats.Contains(DataFormat.File)) e.DragEffects = DragDropEffects.None; } } diff --git a/KitX Dashboard/Views/Pages/SettingsPage.axaml.cs b/KitX Dashboard/Views/Pages/SettingsPage.axaml.cs index 790aea5f..8ad0263d 100644 --- a/KitX Dashboard/Views/Pages/SettingsPage.axaml.cs +++ b/KitX Dashboard/Views/Pages/SettingsPage.axaml.cs @@ -1,7 +1,7 @@ using System; using Avalonia.Controls; using FluentAvalonia.UI.Controls; -using KitX.Dashboard.Managers; +using KitX.Core.Contract.Configuration; using KitX.Dashboard.ViewModels.Pages; using KitX.Dashboard.Views.Pages.Controls; using Serilog; @@ -50,10 +50,10 @@ private void SettingsNavigationView_SelectionChanged(object? sender, NavigationV private static string SelectedViewName { - get => ConfigManager.Instance.AppConfig.Pages.Settings.SelectedViewName; + get => App.GetService().AppConfig.Pages.Settings.SelectedViewName; set { - ConfigManager.Instance.AppConfig.Pages.Settings.SelectedViewName = value; + App.GetService().AppConfig.Pages.Settings.SelectedViewName = value; IView.SaveAppConfigChanges(); } diff --git a/KitX Dashboard/Views/Pages/WorkflowPage.axaml b/KitX Dashboard/Views/Pages/WorkflowPage.axaml index 58bda2fd..ca5dd138 100644 --- a/KitX Dashboard/Views/Pages/WorkflowPage.axaml +++ b/KitX Dashboard/Views/Pages/WorkflowPage.axaml @@ -1,4 +1,4 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -57,66 +126,95 @@ - + - - + - - - - @@ -125,4 +223,4 @@ - \ No newline at end of file + diff --git a/KitX Dashboard/Views/Pages/WorkflowPage.axaml.cs b/KitX Dashboard/Views/Pages/WorkflowPage.axaml.cs index 3a3cae4a..e6652223 100644 --- a/KitX Dashboard/Views/Pages/WorkflowPage.axaml.cs +++ b/KitX Dashboard/Views/Pages/WorkflowPage.axaml.cs @@ -1,3 +1,4 @@ +using System.Collections.Specialized; using Avalonia.Controls; using KitX.Dashboard.ViewModels.Pages; @@ -12,5 +13,21 @@ public WorkflowPage() InitializeComponent(); DataContext = workflowViewModel; + + // Auto-scroll the activity log to the newest line as items arrive. + // Hooking here (rather than in the VM) keeps UI concerns out of the VM and lets + // us reach the ListBox's internal ScrollViewer cleanly. + if (workflowViewModel.ExecutionLog is INotifyCollectionChanged ncc) + { + ncc.CollectionChanged += (_, args) => + { + if (args.Action == NotifyCollectionChangedAction.Reset) return; + if (ActivityLogList?.ItemCount is > 0) + { + // Scroll to the last item; Avalonia's ListBox.ScrollIntoView is 0-based. + ActivityLogList.ScrollIntoView(ActivityLogList.ItemCount - 1); + } + }; + } } } diff --git a/KitX Dashboard/Views/PluginDetailWindow.axaml b/KitX Dashboard/Views/PluginDetailWindow.axaml index d7e19f10..52db9ffa 100644 --- a/KitX Dashboard/Views/PluginDetailWindow.axaml +++ b/KitX Dashboard/Views/PluginDetailWindow.axaml @@ -104,7 +104,7 @@ diff --git a/KitX Dashboard/Views/PluginDetailWindow.axaml.cs b/KitX Dashboard/Views/PluginDetailWindow.axaml.cs index 7c9ede1d..d4fbce00 100644 --- a/KitX Dashboard/Views/PluginDetailWindow.axaml.cs +++ b/KitX Dashboard/Views/PluginDetailWindow.axaml.cs @@ -2,7 +2,9 @@ using Avalonia.Controls; using Avalonia.Media; using Common.BasicHelper.Graphics.Screen; -using KitX.Dashboard.Services; +using KitX.Core.Contract.Event; +using KitX.Core.Event; +using KitX.Dashboard; using KitX.Dashboard.ViewModels; using KitX.Shared.CSharp.Plugin; using Serilog; @@ -60,7 +62,8 @@ public PluginDetailWindow() Opened += (_, _) => viewModel.InitFunctionsAndTags(); - EventService.OnExiting += Close; + var eventService = App.GetService(); + eventService.Subscribe(EventNames.OnExiting, (s, e) => Close()); } public PluginDetailWindow SetPluginInfo(PluginInfo ps) diff --git a/KitX Dashboard/Views/PluginsLaunchWindow.axaml.cs b/KitX Dashboard/Views/PluginsLaunchWindow.axaml.cs index 03e00469..4cb321f2 100644 --- a/KitX Dashboard/Views/PluginsLaunchWindow.axaml.cs +++ b/KitX Dashboard/Views/PluginsLaunchWindow.axaml.cs @@ -1,16 +1,19 @@ -using System; +using System; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Threading; -using KitX.Dashboard.Services; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Hotkey; +using KitX.Core.Event; +using KitX.Dashboard; using KitX.Dashboard.ViewModels; -using SharpHook.Native; namespace KitX.Dashboard.Views; public partial class PluginsLaunchWindow : Window { private readonly PluginsLaunchWindowViewModel viewModel = new(); + private readonly IKeyHookService _keyHookService; private readonly Action? OnHideAction; @@ -24,9 +27,12 @@ public PluginsLaunchWindow() DataContext = viewModel; + _keyHookService = App.GetService(); + OnHideAction = () => pluginsLaunchWindowDisplayed = false; - EventService.OnExiting += Close; + var eventService = App.GetService(); + eventService.Subscribe(EventNames.OnExiting, (s, e) => Close()); Initialize(); } @@ -100,43 +106,46 @@ private void PluginsScrollViewer_KeyDown(object? sender, KeyEventArgs e) private void RegisterGlobalHotKey() { - Instances.KeyHookManager?.RegisterHotKeyHandler( - nameof(PluginsLaunchWindow), - codes => - { - var count = codes.Length; + if (_keyHookService is KitX.Core.Hotkey.KeyHookManager keyHookManager) + { + keyHookManager.RegisterHotKeyHandler( + nameof(PluginsLaunchWindow), + codes => + { + var count = codes.Length; - var tmpList = codes; + var tmpList = codes; - if (count < 3) - return; + if (count < 3) + return; - if (tmpList[count - 3] != KeyCode.VcLeftControl) - return; + if (tmpList[count - 3] != "VcLeftControl") + return; - if (tmpList[count - 2] != KeyCode.VcLeftMeta) - return; + if (tmpList[count - 2] != "VcLeftMeta") + return; - if (tmpList[count - 1] != KeyCode.VcC) - return; + if (tmpList[count - 1] != "VcC") + return; - Dispatcher.UIThread.Post(() => - { - if (pluginsLaunchWindowDisplayed) + Dispatcher.UIThread.Post(() => { - Activate(); - - Focus(); - } - else - { - Show(); - } - - pluginsLaunchWindowDisplayed = true; - }); - } - ); + if (pluginsLaunchWindowDisplayed) + { + Activate(); + + Focus(); + } + else + { + Show(); + } + + pluginsLaunchWindowDisplayed = true; + }); + } + ); + } } protected override void OnKeyDown(KeyEventArgs e) diff --git a/KitX Dashboard/Views/ViewInstances.cs b/KitX Dashboard/Views/ViewInstances.cs deleted file mode 100644 index 0e534271..00000000 --- a/KitX Dashboard/Views/ViewInstances.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using Avalonia.Controls; -using KitX.Dashboard.Models; -using KitX.Dashboard.Services; -using KitX.Shared.CSharp.Plugin; - -namespace KitX.Dashboard.Views; - -public static class ViewInstances -{ - public static ObservableCollection DeviceCases { get; set; } = []; - - public static ObservableCollection WorkflowCases { get; set; } = []; - - public static ObservableCollection PluginInfos { get; set; } = []; - - public static MainWindow? MainWindow { get; set; } - - public static PluginsLaunchWindow? PluginsLaunchWindow { get; set; } - - public static List Windows { get; set; } = []; - - public static void ShowWindow(T window, Window? owner = null, bool showDialog = false, bool onlyOneInSameTime = false) - where T : Window - { - if (onlyOneInSameTime && Windows.Any(x => x.Title?.Equals(window.Title) ?? window.Title is null)) - return; - - EventService.OnExiting += window.Close; - - Windows.Add(window); - - window.Closed += (_, _) => Windows.Remove(window); - - if (showDialog && owner is not null) - window.ShowDialog(owner); - else if (owner is null || owner.IsVisible == false) - window.Show(); - else - window.Show(owner); - } -} diff --git a/KitX Dashboard/Views/WorkflowEditorWindow.axaml b/KitX Dashboard/Views/WorkflowEditorWindow.axaml new file mode 100644 index 00000000..322f1d6f --- /dev/null +++ b/KitX Dashboard/Views/WorkflowEditorWindow.axaml @@ -0,0 +1,750 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KitX Dashboard/Views/WorkflowEditorWindow.axaml.cs b/KitX Dashboard/Views/WorkflowEditorWindow.axaml.cs new file mode 100644 index 00000000..e76c8717 --- /dev/null +++ b/KitX Dashboard/Views/WorkflowEditorWindow.axaml.cs @@ -0,0 +1,833 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvaloniaEdit; +using KitX.Core.Contract.Event; +using KitX.Core.Contract.Tasks; +using KitX.Core.Contract.Workflow; +using KitX.Core.Event; +using KitX.Dashboard.Controls; +using KitX.Dashboard.Services; +using KitX.Dashboard.ViewModels; +using NodifyM.Avalonia.Controls; +using TextMateSharp.Grammars; +using AvaloniaEdit.TextMate; +using Avalonia.Styling; + +namespace KitX.Dashboard.Views; + +public partial class WorkflowEditorWindow : Window, IView +{ + private readonly WorkflowEditorViewModel _viewModel; + private bool _isEditingHelperFunction = false; + private CancellationTokenSource? _debounceCts; + private CancellationTokenSource? _autoSaveCts; + private Popup? _contextPopup; + private string _workflowId = string.Empty; + + private static string? GetResource(string key) => + Application.Current?.TryFindResource(key, out var v) == true ? v as string : null; + + public WorkflowEditorWindow() + { + InitializeComponent(); + + var scriptVM = App.GetService(); + var blueprintVM = App.GetService(); + + _viewModel = new WorkflowEditorViewModel( + App.GetService(), + App.GetService(), + App.GetService(), + scriptVM, + blueprintVM + ); + + DataContext = _viewModel; + + Initialize(); + } + + /// + /// Loads a workflow by ID after construction. + /// Call before Show(). + /// + public async Task LoadWorkflowAsync(string workflowId) + { + _workflowId = workflowId; + await _viewModel.LoadWorkflowAsync(workflowId); + + // Update the code editor with loaded content + var codeEditor = this.FindControl("CodeEditor"); + if (codeEditor != null) + { + codeEditor.Text = _viewModel.ScriptVM.MainProgramCode ?? string.Empty; + } + } + + private void Initialize() + { + InitializeEditor(); + + var eventService = App.GetService(); + eventService.Subscribe(EventNames.ThemeConfigChanged, (s, e) => InitializeEditor()); + + WireUpCodeEditor(); + WireUpHelperFunctions(); + WireUpConstants(); + WireUpRunStop(); + WireUpOutput(); + WireUpDebugHighlight(); + WireUpBPContextMenu(); + WireUpModeSwitch(); + + // Auto-save on window closing + Closing += OnWindowClosing; + } + + #region AvaloniaEdit Initialization + + private void InitializeEditor() + { + var textEditor = this.FindControl("CodeEditor"); + SetEditorSyntax(textEditor, ".cs"); + } + + private void SetEditorSyntax(TextEditor? textEditor, string ext) + { + if (textEditor is null) return; + + var registryOptions = new RegistryOptions( + ActualThemeVariant == ThemeVariant.Light ? ThemeName.LightPlus : ThemeName.DarkPlus + ); + var textMateInstallation = textEditor.InstallTextMate(registryOptions); + textMateInstallation.SetGrammar( + registryOptions.GetScopeByLanguageId(registryOptions.GetLanguageByExtension(ext).Id) + ); + } + + #endregion + + #region Code Editor Wiring + + private void WireUpCodeEditor() + { + var codeEditor = this.FindControl("CodeEditor"); + var constantsItemsControl = this.FindControl("ConstantsItemsControl"); + if (codeEditor == null) return; + + _viewModel.ScriptVM.CodeDocument = codeEditor.Document; + + codeEditor.TextChanged += (s, e) => + { + if (codeEditor.Document == null) return; + + // Helper function editing: sync immediately + if (_isEditingHelperFunction) + { + if (_viewModel.ScriptVM.SelectedHelperFunction != null) + { + _viewModel.ScriptVM.SelectedHelperFunction.Code = codeEditor.Document.Text; + } + return; + } + + // Main program editing: debounce parse + auto-save + _viewModel.ScriptVM.MainProgramCode = codeEditor.Document.Text; + _viewModel.IsDirty = true; + + // Debounced constant parsing + _debounceCts?.Cancel(); + _debounceCts = new CancellationTokenSource(); + var token = _debounceCts.Token; + + _ = Task.Delay(500, token).ContinueWith(t => + { + if (t.IsCanceled) return; + Dispatcher.UIThread.InvokeAsync(() => + { + if (codeEditor.Document == null) return; + _viewModel.ScriptVM.ParseConstantsFromCode(codeEditor.Document.Text); + if (constantsItemsControl != null) + constantsItemsControl.ItemsSource = _viewModel.ScriptVM.VariableConstants; + }); + }, token); + + // Auto-save debounce (3 seconds) + ScheduleAutoSave(); + }; + } + + #endregion + + #region Auto-Save + + private void ScheduleAutoSave() + { + _autoSaveCts?.Cancel(); + _autoSaveCts = new CancellationTokenSource(); + var token = _autoSaveCts.Token; + + _ = Task.Delay(3000, token).ContinueWith(t => + { + if (t.IsCanceled) return; + Dispatcher.UIThread.InvokeAsync(async () => + { + if (_viewModel.IsDirty) + await _viewModel.SaveAsync(); + }); + }, token); + } + + private async void OnWindowClosing(object? sender, WindowClosingEventArgs e) + { + _autoSaveCts?.Cancel(); + + if (_viewModel.IsDirty) + { + await _viewModel.SaveAsync(); + } + } + + #endregion + + #region Helper Functions Wiring + + private void WireUpHelperFunctions() + { + var helperFunctionsListBox = this.FindControl("HelperFunctionsListBox"); + if (helperFunctionsListBox != null) + { + helperFunctionsListBox.ItemsSource = _viewModel.ScriptVM.HelperFunctions; + helperFunctionsListBox.SelectionChanged += OnHelperFunctionSelected; + } + + var addBtn = this.FindControl