Skip to content

Commit 70cb8f1

Browse files
committed
feat: improve runtime lifecycle and title bar double-click
1 parent f00fa84 commit 70cb8f1

File tree

14 files changed

+417
-44
lines changed

14 files changed

+417
-44
lines changed
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
## 1. Implementation
2-
- [ ] 1.1 Implement module-scoped DI + lifecycle execution on runtime load
3-
- [ ] 1.2 Add unload cleanup (shutdown hooks, menu/view/service deregistration, dispose providers)
4-
- [ ] 1.3 Strengthen manifest validation (fields, host match, semver deps, hashes/signature)
5-
- [ ] 1.4 Build unified dependency graph (manifest deps + DependsOn) with cycle/missing detection
6-
- [ ] 1.5 Drive shared assembly allowlist from assembly domain metadata with diagnostics
2+
- [x] 1.1 Implement module-scoped DI + lifecycle execution on runtime load
3+
- [x] 1.2 Add unload cleanup (shutdown hooks, menu/view/service deregistration, dispose providers)
4+
- [x] 1.3 Strengthen manifest validation (fields, host match, semver deps, hashes/signature)
5+
- [x] 1.4 Build unified dependency graph (manifest deps + DependsOn) with cycle/missing detection
6+
- [x] 1.5 Drive shared assembly allowlist from assembly domain metadata with diagnostics
77

88

src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaNavigationService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ private async Task<bool> EvaluateGuardsAsync(NavigationContext context)
298298
// Fall back to ActivatorUtilities
299299
return ActivatorUtilities.CreateInstance(_serviceProvider, vmType);
300300
}
301-
catch (Exception ex)
301+
catch (Exception)
302302
{
303303
return null;
304304
}

src/Modulus.Core/Architecture/AssemblyDomainInfo.cs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,6 @@ namespace Modulus.Core.Architecture;
99
/// </summary>
1010
public static class AssemblyDomainInfo
1111
{
12-
private static readonly HashSet<string> KnownSharedAssemblies = new(StringComparer.OrdinalIgnoreCase)
13-
{
14-
"Modulus.Core",
15-
"Modulus.Sdk",
16-
"Modulus.UI.Abstractions",
17-
"Modulus.UI.Avalonia",
18-
"Modulus.UI.Blazor"
19-
};
20-
2112
/// <summary>
2213
/// Gets the domain type of the specified assembly.
2314
/// First checks for [AssemblyDomain] attribute, then falls back to known assemblies list.
@@ -33,13 +24,6 @@ public static AssemblyDomainType GetDomainType(Assembly assembly)
3324
return attr.DomainType;
3425
}
3526

36-
// Fallback to known assemblies
37-
var assemblyName = assembly.GetName().Name;
38-
if (assemblyName != null && KnownSharedAssemblies.Contains(assemblyName))
39-
{
40-
return AssemblyDomainType.Shared;
41-
}
42-
4327
// Check if loaded in default context (likely shared) or isolated context (module)
4428
var context = AssemblyLoadContext.GetLoadContext(assembly);
4529
if (context == AssemblyLoadContext.Default)

src/Modulus.Core/Manifest/DefaultManifestValidator.cs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,24 @@ public async Task<bool> ValidateAsync(string packagePath, string manifestPath, M
3535
errors.Add("Manifest is missing required field 'id'.");
3636
}
3737

38+
if (string.IsNullOrWhiteSpace(manifest.DisplayName))
39+
{
40+
errors.Add("Manifest is missing required field 'displayName'.");
41+
}
42+
3843
if (string.IsNullOrWhiteSpace(manifest.Version) || !NuGetVersion.TryParse(manifest.Version, out _))
3944
{
4045
errors.Add($"Manifest version '{manifest.Version}' is not a valid semantic version.");
4146
}
4247

43-
if (manifest.CoreAssemblies == null || manifest.UiAssemblies == null)
48+
if (manifest.CoreAssemblies == null || manifest.CoreAssemblies.Count == 0)
49+
{
50+
errors.Add("Manifest must include at least one core assembly in 'coreAssemblies'.");
51+
}
52+
53+
if (manifest.UiAssemblies == null)
4454
{
45-
errors.Add("Manifest must include coreAssemblies and uiAssemblies.");
55+
errors.Add("Manifest must include uiAssemblies object (may be empty per host).");
4656
}
4757

