Merged PR 9371: Revive support for globalization and localization in Blazor WASM
Revive support for globalization and localization in Blazor WASM * Load icu and timezone data files * Unskip tests Fixes https://github.com/dotnet/aspnetcore/issues/24174 Fixes https://github.com/dotnet/aspnetcore/issues/22975 Fixes https://github.com/dotnet/aspnetcore/issues/23260
This commit is contained in:
parent
524c8cda63
commit
4e57d1b041
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -3,11 +3,11 @@ import { attachDebuggerHotkey, hasDebuggingEnabled } from './MonoDebugger';
|
||||||
import { showErrorNotification } from '../../BootErrors';
|
import { showErrorNotification } from '../../BootErrors';
|
||||||
import { WebAssemblyResourceLoader, LoadingResource } from '../WebAssemblyResourceLoader';
|
import { WebAssemblyResourceLoader, LoadingResource } from '../WebAssemblyResourceLoader';
|
||||||
import { Platform, System_Array, Pointer, System_Object, System_String, HeapLock } from '../Platform';
|
import { Platform, System_Array, Pointer, System_Object, System_String, HeapLock } from '../Platform';
|
||||||
import { loadTimezoneData } from './TimezoneDataFile';
|
|
||||||
import { WebAssemblyBootResourceType } from '../WebAssemblyStartOptions';
|
import { WebAssemblyBootResourceType } from '../WebAssemblyStartOptions';
|
||||||
|
|
||||||
let mono_wasm_add_assembly: (name: string, heapAddress: number, length: number) => void;
|
let mono_wasm_add_assembly: (name: string, heapAddress: number, length: number) => void;
|
||||||
const appBinDirName = 'appBinDir';
|
const appBinDirName = 'appBinDir';
|
||||||
|
const icuDataResourceName = 'icudt.dat';
|
||||||
const uint64HighOrderShift = Math.pow(2, 32);
|
const uint64HighOrderShift = Math.pow(2, 32);
|
||||||
const maxSafeNumberHighPart = Math.pow(2, 21) - 1; // The high-order int32 from Number.MAX_SAFE_INTEGER
|
const maxSafeNumberHighPart = Math.pow(2, 21) - 1; // The high-order int32 from Number.MAX_SAFE_INTEGER
|
||||||
|
|
||||||
|
|
@ -239,14 +239,26 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
|
||||||
/* hash */ resourceLoader.bootConfig.resources.runtime[dotnetWasmResourceName],
|
/* hash */ resourceLoader.bootConfig.resources.runtime[dotnetWasmResourceName],
|
||||||
/* type */ 'dotnetwasm');
|
/* type */ 'dotnetwasm');
|
||||||
|
|
||||||
const dotnetTimeZoneResourceName = 'dotnet.timezones.dat';
|
const dotnetTimeZoneResourceName = 'dotnet.timezones.blat';
|
||||||
let timeZoneResource: LoadingResource | undefined;
|
let timeZoneResource: LoadingResource | undefined;
|
||||||
if (resourceLoader.bootConfig.resources.runtime.hasOwnProperty(dotnetTimeZoneResourceName)) {
|
if (resourceLoader.bootConfig.resources.runtime.hasOwnProperty(dotnetTimeZoneResourceName)) {
|
||||||
timeZoneResource = resourceLoader.loadResource(
|
timeZoneResource = resourceLoader.loadResource(
|
||||||
dotnetTimeZoneResourceName,
|
dotnetTimeZoneResourceName,
|
||||||
`_framework/${dotnetTimeZoneResourceName}`,
|
`_framework/${dotnetTimeZoneResourceName}`,
|
||||||
resourceLoader.bootConfig.resources.runtime[dotnetTimeZoneResourceName],
|
resourceLoader.bootConfig.resources.runtime[dotnetTimeZoneResourceName],
|
||||||
'timezonedata');
|
'globalization');
|
||||||
|
}
|
||||||
|
|
||||||
|
let icuDataResource: LoadingResource | undefined;
|
||||||
|
if (resourceLoader.bootConfig.resources.runtime.hasOwnProperty(icuDataResourceName)) {
|
||||||
|
icuDataResource = resourceLoader.loadResource(
|
||||||
|
icuDataResourceName,
|
||||||
|
`_framework/${icuDataResourceName}`,
|
||||||
|
resourceLoader.bootConfig.resources.runtime[icuDataResourceName],
|
||||||
|
'globalization');
|
||||||
|
} else {
|
||||||
|
// Use invariant culture if the app does not carry icu data.
|
||||||
|
MONO.mono_wasm_setenv("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override the mechanism for fetching the main wasm file so we can connect it to our cache
|
// Override the mechanism for fetching the main wasm file so we can connect it to our cache
|
||||||
|
|
@ -274,6 +286,10 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
|
||||||
loadTimezone(timeZoneResource);
|
loadTimezone(timeZoneResource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (icuDataResource) {
|
||||||
|
loadICUData(icuDataResource);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch the assemblies and PDBs in the background, telling Mono to wait until they are loaded
|
// Fetch the assemblies and PDBs in the background, telling Mono to wait until they are loaded
|
||||||
// Mono requires the assembly filenames to have a '.dll' extension, so supply such names regardless
|
// Mono requires the assembly filenames to have a '.dll' extension, so supply such names regardless
|
||||||
// of the extensions in the URLs. This allows loading assemblies with arbitrary filenames.
|
// of the extensions in the URLs. This allows loading assemblies with arbitrary filenames.
|
||||||
|
|
@ -358,7 +374,11 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
|
||||||
resourceLoader.purgeUnusedCacheEntriesAsync(); // Don't await - it's fine to run in background
|
resourceLoader.purgeUnusedCacheEntriesAsync(); // Don't await - it's fine to run in background
|
||||||
|
|
||||||
MONO.mono_wasm_setenv("MONO_URI_DOTNETRELATIVEORABSOLUTE", "true");
|
MONO.mono_wasm_setenv("MONO_URI_DOTNETRELATIVEORABSOLUTE", "true");
|
||||||
MONO.mono_wasm_setenv("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1");
|
let timeZone = "UTC";
|
||||||
|
try {
|
||||||
|
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
} catch { }
|
||||||
|
MONO.mono_wasm_setenv("TZ", timeZone);
|
||||||
// Turn off full-gc to prevent browser freezing.
|
// Turn off full-gc to prevent browser freezing.
|
||||||
const mono_wasm_enable_on_demand_gc = cwrap('mono_wasm_enable_on_demand_gc', null, ['number']);
|
const mono_wasm_enable_on_demand_gc = cwrap('mono_wasm_enable_on_demand_gc', null, ['number']);
|
||||||
mono_wasm_enable_on_demand_gc(0);
|
mono_wasm_enable_on_demand_gc(0);
|
||||||
|
|
@ -459,8 +479,27 @@ async function loadTimezone(timeZoneResource: LoadingResource) : Promise<void> {
|
||||||
|
|
||||||
const request = await timeZoneResource.response;
|
const request = await timeZoneResource.response;
|
||||||
const arrayBuffer = await request.arrayBuffer();
|
const arrayBuffer = await request.arrayBuffer();
|
||||||
loadTimezoneData(arrayBuffer)
|
|
||||||
|
|
||||||
|
Module['FS_createPath']('/', 'usr', true, true);
|
||||||
|
Module['FS_createPath']('/usr/', 'share', true, true);
|
||||||
|
Module['FS_createPath']('/usr/share/', 'zoneinfo', true, true);
|
||||||
|
MONO.mono_wasm_load_data_archive(new Uint8Array(arrayBuffer), '/usr/share/zoneinfo/');
|
||||||
|
|
||||||
|
removeRunDependency(runDependencyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadICUData(icuDataResource: LoadingResource) : Promise<void> {
|
||||||
|
const runDependencyId = `blazor:icudata`;
|
||||||
|
addRunDependency(runDependencyId);
|
||||||
|
|
||||||
|
const request = await icuDataResource.response;
|
||||||
|
const array = new Uint8Array(await request.arrayBuffer());
|
||||||
|
|
||||||
|
const offset = MONO.mono_wasm_load_bytes_into_heap(array);
|
||||||
|
if (!MONO.mono_wasm_load_icu_data(offset))
|
||||||
|
{
|
||||||
|
throw new Error("Error loading ICU asset.");
|
||||||
|
}
|
||||||
removeRunDependency(runDependencyId);
|
removeRunDependency(runDependencyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ declare interface MONO {
|
||||||
loaded_files: string[];
|
loaded_files: string[];
|
||||||
mono_wasm_runtime_ready (): void;
|
mono_wasm_runtime_ready (): void;
|
||||||
mono_wasm_setenv (name: string, value: string): void;
|
mono_wasm_setenv (name: string, value: string): void;
|
||||||
|
mono_wasm_load_data_archive(data: Uint8Array, prefix: string): void;
|
||||||
|
mono_wasm_load_bytes_into_heap (data: Uint8Array): Pointer;
|
||||||
|
mono_wasm_load_icu_data(heapAddress: Pointer): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mono uses this global to hold low-level interop APIs
|
// Mono uses this global to hold low-level interop APIs
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { readInt32LE } from "../../BinaryDecoder";
|
|
||||||
import { decodeUtf8 } from "../../Utf8Decoder";
|
|
||||||
|
|
||||||
export function loadTimezoneData(arrayBuffer: ArrayBuffer) {
|
|
||||||
let remainingData = new Uint8Array(arrayBuffer);
|
|
||||||
|
|
||||||
// The timezone file is generated by https://github.com/dotnet/blazor/tree/master/src/TimeZoneData.
|
|
||||||
// The file format of the TZ file look like so
|
|
||||||
//
|
|
||||||
// [4 - byte length of manifest]
|
|
||||||
// [json manifest]
|
|
||||||
// [data bytes]
|
|
||||||
//
|
|
||||||
// The json manifest is an array that looks like so:
|
|
||||||
//
|
|
||||||
// [...["America/Fort_Nelson",2249],["America/Glace_Bay",2206]..]
|
|
||||||
//
|
|
||||||
// where the first token in each array is the relative path of the file on disk, and the second is the
|
|
||||||
// length of the file. The starting offset of a file can be calculated using the lengths of all files
|
|
||||||
// that appear prior to it.
|
|
||||||
const manifestSize = readInt32LE(remainingData, 0);
|
|
||||||
remainingData = remainingData.slice(4);
|
|
||||||
const manifestContent = decodeUtf8(remainingData.slice(0, manifestSize));
|
|
||||||
const manifest = JSON.parse(manifestContent) as ManifestEntry[];
|
|
||||||
remainingData = remainingData.slice(manifestSize);
|
|
||||||
|
|
||||||
// Create the folder structure
|
|
||||||
// /zoneinfo
|
|
||||||
// /zoneinfo/Africa
|
|
||||||
// /zoneinfo/Asia
|
|
||||||
// ..
|
|
||||||
Module['FS_createPath']('/', 'zoneinfo', true, true);
|
|
||||||
new Set(manifest.map(m => m[0].split('/')![0])).forEach(folder =>
|
|
||||||
Module['FS_createPath']('/zoneinfo', folder, true, true));
|
|
||||||
|
|
||||||
for (const [name, length] of manifest) {
|
|
||||||
const bytes = remainingData.slice(0, length);
|
|
||||||
Module['FS_createDataFile'](`/zoneinfo/${name}`, null, bytes, true, true, true);
|
|
||||||
remainingData = remainingData.slice(length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ManifestEntry = [string, number];
|
|
||||||
|
|
@ -19,4 +19,4 @@ export interface WebAssemblyStartOptions {
|
||||||
// This type doesn't have to align with anything in BootConfig.
|
// This type doesn't have to align with anything in BootConfig.
|
||||||
// Instead, this represents the public API through which certain aspects
|
// Instead, this represents the public API through which certain aspects
|
||||||
// of boot resource loading can be customized.
|
// of boot resource loading can be customized.
|
||||||
export type WebAssemblyBootResourceType = 'assembly' | 'pdb' | 'dotnetjs' | 'dotnetwasm' | 'timezonedata';
|
export type WebAssemblyBootResourceType = 'assembly' | 'pdb' | 'dotnetjs' | 'dotnetwasm' | 'globalization';
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
|
||||||
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json");
|
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json");
|
||||||
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazor.webassembly.js");
|
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazor.webassembly.js");
|
||||||
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm");
|
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm");
|
||||||
|
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.timezones.blat");
|
||||||
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm.gz");
|
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm.gz");
|
||||||
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", DotNetJsFileName);
|
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", DotNetJsFileName);
|
||||||
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazorwasm-minimal.dll");
|
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazorwasm-minimal.dll");
|
||||||
|
|
@ -169,7 +170,7 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
|
||||||
Assert.Null(bootJsonData.resources.satelliteResources);
|
Assert.Null(bootJsonData.resources.satelliteResources);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/22975")]
|
[Fact]
|
||||||
public async Task Build_WithBlazorEnableTimeZoneSupportDisabled_DoesNotCopyTimeZoneInfo()
|
public async Task Build_WithBlazorEnableTimeZoneSupportDisabled_DoesNotCopyTimeZoneInfo()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
@ -192,10 +193,10 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
|
||||||
|
|
||||||
var runtime = bootJsonData.resources.runtime.Keys;
|
var runtime = bootJsonData.resources.runtime.Keys;
|
||||||
Assert.Contains("dotnet.wasm", runtime);
|
Assert.Contains("dotnet.wasm", runtime);
|
||||||
Assert.DoesNotContain("dotnet.timezones.dat", runtime);
|
Assert.DoesNotContain("dotnet.timezones.blat", runtime);
|
||||||
|
|
||||||
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "wasm", "dotnet.wasm");
|
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm");
|
||||||
Assert.FileDoesNotExist(result, buildOutputDirectory, "wwwroot", "_framework", "wasm", "dotnet.timezones.dat");
|
Assert.FileDoesNotExist(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.timezones.blat");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<configuration>
|
<configuration>
|
||||||
<system.webServer>
|
<system.webServer>
|
||||||
<staticContent>
|
<staticContent>
|
||||||
|
<remove fileExtension=".blat" />
|
||||||
<remove fileExtension=".dat" />
|
<remove fileExtension=".dat" />
|
||||||
<remove fileExtension=".dll" />
|
<remove fileExtension=".dll" />
|
||||||
<remove fileExtension=".json" />
|
<remove fileExtension=".json" />
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,8 @@ Copyright (c) .NET Foundation. All rights reserved.
|
||||||
<!-- Clear out temporary build artifacts that the runtime packages -->
|
<!-- Clear out temporary build artifacts that the runtime packages -->
|
||||||
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)" Condition="'%(ReferenceCopyLocalPaths.Extension)' == '.a'" />
|
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)" Condition="'%(ReferenceCopyLocalPaths.Extension)' == '.a'" />
|
||||||
|
|
||||||
|
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)" Condition="'$(BlazorEnableTimeZoneSupport)' == 'false' AND '%(ReferenceCopyLocalPaths.FileName)' == 'dotnet.timezones'" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
ReferenceCopyLocalPaths includes satellite assemblies from referenced projects but are inexpicably missing
|
ReferenceCopyLocalPaths includes satellite assemblies from referenced projects but are inexpicably missing
|
||||||
any metadata that might allow them to be differentiated. We'll explicitly add those
|
any metadata that might allow them to be differentiated. We'll explicitly add those
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ namespace Microsoft.AspNetCore.Builder
|
||||||
AddMapping(contentTypeProvider, ".pdb", MediaTypeNames.Application.Octet);
|
AddMapping(contentTypeProvider, ".pdb", MediaTypeNames.Application.Octet);
|
||||||
AddMapping(contentTypeProvider, ".br", MediaTypeNames.Application.Octet);
|
AddMapping(contentTypeProvider, ".br", MediaTypeNames.Application.Octet);
|
||||||
AddMapping(contentTypeProvider, ".dat", MediaTypeNames.Application.Octet);
|
AddMapping(contentTypeProvider, ".dat", MediaTypeNames.Application.Octet);
|
||||||
|
AddMapping(contentTypeProvider, ".blat", MediaTypeNames.Application.Octet);
|
||||||
|
|
||||||
options.ContentTypeProvider = contentTypeProvider;
|
options.ContentTypeProvider = contentTypeProvider;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Runtime.Loader;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Components.WebAssembly.Services;
|
using Microsoft.AspNetCore.Components.WebAssembly.Services;
|
||||||
|
|
||||||
|
|
@ -55,7 +57,8 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
|
||||||
|
|
||||||
for (var i = 0; i < assemblies.Length; i++)
|
for (var i = 0; i < assemblies.Length; i++)
|
||||||
{
|
{
|
||||||
Assembly.Load((byte[])assemblies[i]);
|
using var stream = new MemoryStream((byte[])assemblies[i]);
|
||||||
|
AssemblyLoadContext.Default.LoadFromStream(stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -979,8 +979,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
// Modify target to something invalid - the invalid change is reverted
|
// Modify target to something invalid - the invalid change is reverted
|
||||||
// back to the last valid value
|
// back to the last valid value
|
||||||
target.SendKeys(Keys.Control + "a"); // select all
|
target.SendKeys(Keys.Control + "a"); // select all
|
||||||
target.SendKeys("05/06A");
|
target.SendKeys("05/06X");
|
||||||
Browser.Equal("05/06A", () => target.GetAttribute("value"));
|
Browser.Equal("05/06X", () => target.GetAttribute("value"));
|
||||||
target.SendKeys("\t");
|
target.SendKeys("\t");
|
||||||
Browser.Equal(expected, () => DateTime.Parse(target.GetAttribute("value")));
|
Browser.Equal(expected, () => DateTime.Parse(target.GetAttribute("value")));
|
||||||
Assert.Equal(expected, DateTime.Parse(boundValue.Text));
|
Assert.Equal(expected, DateTime.Parse(boundValue.Text));
|
||||||
|
|
@ -1017,8 +1017,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
// Modify target to something invalid - the invalid change is reverted
|
// Modify target to something invalid - the invalid change is reverted
|
||||||
// back to the last valid value
|
// back to the last valid value
|
||||||
target.SendKeys(Keys.Control + "a"); // select all
|
target.SendKeys(Keys.Control + "a"); // select all
|
||||||
target.SendKeys("05/06A");
|
target.SendKeys("05/06X");
|
||||||
Browser.Equal("05/06A", () => target.GetAttribute("value"));
|
Browser.Equal("05/06X", () => target.GetAttribute("value"));
|
||||||
target.SendKeys("\t");
|
target.SendKeys("\t");
|
||||||
Browser.Equal(expected.DateTime, () => DateTimeOffset.Parse(target.GetAttribute("value")).DateTime);
|
Browser.Equal(expected.DateTime, () => DateTimeOffset.Parse(target.GetAttribute("value")).DateTime);
|
||||||
Assert.Equal(expected.DateTime, DateTimeOffset.Parse(boundValue.Text).DateTime);
|
Assert.Equal(expected.DateTime, DateTimeOffset.Parse(boundValue.Text).DateTime);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory(Skip = "https://github.com/dotnet/runtime/issues/38124")]
|
[Theory]
|
||||||
[InlineData("en-US", "Hello!")]
|
[InlineData("en-US", "Hello!")]
|
||||||
[InlineData("fr-FR", "Bonjour!")]
|
[InlineData("fr-FR", "Bonjour!")]
|
||||||
public void CanSetCultureAndReadLocalizedResources(string culture, string message)
|
public void CanSetCultureAndReadLocalizedResources(string culture, string message)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<configuration>
|
<configuration>
|
||||||
<system.webServer>
|
<system.webServer>
|
||||||
<staticContent>
|
<staticContent>
|
||||||
|
<remove fileExtension=".blat" />
|
||||||
<remove fileExtension=".dat" />
|
<remove fileExtension=".dat" />
|
||||||
<remove fileExtension=".dll" />
|
<remove fileExtension=".dll" />
|
||||||
<remove fileExtension=".json" />
|
<remove fileExtension=".json" />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue