diff --git a/Oqtane.Client/AssemblyInfo.cs b/Oqtane.Client/AssemblyInfo.cs new file mode 100644 index 00000000..d598bfb9 --- /dev/null +++ b/Oqtane.Client/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Microsoft.Extensions.Localization; + +[assembly: RootNamespace("Oqtane")] diff --git a/Oqtane.Client/Oqtane.Client.csproj b/Oqtane.Client/Oqtane.Client.csproj index bff51215..515bf8f5 100644 --- a/Oqtane.Client/Oqtane.Client.csproj +++ b/Oqtane.Client/Oqtane.Client.csproj @@ -32,6 +32,7 @@ + diff --git a/Oqtane.Client/Program.cs b/Oqtane.Client/Program.cs index 8a1fd2b8..912720d9 100644 --- a/Oqtane.Client/Program.cs +++ b/Oqtane.Client/Program.cs @@ -1,19 +1,18 @@ -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using Microsoft.Extensions.DependencyInjection; -using System.Threading.Tasks; -using Oqtane.Services; -using System.Reflection; -using System; +using System; using System.Collections.Generic; +using System.IO; +using System.IO.Compression; using System.Linq; using System.Net.Http; -using System.Net.Http.Json; -using Oqtane.Modules; -using Oqtane.Shared; -using Oqtane.Providers; +using System.Reflection; +using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Authorization; -using System.IO.Compression; -using System.IO; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Oqtane.Modules; +using Oqtane.Providers; +using Oqtane.Shared; +using Oqtane.Services; namespace Oqtane.Client { @@ -28,6 +27,9 @@ namespace Oqtane.Client builder.Services.AddSingleton(httpClient); builder.Services.AddOptions(); + // Register localization services + builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); + // register auth services builder.Services.AddAuthorizationCore(); builder.Services.AddScoped(); @@ -101,8 +103,8 @@ namespace Oqtane.Client // asemblies and debug symbols are packaged in a zip file using (ZipArchive archive = new ZipArchive(new MemoryStream(zip))) { - Dictionary dlls = new Dictionary(); - Dictionary pdbs = new Dictionary(); + var dlls = new Dictionary(); + var pdbs = new Dictionary(); foreach (ZipArchiveEntry entry in archive.Entries) { @@ -115,7 +117,15 @@ namespace Oqtane.Client switch (Path.GetExtension(entry.Name)) { case ".dll": - dlls.Add(entry.Name, file); + // Loads the stallite assemblies early + if (entry.Name.EndsWith(Constants.StalliteAssemblyExtension)) + { + Assembly.Load(file); + } + else + { + dlls.Add(entry.Name, file); + } break; case ".pdb": pdbs.Add(entry.Name, file); diff --git a/Oqtane.Server/Controllers/InstallationController.cs b/Oqtane.Server/Controllers/InstallationController.cs index 50b45710..d3529a98 100644 --- a/Oqtane.Server/Controllers/InstallationController.cs +++ b/Oqtane.Server/Controllers/InstallationController.cs @@ -1,17 +1,16 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Oqtane.Models; -using Oqtane.Shared; -using Oqtane.Infrastructure; -using System; +using System; using System.IO; using System.Reflection; using System.Linq; using System.IO.Compression; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Oqtane.Infrastructure; +using Oqtane.Models; using Oqtane.Modules; +using Oqtane.Shared; using Oqtane.Themes; -using System.Diagnostics; namespace Oqtane.Controllers { @@ -21,12 +20,14 @@ namespace Oqtane.Controllers private readonly IConfigurationRoot _config; private readonly IInstallationManager _installationManager; private readonly IDatabaseManager _databaseManager; + private readonly ILocalizationManager _localizationManager; - public InstallationController(IConfigurationRoot config, IInstallationManager installationManager, IDatabaseManager databaseManager) + public InstallationController(IConfigurationRoot config, IInstallationManager installationManager, IDatabaseManager databaseManager, ILocalizationManager localizationManager) { _config = config; _installationManager = installationManager; _databaseManager = databaseManager; + _localizationManager = localizationManager; } // POST api/ @@ -73,6 +74,21 @@ namespace Oqtane.Controllers // get list of assemblies which should be downloaded to browser var assemblies = AppDomain.CurrentDomain.GetOqtaneClientAssemblies(); var list = assemblies.Select(a => a.GetName().Name).ToList(); + var binFolder = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); + + // Get the satellite assemblies + foreach (var culture in _localizationManager.GetSupportedCultures()) + { + if (culture == Constants.DefaultCulture) + { + continue; + } + + foreach (var resourceFile in Directory.EnumerateFiles(Path.Combine(binFolder, culture))) + { + list.Add(Path.Combine(culture, Path.GetFileNameWithoutExtension(resourceFile))); + } + } // get module and theme dependencies foreach (var assembly in assemblies) @@ -96,7 +112,6 @@ namespace Oqtane.Controllers } // create zip file containing assemblies and debug symbols - string binfolder = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); byte[] zipfile; using (var memoryStream = new MemoryStream()) { @@ -106,17 +121,17 @@ namespace Oqtane.Controllers foreach (string file in list) { entry = archive.CreateEntry(file + ".dll"); - using (var filestream = new FileStream(Path.Combine(binfolder, file + ".dll"), FileMode.Open, FileAccess.Read)) + using (var filestream = new FileStream(Path.Combine(binFolder, file + ".dll"), FileMode.Open, FileAccess.Read)) using (var entrystream = entry.Open()) { filestream.CopyTo(entrystream); } // include debug symbols ( we may want to consider restricting this to only host users or when running in debug mode for performance ) - if (System.IO.File.Exists(Path.Combine(binfolder, file + ".pdb"))) + if (System.IO.File.Exists(Path.Combine(binFolder, file + ".pdb"))) { entry = archive.CreateEntry(file + ".pdb"); - using (var filestream = new FileStream(Path.Combine(binfolder, file + ".pdb"), FileMode.Open, FileAccess.Read)) + using (var filestream = new FileStream(Path.Combine(binFolder, file + ".pdb"), FileMode.Open, FileAccess.Read)) using (var entrystream = entry.Open()) { filestream.CopyTo(entrystream); diff --git a/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs b/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs index d1c77301..67676d16 100644 --- a/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs +++ b/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs @@ -1,8 +1,10 @@ using System; +using System.Globalization; using System.Linq; using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; using Oqtane.Infrastructure; namespace Oqtane.Extensions @@ -22,5 +24,22 @@ namespace Oqtane.Extensions return app; } + + public static IApplicationBuilder UseOqtaneLocalization(this IApplicationBuilder app) + { + var localizationManager = app.ApplicationServices.GetService(); + var defaultCulture = localizationManager.GetDefaultCulture(); + var supportedCultures = localizationManager.GetSupportedCultures(); + + CultureInfo.CurrentUICulture = new CultureInfo(defaultCulture); + + app.UseRequestLocalization(options => { + options.SetDefaultCulture(defaultCulture) + .AddSupportedUICultures(supportedCultures) + .AddSupportedUICultures(supportedCultures); + }); + + return app; + } } } diff --git a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs index f49d189a..4090be8d 100644 --- a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs +++ b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Hosting; using Oqtane.Infrastructure; using Oqtane.Modules; using Oqtane.Services; +using Oqtane.Shared; using Oqtane.UI; // ReSharper disable once CheckNamespace @@ -14,10 +15,12 @@ namespace Microsoft.Extensions.DependencyInjection { public static class OqtaneServiceCollectionExtensions { - public static IServiceCollection AddOqtaneParts(this IServiceCollection services, Runtime runtime) + public static IServiceCollection AddOqtane(this IServiceCollection services, Runtime runtime) { LoadAssemblies(); + LoadSatelliteAssemblies(); services.AddOqtaneServices(runtime); + return services; } @@ -119,6 +122,55 @@ namespace Microsoft.Extensions.DependencyInjection } } + private static void LoadSatelliteAssemblies() + { + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + var assemblyPath = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location); + if (assemblyPath == null) + { + return; + } + + AssemblyLoadContext.Default.Resolving += ResolveDependencies; + + using (var serviceScope = ServiceActivator.GetScope()) + { + var localizationManager = serviceScope.ServiceProvider.GetService(); + foreach (var culture in localizationManager.GetSupportedCultures()) + { + if (culture == Constants.DefaultCulture) + { + continue; + } + + var assembliesFolder = new DirectoryInfo(Path.Combine(assemblyPath, culture)); + foreach (var assemblyFile in assembliesFolder.EnumerateFiles(Constants.StalliteAssemblyExtension)) + { + AssemblyName assemblyName; + try + { + assemblyName = AssemblyName.GetAssemblyName(assemblyFile.FullName); + } + catch + { + Console.WriteLine($"Not Satellite Assembly : {assemblyFile.Name}"); + continue; + } + + try + { + Assembly assembly = AssemblyLoadContext.Default.LoadFromStream(new MemoryStream(File.ReadAllBytes(assemblyFile.FullName))); + Console.WriteLine($"Loaded : {assemblyName}"); + } + catch (Exception e) + { + Console.WriteLine($"Failed : {assemblyName}\n{e}"); + } + } + } + } + } + private static Assembly ResolveDependencies(AssemblyLoadContext context, AssemblyName name) { var assemblyPath = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location) + "\\" + name.Name + ".dll"; diff --git a/Oqtane.Server/Infrastructure/Interfaces/ILocalizationManager.cs b/Oqtane.Server/Infrastructure/Interfaces/ILocalizationManager.cs new file mode 100644 index 00000000..fa20eef4 --- /dev/null +++ b/Oqtane.Server/Infrastructure/Interfaces/ILocalizationManager.cs @@ -0,0 +1,9 @@ +namespace Oqtane.Infrastructure +{ + public interface ILocalizationManager + { + string GetDefaultCulture(); + + string[] GetSupportedCultures(); + } +} diff --git a/Oqtane.Server/Infrastructure/Localization/LocalizationOptions.cs b/Oqtane.Server/Infrastructure/Localization/LocalizationOptions.cs new file mode 100644 index 00000000..6330130e --- /dev/null +++ b/Oqtane.Server/Infrastructure/Localization/LocalizationOptions.cs @@ -0,0 +1,9 @@ +namespace Oqtane.Infrastructure +{ + public class LocalizationOptions + { + public string DefaultCulture { get; set; } + + public string[] SupportedCultures { get; set; } + } +} diff --git a/Oqtane.Server/Infrastructure/LocalizationManager.cs b/Oqtane.Server/Infrastructure/LocalizationManager.cs new file mode 100644 index 00000000..22b4ca79 --- /dev/null +++ b/Oqtane.Server/Infrastructure/LocalizationManager.cs @@ -0,0 +1,29 @@ +using System.Collections; +using Microsoft.Extensions.Options; +using Oqtane.Shared; + +namespace Oqtane.Infrastructure +{ + public class LocalizationManager : ILocalizationManager + { + private static readonly string DefaultCulture = Constants.DefaultCulture; + private static readonly string[] SupportedCultures = new[] { DefaultCulture }; + + private readonly LocalizationOptions _localizationOptions; + + public LocalizationManager(IOptions localizationOptions) + { + _localizationOptions = localizationOptions.Value; + } + + public string GetDefaultCulture() + => string.IsNullOrEmpty(_localizationOptions.DefaultCulture) + ? DefaultCulture + : _localizationOptions.DefaultCulture; + + public string[] GetSupportedCultures() + => _localizationOptions.SupportedCultures.IsNullOrEmpty() + ? SupportedCultures + : _localizationOptions.SupportedCultures; + } +} diff --git a/Oqtane.Server/Oqtane.Server.csproj b/Oqtane.Server/Oqtane.Server.csproj index 44237543..31523442 100644 --- a/Oqtane.Server/Oqtane.Server.csproj +++ b/Oqtane.Server/Oqtane.Server.csproj @@ -43,6 +43,7 @@ + diff --git a/Oqtane.Server/Pages/_Host.cshtml b/Oqtane.Server/Pages/_Host.cshtml index 0fe00957..e4b6e5a7 100644 --- a/Oqtane.Server/Pages/_Host.cshtml +++ b/Oqtane.Server/Pages/_Host.cshtml @@ -1,10 +1,17 @@ @page "/" @namespace Oqtane.Pages @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@using System.Globalization +@using Microsoft.AspNetCore.Localization @using Microsoft.Extensions.Configuration @inject IConfiguration Configuration @model Oqtane.Pages.HostModel +@{ + // Set localization cookie + var localizationCookieValue = CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture)); + HttpContext.Response.Cookies.Append(CookieRequestCultureProvider.DefaultCookieName, localizationCookieValue); +} diff --git a/Oqtane.Server/Startup.cs b/Oqtane.Server/Startup.cs index ee53f340..98d43bcd 100644 --- a/Oqtane.Server/Startup.cs +++ b/Oqtane.Server/Startup.cs @@ -51,6 +51,9 @@ namespace Oqtane // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 public void ConfigureServices(IServiceCollection services) { + // Register localization services + services.AddLocalization(options => options.ResourcesPath = "Resources"); + services.AddServerSideBlazor(); // setup HttpClient for server side in a client side compatible fashion ( with auth cookie ) @@ -125,6 +128,8 @@ namespace Oqtane .AddSignInManager() .AddDefaultTokenProviders(); + services.Configure(Configuration.GetSection("Localization")); + services.Configure(options => { // Password settings @@ -187,6 +192,7 @@ namespace Oqtane services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -196,8 +202,11 @@ namespace Oqtane services.AddTransient(); services.AddTransient(); + // TODO: Check if there's a better way instead of building service provider + ServiceActivator.Configure(services.BuildServiceProvider()); + // load the external assemblies into the app domain, install services - services.AddOqtaneParts(_runtime); + services.AddOqtane(_runtime); services.AddMvc() .AddNewtonsoftJson() @@ -225,6 +234,10 @@ namespace Oqtane } // to allow install middleware it should be moved up app.ConfigureOqtaneAssemblies(env); + + // Allow oqtane localization middleware + app.UseOqtaneLocalization(); + app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseBlazorFrameworkFiles(); diff --git a/Oqtane.Server/appsettings.json b/Oqtane.Server/appsettings.json index 0ab499f0..1af87b4c 100644 --- a/Oqtane.Server/appsettings.json +++ b/Oqtane.Server/appsettings.json @@ -11,5 +11,9 @@ "DefaultTheme": "", "DefaultLayout": "", "DefaultContainer": "" + }, + "Localization": { + "DefaultCulture": "", + "SupportedCultures": [] } } \ No newline at end of file diff --git a/Oqtane.Shared/Extensions/EnumerableExtensions.cs b/Oqtane.Shared/Extensions/EnumerableExtensions.cs new file mode 100644 index 00000000..a4bdde7c --- /dev/null +++ b/Oqtane.Shared/Extensions/EnumerableExtensions.cs @@ -0,0 +1,8 @@ +namespace System.Collections +{ + public static class EnumerableExtensions + { + public static bool IsNullOrEmpty(this IEnumerable source) + => source == null || source.GetEnumerator().MoveNext() == false; + } +} diff --git a/Oqtane.Shared/Shared/Constants.cs b/Oqtane.Shared/Shared/Constants.cs index c237857d..db62f416 100644 --- a/Oqtane.Shared/Shared/Constants.cs +++ b/Oqtane.Shared/Shared/Constants.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; namespace Oqtane.Shared { @@ -57,5 +58,9 @@ namespace Oqtane.Shared (Char) 28, (Char) 29, (Char) 30, (Char) 31, ':', '*', '?', '\\', '/' }; public static readonly string[] InvalidFileNameEndingChars = { ".", " " }; + + public static readonly string StalliteAssemblyExtension = ".resources.dll"; + + public static readonly string DefaultCulture = CultureInfo.InstalledUICulture.Name; } } diff --git a/Oqtane.Shared/Shared/ServiceActivator.cs b/Oqtane.Shared/Shared/ServiceActivator.cs new file mode 100644 index 00000000..5718d174 --- /dev/null +++ b/Oqtane.Shared/Shared/ServiceActivator.cs @@ -0,0 +1,22 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Oqtane.Shared +{ + public static class ServiceActivator + { + private static IServiceProvider _serviceProvider = null; + + public static void Configure(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public static IServiceScope GetScope(IServiceProvider serviceProvider = null) + { + var provider = serviceProvider ?? _serviceProvider; + + return provider?.GetRequiredService().CreateScope(); + } + } +}