diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index fa150061..f7b70a18 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -14,7 +14,7 @@ jobs: - name: Report uses: dorny/test-reporter@v1.4.2 with: - artifact: TestResults-${{ inputs.name }} + artifact: TestResults-Vignette.Core.Tests name: Test Results path: "*.trx" reporter: dotnet-trx diff --git a/Vignette.Universal.slnf b/Vignette.Universal.slnf index 59264cb1..40d5f46b 100644 --- a/Vignette.Universal.slnf +++ b/Vignette.Universal.slnf @@ -4,6 +4,7 @@ "projects": [ "src\\desktop\\Vignette.Desktop\\Vignette.Desktop.csproj", "src\\core\\Vignette.Core\\Vignette.Core.csproj", + "tests\\Vignette.Core.Tests\\Vignette.Core.Tests.csproj" ] } } diff --git a/Vignette.Windows.slnf b/Vignette.Windows.slnf index a88da83b..d7a36f51 100644 --- a/Vignette.Windows.slnf +++ b/Vignette.Windows.slnf @@ -4,6 +4,7 @@ "projects": [ "src\\desktop\\Vignette.Desktop.Windows\\Vignette.Desktop.Windows.csproj", "src\\core\\Vignette.Core\\Vignette.Core.csproj", + "tests\\Vignette.Core.Tests\\Vignette.Core.Tests.csproj" ] } } diff --git a/Vignette.sln b/Vignette.sln index 47c2ecf1..4f403b7f 100644 --- a/Vignette.sln +++ b/Vignette.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 16 VisualStudioVersion = 16.0.0.0 @@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vignette.Desktop", "src\des EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vignette.Desktop.Windows", "src\desktop\Vignette.Desktop.Windows\Vignette.Desktop.Windows.csproj", "{F2EC1766-D8F0-4AAF-8E61-E58DA667F273}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9382184D-E916-4D97-B545-4885E19F362B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vignette.Core.Tests", "tests\Vignette.Core.Tests\Vignette.Core.Tests.csproj", "{22C61C8D-5399-4F4F-AAA4-A822CA058A60}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -61,6 +65,18 @@ Global {F2EC1766-D8F0-4AAF-8E61-E58DA667F273}.Release|x64.Build.0 = Release|Any CPU {F2EC1766-D8F0-4AAF-8E61-E58DA667F273}.Release|x86.ActiveCfg = Release|Any CPU {F2EC1766-D8F0-4AAF-8E61-E58DA667F273}.Release|x86.Build.0 = Release|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Debug|x64.ActiveCfg = Debug|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Debug|x64.Build.0 = Debug|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Debug|x86.ActiveCfg = Debug|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Debug|x86.Build.0 = Debug|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Release|Any CPU.Build.0 = Release|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Release|x64.ActiveCfg = Release|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Release|x64.Build.0 = Release|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Release|x86.ActiveCfg = Release|Any CPU + {22C61C8D-5399-4F4F-AAA4-A822CA058A60}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {AB5C2DA7-E113-4FBB-A15E-F861839975CC} = {074EAA67-8F73-4FE6-A559-3BDE0730F5CA} @@ -68,5 +84,6 @@ Global {6DA85738-CC0C-4032-B4E0-A5C7B0359569} = {074EAA67-8F73-4FE6-A559-3BDE0730F5CA} {F9E6FD1F-ECEB-4D6B-AF7E-0D956F162CFF} = {6DA85738-CC0C-4032-B4E0-A5C7B0359569} {F2EC1766-D8F0-4AAF-8E61-E58DA667F273} = {6DA85738-CC0C-4032-B4E0-A5C7B0359569} + {22C61C8D-5399-4F4F-AAA4-A822CA058A60} = {9382184D-E916-4D97-B545-4885E19F362B} EndGlobalSection EndGlobal diff --git a/src/core/Vignette.Core/Extensions/Extension.cs b/src/core/Vignette.Core/Extensions/Extension.cs new file mode 100644 index 00000000..4f658648 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/Extension.cs @@ -0,0 +1,117 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Vignette.Core.Extensions +{ + public abstract class Extension : IExtension + { + public abstract string Name { get; } + public abstract string Author { get; } + public abstract string Description { get; } + public abstract string Identifier { get; } + public abstract Version Version { get; } + public bool Activated { get; private set; } + protected ExtensionSystem ExtensionSystem { get; private set; } + protected IReadOnlyDictionary Channels => channels; + private readonly Dictionary channels = new Dictionary(); + + public void Activate(ExtensionSystem extensionSystem) + { + if (Activated) + return; + + ExtensionSystem = extensionSystem ?? throw new ArgumentNullException(nameof(extensionSystem)); + + Initialize(); + + Activated = true; + } + + public void Deactivate() + { + if (!Activated) + return; + + Destroy(); + + ExtensionSystem = null; + Activated = false; + } + + public virtual Task TryDispatchAsync(IExtension actor, string channel, out object value, CancellationToken token = default, params object[] args) + { + if (!channels.TryGetValue(channel, out var item)) + { + value = new ChannelNotFoundException(); + return Task.FromResult(false); + } + + try + { + value = Invoke(item, args); + return Task.FromResult(true); + } + catch (Exception e) + { + value = e; + return Task.FromResult(false); + } + } + + public bool TryDispatch(IExtension actor, string channel, out object value, params object[] args) + => TryDispatchAsync(actor, channel, out value, default, args).GetAwaiter().GetResult(); + + public async Task DispatchAsync(IExtension actor, string channel, params object[] args) + { + await TryDispatchAsync(actor, channel, out var value, default, args); + + if (value is Exception e) + throw e; + + return value; + } + + public object Dispatch(IExtension actor, string channel, params object[] args) + => DispatchAsync(actor, channel, args).GetAwaiter().GetResult(); + + public async Task DispatchAsync(IExtension actor, string channel, params object[] args) + => (T)await DispatchAsync(actor, channel, args); + + public T Dispatch(IExtension actor, string channel, params object[] args) + => (T)Dispatch(actor, channel, args); + + protected virtual void Initialize() + { + } + + protected virtual void Destroy() + { + } + + protected bool Register(string channel, object action) => channels.TryAdd(channel, action); + protected void Unregister(string channel) => channels.Remove(channel); + protected abstract object Invoke(object method, params object[] args); + + public bool Equals(IExtension other) + => Name.Equals(other.Name) && Author.Equals(other.Author) + && Description.Equals(other.Description) && Identifier.Equals(other.Identifier) + && Version.Equals(other.Version); + + public override bool Equals(object obj) + => Equals(obj as IExtension); + + public override int GetHashCode() + => HashCode.Combine(Name, Author, Description, Identifier, Version); + + public override string ToString() => $@"{Identifier} ({Version.ToString(3)})"; + } + + public class ChannelNotFoundException : Exception + { + } +} diff --git a/src/core/Vignette.Core/Extensions/ExtensionIntents.cs b/src/core/Vignette.Core/Extensions/ExtensionIntents.cs new file mode 100644 index 00000000..8e36b404 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/ExtensionIntents.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; + +namespace Vignette.Core.Extensions +{ + [Flags] + public enum ExtensionIntents + { + None, + Files, + } +} diff --git a/src/core/Vignette.Core/Extensions/ExtensionMode.cs b/src/core/Vignette.Core/Extensions/ExtensionMode.cs new file mode 100644 index 00000000..1e7f6021 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/ExtensionMode.cs @@ -0,0 +1,11 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +namespace Vignette.Core.Extensions.Vendor +{ + public enum ExtensionMode + { + Production, + Development, + } +} diff --git a/src/core/Vignette.Core/Extensions/ExtensionSystem.cs b/src/core/Vignette.Core/Extensions/ExtensionSystem.cs new file mode 100644 index 00000000..6cee6d58 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/ExtensionSystem.cs @@ -0,0 +1,92 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.ClearScript.V8; +using Stride.Core; +using Stride.Core.Annotations; +using Stride.Games; +using Vignette.Core.Extensions.Host; +using Vignette.Core.Extensions.Vendor; + +namespace Vignette.Core.Extensions +{ + public class ExtensionSystem : GameSystemBase + { + public readonly V8Runtime Runtime = new V8Runtime(); + public IReadOnlyCollection Loaded => extensions; + private readonly HashSet extensions = new HashSet(); + + public ExtensionSystem([NotNull] IServiceRegistry registry) + : base(registry) + { + } + + public void Load(Extension extension) + { + if (extension == null) + throw new ArgumentNullException(nameof(extension)); + + if (Loaded.Contains(extension, EqualityComparer.Default)) + throw new ExtensionLoadException(@"Extension is already loaded."); + + if (extension is VendorExtension vendored) + { + var missing = vendored.Dependencies?.Where(dep => !Loaded.Any(ext => ext.Identifier == dep.Identifier && ext.Version >= dep.Version)); + + if (missing.Any()) + throw new ExtensionLoadException(@"Failed to load extension as one or more dependencies have not been met."); + } + + if (extensions.Add(extension)) + extension.Activate(this); + } + + public void Unload(Extension extension) + { + if (extension == null) + throw new ArgumentNullException(nameof(extension)); + + if (!Loaded.Contains(extension, EqualityComparer.Default)) + throw new ExtensionUnloadException(@"Extension is not loaded."); + + if (extension is VendorExtension vendored) + { + var allDependencies = Loaded.OfType().SelectMany(ext => ext.Dependencies).Distinct(EqualityComparer.Default); + if (allDependencies.Any(dep => extension.Identifier == dep.Identifier && extension.Version >= dep.Version)) + throw new ExtensionUnloadException(@"Failed to unload extension as one or more extensions depend on it."); + } + + if (extensions.Remove(extension) && extension is not HostExtension) + extension.Deactivate(); + } + + protected override void Destroy() + { + base.Destroy(); + + foreach (var ext in Loaded.OfType()) + ext.Dispose(); + + Runtime.Dispose(); + } + } + + public class ExtensionLoadException : Exception + { + public ExtensionLoadException(string message) + : base(message) + { + } + } + + public class ExtensionUnloadException : Exception + { + public ExtensionUnloadException(string message) + : base(message) + { + } + } +} diff --git a/src/core/Vignette.Core/Extensions/Host/HostExtension.cs b/src/core/Vignette.Core/Extensions/Host/HostExtension.cs new file mode 100644 index 00000000..75ea00f1 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/Host/HostExtension.cs @@ -0,0 +1,110 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Vignette.Core.Extensions.Vendor; + +namespace Vignette.Core.Extensions.Host +{ + public abstract class HostExtension : Extension + { + public override string Author { get; } = Assembly.GetExecutingAssembly().GetCustomAttribute().Company; + public override Version Version { get; } = Assembly.GetExecutingAssembly().GetName().Version; + public virtual ExtensionIntents Intent => ExtensionIntents.None; + + protected override void Initialize() + { + foreach (var method in GetType().GetMethods(BindingFlags.Instance | BindingFlags.Public)) + { + var chan = method.GetCustomAttribute(); + + if (chan == null) + continue; + + Register(chan.Name, new Dispatcher(GetType(), method)); + } + } + + protected sealed override void Destroy() + { + throw new InvalidOperationException(@"Built-in extensions cannot be disabled."); + } + + public sealed override Task TryDispatchAsync(IExtension actor, string channel, out object value, CancellationToken token = default, params object[] args) + { + if (Intent != ExtensionIntents.None) + { + if (actor is VendorExtension vendor && !vendor.Intents.HasFlag(Intent)) + { + value = new InsufficientIntentsException(@"Failed to dispatch as actor lacks proper intents to perform this action."); + return Task.FromResult(false); + } + } + + return base.TryDispatchAsync(actor, channel, out value, token, args); + } + + protected sealed override object Invoke(object method, params object[] args) + => (method as Dispatcher)?.Invoke(this, args); + + [AttributeUsage(AttributeTargets.Method)] + protected class ListenAttribute : Attribute + { + public string Name { get; set; } + + public ListenAttribute(string name) + { + Name = name; + } + } + + private class Dispatcher + { + private readonly Func dispatch; + + public Dispatcher(Type type, MethodInfo method) + { + var argsExpression = Expression.Parameter(typeof(object[]), "Params"); + var targetExpression = Expression.Parameter(typeof(object), "Target"); + var paramExpressions = method.GetParameters().Select((p, i) => + { + var constExpression = Expression.Constant(i, typeof(int)); + var indexExpression = Expression.ArrayIndex(argsExpression, constExpression); + return Expression.Convert(indexExpression, p.ParameterType); + }); + var invokeExpression = Expression.Call(Expression.Convert(targetExpression, type), method, paramExpressions); + + LambdaExpression lambdaExpression; + + if (method.ReturnType != typeof(void)) + { + lambdaExpression = Expression.Lambda(Expression.Convert(invokeExpression, typeof(object)), targetExpression, argsExpression); + } + else + { + var nullExpression = Expression.Constant(null, typeof(object)); + var bodyExpression = Expression.Block(invokeExpression, nullExpression); + lambdaExpression = Expression.Lambda(bodyExpression, targetExpression, argsExpression); + } + + dispatch = (Func)lambdaExpression.Compile(); + } + + public object Invoke(object target, object[] args) + => dispatch(target, args); + } + } + + public class InsufficientIntentsException : Exception + { + public InsufficientIntentsException(string message) + : base(message) + { + } + } +} diff --git a/src/core/Vignette.Core/Extensions/IExtension.cs b/src/core/Vignette.Core/Extensions/IExtension.cs new file mode 100644 index 00000000..be934253 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/IExtension.cs @@ -0,0 +1,16 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; + +namespace Vignette.Core.Extensions +{ + public interface IExtension : IEquatable + { + string Name { get; } + string Author { get; } + string Description { get; } + string Identifier { get; } + Version Version { get; } + } +} diff --git a/src/core/Vignette.Core/Extensions/Vendor/ArchiveBackedVendorExtension.cs b/src/core/Vignette.Core/Extensions/Vendor/ArchiveBackedVendorExtension.cs new file mode 100644 index 00000000..c4b6991c --- /dev/null +++ b/src/core/Vignette.Core/Extensions/Vendor/ArchiveBackedVendorExtension.cs @@ -0,0 +1,60 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.ClearScript; +using Microsoft.ClearScript.JavaScript; +using Microsoft.ClearScript.V8; +using Stride.Core.IO; +using Vignette.Core.IO; + +namespace Vignette.Core.Extensions.Vendor +{ + public class ArchiveBackedVendorExtension : FileProviderBackedVendorExtension + { + public ArchiveBackedVendorExtension(string archivePath) + : base(new ArchiveFileProvider($"{MountPath}/{Path.GetFileNameWithoutExtension(archivePath)}", archivePath), string.Empty) + { + } + + protected sealed override void Prepare(V8ScriptEngine engine) + { + engine.DocumentSettings.Loader = new ArchiveBackedDocumentLoader(this); + } + + private class ArchiveBackedDocumentLoader : DocumentLoader + { + private readonly ArchiveBackedVendorExtension extension; + + public ArchiveBackedDocumentLoader(ArchiveBackedVendorExtension extension) + { + this.extension = extension; + } + + public override Task LoadDocumentAsync(DocumentSettings settings, DocumentInfo? sourceInfo, string specifier, DocumentCategory category, DocumentContextCallback contextCallback) + { + if (!extension.Files.FileExists(specifier)) + throw new FileNotFoundException(null, specifier); + + var stream = extension.Files.OpenStream(specifier, VirtualFileMode.Open, VirtualFileAccess.Read); + var info = new DocumentInfo(new Uri(specifier)) { Category = ModuleCategory.Standard, ContextCallback = contextCallback }; + + return Task.FromResult(new StreamBackedDocument(info, stream)); + } + } + + private class StreamBackedDocument : Document + { + public override DocumentInfo Info { get; } + public override Stream Contents { get; } + + public StreamBackedDocument(DocumentInfo info, Stream contents) + { + Info = info; + Contents = contents; + } + } + } +} diff --git a/src/core/Vignette.Core/Extensions/Vendor/FileProviderBackedVendorExtension.cs b/src/core/Vignette.Core/Extensions/Vendor/FileProviderBackedVendorExtension.cs new file mode 100644 index 00000000..3ff6ff25 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/Vendor/FileProviderBackedVendorExtension.cs @@ -0,0 +1,56 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.IO; +using System.Text.Json; +using Microsoft.ClearScript; +using Stride.Core.IO; + +namespace Vignette.Core.Extensions.Vendor +{ + public abstract class FileProviderBackedVendorExtension : VendorExtension + { + private const string metadata = @"meta.json"; + private const string entry = @"extension.js"; + public const string MountPath = @"/extensions"; + protected readonly IVirtualFileProvider Files; + protected readonly IVirtualFileProvider Data; + protected readonly string BasePath; + public override Uri DocumentUri => new Uri(Path.Combine(BasePath, "extension.js")); + + protected FileProviderBackedVendorExtension(IVirtualFileProvider files, string basePath) + : base(getMetadata(files)) + { + Files = files; + BasePath = basePath; + + if (Intents.HasFlag(ExtensionIntents.Files)) + Data = VirtualFileSystem.MountFileSystem($@"{MountPath}/data/{Identifier}", Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "extensions", Identifier)); + } + + protected sealed override string GetDocumentContent(DocumentInfo documentInfo) + { + if (!Files.FileExists(entry)) + throw new FileNotFoundException(null, entry); + + using var stream = Files.OpenStream(entry, VirtualFileMode.Open, VirtualFileAccess.Read); + using var reader = new StreamReader(stream); + + string code = reader.ReadToEnd(); + documentInfo.SourceMapUri = new Uri(code[code.IndexOf("//# sourceMappingURL=")..].Replace("//# sourceMappingURL=", string.Empty)); + + return code; + } + + private static VendorExtensionMetadata getMetadata(IVirtualFileProvider files) + { + if (!files.FileExists(metadata)) + throw new FileNotFoundException(null, metadata); + + using var stream = files.OpenStream(metadata, VirtualFileMode.Open, VirtualFileAccess.Read); + using var reader = new StreamReader(stream); + return JsonSerializer.Deserialize(reader.ReadToEnd(), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + } + } +} diff --git a/src/core/Vignette.Core/Extensions/Vendor/FolderBackedVendorExtension.cs b/src/core/Vignette.Core/Extensions/Vendor/FolderBackedVendorExtension.cs new file mode 100644 index 00000000..a537b590 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/Vendor/FolderBackedVendorExtension.cs @@ -0,0 +1,16 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System.IO; +using Stride.Core.IO; + +namespace Vignette.Core.Extensions.Vendor +{ + public class FolderBackedVendorExtension : FileProviderBackedVendorExtension + { + public FolderBackedVendorExtension(string path) + : base(VirtualFileSystem.MountFileSystem($"{MountPath}/{Path.GetFileNameWithoutExtension(path)}", path), path) + { + } + } +} diff --git a/src/core/Vignette.Core/Extensions/Vendor/VendorExtension.cs b/src/core/Vignette.Core/Extensions/Vendor/VendorExtension.cs new file mode 100644 index 00000000..29594f40 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/Vendor/VendorExtension.cs @@ -0,0 +1,92 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.Collections.Generic; +using Microsoft.ClearScript; +using Microsoft.ClearScript.JavaScript; +using Microsoft.ClearScript.V8; + +namespace Vignette.Core.Extensions.Vendor +{ + public abstract partial class VendorExtension : Extension, IDisposable + { + public override string Name => metadata.Name; + public override string Author => metadata.Author; + public override string Description => metadata.Description; + public override string Identifier => metadata.Identifier; + public override Version Version => metadata.Version; + public ExtensionIntents Intents => metadata.Intents; + public IReadOnlyList Dependencies => metadata.Dependencies; + public virtual Uri DocumentUri { get; } + public virtual ExtensionMode Mode => ExtensionMode.Production; + + protected bool IsDisposed { get; private set; } + + private readonly VendorExtensionMetadata metadata; + private V8ScriptEngine engine; + + public VendorExtension(VendorExtensionMetadata metadata) + { + this.metadata = metadata; + } + + protected sealed override void Initialize() + { + var flags = V8ScriptEngineFlags.EnableDynamicModuleImports + | V8ScriptEngineFlags.EnableTaskPromiseConversion + | V8ScriptEngineFlags.EnableValueTaskPromiseConversion + | V8ScriptEngineFlags.DisableGlobalMembers; + + engine = ExtensionSystem.Runtime.CreateScriptEngine(Identifier, flags); + engine.DocumentSettings.AccessFlags = DocumentAccessFlags.EnforceRelativePrefix | DocumentAccessFlags.EnableFileLoading; + engine.DocumentSettings.AddSystemDocument("vignette", ModuleCategory.Standard, "export const { vignette } = import.meta;", getVendorMeta); + + var documentInfo = DocumentUri != null ? new DocumentInfo(DocumentUri) : new DocumentInfo("extension"); + documentInfo.Category = ModuleCategory.Standard; + + engine.DocumentSettings.AddSystemDocument("extension", new StringDocument(documentInfo, GetDocumentContent(documentInfo))); + + Prepare(engine); + + engine.Execute(new DocumentInfo { Category = ModuleCategory.Standard }, "import { activate } from 'extension'; activate();"); + } + + protected sealed override void Destroy() + { + Dispose(); + } + + protected sealed override object Invoke(object method, params object[] args) + { + if (method is ScriptObject item) + return item.Invoke(false, args); + + return null; + } + + protected abstract string GetDocumentContent(DocumentInfo documentInfo); + + protected virtual void Prepare(V8ScriptEngine engine) + { + } + + protected virtual void Dispose(bool disposing) + { + if (IsDisposed) + return; + + engine?.Execute(new DocumentInfo { Category = ModuleCategory.Standard }, "import { deactivate } from 'extension'; deactivate();"); + engine?.Dispose(); + engine = null; + + IsDisposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/core/Vignette.Core/Extensions/Vendor/VendorExtensionDependency.cs b/src/core/Vignette.Core/Extensions/Vendor/VendorExtensionDependency.cs new file mode 100644 index 00000000..aef66d02 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/Vendor/VendorExtensionDependency.cs @@ -0,0 +1,41 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.Text.Json.Serialization; +using Vignette.Core.IO.Serialization; + +namespace Vignette.Core.Extensions.Vendor +{ + public struct VendorExtensionDependency : IEquatable + { + [JsonPropertyName("id")] + public string Identifier { get; set; } + + [JsonConverter(typeof(VersionConverter))] + public Version Version { get; set; } + + public bool Equals(VendorExtensionDependency other) + => other.Identifier == Identifier && other.Version == Version; + + public override bool Equals(object obj) + { + if (obj is VendorExtensionDependency dep) + return Equals(dep); + + return false; + } + + public override int GetHashCode() + => HashCode.Combine(Identifier, Version); + + public override string ToString() + => $@"{Identifier} ({Version})"; + + public static bool operator ==(VendorExtensionDependency left, VendorExtensionDependency right) + => left.Equals(right); + + public static bool operator !=(VendorExtensionDependency left, VendorExtensionDependency right) + => !(left == right); + } +} diff --git a/src/core/Vignette.Core/Extensions/Vendor/VendorExtensionMetadata.cs b/src/core/Vignette.Core/Extensions/Vendor/VendorExtensionMetadata.cs new file mode 100644 index 00000000..e8380d52 --- /dev/null +++ b/src/core/Vignette.Core/Extensions/Vendor/VendorExtensionMetadata.cs @@ -0,0 +1,29 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Vignette.Core.IO.Serialization; + +namespace Vignette.Core.Extensions.Vendor +{ + public class VendorExtensionMetadata : IExtension + { + public string Name { get; set; } = @"Extension"; + public string Author { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("id")] + public string Identifier { get; set; } = @"extension"; + + [JsonConverter(typeof(VersionConverter))] + public Version Version { get; set; } = new Version("0.0.0"); + + [JsonConverter(typeof(FlagConverter))] + public ExtensionIntents Intents { get; set; } = ExtensionIntents.None; + public IReadOnlyList Dependencies { get; set; } = Array.Empty(); + + public bool Equals(IExtension other) => false; + } +} diff --git a/src/core/Vignette.Core/Extensions/Vendor/VendorExtensionModule.cs b/src/core/Vignette.Core/Extensions/Vendor/VendorExtensionModule.cs new file mode 100644 index 00000000..7662549c --- /dev/null +++ b/src/core/Vignette.Core/Extensions/Vendor/VendorExtensionModule.cs @@ -0,0 +1,59 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.ClearScript; + +namespace Vignette.Core.Extensions.Vendor +{ + public partial class VendorExtension + { + private IDictionary getVendorMeta(DocumentInfo info) => new Dictionary + { + { + "vignette", + new + { + version = Assembly.GetExecutingAssembly().GetName().Version.ToString(3), + commands = new + { + register = new Action(register), + dispatch = new Func(dispatch), + }, + extension = new + { + id = Identifier, + mode = Mode, + name = Name, + author = Author, + version = Version.ToString(3), + description = Description, + } + } + } + }; + + private void register(string channel, ScriptObject value) + { + if (((dynamic)value).constructor.name != "Function") + throw new ArgumentException(@"Value must be a function."); + + Register(channel, value); + } + + private object dispatch(string command, IList args) + { + string[] cmd = command.Split(':', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var entry = ExtensionSystem.Loaded.FirstOrDefault(e => e.Identifier == cmd[0]); + + if (entry is not Extension ext) + throw new Exception($@"Extension {cmd[0]} is not found."); + + return ext.Dispatch(this, cmd[1], args.Cast().ToArray()); + } + } +} diff --git a/src/core/Vignette.Core/IO/ArchiveFileProvider.cs b/src/core/Vignette.Core/IO/ArchiveFileProvider.cs new file mode 100644 index 00000000..2446f297 --- /dev/null +++ b/src/core/Vignette.Core/IO/ArchiveFileProvider.cs @@ -0,0 +1,74 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using Stride.Core.IO; + +namespace Vignette.Core.IO +{ + public class ArchiveFileProvider : VirtualFileProviderBase, IDisposable + { + private readonly ZipArchive archive; + private readonly Dictionary entries = new Dictionary(); + + public ArchiveFileProvider(string rootPath, string archivePath) + : base(rootPath) + { + archive = ZipFile.OpenRead(archivePath); + entries = archive.Entries.ToDictionary(k => k.FullName, v => v); + } + + public override Stream OpenStream(string url, VirtualFileMode mode, VirtualFileAccess access, VirtualFileShare share = VirtualFileShare.Read, StreamFlags streamFlags = StreamFlags.None) + { + if (!entries.TryGetValue(url, out var entry)) + throw new FileNotFoundException(); + + if (mode != VirtualFileMode.Open || access != VirtualFileAccess.Read) + throw new UnauthorizedAccessException(@"File system is read-only."); + + lock (archive) + { + var memory = new MemoryStream(); + + using (var stream = entry.Open()) + stream.CopyTo(memory); + + memory.Position = 0; + return memory; + } + } + + public override bool DirectoryExists(string url) + { + if (url == null) + throw new ArgumentNullException(nameof(url)); + + if (!url.EndsWith('/')) + url += '/'; + + return entries.Any(x => x.Key.StartsWith(url)); + } + + public override bool FileExists(string url) + => entries.ContainsKey(url); + + public override long FileSize(string url) + { + if (!entries.TryGetValue(url, out var entry)) + throw new FileNotFoundException(); + + return entry.Length; + } + + public new void Dispose() + { + base.Dispose(); + archive.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/core/Vignette.Core/IO/Serialization/FlagConverter.cs b/src/core/Vignette.Core/IO/Serialization/FlagConverter.cs new file mode 100644 index 00000000..fe02f971 --- /dev/null +++ b/src/core/Vignette.Core/IO/Serialization/FlagConverter.cs @@ -0,0 +1,42 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Vignette.Core.IO.Serialization +{ + public class FlagConverter : JsonConverter + where T : struct, Enum + { + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + int value = 0; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + break; + + if (Enum.TryParse(reader.GetString(), true, out var result)) + value |= Convert.ToInt32(result); + } + + return (T)Enum.ToObject(typeof(T), value); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + + foreach (var flag in Enum.GetValues()) + { + if (value.HasFlag(flag)) + writer.WriteStringValue(Enum.GetName(flag)); + } + + writer.WriteEndArray(); + } + } +} diff --git a/src/core/Vignette.Core/IO/Serialization/VersionConverter.cs b/src/core/Vignette.Core/IO/Serialization/VersionConverter.cs new file mode 100644 index 00000000..45eb82ca --- /dev/null +++ b/src/core/Vignette.Core/IO/Serialization/VersionConverter.cs @@ -0,0 +1,17 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Vignette.Core.IO.Serialization +{ + public class VersionConverter : JsonConverter + { + public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new Version(reader.GetString()); + public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString(3)); + } +} diff --git a/src/core/Vignette.Core/Vignette.Core.csproj b/src/core/Vignette.Core/Vignette.Core.csproj index 3f190b31..b7c92817 100644 --- a/src/core/Vignette.Core/Vignette.Core.csproj +++ b/src/core/Vignette.Core/Vignette.Core.csproj @@ -5,6 +5,9 @@ + + + diff --git a/tests/Vignette.Core.Tests/Extensions/ExtensionSystemTests.cs b/tests/Vignette.Core.Tests/Extensions/ExtensionSystemTests.cs new file mode 100644 index 00000000..4c0f9313 --- /dev/null +++ b/tests/Vignette.Core.Tests/Extensions/ExtensionSystemTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using Microsoft.ClearScript; +using NUnit.Framework; +using Stride.Core; +using Vignette.Core.Extensions; +using Vignette.Core.Extensions.Vendor; + +namespace Vignette.Core.Tests.Extensions +{ + public class ExtensionSystemTests + { + private ExtensionSystem sys; + + [SetUp] + public void SetUp() + { + sys = new ExtensionSystem(new ServiceRegistry()); + } + + [TearDown] + public void TearDown() + { + sys?.Dispose(); + } + + [Test] + public void TestLoading() + { + var a = new TestExtension(new VendorExtensionMetadata + { + Identifier = "a", + Version = new Version("1.0.0"), + }); + + var b = new TestExtension(new VendorExtensionMetadata + { + Dependencies = new[] + { + new VendorExtensionDependency { Identifier = "a", Version = new Version("0.0.1") } + } + }); + + Assert.That(() => sys.Load(a), Throws.Nothing); + Assert.That(() => sys.Load(b), Throws.Nothing); + Assert.That(sys.Loaded.Count, Is.EqualTo(2)); + } + + [Test] + public void TestThrowDependencyMissing() + { + var ext = new TestExtension(new VendorExtensionMetadata + { + Dependencies = new[] + { + new VendorExtensionDependency { Identifier = "test", Version = new Version("1.0.0") } + } + }); + + Assert.That(() => sys.Load(ext), Throws.InstanceOf()); + } + + [Test] + public void TestThrowDependencyOlder() + { + var a = new TestExtension(new VendorExtensionMetadata + { + Identifier = "a", + Version = new Version("1.0.0"), + }); + + var b = new TestExtension(new VendorExtensionMetadata + { + Dependencies = new[] + { + new VendorExtensionDependency { Identifier = "a", Version = new Version("2.0.0") } + } + }); + + Assert.That(() => sys.Load(a), Throws.Nothing); + Assert.That(() => sys.Load(b), Throws.InstanceOf()); + } + + private class TestExtension : VendorExtension + { + public TestExtension(VendorExtensionMetadata metadata) + : base(metadata) + { + } + + protected override string GetDocumentContent(DocumentInfo documentInfo) + { + return @"export function activate() { }; export function deactivate() { };"; + } + } + } +} diff --git a/tests/Vignette.Core.Tests/Extensions/HostExtensionTests.cs b/tests/Vignette.Core.Tests/Extensions/HostExtensionTests.cs new file mode 100644 index 00000000..d1893912 --- /dev/null +++ b/tests/Vignette.Core.Tests/Extensions/HostExtensionTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System; +using NUnit.Framework; +using Stride.Core; +using Vignette.Core.Extensions; + +namespace Vignette.Core.Tests.Extensions +{ + public class BuiltInExtensionTests + { + private TestHostExtension ext; + private ExtensionSystem sys; + + [SetUp] + public void SetUp() + { + sys = new ExtensionSystem(new ServiceRegistry()); + ext = new TestHostExtension(); + sys.Load(ext); + } + + [TearDown] + public void TearDown() + { + sys.Dispose(); + } + + [Test] + public void TestDispatch() + { + string result = ext.Dispatch(null, "sayHello", "World"); + Assert.That(result, Is.EqualTo("Hello World from Test")); + } + + [Test] + public void TestDispatchInvalidChannel() + { + Assert.That(() => ext.Dispatch(null, "none"), Throws.InstanceOf()); + } + + [Test] + public void TestDispatchInvalidParameters() + { + Assert.That(() => ext.Dispatch(null, "sayHello", 42), Throws.InstanceOf()); + Assert.That(() => ext.Dispatch(null, "sayHello"), Throws.InstanceOf()); + } + } +} diff --git a/tests/Vignette.Core.Tests/Extensions/TestHostExtension.cs b/tests/Vignette.Core.Tests/Extensions/TestHostExtension.cs new file mode 100644 index 00000000..694f1b41 --- /dev/null +++ b/tests/Vignette.Core.Tests/Extensions/TestHostExtension.cs @@ -0,0 +1,20 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using Vignette.Core.Extensions.Host; + +namespace Vignette.Core.Tests.Extensions +{ + public class TestHostExtension : HostExtension + { + public override string Name => @"Test"; + public override string Description => @"Test"; + public override string Identifier => @"Test"; + + [Listen("sayHello")] + public string SayHello(string name) + { + return $"Hello {name} from {Name}"; + } + } +} diff --git a/tests/Vignette.Core.Tests/Extensions/TestVendorExtension.cs b/tests/Vignette.Core.Tests/Extensions/TestVendorExtension.cs new file mode 100644 index 00000000..05be3c76 --- /dev/null +++ b/tests/Vignette.Core.Tests/Extensions/TestVendorExtension.cs @@ -0,0 +1,22 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System.Collections.Generic; +using Microsoft.ClearScript; +using Vignette.Core.Extensions.Vendor; + +namespace Vignette.Core.Tests.Extensions +{ + public class TestVendorExtension : VendorExtension + { + public string Code { get; set; } + public new IReadOnlyDictionary Channels => base.Channels; + + public TestVendorExtension() + : base(new VendorExtensionMetadata()) + { + } + + protected override string GetDocumentContent(DocumentInfo documentInfo) => Code; + } +} diff --git a/tests/Vignette.Core.Tests/Extensions/VendorExtensionTests.cs b/tests/Vignette.Core.Tests/Extensions/VendorExtensionTests.cs new file mode 100644 index 00000000..5f832618 --- /dev/null +++ b/tests/Vignette.Core.Tests/Extensions/VendorExtensionTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) The Vignette Authors +// Licensed under GPL-3.0 (With SDK Exception). See LICENSE for details. + +using System.Linq; +using Microsoft.ClearScript; +using NUnit.Framework; +using Stride.Core; +using Vignette.Core.Extensions; + +namespace Vignette.Core.Tests.Extensions +{ + public class VendorExtensionTests + { + private ExtensionSystem sys; + private TestVendorExtension ext; + + [SetUp] + public void SetUp() + { + sys = new ExtensionSystem(new ServiceRegistry()); + ext = new TestVendorExtension(); + } + + [TearDown] + public void TearDown() + { + ext?.Dispose(); + sys?.Dispose(); + } + + [Test] + public void TestRegisterCommand() + { + ext.Code = @"import { vignette } from 'vignette'; + +export function activate() { + vignette.commands.register('testCommand', () => true); +}; + +export function deactivate() { } + +"; + + sys.Load(ext); + Assert.That(ext.Channels.Count, Is.EqualTo(1)); + Assert.That(ext.Channels.First().Key, Is.EqualTo("testCommand")); + Assert.That(ext.Channels.First().Value, Is.AssignableTo()); + Assert.That(ext.Dispatch(null, "testCommand"), Is.True); + } + + [Test] + public void TestDispatchCommand() + { + ext.Code = @"import { vignette } from 'vignette'; + +export function activate() { + vignette.commands.register('testCommand', () => vignette.commands.dispatch('Test:sayHello', [ 'World' ])); +}; + +export function deactivate() { } + +"; + + sys.Load(new TestHostExtension()); + sys.Load(ext); + Assert.That(ext.Dispatch(null, "testCommand"), Is.EqualTo("Hello World from Test")); + } + } +} diff --git a/tests/Vignette.Core.Tests/Vignette.Core.Tests.csproj b/tests/Vignette.Core.Tests/Vignette.Core.Tests.csproj new file mode 100644 index 00000000..33d51015 --- /dev/null +++ b/tests/Vignette.Core.Tests/Vignette.Core.Tests.csproj @@ -0,0 +1,19 @@ + + + + net5.0 + false + + + + + + + + + + + + + +