From f75e0ae912fe135ce75faa029f7dc6ba6e109348 Mon Sep 17 00:00:00 2001 From: "Novo.Xi" Date: Sun, 7 Dec 2025 23:34:07 +0800 Subject: [PATCH 1/2] Fix module VM service resolution and clean navigation logs --- .../update-runtime-module-lifecycle/design.md | 25 ++ .../proposal.md | 19 + .../specs/runtime/spec.md | 60 +++ .../update-runtime-module-lifecycle/tasks.md | 8 + .../Services/AvaloniaNavigationService.cs | 91 ++++- .../Shell/Services/MenuRegistry.cs | 17 +- .../Shell/ViewModels/ShellViewModel.cs | 8 + src/Hosts/Modulus.Host.Blazor/MauiProgram.cs | 2 + .../Modulus.Host.Blazor.csproj | 6 +- .../Shell/Services/MenuRegistry.cs | 20 +- .../Shell/ViewModels/ShellViewModel.cs | 1 + .../Architecture/SharedAssemblyCatalog.cs | 86 ++++ .../Hosting/ModulusHostBuilderExtensions.cs | 4 + .../Manifest/DefaultManifestValidator.cs | 79 +++- .../Manifest/IManifestValidator.cs | 2 +- src/Modulus.Core/Modulus.Core.csproj | 1 + .../Runtime/CompositeServiceProvider.cs | 63 +++ .../Runtime/ModuleDependencyResolver.cs | 82 ++++ src/Modulus.Core/Runtime/ModuleLoadContext.cs | 49 +-- src/Modulus.Core/Runtime/ModuleLoader.cs | 378 ++++++++++++++++-- src/Modulus.Core/Runtime/ModuleManager.cs | 73 ++-- .../Runtime/ModulusApplication.cs | 8 + .../Runtime/ModulusApplicationFactory.cs | 77 +++- src/Modulus.Core/Runtime/RuntimeContext.cs | 22 + src/Modulus.Core/Runtime/RuntimeModule.cs | 5 +- .../Runtime/RuntimeModuleHandle.cs | 59 +++ src/Modulus.Sdk/ModuleManifest.cs | 3 + src/Modulus.UI.Abstractions/IMenuRegistry.cs | 2 + tests/Modulus.Core.Tests/ModuleLoaderTests.cs | 48 ++- .../Modulus.Core.Tests/ModuleManagerTests.cs | 16 + .../Modulus.Core.Tests/RuntimeContextTests.cs | 24 +- .../ModulusApplicationIntegrationTests.cs | 4 +- .../Modulus.Modules.Tests/EchoPluginTests.cs | 10 +- 33 files changed, 1204 insertions(+), 148 deletions(-) create mode 100644 openspec/changes/update-runtime-module-lifecycle/design.md create mode 100644 openspec/changes/update-runtime-module-lifecycle/proposal.md create mode 100644 openspec/changes/update-runtime-module-lifecycle/specs/runtime/spec.md create mode 100644 openspec/changes/update-runtime-module-lifecycle/tasks.md create mode 100644 src/Modulus.Core/Architecture/SharedAssemblyCatalog.cs create mode 100644 src/Modulus.Core/Runtime/CompositeServiceProvider.cs create mode 100644 src/Modulus.Core/Runtime/ModuleDependencyResolver.cs create mode 100644 src/Modulus.Core/Runtime/RuntimeModuleHandle.cs diff --git a/openspec/changes/update-runtime-module-lifecycle/design.md b/openspec/changes/update-runtime-module-lifecycle/design.md new file mode 100644 index 0000000..b678bfe --- /dev/null +++ b/openspec/changes/update-runtime-module-lifecycle/design.md @@ -0,0 +1,25 @@ +## Context +- Runtime hot-load/unload must execute module lifecycle and integrate UI/service registrations safely. +- Manifest validation needs host/dependency/semver/integrity checks. +- Dependency ordering must merge manifest deps with DependsOn and detect cycles/missing modules. +- Shared assembly allowlist should follow assembly-domain metadata instead of hardcoded names. + +## Decisions +- Create module-scoped ServiceCollection/ServiceProvider per runtime load; run Pre/Configure/PostConfigureServices then OnApplicationInitializationAsync; register menus/views immediately; store handle for cleanup. +- On unload, invoke OnApplicationShutdownAsync, unregister menus/navigation, dispose module provider/scope, remove module manager/context entry, then unload ALC; guard system modules. +- Extend manifest validator: required fields, supportedHosts match host, semver parse + VersionRange validation, dependency existence, optional assembly hashes + signature. +- Build unified dependency graph combining manifest deps (id+version) and DependsOn attributes; topological sort with cycle/missing diagnostics, surface errors to UI/logs. +- Shared allowlist sourced from AssemblyDomainInfo metadata over default context assemblies plus optional config; ModuleLoadContext consults catalog and logs diagnostics when Module-domain assemblies are requested as shared. + +## Risks / Trade-offs +- Additional DI scopes per module increase memory; mitigated by disposal on unload. +- Semver dependency validation adds NuGet.Versioning dependency; chosen for correctness over bespoke parsing. +- Menu/navigation deregistration requires registry changes; ensure backward compatibility with additive remove APIs. + +## Migration Plan +- Add shared allowlist catalog and manifest validation enhancements. +- Update loader/unloader to use module handles and dependency resolver. +- Update navigation/menu registries to support deregistration. +- Run tests covering load/unload, validation, and dependency ordering. + + diff --git a/openspec/changes/update-runtime-module-lifecycle/proposal.md b/openspec/changes/update-runtime-module-lifecycle/proposal.md new file mode 100644 index 0000000..46e929c --- /dev/null +++ b/openspec/changes/update-runtime-module-lifecycle/proposal.md @@ -0,0 +1,19 @@ +# Change: Update runtime module lifecycle & validation + +## Why +- Runtime-loaded modules skip DI/lifecycle and leave stale UI/service registrations. +- Manifest validation misses host compatibility, dependency semantics, and integrity checks. +- Dependency ordering ignores manifest deps and cycle/missing detection. +- Shared assembly allowlist is hardcoded, risking duplicate loads and type mismatches. + +## What Changes +- Add module-scoped DI + lifecycle execution (Pre/Configure/Post + init/shutdown) with tracked handles and cleanup on unload. +- Strengthen manifest validation for required fields, supported hosts, semver dependencies, hashes, and signature. +- Build unified dependency graph (manifest deps + DependsOn) with topo sort and diagnostics. +- Drive shared assembly allowlist from assembly-domain metadata with runtime diagnostics. + +## Impact +- Affected specs: runtime +- Affected code: Modulus.Core runtime loader/manager, manifest validation, load context, host menu/navigation integration + + diff --git a/openspec/changes/update-runtime-module-lifecycle/specs/runtime/spec.md b/openspec/changes/update-runtime-module-lifecycle/specs/runtime/spec.md new file mode 100644 index 0000000..b7863d6 --- /dev/null +++ b/openspec/changes/update-runtime-module-lifecycle/specs/runtime/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Runtime module lifecycle and cleanup +Runtime-loaded modules MUST execute full lifecycle with module-scoped DI and clean teardown. + +#### Scenario: Load executes lifecycle with module services +- **WHEN** a module is loaded at runtime with a valid manifest and supported host +- **THEN** the loader builds a module ServiceCollection/ServiceProvider +- **AND** instantiates all `IModule` types in the package +- **AND** runs Pre/Configure/PostConfigureServices followed by OnApplicationInitializationAsync +- **AND** registers module UI/menu contributions and marks the module active + +#### Scenario: Unload calls shutdown and removes registrations +- **WHEN** a loaded module is unloaded +- **THEN** OnApplicationShutdownAsync is invoked in reverse order +- **AND** module menus/navigation/views/services registered during load are deregistered +- **AND** the module ServiceProvider is disposed and its AssemblyLoadContext is unloaded + +#### Scenario: System modules protected from unload +- **WHEN** an unload is requested for a system module +- **THEN** the operation is rejected with a clear error + +### Requirement: Manifest validation for host, deps, and integrity +Manifests MUST be validated for required fields, host compatibility, dependency semantics, and integrity. + +#### Scenario: Unsupported host is rejected +- **WHEN** the manifest SupportedHosts does not include the current host +- **THEN** loading fails with a diagnostic explaining the host mismatch + +#### Scenario: Dependency or version constraint failure blocks load +- **WHEN** a declared dependency is missing or its version does not satisfy the SemVer range +- **THEN** loading fails with a diagnostic naming the missing/invalid dependency + +#### Scenario: Integrity checks enforced when provided +- **WHEN** manifest or assembly hashes/signatures are present +- **THEN** the loader verifies them and rejects the module on mismatch + +### Requirement: Unified dependency graph with topo ordering +Module initialization order MUST use a unified dependency graph from manifest Dependencies and DependsOn attributes, with validation. + +#### Scenario: Missing or cyclic dependency fails fast +- **WHEN** the graph contains a missing module or a cycle +- **THEN** loading/initialization is blocked and the error identifies the offending modules + +#### Scenario: Modules initialize in dependency order +- **WHEN** modules have dependencies +- **THEN** initialization runs in topological order honoring both manifest and DependsOn links + +### Requirement: Shared assembly resolution from domain metadata +Shared assembly allowlist MUST come from assembly-domain metadata/config, with diagnostics for mismatches. + +#### Scenario: Shared assemblies resolved via domain metadata +- **WHEN** a module requests an assembly marked Shared by assembly-domain metadata +- **THEN** it is resolved from the host shared context instead of a private copy + +#### Scenario: Misdeclared shared/module assembly surfaces diagnostics +- **WHEN** a Module-domain assembly is requested from the shared list or vice versa +- **THEN** the loader emits a diagnostic to help correct the domain assignment + + diff --git a/openspec/changes/update-runtime-module-lifecycle/tasks.md b/openspec/changes/update-runtime-module-lifecycle/tasks.md new file mode 100644 index 0000000..cfbc172 --- /dev/null +++ b/openspec/changes/update-runtime-module-lifecycle/tasks.md @@ -0,0 +1,8 @@ +## 1. Implementation +- [ ] 1.1 Implement module-scoped DI + lifecycle execution on runtime load +- [ ] 1.2 Add unload cleanup (shutdown hooks, menu/view/service deregistration, dispose providers) +- [ ] 1.3 Strengthen manifest validation (fields, host match, semver deps, hashes/signature) +- [ ] 1.4 Build unified dependency graph (manifest deps + DependsOn) with cycle/missing detection +- [ ] 1.5 Drive shared assembly allowlist from assembly domain metadata with diagnostics + + diff --git a/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaNavigationService.cs b/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaNavigationService.cs index d0936eb..d11dbca 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaNavigationService.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaNavigationService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Modulus.Core.Runtime; using Modulus.UI.Abstractions; namespace Modulus.Host.Avalonia.Services; @@ -16,6 +17,7 @@ public class AvaloniaNavigationService : INavigationService private readonly IServiceProvider _serviceProvider; private readonly IUIFactory _uiFactory; private readonly IMenuRegistry _menuRegistry; + private readonly RuntimeContext _runtimeContext; private readonly List _guards = new(); private readonly ConcurrentDictionary _singletonViewModels = new(); private readonly ConcurrentDictionary _singletonViews = new(); @@ -37,11 +39,13 @@ public class AvaloniaNavigationService : INavigationService public AvaloniaNavigationService( IServiceProvider serviceProvider, IUIFactory uiFactory, - IMenuRegistry menuRegistry) + IMenuRegistry menuRegistry, + RuntimeContext runtimeContext) { _serviceProvider = serviceProvider; _uiFactory = uiFactory; _menuRegistry = menuRegistry; + _runtimeContext = runtimeContext; } public async Task NavigateToAsync(string navigationKey, NavigationOptions? options = null) @@ -199,7 +203,7 @@ private async Task EvaluateGuardsAsync(NavigationContext context) return vmType; } - // Search loaded assemblies + // Search host assemblies vmType = AppDomain.CurrentDomain.GetAssemblies() .SelectMany(a => { @@ -208,7 +212,54 @@ private async Task EvaluateGuardsAsync(NavigationContext context) }) .FirstOrDefault(t => t.FullName == navigationKey || t.Name == navigationKey); - return vmType; + if (vmType != null) + { + return vmType; + } + + // Search module assemblies (loaded in separate AssemblyLoadContexts) + foreach (var runtimeModule in _runtimeContext.RuntimeModules) + { + // Also search RuntimeModuleHandle assemblies (more complete list) + if (_runtimeContext.TryGetModuleHandle(runtimeModule.Descriptor.Id, out var handle) && handle != null) + { + foreach (var assembly in handle.Assemblies) + { + try + { + vmType = assembly.GetTypes() + .FirstOrDefault(t => t.FullName == navigationKey || t.Name == navigationKey); + if (vmType != null) + { + return vmType; + } + } + catch + { + // Skip assemblies that fail to enumerate types + } + } + } + // Fallback to LoadContext.Assemblies + foreach (var assembly in runtimeModule.LoadContext.Assemblies) + { + try + { + vmType = assembly.GetTypes() + .FirstOrDefault(t => t.FullName == navigationKey || t.Name == navigationKey); + if (vmType != null) + { + return vmType; + } + } + catch + { + // Skip assemblies that fail to enumerate types + } + } + } + + return null; } private object? GetOrCreateViewModel(Type vmType, string navigationKey, PageInstanceMode instanceMode, bool forceNew) @@ -225,24 +276,40 @@ private async Task EvaluateGuardsAsync(NavigationContext context) private object? CreateViewModel(Type vmType) { - // Try DI first - var vm = _serviceProvider.GetService(vmType); - if (vm != null) - { - return vm; - } - - // Fall back to ActivatorUtilities try { + var moduleProvider = ResolveModuleServiceProvider(vmType); + + if (moduleProvider != null) + { + var moduleVm = moduleProvider.GetService(vmType) ?? ActivatorUtilities.CreateInstance(moduleProvider, vmType); + if (moduleVm != null) + { + return moduleVm; + } + } + + var vm = _serviceProvider.GetService(vmType); + if (vm != null) + { + return vm; + } + + // Fall back to ActivatorUtilities return ActivatorUtilities.CreateInstance(_serviceProvider, vmType); } - catch + catch (Exception ex) { return null; } } + private IServiceProvider? ResolveModuleServiceProvider(Type vmType) + { + var handle = _runtimeContext.ModuleHandles.FirstOrDefault(h => h.Assemblies.Any(a => a == vmType.Assembly)); + return handle?.CompositeServiceProvider; + } + private static string ExtractDisplayName(string navigationKey) { var name = navigationKey.Split('.').LastOrDefault() ?? navigationKey; diff --git a/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs b/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs index f4d4357..8d839be 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs @@ -7,16 +7,27 @@ namespace Modulus.Host.Avalonia.Shell.Services; public class MenuRegistry : IMenuRegistry { - private readonly ConcurrentBag _items = new(); + private readonly ConcurrentDictionary _items = new(StringComparer.OrdinalIgnoreCase); + + public event EventHandler? MenuChanged; public void Register(MenuItem item) { - _items.Add(item); + _items[item.Id] = item; + MenuChanged?.Invoke(this, EventArgs.Empty); + } + + public void Unregister(string id) + { + if (_items.TryRemove(id, out _)) + { + MenuChanged?.Invoke(this, EventArgs.Empty); + } } public IEnumerable GetItems(MenuLocation location) { - return _items.Where(i => i.Location == location).OrderBy(i => i.Order); + return _items.Values.Where(i => i.Location == location).OrderBy(i => i.Order); } } diff --git a/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs b/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs index 8473aa0..449f740 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs @@ -57,6 +57,14 @@ public ShellViewModel( _avaloniaNavService.OnViewChanged = OnNavigationViewChanged; } + _menuRegistry.MenuChanged += (s, e) => + { + // Ensure UI update happens on UI thread if needed, though ObservableCollection handles some sync + // For safety in Avalonia, we might need Dispatcher.UIThread.InvokeAsync, but ViewModel is usually bound + // Let's assume Mvvm toolkit handles basic binding updates or we are on UI thread from Loader + RefreshMenu(); + }; + RefreshMenu(); } diff --git a/src/Hosts/Modulus.Host.Blazor/MauiProgram.cs b/src/Hosts/Modulus.Host.Blazor/MauiProgram.cs index 36aaf89..2143004 100644 --- a/src/Hosts/Modulus.Host.Blazor/MauiProgram.cs +++ b/src/Hosts/Modulus.Host.Blazor/MauiProgram.cs @@ -56,6 +56,8 @@ public override Task OnApplicationInitializationAsync(IModuleInitializationConte public static class MauiProgram { + public static void Main(string[] args) {} // Dummy entry point for net10.0 target without MAUI + public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); diff --git a/src/Hosts/Modulus.Host.Blazor/Modulus.Host.Blazor.csproj b/src/Hosts/Modulus.Host.Blazor/Modulus.Host.Blazor.csproj index 5c2ebda..4b50c48 100644 --- a/src/Hosts/Modulus.Host.Blazor/Modulus.Host.Blazor.csproj +++ b/src/Hosts/Modulus.Host.Blazor/Modulus.Host.Blazor.csproj @@ -1,8 +1,9 @@  - net10.0-android;net10.0-ios;net10.0-maccatalyst - $(TargetFrameworks);net10.0-windows10.0.19041.0 + net10.0 + + Exe + Modulus.Host.Blazor.MauiProgram Modulus.Host.Blazor true true diff --git a/src/Hosts/Modulus.Host.Blazor/Shell/Services/MenuRegistry.cs b/src/Hosts/Modulus.Host.Blazor/Shell/Services/MenuRegistry.cs index 23d76cc..278e04d 100644 --- a/src/Hosts/Modulus.Host.Blazor/Shell/Services/MenuRegistry.cs +++ b/src/Hosts/Modulus.Host.Blazor/Shell/Services/MenuRegistry.cs @@ -1,4 +1,7 @@ +using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; using Modulus.UI.Abstractions; using UiMenuItem = Modulus.UI.Abstractions.MenuItem; @@ -6,15 +9,26 @@ namespace Modulus.Host.Blazor.Shell.Services; public class MenuRegistry : IMenuRegistry { - private readonly ConcurrentBag _items = new(); + private readonly ConcurrentDictionary _items = new(StringComparer.OrdinalIgnoreCase); + + public event EventHandler? MenuChanged; public void Register(UiMenuItem item) { - _items.Add(item); + _items[item.Id] = item; + MenuChanged?.Invoke(this, EventArgs.Empty); + } + + public void Unregister(string id) + { + if (_items.TryRemove(id, out _)) + { + MenuChanged?.Invoke(this, EventArgs.Empty); + } } public IEnumerable GetItems(MenuLocation location) { - return _items.Where(i => i.Location == location).OrderBy(i => i.Order); + return _items.Values.Where(i => i.Location == location).OrderBy(i => i.Order); } } diff --git a/src/Hosts/Modulus.Host.Blazor/Shell/ViewModels/ShellViewModel.cs b/src/Hosts/Modulus.Host.Blazor/Shell/ViewModels/ShellViewModel.cs index 8089a0d..c5b1c78 100644 --- a/src/Hosts/Modulus.Host.Blazor/Shell/ViewModels/ShellViewModel.cs +++ b/src/Hosts/Modulus.Host.Blazor/Shell/ViewModels/ShellViewModel.cs @@ -21,6 +21,7 @@ public partial class ShellViewModel : ObservableObject public ShellViewModel(IMenuRegistry menuRegistry) { _menuRegistry = menuRegistry; + _menuRegistry.MenuChanged += (s, e) => RefreshMenu(); RefreshMenu(); } diff --git a/src/Modulus.Core/Architecture/SharedAssemblyCatalog.cs b/src/Modulus.Core/Architecture/SharedAssemblyCatalog.cs new file mode 100644 index 0000000..8e554e4 --- /dev/null +++ b/src/Modulus.Core/Architecture/SharedAssemblyCatalog.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Modulus.Architecture; + +namespace Modulus.Core.Architecture; + +public interface ISharedAssemblyCatalog +{ + IReadOnlyCollection Names { get; } + bool IsShared(AssemblyName assemblyName); +} + +/// +/// Catalog of assemblies that must be resolved from the shared domain. +/// Built from assembly-domain metadata in the default load context. +/// +public sealed class SharedAssemblyCatalog : ISharedAssemblyCatalog +{ + private readonly HashSet _sharedNames; + private readonly Dictionary _domainMap; + private readonly ILogger? _logger; + + private SharedAssemblyCatalog(HashSet sharedNames, Dictionary domainMap, ILogger? logger) + { + _sharedNames = sharedNames; + _domainMap = domainMap; + _logger = logger; + } + + public static SharedAssemblyCatalog FromAssemblies(IEnumerable assemblies, IEnumerable? additionalNames = null, ILogger? logger = null) + { + var sharedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var domainMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var assembly in assemblies) + { + var name = assembly.GetName().Name; + if (string.IsNullOrEmpty(name)) + { + continue; + } + + var domain = AssemblyDomainInfo.GetDomainType(assembly); + domainMap[name] = domain; + + if (domain == AssemblyDomainType.Shared) + { + sharedNames.Add(name); + } + } + + if (additionalNames != null) + { + foreach (var extra in additionalNames) + { + if (!string.IsNullOrWhiteSpace(extra)) + { + sharedNames.Add(extra); + } + } + } + + return new SharedAssemblyCatalog(sharedNames, domainMap, logger); + } + + public IReadOnlyCollection Names => _sharedNames; + + public bool IsShared(AssemblyName assemblyName) + { + if (assemblyName.Name is null) + { + return false; + } + + var isShared = _sharedNames.Contains(assemblyName.Name); + if (isShared && _domainMap.TryGetValue(assemblyName.Name, out var domain) && domain != AssemblyDomainType.Shared) + { + _logger?.LogWarning("Assembly {Assembly} is marked shared but declared as {Domain}.", assemblyName.Name, domain); + } + + return isShared; + } +} + diff --git a/src/Modulus.Core/Hosting/ModulusHostBuilderExtensions.cs b/src/Modulus.Core/Hosting/ModulusHostBuilderExtensions.cs index 79d3dd9..efc8ddb 100644 --- a/src/Modulus.Core/Hosting/ModulusHostBuilderExtensions.cs +++ b/src/Modulus.Core/Hosting/ModulusHostBuilderExtensions.cs @@ -1,5 +1,8 @@ +using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Modulus.Core.Architecture; using Modulus.Core.Manifest; using Modulus.Core.Runtime; @@ -12,6 +15,7 @@ public static IHostBuilder UseModulusRuntime(this IHostBuilder builder) return builder.ConfigureServices((_, services) => { services.AddSingleton(); + services.AddSingleton(sp => SharedAssemblyCatalog.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies(), null, sp.GetService>())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Modulus.Core/Manifest/DefaultManifestValidator.cs b/src/Modulus.Core/Manifest/DefaultManifestValidator.cs index 096d02d..6bb142e 100644 --- a/src/Modulus.Core/Manifest/DefaultManifestValidator.cs +++ b/src/Modulus.Core/Manifest/DefaultManifestValidator.cs @@ -1,5 +1,10 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; using Microsoft.Extensions.Logging; using Modulus.Sdk; +using NuGet.Versioning; namespace Modulus.Core.Manifest; @@ -16,11 +21,73 @@ public DefaultManifestValidator(IManifestSignatureVerifier signatureVerifier, IL _logger = logger; } - public async Task ValidateAsync(string packagePath, string manifestPath, ModuleManifest manifest, CancellationToken cancellationToken = default) + public async Task ValidateAsync(string packagePath, string manifestPath, ModuleManifest manifest, string? hostType = null, CancellationToken cancellationToken = default) { + var errors = new List(); + if (!string.Equals(manifest.ManifestVersion, SupportedManifestVersion, StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning("Manifest version {Version} is not supported. Expected {Expected}.", manifest.ManifestVersion, SupportedManifestVersion); + errors.Add($"Manifest version {manifest.ManifestVersion} is not supported. Expected {SupportedManifestVersion}."); + } + + if (string.IsNullOrWhiteSpace(manifest.Id)) + { + errors.Add("Manifest is missing required field 'id'."); + } + + if (string.IsNullOrWhiteSpace(manifest.Version) || !NuGetVersion.TryParse(manifest.Version, out _)) + { + errors.Add($"Manifest version '{manifest.Version}' is not a valid semantic version."); + } + + if (manifest.CoreAssemblies == null || manifest.UiAssemblies == null) + { + errors.Add("Manifest must include coreAssemblies and uiAssemblies."); + } + + if (hostType != null) + { + if (manifest.SupportedHosts == null || !manifest.SupportedHosts.Any(h => string.Equals(h, hostType, StringComparison.OrdinalIgnoreCase))) + { + errors.Add($"Host '{hostType}' is not supported by this module."); + } + + if (manifest.UiAssemblies != null && manifest.UiAssemblies.Count > 0 && !manifest.UiAssemblies.ContainsKey(hostType)) + { + errors.Add($"Manifest does not declare UI assemblies for host '{hostType}'."); + } + } + + foreach (var (dependencyId, dependencyRange) in manifest.Dependencies) + { + if (!VersionRange.TryParse(dependencyRange, out _)) + { + errors.Add($"Dependency '{dependencyId}' has invalid version range '{dependencyRange}'."); + } + } + + foreach (var (assemblyRelativePath, expectedHash) in manifest.AssemblyHashes) + { + var assemblyPath = Path.Combine(packagePath, assemblyRelativePath); + if (!File.Exists(assemblyPath)) + { + errors.Add($"Assembly hash declared for missing file '{assemblyRelativePath}'."); + continue; + } + + var hash = await ComputeSha256Async(assemblyPath, cancellationToken).ConfigureAwait(false); + if (!hash.Equals(expectedHash, StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"Assembly hash mismatch for '{assemblyRelativePath}'. Expected {expectedHash}, computed {hash}."); + } + } + + if (errors.Count > 0) + { + foreach (var error in errors) + { + _logger.LogWarning(error); + } return false; } @@ -39,5 +106,13 @@ public async Task ValidateAsync(string packagePath, string manifestPath, M return true; } + + private static async Task ComputeSha256Async(string path, CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(path); + using var sha = SHA256.Create(); + var hashBytes = await sha.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(hashBytes); + } } diff --git a/src/Modulus.Core/Manifest/IManifestValidator.cs b/src/Modulus.Core/Manifest/IManifestValidator.cs index 0d53391..c4acd2f 100644 --- a/src/Modulus.Core/Manifest/IManifestValidator.cs +++ b/src/Modulus.Core/Manifest/IManifestValidator.cs @@ -6,6 +6,6 @@ namespace Modulus.Core.Manifest; public interface IManifestValidator { - Task ValidateAsync(string packagePath, string manifestPath, ModuleManifest manifest, CancellationToken cancellationToken = default); + Task ValidateAsync(string packagePath, string manifestPath, ModuleManifest manifest, string? hostType = null, CancellationToken cancellationToken = default); } diff --git a/src/Modulus.Core/Modulus.Core.csproj b/src/Modulus.Core/Modulus.Core.csproj index 54a3f1e..6afd291 100644 --- a/src/Modulus.Core/Modulus.Core.csproj +++ b/src/Modulus.Core/Modulus.Core.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Modulus.Core/Runtime/CompositeServiceProvider.cs b/src/Modulus.Core/Runtime/CompositeServiceProvider.cs new file mode 100644 index 0000000..6017c09 --- /dev/null +++ b/src/Modulus.Core/Runtime/CompositeServiceProvider.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Modulus.Core.Runtime; + +internal sealed class CompositeServiceProvider : IServiceProvider, IServiceProviderIsService, IAsyncDisposable, IDisposable +{ + private readonly IServiceProvider _primary; + private readonly IServiceProvider _fallback; + private readonly IServiceProviderIsService? _primaryIsService; + private readonly IServiceProviderIsService? _fallbackIsService; + + public CompositeServiceProvider(IServiceProvider primary, IServiceProvider fallback) + { + _primary = primary; + _fallback = fallback; + _primaryIsService = primary.GetService(); + _fallbackIsService = fallback.GetService(); + } + + public object? GetService(Type serviceType) + { + // Return self for IServiceProviderIsService queries + if (serviceType == typeof(IServiceProviderIsService)) + { + return this; + } + + var service = _primary.GetService(serviceType); + return service ?? _fallback.GetService(serviceType); + } + + public bool IsService(Type serviceType) + { + if (_primaryIsService?.IsService(serviceType) == true) return true; + if (_fallbackIsService?.IsService(serviceType) == true) return true; + + // Fallback: probe providers directly (covers default DI where IServiceProviderIsService is not exposed) + return _primary.GetService(serviceType) != null + || _fallback.GetService(serviceType) != null; + } + + public ValueTask DisposeAsync() + { + if (_primary is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync(); + } + + (_primary as IDisposable)?.Dispose(); + return ValueTask.CompletedTask; + } + + public void Dispose() + { + if (_primary is IDisposable disposable) + { + disposable.Dispose(); + } + } +} + diff --git a/src/Modulus.Core/Runtime/ModuleDependencyResolver.cs b/src/Modulus.Core/Runtime/ModuleDependencyResolver.cs new file mode 100644 index 0000000..84d6cac --- /dev/null +++ b/src/Modulus.Core/Runtime/ModuleDependencyResolver.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace Modulus.Core.Runtime; + +internal static class ModuleDependencyResolver +{ + public static IReadOnlyList TopologicallySort( + IEnumerable items, + Func getId, + Func> getDependencies, + ILogger? logger = null) + { + var idComparer = StringComparer.OrdinalIgnoreCase; + var nodes = items.ToDictionary(getId, item => item, idComparer); + var indegrees = new Dictionary(idComparer); + var edges = new Dictionary>(idComparer); + + foreach (var (id, item) in nodes) + { + var deps = getDependencies(item); + indegrees[id] = indegrees.GetValueOrDefault(id); + + foreach (var dep in deps) + { + if (string.IsNullOrWhiteSpace(dep)) + { + continue; + } + + if (!nodes.ContainsKey(dep)) + { + logger?.LogError("Missing dependency {Dependency} for module {Module}.", dep, id); + throw new InvalidOperationException($"Missing dependency '{dep}' for module '{id}'."); + } + + indegrees[id] = indegrees.GetValueOrDefault(id) + 1; + var dependents = edges.GetValueOrDefault(dep); + if (dependents == null) + { + dependents = new List(); + edges[dep] = dependents; + } + dependents.Add(id); + } + } + + var queue = new Queue(indegrees.Where(kvp => kvp.Value == 0).Select(kvp => kvp.Key)); + var ordered = new List(); + + while (queue.TryDequeue(out var current)) + { + ordered.Add(nodes[current]); + + if (!edges.TryGetValue(current, out var dependents)) + { + continue; + } + + foreach (var dependent in dependents) + { + indegrees[dependent]--; + if (indegrees[dependent] == 0) + { + queue.Enqueue(dependent); + } + } + } + + if (ordered.Count != nodes.Count) + { + var remaining = indegrees.Where(kvp => kvp.Value > 0).Select(kvp => kvp.Key).ToList(); + logger?.LogError("Detected cyclic dependency among modules: {Modules}", string.Join(", ", remaining)); + throw new InvalidOperationException("Detected cyclic dependency among modules: " + string.Join(", ", remaining)); + } + + return ordered; + } +} + diff --git a/src/Modulus.Core/Runtime/ModuleLoadContext.cs b/src/Modulus.Core/Runtime/ModuleLoadContext.cs index 4e12fb1..f03bd31 100644 --- a/src/Modulus.Core/Runtime/ModuleLoadContext.cs +++ b/src/Modulus.Core/Runtime/ModuleLoadContext.cs @@ -1,5 +1,8 @@ +using System.Linq; using System.Reflection; using System.Runtime.Loader; +using Microsoft.Extensions.Logging; +using Modulus.Core.Architecture; namespace Modulus.Core.Runtime; @@ -9,18 +12,28 @@ namespace Modulus.Core.Runtime; public sealed class ModuleLoadContext : AssemblyLoadContext { private readonly string _basePath; + private readonly ISharedAssemblyCatalog _sharedAssemblies; + private readonly ILogger? _logger; - public ModuleLoadContext(string moduleId, string basePath) + public ModuleLoadContext(string moduleId, string basePath, ISharedAssemblyCatalog sharedAssemblies, ILogger? logger = null) : base(name: moduleId, isCollectible: true) { _basePath = basePath; + _sharedAssemblies = sharedAssemblies; + _logger = logger; } protected override Assembly? Load(AssemblyName assemblyName) { // 1. Force shared assemblies to be loaded from default context (Host) - if (IsSharedAssembly(assemblyName)) + if (_sharedAssemblies.IsShared(assemblyName)) { + var defaultAssembly = AssemblyLoadContext.Default.Assemblies.FirstOrDefault(a => + string.Equals(a.GetName().Name, assemblyName.Name, StringComparison.OrdinalIgnoreCase)); + if (defaultAssembly != null && AssemblyDomainInfo.GetDomainType(defaultAssembly) == Modulus.Architecture.AssemblyDomainType.Module) + { + _logger?.LogWarning("Assembly {Assembly} is requested as shared but declared Module.", assemblyName.Name); + } return null; // Delegate to default context } @@ -34,36 +47,4 @@ public ModuleLoadContext(string moduleId, string basePath) return null; } - private static bool IsSharedAssembly(AssemblyName assemblyName) - { - var name = assemblyName.Name; - if (string.IsNullOrEmpty(name)) return false; - - // Core Framework & Extensions - if (name.StartsWith("System.") || - name.StartsWith("Microsoft.") || - name.Equals("mscorlib") || - name.Equals("netstandard")) - { - return true; - } - - // Modulus Core Assemblies - if (name.Equals("Modulus.Core") || - name.Equals("Modulus.Sdk") || - name.Equals("Modulus.UI.Abstractions") || - name.Equals("Modulus.UI.Avalonia") || - name.Equals("Modulus.UI.Blazor")) - { - return true; - } - - // Avalonia Assemblies (Host should provide these) - if (name.StartsWith("Avalonia")) - { - return true; - } - - return false; - } } diff --git a/src/Modulus.Core/Runtime/ModuleLoader.cs b/src/Modulus.Core/Runtime/ModuleLoader.cs index 4b6234a..16ce9b4 100644 --- a/src/Modulus.Core/Runtime/ModuleLoader.cs +++ b/src/Modulus.Core/Runtime/ModuleLoader.cs @@ -1,20 +1,45 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Modulus.Sdk; +using Modulus.Core; +using Modulus.Core.Architecture; using Modulus.Core.Manifest; +using Modulus.Sdk; +using Modulus.UI.Abstractions; +using NuGet.Versioning; namespace Modulus.Core.Runtime; -public sealed class ModuleLoader : IModuleLoader +public interface IHostAwareModuleLoader +{ + void BindHostServices(IServiceProvider hostServices); +} + +public sealed class ModuleLoader : IModuleLoader, IHostAwareModuleLoader { private readonly RuntimeContext _runtimeContext; private readonly IManifestValidator _manifestValidator; private readonly ILogger _logger; + private readonly ISharedAssemblyCatalog _sharedAssemblyCatalog; + private readonly ModuleMetadataScanner _metadataScanner; + private IServiceProvider? _hostServices; - public ModuleLoader(RuntimeContext runtimeContext, IManifestValidator manifestValidator, ILogger logger) + public ModuleLoader(RuntimeContext runtimeContext, IManifestValidator manifestValidator, ISharedAssemblyCatalog sharedAssemblyCatalog, ILogger logger, IServiceProvider? hostServices = null) { _runtimeContext = runtimeContext; _manifestValidator = manifestValidator; + _sharedAssemblyCatalog = sharedAssemblyCatalog; _logger = logger; + _metadataScanner = new ModuleMetadataScanner(logger); + _hostServices = hostServices; + } + + public void BindHostServices(IServiceProvider hostServices) + { + _hostServices = hostServices; } public async Task LoadAsync(string packagePath, bool isSystem = false, CancellationToken cancellationToken = default) @@ -35,7 +60,14 @@ public ModuleLoader(RuntimeContext runtimeContext, IManifestValidator manifestVa return null; } - var manifestValid = await _manifestValidator.ValidateAsync(packagePath, manifestPath, manifest, cancellationToken).ConfigureAwait(false); + var hostType = _runtimeContext.HostType; + if (string.IsNullOrWhiteSpace(hostType)) + { + _logger.LogWarning("Host type is not set in RuntimeContext. Set host before loading modules."); + return null; + } + + var manifestValid = await _manifestValidator.ValidateAsync(packagePath, manifestPath, manifest, hostType, cancellationToken).ConfigureAwait(false); if (!manifestValid) { _logger.LogWarning("Manifest validation failed for {ManifestPath}.", manifestPath); @@ -49,16 +81,28 @@ public ModuleLoader(RuntimeContext runtimeContext, IManifestValidator manifestVa return existingModule!.Descriptor; } + if (!NuGetVersion.TryParse(manifest.Version, out _)) + { + _logger.LogWarning("Module {ModuleId} version {Version} is not a valid semantic version.", manifest.Id, manifest.Version); + return null; + } + + if (!EnsureDependenciesSatisfied(manifest)) + { + return null; + } + var descriptor = new ModuleDescriptor( manifest.Id, manifest.Version, manifest.DisplayName, manifest.Description, manifest.SupportedHosts); - var alc = new ModuleLoadContext(manifest.Id, packagePath); + var alc = new ModuleLoadContext(manifest.Id, packagePath, _sharedAssemblyCatalog, _logger); + var loadedAssemblies = new List(); // Get Current Host ID from RuntimeContext - var currentHostId = _runtimeContext.HostType; + var currentHostId = hostType; try { @@ -72,7 +116,11 @@ public ModuleLoader(RuntimeContext runtimeContext, IManifestValidator manifestVa continue; } - alc.LoadFromAssemblyPath(assemblyPath); + var assembly = alc.LoadFromAssemblyPath(assemblyPath); + if (assembly != null) + { + loadedAssemblies.Add(assembly); + } } // 2. Load UI Assemblies (Host-Specific) @@ -86,7 +134,11 @@ public ModuleLoader(RuntimeContext runtimeContext, IManifestValidator manifestVa _logger.LogWarning("UI Assembly {AssemblyPath} not found for module {ModuleId} host {HostType}.", assemblyPath, manifest.Id, currentHostId); continue; } - alc.LoadFromAssemblyPath(assemblyPath); + var assembly = alc.LoadFromAssemblyPath(assemblyPath); + if (assembly != null) + { + loadedAssemblies.Add(assembly); + } } } } @@ -97,15 +149,134 @@ public ModuleLoader(RuntimeContext runtimeContext, IManifestValidator manifestVa return null; } - var runtimeModule = new RuntimeModule(descriptor, alc, packagePath, isSystem); - _runtimeContext.RegisterModule(runtimeModule); - - _logger.LogInformation("Module {ModuleId} (v{Version}) loaded from {PackagePath} for host {HostType} (System: {IsSystem}).", manifest.Id, manifest.Version, packagePath, currentHostId ?? "None", isSystem); + if (loadedAssemblies.Count == 0 && alc.Assemblies.Any()) + { + loadedAssemblies.AddRange(alc.Assemblies); + } + + var moduleTypes = loadedAssemblies + .SelectMany(SafeGetTypes) + .Where(t => typeof(IModule).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface) + .ToList(); + + var moduleRegistrations = new List(); + foreach (var moduleType in moduleTypes) + { + try + { + var moduleInstance = CreateModuleInstance(moduleType); + if (moduleInstance == null) + { + continue; + } + + var moduleId = ResolveModuleId(moduleType, manifest.Id); + var dependencies = new HashSet(manifest.Dependencies.Keys, StringComparer.OrdinalIgnoreCase); + foreach (var attr in moduleType.GetCustomAttributes()) + { + foreach (var depType in attr.DependedModuleTypes) + { + var depId = ResolveModuleId(depType, depType.FullName ?? depType.Name); + dependencies.Add(depId); + } + } + + moduleRegistrations.Add(new ModuleRegistration(moduleInstance, moduleId, dependencies)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to instantiate module {ModuleType}", moduleType.FullName); + return null; + } + } + + var moduleIdSet = new HashSet(moduleRegistrations.Select(r => r.ModuleId), StringComparer.OrdinalIgnoreCase); + foreach (var registration in moduleRegistrations) + { + registration.Dependencies.RemoveWhere(dep => !moduleIdSet.Contains(dep)); + } + + var sortedModules = ModuleDependencyResolver.TopologicallySort( + moduleRegistrations, + r => r.ModuleId, + r => r.Dependencies, + _logger).Select(r => r.Instance).ToList(); + + var services = new ServiceCollection(); + services.AddSingleton(_runtimeContext); + services.AddSingleton(descriptor); + services.AddSingleton(manifest); + services.AddSingleton(alc); + + var lifecycleContext = new ModuleLifecycleContext(services); + foreach (var module in sortedModules) + { + module.PreConfigureServices(lifecycleContext); + } + + foreach (var module in sortedModules) + { + module.ConfigureServices(lifecycleContext); + } + + foreach (var module in sortedModules) + { + module.PostConfigureServices(lifecycleContext); + } + + var moduleProvider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + var moduleScope = moduleProvider.CreateScope(); + var scopedProvider = moduleScope.ServiceProvider; + var compositeProvider = _hostServices != null + ? new CompositeServiceProvider(scopedProvider, _hostServices) + : scopedProvider; + + var initContext = new ModuleInitializationContext(compositeProvider); + + var initialized = false; + try + { + foreach (var module in sortedModules) + { + try + { + await module.OnApplicationInitializationAsync(initContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initializing module {ModuleType}", module.GetType().Name); + return null; + } + } + + var registeredMenus = RegisterMenus(sortedModules, compositeProvider, currentHostId, manifest.Id).ToList(); + + var runtimeModule = new RuntimeModule(descriptor, alc, packagePath, manifest, isSystem) + { + State = ModuleState.Active + }; + + _runtimeContext.RegisterModule(runtimeModule); + var handle = new RuntimeModuleHandle(runtimeModule, manifest, moduleScope, moduleProvider, compositeProvider, sortedModules, registeredMenus, loadedAssemblies); + _runtimeContext.RegisterModuleHandle(handle); + initialized = true; + + _logger.LogInformation("Module {ModuleId} (v{Version}) loaded from {PackagePath} for host {HostType} (System: {IsSystem}).", manifest.Id, manifest.Version, packagePath, currentHostId ?? "None", isSystem); - return descriptor; + return descriptor; + } + finally + { + if (!initialized) + { + moduleScope.Dispose(); + moduleProvider.Dispose(); + alc.Unload(); + } + } } - public Task UnloadAsync(string moduleId) + public async Task UnloadAsync(string moduleId) { if (_runtimeContext.TryGetModule(moduleId, out var runtimeModule)) { @@ -116,27 +287,61 @@ public Task UnloadAsync(string moduleId) } _logger.LogInformation("Unloading module {ModuleId}...", moduleId); - - // 1. Remove from context - _runtimeContext.RemoveModule(moduleId); - // 2. Set state - if (runtimeModule != null) + RuntimeModuleHandle? handle = null; + if (_runtimeContext.TryGetModuleHandle(moduleId, out var storedHandle)) + { + handle = storedHandle; + } + + if (handle != null) { - runtimeModule.State = ModuleState.Unloaded; - - // 3. Unload ALC - runtimeModule.LoadContext.Unload(); + var shutdownContext = new ModuleInitializationContext(handle.CompositeServiceProvider); + var moduleInstances = handle.ModuleInstances.ToList(); + for (var i = moduleInstances.Count - 1; i >= 0; i--) + { + try + { + await moduleInstances[i].OnApplicationShutdownAsync(shutdownContext).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error shutting down module {ModuleType}", moduleInstances[i].GetType().Name); + } + } + + if (_hostServices?.GetService() is IMenuRegistry menuRegistry) + { + menuRegistry.UnregisterModuleItems(moduleId); + } + else if (handle.CompositeServiceProvider.GetService() is IMenuRegistry compositeRegistry) + { + compositeRegistry.UnregisterModuleItems(moduleId); + } + + await handle.DisposeAsync().ConfigureAwait(false); + _runtimeContext.RemoveModuleHandle(moduleId); + } + else + { + // Fallback: try to clean up menus if handle is missing (e.g. startup modules) + if (_hostServices?.GetService() is IMenuRegistry menuRegistry) + { + menuRegistry.UnregisterModuleItems(moduleId); + } } + _runtimeContext.RemoveModule(moduleId); + + runtimeModule.State = ModuleState.Unloaded; + runtimeModule.LoadContext.Unload(); + _logger.LogInformation("Module {ModuleId} unloaded.", moduleId); } else { _logger.LogWarning("Cannot unload module {ModuleId}: not found.", moduleId); } - - return Task.CompletedTask; } public async Task ReloadAsync(string moduleId, CancellationToken cancellationToken = default) @@ -144,6 +349,7 @@ public Task UnloadAsync(string moduleId) if (_runtimeContext.TryGetModule(moduleId, out var runtimeModule)) { var packagePath = runtimeModule!.PackagePath; + var isSystem = runtimeModule.IsSystem; await UnloadAsync(moduleId); // Allow some time for cleanup if necessary @@ -160,7 +366,7 @@ public Task UnloadAsync(string moduleId) // If we want to support system module reload, we should probably pass isSystem=true if it was system. // But we unloaded it, so we lost that info unless we kept it. // Let's default to false for now to fix the build error. - return await LoadAsync(packagePath, false, cancellationToken).ConfigureAwait(false); + return await LoadAsync(packagePath, isSystem, cancellationToken).ConfigureAwait(false); } _logger.LogWarning("Cannot reload module {ModuleId}: not found.", moduleId); @@ -182,4 +388,124 @@ public Task UnloadAsync(string moduleId) manifest.Description, manifest.SupportedHosts); } + + private bool EnsureDependenciesSatisfied(ModuleManifest manifest) + { + foreach (var (dependencyId, dependencyRange) in manifest.Dependencies) + { + if (!_runtimeContext.TryGetModule(dependencyId, out var dependencyModule) || dependencyModule == null) + { + _logger.LogWarning("Module {ModuleId} requires dependency {DependencyId} which is not loaded.", manifest.Id, dependencyId); + return false; + } + + if (!NuGetVersion.TryParse(dependencyModule.Descriptor.Version, out var dependencyVersion)) + { + _logger.LogWarning("Module {ModuleId} dependency {DependencyId} has invalid version {DependencyVersion}.", manifest.Id, dependencyId, dependencyModule.Descriptor.Version); + return false; + } + + if (!VersionRange.TryParse(dependencyRange, out var range)) + { + _logger.LogWarning("Module {ModuleId} dependency {DependencyId} has invalid version range {Range}.", manifest.Id, dependencyId, dependencyRange); + return false; + } + + if (!range.Satisfies(dependencyVersion)) + { + _logger.LogWarning("Module {ModuleId} dependency {DependencyId} version {DependencyVersion} does not satisfy range {Range}.", manifest.Id, dependencyId, dependencyVersion, dependencyRange); + return false; + } + } + + return true; + } + + private IModule? CreateModuleInstance(Type moduleType) + { + if (_hostServices != null) + { + try + { + return (IModule)ActivatorUtilities.CreateInstance(_hostServices, moduleType)!; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to create module {ModuleType} from host services, falling back to parameterless construction.", moduleType.FullName); + } + } + + return (IModule)Activator.CreateInstance(moduleType)!; + } + + private static string ResolveModuleId(Type moduleType, string fallback) + { + var moduleAttribute = moduleType.GetCustomAttribute(); + if (moduleAttribute != null && !string.IsNullOrWhiteSpace(moduleAttribute.Id)) + { + return moduleAttribute.Id; + } + + return fallback; + } + + private IEnumerable RegisterMenus(IReadOnlyCollection modules, IServiceProvider serviceProvider, string? hostType, string fallbackModuleId) + { + var menuRegistry = serviceProvider.GetService(); + if (menuRegistry == null || string.IsNullOrEmpty(hostType)) + { + return Array.Empty(); + } + + var menus = new List(); + + foreach (var module in modules) + { + var moduleType = module.GetType(); + List menuMetadata; + + if (hostType == HostType.Avalonia) + { + menuMetadata = _metadataScanner.ScanAvaloniaMenus(moduleType); + } + else if (hostType == HostType.Blazor) + { + menuMetadata = _metadataScanner.ScanBlazorMenus(moduleType); + } + else + { + continue; + } + + foreach (var menu in menuMetadata) + { + var navigationKey = !string.IsNullOrEmpty(menu.Route) ? menu.Route : menu.ViewModelType ?? menu.Id; + var item = new MenuItem($"{moduleType.Name}.{menu.Id}", menu.DisplayName, menu.Icon, navigationKey, menu.Location, menu.Order); + var moduleId = ResolveModuleId(moduleType, fallbackModuleId); + item.ModuleId = moduleId; + menuRegistry.Register(item); + menus.Add(item); + } + } + + return menus; + } + + private static IEnumerable SafeGetTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t != null)!; + } + catch + { + return Array.Empty(); + } + } + + private sealed record ModuleRegistration(IModule Instance, string ModuleId, HashSet Dependencies); } diff --git a/src/Modulus.Core/Runtime/ModuleManager.cs b/src/Modulus.Core/Runtime/ModuleManager.cs index 7464cbb..a3a835e 100644 --- a/src/Modulus.Core/Runtime/ModuleManager.cs +++ b/src/Modulus.Core/Runtime/ModuleManager.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Modulus.Sdk; @@ -11,56 +10,62 @@ namespace Modulus.Core.Runtime; public class ModuleManager { private readonly ILogger _logger; - private readonly List _modules = new(); + private readonly List _registrations = new(); public ModuleManager(ILogger logger) { _logger = logger; } - public void AddModule(IModule module) + public void AddModule(IModule module, string? moduleId = null, IReadOnlyCollection? manifestDependencies = null) { - _modules.Add(module); - } - - public IReadOnlyList GetModules() => _modules.AsReadOnly(); - - public IReadOnlyList GetSortedModules() - { - // Simple Topological Sort - var visited = new HashSet(); - var sorted = new List(); - - // Map Type -> Instance - var moduleMap = _modules.ToDictionary(m => m.GetType(), m => m); + var id = moduleId ?? ResolveModuleId(module.GetType()); + var dependencies = new HashSet(StringComparer.OrdinalIgnoreCase); - void Visit(IModule module) + if (manifestDependencies != null) { - var type = module.GetType(); - if (visited.Contains(type)) return; - visited.Add(type); - - var dependsOnAttrs = type.GetCustomAttributes(); - foreach (var attr in dependsOnAttrs) + foreach (var dep in manifestDependencies) { - foreach (var depType in attr.DependedModuleTypes) - { - if (moduleMap.TryGetValue(depType, out var depModule)) - { - Visit(depModule); - } - } + dependencies.Add(dep); } + } - sorted.Add(module); + var dependsOnAttrs = module.GetType().GetCustomAttributes(); + foreach (var attr in dependsOnAttrs) + { + foreach (var depType in attr.DependedModuleTypes) + { + dependencies.Add(ResolveModuleId(depType)); + } } - foreach (var module in _modules) + _registrations.Add(new ModuleRegistration(module, id, dependencies)); + } + + public IReadOnlyList GetModules() => _registrations.Select(r => r.Module).ToList().AsReadOnly(); + + public IReadOnlyList GetSortedModules() + { + var sorted = ModuleDependencyResolver.TopologicallySort( + _registrations, + r => r.Id, + r => r.Dependencies, + _logger); + + return sorted.Select(r => r.Module).ToList(); + } + + private static string ResolveModuleId(Type type) + { + var attr = type.GetCustomAttribute(); + if (attr != null && !string.IsNullOrWhiteSpace(attr.Id)) { - Visit(module); + return attr.Id; } - return sorted; + return type.FullName ?? type.Name; } + + private sealed record ModuleRegistration(IModule Module, string Id, HashSet Dependencies); } diff --git a/src/Modulus.Core/Runtime/ModulusApplication.cs b/src/Modulus.Core/Runtime/ModulusApplication.cs index f2cb29c..3342100 100644 --- a/src/Modulus.Core/Runtime/ModulusApplication.cs +++ b/src/Modulus.Core/Runtime/ModulusApplication.cs @@ -90,6 +90,11 @@ public void ConfigureServices() public void SetServiceProvider(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; + + if (_serviceProvider.GetService() is IHostAwareModuleLoader hostAwareLoader) + { + hostAwareLoader.BindHostServices(_serviceProvider); + } } public async Task InitializeAsync() @@ -170,6 +175,9 @@ private void RegisterMenuItemsFromAttributes() menu.Location, menu.Order ); + + var attr = moduleType.GetCustomAttribute(); + item.ModuleId = attr?.Id ?? moduleType.FullName ?? moduleType.Name; menuRegistry.Register(item); _logger.LogDebug("Registered menu: {DisplayName} -> {NavigationKey}", menu.DisplayName, navigationKey); diff --git a/src/Modulus.Core/Runtime/ModulusApplicationFactory.cs b/src/Modulus.Core/Runtime/ModulusApplicationFactory.cs index a195c8a..77b0cc1 100644 --- a/src/Modulus.Core/Runtime/ModulusApplicationFactory.cs +++ b/src/Modulus.Core/Runtime/ModulusApplicationFactory.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Modulus.Core.Architecture; using Modulus.Core.Manifest; using Modulus.Sdk; @@ -30,12 +31,15 @@ public static async Task CreateAsync( var signatureVerifier = new Sha256ManifestSignatureVerifier(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var manifestValidator = new DefaultManifestValidator(signatureVerifier, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - var moduleLoader = new ModuleLoader(runtimeContext, manifestValidator, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var sharedAssemblies = SharedAssemblyCatalog.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies()); + var moduleLoader = new ModuleLoader(runtimeContext, manifestValidator, sharedAssemblies, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + services.AddSingleton(sharedAssemblies); var moduleManagerLogger = loggerFactory.CreateLogger(); var moduleManager = new ModuleManager(moduleManagerLogger); - // 2. Load Modules (Discovery Phase using Providers) + // 2. Load Modules (Discovery Phase using Providers) with dependency ordering + var packageInfos = new List(); if (moduleProviders != null) { foreach (var provider in moduleProviders) @@ -45,17 +49,38 @@ public static async Task CreateAsync( { try { - // Pass IsSystemSource from provider - await moduleLoader.LoadAsync(path, provider.IsSystemSource).ConfigureAwait(false); + var manifestPath = Path.Combine(path, "manifest.json"); + var manifest = await ManifestReader.ReadFromFileAsync(manifestPath).ConfigureAwait(false); + if (manifest == null) + { + logger.LogWarning("Skipping module at {Path}: manifest not found.", path); + continue; + } + + packageInfos.Add(new ModulePackageInfo(path, manifest, provider.IsSystemSource)); } catch (Exception ex) { - logger.LogError(ex, "Failed to load module from {Path}", path); + logger.LogError(ex, "Failed to read manifest from {Path}", path); } } } } + var orderedPackages = OrderPackages(packageInfos, runtimeContext, logger); + + foreach (var package in orderedPackages) + { + try + { + await moduleLoader.LoadAsync(package.Path, package.IsSystem).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load module from {Path}", package.Path); + } + } + // 3. Register Modules to Manager // Add Startup Module moduleManager.AddModule(new TStartupModule()); @@ -77,7 +102,7 @@ public static async Task CreateAsync( { // logger.LogInformation("Found module type {Type}", type.FullName); var instance = (IModule)Activator.CreateInstance(type)!; - moduleManager.AddModule(instance); + moduleManager.AddModule(instance, runtimeModule.Descriptor.Id, runtimeModule.Manifest.Dependencies.Keys); } catch (Exception ex) { @@ -106,4 +131,44 @@ public static async Task CreateAsync( return app; } + + private static IReadOnlyList OrderPackages(IEnumerable packages, RuntimeContext runtimeContext, ILogger logger) + { + var packageList = packages.ToList(); + var packageIdSet = new HashSet(packageList.Select(p => p.Manifest.Id), StringComparer.OrdinalIgnoreCase); + + var registrations = new List(); + foreach (var package in packageList) + { + var deps = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var dependency in package.Manifest.Dependencies.Keys) + { + if (packageIdSet.Contains(dependency)) + { + deps.Add(dependency); + } + else if (!runtimeContext.TryGetModule(dependency, out _)) + { + logger.LogError("Module {ModuleId} is missing dependency {DependencyId}.", package.Manifest.Id, dependency); + throw new InvalidOperationException($"Missing dependency '{dependency}' for module '{package.Manifest.Id}'."); + } + } + + registrations.Add(new PackageRegistration(package, package.Manifest.Id, deps)); + } + + var sorted = ModuleDependencyResolver.TopologicallySort( + registrations, + r => r.ModuleId, + r => r.Dependencies, + logger); + + return sorted.Select(r => r.Package).ToList(); + } + + private sealed record ModulePackageInfo(string Path, ModuleManifest Manifest, bool IsSystem); + + private sealed record PackageRegistration(ModulePackageInfo Package, string ModuleId, HashSet Dependencies); } + + diff --git a/src/Modulus.Core/Runtime/RuntimeContext.cs b/src/Modulus.Core/Runtime/RuntimeContext.cs index b5b9433..2b010c2 100644 --- a/src/Modulus.Core/Runtime/RuntimeContext.cs +++ b/src/Modulus.Core/Runtime/RuntimeContext.cs @@ -13,6 +13,7 @@ public sealed class RuntimeContext // Use ConcurrentDictionary for thread safety private readonly ConcurrentDictionary _modules = new(); private readonly ConcurrentDictionary _hosts = new(); + private readonly ConcurrentDictionary _moduleHandles = new(); /// /// Gets the identifier of the currently running host (e.g. HostType.Blazor). @@ -23,6 +24,7 @@ public sealed class RuntimeContext // Allow access to full runtime info internally or for advanced scenarios public IReadOnlyCollection RuntimeModules => _modules.Values.ToList(); + public IReadOnlyCollection ModuleHandles => _moduleHandles.Values.ToList(); public IReadOnlyCollection Hosts => _hosts.Values.ToList(); @@ -53,6 +55,15 @@ public void RegisterModule(RuntimeModule module) } } + public void RegisterModuleHandle(RuntimeModuleHandle handle) + { + ArgumentNullException.ThrowIfNull(handle); + if (!_moduleHandles.TryAdd(handle.RuntimeModule.Descriptor.Id, handle)) + { + throw new InvalidOperationException($"Module handle {handle.RuntimeModule.Descriptor.Id} is already registered."); + } + } + public bool TryGetModule(string moduleId, out RuntimeModule? module) { return _modules.TryGetValue(moduleId, out module); @@ -63,4 +74,15 @@ public bool TryGetModule(string moduleId, out RuntimeModule? module) _modules.TryRemove(moduleId, out var module); return module; } + + public RuntimeModuleHandle? RemoveModuleHandle(string moduleId) + { + _moduleHandles.TryRemove(moduleId, out var handle); + return handle; + } + + public bool TryGetModuleHandle(string moduleId, out RuntimeModuleHandle? handle) + { + return _moduleHandles.TryGetValue(moduleId, out handle); + } } diff --git a/src/Modulus.Core/Runtime/RuntimeModule.cs b/src/Modulus.Core/Runtime/RuntimeModule.cs index 8c5fa53..a9f7f78 100644 --- a/src/Modulus.Core/Runtime/RuntimeModule.cs +++ b/src/Modulus.Core/Runtime/RuntimeModule.cs @@ -1,4 +1,5 @@ using System; +using Modulus.Sdk; namespace Modulus.Core.Runtime; @@ -10,16 +11,18 @@ public sealed class RuntimeModule public ModuleDescriptor Descriptor { get; } public ModuleLoadContext LoadContext { get; } public string PackagePath { get; } + public ModuleManifest Manifest { get; } public ModuleState State { get; set; } public Exception? LastError { get; set; } public bool IsSystem { get; } - public RuntimeModule(ModuleDescriptor descriptor, ModuleLoadContext loadContext, string packagePath, bool isSystem = false) + public RuntimeModule(ModuleDescriptor descriptor, ModuleLoadContext loadContext, string packagePath, ModuleManifest manifest, bool isSystem = false) { Descriptor = descriptor; LoadContext = loadContext; PackagePath = packagePath; + Manifest = manifest; State = ModuleState.Loaded; IsSystem = isSystem; } diff --git a/src/Modulus.Core/Runtime/RuntimeModuleHandle.cs b/src/Modulus.Core/Runtime/RuntimeModuleHandle.cs new file mode 100644 index 0000000..9eb52c6 --- /dev/null +++ b/src/Modulus.Core/Runtime/RuntimeModuleHandle.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Modulus.Sdk; +using Modulus.UI.Abstractions; + +namespace Modulus.Core.Runtime; + +public sealed class RuntimeModuleHandle : IAsyncDisposable, IDisposable +{ + public RuntimeModule RuntimeModule { get; } + public ModuleManifest Manifest { get; } + public IServiceProvider ServiceProvider { get; } + public IServiceProvider CompositeServiceProvider { get; } + public IReadOnlyCollection ModuleInstances { get; } + public IReadOnlyCollection RegisteredMenus { get; } + public IReadOnlyCollection Assemblies { get; } + + private readonly IServiceScope? _serviceScope; + + public RuntimeModuleHandle( + RuntimeModule runtimeModule, + ModuleManifest manifest, + IServiceScope? serviceScope, + IServiceProvider serviceProvider, + IServiceProvider compositeServiceProvider, + IReadOnlyCollection moduleInstances, + IReadOnlyCollection registeredMenus, + IReadOnlyCollection assemblies) + { + RuntimeModule = runtimeModule; + Manifest = manifest; + _serviceScope = serviceScope; + ServiceProvider = serviceProvider; + CompositeServiceProvider = compositeServiceProvider; + ModuleInstances = moduleInstances; + RegisteredMenus = registeredMenus; + Assemblies = assemblies; + } + + public void Dispose() + { + _serviceScope?.Dispose(); + (ServiceProvider as IDisposable)?.Dispose(); + } + + public ValueTask DisposeAsync() + { + if (_serviceScope is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync(); + } + + Dispose(); + return ValueTask.CompletedTask; + } +} + diff --git a/src/Modulus.Sdk/ModuleManifest.cs b/src/Modulus.Sdk/ModuleManifest.cs index 970bba6..70c61d9 100644 --- a/src/Modulus.Sdk/ModuleManifest.cs +++ b/src/Modulus.Sdk/ModuleManifest.cs @@ -39,6 +39,9 @@ public sealed class ModuleManifest [JsonPropertyName("dependencies")] public Dictionary Dependencies { get; init; } = new(); + [JsonPropertyName("assemblyHashes")] + public Dictionary AssemblyHashes { get; init; } = new(); + [JsonPropertyName("signature")] public ManifestSignature? Signature { get; init; } } diff --git a/src/Modulus.UI.Abstractions/IMenuRegistry.cs b/src/Modulus.UI.Abstractions/IMenuRegistry.cs index cab4d41..bdbac4b 100644 --- a/src/Modulus.UI.Abstractions/IMenuRegistry.cs +++ b/src/Modulus.UI.Abstractions/IMenuRegistry.cs @@ -5,7 +5,9 @@ namespace Modulus.UI.Abstractions; public interface IMenuRegistry { + event EventHandler MenuChanged; void Register(MenuItem item); + void Unregister(string id); IEnumerable GetItems(MenuLocation location); } diff --git a/tests/Modulus.Core.Tests/ModuleLoaderTests.cs b/tests/Modulus.Core.Tests/ModuleLoaderTests.cs index 4908c0d..9b728c7 100644 --- a/tests/Modulus.Core.Tests/ModuleLoaderTests.cs +++ b/tests/Modulus.Core.Tests/ModuleLoaderTests.cs @@ -1,5 +1,9 @@ +using System; +using System.Collections.Generic; +using System.IO; using System.Text.Json; using Microsoft.Extensions.Logging; +using Modulus.Core.Architecture; using Modulus.Core.Manifest; using Modulus.Core.Runtime; using Modulus.Sdk; @@ -14,6 +18,7 @@ public class ModuleLoaderTests : IDisposable private readonly IManifestValidator _validator; private readonly ILogger _logger; private readonly ModuleLoader _loader; + private readonly ISharedAssemblyCatalog _sharedCatalog; public ModuleLoaderTests() { @@ -22,11 +27,12 @@ public ModuleLoaderTests() _runtimeContext = new RuntimeContext(); _runtimeContext.SetCurrentHost(HostType.Avalonia); - + _sharedCatalog = SharedAssemblyCatalog.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies()); + _validator = Substitute.For(); _logger = Substitute.For>(); - _loader = new ModuleLoader(_runtimeContext, _validator, _logger); + _loader = new ModuleLoader(_runtimeContext, _validator, _sharedCatalog, _logger); } public void Dispose() @@ -46,7 +52,7 @@ public async Task LoadAsync_ValidModule_ReturnsDescriptorAndRegisters() var moduleId = "test-module-001"; var modulePath = CreateTestModule(moduleId, "1.0.0"); - _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); // Act @@ -57,7 +63,7 @@ public async Task LoadAsync_ValidModule_ReturnsDescriptorAndRegisters() Assert.Equal(moduleId, result.Id); Assert.True(_runtimeContext.TryGetModule(moduleId, out var runtimeModule)); - Assert.Equal(ModuleState.Loaded, runtimeModule!.State); + Assert.Equal(ModuleState.Active, runtimeModule!.State); } [Fact] @@ -67,7 +73,7 @@ public async Task LoadAsync_InvalidManifest_ReturnsNull() var moduleId = "invalid-module-001"; var modulePath = CreateTestModule(moduleId, "1.0.0"); - _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(false); // Act @@ -98,7 +104,7 @@ public async Task LoadAsync_DuplicateModule_ReturnsExistingDescriptor() var moduleId = "duplicate-module-001"; var modulePath = CreateTestModule(moduleId, "1.0.0"); - _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); // Act @@ -117,7 +123,7 @@ public async Task UnloadAsync_LoadedModule_RemovesFromContext() // Arrange var moduleId = "unload-module-001"; var modulePath = CreateTestModule(moduleId, "1.0.0"); - _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); await _loader.LoadAsync(modulePath); @@ -136,7 +142,7 @@ public async Task UnloadAsync_SystemModule_ThrowsException() // Arrange var moduleId = "system-module-001"; var modulePath = CreateTestModule(moduleId, "1.0.0"); - _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); await _loader.LoadAsync(modulePath, isSystem: true); @@ -151,7 +157,7 @@ public async Task ReloadAsync_LoadedModule_ReloadsSuccessfully() // Arrange var moduleId = "reload-module-001"; var modulePath = CreateTestModule(moduleId, "1.0.0"); - _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); await _loader.LoadAsync(modulePath); @@ -164,7 +170,7 @@ public async Task ReloadAsync_LoadedModule_ReloadsSuccessfully() Assert.NotNull(result); Assert.True(_runtimeContext.TryGetModule(moduleId, out var reloadedModule)); Assert.NotSame(originalModule, reloadedModule); - Assert.Equal(ModuleState.Loaded, reloadedModule!.State); + Assert.Equal(ModuleState.Active, reloadedModule!.State); } [Fact] @@ -193,7 +199,24 @@ public async Task GetDescriptorAsync_ValidManifest_ReturnsDescriptor() Assert.Equal("2.0.0", result.Version); } - private string CreateTestModule(string id, string version) + [Fact] + public async Task LoadAsync_MissingDependency_ReturnsNull() + { + // Arrange + var moduleId = "dependent-module-001"; + var modulePath = CreateTestModule(moduleId, "1.0.0", new Dictionary { { "missing-dep", "[1.0.0]" } }); + + _validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + // Act + var result = await _loader.LoadAsync(modulePath); + + // Assert + Assert.Null(result); + } + + private string CreateTestModule(string id, string version, Dictionary? dependencies = null) { var modulePath = Path.Combine(_testRoot, id); Directory.CreateDirectory(modulePath); @@ -206,7 +229,8 @@ private string CreateTestModule(string id, string version) SupportedHosts = new List { HostType.Avalonia, HostType.Blazor }, CoreAssemblies = new List(), DisplayName = $"Test Module {id}", - Description = "A test module for unit testing" + Description = "A test module for unit testing", + Dependencies = dependencies ?? new Dictionary() }; var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }); diff --git a/tests/Modulus.Core.Tests/ModuleManagerTests.cs b/tests/Modulus.Core.Tests/ModuleManagerTests.cs index f6fb9d0..6859ebb 100644 --- a/tests/Modulus.Core.Tests/ModuleManagerTests.cs +++ b/tests/Modulus.Core.Tests/ModuleManagerTests.cs @@ -67,6 +67,17 @@ public void GetSortedModules_HandlesNoDependencies() Assert.Equal(2, sorted.Count); } + [Fact] + public void GetSortedModules_MissingDependency_Throws() + { + // Arrange + var moduleWithMissingDep = new ModuleWithMissingDependency(); + _manager.AddModule(moduleWithMissingDep); + + // Act & Assert + Assert.Throws(() => _manager.GetSortedModules()); + } + // Test module classes private class TestModule : ModuleBase { } private class TestModule2 : ModuleBase { } @@ -75,5 +86,10 @@ private class TestModuleA : ModuleBase { } [DependsOn(typeof(TestModuleA))] private class TestModuleB : ModuleBase { } + + [DependsOn(typeof(ExternalDependency))] + private class ModuleWithMissingDependency : ModuleBase { } + + private class ExternalDependency : ModuleBase { } } diff --git a/tests/Modulus.Core.Tests/RuntimeContextTests.cs b/tests/Modulus.Core.Tests/RuntimeContextTests.cs index 208cd1f..f916cec 100644 --- a/tests/Modulus.Core.Tests/RuntimeContextTests.cs +++ b/tests/Modulus.Core.Tests/RuntimeContextTests.cs @@ -1,4 +1,5 @@ using Modulus.Core.Runtime; +using Modulus.Core.Architecture; namespace Modulus.Core.Tests; @@ -23,8 +24,10 @@ public void RegisterModule_AddsModuleToContext() // Arrange var context = new RuntimeContext(); var descriptor = new ModuleDescriptor("test-module", "1.0.0", "Test", "Description", new[] { HostType.Avalonia }); - var loadContext = new ModuleLoadContext("test-module", "/path/to/module"); - var runtimeModule = new RuntimeModule(descriptor, loadContext, "/path/to/module", false); + var sharedCatalog = SharedAssemblyCatalog.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies()); + var loadContext = new ModuleLoadContext("test-module", "/path/to/module", sharedCatalog); + var manifest = new Modulus.Sdk.ModuleManifest { Id = "test-module", Version = "1.0.0" }; + var runtimeModule = new RuntimeModule(descriptor, loadContext, "/path/to/module", manifest, false); // Act context.RegisterModule(runtimeModule); @@ -40,8 +43,10 @@ public void RemoveModule_RemovesModuleFromContext() // Arrange var context = new RuntimeContext(); var descriptor = new ModuleDescriptor("test-module", "1.0.0", "Test", "Description", new[] { HostType.Avalonia }); - var loadContext = new ModuleLoadContext("test-module", "/path/to/module"); - var runtimeModule = new RuntimeModule(descriptor, loadContext, "/path/to/module", false); + var sharedCatalog = SharedAssemblyCatalog.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies()); + var loadContext = new ModuleLoadContext("test-module", "/path/to/module", sharedCatalog); + var manifest = new Modulus.Sdk.ModuleManifest { Id = "test-module", Version = "1.0.0" }; + var runtimeModule = new RuntimeModule(descriptor, loadContext, "/path/to/module", manifest, false); context.RegisterModule(runtimeModule); // Act @@ -72,12 +77,15 @@ public void RuntimeModules_ReturnsAllRegisteredModules() var context = new RuntimeContext(); var descriptor1 = new ModuleDescriptor("module-1", "1.0.0", "Module 1", "Desc", new[] { HostType.Avalonia }); - var loadContext1 = new ModuleLoadContext("module-1", "/path/1"); - var module1 = new RuntimeModule(descriptor1, loadContext1, "/path/1", false); + var sharedCatalog = SharedAssemblyCatalog.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies()); + var loadContext1 = new ModuleLoadContext("module-1", "/path/1", sharedCatalog); + var manifest1 = new Modulus.Sdk.ModuleManifest { Id = "module-1", Version = "1.0.0" }; + var module1 = new RuntimeModule(descriptor1, loadContext1, "/path/1", manifest1, false); var descriptor2 = new ModuleDescriptor("module-2", "1.0.0", "Module 2", "Desc", new[] { HostType.Blazor }); - var loadContext2 = new ModuleLoadContext("module-2", "/path/2"); - var module2 = new RuntimeModule(descriptor2, loadContext2, "/path/2", false); + var loadContext2 = new ModuleLoadContext("module-2", "/path/2", sharedCatalog); + var manifest2 = new Modulus.Sdk.ModuleManifest { Id = "module-2", Version = "1.0.0" }; + var module2 = new RuntimeModule(descriptor2, loadContext2, "/path/2", manifest2, false); context.RegisterModule(module1); context.RegisterModule(module2); diff --git a/tests/Modulus.Hosts.Tests/ModulusApplicationIntegrationTests.cs b/tests/Modulus.Hosts.Tests/ModulusApplicationIntegrationTests.cs index 3ade307..d7bca9b 100644 --- a/tests/Modulus.Hosts.Tests/ModulusApplicationIntegrationTests.cs +++ b/tests/Modulus.Hosts.Tests/ModulusApplicationIntegrationTests.cs @@ -123,7 +123,7 @@ public async Task ModuleLoader_EnableDisableReload_WorksCorrectly() // Act & Assert - Verify module is loaded Assert.True(runtimeContext.TryGetModule("reload-test-module", out var module)); - Assert.Equal(ModuleState.Loaded, module!.State); + Assert.Equal(ModuleState.Active, module!.State); // Unload await loader.UnloadAsync("reload-test-module"); @@ -133,7 +133,7 @@ public async Task ModuleLoader_EnableDisableReload_WorksCorrectly() var reloaded = await loader.LoadAsync(modulePath); Assert.NotNull(reloaded); Assert.True(runtimeContext.TryGetModule("reload-test-module", out var reloadedModule)); - Assert.Equal(ModuleState.Loaded, reloadedModule!.State); + Assert.Equal(ModuleState.Active, reloadedModule!.State); } private string CreateTestModule(string id, string version) diff --git a/tests/Modulus.Modules.Tests/EchoPluginTests.cs b/tests/Modulus.Modules.Tests/EchoPluginTests.cs index 5ea68cc..d9a3568 100644 --- a/tests/Modulus.Modules.Tests/EchoPluginTests.cs +++ b/tests/Modulus.Modules.Tests/EchoPluginTests.cs @@ -1,3 +1,5 @@ +using Modulus.Core; +using Modulus.Core.Architecture; using Modulus.Core.Manifest; using Modulus.Core.Runtime; using Microsoft.Extensions.Logging.Abstractions; @@ -24,9 +26,11 @@ public async Task Can_Load_EchoPlugin_From_Output() } var runtimeContext = new RuntimeContext(); + runtimeContext.SetCurrentHost(HostType.Avalonia); var signatureVerifier = new Sha256ManifestSignatureVerifier(NullLogger.Instance); var manifestValidator = new DefaultManifestValidator(signatureVerifier, NullLogger.Instance); - var loader = new ModuleLoader(runtimeContext, manifestValidator, NullLogger.Instance); + var sharedAssemblies = SharedAssemblyCatalog.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies()); + var loader = new ModuleLoader(runtimeContext, manifestValidator, sharedAssemblies, NullLogger.Instance); // Act var descriptor = await loader.LoadAsync(echoPluginPath); @@ -58,9 +62,11 @@ public async Task Can_Load_EchoPlugin_From_DevOutput() } var runtimeContext = new RuntimeContext(); + runtimeContext.SetCurrentHost(HostType.Avalonia); var signatureVerifier = new Sha256ManifestSignatureVerifier(NullLogger.Instance); var manifestValidator = new DefaultManifestValidator(signatureVerifier, NullLogger.Instance); - var loader = new ModuleLoader(runtimeContext, manifestValidator, NullLogger.Instance); + var sharedAssemblies = SharedAssemblyCatalog.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies()); + var loader = new ModuleLoader(runtimeContext, manifestValidator, sharedAssemblies, NullLogger.Instance); // Act var descriptor = await loader.LoadAsync(devOutputPath); From 0df8d8a4077afb5f111a71072ba61f9670cca49f Mon Sep 17 00:00:00 2001 From: "Novo.Xi" Date: Sun, 7 Dec 2025 23:34:57 +0800 Subject: [PATCH 2/2] chore: commit pending workspace changes --- .nuke/build.schema.json | 15 +- .specify/memory/constitution.md | 191 -------- .../powershell/check-prerequisites.ps1 | 148 ------ .specify/scripts/powershell/common.ps1 | 137 ------ .../scripts/powershell/create-new-feature.ps1 | 327 ------------- .specify/scripts/powershell/setup-plan.ps1 | 61 --- .../powershell/update-agent-context.ps1 | 445 ------------------ .specify/templates/agent-file-template.md | 28 -- .specify/templates/checklist-template.md | 40 -- .specify/templates/plan-template.md | 120 ----- .specify/templates/spec-template.md | 149 ------ .specify/templates/tasks-template.md | 257 ---------- Modulus.sln | 15 + build/BuildTasks.cs | 242 +++++++++- .../changes/database-driven-modules/design.md | 70 +++ .../database-driven-modules/proposal.md | 32 ++ .../specs/runtime/spec.md | 57 +++ .../changes/database-driven-modules/tasks.md | 33 ++ scripts/deploy-module.ps1 | 42 -- scripts/deploy-shell.ps1 | 33 -- .../contracts/runtime-contracts.md | 65 --- specs/001-core-architecture/data-model.md | 144 ------ specs/001-core-architecture/plan.md | 124 ----- specs/001-core-architecture/quickstart.md | 320 ------------- specs/001-core-architecture/research.md | 101 ---- specs/001-core-architecture/spec.md | 231 --------- specs/001-core-architecture/tasks.md | 205 -------- src/Hosts/Modulus.Host.Avalonia/App.axaml.cs | 95 ++-- .../Modulus.Host.Avalonia.csproj | 12 + .../Services/AvaloniaUIFactory.cs | 29 +- .../Shell/Services/MenuRegistry.cs | 13 +- .../Shell/ViewModels/ModuleListViewModel.cs | 394 ++++++++++++---- .../Shell/ViewModels/ShellViewModel.cs | 70 ++- .../Shell/Views/ModuleListView.axaml | 295 ++++++++---- .../Modulus.Host.Avalonia/appsettings.json | 6 + .../Components/Layout/MainLayout.razor | 4 +- .../Components/Pages/Modules.razor | 247 +++++----- src/Hosts/Modulus.Host.Blazor/MauiProgram.cs | 75 +-- .../Modulus.Host.Blazor.csproj | 10 + .../Shell/Services/MenuRegistry.cs | 14 +- .../Shell/ViewModels/ModuleListViewModel.cs | 266 ++++++++--- .../Shell/ViewModels/ShellViewModel.cs | 58 ++- .../Modulus.Host.Blazor/appsettings.json | 6 + .../ComponentsDemoModule.cs | 3 +- .../ComponentsDemoAvaloniaModule.cs | 1 + .../ComponentsDemoBlazorModule.cs | 6 +- .../Pages/BadgeNavDemo.razor | 2 +- .../Pages/BasicNavDemo.razor | 2 +- .../Pages/DisabledNavDemo.razor | 2 +- .../EchoPlugin.Core/EchoPluginModule.cs | 6 +- .../EchoPluginAvaloniaModule.cs | 1 + .../EchoPluginBlazorModule.cs | 2 +- .../SimpleNotes.Core/SimpleNotesModule.cs | 3 +- .../SimpleNotesBlazorModule.cs | 2 +- .../Data/DatabaseServiceExtensions.cs | 16 +- src/Modulus.Core/Data/EfAppDatabase.cs | 84 +--- .../Data/Entities/InstalledModule.cs | 31 -- src/Modulus.Core/Data/IAppDatabase.cs | 10 - src/Modulus.Core/Data/ModulusDbContext.cs | 41 -- .../Hosting/ModulusHostBuilderExtensions.cs | 19 +- .../Installation/HostModuleSeeder.cs | 102 ++++ .../Installation/IModuleInstallerService.cs | 22 + .../Installation/ModuleInstallerService.cs | 234 +++++++++ .../Installation/ModuleIntegrityChecker.cs | 56 +++ .../Installation/SystemModuleSeeder.cs | 77 +++ src/Modulus.Core/Manifest/ManifestReader.cs | 8 +- src/Modulus.Core/Modulus.Core.csproj | 1 + .../Runtime/ComponentDependencyResolver.cs | 101 ++++ src/Modulus.Core/Runtime/IModuleLoader.cs | 3 +- src/Modulus.Core/Runtime/ModuleLoadContext.cs | 2 +- src/Modulus.Core/Runtime/ModuleLoader.cs | 243 +++++----- src/Modulus.Core/Runtime/ModuleManager.cs | 14 + .../Runtime/ModulusApplication.cs | 137 +++--- .../Runtime/ModulusApplicationFactory.cs | 223 +++++---- .../Runtime/RuntimeModuleHandle.cs | 11 +- src/Modulus.Sdk/ModuleManifest.cs | 12 + .../{ModuleBase.cs => ModulusComponent.cs} | 9 +- src/Modulus.Sdk/ToolPluginBase.cs | 2 +- src/Modulus.UI.Abstractions/IMenuRegistry.cs | 3 +- src/Modulus.UI.Abstractions/MenuItem.cs | 5 + .../Messages/MenuMessages.cs | 19 + .../Infrastructure/AvaloniaModuleBase.cs | 2 +- .../Themes/Controls/Buttons.axaml | 56 +++ src/Modulus.UI.Avalonia/Themes/Generic.axaml | 1 + .../20251206072539_InitialCreate.Designer.cs | 112 +++++ .../20251206072539_InitialCreate.cs | 72 +++ ...251206100637_AddEntryComponent.Designer.cs | 115 +++++ .../20251206100637_AddEntryComponent.cs | 28 ++ ...32255_AddMenuLocationToModules.Designer.cs | 120 +++++ ...20251206132255_AddMenuLocationToModules.cs | 29 ++ ...33251_FixNonSystemMenuLocation.Designer.cs | 120 +++++ ...20251206133251_FixNonSystemMenuLocation.cs | 31 ++ .../20251206143250_AddAppSettings.Designer.cs | 139 ++++++ .../20251206143250_AddAppSettings.cs | 35 ++ ...207125958_AddModuleDescription.Designer.cs | 142 ++++++ .../20251207125958_AddModuleDescription.cs | 28 ++ .../ModulusDbContextModelSnapshot.cs | 139 ++++++ .../Models/AppSettingEntity.cs} | 4 +- .../Models/MenuEntity.cs | 31 ++ .../Models/ModuleEntity.cs | 57 +++ .../Models/ModuleState.cs | 10 + .../Modulus.Infrastructure.Data.csproj | 21 + .../ModulusDbContext.cs | 50 ++ .../ModulusDbContextFactory.cs | 16 + .../Repositories/IRepositories.cs | 24 + .../Repositories/Repositories.cs | 141 ++++++ .../Modulus.Infrastructure.Data/modulus.db | Bin 0 -> 45056 bytes .../Modulus.Core.Tests/ModuleManagerTests.cs | 12 +- .../ModulusApplicationIntegrationTests.cs | 6 +- 109 files changed, 4179 insertions(+), 4103 deletions(-) delete mode 100644 .specify/memory/constitution.md delete mode 100644 .specify/scripts/powershell/check-prerequisites.ps1 delete mode 100644 .specify/scripts/powershell/common.ps1 delete mode 100644 .specify/scripts/powershell/create-new-feature.ps1 delete mode 100644 .specify/scripts/powershell/setup-plan.ps1 delete mode 100644 .specify/scripts/powershell/update-agent-context.ps1 delete mode 100644 .specify/templates/agent-file-template.md delete mode 100644 .specify/templates/checklist-template.md delete mode 100644 .specify/templates/plan-template.md delete mode 100644 .specify/templates/spec-template.md delete mode 100644 .specify/templates/tasks-template.md create mode 100644 openspec/changes/database-driven-modules/design.md create mode 100644 openspec/changes/database-driven-modules/proposal.md create mode 100644 openspec/changes/database-driven-modules/specs/runtime/spec.md create mode 100644 openspec/changes/database-driven-modules/tasks.md delete mode 100644 scripts/deploy-module.ps1 delete mode 100644 scripts/deploy-shell.ps1 delete mode 100644 specs/001-core-architecture/contracts/runtime-contracts.md delete mode 100644 specs/001-core-architecture/data-model.md delete mode 100644 specs/001-core-architecture/plan.md delete mode 100644 specs/001-core-architecture/quickstart.md delete mode 100644 specs/001-core-architecture/research.md delete mode 100644 specs/001-core-architecture/spec.md delete mode 100644 specs/001-core-architecture/tasks.md create mode 100644 src/Hosts/Modulus.Host.Avalonia/appsettings.json create mode 100644 src/Hosts/Modulus.Host.Blazor/appsettings.json delete mode 100644 src/Modulus.Core/Data/Entities/InstalledModule.cs delete mode 100644 src/Modulus.Core/Data/ModulusDbContext.cs create mode 100644 src/Modulus.Core/Installation/HostModuleSeeder.cs create mode 100644 src/Modulus.Core/Installation/IModuleInstallerService.cs create mode 100644 src/Modulus.Core/Installation/ModuleInstallerService.cs create mode 100644 src/Modulus.Core/Installation/ModuleIntegrityChecker.cs create mode 100644 src/Modulus.Core/Installation/SystemModuleSeeder.cs create mode 100644 src/Modulus.Core/Runtime/ComponentDependencyResolver.cs rename src/Modulus.Sdk/{ModuleBase.cs => ModulusComponent.cs} (73%) create mode 100644 src/Modulus.UI.Abstractions/Messages/MenuMessages.cs create mode 100644 src/Modulus.UI.Avalonia/Themes/Controls/Buttons.axaml create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251206072539_InitialCreate.Designer.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251206072539_InitialCreate.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251206100637_AddEntryComponent.Designer.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251206100637_AddEntryComponent.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251206132255_AddMenuLocationToModules.Designer.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251206132255_AddMenuLocationToModules.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251206133251_FixNonSystemMenuLocation.Designer.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251206133251_FixNonSystemMenuLocation.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251206143250_AddAppSettings.Designer.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251206143250_AddAppSettings.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251207125958_AddModuleDescription.Designer.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/20251207125958_AddModuleDescription.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Migrations/ModulusDbContextModelSnapshot.cs rename src/{Modulus.Core/Data/Entities/AppSetting.cs => Shared/Modulus.Infrastructure.Data/Models/AppSettingEntity.cs} (82%) create mode 100644 src/Shared/Modulus.Infrastructure.Data/Models/MenuEntity.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Models/ModuleEntity.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Models/ModuleState.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Modulus.Infrastructure.Data.csproj create mode 100644 src/Shared/Modulus.Infrastructure.Data/ModulusDbContext.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/ModulusDbContextFactory.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Repositories/IRepositories.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/Repositories/Repositories.cs create mode 100644 src/Shared/Modulus.Infrastructure.Data/modulus.db diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index e8cb077..295b75a 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -26,15 +26,16 @@ "enum": [ "Build", "BuildAll", + "BuildApp", + "BuildModule", "Clean", "CleanPluginsArtifacts", + "Compile", "Default", "Pack", "Plugin", "Restore", "Run", - "StartAI", - "SyncAIManifest", "Test" ] }, @@ -125,17 +126,13 @@ "type": "string", "description": "Operation to perform: 'all' (default) or 'single'" }, - "role": { - "type": "string", - "description": "Role to filter context (e.g., Backend, Frontend, Plugin)" - }, "Solution": { "type": "string", "description": "Path to a solution file that is automatically loaded" }, - "verbose": { - "type": "boolean", - "description": "Verbosity for ManifestSync tool (true/false)" + "target-host": { + "type": "string", + "description": "Target host to build for: 'avalonia' (default), 'blazor', or 'all'" } } }, diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md deleted file mode 100644 index 7911cca..0000000 --- a/.specify/memory/constitution.md +++ /dev/null @@ -1,191 +0,0 @@ - - -# Modulus Constitution - -## Core Principles - -### UI-Agnostic Core - -- Core libraries in the Domain and Application layers MUST NOT reference any concrete UI - framework (for example `Microsoft.AspNetCore.Components`, `Avalonia`, or HTML/XAML types). -- All user interaction flows MUST be expressed in terms of `Modulus.UI.Abstractions` contracts - (such as `IUIFactory`, `IViewHost`, and related interfaces). -- Cross-cutting concerns (logging, configuration, localization) in core layers MUST remain - independent of any UI host and be injectable from the outside. -- Rationale: This keeps the business model portable across Blazor, Avalonia, CLI tools, and - future hosts. - -### Dual-Engine Host Architecture - -- The system MUST support at least two first-class hosts: `Modulus.Host.Blazor` (web ecosystem / - hybrid) and `Modulus.Host.Avalonia` (native rendering). -- Each module MAY provide multiple UI assemblies (for example `Module.UI.Blazor.dll`, - `Module.UI.Avalonia.dll`) that implement the same abstraction contracts for different hosts. -- Hosts are responsible for application shell concerns (windowing, routing, environment - integration) while modules are responsible for business logic and UI contracts only. -- Rationale: This allows the same module to be reused across lightweight web-style UIs and - high-performance native experiences. - -### Vertical Slice Modularity - -- The primary delivery unit is a module; each feature MUST be delivered as a module that - represents a vertical slice through the architecture. -- A module MAY implement one or more layers (for example Domain + Application only, or a full - stack including Presentation), but MUST declare its boundaries and registrations via - dependency injection. -- Runtime discovery MUST treat built-in features and external plugins uniformly, based on module - metadata and assembly scanning, without hidden "special core" modules. -- Rationale: Vertical slices keep features independently testable, deployable, and removable - without impacting unrelated areas. - -### Pyramid Layering - -- Dependencies MUST flow only in this direction: - Presentation → UI Abstraction → Application → Domain → Infrastructure. -- Cross-layer shortcuts (for example UI calling Infrastructure directly, or Application - depending on concrete UI frameworks) are forbidden. -- Communication between modules MUST occur via MediatR or well-defined interfaces, never - through direct coupling between feature implementations. -- Rationale: A strict dependency pyramid keeps the runtime composable, testable, and - host-agnostic. - -### AI-Friendly Contracts & Plugin SDK - -- Public plugin and module contracts MUST be strongly typed, self-describing, and use explicit - DTOs for inputs, outputs, and errors. -- The SDK MUST provide opinionated base classes and helpers (for example `BlazorToolPluginBase`, - `AvaloniaToolPluginBase`, and module base types) that encode recommended patterns for AI and - human authors. -- Any breaking change to public contracts MUST be versioned, documented in specifications, and - accompanied by migration guidance. -- Rationale: Clear contracts enable AI agents to generate high-quality plugins that compile and - behave correctly on first attempt. - -### Modern .NET & Technology Discipline - -- The project MUST target a current LTS or Current .NET runtime; introducing legacy frameworks - or outdated runtimes requires explicit justification and governance review. -- Core libraries MUST NOT depend on web-only constructs such as `HttpContext` or - environment-specific APIs that would prevent reuse across hosts. -- MediatR MUST be the default choice for in-process cross-module communication to avoid ad hoc - event or static coupling. -- ViewModel implementations MUST use `CommunityToolkit.Mvvm` (Source Generators, `ObservableObject`, `RelayCommand`) to standardize MVVM patterns and avoid boilerplate. -- Rationale: A disciplined, modern stack reduces maintenance cost and keeps the framework - portable. - -## Architecture & Additional Constraints - -### Module structure - -- Each feature MUST be represented as a module with a clear root namespace and assembly set, - typically following patterns such as `Modulus.Modules..Domain`, `...Application`, - `...Infrastructure`, and optional `...UI.Blazor` / `...UI.Avalonia`. -- Modules MAY implement only the layers they need (for example a pure infrastructure module or a - domain-only module), but MUST respect the dependency pyramid and expose clear integration - points. -- Module registration MUST be driven by DI and metadata (for example module attributes or - manifests), not by hard-coded lists in host applications. - -### Hosts and UI assemblies - -- Modules MAY ship separate UI assemblies for different hosts (for example `Module.UI.Blazor.dll` - and `Module.UI.Avalonia.dll`) implementing the same UI abstraction contracts. -- Hosts are responsible for resolving and loading the appropriate UI assemblies for the active - environment, leaving core assemblies reusable across all hosts. -- Presentation-layer projects MAY depend on host-specific frameworks (Blazor, Avalonia, - MAUI/Photino), but MUST only communicate with core logic through the UI abstraction layer, - Application services, and MediatR. - -### Plugin packaging and discovery - -- Plugin packages SHOULD use a structured container format (for example `.modpkg`) containing a - manifest plus assemblies for core and UI layers, as defined in the architecture docs. -- Plugin entry points MUST declare themselves via the Modulus SDK (for example module base - types or explicit plugin descriptors) so that discovery and unloading rely on clear contracts - rather than reflection heuristics. -- Runtime discovery MUST apply the same rules to built-in modules and external plugin packages - to ensure consistent behavior and isolation. -- Rationale: A consistent packaging and discovery model simplifies deployment, enables - hot-reload and unloading, and allows AI to generate deployable plugins. - -## Development Workflow & AI Collaboration - -### Planning and Constitution Check - -- Every implementation plan generated from `/speckit.plan` MUST include a "Constitution Check" - section that evaluates the feature against each core principle: - UI-agnostic core, dual-engine host architecture, vertical slice modularity, pyramid layering, - AI-friendly contracts, and modern .NET discipline. -- A plan MUST NOT proceed past Phase 0 research unless all identified constitutional risks have - either a mitigation plan or an explicit governance decision. - -### Specifications - -- Feature specifications (`/speckit.specify`) MUST state: - - Which module(s) own the feature as a vertical slice. - - Which host(s) (Blazor, Avalonia, or both) the feature targets. - - Any new or changed public contracts, DTOs, or SDK base types that affect plugins or AI - integration. -- Requirements MUST remain technology-agnostic at the Domain and Application layers, expressing - behavior without binding to a specific UI framework. - -### Tasks and implementation - -- Task breakdowns (`/speckit.tasks`) MUST group work by user story and also make module and host - boundaries explicit in task descriptions (for example which module and which UI assembly a - task touches). -- Foundational tasks MUST cover: - - Enforcing the dependency pyramid in project references and namespaces. - - Configuring MediatR for module-level and cross-module communication. - - Ensuring no Domain/Application projects reference concrete UI frameworks. -- Cross-cutting tasks MAY include constitution compliance checks, architecture reviews, and - updates to AI manifests used by `nuke StartAI`. - -### AI-assisted development - -- AI tools (for example GitHub Copilot) MUST consume up-to-date project context, including this - constitution, before generating significant architecture or plugin code. -- When generating plugins or modules, AI prompts and manifests SHOULD reference the official - SDK base types and contracts defined by Modulus, instead of ad hoc patterns. -- Changes produced with AI assistance MUST still pass all constitutional gates in plans, specs, - and reviews. - -## Governance - -- This constitution supersedes conflicting practices in older documentation or legacy code for - this repository. -- Amendments to the constitution MUST be proposed through design stories and specifications that - explain the motivation, affected modules, and migration considerations. -- The constitution uses semantic versioning in the form `MAJOR.MINOR.PATCH`: - - MAJOR: Backward-incompatible changes to principles or governance (for example removing or - redefining a principle). - - MINOR: New principles or sections added, or materially expanded guidance. - - PATCH: Clarifications, wording adjustments, and non-semantic refinements. -- All implementation plans MUST pass the Constitution Check before work begins, and pull - request reviews MUST verify that new code and modules adhere to UI agnosticism, dual host - support, vertical slice modularity, pyramid layering, AI-friendly contracts, and technology - discipline. -- Governance decisions and exceptions (if any) MUST be documented alongside the affected - features and referenced from the relevant plan and spec files. - -**Version**: 1.1.0 | **Ratified**: 2025-11-27 | **Last Amended**: 2025-12-01 - diff --git a/.specify/scripts/powershell/check-prerequisites.ps1 b/.specify/scripts/powershell/check-prerequisites.ps1 deleted file mode 100644 index 91667e9..0000000 --- a/.specify/scripts/powershell/check-prerequisites.ps1 +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env pwsh - -# Consolidated prerequisite checking script (PowerShell) -# -# This script provides unified prerequisite checking for Spec-Driven Development workflow. -# It replaces the functionality previously spread across multiple scripts. -# -# Usage: ./check-prerequisites.ps1 [OPTIONS] -# -# OPTIONS: -# -Json Output in JSON format -# -RequireTasks Require tasks.md to exist (for implementation phase) -# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list -# -PathsOnly Only output path variables (no validation) -# -Help, -h Show help message - -[CmdletBinding()] -param( - [switch]$Json, - [switch]$RequireTasks, - [switch]$IncludeTasks, - [switch]$PathsOnly, - [switch]$Help -) - -$ErrorActionPreference = 'Stop' - -# Show help if requested -if ($Help) { - Write-Output @" -Usage: check-prerequisites.ps1 [OPTIONS] - -Consolidated prerequisite checking for Spec-Driven Development workflow. - -OPTIONS: - -Json Output in JSON format - -RequireTasks Require tasks.md to exist (for implementation phase) - -IncludeTasks Include tasks.md in AVAILABLE_DOCS list - -PathsOnly Only output path variables (no prerequisite validation) - -Help, -h Show this help message - -EXAMPLES: - # Check task prerequisites (plan.md required) - .\check-prerequisites.ps1 -Json - - # Check implementation prerequisites (plan.md + tasks.md required) - .\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks - - # Get feature paths only (no validation) - .\check-prerequisites.ps1 -PathsOnly - -"@ - exit 0 -} - -# Source common functions -. "$PSScriptRoot/common.ps1" - -# Get feature paths and validate branch -$paths = Get-FeaturePathsEnv - -if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { - exit 1 -} - -# If paths-only mode, output paths and exit (support combined -Json -PathsOnly) -if ($PathsOnly) { - if ($Json) { - [PSCustomObject]@{ - REPO_ROOT = $paths.REPO_ROOT - BRANCH = $paths.CURRENT_BRANCH - FEATURE_DIR = $paths.FEATURE_DIR - FEATURE_SPEC = $paths.FEATURE_SPEC - IMPL_PLAN = $paths.IMPL_PLAN - TASKS = $paths.TASKS - } | ConvertTo-Json -Compress - } else { - Write-Output "REPO_ROOT: $($paths.REPO_ROOT)" - Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" - Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)" - Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)" - Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" - Write-Output "TASKS: $($paths.TASKS)" - } - exit 0 -} - -# Validate required directories and files -if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { - Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)" - Write-Output "Run /speckit.specify first to create the feature structure." - exit 1 -} - -if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { - Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)" - Write-Output "Run /speckit.plan first to create the implementation plan." - exit 1 -} - -# Check for tasks.md if required -if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) { - Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)" - Write-Output "Run /speckit.tasks first to create the task list." - exit 1 -} - -# Build list of available documents -$docs = @() - -# Always check these optional docs -if (Test-Path $paths.RESEARCH) { $docs += 'research.md' } -if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' } - -# Check contracts directory (only if it exists and has files) -if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) { - $docs += 'contracts/' -} - -if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' } - -# Include tasks.md if requested and it exists -if ($IncludeTasks -and (Test-Path $paths.TASKS)) { - $docs += 'tasks.md' -} - -# Output results -if ($Json) { - # JSON output - [PSCustomObject]@{ - FEATURE_DIR = $paths.FEATURE_DIR - AVAILABLE_DOCS = $docs - } | ConvertTo-Json -Compress -} else { - # Text output - Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)" - Write-Output "AVAILABLE_DOCS:" - - # Show status of each potential document - Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null - Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null - Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null - Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null - - if ($IncludeTasks) { - Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null - } -} diff --git a/.specify/scripts/powershell/common.ps1 b/.specify/scripts/powershell/common.ps1 deleted file mode 100644 index b0be273..0000000 --- a/.specify/scripts/powershell/common.ps1 +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env pwsh -# Common PowerShell functions analogous to common.sh - -function Get-RepoRoot { - try { - $result = git rev-parse --show-toplevel 2>$null - if ($LASTEXITCODE -eq 0) { - return $result - } - } catch { - # Git command failed - } - - # Fall back to script location for non-git repos - return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path -} - -function Get-CurrentBranch { - # First check if SPECIFY_FEATURE environment variable is set - if ($env:SPECIFY_FEATURE) { - return $env:SPECIFY_FEATURE - } - - # Then check git if available - try { - $result = git rev-parse --abbrev-ref HEAD 2>$null - if ($LASTEXITCODE -eq 0) { - return $result - } - } catch { - # Git command failed - } - - # For non-git repos, try to find the latest feature directory - $repoRoot = Get-RepoRoot - $specsDir = Join-Path $repoRoot "specs" - - if (Test-Path $specsDir) { - $latestFeature = "" - $highest = 0 - - Get-ChildItem -Path $specsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d{3})-') { - $num = [int]$matches[1] - if ($num -gt $highest) { - $highest = $num - $latestFeature = $_.Name - } - } - } - - if ($latestFeature) { - return $latestFeature - } - } - - # Final fallback - return "main" -} - -function Test-HasGit { - try { - git rev-parse --show-toplevel 2>$null | Out-Null - return ($LASTEXITCODE -eq 0) - } catch { - return $false - } -} - -function Test-FeatureBranch { - param( - [string]$Branch, - [bool]$HasGit = $true - ) - - # For non-git repos, we can't enforce branch naming but still provide output - if (-not $HasGit) { - Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" - return $true - } - - if ($Branch -notmatch '^[0-9]{3}-') { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name" - return $false - } - return $true -} - -function Get-FeatureDir { - param([string]$RepoRoot, [string]$Branch) - Join-Path $RepoRoot "specs/$Branch" -} - -function Get-FeaturePathsEnv { - $repoRoot = Get-RepoRoot - $currentBranch = Get-CurrentBranch - $hasGit = Test-HasGit - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch - - [PSCustomObject]@{ - REPO_ROOT = $repoRoot - CURRENT_BRANCH = $currentBranch - HAS_GIT = $hasGit - FEATURE_DIR = $featureDir - FEATURE_SPEC = Join-Path $featureDir 'spec.md' - IMPL_PLAN = Join-Path $featureDir 'plan.md' - TASKS = Join-Path $featureDir 'tasks.md' - RESEARCH = Join-Path $featureDir 'research.md' - DATA_MODEL = Join-Path $featureDir 'data-model.md' - QUICKSTART = Join-Path $featureDir 'quickstart.md' - CONTRACTS_DIR = Join-Path $featureDir 'contracts' - } -} - -function Test-FileExists { - param([string]$Path, [string]$Description) - if (Test-Path -Path $Path -PathType Leaf) { - Write-Output " ✓ $Description" - return $true - } else { - Write-Output " ✗ $Description" - return $false - } -} - -function Test-DirHasFiles { - param([string]$Path, [string]$Description) - if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) { - Write-Output " ✓ $Description" - return $true - } else { - Write-Output " ✗ $Description" - return $false - } -} - diff --git a/.specify/scripts/powershell/create-new-feature.ps1 b/.specify/scripts/powershell/create-new-feature.ps1 deleted file mode 100644 index 351f4e9..0000000 --- a/.specify/scripts/powershell/create-new-feature.ps1 +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env pwsh -# Create a new feature -[CmdletBinding()] -param( - [switch]$Json, - [string]$ShortName, - [int]$Number = 0, - [switch]$Help, - [Parameter(ValueFromRemainingArguments = $true)] - [string[]]$FeatureDescription -) -$ErrorActionPreference = 'Stop' - -# Show help if requested -if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] " - Write-Host "" - Write-Host "Options:" - Write-Host " -Json Output in JSON format" - Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" - Write-Host " -Number N Specify branch number manually (overrides auto-detection)" - Write-Host " -Help Show this help message" - Write-Host "" - Write-Host "Examples:" - Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'" - Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'" - exit 0 -} - -# Check if feature description provided -if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] " - exit 1 -} - -$featureDesc = ($FeatureDescription -join ' ').Trim() - -# Resolve repository root. Prefer git information when available, but fall back -# to searching for repository markers so the workflow still functions in repositories that -# were initialized with --no-git. -function Find-RepositoryRoot { - param( - [string]$StartDir, - [string[]]$Markers = @('.git', '.specify') - ) - $current = Resolve-Path $StartDir - while ($true) { - foreach ($marker in $Markers) { - if (Test-Path (Join-Path $current $marker)) { - return $current - } - } - $parent = Split-Path $current -Parent - if ($parent -eq $current) { - # Reached filesystem root without finding markers - return $null - } - $current = $parent - } -} - -function Get-HighestNumberFromSpecs { - param([string]$SpecsDir) - - $highest = 0 - if (Test-Path $SpecsDir) { - Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d+)') { - $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } - } - } - } - return $highest -} - -function Get-HighestNumberFromBranches { - param() - - $highest = 0 - try { - $branches = git branch -a 2>$null - if ($LASTEXITCODE -eq 0) { - foreach ($branch in $branches) { - # Clean branch name: remove leading markers and remote prefixes - $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' - - # Extract feature number if branch matches pattern ###-* - if ($cleanBranch -match '^(\d+)-') { - $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } - } - } - } - } catch { - # If git command fails, return 0 - Write-Verbose "Could not check Git branches: $_" - } - return $highest -} - -function Get-NextBranchNumber { - param( - [string]$ShortName, - [string]$SpecsDir - ) - - # Fetch all remotes to get latest branch info (suppress errors if no remotes) - try { - git fetch --all --prune 2>$null | Out-Null - } catch { - # Ignore fetch errors - } - - # Find remote branches matching the pattern using git ls-remote - $remoteBranches = @() - try { - $remoteRefs = git ls-remote --heads origin 2>$null - if ($remoteRefs) { - $remoteBranches = $remoteRefs | Where-Object { $_ -match "refs/heads/(\d+)-$([regex]::Escape($ShortName))$" } | ForEach-Object { - if ($_ -match "refs/heads/(\d+)-") { - [int]$matches[1] - } - } - } - } catch { - # Ignore errors - } - - # Check local branches - $localBranches = @() - try { - $allBranches = git branch 2>$null - if ($allBranches) { - $localBranches = $allBranches | Where-Object { $_ -match "^\*?\s*(\d+)-$([regex]::Escape($ShortName))$" } | ForEach-Object { - if ($_ -match "(\d+)-") { - [int]$matches[1] - } - } - } - } catch { - # Ignore errors - } - - # Check specs directory - $specDirs = @() - if (Test-Path $SpecsDir) { - try { - $specDirs = Get-ChildItem -Path $SpecsDir -Directory | Where-Object { $_.Name -match "^(\d+)-$([regex]::Escape($ShortName))$" } | ForEach-Object { - if ($_.Name -match "^(\d+)-") { - [int]$matches[1] - } - } - } catch { - # Ignore errors - } - } - - # Combine all sources and get the highest number - $maxNum = 0 - foreach ($num in ($remoteBranches + $localBranches + $specDirs)) { - if ($num -gt $maxNum) { - $maxNum = $num - } - } - - # Return next number - return $maxNum + 1 -} - -function ConvertTo-CleanBranchName { - param([string]$Name) - - return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' -} -$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) -if (-not $fallbackRoot) { - Write-Error "Error: Could not determine repository root. Please run this script from within the repository." - exit 1 -} - -try { - $repoRoot = git rev-parse --show-toplevel 2>$null - if ($LASTEXITCODE -eq 0) { - $hasGit = $true - } else { - throw "Git not available" - } -} catch { - $repoRoot = $fallbackRoot - $hasGit = $false -} - -Set-Location $repoRoot - -$specsDir = Join-Path $repoRoot 'specs' -New-Item -ItemType Directory -Path $specsDir -Force | Out-Null - -# Function to generate branch name with stop word filtering and length filtering -function Get-BranchName { - param([string]$Description) - - # Common stop words to filter out - $stopWords = @( - 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', - 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', - 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall', - 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', - 'want', 'need', 'add', 'get', 'set' - ) - - # Convert to lowercase and extract words (alphanumeric only) - $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' - $words = $cleanName -split '\s+' | Where-Object { $_ } - - # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) - $meaningfulWords = @() - foreach ($word in $words) { - # Skip stop words - if ($stopWords -contains $word) { continue } - - # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) - if ($word.Length -ge 3) { - $meaningfulWords += $word - } elseif ($Description -match "\b$($word.ToUpper())\b") { - # Keep short words if they appear as uppercase in original (likely acronyms) - $meaningfulWords += $word - } - } - - # If we have meaningful words, use first 3-4 of them - if ($meaningfulWords.Count -gt 0) { - $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } - $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' - return $result - } else { - # Fallback to original logic if no meaningful words found - $result = ConvertTo-CleanBranchName -Name $Description - $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 - return [string]::Join('-', $fallbackWords) - } -} - -# Generate branch name -if ($ShortName) { - # Use provided short name, just clean it up - $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName -} else { - # Generate from description with smart filtering - $branchSuffix = Get-BranchName -Description $featureDesc -} - -# Determine branch number -if ($Number -eq 0) { - if ($hasGit) { - # Check existing branches on remotes - $Number = Get-NextBranchNumber -ShortName $branchSuffix -SpecsDir $specsDir - } else { - # Fall back to local directory check - $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 - } -} - -$featureNum = ('{0:000}' -f $Number) -$branchName = "$featureNum-$branchSuffix" - -# GitHub enforces a 244-byte limit on branch names -# Validate and truncate if necessary -$maxBranchLength = 244 -if ($branchName.Length -gt $maxBranchLength) { - # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - $maxSuffixLength = $maxBranchLength - 4 - - # Truncate suffix - $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) - # Remove trailing hyphen if truncation created one - $truncatedSuffix = $truncatedSuffix -replace '-$', '' - - $originalBranchName = $branchName - $branchName = "$featureNum-$truncatedSuffix" - - Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" - Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" - Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" -} - -if ($hasGit) { - try { - git checkout -b $branchName | Out-Null - } catch { - Write-Warning "Failed to create git branch: $branchName" - } -} else { - Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" -} - -$featureDir = Join-Path $specsDir $branchName -New-Item -ItemType Directory -Path $featureDir -Force | Out-Null - -$template = Join-Path $repoRoot '.specify/templates/spec-template.md' -$specFile = Join-Path $featureDir 'spec.md' -if (Test-Path $template) { - Copy-Item $template $specFile -Force -} else { - New-Item -ItemType File -Path $specFile | Out-Null -} - -# Set the SPECIFY_FEATURE environment variable for the current session -$env:SPECIFY_FEATURE = $branchName - -if ($Json) { - $obj = [PSCustomObject]@{ - BRANCH_NAME = $branchName - SPEC_FILE = $specFile - FEATURE_NUM = $featureNum - HAS_GIT = $hasGit - } - $obj | ConvertTo-Json -Compress -} else { - Write-Output "BRANCH_NAME: $branchName" - Write-Output "SPEC_FILE: $specFile" - Write-Output "FEATURE_NUM: $featureNum" - Write-Output "HAS_GIT: $hasGit" - Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" -} - diff --git a/.specify/scripts/powershell/setup-plan.ps1 b/.specify/scripts/powershell/setup-plan.ps1 deleted file mode 100644 index d0ed582..0000000 --- a/.specify/scripts/powershell/setup-plan.ps1 +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env pwsh -# Setup implementation plan for a feature - -[CmdletBinding()] -param( - [switch]$Json, - [switch]$Help -) - -$ErrorActionPreference = 'Stop' - -# Show help if requested -if ($Help) { - Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]" - Write-Output " -Json Output results in JSON format" - Write-Output " -Help Show this help message" - exit 0 -} - -# Load common functions -. "$PSScriptRoot/common.ps1" - -# Get all paths and variables from common functions -$paths = Get-FeaturePathsEnv - -# Check if we're on a proper feature branch (only for git repos) -if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { - exit 1 -} - -# Ensure the feature directory exists -New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null - -# Copy plan template if it exists, otherwise note it or create empty file -$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md' -if (Test-Path $template) { - Copy-Item $template $paths.IMPL_PLAN -Force - Write-Output "Copied plan template to $($paths.IMPL_PLAN)" -} else { - Write-Warning "Plan template not found at $template" - # Create a basic plan file if template doesn't exist - New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null -} - -# Output results -if ($Json) { - $result = [PSCustomObject]@{ - FEATURE_SPEC = $paths.FEATURE_SPEC - IMPL_PLAN = $paths.IMPL_PLAN - SPECS_DIR = $paths.FEATURE_DIR - BRANCH = $paths.CURRENT_BRANCH - HAS_GIT = $paths.HAS_GIT - } - $result | ConvertTo-Json -Compress -} else { - Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)" - Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" - Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)" - Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" - Write-Output "HAS_GIT: $($paths.HAS_GIT)" -} diff --git a/.specify/scripts/powershell/update-agent-context.ps1 b/.specify/scripts/powershell/update-agent-context.ps1 deleted file mode 100644 index e887b2b..0000000 --- a/.specify/scripts/powershell/update-agent-context.ps1 +++ /dev/null @@ -1,445 +0,0 @@ -#!/usr/bin/env pwsh -<#! -.SYNOPSIS -Update agent context files with information from plan.md (PowerShell version) - -.DESCRIPTION -Mirrors the behavior of scripts/bash/update-agent-context.sh: - 1. Environment Validation - 2. Plan Data Extraction - 3. Agent File Management (create from template or update existing) - 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, bob) - -.PARAMETER AgentType -Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). - -.EXAMPLE -./update-agent-context.ps1 -AgentType claude - -.EXAMPLE -./update-agent-context.ps1 # Updates all existing agent files - -.NOTES -Relies on common helper functions in common.ps1 -#> -param( - [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','bob')] - [string]$AgentType -) - -$ErrorActionPreference = 'Stop' - -# Import common helpers -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -. (Join-Path $ScriptDir 'common.ps1') - -# Acquire environment paths -$envData = Get-FeaturePathsEnv -$REPO_ROOT = $envData.REPO_ROOT -$CURRENT_BRANCH = $envData.CURRENT_BRANCH -$HAS_GIT = $envData.HAS_GIT -$IMPL_PLAN = $envData.IMPL_PLAN -$NEW_PLAN = $IMPL_PLAN - -# Agent file paths -$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md' -$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md' -$COPILOT_FILE = Join-Path $REPO_ROOT '.github/agents/copilot-instructions.md' -$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc' -$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md' -$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md' -$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md' -$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md' -$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md' -$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md' -$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' -$Q_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' - -$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' - -# Parsed plan data placeholders -$script:NEW_LANG = '' -$script:NEW_FRAMEWORK = '' -$script:NEW_DB = '' -$script:NEW_PROJECT_TYPE = '' - -function Write-Info { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "INFO: $Message" -} - -function Write-Success { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "$([char]0x2713) $Message" -} - -function Write-WarningMsg { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Warning $Message -} - -function Write-Err { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "ERROR: $Message" -ForegroundColor Red -} - -function Validate-Environment { - if (-not $CURRENT_BRANCH) { - Write-Err 'Unable to determine current feature' - if ($HAS_GIT) { Write-Info "Make sure you're on a feature branch" } else { Write-Info 'Set SPECIFY_FEATURE environment variable or create a feature first' } - exit 1 - } - if (-not (Test-Path $NEW_PLAN)) { - Write-Err "No plan.md found at $NEW_PLAN" - Write-Info 'Ensure you are working on a feature with a corresponding spec directory' - if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' } - exit 1 - } - if (-not (Test-Path $TEMPLATE_FILE)) { - Write-Err "Template file not found at $TEMPLATE_FILE" - Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.' - exit 1 - } -} - -function Extract-PlanField { - param( - [Parameter(Mandatory=$true)] - [string]$FieldPattern, - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { return '' } - # Lines like **Language/Version**: Python 3.12 - $regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$" - Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object { - if ($_ -match $regex) { - $val = $Matches[1].Trim() - if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val } - } - } | Select-Object -First 1 -} - -function Parse-PlanData { - param( - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { Write-Err "Plan file not found: $PlanFile"; return $false } - Write-Info "Parsing plan data from $PlanFile" - $script:NEW_LANG = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile - $script:NEW_FRAMEWORK = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile - $script:NEW_DB = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile - $script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile - - if ($NEW_LANG) { Write-Info "Found language: $NEW_LANG" } else { Write-WarningMsg 'No language information found in plan' } - if ($NEW_FRAMEWORK) { Write-Info "Found framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info "Found database: $NEW_DB" } - if ($NEW_PROJECT_TYPE) { Write-Info "Found project type: $NEW_PROJECT_TYPE" } - return $true -} - -function Format-TechnologyStack { - param( - [Parameter(Mandatory=$false)] - [string]$Lang, - [Parameter(Mandatory=$false)] - [string]$Framework - ) - $parts = @() - if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang } - if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework } - if (-not $parts) { return '' } - return ($parts -join ' + ') -} - -function Get-ProjectStructure { - param( - [Parameter(Mandatory=$false)] - [string]$ProjectType - ) - if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" } -} - -function Get-CommandsForLanguage { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - switch -Regex ($Lang) { - 'Python' { return "cd src; pytest; ruff check ." } - 'Rust' { return "cargo test; cargo clippy" } - 'JavaScript|TypeScript' { return "npm test; npm run lint" } - default { return "# Add commands for $Lang" } - } -} - -function Get-LanguageConventions { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' } -} - -function New-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$ProjectName, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err "Template not found at $TEMPLATE_FILE"; return $false } - $temp = New-TemporaryFile - Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force - - $projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE - $commands = Get-CommandsForLanguage -Lang $NEW_LANG - $languageConventions = Get-LanguageConventions -Lang $NEW_LANG - - $escaped_lang = $NEW_LANG - $escaped_framework = $NEW_FRAMEWORK - $escaped_branch = $CURRENT_BRANCH - - $content = Get-Content -LiteralPath $temp -Raw -Encoding utf8 - $content = $content -replace '\[PROJECT NAME\]',$ProjectName - $content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd') - - # Build the technology stack string safely - $techStackForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $techStackForTemplate = "- $escaped_lang + $escaped_framework ($escaped_branch)" - } elseif ($escaped_lang) { - $techStackForTemplate = "- $escaped_lang ($escaped_branch)" - } elseif ($escaped_framework) { - $techStackForTemplate = "- $escaped_framework ($escaped_branch)" - } - - $content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate - # For project structure we manually embed (keep newlines) - $escapedStructure = [Regex]::Escape($projectStructure) - $content = $content -replace '\[ACTUAL STRUCTURE FROM PLANS\]',$escapedStructure - # Replace escaped newlines placeholder after all replacements - $content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands - $content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions - - # Build the recent changes string safely - $recentChangesForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}" - } elseif ($escaped_lang) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang}" - } elseif ($escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}" - } - - $content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate - # Convert literal \n sequences introduced by Escape to real newlines - $content = $content -replace '\\n',[Environment]::NewLine - - $parent = Split-Path -Parent $TargetFile - if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null } - Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8 - Remove-Item $temp -Force - return $true -} - -function Update-ExistingAgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) } - - $techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK - $newTechEntries = @() - if ($techStack) { - $escapedTechStack = [Regex]::Escape($techStack) - if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) { - $newTechEntries += "- $techStack ($CURRENT_BRANCH)" - } - } - if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { - $escapedDB = [Regex]::Escape($NEW_DB) - if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) { - $newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)" - } - } - $newChangeEntry = '' - if ($techStack) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${techStack}" } - elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${NEW_DB}" } - - $lines = Get-Content -LiteralPath $TargetFile -Encoding utf8 - $output = New-Object System.Collections.Generic.List[string] - $inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0 - - for ($i=0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -eq '## Active Technologies') { - $output.Add($line) - $inTech = $true - continue - } - if ($inTech -and $line -match '^##\s') { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); $inTech = $false; continue - } - if ($inTech -and [string]::IsNullOrWhiteSpace($line)) { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); continue - } - if ($line -eq '## Recent Changes') { - $output.Add($line) - if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true } - $inChanges = $true - continue - } - if ($inChanges -and $line -match '^##\s') { $output.Add($line); $inChanges = $false; continue } - if ($inChanges -and $line -match '^- ') { - if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ } - continue - } - if ($line -match '\*\*Last updated\*\*: .*\d{4}-\d{2}-\d{2}') { - $output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd'))) - continue - } - $output.Add($line) - } - - # Post-loop check: if we're still in the Active Technologies section and haven't added new entries - if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) { - $newTechEntries | ForEach-Object { $output.Add($_) } - } - - Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8 - return $true -} - -function Update-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$AgentName - ) - if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false } - Write-Info "Updating $AgentName context file: $TargetFile" - $projectName = Split-Path $REPO_ROOT -Leaf - $date = Get-Date - - $dir = Split-Path -Parent $TargetFile - if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } - - if (-not (Test-Path $TargetFile)) { - if (New-AgentFile -TargetFile $TargetFile -ProjectName $projectName -Date $date) { Write-Success "Created new $AgentName context file" } else { Write-Err 'Failed to create new agent file'; return $false } - } else { - try { - if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success "Updated existing $AgentName context file" } else { Write-Err 'Failed to update agent file'; return $false } - } catch { - Write-Err "Cannot access or update existing file: $TargetFile. $_" - return $false - } - } - return $true -} - -function Update-SpecificAgent { - param( - [Parameter(Mandatory=$true)] - [string]$Type - ) - switch ($Type) { - 'claude' { Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code' } - 'gemini' { Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI' } - 'copilot' { Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot' } - 'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE' } - 'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' } - 'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' } - 'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' } - 'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' } - 'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' } - 'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } - 'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } - 'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' } - 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } - 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } - 'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' } - 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob'; return $false } - } -} - -function Update-AllExistingAgents { - $found = $false - $ok = $true - if (Test-Path $CLAUDE_FILE) { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }; $found = $true } - if (Test-Path $GEMINI_FILE) { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true } - if (Test-Path $COPILOT_FILE) { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true } - if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true } - if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true } - if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true } - if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true } - if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true } - if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true } - if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true } - if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true } - if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true } - if (Test-Path $Q_FILE) { if (-not (Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI')) { $ok = $false }; $found = $true } - if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } - if (-not $found) { - Write-Info 'No existing agent files found, creating default Claude file...' - if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } - } - return $ok -} - -function Print-Summary { - Write-Host '' - Write-Info 'Summary of changes:' - if ($NEW_LANG) { Write-Host " - Added language: $NEW_LANG" } - if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } - Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob]' -} - -function Main { - Validate-Environment - Write-Info "=== Updating agent context files for feature $CURRENT_BRANCH ===" - if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 } - $success = $true - if ($AgentType) { - Write-Info "Updating specific agent: $AgentType" - if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false } - } - else { - Write-Info 'No agent specified, updating all existing agent files...' - if (-not (Update-AllExistingAgents)) { $success = $false } - } - Print-Summary - if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 } -} - -Main - diff --git a/.specify/templates/agent-file-template.md b/.specify/templates/agent-file-template.md deleted file mode 100644 index 4cc7fd6..0000000 --- a/.specify/templates/agent-file-template.md +++ /dev/null @@ -1,28 +0,0 @@ -# [PROJECT NAME] Development Guidelines - -Auto-generated from all feature plans. Last updated: [DATE] - -## Active Technologies - -[EXTRACTED FROM ALL PLAN.MD FILES] - -## Project Structure - -```text -[ACTUAL STRUCTURE FROM PLANS] -``` - -## Commands - -[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] - -## Code Style - -[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] - -## Recent Changes - -[LAST 3 FEATURES AND WHAT THEY ADDED] - - - diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md deleted file mode 100644 index 806657d..0000000 --- a/.specify/templates/checklist-template.md +++ /dev/null @@ -1,40 +0,0 @@ -# [CHECKLIST TYPE] Checklist: [FEATURE NAME] - -**Purpose**: [Brief description of what this checklist covers] -**Created**: [DATE] -**Feature**: [Link to spec.md or relevant documentation] - -**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. - - - -## [Category 1] - -- [ ] CHK001 First checklist item with clear action -- [ ] CHK002 Second checklist item -- [ ] CHK003 Third checklist item - -## [Category 2] - -- [ ] CHK004 Another category item -- [ ] CHK005 Item with specific criteria -- [ ] CHK006 Final item in this category - -## Notes - -- Check items off as completed: `[x]` -- Add comments or findings inline -- Link to relevant resources or documentation -- Items are numbered sequentially for easy reference diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md deleted file mode 100644 index 2336c15..0000000 --- a/.specify/templates/plan-template.md +++ /dev/null @@ -1,120 +0,0 @@ -# Implementation Plan: [FEATURE] - -**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] -**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` - -**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. - -## Summary - -[Extract from feature spec: primary requirement + technical approach from research] - -## Technical Context - - - -**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] -**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] -**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] -**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] -**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] -**Project Type**: [single/web/mobile - determines source structure] -**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] -**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] -**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -- **UI-Agnostic Core**: Does the proposed feature keep Domain/Application code free of any - concrete UI framework dependencies and use only `Modulus.UI.Abstractions` for UI contracts? -- **Dual-Engine Host Architecture**: Which hosts (Blazor, Avalonia) are targeted, and how will - UI assemblies be separated from core logic so the same module can run under multiple hosts? -- **Vertical Slice Modularity**: Which module(s) own this feature as a vertical slice, and can - they be loaded, tested, and versioned independently? -- **Pyramid Layering**: Do all dependencies follow - Presentation → UI Abstraction → Application → Domain → Infrastructure, with no cross-layer - shortcuts? -- **AI-Friendly Contracts & Plugin SDK**: What public contracts or SDK base types are required - or changed, and how will they remain strongly typed and self-describing for AI and human - authors? -- **Modern .NET & Technology Discipline**: Are the chosen runtime targets, MediatR usage, and - portability requirements consistent with the Modulus Constitution? - -Summarize any risks or intentional deviations here and link to the governance decision if -applicable. - -## Project Structure - -### Documentation (this feature) - -```text -specs/[###-feature]/ -├── plan.md # This file (/speckit.plan command output) -├── research.md # Phase 0 output (/speckit.plan command) -├── data-model.md # Phase 1 output (/speckit.plan command) -├── quickstart.md # Phase 1 output (/speckit.plan command) -├── contracts/ # Phase 1 output (/speckit.plan command) -└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) -``` - -### Source Code (repository root) - - -```text -# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) -src/ -├── models/ -├── services/ -├── cli/ -└── lib/ - -tests/ -├── contract/ -├── integration/ -└── unit/ - -# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) -backend/ -├── src/ -│ ├── models/ -│ ├── services/ -│ └── api/ -└── tests/ - -frontend/ -├── src/ -│ ├── components/ -│ ├── pages/ -│ └── services/ -└── tests/ - -# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) -api/ -└── [same as backend above] - -ios/ or android/ -└── [platform-specific structure: feature modules, UI flows, platform tests] -``` - -**Structure Decision**: [Document the selected structure and reference the real -directories captured above] - -## Complexity Tracking - -> **Fill ONLY if Constitution Check has violations that must be justified** - -| Violation | Why Needed | Simpler Alternative Rejected Because | -|-----------|------------|-------------------------------------| -| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | -| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md deleted file mode 100644 index b2ebf75..0000000 --- a/.specify/templates/spec-template.md +++ /dev/null @@ -1,149 +0,0 @@ -# Feature Specification: [FEATURE NAME] - -**Feature Branch**: `[###-feature-name]` -**Created**: [DATE] -**Status**: Draft -**Input**: User description: "$ARGUMENTS" - -## User Scenarios & Testing *(mandatory)* - - - -### User Story 1 - [Brief Title] (Priority: P1) - -[Describe this user journey in plain language] - -**Why this priority**: [Explain the value and why it has this priority level] - -**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] - -**Acceptance Scenarios**: - -1. **Given** [initial state], **When** [action], **Then** [expected outcome] -2. **Given** [initial state], **When** [action], **Then** [expected outcome] - ---- - -### User Story 2 - [Brief Title] (Priority: P2) - -[Describe this user journey in plain language] - -**Why this priority**: [Explain the value and why it has this priority level] - -**Independent Test**: [Describe how this can be tested independently] - -**Acceptance Scenarios**: - -1. **Given** [initial state], **When** [action], **Then** [expected outcome] - ---- - -### User Story 3 - [Brief Title] (Priority: P3) - -[Describe this user journey in plain language] - -**Why this priority**: [Explain the value and why it has this priority level] - -**Independent Test**: [Describe how this can be tested independently] - -**Acceptance Scenarios**: - -1. **Given** [initial state], **When** [action], **Then** [expected outcome] - ---- - -[Add more user stories as needed, each with an assigned priority] - -### Edge Cases - - - -- What happens when [boundary condition]? -- How does system handle [error scenario]? - -## Requirements *(mandatory)* - - - -### Functional Requirements - -- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] -- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] -- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] -- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] -- **FR-005**: System MUST [behavior, e.g., "log all security events"] - -*Example of marking unclear requirements:* - -- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] -- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] - -### Key Entities *(include if feature involves data)* - -- **[Entity 1]**: [What it represents, key attributes without implementation] -- **[Entity 2]**: [What it represents, relationships to other entities] - -## Success Criteria *(mandatory)* - - - -### Measurable Outcomes - -- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] -- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] -- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] -- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] - -## Architecture & Modulus Constraints *(mandatory for this repository)* - - - -### Module & Host Mapping - -- Owning module(s) (vertical slice): - - [e.g., Modulus.Modules.Logging, Modulus.Modules.Calculator] -- Target host(s): - - [Blazor, Avalonia, or both] -- UI assemblies involved (if any): - - [e.g., Module.UI.Blazor.dll, Module.UI.Avalonia.dll] - -### Layering & Dependencies - -- Layers touched by this feature: - - [Presentation, UI Abstraction, Application, Domain, Infrastructure] -- Any required cross-module communication (via MediatR or explicit interfaces): - - [Describe requests/notifications and handlers] -- Confirm there are no planned violations of the dependency pyramid: - - [If any, explain and link to governance decision] - -### Public Contracts & SDK Impact - -- New or changed public contracts / DTOs: - - [List request/response types and error models] -- New or updated SDK base types (if any): - - [e.g., new plugin base, new module base] -- Backward compatibility and migration notes: - - [How existing plugins/modules will be affected] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md deleted file mode 100644 index 401ce8c..0000000 --- a/.specify/templates/tasks-template.md +++ /dev/null @@ -1,257 +0,0 @@ ---- - -description: "Task list template for feature implementation" ---- - -# Tasks: [FEATURE NAME] - -**Input**: Design documents from `/specs/[###-feature-name]/` -**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ - -**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. - -**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) -- Include exact file paths in descriptions - -## Path Conventions - -- **Single project**: `src/`, `tests/` at repository root -- **Web app**: `backend/src/`, `frontend/src/` -- **Mobile**: `api/src/`, `ios/src/` or `android/src/` -- Paths shown below assume single project - adjust based on plan.md structure - - - -## Phase 1: Setup (Shared Infrastructure) - -**Purpose**: Project initialization and basic structure - -- [ ] T001 Create project structure per implementation plan -- [ ] T002 Initialize [language] project with [framework] dependencies -- [ ] T003 [P] Configure linting and formatting tools - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented - -**⚠️ CRITICAL**: No user story work can begin until this phase is complete - -Examples of foundational tasks (adjust based on your project): - -- [ ] T004 Setup database schema and migrations framework -- [ ] T005 [P] Implement authentication/authorization framework -- [ ] T006 [P] Setup API routing and middleware structure -- [ ] T007 Create base models/entities that all stories depend on -- [ ] T008 Configure error handling and logging infrastructure -- [ ] T009 Setup environment configuration management -- [ ] T00A Verify project references and namespaces follow the Modulus - Presentation → UI Abstraction → Application → Domain → Infrastructure dependency pyramid -- [ ] T00B Configure DI and MediatR scanning so modules and cross-module handlers - are discoverable without hard-coded wiring -- [ ] T00C Ensure Domain/Application projects do not reference concrete UI frameworks - and use only `Modulus.UI.Abstractions` for UI contracts - -**Checkpoint**: Foundation ready - user story implementation can now begin in parallel - ---- - -## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP - -**Goal**: [Brief description of what this story delivers] - -**Independent Test**: [How to verify this story works on its own] - -### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ - -> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** - -- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py -- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py - -### Implementation for User Story 1 - -- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py -- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py -- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) -- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py -- [ ] T016 [US1] Add validation and error handling -- [ ] T017 [US1] Add logging for user story 1 operations - -**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently - ---- - -## Phase 4: User Story 2 - [Title] (Priority: P2) - -**Goal**: [Brief description of what this story delivers] - -**Independent Test**: [How to verify this story works on its own] - -### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ - -- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py -- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py - -### Implementation for User Story 2 - -- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py -- [ ] T021 [US2] Implement [Service] in src/services/[service].py -- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py -- [ ] T023 [US2] Integrate with User Story 1 components (if needed) - -**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently - ---- - -## Phase 5: User Story 3 - [Title] (Priority: P3) - -**Goal**: [Brief description of what this story delivers] - -**Independent Test**: [How to verify this story works on its own] - -### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ - -- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py -- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py - -### Implementation for User Story 3 - -- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py -- [ ] T027 [US3] Implement [Service] in src/services/[service].py -- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py - -**Checkpoint**: All user stories should now be independently functional - ---- - -[Add more user story phases as needed, following the same pattern] - ---- - -## Phase N: Polish & Cross-Cutting Concerns - -**Purpose**: Improvements that affect multiple user stories - -- [ ] TXXX [P] Documentation updates in docs/ -- [ ] TXXX Code cleanup and refactoring -- [ ] TXXX Performance optimization across all stories -- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ -- [ ] TXXX Security hardening -- [ ] TXXX Run quickstart.md validation - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies - can start immediately -- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories -- **User Stories (Phase 3+)**: All depend on Foundational phase completion - - User stories can then proceed in parallel (if staffed) - - Or sequentially in priority order (P1 → P2 → P3) -- **Polish (Final Phase)**: Depends on all desired user stories being complete - -### User Story Dependencies - -- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories -- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable -- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable - -### Within Each User Story - -- Tests (if included) MUST be written and FAIL before implementation -- Models before services -- Services before endpoints -- Core implementation before integration -- Story complete before moving to next priority - -### Parallel Opportunities - -- All Setup tasks marked [P] can run in parallel -- All Foundational tasks marked [P] can run in parallel (within Phase 2) -- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) -- All tests for a user story marked [P] can run in parallel -- Models within a story marked [P] can run in parallel -- Different user stories can be worked on in parallel by different team members - ---- - -## Parallel Example: User Story 1 - -```bash -# Launch all tests for User Story 1 together (if tests requested): -Task: "Contract test for [endpoint] in tests/contract/test_[name].py" -Task: "Integration test for [user journey] in tests/integration/test_[name].py" - -# Launch all models for User Story 1 together: -Task: "Create [Entity1] model in src/models/[entity1].py" -Task: "Create [Entity2] model in src/models/[entity2].py" -``` - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. Complete Phase 1: Setup -2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) -3. Complete Phase 3: User Story 1 -4. **STOP and VALIDATE**: Test User Story 1 independently -5. Deploy/demo if ready - -### Incremental Delivery - -1. Complete Setup + Foundational → Foundation ready -2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) -3. Add User Story 2 → Test independently → Deploy/Demo -4. Add User Story 3 → Test independently → Deploy/Demo -5. Each story adds value without breaking previous stories - -### Parallel Team Strategy - -With multiple developers: - -1. Team completes Setup + Foundational together -2. Once Foundational is done: - - Developer A: User Story 1 - - Developer B: User Story 2 - - Developer C: User Story 3 -3. Stories complete and integrate independently - ---- - -## Notes - -- [P] tasks = different files, no dependencies -- [Story] label maps task to specific user story for traceability -- Each user story should be independently completable and testable -- Verify tests fail before implementing -- Commit after each task or logical group -- Stop at any checkpoint to validate story independently -- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/Modulus.sln b/Modulus.sln index 84611f6..10bd18e 100644 --- a/Modulus.sln +++ b/Modulus.sln @@ -65,6 +65,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modulus.UI.Blazor", "src\Mo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modulus.UI.Avalonia.Tests", "tests\Modulus.UI.Avalonia.Tests\Modulus.UI.Avalonia.Tests.csproj", "{6D47D1B4-A5E3-4BD6-B857-7845F59E4076}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modulus.Infrastructure.Data", "src\Shared\Modulus.Infrastructure.Data\Modulus.Infrastructure.Data.csproj", "{27F8D10F-EB5E-4903-8FBA-07457612C088}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -350,6 +352,18 @@ Global {6D47D1B4-A5E3-4BD6-B857-7845F59E4076}.Release|x64.Build.0 = Release|Any CPU {6D47D1B4-A5E3-4BD6-B857-7845F59E4076}.Release|x86.ActiveCfg = Release|Any CPU {6D47D1B4-A5E3-4BD6-B857-7845F59E4076}.Release|x86.Build.0 = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|Any CPU.Build.0 = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|x64.ActiveCfg = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|x64.Build.0 = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|x86.ActiveCfg = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|x86.Build.0 = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|Any CPU.ActiveCfg = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|Any CPU.Build.0 = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|x64.ActiveCfg = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|x64.Build.0 = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|x86.ActiveCfg = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -383,5 +397,6 @@ Global {E05CB311-CACE-451C-9682-EC03547B1EE3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {07C30195-C5FD-4B14-86AB-3CE59E5C9CF8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {6D47D1B4-A5E3-4BD6-B857-7845F59E4076} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {27F8D10F-EB5E-4903-8FBA-07457612C088} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C} EndGlobalSection EndGlobal diff --git a/build/BuildTasks.cs b/build/BuildTasks.cs index 2d1628d..eaf500e 100644 --- a/build/BuildTasks.cs +++ b/build/BuildTasks.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Collections.Generic; +using System.Text.Json; using Serilog; using Serilog.Sinks.SystemConsole.Themes; using Nuke.Common; @@ -26,7 +27,7 @@ public static int Main() .WriteTo.Console(theme: AnsiConsoleTheme.Code) .CreateLogger(); - return Execute(x => x.BuildAll); + return Execute(x => x.Compile); } // Custom log helpers with ANSI color codes for consistent coloring across platforms @@ -51,12 +52,20 @@ private static void LogHeader(string message) [Parameter("Name of the plugin to pack (required when op=single)", Name = "name")] readonly string PluginName; + + [Parameter("Target host to build for: 'avalonia' (default), 'blazor', or 'all'", Name = "target-host")] + readonly string TargetHost = "avalonia"; [Solution] readonly Solution Solution; AbsolutePath ArtifactsDirectory => RootDirectory / "artifacts"; AbsolutePath PluginsArtifactsDirectory => ArtifactsDirectory / "plugins"; AbsolutePath SamplesDirectory => RootDirectory / "src" / "samples"; + AbsolutePath ModulesDirectory => RootDirectory / "src" / "Modules"; + + // Host project names + const string AvaloniaHostProject = "Modulus.Host.Avalonia"; + const string BlazorHostProject = "Modulus.Host.Blazor"; Target Clean => _ => _ .Executes(() => @@ -78,18 +87,8 @@ private static void LogHeader(string message) .SetProjectFile(Solution)); }); - Target Build => _ => _ - .DependsOn(Restore) - .Executes(() => - { - DotNetTasks.DotNetBuild(s => s - .SetProjectFile(Solution) - .SetConfiguration(Configuration) - .EnableNoRestore()); - }); - Target Pack => _ => _ - .DependsOn(Build) + .DependsOn(Compile) .Executes(() => { foreach (var project in Solution.AllProjects.Where(p => p.Name.Contains("Plugin") || p.Name.Contains("App"))) @@ -102,19 +101,50 @@ private static void LogHeader(string message) } }); + /// + /// Build all and run the host application + /// Usage: nuke run [--target-host avalonia|blazor] + /// Target Run => _ => _ + .DependsOn(Build) + .Description("Build all and run the host application") .Executes(() => { - var desktopProject = Solution.AllProjects.FirstOrDefault(p => p.Name == "Modulus.Host.Avalonia"); - if (desktopProject == null) - throw new Exception("Modulus.Host.Avalonia project not found"); - DotNetTasks.DotNetRun(s => s - .SetProjectFile(desktopProject) - .SetConfiguration(Configuration)); + var hostProjectName = TargetHost?.ToLower() == "blazor" ? BlazorHostProject : AvaloniaHostProject; + var outputDir = ArtifactsDirectory / hostProjectName; + + var executable = outputDir / hostProjectName; + if (OperatingSystem.IsWindows()) + executable = outputDir / $"{hostProjectName}.exe"; + + if (!File.Exists(executable)) + { + LogError($"Executable not found: {executable}"); + LogError("Run 'nuke build' first to build the application."); + throw new Exception($"Executable not found: {executable}"); + } + + LogHeader($"Running {hostProjectName}"); + LogHighlight($"Executable: {executable}"); + + // Run the application + var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = executable, + WorkingDirectory = outputDir, + UseShellExecute = false + }); + + if (process != null) + { + LogSuccess($"Started {hostProjectName} (PID: {process.Id})"); + process.WaitForExit(); + LogNormal($"Application exited with code: {process.ExitCode}"); + } }); Target Test => _ => _ - .DependsOn(Build) + .DependsOn(Compile) .Executes(() => { DotNetTasks.DotNetTest(s => s @@ -124,10 +154,180 @@ private static void LogHeader(string message) }); Target BuildAll => _ => _ - .DependsOn(Build, Test); + .DependsOn(Compile, Test); Target Default => _ => _ - .DependsOn(Build); + .DependsOn(Compile); + + // ============================================================ + // Application Build Targets + // ============================================================ + + /// + /// Just compile the solution (no publish/package) + /// + Target Compile => _ => _ + .DependsOn(Restore) + .Description("Compile the solution") + .Executes(() => + { + DotNetTasks.DotNetBuild(s => s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .EnableNoRestore()); + }); + + /// + /// Build and publish host application to artifacts/ + /// Usage: nuke build-app [--target-host avalonia|blazor|all] + /// + Target BuildApp => _ => _ + .DependsOn(Restore) + .Description("Build and publish host application to artifacts/") + .Executes(() => + { + var hostProjects = GetTargetHostProjects(); + + foreach (var hostProjectName in hostProjects) + { + var hostProject = Solution.AllProjects.FirstOrDefault(p => p.Name == hostProjectName); + if (hostProject == null) + { + LogError($"Host project not found: {hostProjectName}"); + continue; + } + + var outputDir = ArtifactsDirectory / hostProjectName; + + LogHeader($"Building {hostProjectName}"); + + DotNetTasks.DotNetPublish(s => s + .SetProject(hostProject) + .SetConfiguration(Configuration) + .SetOutput(outputDir) + .EnableNoRestore()); + + LogSuccess($"Published {hostProjectName} to {outputDir}"); + } + }); + + /// + /// Build and package modules to artifacts/{Host}/Modules/ + /// Usage: nuke build-module [--target-host avalonia|blazor|all] [--name ModuleName] + /// + Target BuildModule => _ => _ + .DependsOn(Restore) + .Description("Build and package modules to artifacts/{Host}/Modules/") + .Executes(() => + { + var hostProjects = GetTargetHostProjects(); + + // Get module directories to build + var moduleDirectories = string.IsNullOrEmpty(PluginName) + ? Directory.GetDirectories(ModulesDirectory).Select(d => (AbsolutePath)d).ToArray() + : new[] { ModulesDirectory / PluginName }; + + foreach (var moduleDir in moduleDirectories) + { + if (!Directory.Exists(moduleDir)) + { + LogWarning($"Module directory not found: {moduleDir}"); + continue; + } + + var moduleName = Path.GetFileName(moduleDir); + var manifestPath = Path.Combine(moduleDir, "manifest.json"); + + if (!File.Exists(manifestPath)) + { + LogWarning($"No manifest.json in {moduleName}, skipping"); + continue; + } + + LogHeader($"Building Module: {moduleName}"); + + // Build all projects in this module + var moduleProjects = Directory.GetFiles(moduleDir, "*.csproj", SearchOption.AllDirectories); + foreach (var projectPath in moduleProjects) + { + DotNetTasks.DotNetBuild(s => s + .SetProjectFile(projectPath) + .SetConfiguration(Configuration) + .EnableNoRestore()); + } + + // Package to each target host's Modules directory + foreach (var hostProjectName in hostProjects) + { + var hostType = hostProjectName.Contains("Avalonia") ? "Avalonia" : "Blazor"; + var moduleOutputDir = ArtifactsDirectory / hostProjectName / "Modules" / moduleName; + + // Clean and create output directory + if (Directory.Exists(moduleOutputDir)) + Directory.Delete(moduleOutputDir, true); + Directory.CreateDirectory(moduleOutputDir); + + // Copy manifest.json + File.Copy(manifestPath, moduleOutputDir / "manifest.json"); + + // Copy DLLs from each project's output + foreach (var projectPath in moduleProjects) + { + var projectDir = Path.GetDirectoryName(projectPath); + var projectName = Path.GetFileNameWithoutExtension(projectPath); + + // Skip UI projects that don't match the host type + if (projectName.Contains(".UI.")) + { + var isAvaloniaUi = projectName.Contains(".UI.Avalonia"); + var isBlazorUi = projectName.Contains(".UI.Blazor"); + + if (isAvaloniaUi && hostType != "Avalonia") continue; + if (isBlazorUi && hostType != "Blazor") continue; + } + + // Find the output directory + var binDir = Path.Combine(projectDir, "bin", Configuration.ToString()); + if (!Directory.Exists(binDir)) continue; + + // Find the target framework folder + var tfmDirs = Directory.GetDirectories(binDir); + var tfmDir = tfmDirs.FirstOrDefault(d => d.Contains("net")); + if (tfmDir == null) continue; + + // Copy DLL and PDB + var dllPath = Path.Combine(tfmDir, $"{projectName}.dll"); + var pdbPath = Path.Combine(tfmDir, $"{projectName}.pdb"); + + if (File.Exists(dllPath)) + File.Copy(dllPath, moduleOutputDir / $"{projectName}.dll", true); + if (File.Exists(pdbPath)) + File.Copy(pdbPath, moduleOutputDir / $"{projectName}.pdb", true); + } + + LogSuccess($" → {hostType}: {moduleOutputDir}"); + } + } + }); + + /// + /// Full build: host application + all modules + /// Usage: nuke build [--target-host avalonia|blazor|all] + /// + Target Build => _ => _ + .DependsOn(BuildApp, BuildModule) + .Description("Full build: host application + all modules"); + + // Helper to get target host projects based on --target-host parameter + private string[] GetTargetHostProjects() + { + return TargetHost?.ToLower() switch + { + "blazor" => new[] { BlazorHostProject }, + "all" => new[] { AvaloniaHostProject, BlazorHostProject }, + _ => new[] { AvaloniaHostProject } // default to avalonia + }; + } Target CleanPluginsArtifacts => _ => _ .Executes(() => diff --git a/openspec/changes/database-driven-modules/design.md b/openspec/changes/database-driven-modules/design.md new file mode 100644 index 0000000..951ce51 --- /dev/null +++ b/openspec/changes/database-driven-modules/design.md @@ -0,0 +1,70 @@ +# Design: Database-Driven Plugin System (Module / Component Split) + +## 1) Terminology & Layers +- **ModulusModule** (模块,交付单元) + - 用户安装/卸载/启用/禁用的顶层对象。 + - 映射:数据库 `Sys_Modules` 记录;文件夹 `App_Data/Modules/{Id}`;`manifest.json`。 + - 边界:版本、权限、隔离(ALC)、可选独立存储/配置。 +- **ModulusComponent** (组件,代码单元) + - C# 类:`public class XxxComponent : ModulusComponent`。 + - 依赖:`[DependsOn]`(可跨模块)。 + - 职责:注册服务、声明菜单/扩展点、参与生命周期。 + - 一个模块可包含多个组件;模块通过 `manifest.entryComponent` 指定入口组件。 +- **MenuEntry** 仍投影到 DB (`Sys_Menus`),来源于组件属性或 manifest。 + +## 2) Data Model (SQLite) + +### `ModuleEntity` (`Sys_Modules`) +| Property | Type | Description | +|---|---|---| +| `Id` | PK, String | `manifest.id` (ModuleId) | +| `Name` | String | Display name | +| `Version` | String | Installed version | +| `Author` | String | Author | +| `Website` | String | Website | +| `Path` | String | Relative path to `manifest.json` | +| `EntryComponent` | String | FQCN of entry component | +| `IsSystem` | Bool | Managed by seeder; not uninstallable | +| `IsEnabled` | Bool | User preference | +| `State` | Enum | `Ready`, `MissingFiles`, `Incompatible` | + +### `MenuEntity` (`Sys_Menus`) +| Property | Type | Description | +|---|---|---| +| `Id` | PK, String | Menu id | +| `ModuleId` | FK | Module owner | +| `ParentId` | String? | Nesting | +| `DisplayName`| String | | +| `Icon` | String | | +| `Route` | String | | +| `Order` | Int | | + +## 3) Runtime Workflow (Hybrid Driven) + +### A. Startup (Seeding + Integrity) +1. EF Core migrate. +2. System seeding: ensure required modules exist/updated. +3. Integrity: mark `MissingFiles` if manifest missing. +4. **Module load (physical)**: for each enabled `Ready` module, create ALC and load assemblies. +5. **Component resolution (logical)**: scan assemblies for `ModulusComponent`, build dependency graph via `[DependsOn]`, topo-init (ConfigureServices → Init). + +### B. Install/Update (Projection) +1. Extract to `Modules/{Id}`. +2. Read manifest (includes `entryComponent`). +3. Isolated scan to find `ModulusComponent` + `[Menu]`. +4. Upsert `ModuleEntity`; replace menus in `Sys_Menus`. +5. (Optional) cache component list for diagnostics. + +### C. Menu Rendering +- UI reads `Sys_Menus` (join enabled modules). Zero reflection at render time. + +## 4) Developer Experience +- **Authoring**: build a Module (package) with one or more Components; mark entry via manifest; use `[DependsOn]`, `[Menu]`, future `[ExtensionPoint]`. +- **Testing**: import local module (folder/manifest) → projection pipeline. +- **AI-friendly**: explicit component dependencies; declarative module metadata. + +## 5) Technology +- EF Core Sqlite for state. +- ALC per module (default isolation). +- Markdown README for detail page (fallback to manifest description). + diff --git a/openspec/changes/database-driven-modules/proposal.md b/openspec/changes/database-driven-modules/proposal.md new file mode 100644 index 0000000..3058e6f --- /dev/null +++ b/openspec/changes/database-driven-modules/proposal.md @@ -0,0 +1,32 @@ +# Database-Driven Module Lifecycle & Menu System + +## Why +Currently, Modulus relies on filesystem scanning to find and load modules. This has several limitations: +1. **Duplicate Menus**: Inconsistent module IDs between loading and menu registration. +2. **Performance**: Scanning assemblies and parsing attributes at every startup is slow. +3. **Fragility**: No persistent state to track user preferences (enabled/disabled) or handle missing files gracefully. +4. **Versioning**: Hard to manage system module updates vs. user modules. + +## What +We will transition from a **Runtime-Scanning** architecture to a **State-Driven** architecture backed by a local database (SQLite + EF Core). + +### Key Changes +1. **Persistence Layer**: Introduce `Sys_Modules` and `Sys_Menus` tables. +2. **Seeding & Migration**: Auto-register system modules on startup/update. +3. **Install/Update Pipeline**: + * **Install**: Extract -> Load (Temp Context) -> Scan Attributes -> Write DB (Modules & Menus). + * **Runtime**: Read DB -> Load Assemblies (No Scanning). +4. **Menu System**: + * **Write-Time**: Parse `[Menu]` attributes during installation. + * **Read-Time**: Query `Sys_Menus` to render UI. +5. **Self-Healing**: Detect missing files on startup, mark as "Warning", allow clean removal. + +## Impact +* **Performance**: Faster startup (menu rendering doesn't wait for modules). +* **Stability**: Zero duplicate menus; atomic installation. +* **UX**: Users can manage (enable/disable/remove) modules; "Yellow Warning" for broken ones. +* **Dev**: Keep using convenient `[Menu]` attributes. + +## Migration +Existing modules will need to be "imported" into the database on the first run of the new version. + diff --git a/openspec/changes/database-driven-modules/specs/runtime/spec.md b/openspec/changes/database-driven-modules/specs/runtime/spec.md new file mode 100644 index 0000000..a5948fd --- /dev/null +++ b/openspec/changes/database-driven-modules/specs/runtime/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Hybrid Module/Component Runtime +The runtime SHALL load modules (ModulusModule) as deployable units and resolve components (ModulusComponent) as code units with dependency ordering. + +#### Scenario: Module load succeeds +- **WHEN** a module is enabled and its manifest exists +- **THEN** the runtime loads its assemblies in an isolated ALC (by default) +- **AND** discovers all `ModulusComponent` types +- **AND** builds a dependency graph from `[DependsOn]` (including cross-module dependencies) +- **AND** initializes components in topological order (`ConfigureServices` then `OnApplicationInitialization`). + +#### Scenario: Missing files are flagged +- **WHEN** a module manifest file is missing at startup +- **THEN** the module state is set to `MissingFiles` +- **AND** the module is skipped from the load list. + +### Requirement: Menu Projection +Menu entries SHALL be projected to the database at install/update time and read from the database at render time. + +#### Scenario: Install or update module +- **WHEN** a module is installed or updated +- **THEN** the installer scans components for `[Menu]` (host-aware) +- **AND** writes menu rows with ids like `{ModuleId}.{MenuId}` into `Sys_Menus` +- **AND** replaces any existing menus for that module (bulk upsert). + +#### Scenario: Render menus +- **WHEN** the shell renders navigation +- **THEN** it queries `Sys_Menus` joined with enabled modules +- **AND** does not require reflection at render time. + +### Requirement: Detail Content Fallback +Module detail pages SHALL prefer README content and fall back to manifest description. + +#### Scenario: README available +- **WHEN** `README.md` exists in the module folder +- **THEN** its Markdown content is used for the detail view. + +#### Scenario: README missing +- **WHEN** no `README.md` exists +- **AND** `manifest.description` is present +- **THEN** the manifest description is shown. + +#### Scenario: No content available +- **WHEN** neither README nor manifest description exists +- **THEN** show "No description provided." + +### Requirement: Module State Management +Module state SHALL be persisted and enforced at startup. + +#### Scenario: Startup integrity +- **WHEN** the application starts +- **THEN** the system migrates the DB +- **AND** seeds system modules (install/upgrade if missing or outdated) +- **AND** marks missing manifests as `MissingFiles` +- **AND** only modules with `State=Ready` and `IsEnabled=true` are loaded. + diff --git a/openspec/changes/database-driven-modules/tasks.md b/openspec/changes/database-driven-modules/tasks.md new file mode 100644 index 0000000..e189e3b --- /dev/null +++ b/openspec/changes/database-driven-modules/tasks.md @@ -0,0 +1,33 @@ +# Tasks + +- [x] **Infrastructure Setup** + - [x] Add `Modulus.Infrastructure.Data` project (Class Library). + - [x] Add packages: `Microsoft.EntityFrameworkCore.Sqlite`, `Microsoft.EntityFrameworkCore.Design`. + - [x] Define `ModulusDbContext`, `ModuleEntity`, `MenuEntity`. + - [x] Create initial EF Migration. + +- [x] **Core Logic Refactor** + - [x] Implement `IModuleRepository` and `IMenuRepository`. + - [x] Create `ModuleInstallerService`: + - [x] Logic to Scan Assembly Attributes -> MenuEntities. + - [x] Logic to Write Manifest -> ModuleEntity. + - [x] Create `SystemModuleSeeder`: + - [x] List of built-in modules. + - [x] Logic to trigger `ModuleInstallerService` on startup. + +- [x] **Runtime Integration** + - [x] Update `ModulusApplication`: + - [x] Remove directory scanner. + - [x] Call `SystemModuleSeeder.EnsureSeededAsync()`. + - [x] Call `IntegrityChecker.CheckAsync()`. + - [x] Update `ModuleLoader`: + - [x] Accept `List` instead of scanning paths (via `ModulusApplicationFactory` logic). + - [x] Update `ShellViewModel` (Avalonia/Blazor): + - [x] Load menus from `IMenuRepository` (Via `ModulusApplication` pushing to `IMenuRegistry` which Shell consumes). + +- [x] **UI Updates** + - [x] Update Module Management Page: + - [x] Show list from DB. + - [x] Handle "Yellow Warning" state (Missing Files). + - [x] Implement "Remove" (Delete DB record + Clean folder). + - [x] Add "Import Module" button (for Devs). diff --git a/scripts/deploy-module.ps1 b/scripts/deploy-module.ps1 deleted file mode 100644 index d818b49..0000000 --- a/scripts/deploy-module.ps1 +++ /dev/null @@ -1,42 +0,0 @@ -param( - [string]$ModuleId = "SimpleNotes", - [string]$Configuration = "Debug" -) - -$RootDir = Resolve-Path "$PSScriptRoot/.." -$ModulesOutDir = "$RootDir/_output/modules/$ModuleId" - -Write-Host "Deploying $ModuleId to $ModulesOutDir..." - -# Clean -if (Test-Path $ModulesOutDir) { - Remove-Item $ModulesOutDir -Recurse -Force -} -New-Item -ItemType Directory -Path $ModulesOutDir -Force | Out-Null - -# Copy Manifest -Copy-Item "$RootDir/src/Modules/$ModuleId/manifest.json" "$ModulesOutDir/" - -# Build and Copy Core -$CoreProject = "$RootDir/src/Modules/$ModuleId/$ModuleId.Core/$ModuleId.Core.csproj" -dotnet publish $CoreProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -# Build and Copy UI.Blazor -$BlazorProject = "$RootDir/src/Modules/$ModuleId/$ModuleId.UI.Blazor/$ModuleId.UI.Blazor.csproj" -dotnet publish $BlazorProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -# Build and Copy UI.Avalonia -$AvaloniaProject = "$RootDir/src/Modules/$ModuleId/$ModuleId.UI.Avalonia/$ModuleId.UI.Avalonia.csproj" -dotnet publish $AvaloniaProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -# Cleanup unwanted files (pdb, etc if needed, or duplicates) -# dotnet publish will copy dependencies. We might have duplicates or shared libs. -# For now, simplistic flat folder. -# NOTE: In real scenario, we might want subfolders or ALC handling of shared deps. -# Modulus Core expects flat structure currently? -# ModuleLoadContext looks in `_basePath`. -# ModuleLoader: `foreach (var assemblyRelativePath in manifest.CoreAssemblies) ... alc.LoadFromAssemblyPath(Path.Combine(packagePath, assemblyRelativePath));` -# So flat is fine if manifest says "SimpleNotes.Core.dll". - -Write-Host "Deployment Complete." - diff --git a/scripts/deploy-shell.ps1 b/scripts/deploy-shell.ps1 deleted file mode 100644 index 7be45a0..0000000 --- a/scripts/deploy-shell.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -param( - [string]$ModuleId = "Modulus.Shell", - [string]$Configuration = "Debug" -) - -$RootDir = Resolve-Path "$PSScriptRoot/.." -$ModulesOutDir = "$RootDir/_output/modules/$ModuleId" - -Write-Host "Deploying $ModuleId to $ModulesOutDir..." - -if (Test-Path $ModulesOutDir) { - Remove-Item $ModulesOutDir -Recurse -Force -} -New-Item -ItemType Directory -Path $ModulesOutDir -Force | Out-Null - -Copy-Item "$RootDir/src/Modules/Shell/manifest.json" "$ModulesOutDir/" - -# Core -# Project name mismatch: folder is Shell.Core but csproj is Shell.Core.csproj? -# I created it as Shell.Core/Shell.Core.csproj -$CoreProject = "$RootDir/src/Modules/Shell/Shell.Core/Shell.Core.csproj" -dotnet publish $CoreProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -# Blazor -$BlazorProject = "$RootDir/src/Modules/Shell/Shell.UI.Blazor/Shell.UI.Blazor.csproj" -dotnet publish $BlazorProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -# Avalonia -$AvaloniaProject = "$RootDir/src/Modules/Shell/Shell.UI.Avalonia/Shell.UI.Avalonia.csproj" -dotnet publish $AvaloniaProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -Write-Host "Deployment Complete." - diff --git a/specs/001-core-architecture/contracts/runtime-contracts.md b/specs/001-core-architecture/contracts/runtime-contracts.md deleted file mode 100644 index 27ecea4..0000000 --- a/specs/001-core-architecture/contracts/runtime-contracts.md +++ /dev/null @@ -1,65 +0,0 @@ -# Runtime Contracts & Interfaces - -## Module System - -### IModule -所有模块必须实现的接口。推荐继承 `Modulus.Sdk.ModuleBase`。 - -```csharp -public interface IModule -{ - void PreConfigureServices(IModuleLifecycleContext context); - void ConfigureServices(IModuleLifecycleContext context); - void PostConfigureServices(IModuleLifecycleContext context); - - Task OnApplicationInitializationAsync(IModuleInitializationContext context, CancellationToken cancellationToken = default); - Task OnApplicationShutdownAsync(IModuleInitializationContext context, CancellationToken cancellationToken = default); -} -``` - -### IModuleProvider -定义模块发现策略。 - -```csharp -public interface IModuleProvider -{ - Task> GetModulePackagesAsync(CancellationToken cancellationToken = default); - bool IsSystemSource { get; } -} -``` - -### IModuleLoader -负责加载、卸载和重载模块。 - -```csharp -public interface IModuleLoader -{ - Task LoadAsync(string packagePath, bool isSystem = false, CancellationToken cancellationToken = default); - Task UnloadAsync(string moduleId); - Task ReloadAsync(string moduleId, CancellationToken cancellationToken = default); - Task GetDescriptorAsync(string packagePath, CancellationToken cancellationToken = default); -} -``` - -## Module Manifest -`manifest.json` 结构定义 (see `Modulus.Sdk.ModuleManifest`). - -```json -{ - "id": "string", - "version": "string", - "displayName": "string?", - "description": "string?", - "supportedHosts": ["string"], - "coreAssemblies": ["string"], - "uiAssemblies": { - "HostType": ["string"] - }, - "dependencies": { - "ModuleId": "Version" - } -} -``` - -## SDK Helpers -`Modulus.Sdk.PluginPackageBuilder` 用于辅助构建插件包结构和清单。 diff --git a/specs/001-core-architecture/data-model.md b/specs/001-core-architecture/data-model.md deleted file mode 100644 index d385393..0000000 --- a/specs/001-core-architecture/data-model.md +++ /dev/null @@ -1,144 +0,0 @@ -# Data Model: Modulus 核心架构与双宿主运行时 - -**Feature**: `001-core-architecture` -**Source Spec**: `specs/001-core-architecture/spec.md` -**Last Updated**: 2025-12-03 - -本文件从规格中提取核心概念实体,描述它们的职责、关键字段与关系,供后续实现与 SDK 设计参考。 - ---- - -## 1. Module(模块) - -**职责**: 表示一个垂直切片功能单元,可包含 Domain / Application / Infrastructure 以及可选的 -Presentation / UI 实现。 - -**关键属性(示意字段名,仅为设计参考)**: - -- `ModuleId`:模块唯一标识(**推荐使用 GUID**,例如 `"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"`) -- `Version`:模块版本(语义化版本) -- `DisplayName`:模块对用户展示的名称 -- `Description`:模块描述 -- `SupportedHosts`:支持的宿主类型列表(如 `BlazorApp`, `AvaloniaApp`) -- `Assemblies`:该模块包含的程序集列表(含核心与 UI 程序集) -- `Dependencies`:对其它模块或运行时能力的依赖声明 - -**关系**: - -- 一个 `Module` 由一个或多个程序集实现; -- 一个 `Module` 需要映射到一个或多个 `PluginPackage`。 - ---- - -## 2. Host(宿主) - -**职责**: 提供外壳环境(窗口、生命周期、导航、菜单等),负责加载与管理模块。 - -**关键属性**: - -- `HostId`:宿主标识(如 `"BlazorDesktop"`, `"AvaloniaDesktop"`) -- `HostType`:宿主类型枚举(`Blazor`, `Avalonia`, ...) -- `Capabilities`:宿主提供的能力集合(窗口管理、通知、文档标签页等) -- `LoadedModules`:当前已加载模块列表 - -**关系**: - -- 一个 `Host` 可以同时加载多个 `Module`; -- 不同 `Host` 可以加载相同的 `Module` 核心程序集,但使用不同的 UI 程序集。 - ---- - -## 3. PluginPackage(插件包) - -**职责**: 作为部署与分发单位,将模块的程序集与资源打包在一起,供宿主发现与加载。 - -**关键属性**: - -- `PackageId`:插件包标识(通常与模块标识相关) -- `Version`:插件包版本 -- `Manifest`:`Manifest` 对象(见下一节) -- `ContentPath`:包内容所在路径(本地文件路径或解压目录) -- `SignatureInfo`:签名与完整性校验信息(如签名文件路径或证书指纹) - -**关系**: - -- 一个 `PluginPackage` 至少包含一个 `Module` 的实现; -- 一个 `Module` 在不同部署形式下可以对应多个 `PluginPackage`(例如社区版 / 企业版)。 - ---- - -## 4. Manifest(模块 / 插件清单) - -**职责**: 描述插件包中包含的模块信息、宿主支持情况与依赖关系,是运行时发现与加载的入口。 - -**关键属性**: - -- `Id`:模块 / 插件标识(与 `ModuleId` 对应) -- `Version`:模块版本 -- `Title`:显示名称 -- `Description`:描述 -- `Authors`:作者 / 组织信息 -- `SupportedHosts`:支持的宿主枚举列表 -- `CoreAssemblies`:核心程序集路径列表 -- `UiAssemblies`:按宿主类型分组的 UI 程序集路径列表 -- `Dependencies`:对其它模块或运行时能力的依赖 - -**关系**: - -- 一个 `Manifest` 与一个 `PluginPackage` 一一对应; -- 运行时通过解析 `Manifest` 构建 `Module` 对象与加载计划。 - ---- - -## 5. UIAbstractionContract(UI 抽象契约) - -**职责**: 定义模块表达 UI 意图的方式,屏蔽具体 UI 技术细节。 - -**核心接口示例(概念级,而非最终 API)**: - -- `IUIFactory`:根据 ViewModel / 标识创建视图或 UI 容器; -- `IViewHost`:承载视图的宿主接口,用于显示 / 关闭 / 激活视图; -- `IUiCommand`:描述可绑定到 UI 的命令; -- `NotificationContract`:描述通知 / 提示的显示方式。 - -**关系**: - -- 模块通过 `UIAbstractionContract` 与 `Host` 进行 UI 交互; -- 不同宿主提供各自的 UI 实现,但共享同一套抽象契约。 - ---- - -## 6. SDKBaseType(SDK 基类) - -**职责**: 为模块与插件作者(含 AI)提供强类型基类与扩展点,固化推荐模式。 - -**典型基类示意**: - -- `ModuleBase`:模块生命周期与依赖注册入口; -- `ToolPluginBase`:工具型插件基类,封装命令、UI 注册等; -- `DocumentPluginBase`:文档型 / 编辑器型插件基类。 - -**关系**: - -- SDK 基类通常依赖 `UIAbstractionContract` 与核心运行时服务; -- 插件 / 模块实现者通过继承 SDK 基类与实现特定虚方法完成注册。 - ---- - -## 7. RuntimeContext / ModuleRuntime(运行时上下文) - -**职责**: 表示运行中的模块系统状态,管理加载的模块、宿主与 ALC。 - -**关键属性**: - -- `LoadedModules`:已加载的 `Module` 集合 -- `Hosts`:已激活的 `Host` 集合 -- `AssemblyLoadContexts`:每个模块或模块组对应的 ALC 信息 -- `Mediator`:MediatR 实例或接口,用于请求 / 通知分发 - -**关系**: - -- 运行时在启动时创建 `RuntimeContext`,之后所有模块加载 / 卸载操作都经由此上下文协调; -- 该模型为后续实现插件监控、诊断与调试提供基础。 - - diff --git a/specs/001-core-architecture/plan.md b/specs/001-core-architecture/plan.md deleted file mode 100644 index b98ad62..0000000 --- a/specs/001-core-architecture/plan.md +++ /dev/null @@ -1,124 +0,0 @@ -# Implementation Plan: Modulus 核心架构与双宿主运行时 - -**Branch**: `[001-core-architecture]` | **Date**: 2025-11-27 | **Spec**: `specs/001-core-architecture/spec.md` -**Input**: Feature specification from `specs/001-core-architecture/spec.md` - -**Note**: This plan is generated for the `/speckit.plan` workflow and aligned with the Modulus Constitution. - -## Summary - -本特性旨在为 Modulus 提供一个 UI 无关的核心架构与双宿主运行时,使模块能够以垂直切片方式开发, -在 Blazor 风格宿主与 Avalonia 原生宿主下复用同一套 Domain / Application 逻辑。 -运行时将基于 `AssemblyLoadContext` 实现模块隔离与控制加载 / 卸载,通过 MediatR 处理模块间通信, -并通过统一的插件打包格式与 SDK 基类,为 AI 生成插件和人类开发者提供强类型扩展点。 - -Phase 1 (MVP) 聚焦于核心运行时、UI 抽象层、Web 风格宿主和最小可用 SDK; -Phase 2 (v1) 在此基础上补齐 Avalonia 宿主、完善 UI 抽象层、增强插件热重载与 SDK 能力。 - -## Technical Context - -**Language/Version**: .NET 10 (Current), C# (最新稳定版本;后续可评估 LTS 发布节奏进行调整) -**Primary Dependencies**: `MediatR`, `Avalonia`, `Blazor` (Hybrid / WebView 宿主), `Microsoft.Extensions.DependencyInjection`, `System.Text.Json` -**Storage**: N/A(本特性为框架级运行时与宿主,不绑定具体存储;业务模块可选择数据库 / 文件等) -**Testing**: xUnit + 集成测试(基于宿主运行时的端到端测试)+ 针对 SDK 与 manifest 的契约测试 -**Target Platform**: Windows 10+ / 最新 macOS(支持 Avalonia 原生宿主);后续可扩展到 Linux(Avalonia) -**Project Type**: 桌面 / 工具框架(多项目解决方案,单仓库,多宿主 + 多模块) -**Performance Goals**: 典型硬件上应用冷启动 < 3s,模块加载 / 卸载用户可感知延迟 < 2s;保持内存占用随模块数量线性可控 -**Constraints**: 核心层不得依赖具体 UI / Web 环境;插件加载 / 卸载需尽可能避免 `AssemblyLoadContext` 泄漏;必须符合宪章中金字塔分层与双宿主要求 -**Scale/Scope**: 初期包含 1–2 个内置模块 + 若干示例插件;解决方案预计包含若干 Core / Host / Modules / SDK 项目,后续可扩展到插件市场场景 - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -- **UI-Agnostic Core**: Does the proposed feature keep Domain/Application code free of any - concrete UI framework dependencies and use only `Modulus.UI.Abstractions` for UI contracts? -- **Dual-Engine Host Architecture**: Which hosts (Blazor, Avalonia) are targeted, and how will - UI assemblies be separated from core logic so the same module can run under multiple hosts? -- **Vertical Slice Modularity**: Which module(s) own this feature as a vertical slice, and can - they be loaded, tested, and versioned independently? -- **Pyramid Layering**: Do all dependencies follow - Presentation → UI Abstraction → Application → Domain → Infrastructure, with no cross-layer - shortcuts? -- **AI-Friendly Contracts & Plugin SDK**: What public contracts or SDK base types are required - or changed, and how will they remain strongly typed and self-describing for AI and human - authors? -- **Modern .NET & Technology Discipline**: Are the chosen runtime targets, MediatR usage, and - portability requirements consistent with the Modulus Constitution? - -Summarize any risks or intentional deviations here and link to the governance decision if -applicable. - -本实现计划严格遵守宪章中关于 UI 无关核心、双宿主架构、垂直切片模块化与金字塔分层的约束: -核心项目仅依赖 `Modulus.UI.Abstractions`,宿主分别封装 Blazor 与 Avalonia 相关依赖; -模块以 `Modulus.Modules..*` 命名的垂直切片组织,通过 DI 与 MediatR 解耦。 - -当前唯一需要在 Phase 0 研究阶段进一步细化的点是插件包的最终容器格式与签名方案 -(例如 Zip‑based `.modpkg` vs 基于 NuGet 的打包变体),但这些选项在设计上均可保持 -对宪章原则的兼容,不构成直接违反。 - -## Project Structure - -### Documentation (this feature) - -```text -specs/001-core-architecture/ -├── plan.md # This file (/speckit.plan command output) -├── research.md # Phase 0 output (/speckit.plan command) -├── data-model.md # Phase 1 output (/speckit.plan command) -├── quickstart.md # Phase 1 output (/speckit.plan command) -├── contracts/ # Phase 1 output (/speckit.plan command) -└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) -``` - -### Source Code (repository root) - -```text -src/ -├── Modulus.Core/ # 核心运行时与模块系统(ALC、模块发现、生命周期) -├── Modulus.UI.Abstractions/ # UI 抽象层接口与 DTO -├── Modulus.Sdk/ # 模块 / 插件 SDK(基类、辅助类型) -├── Hosts/ -│ ├── Modulus.Host.Blazor/ # Web 风格宿主(Blazor Hybrid / WebView 封装) -│ └── Modulus.Host.Avalonia/ # 原生桌面宿主(Avalonia) -├── Modules/ -│ ├── Modulus.Modules.Shell/ # 核心 Shell / 菜单 / 宿主集成模块 -│ ├── Modulus.Modules.Logging/ # 日志与诊断模块(示例) -│ └── Modulus.Modules.Samples/ # 示例模块集合(计算器等) -└── Shared/ - └── Modulus.Shared.Infrastructure/ # 共享基础设施实现(可选,避免业务逻辑泄漏) - -tests/ -├── Modulus.Core.Tests/ # 核心运行时与模块系统单测 / 集成测试 -├── Modulus.Hosts.Tests/ # 宿主层集成测试(启动、模块加载、UI 钩子) -├── Modulus.Modules.Tests/ # 模块层单测 / 集成测试 -└── Modulus.Sdk.Tests/ # SDK 契约与基类的契约测试 -``` - -**Structure Decision**: [Document the selected structure and reference the real -directories captured above] - -本计划采用“单仓库、多项目”的结构:以 `src/` 为根,按职责划分为 Core / UI.Abstractions / Hosts / -Modules / Sdk / Shared 若干项目目录,配套 `tests/` 目录按同样维度划分测试项目。 -这种结构有利于: - -- 清晰映射宪章中的分层与模块划分(例如 `Modulus.Modules..*` 垂直切片); -- 在同一解决方案中管理多个宿主与模块,方便跨项目引用与 CI 配置; -- 为后续 NuGet / `.modpkg` 打包提供稳定的项目边界。 - -备选方案包括: - -- 以 `apps/` + `src/` 结构区分宿主应用与可复用库; -- 以 `packages/` 结构直接对标未来 NuGet 包划分; - -当前选择上述 `src/` + `tests/` 结构作为 v1 基线,后续如需支持独立发布的宿主应用, -可以在根目录引入 `apps/` 目录承载打包与发布工程,而不改变 Core / Modules / Sdk 的结构。 - -## Complexity Tracking - -> **Fill ONLY if Constitution Check has violations that must be justified** - -| Violation | Why Needed | Simpler Alternative Rejected Because | -|-----------|------------|-------------------------------------| -| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | -| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/001-core-architecture/quickstart.md b/specs/001-core-architecture/quickstart.md deleted file mode 100644 index 5728228..0000000 --- a/specs/001-core-architecture/quickstart.md +++ /dev/null @@ -1,320 +0,0 @@ -# Quickstart: Modulus 模块开发指南 - -**Feature**: `001-core-architecture` -**Updated**: 2025-12-03 - -本文档帮助开发者快速上手 Modulus 模块开发。 - ---- - -## 1. 项目结构概览 - -```text -src/ -├── Modulus.Core/ # 核心运行时 (RuntimeContext, ModuleLoader, ModuleManager) -├── Modulus.Sdk/ # SDK 基类与属性 (ModuleBase, ModuleAttribute, etc.) -├── Modulus.UI.Abstractions/ # UI 抽象接口 (IMenuRegistry, IThemeService, etc.) -├── Hosts/ -│ ├── Modulus.Host.Blazor/ # Blazor Hybrid 宿主 (MAUI + MudBlazor) -│ └── Modulus.Host.Avalonia/ # Avalonia 桌面宿主 -└── Modules/ - ├── EchoPlugin/ # 示例: Echo 插件 - │ ├── EchoPlugin.Core/ - │ ├── EchoPlugin.UI.Avalonia/ - │ └── EchoPlugin.UI.Blazor/ - └── SimpleNotes/ # 示例: 笔记模块 - ├── SimpleNotes.Core/ - ├── SimpleNotes.UI.Avalonia/ - └── SimpleNotes.UI.Blazor/ -``` - ---- - -## 2. 创建新模块 - -### 2.1 项目结构 - -每个模块由三个项目组成: - -| 项目 | 类型 | 引用 | -|------|------|------| -| `MyModule.Core` | Class Library | `Modulus.Sdk`, `Modulus.UI.Abstractions` | -| `MyModule.UI.Avalonia` | Class Library | `MyModule.Core`, `Avalonia` | -| `MyModule.UI.Blazor` | Razor Class Library | `MyModule.Core`, `MudBlazor` | - -### 2.2 Core 模块类 - -```csharp -using Modulus.Sdk; -using Modulus.Sdk.Attributes; - -namespace MyModule.Core; - -[Module( - Id = "my-module-guid-here", - DisplayName = "My Module", - Description = "A sample module")] -public class MyModuleModule : ModuleBase -{ - public override void ConfigureServices(IModuleLifecycleContext context) - { - // Register services - context.Services.AddTransient(); - } -} -``` - -### 2.3 ViewModel (使用 CommunityToolkit.Mvvm) - -```csharp -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; - -namespace MyModule.Core.ViewModels; - -public partial class MyViewModel : ObservableObject -{ - [ObservableProperty] - private string _title = "My Module"; - - [ObservableProperty] - private string _inputText = string.Empty; - - [RelayCommand] - private void DoSomething() - { - // Business logic here - } -} -``` - -### 2.4 Avalonia UI 模块 - -```csharp -using Modulus.Sdk; -using Modulus.Sdk.Attributes; - -namespace MyModule.UI.Avalonia; - -[DependsOn(typeof(MyModuleModule))] -[AvaloniaMenu( - DisplayName = "My Module", - Icon = "🔧", - ViewModelType = typeof(MyViewModel), - Location = MenuLocation.Main, - Order = 50)] -public class MyModuleAvaloniaModule : ModuleBase -{ - public override Task OnApplicationInitializationAsync( - IModuleInitializationContext context, - CancellationToken cancellationToken = default) - { - var viewRegistry = context.ServiceProvider.GetRequiredService(); - viewRegistry.Register(); - return Task.CompletedTask; - } -} -``` - -### 2.5 Blazor UI 模块 - -```csharp -using Modulus.Sdk; -using Modulus.Sdk.Attributes; - -namespace MyModule.UI.Blazor; - -[DependsOn(typeof(MyModuleModule))] -[BlazorMenu( - DisplayName = "My Module", - Icon = "extension", // MudBlazor icon name - Route = "/mymodule", - Location = MenuLocation.Main, - Order = 50)] -public class MyModuleBlazorModule : ModuleBase -{ - // Blazor uses route-based navigation, no view registration needed -} -``` - ---- - -## 3. Manifest 配置 - -每个模块需要一个 `manifest.json` 文件: - -```json -{ - "manifestVersion": "1.0", - "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", - "version": "1.0.0", - "displayName": "My Module", - "description": "A sample module for demonstration.", - "supportedHosts": ["BlazorApp", "AvaloniaApp"], - "coreAssemblies": ["MyModule.Core.dll"], - "uiAssemblies": { - "BlazorApp": ["MyModule.UI.Blazor.dll"], - "AvaloniaApp": ["MyModule.UI.Avalonia.dll"] - }, - "dependencies": {} -} -``` - -**重要**: -- `id` 推荐使用 GUID 以确保唯一性 -- `manifest.json` 需要复制到输出目录(在 `.csproj` 中配置) - -```xml - - - -``` - ---- - -## 4. 模块生命周期 - -模块生命周期方法按以下顺序调用: - -1. **ConfigureServices** - 注册 DI 服务 -2. **PreConfigureAsync** - 预配置(依赖模块之前) -3. **ConfigureAsync** - 主配置 -4. **PostConfigureAsync** - 后配置(依赖模块之后) -5. **OnApplicationInitializationAsync** - 应用初始化(注册视图、菜单等) -6. **OnApplicationShutdownAsync** - 应用关闭时清理 - -```csharp -public class MyModuleModule : ModuleBase -{ - public override void ConfigureServices(IModuleLifecycleContext context) - { - // Step 1: Register services - } - - public override Task OnApplicationInitializationAsync( - IModuleInitializationContext context, - CancellationToken cancellationToken = default) - { - // Step 5: Register menus, views, etc. - return Task.CompletedTask; - } - - public override Task OnApplicationShutdownAsync( - IModuleInitializationContext context, - CancellationToken cancellationToken = default) - { - // Step 6: Cleanup - return Task.CompletedTask; - } -} -``` - ---- - -## 5. 依赖管理 - -使用 `[DependsOn]` 属性声明模块依赖: - -```csharp -[DependsOn(typeof(CoreModule), typeof(LoggingModule))] -public class MyModuleModule : ModuleBase -{ - // This module will be initialized after CoreModule and LoggingModule -} -``` - ---- - -## 6. 宿主类型 - -Modulus 支持两种宿主类型: - -| 宿主 | 标识符 | UI 框架 | -|------|--------|---------| -| Blazor Hybrid | `BlazorApp` | MAUI + MudBlazor | -| Avalonia | `AvaloniaApp` | Avalonia UI | - -模块可以通过 `RuntimeContext.HostType` 获取当前宿主类型。 - ---- - -## 7. UI 抽象接口 - -### IMenuRegistry -注册导航菜单项: - -```csharp -var menuRegistry = context.ServiceProvider.GetRequiredService(); -menuRegistry.Register(new MenuItem( - id: "my-menu", - displayName: "My Module", - icon: "🔧", - navigationKey: typeof(MyViewModel).FullName!, - location: MenuLocation.Main, - order: 50)); -``` - -### IThemeService -管理应用主题: - -```csharp -var themeService = context.ServiceProvider.GetRequiredService(); -themeService.SetTheme(AppTheme.Dark); -``` - -### INotificationService -显示通知: - -```csharp -var notificationService = context.ServiceProvider.GetRequiredService(); -await notificationService.ShowInfoAsync("Title", "Message"); -``` - ---- - -## 8. 数据持久化 - -Modulus 使用 SQLite + EF Core 存储应用设置和模块状态: - -### ISettingsService -存取应用设置: - -```csharp -var settings = context.ServiceProvider.GetRequiredService(); - -// Get setting with default value -var theme = settings.GetSetting("AppTheme", AppTheme.System); - -// Set setting -settings.SetSetting("AppTheme", AppTheme.Dark); -``` - ---- - -## 9. 运行与调试 - -### 启动 Avalonia 宿主 -```bash -dotnet run --project src/Hosts/Modulus.Host.Avalonia -``` - -### 启动 Blazor 宿主 -```bash -dotnet run --project src/Hosts/Modulus.Host.Blazor -``` - -### 运行测试 -```bash -dotnet test -``` - ---- - -## 10. 最佳实践 - -1. **保持 Core 模块 UI 无关** - 不要在 Core 项目中引用任何 UI 框架 -2. **使用 GUID 作为模块 ID** - 确保模块标识的唯一性 -3. **使用声明式属性** - 优先使用 `[Module]`, `[AvaloniaMenu]`, `[BlazorMenu]` 属性 -4. **遵循 MVVM 模式** - 使用 CommunityToolkit.Mvvm 实现 ViewModel -5. **正确配置 manifest.json** - 确保复制到输出目录 -6. **测试驱动** - 为模块编写单元测试和集成测试 diff --git a/specs/001-core-architecture/research.md b/specs/001-core-architecture/research.md deleted file mode 100644 index 64cc701..0000000 --- a/specs/001-core-architecture/research.md +++ /dev/null @@ -1,101 +0,0 @@ -# Research: Modulus 核心架构与双宿主运行时 - -**Feature**: `001-core-architecture` -**Date**: 2025-11-27 - -本研究文档用于在实现前澄清关键架构决策,解决规格中的 NEEDS CLARIFICATION,并为后续设计与实现提供依据。 - ---- - -## 1. 插件打包格式与签名方案 - -### Decision - -采用自定义的 Zip‑based 容器格式(暂定扩展名为 `.modpkg`),内部使用清晰的目录结构与 `manifest.json` -描述模块与插件的元数据。 -签名方案优先通过标准的文件签名机制(如 Authenticode 或外部签名文件)集成,而不是把签名逻辑 -硬编码在容器格式中。 - -### Rationale - -- Zip 容器跨平台、易于在 .NET 中读写,生态成熟; -- 自定义扩展名 `.modpkg` 可以与普通 Zip 区分,便于工具与宿主发现; -- 使用 JSON manifest 便于 AI 与人类阅读和生成; -- 将签名视为独立 concern,允许未来支持多种签名与校验方案,而不锁定在单一实现上。 - -### Alternatives considered - -1. **直接使用 NuGet 包 (`.nupkg`)** - - Pros: 与现有 .NET 生态高度兼容,工具链成熟。 - - Cons: NuGet 语义偏向代码分发与依赖管理,不完全匹配运行时插件加载场景; - 同时会引入与普通依赖管理混淆的风险。 - -2. **裸目录结构(无容器,仅文件夹)** - - Pros: 开发调试简单,零额外封装。 - - Cons: 发布与分发体验较差,难以进行完整性校验,且不利于跨平台复制与备份。 - ---- - -## 2. Blazor 宿主承载方式 - -### Decision - -Phase 1 中,将 Blazor 宿主抽象为“基于 WebView 的桌面宿主”,在架构上不锁定具体实现 -(如 .NET MAUI Hybrid 或 Photino),而是通过一层 Host 抽象封装。具体技术选型可以在 -宿主项目中根据目标平台与维护成本做最终决定。 - -### Rationale - -- 当前生态中,.NET MAUI 与 Photino 均可承载 Blazor UI,且各有优劣; -- 在架构层面对“Blazor 宿主”进行抽象,有利于后续根据实际经验切换或并存多种实现; -- 对模块与 SDK 而言,关键在于「存在一个 Web 风格宿主」,而不是具体用哪种技术堆栈。 - -### Alternatives considered - -1. **强制使用 .NET MAUI Hybrid** - - Pros: 官方支持、集成度高,对移动平台扩展有潜力。 - - Cons: 对桌面工具型场景(尤其是已有桌面环境)可能显得偏重。 - -2. **强制使用 Photino / WebWindow 等轻量宿主** - - Pros: 更贴近桌面工具需求,依赖更少,打包体积可能更小。 - - Cons: 社区生态与长期维护情况需要评估。 - ---- - -## 3. 测试框架与测试策略 - -### Decision - -采用 **xUnit** 作为主单元测试框架,配合: - -- 核心运行时与模块系统的单元测试与集成测试(`Modulus.Core.Tests`); -- 宿主层端到端测试(`Modulus.Hosts.Tests`),通过自动化启动宿主、加载示例模块; -- SDK 契约测试(`Modulus.Sdk.Tests`),确保基类行为与文档 / 示例一致。 - -### Rationale - -- xUnit 在 .NET 社区使用广泛,生态与工具支持成熟; -- 现有许多开源项目使用 xUnit,方便贡献者迁移习惯; -- 与 Nuke 等构建工具集成简单。 - -### Alternatives considered - -1. **NUnit** - - Pros: 历史悠久、功能丰富。 - - Cons: 与当前社区主流趋势相比略弱,团队习惯需统一。 - -2. **MSTest** - - Pros: 与部分微软工具链天然集成。 - - Cons: 社区示例与生态相对较少,不利于插件作者快速上手。 - ---- - -## 4. 未决问题与后续研究方向 - -- 插件签名的具体实现路径(使用哪种证书体系、如何在 CI/CD 中集成); -- 是否需要支持进程外插件(Out-of-Process),以及如何与当前 ALC 模式共存; -- 针对大型插件市场场景的版本兼容策略与回滚策略。 - -这些问题不阻塞 Phase 1 / Phase 2 的基本架构设计,但需要在后续 Story / Spec 中单独展开。 - - diff --git a/specs/001-core-architecture/spec.md b/specs/001-core-architecture/spec.md deleted file mode 100644 index cf366b9..0000000 --- a/specs/001-core-architecture/spec.md +++ /dev/null @@ -1,231 +0,0 @@ -# Feature Specification: Modulus 核心架构与双宿主运行时 - -**Feature Branch**: `[001-core-architecture]` -**Created**: 2025-11-27 -**Status**: Draft -**Input**: User description: "Design the core architecture and dual-host runtime for the Modulus modular .NET desktop framework." - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - 一次开发,双宿主运行 (Priority: P1) - -作为模块 / 插件开发者,我可以只围绕 UI 无关的核心(Domain + Application)和统一 SDK 实现一个模块, -并在不修改业务代码的前提下,让同一套核心逻辑分别在 Web 风格宿主和原生桌面宿主下运行。 - -**Why this priority**: 这是 Modulus 的核心价值主张:在不同 UI 环境下复用一套业务逻辑,降低开发与维护成本。 - -**Independent Test**: 实现一个简单的垂直切片示例模块(例如计算器或日志查看器),验证其 -Domain / Application 程序集在 Blazor 风格宿主与 Avalonia 宿主下完全相同,仅通过不同 UI 程序集 -即可完成两端运行。 - -**Acceptance Scenarios**: - -1. **Given** 一个包含 Domain / Application 程序集以及对应 UI 程序集的模块 - **When** 将该模块加载到 Web 风格宿主中 - **Then** 该功能可以在该宿主内完整使用,行为与预期一致,UI 流程可用。 - -2. **Given** 相同的核心程序集 - **When** 将该模块加载到原生桌面宿主中 - **Then** 在不修改 Domain / Application 代码的前提下,功能同样可用,行为等价(样式可以不同但体验应一致)。 - ---- - -### User Story 2 - 运行时安全启用 / 禁用模块 (Priority: P1) - -作为框架维护者或高级用户,我可以在不重启整个应用的前提下,在运行时启用、禁用和重新加载模块 / 插件, -并保证其它已加载模块不会因此失效。 - -**Why this priority**: 运行时可插拔与安全边界是构建可扩展工具平台、支持热重载与快速迭代的基础能力。 - -**Independent Test**: 启动宿主加载一组模块,然后在同一进程内多次重复对某一个模块执行启用 / 禁用 / 重新加载操作, -验证其它模块始终正常工作、宿主稳定不崩溃。 - -**Acceptance Scenarios**: - -1. **Given** 宿主当前已加载多个模块 - **When** 通过运行时管理接口禁用或卸载某个模块 - **Then** 该模块的 UI 与行为会被干净移除,其它模块继续正常工作且无异常。 - -2. **Given** 同一宿主会话 - **When** 重新启用或重新加载之前被禁用的模块 - **Then** 该模块重新出现,其初始化逻辑正常执行,不会破坏共享状态或其它模块。 - ---- - -### User Story 3 - 基于 SDK 的 AI 辅助插件开发 (Priority: P2) - -作为使用 AI 助手的开发者(或 AI Agent 本身),我可以基于一组强类型的 SDK 基类与清晰契约, -自动生成一个简单插件或模块,使其在无需手工调整整体结构的情况下即可编译、打包并在运行时加载执行。 - -**Why this priority**: 对 AI 友好的契约能让 Modulus 成为快速生成工具与自动化插件的平台。 - -**Independent Test**: 使用官方 SDK 基类与示例模版,通过 AI 生成一个简单的 “echo” 或 “calculator” 插件, -然后按标准流程编译、打包并加载到运行时中完成端到端验证。 - -**Acceptance Scenarios**: - -1. **Given** 官方 SDK 基类与示例模版 - **When** AI Agent 按照模版生成一个新的插件实现 - **Then** 插件在不修改结构的前提下可以成功编译,并能按定义的打包格式打包。 - -2. **Given** 生成的插件包 - **When** 宿主通过标准发现机制加载该插件 - **Then** 插件会出现在宿主 UI 中,可被调用,并按照契约定义的行为正确执行。 - ---- - -### Edge Cases - -- 当插件或模块 manifest 缺失必需字段、字段无效或引用的程序集无法加载时,系统应该如何处理? -- 当某个模块在初始化阶段失败(例如启动代码抛异常),但其它模块已成功加载时,系统如何隔离与降级? -- 当某个模块被两个宿主请求加载,但其只提供了单一宿主的 UI 程序集时(例如仅提供 Blazor UI),系统应该如何降级或发出提示? - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: 框架必须提供一个模块运行时,用于发现、加载、启用、禁用和卸载模块 / 插件, - 在常见路径下不需要重启整个应用。 - -- **FR-002**: 各模块的 Domain 与 Application 层必须是 UI 无关的,只能依赖 - `Modulus.UI.Abstractions` 等 UI 抽象层契约,而不能直接引用具体 UI 框架(如 Blazor、Avalonia 等)。 - -- **FR-003**: 架构必须支持至少两种一等宿主类型:Web 风格宿主与原生桌面宿主,使相同的 - Domain / Application 程序集可以在两种宿主下运行。 - -- **FR-004**: 系统必须以垂直切片模块为主要交付单元,每个功能模块需要明确区分: - - **Business Module (Core)**: 包含 Domain / Application 层,**全宿主通用**,不依赖特定 UI 技术栈。 - - **UI Adapter Module (Host-Specific)**: 包含 Presentation 层,依赖 Core 模块,并**明确标记**适配哪个宿主(Blazor, Avalonia, React 等)。宿主加载时必须仅加载匹配其类型的 UI 适配器。 - -- **FR-005**: 运行时必须通过 MediatR(或等价的强类型进程内消息机制)处理模块之间的通信, - 禁止通过直接依赖其它模块实现类的方式进行耦合。 - -- **FR-006**: 必须设计一种插件 / 模块打包格式(概念上类似 `.modpkg` 容器),其中包含: - - 描述标识、版本、依赖、支持宿主等信息的 manifest; - - 一个或多个核心程序集(Domain / Application / Infrastructure); - - 每种宿主类型对应的 0~N 个 UI 程序集。 - -- **FR-007**: 运行时必须支持基于 `AssemblyLoadContext`(或等价机制)的隔离, - 使模块 / 插件可以在一定程度上独立加载与卸载,并尽量减少对其它模块的影响。 - -- **FR-008**: 框架必须提供强类型的 SDK 基类与接口,用于模块与插件开发 - (例如工具型插件、文档型插件、模块基类等),在其中编码推荐的初始化、生命周期和 UI 注册模式。 - -- **FR-009**: 在 **Phase 1 (MVP)** 范围内,架构至少需要交付: - - 核心运行时与模块系统; - - UI 抽象层; - - 一种 Web 风格宿主(如 Blazor-based)及至少一个端到端示例模块; - - 一套最小可用的插件 SDK,使简单插件可以被 AI 辅助生成。 - -- **FR-010**: 在 **Phase 2 (v1)** 中,架构需要在 Phase 1 基础上: - - 增加第二种宿主类型(如 Avalonia 原生宿主); - - 将 UI 抽象层完善为可在两种宿主间一致工作的契约; - - 支持在进程内的插件热重载 / 卸载(在合理约束前提下); - - 扩展 SDK 能力以支持更复杂的插件形式。 - -- **FR-011**: 打包与 manifest 模型必须从设计上支持版本化,以便将来增加新字段或能力时, - 不会破坏现有插件。 - -- **FR-012**: 架构必须明确划分宿主与模块的职责边界:宿主负责外部环境(窗口、导航、菜单等), - 模块负责业务逻辑、UI 契约与插件行为。 - -- **FR-013**: 必须确定插件包的具体容器格式与签名方案(例如 Zip‑based `.modpkg`、NuGet 变体等), - 并说明如何校验插件的来源可信与内容完整。 - - **FR-014**: 模块加载器必须具备**宿主感知能力**。在加载模块时,除了加载核心程序集外, - 只能加载与当前运行宿主(Current Host Type)匹配的 UI 程序集。 - Manifest 中的 `uiAssemblies` 字段应作为查找依据。SDK 层面应提供特性(如 `[HostAffinity]`) - 以进一步校验 UI 模块的兼容性。 - -### Key Entities *(include if feature involves data)* - -- **Module(模块)**: 表示一个垂直切片功能,可能包含 Domain、Application、Infrastructure 以及可选的 - Presentation / UI 程序集,具有唯一标识、版本和支持宿主等元数据。 - -- **Host(宿主)**: 提供环境相关能力(例如窗口、导航、顶层菜单)的外壳应用,根据模块元数据加载模块。 - 至少包括 Web 风格宿主与原生桌面宿主两种。 - -- **PluginPackage(插件包)**: 一个可部署与发现的制品(例如 `.modpkg`),内部包含 manifest、 - 核心程序集、可选的各宿主 UI 程序集以及资源文件,是外部插件的发布单位。 - -- **UIAbstractionContract(UI 抽象契约)**: 一组接口与 DTO(例如 View 工厂、View 宿主、消息契约等), - 用于描述模块如何表达 UI 意图,而不直接依赖具体 UI 框架。 - -- **SDKBaseType(SDK 基类)**: 面向模块与插件开发提供的强类型基类与辅助类型, - 在其中固化推荐模式,简化人类与 AI 的开发体验。 - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: 至少有一个示例垂直切片模块可以在仅构建一次 - Domain / Application / Infrastructure 程序集的前提下,分别在两种宿主下成功运行, - 且这两种运行方式之间不需要修改这些核心程序集。 - -- **SC-002**: 在一轮测试会话中,对同一个模块执行启用、禁用与重新加载操作至少 100 次, - 无需重启应用,且其它已加载模块始终保持正常工作。 - -- **SC-003**: 至少有一个通过 AI 辅助、基于官方 SDK 基类自动生成的简单插件可以: - - 在不调整整体结构的前提下成功编译; - - 按指定打包格式打包; - - 被运行时加载并完成端到端调用。 - -- **SC-004**: 在目标环境的典型硬件配置下,加载或卸载单个模块的用户可感知延迟在合理范围内 - (例如常规场景下可在 2 秒内完成,使用户感觉“几乎即时”)。 - -## Architecture & Modulus Constraints *(mandatory for this repository)* - -### Module & Host Mapping - -- Owning module(s) (vertical slice): - - `Modulus.Core`(核心运行时与模块系统) - - `Modulus.UI.Abstractions`(UI 抽象契约) - - `Modulus.Host.Blazor`(Web 风格宿主模块) - - `Modulus.Host.Avalonia`(原生桌面宿主模块) - - `Modulus.Sdk`(模块 / 插件 SDK) - -- Target host(s): - - Web-style host(基于 Blazor 的宿主) - - Native desktop host(基于 Avalonia 的宿主) - -- UI assemblies involved (if any): - - 宿主级 UI 程序集(如 `Modulus.Host.Blazor.UI`, `Modulus.Host.Avalonia.UI`) - - 示例模块 UI 程序集(如 `ExampleModule.UI.Blazor.dll`, `ExampleModule.UI.Avalonia.dll`) - -### Layering & Dependencies - -- Layers touched by this feature: - - Presentation - - UI Abstraction - - Application - - Domain - - Infrastructure - -- Any required cross-module communication (via MediatR or explicit interfaces): - - 模块发现、加载 / 卸载与状态通知通过 MediatR 请求 / 通知完成; - - 宿主向模块发送与导航、窗口 / 工具窗口创建、环境事件相关的消息; - - 模块通过 UI 抽象层发布 UI 意图(如打开视图、显示通知),而不是直接操作具体 UI 框架。 - -- Confirm there are no planned violations of the dependency pyramid: - - Domain / Application 项目不得引用宿主特定 UI 框架或环境特定 API; - - Presentation 与宿主项目可以依赖 UI 框架,但只能通过 UI 抽象层契约、Application 服务与 MediatR - 与核心逻辑交互; - - 若存在任何计划性例外,必须记录在架构说明中,并关联到宪章治理决策。 - -### Public Contracts & SDK Impact - -- New or changed public contracts / DTOs: - - 模块与插件 manifest 模型(标识、版本、能力、支持宿主等); - - UI 抽象接口(如 View 工厂、View 宿主、消息契约); - - 模块生命周期契约(初始化、启动、停止、释放等)。 - -- New or updated SDK base types (if any): - - 垂直切片模块基类; - - 工具 / 文档类插件基类,用于在两个宿主下进行 UI 集成; - - 打包与 manifest 辅助类型,统一插件自描述方式。 - -- Backward compatibility and migration notes: - - 本规格定义了 Modulus 核心架构与双宿主运行时的初始基线; - - 公共契约与 manifest 必须支持版本化,以便未来引入 - 进程外插件、更多宿主类型等能力时不破坏现有 v1 模块 / 插件; - - 任何后续破坏性变更必须遵循 Modulus 宪章中的版本管理与迁移策略要求, - 并同步更新面向 AI 的 SDK 文档与示例。 \ No newline at end of file diff --git a/specs/001-core-architecture/tasks.md b/specs/001-core-architecture/tasks.md deleted file mode 100644 index 48718a0..0000000 --- a/specs/001-core-architecture/tasks.md +++ /dev/null @@ -1,205 +0,0 @@ ---- - -description: "Tasks for implementing Modulus 核心架构与双宿主运行时" ---- - -# Tasks: Modulus 核心架构与双宿主运行时 - -**Input**: Design documents from `specs/001-core-architecture/` -**Prerequisites**: `plan.md` (required), `spec.md` (required), `research.md`, `data-model.md`, `contracts/`, `quickstart.md` - -**Tests**: 本特性涉及框架与运行时,建议为关键路径添加测试任务(单元测试 + 集成测试),但数量可随实现阶段调整。 -**Organization**: 任务按 User Story 分组,确保每一 Story 都可独立实现与验证。 - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: 可并行执行(不同文件、无依赖) -- **[Story]**: 任务归属的 User Story(US1, US2, US3) -- 描述中必须包含明确文件路径 - ---- - -## Phase 1: Setup (Shared Infrastructure) - -**Purpose**: 创建基础目录与项目结构,为后续实现提供落地点。 - -- [X] T001 创建基础目录结构 `src/` 与 `tests/`(如不存在)于仓库根目录 - (修改路径:`D:\src\tools\Modulus\`) -- [X] T002 [P] 创建核心运行时项目 `src/Modulus.Core/Modulus.Core.csproj` 并将其加入 `Modulus.sln` - (修改文件:`Modulus.sln`) -- [X] T003 [P] 创建 UI 抽象层项目 `src/Modulus.UI.Abstractions/Modulus.UI.Abstractions.csproj` 并加入解决方案 - (修改文件:`Modulus.sln`) -- [X] T004 [P] 创建 SDK 项目 `src/Modulus.Sdk/Modulus.Sdk.csproj` 并加入解决方案 - (修改文件:`Modulus.sln`) -- [X] T005 [P] 在 `src/Hosts/` 下创建 Blazor 宿主项目 `Modulus.Host.Blazor/Modulus.Host.Blazor.csproj` - (修改文件:`Modulus.sln`) -- [X] T006 [P] 在 `src/Hosts/` 下创建 Avalonia 宿主项目 `Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj` - (修改文件:`Modulus.sln`) -- [X] T007 [P] 在 `src/Modules/` 下创建 Shell 模块项目 `Modulus.Modules.Shell/Modulus.Modules.Shell.csproj` - (修改文件:`Modulus.sln`) -- [X] T008 [P] 在 `src/Modules/` 下创建示例模块项目 `Modulus.Modules.Samples/Modulus.Modules.Samples.csproj` - (修改文件:`Modulus.sln`) -- [X] T009 [P] 创建测试项目 `tests/Modulus.Core.Tests/Modulus.Core.Tests.csproj` 并配置到 `Modulus.sln` -- [X] T010 [P] 创建测试项目 `tests/Modulus.Hosts.Tests/Modulus.Hosts.Tests.csproj` 并配置到 `Modulus.sln` -- [X] T011 [P] 创建测试项目 `tests/Modulus.Modules.Tests/Modulus.Modules.Tests.csproj` 并配置到 `Modulus.sln` -- [X] T012 [P] 创建测试项目 `tests/Modulus.Sdk.Tests/Modulus.Sdk.Tests.csproj` 并配置到 `Modulus.sln` - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: 完成所有 User Story 之前必须具备的核心运行时与架构基础。 - -**⚠️ CRITICAL**: 在本阶段完成前,不应开始任何具体 User Story 的业务实现。 - -- [X] T013 在 `src/Modulus.Core/` 中实现基础依赖注入与日志基础设施(使用 `Microsoft.Extensions.DependencyInjection` 和 logging) -- [X] T014 在 `src/Modulus.Core/Runtime/` 下定义并实现核心实体:`Module`, `Host`, `PluginPackage`, `Manifest`, `RuntimeContext`(与 `data-model.md` 一致) -- [X] T015 在 `src/Modulus.Core/Runtime/` 中实现基于 `AssemblyLoadContext` 的模块加载器(创建 / 卸载 ALC,加载模块程序集) -- [X] T016 在 `src/Modulus.Core/Manifest/` 中实现 `.modpkg` 容器与 `manifest.json` 的解析逻辑(路径映射、支持宿主信息、依赖列表) -- [X] T017 在 `src/Modulus.Core/Runtime/` 中集成 `MediatR`,配置模块级与跨模块请求 / 通知分发 -- [X] T018 在 `src/Modulus.UI.Abstractions/` 中定义 UI 抽象接口(`IUIFactory`, `IViewHost`, `INotificationService` 等) -- [X] T019 在 `src/Modulus.Sdk/` 中引入基础 SDK 契约与空实现骨架(`ModuleBase`, `ToolPluginBase`, `DocumentPluginBase` 等) -- [X] T020 在 `tests/Modulus.Core.Tests/` 中编写最小集成测试:验证加载一个 `.modpkg` 包能创建 `Module` 并注册到 `RuntimeContext` -- [X] T021 [P] 在 `tests/Modulus.Sdk.Tests/` 中为 `ModuleBase` 与 `ToolPluginBase` 添加基本契约测试(生命周期调用顺序与必需回调) -- [X] T022 检查并调整解决方案引用,确保遵守 `Presentation → UI Abstraction → Application → Domain → Infrastructure` 依赖金字塔(主要修改 `*.csproj`) -- [X] T023 确认 `src/Modulus.Core/` 与 `src/Modulus.Sdk/` 不引用任何具体 UI 框架命名空间(Blazor / Avalonia),必要时通过分析与重构移除 - -**Checkpoint**: 基础运行时、UI 抽象、SDK 骨架与分层依赖全部就绪,可以开始按 User Story 分阶段实现。 - ---- - -## Phase 3: User Story 1 - 一次开发,双宿主运行 (Priority: P1) 🎯 MVP - -**Goal**: 实现一个示例垂直切片模块,使其在 Web 风格宿主与 Avalonia 宿主下共用同一套核心逻辑运行。 - -**Independent Test**: 构建一次示例模块的 Domain / Application 程序集,在不修改这些程序集的前提下, -分别运行 Blazor 宿主与 Avalonia 宿主,并验证示例功能在两者中行为一致。 - -### Implementation for User Story 1 - -- [X] T024 [P] [US1] 在 `src/Modules/Modulus.Modules.Samples/Domain/` 中实现示例模块的 Domain 模型与服务(例如计算器 / 简单工具) -- [X] T025 [P] [US1] 在 `src/Modules/Modulus.Modules.Samples/Application/` 中实现用例与 Application 服务,依赖 Domain 模型与 `Modulus.UI.Abstractions` -- [X] T026 [P] [US1] 在 `src/Modules/Modulus.Modules.Samples/UI.Blazor/` 下实现 Blazor 视图与适配层,通过 `IUIFactory` 与 Application 服务交互 -- [X] T027 [P] [US1] 在 `src/Modules/Modulus.Modules.Samples/UI.Avalonia/` 下实现 Avalonia 视图与适配层,通过 `IUIFactory` 与 Application 服务交互 -- [X] T028 [US1] 为示例模块编写 manifest 文件(例如 `src/Modules/Modulus.Modules.Samples/manifest.json`),填充模块标识、版本、支持宿主与程序集列表 -- [X] T029 [US1] 在 `src/Modulus.Core/Runtime/` 中接入示例模块的 manifest 与 `.modpkg` 打包,使宿主可发现并加载该模块 -- [X] T030 [US1] 在 `src/Hosts/Modulus.Host.Blazor/` 中添加示例模块入口(菜单 / 工具面板注册)并验证交互闭环 -- [X] T031 [US1] 在 `src/Hosts/Modulus.Host.Avalonia/` 中添加示例模块入口(窗口 / 面板注册)并验证交互闭环 -- [X] T032 [P] [US1] 在 `tests/Modulus.Hosts.Tests/` 中添加端到端测试:在两种宿主下分别加载并调用示例模块,验证行为一致 - -**Checkpoint**: 示例模块在 Blazor 宿主与 Avalonia 宿主下均可用,且共享相同的核心业务程序集。 - ---- - -## Phase 4: User Story 2 - 运行时安全启用 / 禁用模块 (Priority: P1) - -**Goal**: 支持在不重启应用的前提下安全启用、禁用与重新加载模块,并确保其它模块不受影响。 - -**Independent Test**: 在同一进程内重复对一个模块执行启用 / 禁用 / 重新加载操作多次,验证其它模块与宿主稳定性。 - -### Implementation for User Story 2 - -- [X] T033 [US2] 在 `src/Modulus.Core/Runtime/` 中扩展 `RuntimeContext` 与模块管理 API,支持 enable/disable/reload 操作 -- [X] T034 [US2] 在 `src/Modulus.Core/Runtime/` 中实现模块卸载时的清理逻辑(释放 ALC、注销 DI 注册等),避免资源泄漏 -- [X] T035 [US2] 在 `src/Hosts/` 各宿主中实现模块管理 UI(列表 / 状态 / 操作按钮),Shell 作为宿主内置组件 -- [X] T036 [P] [US2] 在 `src/Hosts/Modulus.Host.Blazor/` 中集成 Shell 模块的管理 UI(Modules 页面) -- [X] T037 [P] [US2] 在 `src/Hosts/Modulus.Host.Avalonia/` 中集成 Shell 模块的管理 UI(ModuleListView) -- [X] T038 [US2] 在 `tests/Modulus.Hosts.Tests/` 中添加端到端测试:对示例模块连续执行多次 enable/disable/reload,验证不崩溃且状态正确恢复 - -**Checkpoint**: 模块管理 UI 与运行时 API 可协同完成模块启用 / 禁用 / 重新加载,并通过自动化测试验证稳定性。 - ---- - -## Phase 5: User Story 3 - 基于 SDK 的 AI 辅助插件开发 (Priority: P2) - -**Goal**: 提供强类型 SDK 基类与最小示例,使 AI 可以基于这些基类生成可编译、可打包并可运行的插件。 - -**Independent Test**: 使用 SDK 基类为一个简单工具型插件生成代码(可由 AI 生成),在不调整整体结构的前提下完成编译、打包与加载。 - -### Implementation for User Story 3 - -- [X] T039 [US3] 在 `src/Modulus.Sdk/` 中完善 `ModuleBase`, `ToolPluginBase`, `DocumentPluginBase` 等基类的公共 API(生命周期、注册点、错误处理模式) -- [X] T040 [US3] 在 `src/Modulus.Sdk/` 中添加用于生成 manifest 与 `.modpkg` 结构的辅助类型(例如 `PluginPackageBuilder`) -- [X] T041 [US3] 在 `src/Modules/Modulus.Modules.Samples/` 下添加一个基于 SDK 的示例插件实现(例如 Echo 工具),严格遵循 SDK 模式 - (实际位置:`src/Modules/EchoPlugin`) -- [X] T042 [P] [US3] 在 `tests/Modulus.Sdk.Tests/` 中添加契约测试:验证基于 SDK 的示例插件能够完成初始化与注册流程 - (实际位置:`tests/Modulus.Modules.Tests/EchoPluginTests.cs`) -- [X] T043 [US3] 在 `specs/001-core-architecture/contracts/runtime-contracts.md` 中补充/更新与 SDK 相关的公共接口说明,使其与代码保持一致 -- [X] T044 [US3] 更新 `specs/001-core-architecture/quickstart.md`,加入“使用 SDK 创建第一个插件”的简要步骤 - -**Checkpoint**: 存在至少一个通过 SDK 开发的示例插件,可以作为 AI 生成插件的参考模板,并通过测试验证。 - ---- - -## Phase N: Polish & Cross-Cutting Concerns - -**Purpose**: 面向整体架构与开发体验的收尾与跨模块优化。 - -- [X] T045 [P] 完成对 `specs/001-core-architecture/` 下所有文档的最终对齐(spec, plan, data-model, quickstart, contracts) -- [X] T046 [P] 在 `CONTRIBUTING.md` 与 `CONTRIBUTING.zh-CN.md` 中补充 Modulus 宪章与模块 / 宿主架构的简要说明 -- [X] T047 [P] 在 `README.md` 与 `README.zh-CN.md` 中加入对多宿主与插件化架构的简要介绍与链接 -- [X] T049 整体代码清理与重构(命名统一、命名空间与分层依赖检查、移除临时代码) -- [X] T050 运行完整测试集(`dotnet test`),修复发现的问题并记录后续 Story(如需要拆分 v2 功能) - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: 无前置依赖,优先完成,用于搭建项目骨架。 -- **Foundational (Phase 2)**: 依赖 Phase 1,完成后才具备实现任意 User Story 的基础。 -- **User Stories (Phase 3–5)**: 都依赖 Foundational 阶段完成;US1/US2/US3 可部分并行,但建议按优先级顺序交付: - - US1(垂直切片示例)→ US2(运行时模块管理)→ US3(SDK 与 AI 插件)。 -- **Polish (Final Phase)**: 在计划交付的 User Story 全部完成后执行。 - -### User Story Dependencies - -- **User Story 1 (P1)**: 仅依赖 Foundational,提供可运行的端到端示例模块,是整体架构的 MVP 验证。 -- **User Story 2 (P1)**: 依赖 US1 提供的示例模块(用于模块管理验证),也依赖 Foundational 的运行时 API。 -- **User Story 3 (P2)**: 依赖 Foundational 的 SDK 骨架与 US1/US2 的基础体验,用于强化 AI 与 SDK 的协同。 - -### Within Each User Story - -- US1: 先实现核心 Domain / Application,再实现 UI 适配与 manifest,最后完成两种宿主的集成与端到端测试。 -- US2: 先扩展运行时 API,再实现 Shell 模块的管理 UI,最后在两个宿主中集成并通过自动化测试验证。 -- US3: 先稳定 SDK 基类,再添加示例插件与测试,最后更新文档与 Quickstart。 - -### Parallel Opportunities - -- Setup 阶段中不同项目的创建任务可以并行(T002–T012)。 -- Foundational 阶段中 MediatR 集成、UI 抽象定义与 SDK 骨架实现可以在不互相阻塞的前提下并行。 -- US1 中 Blazor 与 Avalonia UI 适配实现(T026, T027)可以在核心逻辑稳定后并行推进。 -- US2 中 Blazor / Avalonia 的 Shell 集成(T036, T037)可以并行。 -- Polish 阶段中的文档与 AI 上下文更新任务(T045–T048)可以并行执行。 - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. 完成 Phase 1: Setup(项目与目录结构搭建)。 -2. 完成 Phase 2: Foundational(核心运行时 / UI 抽象 / SDK 骨架)。 -3. 完成 Phase 3: User Story 1(示例模块 + 双宿主集成)。 -4. 在两种宿主下验证示例模块端到端运行,满足 SC-001。 -5. 视需要发布首个开发者预览版本。 - -### Incremental Delivery - -1. 在 MVP 完成后,引入 User Story 2(模块启用 / 禁用 / 重新加载)并验证稳定性。 -2. 在核心稳定后引入 User Story 3(SDK 与 AI 插件),为后续插件生态奠定基础。 -3. 每完成一个 Story,即可单独进行演示与反馈收集。 - -### Parallel Team Strategy - -在多人协作场景下: - -1. 团队共同完成 Setup 与 Foundational 阶段。 -2. Foundational 完成后: - - 开发者 A 负责 US1(示例模块与双宿主集成); - - 开发者 B 负责 US2(运行时模块管理与 Shell 集成); - - 开发者 C 负责 US3(SDK 与示例插件 + 文档)。 -3. 通过统一的测试与文档收敛,确保三个 Story 在合并后仍然符合宪章与架构约束。 - - diff --git a/src/Hosts/Modulus.Host.Avalonia/App.axaml.cs b/src/Hosts/Modulus.Host.Avalonia/App.axaml.cs index aa94403..db9c503 100644 --- a/src/Hosts/Modulus.Host.Avalonia/App.axaml.cs +++ b/src/Hosts/Modulus.Host.Avalonia/App.axaml.cs @@ -4,14 +4,17 @@ using Avalonia.Styling; using Modulus.Core; using Modulus.Core.Data; +using Modulus.Core.Installation; using Modulus.Core.Runtime; using Modulus.Host.Avalonia.Services; using Modulus.Host.Avalonia.Shell.Services; using Modulus.Host.Avalonia.Shell.ViewModels; using Modulus.Host.Avalonia.Shell.Views; +using Modulus.Infrastructure.Data.Repositories; using Modulus.Sdk; using Modulus.UI.Abstractions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System; @@ -20,7 +23,7 @@ namespace Modulus.Host.Avalonia; -public class AvaloniaHostModule : ModuleBase +public class AvaloniaHostModule : ModulusComponent { public override void ConfigureServices(IModuleLifecycleContext context) { @@ -39,6 +42,16 @@ public override void ConfigureServices(IModuleLifecycleContext context) context.Services.AddTransient(); context.Services.AddTransient(); } + + public override Task OnApplicationInitializationAsync(IModuleInitializationContext context, CancellationToken cancellationToken = default) + { + // Register view mappings (menus come from database - full database-driven approach) + var viewRegistry = context.ServiceProvider.GetRequiredService(); + viewRegistry.Register(); + viewRegistry.Register(); + + return Task.CompletedTask; + } } public partial class App : Application @@ -63,42 +76,60 @@ public override void OnFrameworkInitializationCompleted() // Add Logging services.AddLogging(); - // Module Providers + // Module Providers - load from Modules/ directory var providers = new System.Collections.Generic.List(); - #if DEBUG +#if DEBUG + // Development: Load from artifacts/ (populated by nuke build-module) var solutionRoot = FindSolutionRoot(AppContext.BaseDirectory); if (solutionRoot != null) { - providers.Add(new DevelopmentModuleScanningProvider(solutionRoot, HostType.Avalonia, NullLogger.Instance)); + var artifactsModules = Path.Combine(solutionRoot, "artifacts", "Modulus.Host.Avalonia", "Modules"); + if (Directory.Exists(artifactsModules)) + { + // User modules from artifacts - NOT system modules + providers.Add(new DirectoryModuleProvider(artifactsModules, NullLogger.Instance, isSystem: false)); + } } - - var outputModules = Path.Combine(solutionRoot ?? AppContext.BaseDirectory, "_output", "modules"); - if (Directory.Exists(outputModules)) - { - providers.Add(new DirectoryModuleProvider(outputModules, NullLogger.Instance, isSystem: true)); - } - #endif - +#else + // Production: Load from {AppBaseDir}/Modules/ var appModules = Path.Combine(AppContext.BaseDirectory, "Modules"); if (Directory.Exists(appModules)) { providers.Add(new DirectoryModuleProvider(appModules, NullLogger.Instance, isSystem: true)); } +#endif + // User-installed modules (for runtime installation) var userModules = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Modulus", "Modules"); if (Directory.Exists(userModules)) { providers.Add(new DirectoryModuleProvider(userModules, NullLogger.Instance, isSystem: false)); } - // Database - var dbPath = DatabaseServiceExtensions.GetDefaultDatabasePath(); + // Configuration + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + + // Database (configurable name; defaults to framework/solution name) + var dbName = configuration["Modulus:DatabaseName"] ?? "Modulus"; + var dbPath = DatabaseServiceExtensions.GetDefaultDatabasePath(dbName); services.AddModulusDatabase(dbPath); + // Repositories & installers (needed at runtime for menu registration) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Bootstrap Modulus var appTask = Task.Run(async () => - await ModulusApplicationFactory.CreateAsync(services, providers, HostType.Avalonia) + await ModulusApplicationFactory.CreateAsync(services, providers, HostType.Avalonia, dbPath) ); _modulusApp = appTask.GetAwaiter().GetResult(); @@ -115,21 +146,27 @@ await ModulusApplicationFactory.CreateAsync(services, provid var database = Services.GetRequiredService(); database.InitializeAsync().GetAwaiter().GetResult(); + // Seed Host module and menus to database (full database-driven approach) + using (var scope = Services.CreateScope()) + { + var hostSeeder = scope.ServiceProvider.GetRequiredService(); + hostSeeder.SeedAsync( + HostType.Avalonia, + typeof(ModuleListViewModel).FullName!, + typeof(SettingsViewModel).FullName! + ).GetAwaiter().GetResult(); + } + // Initialize Theme Service (load saved theme) var themeService = Services.GetRequiredService() as AvaloniaThemeService; themeService?.InitializeAsync().GetAwaiter().GetResult(); - // Register Shell Views + // Register Shell Views (view mappings, menus come from database) var viewRegistry = Services.GetRequiredService(); viewRegistry.Register(); viewRegistry.Register(); - // Register Shell Menu Items - var menuRegistry = Services.GetRequiredService(); - menuRegistry.Register(new MenuItem("Modules", "Modules", IconKind.AppsAddIn, typeof(ModuleListViewModel).FullName!, MenuLocation.Main, 0)); - menuRegistry.Register(new MenuItem("Settings", "Settings", IconKind.Settings, typeof(SettingsViewModel).FullName!, MenuLocation.Bottom, 100)); - - // Initialize Modules + // Initialize Modules (loads menus from database into IMenuRegistry) _modulusApp.InitializeAsync().GetAwaiter().GetResult(); // Create and set ShellViewModel @@ -153,18 +190,20 @@ public void ToggleTheme() var current = RequestedThemeVariant; RequestedThemeVariant = current == ThemeVariant.Dark ? ThemeVariant.Light : ThemeVariant.Dark; } - + +#if DEBUG private static string? FindSolutionRoot(string startPath) { - var current = new DirectoryInfo(startPath); - while (current != null) + var dir = new DirectoryInfo(startPath); + while (dir != null) { - if (File.Exists(Path.Combine(current.FullName, "Modulus.sln"))) + if (File.Exists(Path.Combine(dir.FullName, "Modulus.sln"))) { - return current.FullName; + return dir.FullName; } - current = current.Parent; + dir = dir.Parent; } return null; } +#endif } diff --git a/src/Hosts/Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj b/src/Hosts/Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj index 3125475..eb6315f 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj +++ b/src/Hosts/Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj @@ -32,10 +32,18 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + @@ -48,4 +56,8 @@ + + + + diff --git a/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaUIFactory.cs b/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaUIFactory.cs index 63f3a15..48e72ae 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaUIFactory.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaUIFactory.cs @@ -23,23 +23,38 @@ public AvaloniaUIFactory(RuntimeContext runtimeContext, IServiceProvider service public object CreateView(object viewModel) { var vmType = viewModel.GetType(); - // Console.WriteLine($"CreateView for {vmType.Name}"); // 1. Try Registry var registeredViewType = _viewRegistry.GetViewType(vmType); if (registeredViewType != null) { - // Console.WriteLine($"Found registered view {registeredViewType.Name}"); return CreateViewInstance(registeredViewType, viewModel); } - else + + // 2. Try RuntimeModuleHandle assemblies (more reliable) + var viewName = vmType.Name.Replace("ViewModel", "View"); + foreach (var module in _runtimeContext.RuntimeModules) { - // Console.WriteLine("No registered view found."); + if (module.State != ModuleState.Active && module.State != ModuleState.Loaded) continue; + + if (_runtimeContext.TryGetModuleHandle(module.Descriptor.Id, out var handle) && handle != null) + { + foreach (var asm in handle.Assemblies) + { + Type? type = null; + try { type = asm.GetTypes().FirstOrDefault(t => t.Name == viewName); } + catch { continue; } + + if (type != null && typeof(Control).IsAssignableFrom(type)) + { + _viewRegistry.Register(vmType, type); + return CreateViewInstance(type, viewModel); + } + } + } } - // 2. Try Scan (Fallback) - var viewName = vmType.Name.Replace("ViewModel", "View"); - // Console.WriteLine($"Scanning for {viewName}..."); + // 3. Try LoadContext.Assemblies (Fallback) foreach (var module in _runtimeContext.RuntimeModules) { diff --git a/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs b/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs index 8d839be..2c4ebba 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs @@ -9,19 +9,22 @@ public class MenuRegistry : IMenuRegistry { private readonly ConcurrentDictionary _items = new(StringComparer.OrdinalIgnoreCase); - public event EventHandler? MenuChanged; - public void Register(MenuItem item) { _items[item.Id] = item; - MenuChanged?.Invoke(this, EventArgs.Empty); } public void Unregister(string id) { - if (_items.TryRemove(id, out _)) + _items.TryRemove(id, out _); + } + + public void UnregisterModuleItems(string moduleId) + { + var itemsToRemove = _items.Values.Where(i => i.ModuleId == moduleId).Select(i => i.Id).ToList(); + foreach (var id in itemsToRemove) { - MenuChanged?.Invoke(this, EventArgs.Empty); + _items.TryRemove(id, out _); } } diff --git a/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ModuleListViewModel.cs b/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ModuleListViewModel.cs index 9c23c70..df42b7b 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ModuleListViewModel.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ModuleListViewModel.cs @@ -1,12 +1,23 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Modulus.Core.Installation; +using Modulus.Core.Manifest; using Modulus.Core.Runtime; +using Modulus.Infrastructure.Data.Models; +using Modulus.Infrastructure.Data.Repositories; using Modulus.UI.Abstractions; +using Modulus.UI.Abstractions.Messages; + +// Alias to avoid ambiguity +using DataModuleState = Modulus.Infrastructure.Data.Models.ModuleState; +using RuntimeModuleState = Modulus.Core.Runtime.ModuleState; namespace Modulus.Host.Avalonia.Shell.ViewModels; @@ -14,81 +25,261 @@ public partial class ModuleListViewModel : ViewModelBase { private readonly RuntimeContext _runtimeContext; private readonly IModuleLoader _moduleLoader; - private readonly IEnumerable _moduleProviders; + private readonly IModuleRepository _moduleRepository; + private readonly IMenuRepository _menuRepository; + private readonly IMenuRegistry _menuRegistry; + private readonly IModuleInstallerService _moduleInstaller; private readonly INotificationService? _notificationService; public ObservableCollection Modules { get; } = new(); + [ObservableProperty] + private string _importPath = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredModules))] + [NotifyPropertyChangedFor(nameof(EnabledModules))] + [NotifyPropertyChangedFor(nameof(DisabledModules))] + private string _searchText = string.Empty; + + [ObservableProperty] + private ModuleViewModel? _selectedModule; + + [ObservableProperty] + private string _selectedModuleDetails = string.Empty; + + public List FilteredModules => + (string.IsNullOrWhiteSpace(SearchText) + ? Modules + : Modules.Where(m => m.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + public List EnabledModules => FilteredModules.Where(m => m.IsEnabled).ToList(); + public List DisabledModules => FilteredModules.Where(m => !m.IsEnabled).ToList(); + public ModuleListViewModel( RuntimeContext runtimeContext, IModuleLoader moduleLoader, - IEnumerable moduleProviders, + IModuleRepository moduleRepository, + IMenuRepository menuRepository, + IMenuRegistry menuRegistry, + IModuleInstallerService moduleInstaller, INotificationService? notificationService = null) { _runtimeContext = runtimeContext; _moduleLoader = moduleLoader; - _moduleProviders = moduleProviders; + _moduleRepository = moduleRepository; + _menuRepository = menuRepository; + _menuRegistry = menuRegistry; + _moduleInstaller = moduleInstaller; _notificationService = notificationService; Title = "Module Management"; _ = RefreshModulesAsync(); } - [RelayCommand] - private async Task RefreshModulesAsync() + partial void OnSelectedModuleChanged(ModuleViewModel? value) { - Modules.Clear(); - - var loadedModules = _runtimeContext.RuntimeModules.ToDictionary(m => m.Descriptor.Id); + if (value != null) + { + _ = LoadModuleDetailsAsync(value); + } + else + { + SelectedModuleDetails = string.Empty; + } + } - foreach (var provider in _moduleProviders) + private async Task LoadModuleDetailsAsync(ModuleViewModel module) + { + SelectedModuleDetails = "Loading..."; + + try { - var paths = await provider.GetModulePackagesAsync(); - foreach (var path in paths) + var manifestPath = Path.GetFullPath(module.Entity.Path); + var dir = Path.GetDirectoryName(manifestPath); + + // 1. Try README.md + if (dir != null) { - var descriptor = await _moduleLoader.GetDescriptorAsync(path); - if (descriptor == null) continue; - - if (loadedModules.TryGetValue(descriptor.Id, out var loadedModule)) + var readmePath = Path.Combine(dir, "README.md"); + if (File.Exists(readmePath)) { - Modules.Add(new ModuleViewModel(loadedModule)); - loadedModules.Remove(descriptor.Id); + SelectedModuleDetails = await File.ReadAllTextAsync(readmePath); + return; } - else + } + + // 2. Fallback to Manifest Description + if (File.Exists(manifestPath)) + { + var manifest = await ManifestReader.ReadFromFileAsync(manifestPath); + if (!string.IsNullOrWhiteSpace(manifest?.Description)) { - Modules.Add(new ModuleViewModel(descriptor, path, ModuleState.Unloaded)); + SelectedModuleDetails = manifest.Description; + return; } } } + catch { /* Ignore file access errors */ } + + SelectedModuleDetails = "No description provided."; + } - foreach (var remaining in loadedModules.Values) + [RelayCommand] + private async Task RefreshModulesAsync() + { + Modules.Clear(); + + var dbModules = await _moduleRepository.GetAllAsync(); + + foreach (var dbModule in dbModules) + { + // Skip built-in host modules - they shouldn't appear in the installed modules list + if (dbModule.IsSystem && dbModule.Path == "built-in") + { + continue; + } + + _runtimeContext.TryGetModule(dbModule.Id, out var runtimeModule); + Modules.Add(new ModuleViewModel(dbModule, runtimeModule)); + } + + OnPropertyChanged(nameof(FilteredModules)); + OnPropertyChanged(nameof(EnabledModules)); + OnPropertyChanged(nameof(DisabledModules)); + + if (SelectedModule == null && Modules.Any()) + { + SelectedModule = Modules.First(); + } + else if (SelectedModule != null) { - Modules.Add(new ModuleViewModel(remaining)); + // Reload details for currently selected module + _ = LoadModuleDetailsAsync(SelectedModule); } } [RelayCommand] private async Task ToggleModuleAsync(ModuleViewModel moduleVm) { - if (moduleVm == null) return; + if (moduleVm == null || moduleVm.IsSystem) return; - try + if (moduleVm.IsEnabled) + { + // Disable: Unload if loaded, then mark as disabled + if (moduleVm.IsLoaded) + { + await _moduleLoader.UnloadAsync(moduleVm.Id); + } + + await _moduleRepository.UpdateStateAsync(moduleVm.Id, DataModuleState.Disabled); + + // Unregister menus from MenuRegistry + _menuRegistry.UnregisterModuleItems(moduleVm.Id); + + // Notify ShellViewModel to remove menus (incremental) + WeakReferenceMessenger.Default.Send(new MenuItemsRemovedMessage(moduleVm.Id)); + } + else + { + // Enable + if (moduleVm.Entity.State == DataModuleState.MissingFiles) + { + _notificationService?.ShowErrorAsync("Error", "Cannot enable module with missing files."); + return; + } + + await _moduleRepository.UpdateStateAsync(moduleVm.Id, DataModuleState.Ready); + + // Resolve absolute path + var manifestPath = Path.GetFullPath(moduleVm.Entity.Path); + var packagePath = Path.GetDirectoryName(manifestPath); + + if (packagePath != null) + { + await _moduleLoader.LoadAsync(packagePath, moduleVm.IsSystem); + } + + // Register menus from database and notify ShellViewModel (incremental) + var addedMenus = await RegisterModuleMenusAsync(moduleVm.Id); + if (addedMenus.Count > 0) + { + WeakReferenceMessenger.Default.Send(new MenuItemsAddedMessage(addedMenus)); + } + } + + await RefreshModulesAsync(); + + OnPropertyChanged(nameof(EnabledModules)); + OnPropertyChanged(nameof(DisabledModules)); + } + + private async Task> RegisterModuleMenusAsync(string moduleId) + { + var menus = await _menuRepository.GetByModuleIdAsync(moduleId); + var addedItems = new List(); + + foreach (var menu in menus) { - if (moduleVm.State == ModuleState.Active || moduleVm.State == ModuleState.Loaded) - { - await _moduleLoader.UnloadAsync(moduleVm.Id); - } - else if (moduleVm.State == ModuleState.Unloaded) - { - if (string.IsNullOrEmpty(moduleVm.PackagePath)) - { - _notificationService?.ShowErrorAsync("Error", "Cannot load module: package path unknown."); - return; - } - await _moduleLoader.LoadAsync(moduleVm.PackagePath); - } - - await RefreshModulesAsync(); + var iconKind = IconKind.Grid; + if (Enum.TryParse(menu.Icon, true, out var parsedIcon)) + { + iconKind = parsedIcon; + } + + // Use NavigationKey for Avalonia (ViewModelType fullname) + var navigationKey = menu.Route ?? menu.Id; + + var item = new MenuItem( + menu.Id, + menu.DisplayName, + iconKind, + navigationKey, + menu.Location, + menu.Order + ); + item.ModuleId = menu.ModuleId; + + _menuRegistry.Register(item); + addedItems.Add(item); + } + + return addedItems; + } + + [RelayCommand] + private async Task RemoveModuleAsync(ModuleViewModel moduleVm) + { + if (moduleVm == null || moduleVm.IsSystem) return; + + try + { + if (moduleVm.IsLoaded) + { + await _moduleLoader.UnloadAsync(moduleVm.Id); + } + + await _moduleRepository.DeleteAsync(moduleVm.Id); + // Optionally clean files? For now, we just remove from DB as per task "Remove (Delete DB record + Clean folder)" + // Clean folder logic: + try + { + var manifestPath = Path.GetFullPath(moduleVm.Entity.Path); + var dir = Path.GetDirectoryName(manifestPath); + if (dir != null && Directory.Exists(dir)) + { + // Basic safety: don't delete root or system folders + // TODO: Improve safety + Directory.Delete(dir, true); + } + } + catch (Exception ex) + { + _notificationService?.ShowErrorAsync("Warning", $"Module removed from DB but failed to delete files: {ex.Message}"); + } + + await RefreshModulesAsync(); } catch (Exception ex) { @@ -97,64 +288,97 @@ private async Task ToggleModuleAsync(ModuleViewModel moduleVm) } [RelayCommand] - private async Task ReloadModuleAsync(ModuleViewModel moduleVm) + private async Task ImportModuleAsync() { - if (moduleVm == null) return; - - try - { - await _moduleLoader.ReloadAsync(moduleVm.Id); - await RefreshModulesAsync(); - } - catch (Exception ex) - { - _notificationService?.ShowErrorAsync("Error", ex.Message); - } + if (string.IsNullOrWhiteSpace(ImportPath)) return; + + // ImportPath could be a directory or manifest.json + var path = ImportPath; + if (File.Exists(path) && Path.GetFileName(path) == "manifest.json") + { + // ok + } + else if (Directory.Exists(path)) + { + path = Path.Combine(path, "manifest.json"); + } + else + { + _notificationService?.ShowErrorAsync("Error", "Invalid path."); + return; + } + + try + { + await _moduleInstaller.RegisterDevelopmentModuleAsync(path); + ImportPath = string.Empty; + await RefreshModulesAsync(); + _notificationService?.ShowErrorAsync("Success", "Module imported."); // Using ShowError as ShowInfo might not exist + } + catch (Exception ex) + { + _notificationService?.ShowErrorAsync("Error", ex.Message); + } } } public partial class ModuleViewModel : ObservableObject { - private readonly RuntimeModule? _runtimeModule; - private readonly ModuleDescriptor _descriptor; - private readonly string _packagePath; - - [ObservableProperty] - private ModuleState _state; - - public string Id => _descriptor.Id; - public string DisplayName => _descriptor.DisplayName; - public string Description => _descriptor.Description; - public string Version => _descriptor.Version; - public string PackagePath => _packagePath; - public bool IsSystem => _runtimeModule?.IsSystem ?? false; - - public string StatusColor => State switch - { - ModuleState.Active => "#4CAF50", - ModuleState.Loaded => "#2196F3", - ModuleState.Error => "#F44336", - _ => "#9E9E9E" - }; + public ModuleEntity Entity { get; } + public RuntimeModule? RuntimeModule { get; } - public ModuleViewModel(RuntimeModule module) + public ModuleViewModel(ModuleEntity entity, RuntimeModule? runtimeModule) { - _runtimeModule = module; - _descriptor = module.Descriptor; - _packagePath = module.PackagePath; - State = module.State; + Entity = entity; + RuntimeModule = runtimeModule; } - public ModuleViewModel(ModuleDescriptor descriptor, string packagePath, ModuleState state) + public string Id => Entity.Id; + public string Name => Entity.Name; + public string Version => Entity.Version; + public string Description => Entity.Description ?? "No description"; + public string Author => Entity.Author ?? "AGIBuild"; + public bool IsSystem => Entity.IsSystem; + public string MenuLocation => Entity.MenuLocation.ToString(); + + /// + /// Whether the module is enabled (based on database state, not runtime). + /// + public bool IsEnabled => Entity.IsEnabled && Entity.State != DataModuleState.Disabled; + + /// + /// Whether the module is actually loaded and running in the runtime. + /// + public bool IsLoaded => RuntimeModule?.State == RuntimeModuleState.Active; + + // Status Logic + public string StatusText { - _runtimeModule = null; - _descriptor = descriptor; - _packagePath = packagePath; - State = state; + get + { + if (Entity.State == DataModuleState.MissingFiles) return "Missing Files"; + if (Entity.State == DataModuleState.Disabled || !Entity.IsEnabled) return "Disabled"; + if (IsLoaded) return "Running"; + return "Ready"; // Enabled but not yet loaded + } } - public bool IsLoaded => State == ModuleState.Active || State == ModuleState.Loaded || State == ModuleState.Error; - public bool IsUnloaded => State == ModuleState.Unloaded; - public bool CanUnload => IsLoaded && !IsSystem; -} + public string StatusColor => StatusText switch + { + "Running" => "#4CAF50", // Green + "Ready" => "#2196F3", // Blue + "Disabled" => "#9E9E9E", // Grey + "Missing Files" => "#FFC107", // Amber/Yellow + _ => "#9E9E9E" + }; + /// + /// Whether the toggle button should be shown (non-system modules can be toggled). + /// + public bool ShowToggle => !IsSystem; + + /// + /// Whether this module can be removed (only non-system modules). + /// + public bool CanRemove => !IsSystem; +} diff --git a/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs b/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs index 449f740..1c8c2c0 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs @@ -1,16 +1,20 @@ +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Microsoft.Extensions.DependencyInjection; +using CommunityToolkit.Mvvm.Messaging; using Modulus.Host.Avalonia.Services; using Modulus.UI.Abstractions; -using System; +using Modulus.UI.Abstractions.Messages; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; namespace Modulus.Host.Avalonia.Shell.ViewModels; -public partial class ShellViewModel : ViewModelBase +public partial class ShellViewModel : ViewModelBase, + IRecipient, + IRecipient, + IRecipient { private readonly IMenuRegistry _menuRegistry; private readonly INavigationService _navigationService; @@ -57,16 +61,62 @@ public ShellViewModel( _avaloniaNavService.OnViewChanged = OnNavigationViewChanged; } - _menuRegistry.MenuChanged += (s, e) => - { - // Ensure UI update happens on UI thread if needed, though ObservableCollection handles some sync - // For safety in Avalonia, we might need Dispatcher.UIThread.InvokeAsync, but ViewModel is usually bound - // Let's assume Mvvm toolkit handles basic binding updates or we are on UI thread from Loader - RefreshMenu(); - }; + // Subscribe to menu messages + WeakReferenceMessenger.Default.RegisterAll(this); RefreshMenu(); } + + /// + /// Handle full menu refresh message - reload all menus from registry. + /// + public void Receive(MenuRefreshMessage message) + { + Dispatcher.UIThread.Post(RefreshMenu); + } + + /// + /// Handle incremental menu addition - add items without losing selection. + /// + public void Receive(MenuItemsAddedMessage message) + { + Dispatcher.UIThread.Post(() => + { + foreach (var item in message.Items) + { + var collection = item.Location == MenuLocation.Main ? MainMenuItems : BottomMenuItems; + + // Avoid duplicates + if (collection.All(m => m.Id != item.Id)) + { + // Insert in order + var index = collection.Count(m => m.Order < item.Order); + collection.Insert(index, item); + } + } + }); + } + + /// + /// Handle incremental menu removal - remove items without losing selection. + /// + public void Receive(MenuItemsRemovedMessage message) + { + Dispatcher.UIThread.Post(() => + { + var mainToRemove = MainMenuItems.Where(m => m.ModuleId == message.ModuleId).ToList(); + foreach (var item in mainToRemove) + { + MainMenuItems.Remove(item); + } + + var bottomToRemove = BottomMenuItems.Where(m => m.ModuleId == message.ModuleId).ToList(); + foreach (var item in bottomToRemove) + { + BottomMenuItems.Remove(item); + } + }); + } partial void OnIsNavCollapsedChanged(bool value) { diff --git a/src/Hosts/Modulus.Host.Avalonia/Shell/Views/ModuleListView.axaml b/src/Hosts/Modulus.Host.Avalonia/Shell/Views/ModuleListView.axaml index af62afd..1cf2a52 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Shell/Views/ModuleListView.axaml +++ b/src/Hosts/Modulus.Host.Avalonia/Shell/Views/ModuleListView.axaml @@ -3,104 +3,205 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:Modulus.Host.Avalonia.Shell.ViewModels" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + xmlns:md="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia" + mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600" x:Class="Modulus.Host.Avalonia.Shell.Views.ModuleListView" x:DataType="vm:ModuleListViewModel"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +[DependsOn()] // no explicit deps [Module("ComponentsDemo", "Components Demo", Description = "Demonstrates navigation and UI components for both Avalonia and Blazor hosts.")] -public class ComponentsDemoModule : ModuleBase +public class ComponentsDemoModule : ModulusComponent { public override void ConfigureServices(IModuleLifecycleContext context) { diff --git a/src/Modules/ComponentsDemo/ComponentsDemo.UI.Avalonia/ComponentsDemoAvaloniaModule.cs b/src/Modules/ComponentsDemo/ComponentsDemo.UI.Avalonia/ComponentsDemoAvaloniaModule.cs index 089dd4e..a716845 100644 --- a/src/Modules/ComponentsDemo/ComponentsDemo.UI.Avalonia/ComponentsDemoAvaloniaModule.cs +++ b/src/Modules/ComponentsDemo/ComponentsDemo.UI.Avalonia/ComponentsDemoAvaloniaModule.cs @@ -9,6 +9,7 @@ namespace Modulus.Modules.ComponentsDemo.UI.Avalonia; /// /// Components Demo Avalonia UI - declares Avalonia-specific navigation. /// +[DependsOn(typeof(ComponentsDemo.ComponentsDemoModule))] [AvaloniaMenu("Components", typeof(ComponentsMainViewModel), Icon = IconKind.Grid, Order = 15)] public class ComponentsDemoAvaloniaModule : AvaloniaModuleBase { diff --git a/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/ComponentsDemoBlazorModule.cs b/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/ComponentsDemoBlazorModule.cs index 38cbb43..1b8a67c 100644 --- a/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/ComponentsDemoBlazorModule.cs +++ b/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/ComponentsDemoBlazorModule.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Modulus.Modules.ComponentsDemo.ViewModels; +using Modulus.Modules.ComponentsDemo; using Modulus.Sdk; using Modulus.UI.Abstractions; @@ -8,8 +9,9 @@ namespace Modulus.Modules.ComponentsDemo.UI.Blazor; /// /// Components Demo Blazor UI - declares Blazor-specific navigation. /// -[BlazorMenu("Components", "/components", Icon = IconKind.add, Order = 15)] -public class ComponentsDemoBlazorModule : ModuleBase +[DependsOn(typeof(ComponentsDemoModule))] +[BlazorMenu("Components", "/components", Icon = IconKind.Add, Order = 15)] +public class ComponentsDemoBlazorModule : ModulusComponent { public override Task OnApplicationInitializationAsync(IModuleInitializationContext context, CancellationToken cancellationToken = default) { diff --git a/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/BadgeNavDemo.razor b/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/BadgeNavDemo.razor index 1f4f598..1086465 100644 --- a/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/BadgeNavDemo.razor +++ b/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/BadgeNavDemo.razor @@ -39,7 +39,7 @@ Usage in NavLink: - +
@_codeExample
@code { diff --git a/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/BasicNavDemo.razor b/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/BasicNavDemo.razor index 5d5539e..5dabbd0 100644 --- a/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/BasicNavDemo.razor +++ b/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/BasicNavDemo.razor @@ -25,7 +25,7 @@ Example Code: - +
@_codeExample
@code { diff --git a/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/DisabledNavDemo.razor b/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/DisabledNavDemo.razor index bde05f5..27b27ec 100644 --- a/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/DisabledNavDemo.razor +++ b/src/Modules/ComponentsDemo/ComponentsDemo.UI.Blazor/Pages/DisabledNavDemo.razor @@ -26,7 +26,7 @@ Usage: - +
@_codeExample
@code { diff --git a/src/Modules/EchoPlugin/EchoPlugin.Core/EchoPluginModule.cs b/src/Modules/EchoPlugin/EchoPlugin.Core/EchoPluginModule.cs index 1c62cd2..32d9a17 100644 --- a/src/Modules/EchoPlugin/EchoPlugin.Core/EchoPluginModule.cs +++ b/src/Modules/EchoPlugin/EchoPlugin.Core/EchoPluginModule.cs @@ -7,9 +7,9 @@ namespace Modulus.Modules.EchoPlugin; /// Echo Plugin Core - business logic only. /// UI-specific menu declarations are in UI.Avalonia and UI.Blazor modules. /// -[Module("EchoPlugin", "Echo Tool", - Description = "A simple echo plugin to demonstrate the SDK.")] -public class EchoPluginModule : ModuleBase +[DependsOn()] // no deps +[Module("EchoPlugin", "Echo Tool", Description = "A simple echo plugin to demonstrate the SDK.")] +public class EchoPluginModule : ModulusComponent { public override void ConfigureServices(IModuleLifecycleContext context) { diff --git a/src/Modules/EchoPlugin/EchoPlugin.UI.Avalonia/EchoPluginAvaloniaModule.cs b/src/Modules/EchoPlugin/EchoPlugin.UI.Avalonia/EchoPluginAvaloniaModule.cs index 0c17b99..ce6524b 100644 --- a/src/Modules/EchoPlugin/EchoPlugin.UI.Avalonia/EchoPluginAvaloniaModule.cs +++ b/src/Modules/EchoPlugin/EchoPlugin.UI.Avalonia/EchoPluginAvaloniaModule.cs @@ -9,6 +9,7 @@ namespace Modulus.Modules.EchoPlugin.UI.Avalonia; /// /// Echo Plugin Avalonia UI - declares Avalonia-specific navigation. /// +[DependsOn(typeof(Modulus.Modules.EchoPlugin.EchoPluginModule))] [AvaloniaMenu("Echo Tool", typeof(EchoViewModel), Icon = IconKind.Terminal, Order = 20)] public class EchoPluginAvaloniaModule : AvaloniaModuleBase { diff --git a/src/Modules/EchoPlugin/EchoPlugin.UI.Blazor/EchoPluginBlazorModule.cs b/src/Modules/EchoPlugin/EchoPlugin.UI.Blazor/EchoPluginBlazorModule.cs index c55cab3..f7f97bb 100644 --- a/src/Modules/EchoPlugin/EchoPlugin.UI.Blazor/EchoPluginBlazorModule.cs +++ b/src/Modules/EchoPlugin/EchoPlugin.UI.Blazor/EchoPluginBlazorModule.cs @@ -9,7 +9,7 @@ namespace Modulus.Modules.EchoPlugin.UI.Blazor; /// Echo Plugin Blazor UI - declares Blazor-specific navigation. /// [BlazorMenu("Echo Tool", "/echo", Icon = IconKind.Terminal, Order = 20)] -public class EchoPluginBlazorModule : ModuleBase +public class EchoPluginBlazorModule : ModulusComponent { public override Task OnApplicationInitializationAsync(IModuleInitializationContext context, CancellationToken cancellationToken = default) { diff --git a/src/Modules/SimpleNotes/SimpleNotes.Core/SimpleNotesModule.cs b/src/Modules/SimpleNotes/SimpleNotes.Core/SimpleNotesModule.cs index 7c63ec6..afcd751 100644 --- a/src/Modules/SimpleNotes/SimpleNotes.Core/SimpleNotesModule.cs +++ b/src/Modules/SimpleNotes/SimpleNotes.Core/SimpleNotesModule.cs @@ -9,9 +9,10 @@ namespace SimpleNotes.Core; /// Simple Notes Core - business logic only. /// UI-specific menu declarations are in UI.Avalonia and UI.Blazor modules. /// +[DependsOn()] // no explicit deps [Module("SimpleNotes", "Notes", Description = "A simple note taking module to demonstrate Modulus vertical slice architecture.")] -public class SimpleNotesModule : ModuleBase +public class SimpleNotesModule : ModulusComponent { public override void ConfigureServices(IModuleLifecycleContext context) { diff --git a/src/Modules/SimpleNotes/SimpleNotes.UI.Blazor/SimpleNotesBlazorModule.cs b/src/Modules/SimpleNotes/SimpleNotes.UI.Blazor/SimpleNotesBlazorModule.cs index 7ca2260..fa13ec3 100644 --- a/src/Modules/SimpleNotes/SimpleNotes.UI.Blazor/SimpleNotesBlazorModule.cs +++ b/src/Modules/SimpleNotes/SimpleNotes.UI.Blazor/SimpleNotesBlazorModule.cs @@ -11,7 +11,7 @@ namespace SimpleNotes.UI.Blazor; /// [DependsOn(typeof(SimpleNotesModule))] [BlazorMenu("Notes", "/notes", Icon = IconKind.Document, Order = 30)] -public class SimpleNotesBlazorModule : ModuleBase +public class SimpleNotesBlazorModule : ModulusComponent { public override Task OnApplicationInitializationAsync(IModuleInitializationContext context, CancellationToken cancellationToken = default) { diff --git a/src/Modulus.Core/Data/DatabaseServiceExtensions.cs b/src/Modulus.Core/Data/DatabaseServiceExtensions.cs index e8d3d91..b07399f 100644 --- a/src/Modulus.Core/Data/DatabaseServiceExtensions.cs +++ b/src/Modulus.Core/Data/DatabaseServiceExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Modulus.Infrastructure.Data; namespace Modulus.Core.Data; @@ -23,7 +24,7 @@ public static IServiceCollection AddModulusDatabase(this IServiceCollection serv Directory.CreateDirectory(directory); } - // Register DbContext + // Register unified DbContext (Infrastructure.Data.ModulusDbContext) services.AddDbContext(options => { options.UseSqlite($"Data Source={databasePath}"); @@ -39,10 +40,19 @@ public static IServiceCollection AddModulusDatabase(this IServiceCollection serv /// /// Gets the default database path for the application. /// - public static string GetDefaultDatabasePath() + /// + /// Optional database name (without extension). If null, resolves from + /// environment variable MODULUS_DB_NAME, otherwise falls back to "Modulus". + /// + public static string GetDefaultDatabasePath(string? databaseName = null) { var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - return Path.Combine(appDataPath, "Modulus", "modulus.db"); + var resolvedName = databaseName + ?? Environment.GetEnvironmentVariable("MODULUS_DB_NAME") + ?? "Modulus"; + + var sanitizedName = string.IsNullOrWhiteSpace(resolvedName) ? "Modulus" : resolvedName; + return Path.Combine(appDataPath, "Modulus", $"{sanitizedName}.db"); } } diff --git a/src/Modulus.Core/Data/EfAppDatabase.cs b/src/Modulus.Core/Data/EfAppDatabase.cs index 89c165c..b07cec4 100644 --- a/src/Modulus.Core/Data/EfAppDatabase.cs +++ b/src/Modulus.Core/Data/EfAppDatabase.cs @@ -1,11 +1,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Modulus.Core.Data.Entities; +using Modulus.Infrastructure.Data; +using Modulus.Infrastructure.Data.Models; namespace Modulus.Core.Data; /// /// EF Core implementation of the application database. +/// Uses Infrastructure.Data.ModulusDbContext for unified persistence. /// public class EfAppDatabase : IAppDatabase { @@ -18,11 +20,11 @@ public EfAppDatabase(ModulusDbContext context, ILogger logger) _logger = logger; } - public async Task InitializeAsync(CancellationToken cancellationToken = default) + public Task InitializeAsync(CancellationToken cancellationToken = default) { - // EnsureCreated will create the database and tables if they don't exist - await _context.Database.EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Database initialized"); + // Database initialization (migrations) is handled by ModulusApplicationFactory + _logger.LogInformation("AppDatabase initialized"); + return Task.CompletedTask; } #region App Settings @@ -49,7 +51,7 @@ public async Task SetSettingAsync(string key, string value, CancellationToken ca } else { - _context.AppSettings.Add(new AppSetting + _context.AppSettings.Add(new AppSettingEntity { Key = key, Value = value, @@ -69,74 +71,4 @@ public async Task> GetAllSettingsAsync(CancellationTo } #endregion - - #region Installed Modules - - public async Task> GetInstalledModulesAsync(CancellationToken cancellationToken = default) - { - return await _context.InstalledModules - .AsNoTracking() - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - } - - public async Task GetInstalledModuleAsync(string moduleId, CancellationToken cancellationToken = default) - { - return await _context.InstalledModules - .AsNoTracking() - .FirstOrDefaultAsync(m => m.Id == moduleId, cancellationToken) - .ConfigureAwait(false); - } - - public async Task UpsertInstalledModuleAsync(InstalledModule module, CancellationToken cancellationToken = default) - { - var existing = await _context.InstalledModules - .FirstOrDefaultAsync(m => m.Id == module.Id, cancellationToken) - .ConfigureAwait(false); - - if (existing != null) - { - existing.DisplayName = module.DisplayName; - existing.Version = module.Version; - existing.PackagePath = module.PackagePath; - existing.IsEnabled = module.IsEnabled; - existing.IsSystem = module.IsSystem; - existing.LastLoadedAt = module.LastLoadedAt; - } - else - { - _context.InstalledModules.Add(module); - } - - await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - - public async Task DeleteInstalledModuleAsync(string moduleId, CancellationToken cancellationToken = default) - { - var module = await _context.InstalledModules - .FirstOrDefaultAsync(m => m.Id == moduleId, cancellationToken) - .ConfigureAwait(false); - - if (module != null) - { - _context.InstalledModules.Remove(module); - await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - } - - public async Task UpdateModuleEnabledStateAsync(string moduleId, bool isEnabled, CancellationToken cancellationToken = default) - { - var module = await _context.InstalledModules - .FirstOrDefaultAsync(m => m.Id == moduleId, cancellationToken) - .ConfigureAwait(false); - - if (module != null) - { - module.IsEnabled = isEnabled; - await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - } - - #endregion } - diff --git a/src/Modulus.Core/Data/Entities/InstalledModule.cs b/src/Modulus.Core/Data/Entities/InstalledModule.cs deleted file mode 100644 index 475cdc9..0000000 --- a/src/Modulus.Core/Data/Entities/InstalledModule.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Modulus.Core.Data.Entities; - -/// -/// Represents an installed module record in the database. -/// -public class InstalledModule -{ - [Key] - [MaxLength(128)] - public string Id { get; set; } = string.Empty; - - [MaxLength(256)] - public string DisplayName { get; set; } = string.Empty; - - [MaxLength(64)] - public string Version { get; set; } = string.Empty; - - [MaxLength(1024)] - public string PackagePath { get; set; } = string.Empty; - - public bool IsEnabled { get; set; } = true; - - public bool IsSystem { get; set; } - - public DateTime InstalledAt { get; set; } = DateTime.UtcNow; - - public DateTime? LastLoadedAt { get; set; } -} - diff --git a/src/Modulus.Core/Data/IAppDatabase.cs b/src/Modulus.Core/Data/IAppDatabase.cs index ecdb36a..33d44ba 100644 --- a/src/Modulus.Core/Data/IAppDatabase.cs +++ b/src/Modulus.Core/Data/IAppDatabase.cs @@ -1,5 +1,3 @@ -using Modulus.Core.Data.Entities; - namespace Modulus.Core.Data; /// @@ -16,12 +14,4 @@ public interface IAppDatabase Task GetSettingAsync(string key, CancellationToken cancellationToken = default); Task SetSettingAsync(string key, string value, CancellationToken cancellationToken = default); Task> GetAllSettingsAsync(CancellationToken cancellationToken = default); - - // Installed Modules - Task> GetInstalledModulesAsync(CancellationToken cancellationToken = default); - Task GetInstalledModuleAsync(string moduleId, CancellationToken cancellationToken = default); - Task UpsertInstalledModuleAsync(InstalledModule module, CancellationToken cancellationToken = default); - Task DeleteInstalledModuleAsync(string moduleId, CancellationToken cancellationToken = default); - Task UpdateModuleEnabledStateAsync(string moduleId, bool isEnabled, CancellationToken cancellationToken = default); } - diff --git a/src/Modulus.Core/Data/ModulusDbContext.cs b/src/Modulus.Core/Data/ModulusDbContext.cs deleted file mode 100644 index bb167db..0000000 --- a/src/Modulus.Core/Data/ModulusDbContext.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Modulus.Core.Data.Entities; - -namespace Modulus.Core.Data; - -/// -/// EF Core DbContext for Modulus application data. -/// -public class ModulusDbContext : DbContext -{ - public ModulusDbContext(DbContextOptions options) : base(options) - { - } - - public DbSet AppSettings => Set(); - public DbSet InstalledModules => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - // AppSetting configuration - modelBuilder.Entity(entity => - { - entity.ToTable("AppSettings"); - entity.HasKey(e => e.Key); - entity.Property(e => e.Value).IsRequired(); - }); - - // InstalledModule configuration - modelBuilder.Entity(entity => - { - entity.ToTable("InstalledModules"); - entity.HasKey(e => e.Id); - entity.Property(e => e.DisplayName).IsRequired(); - entity.Property(e => e.Version).IsRequired(); - entity.Property(e => e.PackagePath).IsRequired(); - }); - } -} - diff --git a/src/Modulus.Core/Hosting/ModulusHostBuilderExtensions.cs b/src/Modulus.Core/Hosting/ModulusHostBuilderExtensions.cs index efc8ddb..1b4f026 100644 --- a/src/Modulus.Core/Hosting/ModulusHostBuilderExtensions.cs +++ b/src/Modulus.Core/Hosting/ModulusHostBuilderExtensions.cs @@ -1,10 +1,14 @@ using System; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Modulus.Core.Architecture; +using Modulus.Core.Installation; using Modulus.Core.Manifest; using Modulus.Core.Runtime; +using Modulus.Infrastructure.Data; +using Modulus.Infrastructure.Data.Repositories; namespace Modulus.Core.Hosting; @@ -14,6 +18,20 @@ public static IHostBuilder UseModulusRuntime(this IHostBuilder builder) { return builder.ConfigureServices((_, services) => { + // Database + services.AddDbContext(options => + options.UseSqlite("Data Source=modulus.db")); + + // Repositories + services.AddScoped(); + services.AddScoped(); + + // Installation Services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Core Runtime services.AddSingleton(); services.AddSingleton(sp => SharedAssemblyCatalog.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies(), null, sp.GetService>())); services.AddSingleton(); @@ -23,4 +41,3 @@ public static IHostBuilder UseModulusRuntime(this IHostBuilder builder) }); } } - diff --git a/src/Modulus.Core/Installation/HostModuleSeeder.cs b/src/Modulus.Core/Installation/HostModuleSeeder.cs new file mode 100644 index 0000000..cfdf0f8 --- /dev/null +++ b/src/Modulus.Core/Installation/HostModuleSeeder.cs @@ -0,0 +1,102 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Modulus.Infrastructure.Data.Models; +using Modulus.Infrastructure.Data.Repositories; +using Modulus.UI.Abstractions; + +namespace Modulus.Core.Installation; + +/// +/// Seeds the Host application as a system module with its built-in menus. +/// This ensures all menus come from the database (full database-driven approach). +/// +public class HostModuleSeeder +{ + private readonly IModuleRepository _moduleRepository; + private readonly IMenuRepository _menuRepository; + private readonly ILogger _logger; + + public HostModuleSeeder( + IModuleRepository moduleRepository, + IMenuRepository menuRepository, + ILogger logger) + { + _moduleRepository = moduleRepository; + _menuRepository = menuRepository; + _logger = logger; + } + + /// + /// Seeds the Host module and its built-in menus if not already present. + /// + /// The host type (e.g., "Avalonia", "Blazor") + /// Full type name of the Modules/Extensions ViewModel + /// Full type name of the Settings ViewModel + /// Cancellation token + public async Task SeedAsync( + string hostType, + string modulesViewModelType, + string settingsViewModelType, + CancellationToken cancellationToken = default) + { + var hostModuleId = $"Modulus.Host.{hostType}"; + + var existing = await _moduleRepository.GetAsync(hostModuleId, cancellationToken); + if (existing != null) + { + _logger.LogDebug("Host module {ModuleId} already exists, skipping seed.", hostModuleId); + return; + } + + _logger.LogInformation("Seeding Host module {ModuleId}...", hostModuleId); + + // Create Host module entity + var hostModule = new ModuleEntity + { + Id = hostModuleId, + Name = $"Modulus Host ({hostType})", + Version = "1.0.0", + Author = "Modulus Framework", + Website = "https://github.com/AGIBuild/Modulus", + Path = "built-in", // Special marker for host module + IsSystem = true, + IsEnabled = true, + State = ModuleState.Ready, + MenuLocation = MenuLocation.Main // Host menus can be in both Main and Bottom + }; + + await _moduleRepository.UpsertAsync(hostModule, cancellationToken); + + // Create built-in menus + var menus = new[] + { + new MenuEntity + { + Id = $"{hostModuleId}.Modules", + ModuleId = hostModuleId, + DisplayName = "Extensions", + Icon = IconKind.AppsAddIn.ToString(), + Route = modulesViewModelType, + Location = MenuLocation.Main, + Order = 1000 // At the end of main menu + }, + new MenuEntity + { + Id = $"{hostModuleId}.Settings", + ModuleId = hostModuleId, + DisplayName = "Settings", + Icon = IconKind.Settings.ToString(), + Route = settingsViewModelType, + Location = MenuLocation.Bottom, + Order = 100 + } + }; + + await _menuRepository.ReplaceModuleMenusAsync(hostModuleId, menus, cancellationToken); + + _logger.LogInformation("Host module {ModuleId} seeded with {MenuCount} menus.", hostModuleId, menus.Length); + } +} + diff --git a/src/Modulus.Core/Installation/IModuleInstallerService.cs b/src/Modulus.Core/Installation/IModuleInstallerService.cs new file mode 100644 index 0000000..e1ed69c --- /dev/null +++ b/src/Modulus.Core/Installation/IModuleInstallerService.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Modulus.Core.Installation; + +public interface IModuleInstallerService +{ + /// + /// Installs or updates a module from a given package directory. + /// Scans metadata and persists to database. + /// + /// The full path to the module directory. + /// Whether this is a system module. + /// The current host type (e.g., "AvaloniaApp", "BlazorApp"). Only loads UI assemblies for this host. + Task InstallFromPathAsync(string packagePath, bool isSystem = false, string? hostType = null, CancellationToken cancellationToken = default); + + /// + /// Registers a development module that exists on disk but is not in the database. + /// + Task RegisterDevelopmentModuleAsync(string manifestPath, CancellationToken cancellationToken = default); +} + diff --git a/src/Modulus.Core/Installation/ModuleInstallerService.cs b/src/Modulus.Core/Installation/ModuleInstallerService.cs new file mode 100644 index 0000000..15f211d --- /dev/null +++ b/src/Modulus.Core/Installation/ModuleInstallerService.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Modulus.Core.Architecture; +using Modulus.Core.Manifest; +using Modulus.Core.Runtime; +using Modulus.Infrastructure.Data.Models; +using Modulus.Infrastructure.Data.Repositories; +using Modulus.Sdk; +using Modulus.UI.Abstractions; + +namespace Modulus.Core.Installation; + +public class ModuleInstallerService : IModuleInstallerService +{ + private readonly IModuleRepository _moduleRepository; + private readonly IMenuRepository _menuRepository; + private readonly ISharedAssemblyCatalog _sharedCatalog; + private readonly IManifestValidator _manifestValidator; + private readonly ModuleMetadataScanner _scanner; + private readonly ILogger _logger; + + public ModuleInstallerService( + IModuleRepository moduleRepository, + IMenuRepository menuRepository, + ISharedAssemblyCatalog sharedCatalog, + IManifestValidator manifestValidator, + ILogger logger) + { + _moduleRepository = moduleRepository; + _menuRepository = menuRepository; + _sharedCatalog = sharedCatalog; + _manifestValidator = manifestValidator; + _logger = logger; + _scanner = new ModuleMetadataScanner(logger); + } + + public async Task InstallFromPathAsync(string packagePath, bool isSystem = false, string? hostType = null, CancellationToken cancellationToken = default) + { + var manifestPath = Path.Combine(packagePath, "manifest.json"); + if (!File.Exists(manifestPath)) + { + throw new FileNotFoundException("Manifest not found", manifestPath); + } + + var manifest = await ManifestReader.ReadFromFileAsync(manifestPath, cancellationToken); + if (manifest == null) + { + throw new InvalidOperationException($"Failed to read manifest from {manifestPath}"); + } + + // Validate basic integrity + if (!await _manifestValidator.ValidateAsync(packagePath, manifestPath, manifest, null, cancellationToken)) + { + _logger.LogWarning("Manifest validation failed for {ModuleId}. Installation may proceed but issues are expected.", manifest.Id); + } + + // Isolation context for inspection + // Note: passing null for logger to avoid context leaking into logger scope if possible, + // though our ModuleLoadContext holds a logger reference. + var inspectionContext = new ModuleLoadContext(manifest.Id, packagePath, _sharedCatalog, _logger); + + try + { + var loadedAssemblies = new List(); + + // Load Core Assemblies + foreach (var asmName in manifest.CoreAssemblies) + { + var assembly = LoadAssembly(inspectionContext, asmName); + if (assembly != null) loadedAssemblies.Add(assembly); + } + + // Load UI Assemblies (only for current host if specified) + if (manifest.UiAssemblies != null) + { + if (hostType != null && manifest.UiAssemblies.TryGetValue(hostType, out var hostAssemblies)) + { + // Load only for current host + foreach (var asmName in hostAssemblies) + { + var assembly = LoadAssembly(inspectionContext, asmName); + if (assembly != null) loadedAssemblies.Add(assembly); + } + } + else if (hostType == null) + { + // No host specified - load all (for backward compatibility) + foreach (var hostGroup in manifest.UiAssemblies.Values) + { + foreach (var asmName in hostGroup) + { + var assembly = LoadAssembly(inspectionContext, asmName); + if (assembly != null) loadedAssemblies.Add(assembly); + } + } + } + } + + // Find IModule implementation to verify validity and extract metadata + var moduleType = loadedAssemblies + .SelectMany(a => SafeGetTypes(a)) + .FirstOrDefault(t => typeof(IModule).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface); + + if (moduleType == null) + { + _logger.LogWarning("No IModule implementation found in {ModuleId}.", manifest.Id); + } + + // Collect menu metadata first to decide module-level location + var menuMetas = new List(); + + foreach (var assembly in loadedAssemblies) + { + foreach (var type in SafeGetTypes(assembly)) + { + // Avalonia + menuMetas.AddRange(_scanner.ScanAvaloniaMenus(type)); + + // Blazor + menuMetas.AddRange(_scanner.ScanBlazorMenus(type)); + } + } + + var requestedBottom = menuMetas.Any(m => m.Location == MenuLocation.Bottom); + var moduleLocation = (isSystem && requestedBottom) ? MenuLocation.Bottom : MenuLocation.Main; + + if (!isSystem && requestedBottom) + { + _logger.LogWarning("Module {ModuleId} requested Bottom menu location but is not system-managed. Forcing to Main.", manifest.Id); + } + + // Prepare Entities + var moduleEntity = new ModuleEntity + { + Id = manifest.Id, + Name = manifest.DisplayName ?? manifest.Id, + Version = manifest.Version, + Description = manifest.Description, + Author = manifest.Author, + Website = manifest.Website, + EntryComponent = manifest.EntryComponent, + Path = Path.GetRelativePath(Directory.GetCurrentDirectory(), manifestPath), // Store relative path + IsSystem = isSystem, + IsEnabled = true, // Default to enabled on install + MenuLocation = moduleLocation, + State = Modulus.Infrastructure.Data.Models.ModuleState.Ready + }; + + var menuEntities = new List(); + + foreach (var meta in menuMetas) + { + menuEntities.Add(MapMenu(meta, manifest.Id, moduleLocation)); + } + + // Persist + _logger.LogInformation("Installing module {ModuleId} v{Version} to database...", manifest.Id, manifest.Version); + + await _moduleRepository.UpsertAsync(moduleEntity, cancellationToken); + await _menuRepository.ReplaceModuleMenusAsync(manifest.Id, menuEntities, cancellationToken); + + _logger.LogInformation("Module {ModuleId} installed successfully.", manifest.Id); + + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to install module {ModuleId} from {Path}", manifest.Id, packagePath); + throw; + } + finally + { + inspectionContext.Unload(); + } + } + + public Task RegisterDevelopmentModuleAsync(string manifestPath, CancellationToken cancellationToken = default) + { + var dir = Path.GetDirectoryName(manifestPath); + if (dir == null) throw new ArgumentException("Invalid manifest path"); + + return InstallFromPathAsync(dir, isSystem: false, hostType: null, cancellationToken); + } + + private Assembly? LoadAssembly(ModuleLoadContext context, string assemblyName) + { + try + { + // Remove .dll extension if present - AssemblyName expects just the name + var name = assemblyName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + ? assemblyName[..^4] + : assemblyName; + return context.LoadFromAssemblyName(new AssemblyName(name)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load assembly {AssemblyName} for inspection.", assemblyName); + return null; + } + } + + private IEnumerable SafeGetTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t != null)!; + } + } + + private MenuEntity MapMenu(ModuleMenuMetadata meta, string moduleId, MenuLocation moduleLocation) + { + return new MenuEntity + { + Id = $"{moduleId}.{meta.Id}", // Ensure uniqueness + ModuleId = moduleId, + DisplayName = meta.DisplayName, + Icon = meta.Icon.ToString(), // Store enum as string + Route = !string.IsNullOrEmpty(meta.Route) ? meta.Route : meta.ViewModelType, + Location = moduleLocation, + Order = meta.Order, + ParentId = null // TODO: Support nesting + }; + } +} + diff --git a/src/Modulus.Core/Installation/ModuleIntegrityChecker.cs b/src/Modulus.Core/Installation/ModuleIntegrityChecker.cs new file mode 100644 index 0000000..47d3fa6 --- /dev/null +++ b/src/Modulus.Core/Installation/ModuleIntegrityChecker.cs @@ -0,0 +1,56 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Modulus.Infrastructure.Data.Models; +using Modulus.Infrastructure.Data.Repositories; + +namespace Modulus.Core.Installation; + +public class ModuleIntegrityChecker +{ + private readonly IModuleRepository _moduleRepository; + private readonly ILogger _logger; + + public ModuleIntegrityChecker(IModuleRepository moduleRepository, ILogger logger) + { + _moduleRepository = moduleRepository; + _logger = logger; + } + + /// + /// Checks all enabled modules for file existence. + /// Updates state to MissingFiles if manifest is gone. + /// + public async Task CheckAsync(CancellationToken cancellationToken = default) + { + var modules = await _moduleRepository.GetEnabledModulesAsync(cancellationToken); + + foreach (var module in modules) + { + // Skip Host modules (Path = "built-in") - they don't have manifest files + if (module.Path == "built-in") + { + continue; + } + + // Resolve absolute path + // module.Path is relative to App Root (or CWD) + var absolutePath = Path.GetFullPath(module.Path); + + if (!File.Exists(absolutePath)) + { + _logger.LogWarning("Integrity Check Failed: Module {ModuleId} manifest missing at {Path}. Marking as MissingFiles.", module.Id, absolutePath); + + await _moduleRepository.UpdateStateAsync(module.Id, ModuleState.MissingFiles, cancellationToken); + } + else if (module.State == ModuleState.MissingFiles) + { + // Auto-recover if files reappear + _logger.LogInformation("Integrity Check: Module {ModuleId} files restored. Marking as Ready.", module.Id); + await _moduleRepository.UpdateStateAsync(module.Id, ModuleState.Ready, cancellationToken); + } + } + } +} + diff --git a/src/Modulus.Core/Installation/SystemModuleSeeder.cs b/src/Modulus.Core/Installation/SystemModuleSeeder.cs new file mode 100644 index 0000000..aaa1ed5 --- /dev/null +++ b/src/Modulus.Core/Installation/SystemModuleSeeder.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Modulus.Core.Manifest; +using Modulus.Infrastructure.Data.Repositories; + +namespace Modulus.Core.Installation; + +public class SystemModuleSeeder +{ + private readonly IModuleInstallerService _installer; + private readonly IModuleRepository _moduleRepository; + private readonly ILogger _logger; + + public SystemModuleSeeder( + IModuleInstallerService installer, + IModuleRepository moduleRepository, + ILogger logger) + { + _installer = installer; + _moduleRepository = moduleRepository; + _logger = logger; + } + + /// + /// Scans a root directory containing multiple module folders. + /// + public async Task SeedFromDirectoryAsync(string systemModulesRoot, bool isSystem = true, CancellationToken cancellationToken = default) + { + if (!Directory.Exists(systemModulesRoot)) + { + _logger.LogInformation("Modules directory not found at {Path}. Skipping seeding.", systemModulesRoot); + return; + } + + var moduleDirs = Directory.GetDirectories(systemModulesRoot); + foreach (var dir in moduleDirs) + { + await SeedFromPathAsync(dir, isSystem, hostType: null, cancellationToken); + } + } + + /// + /// Seeds a single module from its package path. + /// + /// Path to module package directory containing manifest.json + /// Whether this is a system module (cannot be uninstalled) + /// The current host type (e.g., "AvaloniaApp"). Only loads UI assemblies for this host. + /// Cancellation token + public async Task SeedFromPathAsync(string modulePath, bool isSystem = true, string? hostType = null, CancellationToken cancellationToken = default) + { + var manifestPath = Path.Combine(modulePath, "manifest.json"); + if (!File.Exists(manifestPath)) return; + + try + { + var manifest = await ManifestReader.ReadFromFileAsync(manifestPath, cancellationToken); + if (manifest == null) return; + + var existing = await _moduleRepository.GetAsync(manifest.Id, cancellationToken); + + // Install if missing or version mismatch + if (existing == null || existing.Version != manifest.Version) + { + _logger.LogInformation("Seeding module {ModuleId} (Version {Version}, IsSystem={IsSystem})...", + manifest.Id, manifest.Version, isSystem); + await _installer.InstallFromPathAsync(modulePath, isSystem, hostType, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to seed module from {Path}", modulePath); + } + } +} diff --git a/src/Modulus.Core/Manifest/ManifestReader.cs b/src/Modulus.Core/Manifest/ManifestReader.cs index 97c6080..3c56c4e 100644 --- a/src/Modulus.Core/Manifest/ManifestReader.cs +++ b/src/Modulus.Core/Manifest/ManifestReader.cs @@ -13,12 +13,12 @@ public static class ManifestReader AllowTrailingCommas = true }; - public static async Task ReadAsync(Stream stream) + public static async Task ReadAsync(Stream stream, CancellationToken cancellationToken = default) { - return await JsonSerializer.DeserializeAsync(stream, Options).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, Options, cancellationToken).ConfigureAwait(false); } - public static async Task ReadFromFileAsync(string filePath) + public static async Task ReadFromFileAsync(string filePath, CancellationToken cancellationToken = default) { if (!File.Exists(filePath)) { @@ -26,7 +26,7 @@ public static class ManifestReader } await using var stream = File.OpenRead(filePath); - return await ReadAsync(stream).ConfigureAwait(false); + return await ReadAsync(stream, cancellationToken).ConfigureAwait(false); } } diff --git a/src/Modulus.Core/Modulus.Core.csproj b/src/Modulus.Core/Modulus.Core.csproj index 6afd291..6428972 100644 --- a/src/Modulus.Core/Modulus.Core.csproj +++ b/src/Modulus.Core/Modulus.Core.csproj @@ -20,6 +20,7 @@ + diff --git a/src/Modulus.Core/Runtime/ComponentDependencyResolver.cs b/src/Modulus.Core/Runtime/ComponentDependencyResolver.cs new file mode 100644 index 0000000..0bb11db --- /dev/null +++ b/src/Modulus.Core/Runtime/ComponentDependencyResolver.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Modulus.Sdk; + +namespace Modulus.Core.Runtime; + +/// +/// Resolves dependency ordering for ModulusComponent types using [DependsOn] attributes. +/// +internal static class ComponentDependencyResolver +{ + public static IReadOnlyList TopologicallySort( + IEnumerable components, + ILogger? logger = null) + { + var idComparer = StringComparer.OrdinalIgnoreCase; + var nodes = components.ToDictionary(t => t.FullName ?? t.Name, t => t, idComparer); + var indegrees = new Dictionary(idComparer); + var edges = new Dictionary>(idComparer); + + foreach (var (id, type) in nodes) + { + indegrees[id] = 0; + var deps = GetDependencies(type); + foreach (var depType in deps) + { + var depId = depType.FullName ?? depType.Name; + if (depId == null) continue; + + if (!nodes.ContainsKey(depId)) + { + logger?.LogError("Dependency '{DependencyId}' for component '{ComponentId}' not found.", depId, id); + throw new InvalidOperationException($"Missing dependency '{depId}' for component '{id}'."); + } + if (!edges.ContainsKey(depId)) + { + edges[depId] = new List(); + } + edges[depId].Add(id); + } + } + + foreach (var (id, type) in nodes) + { + var deps = GetDependencies(type); + foreach (var depType in deps) + { + var depId = depType.FullName ?? depType.Name; + if (depId != null && indegrees.ContainsKey(id)) + { + indegrees[id]++; + } + } + } + + var queue = new Queue(indegrees.Where(pair => pair.Value == 0).Select(pair => pair.Key)); + var sortedList = new List(); + + while (queue.Count > 0) + { + var currentId = queue.Dequeue(); + sortedList.Add(nodes[currentId]); + + if (edges.TryGetValue(currentId, out var dependentIds)) + { + foreach (var dependentId in dependentIds) + { + indegrees[dependentId]--; + if (indegrees[dependentId] == 0) + { + queue.Enqueue(dependentId); + } + } + } + } + + if (sortedList.Count != nodes.Count) + { + var remainingNodes = nodes.Keys.Except(sortedList.Select(t => t.FullName ?? t.Name)).ToList(); + logger?.LogError("Circular dependency detected or missing components: {RemainingNodes}", string.Join(", ", remainingNodes)); + throw new InvalidOperationException($"Circular dependency detected or missing components: {string.Join(", ", remainingNodes)}"); + } + + return sortedList; + } + + private static IReadOnlyCollection GetDependencies(Type componentType) + { + var deps = new List(); + var dependsOnAttrs = componentType.GetCustomAttributes(); + foreach (var attr in dependsOnAttrs) + { + deps.AddRange(attr.DependedModuleTypes); + } + return deps; + } +} + diff --git a/src/Modulus.Core/Runtime/IModuleLoader.cs b/src/Modulus.Core/Runtime/IModuleLoader.cs index 22f93af..4bb74e7 100644 --- a/src/Modulus.Core/Runtime/IModuleLoader.cs +++ b/src/Modulus.Core/Runtime/IModuleLoader.cs @@ -10,8 +10,9 @@ public interface IModuleLoader /// /// Path to the module package. /// Whether this module is considered a system module. + /// If true, modules are loaded but not initialized. Call IHostAwareModuleLoader.InitializeLoadedModulesAsync after host services are bound. /// - Task LoadAsync(string packagePath, bool isSystem = false, CancellationToken cancellationToken = default); + Task LoadAsync(string packagePath, bool isSystem = false, bool skipModuleInitialization = false, CancellationToken cancellationToken = default); /// /// Unloads a module by its ID. diff --git a/src/Modulus.Core/Runtime/ModuleLoadContext.cs b/src/Modulus.Core/Runtime/ModuleLoadContext.cs index f03bd31..5545d33 100644 --- a/src/Modulus.Core/Runtime/ModuleLoadContext.cs +++ b/src/Modulus.Core/Runtime/ModuleLoadContext.cs @@ -37,7 +37,7 @@ public ModuleLoadContext(string moduleId, string basePath, ISharedAssemblyCatalo return null; // Delegate to default context } - // 2. Try to load from module directory + // 2. Load from module package directory (standardized structure: manifest.json + *.dll in same directory) var candidatePath = Path.Combine(_basePath, $"{assemblyName.Name}.dll"); if (File.Exists(candidatePath)) { diff --git a/src/Modulus.Core/Runtime/ModuleLoader.cs b/src/Modulus.Core/Runtime/ModuleLoader.cs index 16ce9b4..8a7b293 100644 --- a/src/Modulus.Core/Runtime/ModuleLoader.cs +++ b/src/Modulus.Core/Runtime/ModuleLoader.cs @@ -16,6 +16,7 @@ namespace Modulus.Core.Runtime; public interface IHostAwareModuleLoader { void BindHostServices(IServiceProvider hostServices); + Task InitializeLoadedModulesAsync(CancellationToken cancellationToken = default); } public sealed class ModuleLoader : IModuleLoader, IHostAwareModuleLoader @@ -24,7 +25,6 @@ public sealed class ModuleLoader : IModuleLoader, IHostAwareModuleLoader private readonly IManifestValidator _manifestValidator; private readonly ILogger _logger; private readonly ISharedAssemblyCatalog _sharedAssemblyCatalog; - private readonly ModuleMetadataScanner _metadataScanner; private IServiceProvider? _hostServices; public ModuleLoader(RuntimeContext runtimeContext, IManifestValidator manifestValidator, ISharedAssemblyCatalog sharedAssemblyCatalog, ILogger logger, IServiceProvider? hostServices = null) @@ -33,16 +33,69 @@ public ModuleLoader(RuntimeContext runtimeContext, IManifestValidator manifestVa _manifestValidator = manifestValidator; _sharedAssemblyCatalog = sharedAssemblyCatalog; _logger = logger; - _metadataScanner = new ModuleMetadataScanner(logger); _hostServices = hostServices; } public void BindHostServices(IServiceProvider hostServices) { + _logger.LogInformation("BindHostServices called. Updating {Count} module handles...", _runtimeContext.ModuleHandles.Count); _hostServices = hostServices; + + // Update all existing module handles with the new host services + foreach (var handle in _runtimeContext.ModuleHandles) + { + _logger.LogInformation(" Updating composite provider for module {ModuleId}", handle.RuntimeModule.Descriptor.Id); + handle.UpdateCompositeServiceProvider(hostServices); + } + } + + /// + /// Initializes all pre-loaded modules that were loaded with skipModuleInitialization=true. + /// This should be called after BindHostServices. + /// + public async Task InitializeLoadedModulesAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("InitializeLoadedModulesAsync called. HostServices bound: {Bound}", _hostServices != null); + + if (_hostServices == null) + { + _logger.LogWarning("Cannot initialize modules: host services not bound."); + return; + } + + _logger.LogInformation("Found {Count} module handles to initialize.", _runtimeContext.ModuleHandles.Count); + + foreach (var handle in _runtimeContext.ModuleHandles) + { + var module = handle.RuntimeModule; + _logger.LogInformation("Checking module {ModuleId}, State={State}", module.Descriptor.Id, module.State); + if (module.State != ModuleState.Loaded) continue; // Skip already initialized or errored modules + + _logger.LogInformation("Initializing pre-loaded module {ModuleId} with {InstanceCount} instances...", + module.Descriptor.Id, handle.ModuleInstances.Count); + + var compositeProvider = new CompositeServiceProvider(handle.ServiceProvider, _hostServices); + var initContext = new ModuleInitializationContext(compositeProvider); + + try + { + foreach (var moduleInstance in handle.ModuleInstances) + { + _logger.LogInformation(" Calling OnApplicationInitializationAsync on {Type}...", moduleInstance.GetType().Name); + await moduleInstance.OnApplicationInitializationAsync(initContext, cancellationToken).ConfigureAwait(false); + } + module.State = ModuleState.Active; + _logger.LogInformation("Module {ModuleId} initialized successfully.", module.Descriptor.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initializing module {ModuleId}", module.Descriptor.Id); + module.State = ModuleState.Error; + } + } } - public async Task LoadAsync(string packagePath, bool isSystem = false, CancellationToken cancellationToken = default) + public async Task LoadAsync(string packagePath, bool isSystem = false, bool skipModuleInitialization = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -154,53 +207,68 @@ public void BindHostServices(IServiceProvider hostServices) loadedAssemblies.AddRange(alc.Assemblies); } - var moduleTypes = loadedAssemblies + var componentTypes = loadedAssemblies .SelectMany(SafeGetTypes) .Where(t => typeof(IModule).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface) .ToList(); - var moduleRegistrations = new List(); - foreach (var moduleType in moduleTypes) + // Entry component filtering (if specified) + if (!string.IsNullOrWhiteSpace(manifest.EntryComponent)) { - try + var entryName = manifest.EntryComponent!; + var map = componentTypes.ToDictionary(t => t.FullName ?? t.Name, StringComparer.OrdinalIgnoreCase); + if (map.TryGetValue(entryName, out var entryType)) { - var moduleInstance = CreateModuleInstance(moduleType); - if (moduleInstance == null) - { - continue; - } - - var moduleId = ResolveModuleId(moduleType, manifest.Id); - var dependencies = new HashSet(manifest.Dependencies.Keys, StringComparer.OrdinalIgnoreCase); - foreach (var attr in moduleType.GetCustomAttributes()) + var reachable = new HashSet(); + void Dfs(Type t) { - foreach (var depType in attr.DependedModuleTypes) + if (!reachable.Add(t)) return; + foreach (var dep in GetComponentDependencies(t)) { - var depId = ResolveModuleId(depType, depType.FullName ?? depType.Name); - dependencies.Add(depId); + var depId = dep.FullName ?? dep.Name; + if (depId != null && map.TryGetValue(depId, out var depType)) + { + Dfs(depType); + } } } - - moduleRegistrations.Add(new ModuleRegistration(moduleInstance, moduleId, dependencies)); + Dfs(entryType); + componentTypes = reachable.ToList(); } - catch (Exception ex) + else { - _logger.LogError(ex, "Failed to instantiate module {ModuleType}", moduleType.FullName); - return null; + _logger.LogWarning("Entry component {Entry} not found in module {ModuleId}; falling back to all components.", entryName, manifest.Id); } } - var moduleIdSet = new HashSet(moduleRegistrations.Select(r => r.ModuleId), StringComparer.OrdinalIgnoreCase); - foreach (var registration in moduleRegistrations) + IReadOnlyList sortedTypes; + try { - registration.Dependencies.RemoveWhere(dep => !moduleIdSet.Contains(dep)); + sortedTypes = ComponentDependencyResolver.TopologicallySort(componentTypes, _logger); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to resolve component dependencies for module {ModuleId}.", manifest.Id); + return null; } - var sortedModules = ModuleDependencyResolver.TopologicallySort( - moduleRegistrations, - r => r.ModuleId, - r => r.Dependencies, - _logger).Select(r => r.Instance).ToList(); + var sortedModules = new List(); + foreach (var type in sortedTypes) + { + try + { + var instance = CreateModuleInstance(type); + if (instance != null) + { + sortedModules.Add(instance); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to instantiate component {Component}", type.FullName); + return null; + } + } var services = new ServiceCollection(); services.AddSingleton(_runtimeContext); @@ -231,37 +299,41 @@ public void BindHostServices(IServiceProvider hostServices) ? new CompositeServiceProvider(scopedProvider, _hostServices) : scopedProvider; - var initContext = new ModuleInitializationContext(compositeProvider); + var registeredMenus = new List(); + var runtimeModule = new RuntimeModule(descriptor, alc, packagePath, manifest, isSystem) + { + State = skipModuleInitialization ? ModuleState.Loaded : ModuleState.Active + }; var initialized = false; try { - foreach (var module in sortedModules) + // Only run module initialization if not skipped (host services must be bound first) + if (!skipModuleInitialization) { - try - { - await module.OnApplicationInitializationAsync(initContext, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) + var initContext = new ModuleInitializationContext(compositeProvider); + foreach (var module in sortedModules) { - _logger.LogError(ex, "Error initializing module {ModuleType}", module.GetType().Name); - return null; + try + { + await module.OnApplicationInitializationAsync(initContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initializing module {ModuleType}", module.GetType().Name); + return null; + } } + runtimeModule.State = ModuleState.Active; } - var registeredMenus = RegisterMenus(sortedModules, compositeProvider, currentHostId, manifest.Id).ToList(); - - var runtimeModule = new RuntimeModule(descriptor, alc, packagePath, manifest, isSystem) - { - State = ModuleState.Active - }; - _runtimeContext.RegisterModule(runtimeModule); var handle = new RuntimeModuleHandle(runtimeModule, manifest, moduleScope, moduleProvider, compositeProvider, sortedModules, registeredMenus, loadedAssemblies); _runtimeContext.RegisterModuleHandle(handle); initialized = true; - _logger.LogInformation("Module {ModuleId} (v{Version}) loaded from {PackagePath} for host {HostType} (System: {IsSystem}).", manifest.Id, manifest.Version, packagePath, currentHostId ?? "None", isSystem); + _logger.LogInformation("Module {ModuleId} (v{Version}) loaded from {PackagePath} for host {HostType} (System: {IsSystem}, Initialized: {Initialized}).", + manifest.Id, manifest.Version, packagePath, currentHostId ?? "None", isSystem, !skipModuleInitialization); return descriptor; } @@ -360,13 +432,8 @@ public async Task UnloadAsync(string moduleId) // We should probably store IsSystem in manifest? No, it's a provider property. // For now, default to false (safe) or true if we could track it. // Actually we can check the path again via providers? Too slow. - // Let's modify ReloadAsync signature or just pass false for now. - // Wait, we can't change signature easily as it's an interface. - // But we can assume false for ReloadAsync as user-initiated reload is usually for dev/user modules. - // If we want to support system module reload, we should probably pass isSystem=true if it was system. - // But we unloaded it, so we lost that info unless we kept it. - // Let's default to false for now to fix the build error. - return await LoadAsync(packagePath, isSystem, cancellationToken).ConfigureAwait(false); + // Reload should initialize immediately (user-initiated action) + return await LoadAsync(packagePath, isSystem, skipModuleInitialization: false, cancellationToken).ConfigureAwait(false); } _logger.LogWarning("Cannot reload module {ModuleId}: not found.", moduleId); @@ -438,59 +505,6 @@ private bool EnsureDependenciesSatisfied(ModuleManifest manifest) return (IModule)Activator.CreateInstance(moduleType)!; } - private static string ResolveModuleId(Type moduleType, string fallback) - { - var moduleAttribute = moduleType.GetCustomAttribute(); - if (moduleAttribute != null && !string.IsNullOrWhiteSpace(moduleAttribute.Id)) - { - return moduleAttribute.Id; - } - - return fallback; - } - - private IEnumerable RegisterMenus(IReadOnlyCollection modules, IServiceProvider serviceProvider, string? hostType, string fallbackModuleId) - { - var menuRegistry = serviceProvider.GetService(); - if (menuRegistry == null || string.IsNullOrEmpty(hostType)) - { - return Array.Empty(); - } - - var menus = new List(); - - foreach (var module in modules) - { - var moduleType = module.GetType(); - List menuMetadata; - - if (hostType == HostType.Avalonia) - { - menuMetadata = _metadataScanner.ScanAvaloniaMenus(moduleType); - } - else if (hostType == HostType.Blazor) - { - menuMetadata = _metadataScanner.ScanBlazorMenus(moduleType); - } - else - { - continue; - } - - foreach (var menu in menuMetadata) - { - var navigationKey = !string.IsNullOrEmpty(menu.Route) ? menu.Route : menu.ViewModelType ?? menu.Id; - var item = new MenuItem($"{moduleType.Name}.{menu.Id}", menu.DisplayName, menu.Icon, navigationKey, menu.Location, menu.Order); - var moduleId = ResolveModuleId(moduleType, fallbackModuleId); - item.ModuleId = moduleId; - menuRegistry.Register(item); - menus.Add(item); - } - } - - return menus; - } - private static IEnumerable SafeGetTypes(Assembly assembly) { try @@ -507,5 +521,14 @@ private static IEnumerable SafeGetTypes(Assembly assembly) } } - private sealed record ModuleRegistration(IModule Instance, string ModuleId, HashSet Dependencies); + private static IReadOnlyCollection GetComponentDependencies(Type componentType) + { + var deps = new List(); + var dependsOnAttrs = componentType.GetCustomAttributes(); + foreach (var attr in dependsOnAttrs) + { + deps.AddRange(attr.DependedModuleTypes); + } + return deps; + } } diff --git a/src/Modulus.Core/Runtime/ModuleManager.cs b/src/Modulus.Core/Runtime/ModuleManager.cs index a3a835e..a36f75d 100644 --- a/src/Modulus.Core/Runtime/ModuleManager.cs +++ b/src/Modulus.Core/Runtime/ModuleManager.cs @@ -55,6 +55,20 @@ public IReadOnlyList GetSortedModules() return sorted.Select(r => r.Module).ToList(); } + /// + /// Gets sorted modules with their registered IDs. + /// + public IReadOnlyList<(IModule Module, string ModuleId)> GetSortedModulesWithIds() + { + var sorted = ModuleDependencyResolver.TopologicallySort( + _registrations, + r => r.Id, + r => r.Dependencies, + _logger); + + return sorted.Select(r => (r.Module, r.Id)).ToList(); + } + private static string ResolveModuleId(Type type) { var attr = type.GetCustomAttribute(); diff --git a/src/Modulus.Core/Runtime/ModulusApplication.cs b/src/Modulus.Core/Runtime/ModulusApplication.cs index 3342100..c80549a 100644 --- a/src/Modulus.Core/Runtime/ModulusApplication.cs +++ b/src/Modulus.Core/Runtime/ModulusApplication.cs @@ -2,11 +2,10 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; +using Modulus.Infrastructure.Data.Repositories; using Modulus.Sdk; using Modulus.UI.Abstractions; @@ -20,7 +19,7 @@ public interface IModulusApplication : IDisposable Task ShutdownAsync(); /// - /// Gets all scanned module metadata (from declarative attributes). + /// Gets all scanned module metadata. /// IReadOnlyList ModuleMetadataList { get; } @@ -36,7 +35,6 @@ public class ModulusApplication : IModulusApplication private IServiceProvider? _serviceProvider; private readonly ModuleManager _moduleManager; private readonly ILogger _logger; - private readonly ModuleMetadataScanner _metadataScanner; private readonly List _moduleMetadataList = new(); private bool _initialized; @@ -53,7 +51,6 @@ internal ModulusApplication(IServiceCollection services, ModuleManager moduleMan _services = services; _moduleManager = moduleManager; _logger = logger; - _metadataScanner = new ModuleMetadataScanner(logger); } public void ConfigureServices() @@ -61,16 +58,6 @@ public void ConfigureServices() var context = new ModuleLifecycleContext(_services); var sortedModules = _moduleManager.GetSortedModules(); - // Scan core module metadata - foreach (var module in sortedModules) - { - var metadata = _metadataScanner.ScanCoreModule(module.GetType()); - if (metadata != null) - { - _moduleMetadataList.Add(metadata); - } - } - foreach (var module in sortedModules) { module.PreConfigureServices(context); @@ -107,8 +94,26 @@ public async Task InitializeAsync() throw new InvalidOperationException("ServiceProvider not set."); } - // Auto-register menu items from UI module attributes - RegisterMenuItemsFromAttributes(); + // Initialize pre-loaded modules (those loaded during CreateAsync with skipModuleInitialization=true) + var moduleLoader = _serviceProvider.GetService(); + _logger.LogInformation("ModuleLoader type: {Type}, IsHostAware: {IsHostAware}", + moduleLoader?.GetType().Name ?? "null", + moduleLoader is IHostAwareModuleLoader); + + if (moduleLoader is IHostAwareModuleLoader hostAwareLoader) + { + await hostAwareLoader.InitializeLoadedModulesAsync(); + } + else + { + _logger.LogWarning("ModuleLoader is not IHostAwareModuleLoader, cannot initialize pre-loaded modules."); + } + + // Register menus from Database + await RegisterMenuItemsFromDatabaseAsync(); + + // Populate metadata from Database + await LoadModuleMetadataAsync(); var context = new ModuleInitializationContext(_serviceProvider); var sortedModules = _moduleManager.GetSortedModules(); @@ -127,61 +132,71 @@ public async Task InitializeAsync() } } - /// - /// Automatically registers menu items from UI module attributes (Avalonia/Blazor). - /// - private void RegisterMenuItemsFromAttributes() + private async Task RegisterMenuItemsFromDatabaseAsync() { - var menuRegistry = _serviceProvider?.GetService(); - if (menuRegistry == null) + using var scope = _serviceProvider!.CreateScope(); + var menuRepo = scope.ServiceProvider.GetService(); + var menuRegistry = _serviceProvider!.GetService(); + + if (menuRepo == null || menuRegistry == null) { - _logger.LogDebug("IMenuRegistry not registered. Skipping declarative menu registration."); + _logger.LogWarning("MenuRepository or MenuRegistry missing. Skipping menu registration."); return; } - - var runtimeContext = _serviceProvider?.GetService(); - var currentHost = runtimeContext?.HostType; - - var sortedModules = _moduleManager.GetSortedModules(); - - foreach (var module in sortedModules) + + // Only load enabled menus + var menus = await menuRepo.GetAllEnabledAsync(); + + foreach (var menu in menus) { - var moduleType = module.GetType(); - List menus; - - // Scan based on host type - if (currentHost == HostType.Avalonia) - { - menus = _metadataScanner.ScanAvaloniaMenus(moduleType); - } - else if (currentHost == HostType.Blazor) + IconKind iconKind = IconKind.Grid; // Default + if (Enum.TryParse(menu.Icon, true, out var parsedIcon)) { - menus = _metadataScanner.ScanBlazorMenus(moduleType); + iconKind = parsedIcon; } - else + + var isSystemModule = menu.Module?.IsSystem ?? false; + var location = isSystemModule ? menu.Location : MenuLocation.Main; + + if (!isSystemModule && menu.Location == MenuLocation.Bottom) { - continue; + _logger.LogWarning("Module {ModuleId} is not system-managed but has Bottom menu location. Forcing to Main.", menu.ModuleId); } - foreach (var menu in menus) + var item = new MenuItem( + menu.Id, + menu.DisplayName, + iconKind, + menu.Route ?? menu.Id, + location, + menu.Order + ); + + item.ModuleId = menu.ModuleId; + + menuRegistry.Register(item); + } + + _logger.LogInformation("Registered {Count} menu items from database.", menus.Count); + } + + private async Task LoadModuleMetadataAsync() + { + using var scope = _serviceProvider!.CreateScope(); + var moduleRepo = scope.ServiceProvider.GetService(); + if (moduleRepo == null) return; + + var modules = await moduleRepo.GetEnabledModulesAsync(); + foreach (var m in modules) + { + _moduleMetadataList.Add(new ModuleMetadata { - var navigationKey = !string.IsNullOrEmpty(menu.Route) ? menu.Route : menu.ViewModelType ?? menu.Id; - - var item = new MenuItem( - $"{moduleType.Name}.{menu.Id}", - menu.DisplayName, - menu.Icon, - navigationKey, - menu.Location, - menu.Order - ); - - var attr = moduleType.GetCustomAttribute(); - item.ModuleId = attr?.Id ?? moduleType.FullName ?? moduleType.Name; - - menuRegistry.Register(item); - _logger.LogDebug("Registered menu: {DisplayName} -> {NavigationKey}", menu.DisplayName, navigationKey); - } + Id = m.Id, + DisplayName = m.Name, + Version = m.Version, + Author = m.Author ?? "", + // Type is not strictly necessary for display purposes + }); } } diff --git a/src/Modulus.Core/Runtime/ModulusApplicationFactory.cs b/src/Modulus.Core/Runtime/ModulusApplicationFactory.cs index 77b0cc1..b66273d 100644 --- a/src/Modulus.Core/Runtime/ModulusApplicationFactory.cs +++ b/src/Modulus.Core/Runtime/ModulusApplicationFactory.cs @@ -3,10 +3,16 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Modulus.Core.Architecture; +using Modulus.Core.Installation; using Modulus.Core.Manifest; +using Modulus.Infrastructure.Data; +using Modulus.Infrastructure.Data.Models; +using Modulus.Infrastructure.Data.Repositories; using Modulus.Sdk; namespace Modulus.Core.Runtime; @@ -16,81 +22,123 @@ public static class ModulusApplicationFactory public static async Task CreateAsync( IServiceCollection services, IEnumerable moduleProviders, - string? hostType = null) + string? hostType = null, + string? databasePath = null) where TStartupModule : IModule, new() { - // 1. Setup minimal runtime components for loading + // 1. Setup Runtime Components var runtimeContext = new RuntimeContext(); - if (hostType != null) + if (hostType != null) { runtimeContext.SetCurrentHost(hostType); } - + var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); var logger = loggerFactory.CreateLogger(); - var signatureVerifier = new Sha256ManifestSignatureVerifier(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - var manifestValidator = new DefaultManifestValidator(signatureVerifier, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var signatureVerifier = new Sha256ManifestSignatureVerifier(NullLogger.Instance); + var manifestValidator = new DefaultManifestValidator(signatureVerifier, NullLogger.Instance); var sharedAssemblies = SharedAssemblyCatalog.FromAssemblies(AppDomain.CurrentDomain.GetAssemblies()); - var moduleLoader = new ModuleLoader(runtimeContext, manifestValidator, sharedAssemblies, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - services.AddSingleton(sharedAssemblies); + var moduleLoader = new ModuleLoader(runtimeContext, manifestValidator, sharedAssemblies, loggerFactory.CreateLogger()); + var moduleManager = new ModuleManager(loggerFactory.CreateLogger()); + + // 2. Setup Temporary Services for DB & Seeding + var tempServices = new ServiceCollection(); + tempServices.AddLogging(builder => builder.AddConsole()); + tempServices.AddSingleton(sharedAssemblies); + tempServices.AddSingleton(manifestValidator); - var moduleManagerLogger = loggerFactory.CreateLogger(); - var moduleManager = new ModuleManager(moduleManagerLogger); - - // 2. Load Modules (Discovery Phase using Providers) with dependency ordering - var packageInfos = new List(); - if (moduleProviders != null) + // Use Sqlite default for now + var connectionString = string.IsNullOrWhiteSpace(databasePath) ? "Data Source=modulus.db" : $"Data Source={databasePath}"; + tempServices.AddDbContext(options => options.UseSqlite(connectionString)); + + tempServices.AddScoped(); + tempServices.AddScoped(); + tempServices.AddScoped(); + tempServices.AddScoped(); + tempServices.AddScoped(); + + using (var sp = tempServices.BuildServiceProvider()) + using (var scope = sp.CreateScope()) { - foreach (var provider in moduleProviders) + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + + var seeder = scope.ServiceProvider.GetRequiredService(); + + // Seed All Modules (system flag determined by provider.IsSystemSource) + if (moduleProviders != null) { - var packagePaths = await provider.GetModulePackagesAsync().ConfigureAwait(false); - foreach (var path in packagePaths) + foreach (var provider in moduleProviders) { - try + var paths = await provider.GetModulePackagesAsync(); + foreach (var path in paths) { - var manifestPath = Path.Combine(path, "manifest.json"); - var manifest = await ManifestReader.ReadFromFileAsync(manifestPath).ConfigureAwait(false); - if (manifest == null) - { - logger.LogWarning("Skipping module at {Path}: manifest not found.", path); - continue; - } - - packageInfos.Add(new ModulePackageInfo(path, manifest, provider.IsSystemSource)); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to read manifest from {Path}", path); + // Pass isSystem flag and hostType from provider to seeder + await seeder.SeedFromPathAsync(path, provider.IsSystemSource, hostType); } } } - } - - var orderedPackages = OrderPackages(packageInfos, runtimeContext, logger); - - foreach (var package in orderedPackages) - { - try + + // Ensure all changes are persisted before querying + await db.SaveChangesAsync(); + + // Integrity Check + var checker = scope.ServiceProvider.GetRequiredService(); + await checker.CheckAsync(); + + // 3. Load Enabled Modules (use fresh query to see seeded data) + var enabledModules = await db.Modules + .AsNoTracking() + .Where(m => m.IsEnabled) + .ToListAsync(); + + logger.LogDebug("Found {Count} enabled modules to load.", enabledModules.Count); + + // 3.1 Order Modules + var orderedModules = await OrderModulesAsync(enabledModules, runtimeContext, logger); + + // 3.2 Load Loop + foreach (var module in orderedModules) { - await moduleLoader.LoadAsync(package.Path, package.IsSystem).ConfigureAwait(false); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load module from {Path}", package.Path); + // Skip built-in host modules (they have no physical path) + if (module.Path == "built-in") + { + logger.LogDebug("Skipping built-in module {ModuleId}.", module.Id); + continue; + } + + try + { + // Resolve absolute path (module.Path is stored as manifest.json path) + var manifestPath = Path.GetFullPath(module.Path); + var packagePath = Path.GetDirectoryName(manifestPath); + + if (packagePath != null) + { + logger.LogDebug("Loading module {ModuleId} from {Path}...", module.Id, packagePath); + // Skip module initialization - it will be done after host services are bound + var descriptor = await moduleLoader.LoadAsync(packagePath, module.IsSystem, skipModuleInitialization: true).ConfigureAwait(false); + if (descriptor == null) + { + logger.LogWarning("Module {ModuleId} failed to load.", module.Id); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load module {ModuleId} from {Path}", module.Id, module.Path); + } } } - // 3. Register Modules to Manager - // Add Startup Module + // 4. Register Loaded Modules to Manager moduleManager.AddModule(new TStartupModule()); - // Add Discovered Dynamic Modules foreach (var runtimeModule in runtimeContext.RuntimeModules) { foreach (var assembly in runtimeModule.LoadContext.Assemblies) { - // logger.LogInformation("Scanning assembly {Assembly} for modules...", assembly.FullName); var moduleTypes = assembly.GetTypes() .Where(t => typeof(IModule).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface); @@ -100,9 +148,10 @@ public static async Task CreateAsync( try { - // logger.LogInformation("Found module type {Type}", type.FullName); var instance = (IModule)Activator.CreateInstance(type)!; - moduleManager.AddModule(instance, runtimeModule.Descriptor.Id, runtimeModule.Manifest.Dependencies.Keys); + // Let ModuleManager resolve the ID from [Module] attribute or type name + // Pass package-level dependencies from manifest + moduleManager.AddModule(instance, manifestDependencies: runtimeModule.Manifest.Dependencies.Keys); } catch (Exception ex) { @@ -112,63 +161,73 @@ public static async Task CreateAsync( } } - // 4. Register Core Services + // 5. Register Services to FINAL ServiceCollection services.AddSingleton(runtimeContext); services.AddSingleton(moduleLoader); services.AddSingleton(moduleManager); - - // Register Module Providers as IEnumerable - var providerList = moduleProviders?.ToList() ?? new List(); - services.AddSingleton>(providerList); - foreach (var provider in providerList) - { - services.AddSingleton(provider.GetType(), provider); - } + services.AddSingleton(sharedAssemblies); + services.AddSingleton(manifestValidator); - // 5. Create App and Configure Services var app = new ModulusApplication(services, moduleManager, logger); - app.ConfigureServices(); // Executes Pre/Config/Post - + app.ConfigureServices(); + return app; } - private static IReadOnlyList OrderPackages(IEnumerable packages, RuntimeContext runtimeContext, ILogger logger) + private static async Task> OrderModulesAsync( + IEnumerable modules, + RuntimeContext runtimeContext, + ILogger logger) { - var packageList = packages.ToList(); - var packageIdSet = new HashSet(packageList.Select(p => p.Manifest.Id), StringComparer.OrdinalIgnoreCase); + var moduleList = modules.ToList(); + var moduleDict = moduleList.ToDictionary(m => m.Id); + + var registrations = new List(); - var registrations = new List(); - foreach (var package in packageList) + foreach (var module in moduleList) { var deps = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var dependency in package.Manifest.Dependencies.Keys) + + try { - if (packageIdSet.Contains(dependency)) + var manifestPath = Path.GetFullPath(module.Path); + if (File.Exists(manifestPath)) { - deps.Add(dependency); - } - else if (!runtimeContext.TryGetModule(dependency, out _)) - { - logger.LogError("Module {ModuleId} is missing dependency {DependencyId}.", package.Manifest.Id, dependency); - throw new InvalidOperationException($"Missing dependency '{dependency}' for module '{package.Manifest.Id}'."); + var manifest = await ManifestReader.ReadFromFileAsync(manifestPath); + + if (manifest != null) + { + foreach (var depId in manifest.Dependencies.Keys) + { + if (moduleDict.ContainsKey(depId)) + { + deps.Add(depId); + } + else if (!runtimeContext.TryGetModule(depId, out _)) + { + // Warn but don't crash + logger.LogDebug("Module {ModuleId} depends on {DepId} which is not in current load list.", module.Id, depId); + } + } + } } } + catch (Exception) + { + logger.LogWarning("Could not read manifest for dependency checking: {Path}", module.Path); + } - registrations.Add(new PackageRegistration(package, package.Manifest.Id, deps)); + registrations.Add(new SortItem(module, module.Id, deps)); } var sorted = ModuleDependencyResolver.TopologicallySort( registrations, - r => r.ModuleId, + r => r.Id, r => r.Dependencies, logger); - return sorted.Select(r => r.Package).ToList(); + return sorted.Select(r => r.Entity).ToList(); } - private sealed record ModulePackageInfo(string Path, ModuleManifest Manifest, bool IsSystem); - - private sealed record PackageRegistration(ModulePackageInfo Package, string ModuleId, HashSet Dependencies); + private sealed record SortItem(ModuleEntity Entity, string Id, HashSet Dependencies); } - - diff --git a/src/Modulus.Core/Runtime/RuntimeModuleHandle.cs b/src/Modulus.Core/Runtime/RuntimeModuleHandle.cs index 9eb52c6..89e6267 100644 --- a/src/Modulus.Core/Runtime/RuntimeModuleHandle.cs +++ b/src/Modulus.Core/Runtime/RuntimeModuleHandle.cs @@ -12,7 +12,7 @@ public sealed class RuntimeModuleHandle : IAsyncDisposable, IDisposable public RuntimeModule RuntimeModule { get; } public ModuleManifest Manifest { get; } public IServiceProvider ServiceProvider { get; } - public IServiceProvider CompositeServiceProvider { get; } + public IServiceProvider CompositeServiceProvider { get; private set; } public IReadOnlyCollection ModuleInstances { get; } public IReadOnlyCollection RegisteredMenus { get; } public IReadOnlyCollection Assemblies { get; } @@ -39,6 +39,15 @@ public RuntimeModuleHandle( Assemblies = assemblies; } + /// + /// Updates the composite service provider with a new host services provider. + /// Called when host services are bound after initial module loading. + /// + public void UpdateCompositeServiceProvider(IServiceProvider hostServices) + { + CompositeServiceProvider = new CompositeServiceProvider(ServiceProvider, hostServices); + } + public void Dispose() { _serviceScope?.Dispose(); diff --git a/src/Modulus.Sdk/ModuleManifest.cs b/src/Modulus.Sdk/ModuleManifest.cs index 70c61d9..ea46725 100644 --- a/src/Modulus.Sdk/ModuleManifest.cs +++ b/src/Modulus.Sdk/ModuleManifest.cs @@ -27,6 +27,18 @@ public sealed class ModuleManifest [JsonPropertyName("description")] public string? Description { get; init; } + /// + /// Fully-qualified type name of the entry component (optional). + /// + [JsonPropertyName("entryComponent")] + public string? EntryComponent { get; init; } + + [JsonPropertyName("author")] + public string? Author { get; init; } + + [JsonPropertyName("website")] + public string? Website { get; init; } + [JsonPropertyName("supportedHosts")] public List SupportedHosts { get; init; } = new(); diff --git a/src/Modulus.Sdk/ModuleBase.cs b/src/Modulus.Sdk/ModulusComponent.cs similarity index 73% rename from src/Modulus.Sdk/ModuleBase.cs rename to src/Modulus.Sdk/ModulusComponent.cs index 5b2e8eb..8a58c58 100644 --- a/src/Modulus.Sdk/ModuleBase.cs +++ b/src/Modulus.Sdk/ModulusComponent.cs @@ -1,10 +1,14 @@ -using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; namespace Modulus.Sdk; -public abstract class ModuleBase : IModule +/// +/// Base class for code components (logical units) that participate in the Modulus runtime. +/// Compatible with existing IModule pipeline. +/// +public abstract class ModulusComponent : IModule { public virtual void PreConfigureServices(IModuleLifecycleContext context) { @@ -28,3 +32,4 @@ public virtual Task OnApplicationShutdownAsync(IModuleInitializationContext cont return Task.CompletedTask; } } + diff --git a/src/Modulus.Sdk/ToolPluginBase.cs b/src/Modulus.Sdk/ToolPluginBase.cs index 210222a..4fc8a8e 100644 --- a/src/Modulus.Sdk/ToolPluginBase.cs +++ b/src/Modulus.Sdk/ToolPluginBase.cs @@ -8,7 +8,7 @@ namespace Modulus.Sdk; /// /// Base class for plugins that primarily provide a "Tool" (e.g. a utility panel). /// -public abstract class ToolPluginBase : ModuleBase +public abstract class ToolPluginBase : ModulusComponent { // In the future, this can provide helper methods to register the tool with the Shell // e.g. RegisterTool(...) diff --git a/src/Modulus.UI.Abstractions/IMenuRegistry.cs b/src/Modulus.UI.Abstractions/IMenuRegistry.cs index bdbac4b..6db71ea 100644 --- a/src/Modulus.UI.Abstractions/IMenuRegistry.cs +++ b/src/Modulus.UI.Abstractions/IMenuRegistry.cs @@ -1,13 +1,12 @@ -using System; using System.Collections.Generic; namespace Modulus.UI.Abstractions; public interface IMenuRegistry { - event EventHandler MenuChanged; void Register(MenuItem item); void Unregister(string id); + void UnregisterModuleItems(string moduleId); IEnumerable GetItems(MenuLocation location); } diff --git a/src/Modulus.UI.Abstractions/MenuItem.cs b/src/Modulus.UI.Abstractions/MenuItem.cs index cc04cab..f982b7f 100644 --- a/src/Modulus.UI.Abstractions/MenuItem.cs +++ b/src/Modulus.UI.Abstractions/MenuItem.cs @@ -36,6 +36,11 @@ public partial class MenuItem : ObservableObject public string NavigationKey { get; } // View Key or Route public int Order { get; } + /// + /// ID of the module that owns this menu item. + /// + public string? ModuleId { get; set; } + /// /// Controls page instance lifecycle during navigation. /// diff --git a/src/Modulus.UI.Abstractions/Messages/MenuMessages.cs b/src/Modulus.UI.Abstractions/Messages/MenuMessages.cs new file mode 100644 index 0000000..892ca67 --- /dev/null +++ b/src/Modulus.UI.Abstractions/Messages/MenuMessages.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Modulus.UI.Abstractions.Messages; + +/// +/// Message sent when menus need to be refreshed (e.g., after module enable/disable). +/// +public record MenuRefreshMessage; + +/// +/// Message sent when menu items should be added to the navigation. +/// +public record MenuItemsAddedMessage(IReadOnlyList Items); + +/// +/// Message sent when menu items should be removed from the navigation. +/// +public record MenuItemsRemovedMessage(string ModuleId); + diff --git a/src/Modulus.UI.Avalonia/Infrastructure/AvaloniaModuleBase.cs b/src/Modulus.UI.Avalonia/Infrastructure/AvaloniaModuleBase.cs index c12e011..2d80eec 100644 --- a/src/Modulus.UI.Avalonia/Infrastructure/AvaloniaModuleBase.cs +++ b/src/Modulus.UI.Avalonia/Infrastructure/AvaloniaModuleBase.cs @@ -8,7 +8,7 @@ namespace Modulus.UI.Avalonia.Infrastructure; /// /// Base module that injects shared Avalonia resources before the module runs. /// -public abstract class AvaloniaModuleBase : ModuleBase +public abstract class AvaloniaModuleBase : ModulusComponent { public override Task OnApplicationInitializationAsync(IModuleInitializationContext context, CancellationToken cancellationToken = default) { diff --git a/src/Modulus.UI.Avalonia/Themes/Controls/Buttons.axaml b/src/Modulus.UI.Avalonia/Themes/Controls/Buttons.axaml new file mode 100644 index 0000000..b4b7b09 --- /dev/null +++ b/src/Modulus.UI.Avalonia/Themes/Controls/Buttons.axaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Modulus.UI.Avalonia/Themes/Generic.axaml b/src/Modulus.UI.Avalonia/Themes/Generic.axaml index dc0d990..3aaabd8 100644 --- a/src/Modulus.UI.Avalonia/Themes/Generic.axaml +++ b/src/Modulus.UI.Avalonia/Themes/Generic.axaml @@ -14,6 +14,7 @@ + diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206072539_InitialCreate.Designer.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206072539_InitialCreate.Designer.cs new file mode 100644 index 0000000..8b5f299 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206072539_InitialCreate.Designer.cs @@ -0,0 +1,112 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Modulus.Infrastructure.Data; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ModulusDbContext))] + [Migration("20251206072539_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Icon") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("INTEGER"); + + b.Property("ModuleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Route") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId"); + + b.ToTable("Menus"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Website") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Modules"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.HasOne("Modulus.Infrastructure.Data.Models.ModuleEntity", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Navigation("Menus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206072539_InitialCreate.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206072539_InitialCreate.cs new file mode 100644 index 0000000..4d36643 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206072539_InitialCreate.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Modules", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Version = table.Column(type: "TEXT", nullable: false), + Author = table.Column(type: "TEXT", nullable: true), + Website = table.Column(type: "TEXT", nullable: true), + Path = table.Column(type: "TEXT", nullable: false), + IsSystem = table.Column(type: "INTEGER", nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false), + State = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Modules", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Menus", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ModuleId = table.Column(type: "TEXT", nullable: false), + ParentId = table.Column(type: "TEXT", nullable: true), + DisplayName = table.Column(type: "TEXT", nullable: false), + Icon = table.Column(type: "TEXT", nullable: true), + Route = table.Column(type: "TEXT", nullable: true), + Location = table.Column(type: "INTEGER", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Menus", x => x.Id); + table.ForeignKey( + name: "FK_Menus_Modules_ModuleId", + column: x => x.ModuleId, + principalTable: "Modules", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Menus_ModuleId", + table: "Menus", + column: "ModuleId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Menus"); + + migrationBuilder.DropTable( + name: "Modules"); + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206100637_AddEntryComponent.Designer.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206100637_AddEntryComponent.Designer.cs new file mode 100644 index 0000000..efdb9f2 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206100637_AddEntryComponent.Designer.cs @@ -0,0 +1,115 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Modulus.Infrastructure.Data; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ModulusDbContext))] + [Migration("20251206100637_AddEntryComponent")] + partial class AddEntryComponent + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Icon") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("INTEGER"); + + b.Property("ModuleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Route") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId"); + + b.ToTable("Menus"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("EntryComponent") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Website") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Modules"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.HasOne("Modulus.Infrastructure.Data.Models.ModuleEntity", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Navigation("Menus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206100637_AddEntryComponent.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206100637_AddEntryComponent.cs new file mode 100644 index 0000000..4593934 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206100637_AddEntryComponent.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + /// + public partial class AddEntryComponent : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EntryComponent", + table: "Modules", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EntryComponent", + table: "Modules"); + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206132255_AddMenuLocationToModules.Designer.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206132255_AddMenuLocationToModules.Designer.cs new file mode 100644 index 0000000..691845c --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206132255_AddMenuLocationToModules.Designer.cs @@ -0,0 +1,120 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Modulus.Infrastructure.Data; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ModulusDbContext))] + [Migration("20251206132255_AddMenuLocationToModules")] + partial class AddMenuLocationToModules + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Icon") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("INTEGER"); + + b.Property("ModuleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Route") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId"); + + b.ToTable("Menus"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("EntryComponent") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("MenuLocation") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Website") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Modules"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.HasOne("Modulus.Infrastructure.Data.Models.ModuleEntity", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Navigation("Menus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206132255_AddMenuLocationToModules.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206132255_AddMenuLocationToModules.cs new file mode 100644 index 0000000..6daab95 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206132255_AddMenuLocationToModules.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + /// + public partial class AddMenuLocationToModules : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MenuLocation", + table: "Modules", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MenuLocation", + table: "Modules"); + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206133251_FixNonSystemMenuLocation.Designer.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206133251_FixNonSystemMenuLocation.Designer.cs new file mode 100644 index 0000000..a6f8185 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206133251_FixNonSystemMenuLocation.Designer.cs @@ -0,0 +1,120 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Modulus.Infrastructure.Data; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ModulusDbContext))] + [Migration("20251206133251_FixNonSystemMenuLocation")] + partial class FixNonSystemMenuLocation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Icon") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("INTEGER"); + + b.Property("ModuleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Route") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId"); + + b.ToTable("Menus"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("EntryComponent") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("MenuLocation") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Website") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Modules"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.HasOne("Modulus.Infrastructure.Data.Models.ModuleEntity", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Navigation("Menus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206133251_FixNonSystemMenuLocation.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206133251_FixNonSystemMenuLocation.cs new file mode 100644 index 0000000..62cd6e0 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206133251_FixNonSystemMenuLocation.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + /// + public partial class FixNonSystemMenuLocation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Force non-system modules to Main menu + migrationBuilder.Sql(@" +UPDATE Modules +SET MenuLocation = 0 +WHERE IsSystem = 0; + +UPDATE Menus +SET Location = 0 +WHERE ModuleId IN (SELECT Id FROM Modules WHERE IsSystem = 0); +"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // No-op: cannot restore previous location safely + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206143250_AddAppSettings.Designer.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206143250_AddAppSettings.Designer.cs new file mode 100644 index 0000000..ff64a43 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206143250_AddAppSettings.Designer.cs @@ -0,0 +1,139 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Modulus.Infrastructure.Data; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ModulusDbContext))] + [Migration("20251206143250_AddAppSettings")] + partial class AddAppSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.AppSettingEntity", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings", (string)null); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Icon") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("INTEGER"); + + b.Property("ModuleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Route") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId"); + + b.ToTable("Menus"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("EntryComponent") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("MenuLocation") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Website") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Modules"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.HasOne("Modulus.Infrastructure.Data.Models.ModuleEntity", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Navigation("Menus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206143250_AddAppSettings.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206143250_AddAppSettings.cs new file mode 100644 index 0000000..4f80c70 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251206143250_AddAppSettings.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + /// + public partial class AddAppSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppSettings", + columns: table => new + { + Key = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Value = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppSettings", x => x.Key); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppSettings"); + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251207125958_AddModuleDescription.Designer.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251207125958_AddModuleDescription.Designer.cs new file mode 100644 index 0000000..f7c146c --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251207125958_AddModuleDescription.Designer.cs @@ -0,0 +1,142 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Modulus.Infrastructure.Data; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ModulusDbContext))] + [Migration("20251207125958_AddModuleDescription")] + partial class AddModuleDescription + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.AppSettingEntity", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings", (string)null); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Icon") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("INTEGER"); + + b.Property("ModuleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Route") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId"); + + b.ToTable("Menus"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("EntryComponent") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("MenuLocation") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Website") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Modules"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.HasOne("Modulus.Infrastructure.Data.Models.ModuleEntity", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Navigation("Menus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/20251207125958_AddModuleDescription.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251207125958_AddModuleDescription.cs new file mode 100644 index 0000000..027f0fa --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/20251207125958_AddModuleDescription.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + /// + public partial class AddModuleDescription : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Description", + table: "Modules", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Description", + table: "Modules"); + } + } +} diff --git a/src/Shared/Modulus.Infrastructure.Data/Migrations/ModulusDbContextModelSnapshot.cs b/src/Shared/Modulus.Infrastructure.Data/Migrations/ModulusDbContextModelSnapshot.cs new file mode 100644 index 0000000..1c6e0bb --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Migrations/ModulusDbContextModelSnapshot.cs @@ -0,0 +1,139 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Modulus.Infrastructure.Data; + +#nullable disable + +namespace Modulus.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ModulusDbContext))] + partial class ModulusDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.AppSettingEntity", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings", (string)null); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Icon") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("INTEGER"); + + b.Property("ModuleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Route") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId"); + + b.ToTable("Menus"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("EntryComponent") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("MenuLocation") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Website") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Modules"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.MenuEntity", b => + { + b.HasOne("Modulus.Infrastructure.Data.Models.ModuleEntity", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Modulus.Infrastructure.Data.Models.ModuleEntity", b => + { + b.Navigation("Menus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modulus.Core/Data/Entities/AppSetting.cs b/src/Shared/Modulus.Infrastructure.Data/Models/AppSettingEntity.cs similarity index 82% rename from src/Modulus.Core/Data/Entities/AppSetting.cs rename to src/Shared/Modulus.Infrastructure.Data/Models/AppSettingEntity.cs index 54f6583..e107fce 100644 --- a/src/Modulus.Core/Data/Entities/AppSetting.cs +++ b/src/Shared/Modulus.Infrastructure.Data/Models/AppSettingEntity.cs @@ -1,11 +1,11 @@ using System.ComponentModel.DataAnnotations; -namespace Modulus.Core.Data.Entities; +namespace Modulus.Infrastructure.Data.Models; /// /// Represents a key-value application setting stored in the database. /// -public class AppSetting +public class AppSettingEntity { [Key] [MaxLength(256)] diff --git a/src/Shared/Modulus.Infrastructure.Data/Models/MenuEntity.cs b/src/Shared/Modulus.Infrastructure.Data/Models/MenuEntity.cs new file mode 100644 index 0000000..6f058b3 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Models/MenuEntity.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Modulus.UI.Abstractions; + +namespace Modulus.Infrastructure.Data.Models; + +public class MenuEntity +{ + [Key] + public string Id { get; set; } = string.Empty; + + [Required] + public string ModuleId { get; set; } = string.Empty; + + [ForeignKey(nameof(ModuleId))] + public virtual ModuleEntity Module { get; set; } = null!; + + public string? ParentId { get; set; } + + [Required] + public string DisplayName { get; set; } = string.Empty; + + public string? Icon { get; set; } + + public string? Route { get; set; } + + public MenuLocation Location { get; set; } + + public int Order { get; set; } +} + diff --git a/src/Shared/Modulus.Infrastructure.Data/Models/ModuleEntity.cs b/src/Shared/Modulus.Infrastructure.Data/Models/ModuleEntity.cs new file mode 100644 index 0000000..b4a33ff --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Models/ModuleEntity.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Modulus.UI.Abstractions; + +namespace Modulus.Infrastructure.Data.Models; + +public class ModuleEntity +{ + [Key] + public string Id { get; set; } = string.Empty; + + [Required] + public string Name { get; set; } = string.Empty; + + [Required] + public string Version { get; set; } = string.Empty; + + /// + /// Brief description of the module. + /// + public string? Description { get; set; } + + public string? Author { get; set; } + + public string? Website { get; set; } + + /// + /// Relative path to manifest.json (e.g. "Modules/User/PluginA/manifest.json") + /// + [Required] + public string Path { get; set; } = string.Empty; + + /// + /// Fully-qualified type name of the entry component (optional). + /// + public string? EntryComponent { get; set; } + + /// + /// If true, this module is managed by the system seeder and cannot be uninstalled. + /// + public bool IsSystem { get; set; } + + /// + /// User preference for enabling/disabling the module. + /// + public bool IsEnabled { get; set; } = true; + + /// + /// Desired menu location for this module (Main or Bottom). + /// + public MenuLocation MenuLocation { get; set; } = MenuLocation.Main; + + public ModuleState State { get; set; } = ModuleState.Ready; + + public virtual ICollection Menus { get; set; } = new List(); +} + diff --git a/src/Shared/Modulus.Infrastructure.Data/Models/ModuleState.cs b/src/Shared/Modulus.Infrastructure.Data/Models/ModuleState.cs new file mode 100644 index 0000000..8efe258 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Models/ModuleState.cs @@ -0,0 +1,10 @@ +namespace Modulus.Infrastructure.Data.Models; + +public enum ModuleState +{ + Ready, + Disabled, + MissingFiles, + Incompatible +} + diff --git a/src/Shared/Modulus.Infrastructure.Data/Modulus.Infrastructure.Data.csproj b/src/Shared/Modulus.Infrastructure.Data/Modulus.Infrastructure.Data.csproj new file mode 100644 index 0000000..8381765 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Modulus.Infrastructure.Data.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/src/Shared/Modulus.Infrastructure.Data/ModulusDbContext.cs b/src/Shared/Modulus.Infrastructure.Data/ModulusDbContext.cs new file mode 100644 index 0000000..adc52d8 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/ModulusDbContext.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Modulus.Infrastructure.Data.Models; +using Modulus.UI.Abstractions; + +namespace Modulus.Infrastructure.Data; + +public class ModulusDbContext : DbContext +{ + public DbSet Modules { get; set; } = null!; + public DbSet Menus { get; set; } = null!; + public DbSet AppSettings { get; set; } = null!; + + public ModulusDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired(); + entity.Property(e => e.Version).IsRequired(); + entity.Property(e => e.Path).IsRequired(); + entity.Property(e => e.EntryComponent); + entity.Property(e => e.MenuLocation).HasDefaultValue(MenuLocation.Main); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.DisplayName).IsRequired(); + + entity.HasOne(d => d.Module) + .WithMany(p => p.Menus) + .HasForeignKey(d => d.ModuleId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("AppSettings"); + entity.HasKey(e => e.Key); + entity.Property(e => e.Value).IsRequired(); + }); + } +} + diff --git a/src/Shared/Modulus.Infrastructure.Data/ModulusDbContextFactory.cs b/src/Shared/Modulus.Infrastructure.Data/ModulusDbContextFactory.cs new file mode 100644 index 0000000..1b557cf --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/ModulusDbContextFactory.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Modulus.Infrastructure.Data; + +public class ModulusDbContextFactory : IDesignTimeDbContextFactory +{ + public ModulusDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite("Data Source=modulus.db"); + + return new ModulusDbContext(optionsBuilder.Options); + } +} + diff --git a/src/Shared/Modulus.Infrastructure.Data/Repositories/IRepositories.cs b/src/Shared/Modulus.Infrastructure.Data/Repositories/IRepositories.cs new file mode 100644 index 0000000..ce975f2 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Repositories/IRepositories.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Modulus.Infrastructure.Data.Models; + +namespace Modulus.Infrastructure.Data.Repositories; + +public interface IModuleRepository +{ + Task GetAsync(string id, CancellationToken cancellationToken = default); + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task> GetEnabledModulesAsync(CancellationToken cancellationToken = default); + Task UpsertAsync(ModuleEntity module, CancellationToken cancellationToken = default); + Task DeleteAsync(string id, CancellationToken cancellationToken = default); + Task UpdateStateAsync(string id, ModuleState state, CancellationToken cancellationToken = default); +} + +public interface IMenuRepository +{ + Task> GetByModuleIdAsync(string moduleId, CancellationToken cancellationToken = default); + Task> GetAllEnabledAsync(CancellationToken cancellationToken = default); + Task ReplaceModuleMenusAsync(string moduleId, IEnumerable menus, CancellationToken cancellationToken = default); +} + diff --git a/src/Shared/Modulus.Infrastructure.Data/Repositories/Repositories.cs b/src/Shared/Modulus.Infrastructure.Data/Repositories/Repositories.cs new file mode 100644 index 0000000..6643d43 --- /dev/null +++ b/src/Shared/Modulus.Infrastructure.Data/Repositories/Repositories.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Modulus.Infrastructure.Data.Models; + +namespace Modulus.Infrastructure.Data.Repositories; + +public class ModuleRepository : IModuleRepository +{ + private readonly ModulusDbContext _context; + + public ModuleRepository(ModulusDbContext context) + { + _context = context; + } + + public Task GetAsync(string id, CancellationToken cancellationToken = default) + { + return _context.Modules + .Include(m => m.Menus) + .FirstOrDefaultAsync(m => m.Id == id, cancellationToken); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _context.Modules + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetEnabledModulesAsync(CancellationToken cancellationToken = default) + { + return await _context.Modules + .AsNoTracking() + .Where(m => m.IsEnabled) + .ToListAsync(cancellationToken); + } + + public async Task UpsertAsync(ModuleEntity module, CancellationToken cancellationToken = default) + { + var existing = await _context.Modules.FindAsync(new object[] { module.Id }, cancellationToken); + if (existing == null) + { + await _context.Modules.AddAsync(module, cancellationToken); + } + else + { + // Update properties + existing.Name = module.Name; + existing.Version = module.Version; + existing.Path = module.Path; + existing.IsSystem = module.IsSystem; + existing.Author = module.Author; + existing.Website = module.Website; + existing.MenuLocation = module.MenuLocation; + existing.EntryComponent = module.EntryComponent; + existing.State = module.State; + // Note: IsEnabled is preserved unless explicitly reset logic is needed + } + + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) + { + var module = await _context.Modules.FindAsync(new object[] { id }, cancellationToken); + if (module != null) + { + _context.Modules.Remove(module); + await _context.SaveChangesAsync(cancellationToken); + } + } + + public async Task UpdateStateAsync(string id, ModuleState state, CancellationToken cancellationToken = default) + { + var module = await _context.Modules.FindAsync(new object[] { id }, cancellationToken); + if (module != null) + { + module.State = state; + await _context.SaveChangesAsync(cancellationToken); + } + } +} + +public class MenuRepository : IMenuRepository +{ + private readonly ModulusDbContext _context; + + public MenuRepository(ModulusDbContext context) + { + _context = context; + } + + public async Task> GetByModuleIdAsync(string moduleId, CancellationToken cancellationToken = default) + { + return await _context.Menus + .AsNoTracking() + .Where(m => m.ModuleId == moduleId) + .OrderBy(m => m.Order) + .ToListAsync(cancellationToken); + } + + public async Task> GetAllEnabledAsync(CancellationToken cancellationToken = default) + { + return await _context.Menus + .AsNoTracking() + .Include(m => m.Module) + .Where(m => m.Module.IsEnabled && m.Module.State == ModuleState.Ready) + .OrderBy(m => m.Order) + .ToListAsync(cancellationToken); + } + + public async Task ReplaceModuleMenusAsync(string moduleId, IEnumerable menus, CancellationToken cancellationToken = default) + { + // Use a transaction + var strategy = _context.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + var existingMenus = await _context.Menus + .Where(m => m.ModuleId == moduleId) + .ToListAsync(cancellationToken); + + _context.Menus.RemoveRange(existingMenus); + await _context.Menus.AddRangeAsync(menus, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + }); + } +} + diff --git a/src/Shared/Modulus.Infrastructure.Data/modulus.db b/src/Shared/Modulus.Infrastructure.Data/modulus.db new file mode 100644 index 0000000000000000000000000000000000000000..5d16409d0b074b59665b211d07eeb88f85b6d020 GIT binary patch literal 45056 zcmeI(&u-&H90zbaZj#n**2~F)9(G6p5$(3vNz-ZzD`8TnnC4GO(xqjEEVnVc7D*gp zN7_p4pdX+-;7(^)9R-M$G6Fs zZl`JaB*We1A`$LGLO70Fp`Ug7371v6!S;m*2oVfamlRmif+MT~v zUnTw!f8Mafi;X`MKP)JVBnUtN0uX=z1RyY9;N=@Dad~r-zg_pOi-uiv>)nR!1*>ST zqH8ss)U=NZI!Ok5ljL2I&?RZqlcc7f)kvvKKc|HP$(2jhT17KTHIh6zHU|YxDn?PO zoReey{6W8L$!gjYrGu2mcE@wv*6c*B>pyclVH#~RDD;_q;n6?FrV~yq|5>;hsl@{GQCea9VZyxp8K)f?l9%)O+97f)E*{L;Z|~ zWHM8dluP{sX-BIqpPAB4Q0eLCR%zA)8g0_zj2 zLz#xD!^j>wUb|sk&F?S9<*>61i&fn2tnM(MJvfkZr*6~!H5rtC>S*`Q)9yVIj$v4m zBww!R#!-p&_Azo&(ervmFXi;==wM>wD8<5bs26lP+HzVoryc5IDix2(k7SQ$_@+g025oz_!tWK$gP zG9xL2j&p2Zg@a(u#bT_~bpLs5HM)cGJwB38+jTk)>)K$L1<7J6E5>AFYo6dpi>5mA zWnJ3k*c}_U9gWG_=Dgy;)y-6cjo9^RBrfmo^EXD%$~5(S(RteGjdSl~$MfCJ)y$p3 zxZlh?1Ia;^!_hKFbWT0fwkI8W*}wE>UwOrpA;jb__UAFoY{gV(?q>JEJ=trGkISmc z-)zsIqJ#9S@#1VxWDyd}K!p;+UrduDc=C7CbOT8;Dic&ybDFm2Tg~=F?@frQ1nVMq zIZpbGZD4@_1Rwwb2tWV=5P$##AOHafK;SfB*y_009U<00Izz00bcLKNNT;DjdDe zZll$-vZwB5(pkmS>h+PS7OJvCf0PyB$uM7r=2OkQ^StD?`ct&n9G7tBPEbM^E0IoT zv#jRPj5Rlym(eegiU`GF8;Y`**=Hrj=V|q`zRe5zAgi*U&SoB(M$7RXtC8#2bgD@| zn@A%Xl{8NJUHVD-Ub>{4SReoa2tWV=5P$##AOHafKmY;|m=t(ZIN}#R9f;l+o(yv= zelD;wo_FCB0pTs7IIMThvw+AwL0=0GZ`M(2tWV=5P$## zAOHafKmY=v0OtRP5?Dh30uX=z1Rwwb2tWV=5P$##mRA7p|CcwmI5P-900Izz00bZa O0SG_<0uX>eDDXGN6Rhh1 literal 0 HcmV?d00001 diff --git a/tests/Modulus.Core.Tests/ModuleManagerTests.cs b/tests/Modulus.Core.Tests/ModuleManagerTests.cs index 6859ebb..97e97b5 100644 --- a/tests/Modulus.Core.Tests/ModuleManagerTests.cs +++ b/tests/Modulus.Core.Tests/ModuleManagerTests.cs @@ -79,17 +79,17 @@ public void GetSortedModules_MissingDependency_Throws() } // Test module classes - private class TestModule : ModuleBase { } - private class TestModule2 : ModuleBase { } + private class TestModule : ModulusComponent { } + private class TestModule2 : ModulusComponent { } - private class TestModuleA : ModuleBase { } + private class TestModuleA : ModulusComponent { } [DependsOn(typeof(TestModuleA))] - private class TestModuleB : ModuleBase { } + private class TestModuleB : ModulusComponent { } [DependsOn(typeof(ExternalDependency))] - private class ModuleWithMissingDependency : ModuleBase { } + private class ModuleWithMissingDependency : ModulusComponent { } - private class ExternalDependency : ModuleBase { } + private class ExternalDependency : ModulusComponent { } } diff --git a/tests/Modulus.Hosts.Tests/ModulusApplicationIntegrationTests.cs b/tests/Modulus.Hosts.Tests/ModulusApplicationIntegrationTests.cs index d7bca9b..dbeef7e 100644 --- a/tests/Modulus.Hosts.Tests/ModulusApplicationIntegrationTests.cs +++ b/tests/Modulus.Hosts.Tests/ModulusApplicationIntegrationTests.cs @@ -51,7 +51,7 @@ public async Task CreateAsync_WithModuleProvider_LoadsModules() var modulePath = CreateTestModule("integration-test-module", "1.0.0"); var providers = new List { - new DirectoryModuleProvider(_testRoot, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, isSystem: false) + new DirectoryModuleProvider(_testRoot, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, isSystem: true) }; // Act @@ -110,7 +110,7 @@ public async Task ModuleLoader_EnableDisableReload_WorksCorrectly() var modulePath = CreateTestModule("reload-test-module", "1.0.0"); var providers = new List { - new DirectoryModuleProvider(_testRoot, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, isSystem: false) + new DirectoryModuleProvider(_testRoot, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, isSystem: true) }; var app = await ModulusApplicationFactory.CreateAsync(services, providers, HostType.Avalonia); @@ -159,7 +159,7 @@ private string CreateTestModule(string id, string version) } // Test host module - private class TestHostModule : ModuleBase + private class TestHostModule : ModulusComponent { public override void ConfigureServices(IModuleLifecycleContext context) {