Skip to content

Commit 21391f9

Browse files
committed
feat: implement bundled modules with runtime seeding
- Add Nuke build target to generate bundled-modules.json from vsixmanifest - Add IHostDataSeeder interface for host-specific data seeding - Add BundledModuleSeeder base class for loading modules from embedded JSON - Add ILazyModuleLoader for on-demand module loading during navigation - Align ModuleEntity fields with vsixmanifest schema (DisplayName, Publisher, etc.) - Fix Blazor Host startup: resolve UI sync context deadlock with Task.Run - Fix Blazor Host exit: ensure process terminates when window closes - Add Blazor ErrorBoundary for component-level exception handling - Move global exception handlers to App.xaml.cs - Fix module disable/enable functionality - Unify log directory naming - Remove redundant try-catch blocks
1 parent cb0a76e commit 21391f9

39 files changed

+2057
-196
lines changed

.nuke/build.schema.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"CleanPluginsArtifacts",
3333
"Compile",
3434
"Default",
35+
"GenerateBundledModules",
3536
"Pack",
3637
"PackCli",
3738
"PackModule",

build/BuildTasks.cs

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
using System.Linq;
44
using System.Collections.Generic;
55
using System.Text.Json;
6+
using System.Text.Json.Serialization;
7+
using System.Xml.Linq;
68
using Serilog;
79
using Serilog.Sinks.SystemConsole.Themes;
810
using Nuke.Common;
@@ -181,11 +183,175 @@ private static void LogHeader(string message)
181183
// Application Build Targets
182184
// ============================================================
183185