4858
if (hostType != null)
@@ -52,14 +62,30 @@ public async Task<bool> ValidateAsync(string packagePath, string manifestPath, M
5262
errors.Add($"Host '{hostType}' is not supported by this module.");
5363
}
5464

55-
if (manifest.UiAssemblies != null && manifest.UiAssemblies.Count > 0 && !manifest.UiAssemblies.ContainsKey(hostType))
65+
if (manifest.UiAssemblies == null || !manifest.UiAssemblies.TryGetValue(hostType, out var hostAssemblies) || hostAssemblies == null || hostAssemblies.Count == 0)
5666
{
57-
errors.Add($"Manifest does not declare UI assemblies for host '{hostType}'.");
67+
errors.Add($"Manifest declares host '{hostType}' but no UI assemblies are provided.");
5868
}
5969
}
6070

71+
if (manifest.SupportedHosts == null || !manifest.SupportedHosts.Any())
72+
{
73+
errors.Add("Manifest must declare at least one supported host in 'supportedHosts'.");
74+
}
75+
6176
foreach (var (dependencyId, dependencyRange) in manifest.Dependencies)
6277
{
78+
if (string.IsNullOrWhiteSpace(dependencyId))
79+
{
80+
errors.Add("Dependency id cannot be empty.");
81+
continue;
82+
}
83+
84+
if (string.Equals(dependencyId, manifest.Id, StringComparison.OrdinalIgnoreCase))
85+
{
86+
errors.Add("Module cannot depend on itself.");
87+
}
88+
6389
if (!VersionRange.TryParse(dependencyRange, out _))
6490
{
6591
errors.Add($"Dependency '{dependencyId}' has invalid version range '{dependencyRange}'.");

src/Modulus.Core/Modulus.Core.csproj

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@
1515
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
1616
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
1717
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
18-
<PackageReference Include="NuGet.Versioning" Version="6.9.1" />
19-
<PackageReference Include="Serilog" Version="4.1.0" />
20-
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
21-
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
22-
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
23-
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
18+
<PackageReference Include="NuGet.Versioning" Version="7.0.1" />
19+
<PackageReference Include="Serilog" Version="4.3.0" />
20+
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
21+
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
22+
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
23+
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
2424
</ItemGroup>
2525

2626
<ItemGroup>

src/Modulus.Core/Runtime/ModuleLoader.cs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,22 @@ public async Task InitializeLoadedModulesAsync(CancellationToken cancellationTok
7474

7575
_logger.LogInformation("Found {Count} module handles to initialize.", _runtimeContext.ModuleHandles.Count);
7676

77-
foreach (var handle in _runtimeContext.ModuleHandles)
77+
IReadOnlyList<RuntimeModuleHandle> sortedHandles;
78+
try
79+
{
80+
sortedHandles = RuntimeDependencyGraph.TopologicallySort(_runtimeContext.ModuleHandles, _logger);
81+
}
82+
catch (Exception ex)
83+
{
84+
_logger.LogError(ex, "Failed to build runtime dependency graph. Module initialization aborted.");
85+
foreach (var handle in _runtimeContext.ModuleHandles)
86+
{
87+
handle.RuntimeModule.State = ModuleState.Error;
88+
}
89+
return;
90+
}
91+
92+
foreach (var handle in sortedHandles)
7893
{
7994
var module = handle.RuntimeModule;
8095
using (_logger.BeginScope(new Dictionary<string, object>
@@ -91,8 +106,8 @@ public async Task InitializeLoadedModulesAsync(CancellationToken cancellationTok
91106
_logger.LogInformation("Initializing pre-loaded module {ModuleName} ({ModuleId}) with {InstanceCount} instances...",
92107
module.Descriptor.DisplayName, module.Descriptor.Id, handle.ModuleInstances.Count);
93108

94-
var compositeProvider = new CompositeServiceProvider(handle.ServiceProvider, _hostServices);
95-
var initContext = new ModuleInitializationContext(compositeProvider);
109+
handle.UpdateCompositeServiceProvider(_hostServices);
110+
var initContext = new ModuleInitializationContext(handle.CompositeServiceProvider);
96111

97112
try
98113
{
@@ -324,7 +339,7 @@ void Dfs(Type t)
324339
module.ConfigureServices(lifecycleContext);
325340
}
326341

327-
foreach (var module in sortedModules)
342+
foreach (var module in sortedModules)
328343
{
329344
module.PostConfigureServices(lifecycleContext);
330345
}

src/Modulus.Core/Runtime/ModulusApplicationFactory.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ public static async Task<ModulusApplication> CreateAsync<TStartupModule>(
3838
}
3939

4040
var effectiveConfig = configuration ?? new ConfigurationBuilder()
41-
.AddEnvironmentVariables()
4241
.Build();
4342

4443
loggerFactory ??= ModulusLogging.CreateLoggerFactory(effectiveConfig, hostType ?? "Host");
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Reflection;
5+
using Microsoft.Extensions.Logging;
6+
using Modulus.Sdk;
7+
using NuGet.Versioning;
8+
9+
namespace Modulus.Core.Runtime;
10+
11+
/// <summary>
12+
/// Builds a unified dependency graph across loaded runtime modules using
13+
/// manifest dependencies (id + version range) and [DependsOn] attributes
14+
/// declared on IModule implementations.
15+
/// </summary>
16+
public static class RuntimeDependencyGraph
17+
{
18+
public static IReadOnlyList<RuntimeModuleHandle> TopologicallySort(
19+
IEnumerable<RuntimeModuleHandle> handles,
20+
ILogger? logger = null)
21+
{
22+
var handleList = handles.ToList();
23+
if (handleList.Count == 0)
24+
{
25+
return Array.Empty<RuntimeModuleHandle>();
26+
}
27+
28+
// Map module instance type -> module id for fast resolution of DependsOn targets.
29+
var typeOwnerMap = BuildTypeOwnerMap(handleList);
30+
31+
var nodes = new List<Node>(handleList.Count);
32+
foreach (var handle in handleList)
33+
{
34+
var moduleId = handle.RuntimeModule.Descriptor.Id;
35+
var deps = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
36+
37+
// 1) Manifest dependency edges (id + version range).
38+
foreach (var (dependencyId, dependencyRange) in handle.Manifest.Dependencies)
39+
{
40+
ValidateDependencyVersion(handle, dependencyId, dependencyRange, handleList, logger);
41+
deps.Add(dependencyId);
42+
}
43+
44+
// 2) [DependsOn] edges between modules.
45+
foreach (var moduleInstance in handle.ModuleInstances)
46+
{
47+
var dependsOnAttrs = moduleInstance.GetType().GetCustomAttributes<DependsOnAttribute>();
48+
foreach (var attr in dependsOnAttrs)
49+
{
50+
foreach (var depType in attr.DependedModuleTypes)
51+
{
52+
if (!TryResolveModuleForType(depType, typeOwnerMap, out var targetModuleId))
53+
{
54+
logger?.LogError("Module {ModuleId} depends on {DependencyType} which is not loaded.", moduleId, depType.FullName ?? depType.Name);
55+
throw new InvalidOperationException($"Missing dependency type '{depType.FullName ?? depType.Name}' for module '{moduleId}'.");
56+
}
57+
deps.Add(targetModuleId);
58+
}
59+
}
60+
}
61+
62+
nodes.Add(new Node(handle, deps));
63+
}
64+
65+
var sorted = ModuleDependencyResolver.TopologicallySort(
66+
nodes,
67+
n => n.Handle.RuntimeModule.Descriptor.Id,
68+
n => n.Dependencies,
69+
logger);
70+
71+
return sorted.Select(n => n.Handle).ToList();
72+
}
73+
74+
private static void ValidateDependencyVersion(
75+
RuntimeModuleHandle sourceHandle,
76+
string dependencyId,
77+
string dependencyRange,
78+
IReadOnlyList<RuntimeModuleHandle> handles,
79+
ILogger? logger)
80+
{
81+
var sourceId = sourceHandle.RuntimeModule.Descriptor.Id;
82+
var targetHandle = handles.FirstOrDefault(h => dependencyId.Equals(h.RuntimeModule.Descriptor.Id, StringComparison.OrdinalIgnoreCase));
83+
if (targetHandle == null)
84+
{
85+
logger?.LogError("Module {ModuleId} declares dependency {DependencyId} which is not loaded.", sourceId, dependencyId);
86+
throw new InvalidOperationException($"Missing dependency '{dependencyId}' for module '{sourceId}'.");
87+
}
88+
89+
if (!NuGetVersion.TryParse(targetHandle.RuntimeModule.Descriptor.Version, out var dependencyVersion))
90+
{
91+
logger?.LogError("Module {ModuleId} dependency {DependencyId} has invalid version {DependencyVersion}.", sourceId, dependencyId, targetHandle.RuntimeModule.Descriptor.Version);
92+
throw new InvalidOperationException($"Dependency '{dependencyId}' version '{targetHandle.RuntimeModule.Descriptor.Version}' is not a valid SemVer.");
93+
}
94+
95+
if (!VersionRange.TryParse(dependencyRange, out var range))
96+
{
97+
logger?.LogError("Module {ModuleId} dependency {DependencyId} has invalid version range {Range}.", sourceId, dependencyId, dependencyRange);
98+
throw new InvalidOperationException($"Dependency '{dependencyId}' range '{dependencyRange}' is not a valid SemVer range.");
99+
}
100+
101+
if (!range.Satisfies(dependencyVersion))
102+
{
103+
logger?.LogError("Module {ModuleId} dependency {DependencyId} version {DependencyVersion} does not satisfy range {Range}.", sourceId, dependencyId, dependencyVersion, dependencyRange);
104+
throw new InvalidOperationException($"Dependency '{dependencyId}' version '{dependencyVersion}' does not satisfy '{dependencyRange}'.");
105+
}
106+
}
107+
108+
private static bool TryResolveModuleForType(
109+
Type dependencyType,
110+
IReadOnlyDictionary<Type, string> typeOwnerMap,
111+
out string moduleId)
112+
{
113+
moduleId = string.Empty;
114+
115+
if (typeOwnerMap.TryGetValue(dependencyType, out var mapped))
116+
{
117+
moduleId = mapped;
118+
return true;
119+
}
120+
121+
// Fallback by FullName match in case types come from different load contexts but share name.
122+
var match = typeOwnerMap.FirstOrDefault(kvp => string.Equals(kvp.Key.FullName, dependencyType.FullName, StringComparison.OrdinalIgnoreCase));
123+
if (!string.IsNullOrEmpty(match.Value))
124+
{
125+
moduleId = match.Value!;
126+
return true;
127+
}
128+
129+
return false;
130+
}
131+
132+
private static Dictionary<Type, string> BuildTypeOwnerMap(IEnumerable<RuntimeModuleHandle> handles)
133+
{
134+
var map = new Dictionary<Type, string>();
135+
foreach (var handle in handles)
136+
{
137+
var moduleId = handle.RuntimeModule.Descriptor.Id;
138+
foreach (var moduleInstance in handle.ModuleInstances)
139+
{
140+
var type = moduleInstance.GetType();
141+
// Last write wins; assemblies should be unique per module but we prefer predictable behavior.
142+
map[type] = moduleId;
143+
}
144+
}
145+
return map;
146+
}
147+
148+
private sealed record Node(RuntimeModuleHandle Handle, HashSet<string> Dependencies);
149+
}
150+

src/Modulus.UI.Avalonia/Behaviors/WindowDragBehavior.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,32 @@ protected override void OnDetaching()
3030

3131
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
3232
{
33-
if (e.GetCurrentPoint(AssociatedObject).Properties.IsLeftButtonPressed)
33+
if (AssociatedObject == null)
3434
{
35-
if (TopLevel.GetTopLevel(AssociatedObject) is Window window)
35+
return;
36+
}
37+
38+
var point = e.GetCurrentPoint(AssociatedObject);
39+
if (!point.Properties.IsLeftButtonPressed)
40+
{
41+
return;
42+
}
43+
44+
if (TopLevel.GetTopLevel(AssociatedObject) is Window window)
45+
{
46+
if (e.ClickCount >= 2)
3647
{
37-
window.BeginMoveDrag(e);
48+
if (window.CanResize)
49+
{
50+
window.WindowState = window.WindowState == WindowState.Maximized
51+
? WindowState.Normal
52+
: WindowState.Maximized;
53+
}
54+
e.Handled = true;
55+
return;
3856
}
57+
58+
window.BeginMoveDrag(e);
3959
}
4060
}
4161
}

0 commit comments

Comments
 (0)