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