186+
/// <summary>
187+
/// Generate bundled-modules.json from extension.vsixmanifest files.
188+
/// This target scans all modules with IsBundled="true" and generates a JSON manifest
189+
/// that will be embedded in host applications for runtime seeding.
190+
/// </summary>
191+
Target GenerateBundledModules => _ => _
192+
.DependsOn(Restore)
193+
.Description("Generate bundled-modules.json from extension.vsixmanifest files")
194+
.Executes(() =>
195+
{
196+
LogHeader("Generating Bundled Modules Manifest");
197+
198+
var vsixNs = XNamespace.Get("http://schemas.microsoft.com/developer/vsx-schema/2011");
199+
var modules = new List<BundledModuleJsonDto>();
200+
201+
// Scan all modules
202+
var moduleDirectories = Directory.GetDirectories(ModulesDirectory);
203+
foreach (var moduleDir in moduleDirectories)
204+
{
205+
var manifestPath = Path.Combine(moduleDir, "extension.vsixmanifest");
206+
if (!File.Exists(manifestPath))
207+
{
208+
LogWarning($"No extension.vsixmanifest in {Path.GetFileName(moduleDir)}, skipping");
209+
continue;
210+
}
211+
212+
try
213+
{
214+
var doc = XDocument.Load(manifestPath);
215+
var root = doc.Root;
216+
if (root == null) continue;
217+
218+
// Parse Identity
219+
var metadata = root.Element(vsixNs + "Metadata") ?? root.Element("Metadata");
220+
var identity = metadata?.Element(vsixNs + "Identity") ?? metadata?.Element("Identity");
221+
222+
if (identity == null)
223+
{
224+
LogWarning($"No Identity element in {Path.GetFileName(moduleDir)}/extension.vsixmanifest");
225+
continue;
226+
}
227+
228+
// Check IsBundled attribute
229+
var isBundled = (string?)identity.Attribute("IsBundled");
230+
if (!string.Equals(isBundled, "true", StringComparison.OrdinalIgnoreCase))
231+
{
232+
LogNormal($"Module {Path.GetFileName(moduleDir)} is not bundled, skipping");
233+
continue;
234+
}
235+
236+
var moduleName = Path.GetFileName(moduleDir);
237+
LogHighlight($"Processing bundled module: {moduleName}");
238+
239+
// Parse module info
240+
var module = new BundledModuleJsonDto
241+
{
242+
Id = (string?)identity.Attribute("Id") ?? moduleName,
243+
Version = (string?)identity.Attribute("Version") ?? "1.0.0",
244+
Language = (string?)identity.Attribute("Language"),
245+
Publisher = (string?)identity.Attribute("Publisher"),
246+
DisplayName = metadata?.Element(vsixNs + "DisplayName")?.Value
247+
?? metadata?.Element("DisplayName")?.Value
248+
?? moduleName,
249+
Description = metadata?.Element(vsixNs + "Description")?.Value
250+
?? metadata?.Element("Description")?.Value,
251+
Tags = metadata?.Element(vsixNs + "Tags")?.Value
252+
?? metadata?.Element("Tags")?.Value,
253+
Website = metadata?.Element(vsixNs + "Website")?.Value
254+
?? metadata?.Element("Website")?.Value,
255+
Path = $"Modules/{moduleName}",
256+
IsBundled = true
257+
};
258+
259+
// Parse Installation targets
260+
var installation = root.Element(vsixNs + "Installation") ?? root.Element("Installation");
261+
if (installation != null)
262+
{
263+
var targets = installation.Elements(vsixNs + "InstallationTarget")
264+
.Concat(installation.Elements("InstallationTarget"));
265+
foreach (var target in targets)
266+
{
267+
var hostId = (string?)target.Attribute("Id");
268+
if (!string.IsNullOrEmpty(hostId))
269+
module.SupportedHosts.Add(hostId);
270+
}
271+
}
272+
273+
// Parse Assets (menus)
274+
var assets = root.Element(vsixNs + "Assets") ?? root.Element("Assets");
275+
if (assets != null)
276+
{
277+
var menuAssets = assets.Elements(vsixNs + "Asset")
278+
.Concat(assets.Elements("Asset"))
279+
.Where(a => (string?)a.Attribute("Type") == "Modulus.Menu");
280+
281+
foreach (var menuAsset in menuAssets)
282+
{
283+
var targetHost = (string?)menuAsset.Attribute("TargetHost") ?? "Modulus.Host.Avalonia";
284+
var menu = new MenuJsonDto
285+
{
286+
Id = (string?)menuAsset.Attribute("Id") ?? "",
287+
DisplayName = (string?)menuAsset.Attribute("DisplayName") ?? "",
288+
Icon = (string?)menuAsset.Attribute("Icon") ?? "Folder",
289+
Route = (string?)menuAsset.Attribute("Route") ?? "",
290+
Location = (string?)menuAsset.Attribute("Location") ?? "Main",
291+
Order = int.TryParse((string?)menuAsset.Attribute("Order"), out var order) ? order : 0
292+
};
293+
294+
if (!module.Menus.ContainsKey(targetHost))
295+
module.Menus[targetHost] = new List<MenuJsonDto>();
296+
297+
module.Menus[targetHost].Add(menu);
298+
}
299+
}
300+
301+
modules.Add(module);
302+
LogSuccess($"Added bundled module: {module.DisplayName} ({module.Id})");
303+
}
304+
catch (Exception ex)
305+
{
306+
LogError($"Failed to parse {Path.GetFileName(moduleDir)}/extension.vsixmanifest: {ex.Message}");
307+
}
308+
}
309+
310+
// Generate JSON manifest
311+
var manifest = new BundledModulesManifestDto
312+
{
313+
GeneratedAt = DateTime.UtcNow,
314+
Configuration = Configuration.ToString(),
315+
Modules = modules
316+
};
317+
318+
var jsonOptions = new JsonSerializerOptions
319+
{
320+
WriteIndented = true,
321+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
322+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
323+
};
324+
325+
var json = JsonSerializer.Serialize(manifest, jsonOptions);
326+
327+
// Output to host projects' Resources directories
328+
var hostProjects = new[]
329+
{
330+
RootDirectory / "src" / "Hosts" / "Modulus.Host.Avalonia" / "Resources",
331+
RootDirectory / "src" / "Hosts" / "Modulus.Host.Blazor" / "Resources"
332+
};
333+
334+
foreach (var resourceDir in hostProjects)
335+
{
336+
Directory.CreateDirectory(resourceDir);
337+
var outputPath = resourceDir / "bundled-modules.json";
338+
File.WriteAllText(outputPath, json);
339+
LogSuccess($"Generated: {outputPath}");
340+
}
341+
342+
LogHeader("BUNDLED MODULES SUMMARY");
343+
LogNormal($"Total bundled modules: {modules.Count}");
344+
foreach (var module in modules)
345+
{
346+
LogSuccess($" ✓ {module.DisplayName} ({module.Id}) - {module.Menus.Count} host(s)");
347+
}
348+
});
349+
184350
/// <summary>
185351
/// Just compile the solution (default bin/Debug output)
186352
/// </summary>
187353
Target Compile => _ => _
188-
.DependsOn(Restore)
354+
.DependsOn(Restore, GenerateBundledModules)
189355
.Description("Compile the solution")
190356
.Executes(() =>
191357
{
@@ -837,4 +1003,41 @@ private bool PackSinglePlugin(string pluginName)
8371003
return false;
8381004
}
8391005
}
1006+
1007+
// ============================================================
1008+
// DTO classes for bundled-modules.json generation
1009+
// ============================================================
1010+
1011+
class BundledModulesManifestDto
1012+
{
1013+
public DateTime GeneratedAt { get; set; }
1014+
public string? Configuration { get; set; }
1015+
public List<BundledModuleJsonDto> Modules { get; set; } = new();
1016+
}
1017+
1018+
class BundledModuleJsonDto
1019+
{
1020+
public string Id { get; set; } = "";
1021+
public string Version { get; set; } = "1.0.0";
1022+
public string? Language { get; set; }
1023+
public string? Publisher { get; set; }
1024+
public string DisplayName { get; set; } = "";
1025+
public string? Description { get; set; }
1026+
public string? Tags { get; set; }
1027+
public string? Website { get; set; }
1028+
public List<string> SupportedHosts { get; set; } = new();
1029+
public string Path { get; set; } = "";
1030+
public bool IsBundled { get; set; } = true;
1031+
public Dictionary<string, List<MenuJsonDto>> Menus { get; set; } = new();
1032+
}
1033+
1034+
class MenuJsonDto
1035+
{
1036+
public string Id { get; set; } = "";
1037+
public string DisplayName { get; set; } = "";
1038+
public string Icon { get; set; } = "Folder";
1039+
public string Route { get; set; } = "";
1040+
public string Location { get; set; } = "Main";
1041+
public int Order { get; set; }
1042+
}
8401043
}

