diff --git a/TweetDuck.sln b/TweetDuck.sln index a3aef2f2..65927bae 100644 --- a/TweetDuck.sln +++ b/TweetDuck.sln @@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetImpl.CefSharp", "windo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetLib.WinForms.Legacy", "windows\TweetLib.WinForms.Legacy\TweetLib.WinForms.Legacy.csproj", "{B54E732A-4090-4DAA-9ABD-311368C17B68}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetLib.Api", "lib\TweetLib.Api\TweetLib.Api.csproj", "{85596C10-F76E-4619-9CC6-6C1593880F83}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetLib.Communication", "lib\TweetLib.Communication\TweetLib.Communication.csproj", "{72473763-4B9D-4FB6-A923-9364B2680F06}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetLib.Core", "lib\TweetLib.Core\TweetLib.Core.csproj", "{93BA3CB4-A812-4949-B07D-8D393FB38937}" @@ -54,6 +56,10 @@ Global {B54E732A-4090-4DAA-9ABD-311368C17B68}.Debug|x86.Build.0 = Debug|x86 {B54E732A-4090-4DAA-9ABD-311368C17B68}.Release|x86.ActiveCfg = Release|x86 {B54E732A-4090-4DAA-9ABD-311368C17B68}.Release|x86.Build.0 = Release|x86 + {85596C10-F76E-4619-9CC6-6C1593880F83}.Debug|x86.ActiveCfg = Debug|x86 + {85596C10-F76E-4619-9CC6-6C1593880F83}.Debug|x86.Build.0 = Debug|x86 + {85596C10-F76E-4619-9CC6-6C1593880F83}.Release|x86.ActiveCfg = Release|x86 + {85596C10-F76E-4619-9CC6-6C1593880F83}.Release|x86.Build.0 = Release|x86 {72473763-4B9D-4FB6-A923-9364B2680F06}.Debug|x86.ActiveCfg = Debug|x86 {72473763-4B9D-4FB6-A923-9364B2680F06}.Debug|x86.Build.0 = Debug|x86 {72473763-4B9D-4FB6-A923-9364B2680F06}.Release|x86.ActiveCfg = Release|x86 diff --git a/lib/TweetLib.Api/Data/NamespacedResource.cs b/lib/TweetLib.Api/Data/NamespacedResource.cs new file mode 100644 index 00000000..91189e6e --- /dev/null +++ b/lib/TweetLib.Api/Data/NamespacedResource.cs @@ -0,0 +1,37 @@ +namespace TweetLib.Api.Data { + public readonly struct NamespacedResource { + public Resource Namespace { get; } + public Resource Path { get; } + + public NamespacedResource(Resource ns, Resource path) { + Namespace = ns; + Path = path; + } + + private bool Equals(NamespacedResource other) { + return Namespace.Equals(other.Namespace) && Path.Equals(other.Path); + } + + public override bool Equals(object? obj) { + return obj is NamespacedResource other && Equals(other); + } + + public static bool operator ==(NamespacedResource left, NamespacedResource right) { + return left.Equals(right); + } + + public static bool operator !=(NamespacedResource left, NamespacedResource right) { + return !left.Equals(right); + } + + public override int GetHashCode() { + unchecked { + return (Namespace.GetHashCode() * 397) ^ Path.GetHashCode(); + } + } + + public override string ToString() { + return $"{Namespace}:{Path}"; + } + } +} diff --git a/lib/TweetLib.Api/Data/Resource.cs b/lib/TweetLib.Api/Data/Resource.cs new file mode 100644 index 00000000..e106da66 --- /dev/null +++ b/lib/TweetLib.Api/Data/Resource.cs @@ -0,0 +1,43 @@ +using System; +using System.Text.RegularExpressions; + +namespace TweetLib.Api.Data { + public readonly struct Resource { + private const string ValidCharacterPattern = "^[a-z0-9_]+$"; + private static readonly Regex ValidCharacterRegex = new Regex(ValidCharacterPattern, RegexOptions.Compiled); + + public string Name { get; } + + public Resource(string name) { + if (!ValidCharacterRegex.IsMatch(name)) { + throw new ArgumentException("Resource name must match the regex: " + ValidCharacterPattern); + } + + Name = name; + } + + private bool Equals(Resource other) { + return Name == other.Name; + } + + public override bool Equals(object? obj) { + return obj is Resource other && Equals(other); + } + + public static bool operator ==(Resource left, Resource right) { + return left.Equals(right); + } + + public static bool operator !=(Resource left, Resource right) { + return !left.Equals(right); + } + + public override int GetHashCode() { + return Name.GetHashCode(); + } + + public override string ToString() { + return Name; + } + } +} diff --git a/lib/TweetLib.Api/ITweetDuckApi.cs b/lib/TweetLib.Api/ITweetDuckApi.cs new file mode 100644 index 00000000..7a3d84cb --- /dev/null +++ b/lib/TweetLib.Api/ITweetDuckApi.cs @@ -0,0 +1,5 @@ +namespace TweetLib.Api { + public interface ITweetDuckApi { + T? FindService<T>() where T : class, ITweetDuckService; + } +} diff --git a/lib/TweetLib.Api/ITweetDuckService.cs b/lib/TweetLib.Api/ITweetDuckService.cs new file mode 100644 index 00000000..94d21d47 --- /dev/null +++ b/lib/TweetLib.Api/ITweetDuckService.cs @@ -0,0 +1,3 @@ +namespace TweetLib.Api { + public interface ITweetDuckService {} +} diff --git a/lib/TweetLib.Api/TweetDuckExtension.cs b/lib/TweetLib.Api/TweetDuckExtension.cs new file mode 100644 index 00000000..20882ca3 --- /dev/null +++ b/lib/TweetLib.Api/TweetDuckExtension.cs @@ -0,0 +1,15 @@ +using TweetLib.Api.Data; + +namespace TweetLib.Api { + public abstract class TweetDuckExtension { + /// <summary> + /// Unique identifier of the extension. + /// </summary> + public abstract Resource Id { get; } + + /// <summary> + /// Called when the extension is loaded on startup, or enabled at runtime. + /// </summary> + public abstract void Enable(ITweetDuckApi api); + } +} diff --git a/lib/TweetLib.Api/TweetLib.Api.csproj b/lib/TweetLib.Api/TweetLib.Api.csproj new file mode 100644 index 00000000..078d5d76 --- /dev/null +++ b/lib/TweetLib.Api/TweetLib.Api.csproj @@ -0,0 +1,16 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <Configurations>Debug;Release</Configurations> + <Platforms>x86;x64</Platforms> + <LangVersion>10</LangVersion> + <Nullable>enable</Nullable> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + </PropertyGroup> + + <ItemGroup> + <Compile Include="..\..\Version.cs" Link="Version.cs" /> + </ItemGroup> + +</Project> diff --git a/lib/TweetLib.Core/App.cs b/lib/TweetLib.Core/App.cs index 1e2c1114..99b0e7d3 100644 --- a/lib/TweetLib.Core/App.cs +++ b/lib/TweetLib.Core/App.cs @@ -5,6 +5,8 @@ using TweetLib.Core.Application; using TweetLib.Core.Features; using TweetLib.Core.Features.Plugins; +using TweetLib.Core.Resources; +using TweetLib.Core.Systems.Api; using TweetLib.Core.Systems.Configuration; using TweetLib.Core.Systems.Logging; using TweetLib.Utils.Static; @@ -21,8 +23,11 @@ public static class App { internal static readonly string PluginPath = Path.Combine(ProgramPath, "plugins"); internal static readonly string GuidePath = Path.Combine(ProgramPath, "guide"); - public static readonly string StoragePath = IsPortable ? Path.Combine(ProgramPath, "portable", "storage") : GetDataFolder(); - public static readonly string LogoPath = Path.Combine(ResourcesPath, "images/logo.png"); + public static readonly string ExtensionPath = Path.Combine(ProgramPath, "extensions"); + public static readonly string StoragePath = IsPortable ? Path.Combine(ProgramPath, "portable", "storage") : GetDataFolder(); + public static readonly string LogoPath = Path.Combine(ResourcesPath, "images/logo.png"); + + public static ApiImplementation Api { get; } = new (); public static Logger Logger { get; } = new (Path.Combine(StoragePath, "TD_Log.txt"), Setup.IsDebugLogging); public static ConfigManager ConfigManager { get; } = Setup.CreateConfigManager(StoragePath); diff --git a/lib/TweetLib.Core/Features/Extensions/ExtensionLoader.cs b/lib/TweetLib.Core/Features/Extensions/ExtensionLoader.cs new file mode 100644 index 00000000..09bf3a6f --- /dev/null +++ b/lib/TweetLib.Core/Features/Extensions/ExtensionLoader.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Reflection; +using TweetLib.Api; +using TweetLib.Core.Systems.Api; + +namespace TweetLib.Core.Features.Extensions { + public static class ExtensionLoader { + public static void LoadAllInFolder(string extensionFolder) { + if (!Directory.Exists(extensionFolder)) { + return; + } + + try { + foreach (string file in Directory.EnumerateFiles(extensionFolder, "*.dll", SearchOption.TopDirectoryOnly)) { + try { + Assembly assembly = Assembly.LoadFile(file); + foreach (Type type in assembly.GetTypes()) { + if (typeof(TweetDuckExtension).IsAssignableFrom(type) && Activator.CreateInstance(type) is TweetDuckExtension extension) { + EnableExtension(extension); + } + } + } catch (Exception e) { + App.ErrorHandler.HandleException("Extension Error", "Could not load extension: " + Path.GetFileNameWithoutExtension(file), true, e); + } + } + } catch (DirectoryNotFoundException) { + // ignore + } catch (Exception e) { + App.ErrorHandler.HandleException("Extension Error", "Could not load extensions.", true, e); + } + } + + private static void EnableExtension(TweetDuckExtension extension) { + ApiImplementation apiImplementation = App.Api; + apiImplementation.CurrentExtension = extension; + extension.Enable(apiImplementation); + apiImplementation.CurrentExtension = null; + } + } +} diff --git a/lib/TweetLib.Core/Systems/Api/ApiImplementation.cs b/lib/TweetLib.Core/Systems/Api/ApiImplementation.cs new file mode 100644 index 00000000..e4ff5991 --- /dev/null +++ b/lib/TweetLib.Core/Systems/Api/ApiImplementation.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using TweetLib.Api; + +namespace TweetLib.Core.Systems.Api { + public class ApiImplementation : ITweetDuckApi { + public TweetDuckExtension? CurrentExtension { get; internal set; } + + private readonly Dictionary<Type, ITweetDuckService> services = new Dictionary<Type, ITweetDuckService>(); + + internal ApiImplementation() {} + + public void RegisterService<T>(T service) where T : class, ITweetDuckService { + if (!typeof(T).IsInterface) { + throw new ArgumentException("Api service implementation must be registered with its interface type."); + } + + services.Add(typeof(T), service); + } + + public T? FindService<T>() where T : class, ITweetDuckService { + return services.TryGetValue(typeof(T), out ITweetDuckService? service) ? service as T : null; + } + } +} diff --git a/lib/TweetLib.Core/TweetLib.Core.csproj b/lib/TweetLib.Core/TweetLib.Core.csproj index 084688a1..ff0cfa16 100644 --- a/lib/TweetLib.Core/TweetLib.Core.csproj +++ b/lib/TweetLib.Core/TweetLib.Core.csproj @@ -14,6 +14,7 @@ </ItemGroup> <ItemGroup> + <ProjectReference Include="..\TweetLib.Api\TweetLib.Api.csproj" /> <ProjectReference Include="..\TweetLib.Browser\TweetLib.Browser.csproj" /> <ProjectReference Include="..\TweetLib.Utils\TweetLib.Utils.csproj" /> </ItemGroup> diff --git a/windows/TweetDuck/Application/ApiServices.cs b/windows/TweetDuck/Application/ApiServices.cs new file mode 100644 index 00000000..dc4fd7cf --- /dev/null +++ b/windows/TweetDuck/Application/ApiServices.cs @@ -0,0 +1,16 @@ +using System; +using TweetLib.Api; +using TweetLib.Api.Data; +using TweetLib.Core; + +namespace TweetDuck.Application { + static class ApiServices { + public static void Register() { + } + + internal static NamespacedResource Namespace(Resource path) { + TweetDuckExtension currentExtension = App.Api.CurrentExtension ?? throw new InvalidOperationException("Cannot use API services outside of designated method calls."); + return new NamespacedResource(currentExtension.Id, path); + } + } +} diff --git a/windows/TweetDuck/Program.cs b/windows/TweetDuck/Program.cs index dfc02d37..a4daa0ee 100644 --- a/windows/TweetDuck/Program.cs +++ b/windows/TweetDuck/Program.cs @@ -16,6 +16,7 @@ using TweetLib.Browser.Request; using TweetLib.Core; using TweetLib.Core.Application; +using TweetLib.Core.Features.Extensions; using TweetLib.Core.Features.Plugins; using TweetLib.Core.Features.Plugins.Config; using TweetLib.Core.Features.TweetDeck; @@ -137,6 +138,9 @@ public void Launch(ResourceCache resourceCache, PluginManager pluginManager) { Cef.Initialize(settings, false, new BrowserProcessHandler()); Win.Application.ApplicationExit += static (_, _) => ExitCleanup(); + + ApiServices.Register(); + ExtensionLoader.LoadAllInFolder(App.ExtensionPath); var updateCheckClient = new UpdateCheckClient(Path.Combine(storagePath, InstallerFolder)); var mainForm = new FormBrowser(resourceCache, pluginManager, updateCheckClient, lockManager.WindowRestoreMessage); Win.Application.Run(mainForm); diff --git a/windows/TweetDuck/TweetDuck.csproj b/windows/TweetDuck/TweetDuck.csproj index b75b0cb7..0aa504c8 100644 --- a/windows/TweetDuck/TweetDuck.csproj +++ b/windows/TweetDuck/TweetDuck.csproj @@ -40,6 +40,7 @@ </ItemGroup> <ItemGroup> + <ProjectReference Include="..\..\lib\TweetLib.Api\TweetLib.Api.csproj" /> <ProjectReference Include="..\..\lib\TweetLib.Browser.CEF\TweetLib.Browser.CEF.csproj" /> <ProjectReference Include="..\..\lib\TweetLib.Browser\TweetLib.Browser.csproj" /> <ProjectReference Include="..\..\lib\TweetLib.Communication\TweetLib.Communication.csproj" />