src/Hosts/Modulus.Host.Avalonia/App.axaml.cs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ public override void OnFrameworkInitializationCompleted()
131131
services.AddScoped<IModuleInstallerService, ModuleInstallerService>();
132132
services.AddScoped<SystemModuleInstaller>();
133133
services.AddScoped<ModuleIntegrityChecker>();
134-
services.AddScoped<HostModuleSeeder>();
134+
services.AddScoped<IHostDataSeeder, AvaloniaHostDataSeeder>();
135+
services.AddSingleton<ILazyModuleLoader, LazyModuleLoader>();
135136

136137
// Get host version from assembly
137138
var hostVersion = typeof(App).Assembly.GetName().Version ?? new Version(1, 0, 0);
@@ -155,15 +156,11 @@ await ModulusApplicationFactory.CreateAsync<AvaloniaHostModule>(services, module
155156
var database = Services.GetRequiredService<IAppDatabase>();
156157
database.InitializeAsync().GetAwaiter().GetResult();
157158

158-
// Seed Host module and menus to database (full database-driven approach)
159+
// Seed Host module and bundled modules to database (from bundled-modules.json)
159160
using (var scope = Services.CreateScope())
160161
{
161-
var hostSeeder = scope.ServiceProvider.GetRequiredService<HostModuleSeeder>();
162-
hostSeeder.SeedAsync(
163-
ModulusHostIds.Avalonia,
164-
typeof(ModuleListViewModel).FullName!,
165-
typeof(SettingsViewModel).FullName!
166-
).GetAwaiter().GetResult();
162+
var hostSeeder = scope.ServiceProvider.GetRequiredService<IHostDataSeeder>();
163+
hostSeeder.SeedAsync().GetAwaiter().GetResult();
167164
}
168165

169166
// Initialize Theme Service (load saved theme)

src/Hosts/Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,9 @@
6666
<None Update="appsettings.json" CopyToOutputDirectory="Always" />
6767
<None Update="appsettings.Development.json" CopyToOutputDirectory="Always" />
6868
</ItemGroup>
69+
70+
<!-- Bundled modules manifest (generated by Nuke build) -->
71+
<ItemGroup>
72+
<EmbeddedResource Include="Resources\bundled-modules.json" Condition="Exists('Resources\bundled-modules.json')" />
73+
</ItemGroup>
6974
</Project>

src/Hosts/Modulus.Host.Avalonia/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.Extensions.Configuration;
44
using Microsoft.Extensions.Logging;
55
using Modulus.Core.Logging;
6+
using Modulus.Sdk;
67
using System;
78
using System.Threading.Tasks;
89

@@ -20,7 +21,7 @@ public static void Main(string[] args)
2021
{
2122
// Initialize logger first - Serilog doesn't depend on Avalonia
2223
var emptyConfig = new ConfigurationBuilder().Build();
23-
var loggerFactory = ModulusLogging.CreateLoggerFactory(emptyConfig, "AvaloniaApp");
24+
var loggerFactory = ModulusLogging.CreateLoggerFactory(emptyConfig, ModulusHostIds.Avalonia);
2425
_logger = loggerFactory.CreateLogger<Program>();
2526

2627
// Setup global exception handlers (logger is now ready)

0 commit comments

Comments
 (0)