From c6be8ed81591da671e705e4660c67f773d229199 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:16:41 +0200 Subject: [PATCH] Add CLI --- .github/workflows/publish-studio-linux.yaml | 2 + OneWare.slnx | 1 + docs/PluginDevelopment.md | 69 +++++++- snap/snapcraft.yaml | 9 + .../OneWareCloudCliModule.cs | 130 ++++++++++++++ .../OneWareCloudIntegrationModule.cs | 1 + .../Services/OneWareCloudLoginService.cs | 72 ++++---- src/OneWare.Core/AssemblyInfo.cs | 3 + .../ModuleLogic/OneWareCliModuleCatalog.cs | 59 +++++++ .../ModuleLogic/OneWareCliModuleManager.cs | 149 ++++++++++++++++ src/OneWare.Core/OneWare.Core.csproj | 2 +- .../Services/OneWareStartupCommandLine.cs | 104 +++++++++++ .../Services/PluginAssemblyLoader.cs | 42 +++++ .../Services/PluginNativeLibraryResolver.cs | 72 ++++++++ src/OneWare.Core/Services/PluginService.cs | 117 +------------ .../OneWare.Essentials.csproj | 4 + .../PluginCompatibilityChecker.cs | 11 +- .../Services/IOneWareCliModule.cs | 27 +++ .../Services/ISettingsService.cs | 5 + .../Services/OneWareCliModuleBase.cs | 20 +++ src/OneWare.Settings/SettingsService.cs | 87 ++++++++-- studio/OneWare.Studio.Cli/CliHostFactory.cs | 69 ++++++++ studio/OneWare.Studio.Cli/CliModuleLoader.cs | 162 ++++++++++++++++++ .../OneWare.Studio.Cli.csproj | 20 +++ studio/OneWare.Studio.Cli/Program.cs | 48 ++++++ studio/OneWare.Studio.Cli/StudioCliModule.cs | 39 +++++ .../StudioProcessController.cs | 157 +++++++++++++++++ .../StudioComponents.wxs | 4 + .../oneware.cmd | 2 + .../OneWare.Studio.Desktop.csproj | 55 +++++- studio/OneWare.Studio.Desktop/Program.cs | 113 ++++-------- .../com.one_ware.OneWare.desktop | 4 +- studio/OneWare.Studio.Desktop/oneware | 2 + studio/OneWare.Studio.Desktop/oneware-studio | 2 + .../PluginNativeLibraryResolverTests.cs | 28 +++ 35 files changed, 1431 insertions(+), 260 deletions(-) create mode 100644 src/OneWare.CloudIntegration/OneWareCloudCliModule.cs create mode 100644 src/OneWare.Core/AssemblyInfo.cs create mode 100644 src/OneWare.Core/ModuleLogic/OneWareCliModuleCatalog.cs create mode 100644 src/OneWare.Core/ModuleLogic/OneWareCliModuleManager.cs create mode 100644 src/OneWare.Core/Services/OneWareStartupCommandLine.cs create mode 100644 src/OneWare.Core/Services/PluginAssemblyLoader.cs create mode 100644 src/OneWare.Core/Services/PluginNativeLibraryResolver.cs create mode 100644 src/OneWare.Essentials/Services/IOneWareCliModule.cs create mode 100644 src/OneWare.Essentials/Services/OneWareCliModuleBase.cs create mode 100644 studio/OneWare.Studio.Cli/CliHostFactory.cs create mode 100644 studio/OneWare.Studio.Cli/CliModuleLoader.cs create mode 100644 studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj create mode 100644 studio/OneWare.Studio.Cli/Program.cs create mode 100644 studio/OneWare.Studio.Cli/StudioCliModule.cs create mode 100644 studio/OneWare.Studio.Cli/StudioProcessController.cs create mode 100644 studio/OneWare.Studio.Desktop.WindowsInstaller/oneware.cmd create mode 100644 studio/OneWare.Studio.Desktop/oneware create mode 100644 studio/OneWare.Studio.Desktop/oneware-studio create mode 100644 tests/OneWare.Studio.Desktop.UnitTests/PluginNativeLibraryResolverTests.cs diff --git a/.github/workflows/publish-studio-linux.yaml b/.github/workflows/publish-studio-linux.yaml index 24b2afba9..4d064f81e 100644 --- a/.github/workflows/publish-studio-linux.yaml +++ b/.github/workflows/publish-studio-linux.yaml @@ -37,6 +37,8 @@ jobs: dotnet-version: 10.0.x - name: Publish run: dotnet publish ./studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj -c Release -r ${{ matrix.arch }} -o ./out + - name: Mark launchers executable + run: chmod +x ./out/oneware ./out/oneware-studio - name: Compress uses: a7ul/tar-action@v1.2.0 id: compress diff --git a/OneWare.slnx b/OneWare.slnx index 64f244ad1..894496048 100644 --- a/OneWare.slnx +++ b/OneWare.slnx @@ -126,6 +126,7 @@ + diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index e70c9fcc2..ce01af445 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -6,9 +6,10 @@ and how UniversalFpgaProject support is wired so you can extend it safely. ## Quick start 1) Create a class library that references `OneWare.Essentials`. -2) Implement a module by deriving from `OneWare.Essentials.Services.OneWareModuleBase`. -3) Add a `compatibility.txt` file next to your plugin assemblies. -4) Package the plugin as a folder (assemblies + `compatibility.txt`) and install it via the plugin manager. +2) Implement a GUI module by deriving from `OneWare.Essentials.Services.OneWareModuleBase`. +3) Optionally implement a CLI module by deriving from `OneWare.Essentials.Services.OneWareCliModuleBase`. +4) Add a `compatibility.txt` file next to your plugin assemblies. +5) Package the plugin as a folder (assemblies + `compatibility.txt`) and install it via the plugin manager. Minimal module example: @@ -38,6 +39,33 @@ public sealed class MyPluginModule : OneWareModuleBase } ``` +Minimal CLI module example: + +```csharp +using System.CommandLine; +using OneWare.Essentials.Services; + +namespace MyCompany.MyPlugin; + +public sealed class MyPluginCliModule : OneWareCliModuleBase +{ + public override IReadOnlyList RegisterCommands(IServiceProvider serviceProvider) + { + var helloCommand = new Command("myplugin", "Commands for MyPlugin"); + var pingCommand = new Command("ping", "Simple health check"); + + pingCommand.SetAction((_, _) => + { + Console.WriteLine("pong"); + return Task.FromResult(0); + }); + + helloCommand.Subcommands.Add(pingCommand); + return [helloCommand]; + } +} +``` + Minimal `compatibility.txt` (one dependency per line): ``` @@ -92,18 +120,33 @@ You can generate `compatibility.txt` automatically during build by marking depen - A plugin must include a `compatibility.txt` file at its root. It lists assembly dependencies and versions using `AssemblyName : Version` lines. The check is enforced by `OneWare.Essentials.PackageManager.Compatibility.PluginCompatibilityChecker`. -- Modules are discovered by scanning assemblies for `IOneWareModule` implementations. -- If `IOneWareModule.RegisterServices` adds services, they are injected into the main container - before module initialization. If the app is already running, modules are initialized immediately. +- GUI modules are discovered by scanning assemblies for `IOneWareModule` implementations. +- CLI modules are discovered by scanning assemblies for `IOneWareCliModule` implementations. +- `IOneWareModule.RegisterServices` contributes services to the desktop app container before + `Initialize(IServiceProvider)` runs. +- `IOneWareCliModule.RegisterServices` contributes services to the CLI container before + `RegisterCommands(IServiceProvider)` runs. +- A single assembly may contain both GUI and CLI modules when the feature supports both surfaces. ## Module lifecycle and dependency injection -`IOneWareModule` is the entry point for plugins: +`IOneWareModule` is the entry point for desktop/UI plugins: - `RegisterServices(IServiceCollection services)` lets you register your own services. - `Initialize(IServiceProvider serviceProvider)` runs after services are registered. - `Dependencies` allows declaring other module IDs to load before yours. +`IOneWareCliModule` is the entry point for CLI plugins: + +- `RegisterServices(IServiceCollection services)` lets you register services needed by CLI commands. +- `RegisterCommands(IServiceProvider serviceProvider)` returns the commands your module contributes. +- `Dependencies` allows declaring other CLI module IDs that must load first. +- CLI module types must be public, non-abstract, and expose a parameterless constructor so the CLI loader can discover and instantiate them. + +CLI modules are intentionally headless: they should not depend on Avalonia UI state, windows, or +desktop-only lifecycles. Prefer shared services for reusable logic and keep CLI-specific behavior in +the command layer. + Use `serviceProvider.Resolve()` or `ContainerLocator.Current` to resolve OneWare services. ## OneWare.Essentials interfaces @@ -142,6 +185,14 @@ The following sections document the core services and their key functions. - `RegisterServices(IServiceCollection)`: add services to the DI container. - `Initialize(IServiceProvider)`: run after services are registered. +#### `IOneWareCliModule` (src/OneWare.Essentials/Services/IOneWareCliModule.cs) + +- `Id`: module identifier (defaults to class name in `OneWareCliModuleBase`). +- `Dependencies`: other CLI module IDs that must load before this one. +- `RegisterServices(IServiceCollection)`: add services to the CLI DI container. +- `RegisterCommands(IServiceProvider)`: return top-level CLI commands for registration. +- Module type: must be public, non-abstract, and expose a parameterless constructor for CLI discovery. + #### `IPluginService` (src/OneWare.Essentials/Services/IPluginService.cs) - `InstalledPlugins`: current plugin list. @@ -565,7 +616,9 @@ Use `IWindowService.RegisterUiExtension` to add UI to these extension points: ## Suggested validation and troubleshooting - Ensure `compatibility.txt` matches the core dependency versions. -- Verify your module class is public and implements `IOneWareModule`. +- Verify your module class is public and implements `IOneWareModule` and/or `IOneWareCliModule`. +- If CLI commands do not appear under `oneware --help`, verify the assembly is copied with the + plugin and the command name does not collide with an existing top-level command or alias. - If your UI does not appear, confirm you used the correct UI extension key. - For FPGA tooling, confirm your toolchain ID matches the project `toolchain` property. diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index d3c37c3f4..f290aa73c 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -17,6 +17,10 @@ icon: ./studio/OneWare.Studio/Assets/com.one_ware.OneWare.svg apps: oneware: + command: ./OneWareCLI + environment: + PATH: "$SNAP/usr/bin:$SNAP/bin:$PATH" + oneware-studio: command: ./OneWareStudio common-id: com.one_ware.OneWare desktop: ./com.one_ware.OneWare.desktop @@ -73,12 +77,17 @@ parts: dotnet publish -r "$DOTNET_RID" -c Release -o "$SNAPCRAFT_PART_INSTALL" chmod +x "$SNAPCRAFT_PART_INSTALL/OneWareStudio" + chmod +x "$SNAPCRAFT_PART_INSTALL/OneWareCLI" chmod +x "$SNAPCRAFT_PART_INSTALL/AsmichiChildProcessHelper" + chmod +x "$SNAPCRAFT_PART_INSTALL/oneware" + chmod +x "$SNAPCRAFT_PART_INSTALL/oneware-studio" file "$SNAPCRAFT_PART_INSTALL/OneWareStudio" file "$SNAPCRAFT_PART_INSTALL/AsmichiChildProcessHelper" sed -i 's|^Icon=.*$|Icon=${SNAP}/meta/gui/com.one_ware.OneWare.svg|' "$SNAPCRAFT_PART_INSTALL/com.one_ware.OneWare.desktop" + sed -i 's|^TryExec=oneware-studio$|TryExec=oneware.oneware-studio|' "$SNAPCRAFT_PART_INSTALL/com.one_ware.OneWare.desktop" + sed -i 's|^Exec=oneware-studio|Exec=oneware.oneware-studio|' "$SNAPCRAFT_PART_INSTALL/com.one_ware.OneWare.desktop" stage-packages: - libgtk-3-0 - libx11-6 diff --git a/src/OneWare.CloudIntegration/OneWareCloudCliModule.cs b/src/OneWare.CloudIntegration/OneWareCloudCliModule.cs new file mode 100644 index 000000000..34d7abdd8 --- /dev/null +++ b/src/OneWare.CloudIntegration/OneWareCloudCliModule.cs @@ -0,0 +1,130 @@ +using System.CommandLine; +using System.IO.Pipes; +using Microsoft.Extensions.DependencyInjection; +using OneWare.CloudIntegration.Services; +using OneWare.Essentials.Services; + +namespace OneWare.CloudIntegration; + +public sealed class OneWareCloudCliModule : OneWareCliModuleBase +{ + public override void RegisterServices(IServiceCollection services) + { + services.AddSingleton(); + } + + public override IReadOnlyList RegisterCommands(IServiceProvider serviceProvider) + { + EnsureSettingsInitialized(serviceProvider); + + var cloudCommand = new Command("cloud", "OneWare Cloud commands"); + + var loginCommand = new Command("login", "Log in to OneWare Cloud"); + loginCommand.SetAction((_, cancellationToken) => LoginAsync(serviceProvider, cancellationToken)); + + var logoutCommand = new Command("logout", "Log out of OneWare Cloud"); + logoutCommand.SetAction((_, cancellationToken) => LogoutAsync(serviceProvider, cancellationToken)); + + cloudCommand.Subcommands.Add(loginCommand); + cloudCommand.Subcommands.Add(logoutCommand); + return [cloudCommand]; + } + + private static async Task LoginAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var settingsService = serviceProvider.GetRequiredService(); + var loginService = serviceProvider.GetRequiredService(); + + Console.WriteLine("Opening browser for OneWare Cloud login..."); + + var success = await loginService.LoginAsync(cancellationToken); + if (!success) + { + Console.Error.WriteLine("OneWare Cloud login failed."); + return 1; + } + + var userId = settingsService.GetSettingValue(OneWareCloudIntegrationModule.OneWareAccountUserIdKey); + if (string.IsNullOrWhiteSpace(userId)) + { + Console.Error.WriteLine("OneWare Cloud login did not produce a stored account."); + return 1; + } + + Console.WriteLine("Logged in to OneWare Cloud."); + return 0; + } + + private static async Task LogoutAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var settingsService = serviceProvider.GetRequiredService(); + var paths = serviceProvider.GetRequiredService(); + var userId = settingsService.GetSettingValue(OneWareCloudIntegrationModule.OneWareAccountUserIdKey); + + if (string.IsNullOrWhiteSpace(userId)) + { + Console.WriteLine("OneWare Cloud is already logged out."); + return 0; + } + + try + { + serviceProvider.GetRequiredService().Logout(userId); + settingsService.SaveValues(paths.SettingsPath, + new Dictionary + { + [OneWareCloudIntegrationModule.OneWareAccountUserIdKey] = + settingsService.GetSettingValue(OneWareCloudIntegrationModule.OneWareAccountUserIdKey) + }, autoSave: false); + + await NotifyRunningStudioAsync(cancellationToken); + Console.WriteLine("Logged out of OneWare Cloud."); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to persist OneWare Cloud logout: {ex.Message}"); + return 1; + } + } + + private static void EnsureSettingsInitialized(IServiceProvider serviceProvider) + { + var settingsService = serviceProvider.GetRequiredService(); + var paths = serviceProvider.GetRequiredService(); + + if (!settingsService.HasSetting(OneWareCloudIntegrationModule.OneWareCloudHostKey)) + settingsService.Register(OneWareCloudIntegrationModule.OneWareCloudHostKey, + OneWareCloudIntegrationModule.OfficialHost); + + if (!settingsService.HasSetting(OneWareCloudIntegrationModule.OneWareAccountUserIdKey)) + settingsService.Register(OneWareCloudIntegrationModule.OneWareAccountUserIdKey, string.Empty); + + settingsService.Load(paths.SettingsPath); + } + + private static async Task NotifyRunningStudioAsync(CancellationToken cancellationToken) + { + try + { + await using var client = new NamedPipeClientStream(".", "oneware-studio-ipc", PipeDirection.Out); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + await client.ConnectAsync(timeout.Token); + + await using var writer = new StreamWriter(client); + await writer.WriteAsync(OneWareCloudIntegrationModule.LogoutIpcMessage.AsMemory(), cancellationToken); + await writer.FlushAsync(cancellationToken); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Studio is not running or is not ready to receive IPC messages. + } + catch (IOException) + { + // Studio is not running or is not ready to receive IPC messages. + } + } +} diff --git a/src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs b/src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs index fe7479ece..b9061b921 100644 --- a/src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs +++ b/src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs @@ -20,6 +20,7 @@ public class OneWareCloudIntegrationModule : OneWareModuleBase public const string OneWareCloudHostKey = "General_OneWareCloud_Host"; public const string OneWareAccountUserIdKey = "General_OneWareCloud_AccountUserId"; public const string CredentialStore = OfficialHost; + public const string LogoutIpcMessage = "cloud-logout"; public override void RegisterServices(IServiceCollection services) { diff --git a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs index ec3773e4d..af4ef54a1 100644 --- a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs +++ b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs @@ -9,7 +9,6 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Web; -using Avalonia.Threading; using GitCredentialManager; using Microsoft.Extensions.Logging; using OneWare.Essentials.Extensions; @@ -250,30 +249,27 @@ private void SaveCredentials(string jwt, string refreshToken) _jwtBearerTokenCache[userId] = jwtToken; - try + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Directory.CreateDirectory(_tokenPath); - var tokenPath = Path.Combine(_tokenPath, $"{userId}.bin"); + Directory.CreateDirectory(_tokenPath); + var tokenPath = Path.Combine(_tokenPath, $"{userId}.bin"); - var plaintext = Encoding.UTF8.GetBytes(refreshToken); - var encrypted = ProtectedData.Protect(plaintext, null, DataProtectionScope.CurrentUser); - File.WriteAllBytes(tokenPath, encrypted); - } - else - { - var store = CredentialManager.Create("oneware"); - store.AddOrUpdate(OneWareCloudIntegrationModule.CredentialStore, userId, refreshToken); - } + var plaintext = Encoding.UTF8.GetBytes(refreshToken); + var encrypted = ProtectedData.Protect(plaintext, null, DataProtectionScope.CurrentUser); + File.WriteAllBytes(tokenPath, encrypted); } - catch (Exception e) + else { - _logger.Error(e.Message, e); + var store = CredentialManager.Create("oneware"); + store.AddOrUpdate(OneWareCloudIntegrationModule.CredentialStore, userId, refreshToken); } _settingService.SetSettingValue(OneWareCloudIntegrationModule.OneWareAccountUserIdKey, userId); - _settingService.Save(_paths.SettingsPath); + _settingService.SaveValues(_paths.SettingsPath, + new Dictionary + { + [OneWareCloudIntegrationModule.OneWareAccountUserIdKey] = userId + }); } /// @@ -461,8 +457,13 @@ public async Task LoginAsync(CancellationToken cancellationToken = default return false; } - await ExchangeCodeForTokensAsync(code1, authProviderBaseUrl, redirectUri, - persistTokens: false, clientIdOverride: "Empty"); + if (!await ExchangeCodeForTokensAsync(code1, authProviderBaseUrl, redirectUri, + persistTokens: false, clientIdOverride: "Empty")) + { + step1Response.StatusCode = 500; + step1Response.Close(); + return false; + } _offlineCodeVerifier = GenerateCodeVerifier(); string offlineCodeChallenge = GenerateCodeChallenge(_offlineCodeVerifier); @@ -509,8 +510,13 @@ await ExchangeCodeForTokensAsync(code1, authProviderBaseUrl, redirectUri, return false; } - await ExchangeCodeForTokensAsync(code2, authProviderBaseUrl, redirectUri, - persistTokens: true, codeVerifierOverride: _offlineCodeVerifier); + if (!await ExchangeCodeForTokensAsync(code2, authProviderBaseUrl, redirectUri, + persistTokens: true, codeVerifierOverride: _offlineCodeVerifier)) + { + step2Response.StatusCode = 500; + step2Response.Close(); + return false; + } var cloudHost = _settingService.GetSettingValue(OneWareCloudIntegrationModule.OneWareCloudHostKey) .TrimEnd('/'); @@ -550,7 +556,7 @@ await ExchangeCodeForTokensAsync(code2, authProviderBaseUrl, redirectUri, return startNewListener; } - private async Task ExchangeCodeForTokensAsync(string code, string authProviderBaseUrl, string redirectUri, + private async Task ExchangeCodeForTokensAsync(string code, string authProviderBaseUrl, string redirectUri, bool persistTokens = true, string? codeVerifierOverride = null, string? clientIdOverride = null) { try @@ -579,7 +585,7 @@ private async Task ExchangeCodeForTokensAsync(string code, string authProviderBa if (string.IsNullOrWhiteSpace(accessToken)) { _logger.Error("Access token not found in response"); - return; + return false; } if (persistTokens) @@ -587,14 +593,10 @@ private async Task ExchangeCodeForTokensAsync(string code, string authProviderBa if (string.IsNullOrWhiteSpace(refreshToken)) { _logger.Error("Refresh token not found in step-2 response"); - return; + return false; } - await Dispatcher.UIThread.InvokeAsync(() => - { - SaveCredentials(accessToken, refreshToken); - _settingService.Save(_paths.SettingsPath); - }); + SaveCredentials(accessToken, refreshToken); } else { @@ -603,15 +605,17 @@ await Dispatcher.UIThread.InvokeAsync(() => if (userId != null) _jwtBearerTokenCache[userId] = jwtToken; } + + return true; } - else - { - _logger.Error($"Failed to exchange code for tokens: {response.StatusCode} - {SanitizeForLog(response.Content)}"); - } + + _logger.Error($"Failed to exchange code for tokens: {response.StatusCode} - {SanitizeForLog(response.Content)}"); + return false; } catch (Exception e) { _logger.Error(e.Message, e); + return false; } } diff --git a/src/OneWare.Core/AssemblyInfo.cs b/src/OneWare.Core/AssemblyInfo.cs new file mode 100644 index 000000000..6d4e2d4ed --- /dev/null +++ b/src/OneWare.Core/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OneWare.Studio.Desktop.UnitTests")] diff --git a/src/OneWare.Core/ModuleLogic/OneWareCliModuleCatalog.cs b/src/OneWare.Core/ModuleLogic/OneWareCliModuleCatalog.cs new file mode 100644 index 000000000..b5b52b4b2 --- /dev/null +++ b/src/OneWare.Core/ModuleLogic/OneWareCliModuleCatalog.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using OneWare.Essentials.Services; + +namespace OneWare.Core.ModuleLogic; + +public sealed class OneWareCliModuleCatalog +{ + private readonly List _modules = new(); + + public IReadOnlyList Modules => _modules; + + public OneWareCliModuleCatalog AddModule() where T : IOneWareCliModule, new() + { + return AddModule(new T()); + } + + public OneWareCliModuleCatalog AddModule(IOneWareCliModule module) + { + if (_modules.Any(x => string.Equals(x.Id, module.Id, StringComparison.OrdinalIgnoreCase))) + return this; + + _modules.Add(module); + return this; + } + + public IReadOnlyList AddModulesFromAssembly(Assembly assembly) + { + var added = new List(); + Type[] candidates; + try + { + candidates = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + candidates = ex.Types.Where(x => x != null).Cast().ToArray(); + } + + var types = candidates + .Where(x => x.IsPublic && + !x.IsAbstract && + x.GetConstructor(Type.EmptyTypes) is not null && + typeof(IOneWareCliModule).IsAssignableFrom(x)); + + foreach (var type in types) + { + if (Activator.CreateInstance(type) is not IOneWareCliModule module) + continue; + + if (_modules.Any(x => string.Equals(x.Id, module.Id, StringComparison.OrdinalIgnoreCase))) + continue; + + _modules.Add(module); + added.Add(module); + } + + return added; + } +} \ No newline at end of file diff --git a/src/OneWare.Core/ModuleLogic/OneWareCliModuleManager.cs b/src/OneWare.Core/ModuleLogic/OneWareCliModuleManager.cs new file mode 100644 index 000000000..4b2e9c60e --- /dev/null +++ b/src/OneWare.Core/ModuleLogic/OneWareCliModuleManager.cs @@ -0,0 +1,149 @@ +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OneWare.Essentials.Services; + +namespace OneWare.Core.ModuleLogic; + +public sealed class OneWareCliModuleManager +{ + private readonly OneWareCliModuleCatalog _catalog; + private ILogger? _logger; + + public OneWareCliModuleManager(OneWareCliModuleCatalog catalog) + { + _catalog = catalog; + } + + public void SetLogger(ILogger logger) + { + _logger = logger; + } + + public void RegisterModuleServices(IServiceCollection services, IEnumerable? modules = null) + { + foreach (var module in GetInitializationOrder(modules)) + try + { + module.RegisterServices(services); + } + catch (Exception ex) + { + _logger?.LogError($"Registering services for module '{module.Id}' failed: {ex.Message}", ex); + } + } + + public IReadOnlyList RegisterCommands(IServiceProvider provider, + IEnumerable? modules = null) + { + List commands = new(); + HashSet registeredNames = new(StringComparer.OrdinalIgnoreCase); + foreach (var module in GetInitializationOrder(modules)) + try + { + foreach (var command in module.RegisterCommands(provider)) + { + if (!TryRegisterCommandName(module, command, registeredNames)) + continue; + + commands.Add(command); + } + + _logger?.Log($"CLI commands for module '{module.Id}' registered."); + } + catch (Exception ex) + { + _logger?.Error($"Registering commands for module '{module.Id}' failed: {ex.Message}", ex); + } + + return commands; + } + + public IReadOnlyList GetInitializationOrder(IEnumerable? modules = null) + { + var moduleList = (modules ?? _catalog.Modules).ToList(); + if (moduleList.Count <= 1) + return moduleList; + + var moduleById = moduleList + .GroupBy(x => x.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var catalogById = _catalog.Modules + .GroupBy(x => x.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var edges = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var indegree = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var module in moduleList) indegree.TryAdd(module.Id, 0); + + foreach (var module in moduleList) + foreach (var dependency in module.Dependencies ?? []) + { + if (!moduleById.ContainsKey(dependency)) + { + if (!catalogById.ContainsKey(dependency)) + _logger?.Warning($"Module '{module.Id}' depends on missing module '{dependency}'."); + continue; + } + + if (!edges.TryGetValue(dependency, out var list)) + { + list = new List(); + edges[dependency] = list; + } + + list.Add(module.Id); + indegree[module.Id] = indegree.GetValueOrDefault(module.Id) + 1; + } + + var queue = new Queue(moduleList.Where(m => indegree[m.Id] == 0).Select(m => m.Id)); + var ordered = new List(); + + while (queue.Count > 0) + { + var id = queue.Dequeue(); + ordered.Add(moduleById[id]); + + if (!edges.TryGetValue(id, out var dependents)) + continue; + + foreach (var dependent in dependents) + { + indegree[dependent]--; + if (indegree[dependent] == 0) + queue.Enqueue(dependent); + } + } + + if (ordered.Count != moduleList.Count) + { + _logger?.Warning("Module dependency graph contains cycles; falling back to declared order."); + return moduleList; + } + + return ordered; + } + + private bool TryRegisterCommandName(IOneWareCliModule module, Command command, HashSet registeredNames) + { + if (!registeredNames.Add(command.Name)) + { + _logger?.LogError("Skipping CLI command '{CommandName}' from module '{ModuleId}' because the name is already registered.", + command.Name, module.Id); + return false; + } + + foreach (var alias in command.Aliases) + if (!registeredNames.Add(alias)) + { + _logger?.LogError( + "Skipping CLI command '{CommandName}' from module '{ModuleId}' because the alias '{Alias}' is already registered.", + command.Name, module.Id, alias); + registeredNames.Remove(command.Name); + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/OneWare.Core/OneWare.Core.csproj b/src/OneWare.Core/OneWare.Core.csproj index 7c78e53aa..395a1690d 100644 --- a/src/OneWare.Core/OneWare.Core.csproj +++ b/src/OneWare.Core/OneWare.Core.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/OneWare.Core/Services/OneWareStartupCommandLine.cs b/src/OneWare.Core/Services/OneWareStartupCommandLine.cs new file mode 100644 index 000000000..d6d689b77 --- /dev/null +++ b/src/OneWare.Core/Services/OneWareStartupCommandLine.cs @@ -0,0 +1,104 @@ +using System.CommandLine; + +namespace OneWare.Core.Services; + +public static class OneWareStartupCommandLine +{ + public static RootCommand CreateRootCommand(OneWareStartupSymbols symbols) + { + return new RootCommand + { + Options = + { + symbols.DirOption, + symbols.AppdataDirOption, + symbols.ProjectsDirOption, + symbols.ModuleOption, + symbols.AutoLaunchOption, + symbols.PackageRepositoryOption, + symbols.ConfigurationProfileOption, + symbols.WorkingDirectoryOption + }, + Arguments = + { + symbols.OpenArgument + } + }; + } + + public static OneWareStartupSymbols CreateSymbols( + string openArgumentDescription = "File/Folder path or oneware:// URI to open") + { + return new OneWareStartupSymbols( + new Option("--oneware-dir") { Description = "Path to documents directory for OneWare Studio. (optional)" }, + new Option("--oneware-projects-dir") { Description = "Path to default projects directory for OneWare Studio. (optional)" }, + new Option("--oneware-appdata-dir") { Description = "Path to application data directory for OneWare Studio. (optional)" }, + new Option("--modules") { Description = "Adds plugin to OneWare Studio during initialization. (optional)" }, + new Option("--autolaunch") { Description = "Auto launches a specific action after OneWare Studio is loaded. Can be used by plugins (optional)" }, + new Option("--package-repository") { Description = "Overrides the package repository URL(s) used by OneWare Studio. Separate multiple URLs with ';'. (optional)" }, + new Option("--configuration-profile") { Description = "Applies a configuration profile (settings, packages, package sources) at startup. Accepts a file path or an http(s) URL. (optional)" }, + new Option("--working-directory", "-C") { Description = "Changes the working directory for this command." }, + new Argument("open") { Description = openArgumentDescription, DefaultValueFactory = _ => null }); + } + + public static void ApplyEnvironmentVariables(ParseResult parseResult, OneWareStartupSymbols symbols) + { + SetWorkingDirectory(parseResult.GetValue(symbols.WorkingDirectoryOption)); + SetPathEnvironmentVariable("ONEWARE_DIR", parseResult.GetValue(symbols.DirOption)); + SetPathEnvironmentVariable("ONEWARE_PROJECTS_DIR", parseResult.GetValue(symbols.ProjectsDirOption)); + SetPathEnvironmentVariable("ONEWARE_APPDATA_DIR", parseResult.GetValue(symbols.AppdataDirOption)); + SetEnvironmentVariable("ONEWARE_MODULES", parseResult.GetValue(symbols.ModuleOption)); + SetEnvironmentVariable("ONEWARE_AUTOLAUNCH", parseResult.GetValue(symbols.AutoLaunchOption)); + SetEnvironmentVariable("ONEWARE_PACKAGE_REPOSITORY", parseResult.GetValue(symbols.PackageRepositoryOption)); + SetEnvironmentVariable("ONEWARE_CONFIGURATION_PROFILE", parseResult.GetValue(symbols.ConfigurationProfileOption)); + + var openValue = parseResult.GetValue(symbols.OpenArgument); + if (string.IsNullOrEmpty(openValue)) + return; + + if (openValue.StartsWith("oneware://", StringComparison.OrdinalIgnoreCase)) + Environment.SetEnvironmentVariable("ONEWARE_OPEN_URL", openValue); + else if (File.Exists(openValue) || Directory.Exists(openValue)) + Environment.SetEnvironmentVariable("ONEWARE_OPEN_PATH", Path.GetFullPath(openValue)); + } + + public static bool ContainsOneWareUriArgument(IEnumerable args) + { + return args.Any(x => x.StartsWith("oneware://", StringComparison.OrdinalIgnoreCase)); + } + + private static void SetPathEnvironmentVariable(string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + Environment.SetEnvironmentVariable(key, Path.GetFullPath(value)); + } + + private static void SetEnvironmentVariable(string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + Environment.SetEnvironmentVariable(key, value); + } + + private static void SetWorkingDirectory(string? value) + { + if (string.IsNullOrEmpty(value)) + return; + + var path = Path.GetFullPath(value); + if (!Directory.Exists(path)) + throw new DirectoryNotFoundException($"The working directory '{path}' does not exist."); + + Environment.CurrentDirectory = path; + } +} + +public sealed record OneWareStartupSymbols( + Option DirOption, + Option ProjectsDirOption, + Option AppdataDirOption, + Option ModuleOption, + Option AutoLaunchOption, + Option PackageRepositoryOption, + Option ConfigurationProfileOption, + Option WorkingDirectoryOption, + Argument OpenArgument); diff --git a/src/OneWare.Core/Services/PluginAssemblyLoader.cs b/src/OneWare.Core/Services/PluginAssemblyLoader.cs new file mode 100644 index 000000000..fa8249dd3 --- /dev/null +++ b/src/OneWare.Core/Services/PluginAssemblyLoader.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using OneWare.Essentials.Helpers; + +namespace OneWare.Core.Services; + +public static class PluginAssemblyLoader +{ + // Usually we can assume that all managed DLLs will be in the base dir of a plugin. + // Some libraries ship in runtimes/arch/lib/... + public static bool ShouldProbePluginAssembly(string pluginPath, string filePath) + { + var relativePath = Path.GetRelativePath(pluginPath, filePath); + var pathSegments = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (pathSegments.Length < 2 || !pathSegments[0].Equals("runtimes", StringComparison.OrdinalIgnoreCase)) + return true; + + if (pathSegments.Length < 4) + return false; + + return pathSegments[1].Equals(PlatformHelper.PlatformIdentifier, StringComparison.OrdinalIgnoreCase) + && pathSegments[2].Equals("lib", StringComparison.OrdinalIgnoreCase); + } + + public static bool TryGetManagedAssemblyName(string filePath, out AssemblyName assemblyName) + { + try + { + assemblyName = AssemblyName.GetAssemblyName(filePath); + return true; + } + catch (BadImageFormatException) + { + } + catch (FileLoadException) + { + } + + assemblyName = null!; + return false; + } +} diff --git a/src/OneWare.Core/Services/PluginNativeLibraryResolver.cs b/src/OneWare.Core/Services/PluginNativeLibraryResolver.cs new file mode 100644 index 000000000..4090cbbaa --- /dev/null +++ b/src/OneWare.Core/Services/PluginNativeLibraryResolver.cs @@ -0,0 +1,72 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using OneWare.Essentials.Helpers; + +namespace OneWare.Core.Services; + +public static class PluginNativeLibraryResolver +{ + private static readonly HashSet ConfiguredAssemblies = []; + private static readonly Lock ConfiguredAssembliesLock = new(); + + public static void Configure( + string pluginPath, + IEnumerable assemblies, + Action? resolverAlreadyConfigured = null) + { + foreach (var assembly in assemblies) + { + lock (ConfiguredAssembliesLock) + { + if (!ConfiguredAssemblies.Add(assembly)) + continue; + } + + try + { + NativeLibrary.SetDllImportResolver( + assembly, + (libraryName, _, _) => Resolve(pluginPath, libraryName)); + } + catch (InvalidOperationException) + { + // An assembly can provide its own resolver; leave it unchanged. + resolverAlreadyConfigured?.Invoke(assembly); + } + } + } + + internal static IReadOnlyList GetCandidatePaths( + string pluginPath, + string libraryName, + string applicationBaseDirectory) + { + var libraryFileName = PlatformHelper.GetLibraryFileName(libraryName); + var platformNativeDirectory = Path.Combine( + pluginPath, + "runtimes", + PlatformHelper.PlatformIdentifier, + "native"); + + return + [ + Path.Combine(platformNativeDirectory, libraryFileName), + Path.Combine(platformNativeDirectory, $"lib{libraryFileName}"), + Path.Combine(pluginPath, libraryFileName), + Path.Combine(pluginPath, $"lib{libraryFileName}"), + Path.Combine(applicationBaseDirectory, libraryFileName), + Path.Combine(applicationBaseDirectory, $"lib{libraryFileName}") + ]; + } + + private static IntPtr Resolve(string pluginPath, string libraryName) + { + foreach (var libraryPath in GetCandidatePaths(pluginPath, libraryName, AppContext.BaseDirectory)) + { + if (File.Exists(libraryPath) && NativeLibrary.TryLoad(libraryPath, out var handle)) + return handle; + } + + return NativeLibrary.TryLoad(libraryName, out var fallbackHandle) ? fallbackHandle : IntPtr.Zero; + } +} diff --git a/src/OneWare.Core/Services/PluginService.cs b/src/OneWare.Core/Services/PluginService.cs index afc363b6a..f0e207869 100644 --- a/src/OneWare.Core/Services/PluginService.cs +++ b/src/OneWare.Core/Services/PluginService.cs @@ -21,10 +21,6 @@ public class PluginService : IPluginService private readonly IApplicationStateService _applicationStateService; private readonly string _pluginDirectory; - private readonly HashSet _resolverSetAssemblies = new(); - - private List _initAssemblies; - public PluginService(OneWareModuleCatalog moduleCatalog, OneWareModuleManager moduleManager, ModuleServiceRegistry moduleServiceRegistry, IPaths paths, IApplicationStateService applicationStateService) { @@ -34,8 +30,6 @@ public PluginService(OneWareModuleCatalog moduleCatalog, OneWareModuleManager mo _moduleServiceRegistry = moduleServiceRegistry; _applicationStateService = applicationStateService; - _initAssemblies = AppDomain.CurrentDomain.GetAssemblies().ToList(); - _pluginDirectory = Path.Combine(paths.SessionDirectory, "Plugins"); Directory.CreateDirectory(_pluginDirectory); } @@ -44,8 +38,7 @@ public PluginService(OneWareModuleCatalog moduleCatalog, OneWareModuleManager mo public IPlugin AddPlugin(string path) { - // Update known assemblies to avoid redundant resolver registration - _initAssemblies = AppDomain.CurrentDomain.GetAssemblies().ToList(); + var initialAssemblies = AppDomain.CurrentDomain.GetAssemblies().ToHashSet(); var plugin = new Plugin(Path.GetFileName(path), path); InstalledPlugins.Add(plugin); @@ -82,7 +75,12 @@ public IPlugin AddPlugin(string path) //We should not use that anymore, since it can break compatibility with code signed apps //We keep it for now except on MacOS - if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) SetupNativeImports(realPath); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + PluginNativeLibraryResolver.Configure( + realPath, + AppDomain.CurrentDomain.GetAssemblies().Where(assembly => !initialAssemblies.Contains(assembly)), + assembly => ContainerLocator.Container?.Resolve().Warning( + $"Skipping resolver setup for {assembly.FullName}, resolver already set.")); } catch (Exception e) { @@ -114,9 +112,9 @@ private IReadOnlyList LoadModulesFromPath(string path) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var file in Directory.GetFiles(path, "*.dll", SearchOption.AllDirectories) - .Where(file => ShouldProbePluginAssembly(path, file))) + .Where(file => PluginAssemblyLoader.ShouldProbePluginAssembly(path, file))) { - if (!TryGetManagedAssemblyName(file, out var assemblyName)) + if (!PluginAssemblyLoader.TryGetManagedAssemblyName(file, out var assemblyName)) continue; if (assemblyName.FullName is { } fullName && loadedAssemblyNames.Contains(fullName)) @@ -145,101 +143,4 @@ private IReadOnlyList LoadModulesFromPath(string path) return added; } - // Usually we can assume that all managed DLLs will be in the base dir of a plugin - // Some libraries ship in runtimes/arch/lib/... - private static bool ShouldProbePluginAssembly(string pluginPath, string filePath) - { - var relativePath = Path.GetRelativePath(pluginPath, filePath); - var pathSegments = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - if (pathSegments.Length < 2 || !pathSegments[0].Equals("runtimes", StringComparison.OrdinalIgnoreCase)) - return true; - - if (pathSegments.Length < 4) - return false; - - return pathSegments[1].Equals(PlatformHelper.PlatformIdentifier, StringComparison.OrdinalIgnoreCase) - && pathSegments[2].Equals("lib", StringComparison.OrdinalIgnoreCase); - } - - private static bool TryGetManagedAssemblyName(string filePath, out AssemblyName assemblyName) - { - try - { - assemblyName = AssemblyName.GetAssemblyName(filePath); - return true; - } - catch (BadImageFormatException) - { - } - catch (FileLoadException) - { - } - - assemblyName = null!; - return false; - } - - private void SetupNativeImports(string pluginPath) - { - var newAssemblies = AppDomain.CurrentDomain.GetAssemblies().Where(x => !_initAssemblies.Contains(x)); - - foreach (var assembly in newAssemblies) - { - _initAssemblies.Add(assembly); - - if (assembly.FullName == null) continue; - - if (_resolverSetAssemblies.Contains(assembly.FullName)) - continue; - - try - { - NativeLibrary.SetDllImportResolver(assembly, (libraryName, _, _) => - { - // Try 1 : Check runtimes folder - var libFileName = PlatformHelper.GetLibraryFileName(libraryName); - var libPath = Path.Combine(pluginPath, "runtimes", PlatformHelper.PlatformIdentifier, "native", - libFileName); - - // Try 2 : add lib infront in runtimes folder - if (!File.Exists(libPath)) - libPath = Path.Combine(pluginPath, "runtimes", PlatformHelper.PlatformIdentifier, "native", - $"lib{libFileName}"); - - // Try 3: check base - if (!File.Exists(libPath)) - libPath = Path.Combine(pluginPath, libFileName); - - // Try 4 : base with lib infront - if (!File.Exists(libPath)) - libPath = Path.Combine(pluginPath, $"lib{libFileName}"); - - // Try 5: MacOS weirdness, look in (own) base folder - // TODO find out why this is not automatic in MacOS, and why even without this we don't have issues - if (!File.Exists(libPath)) - libPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, libFileName); - - // Try 6: Same as 5 but added lib Prefix - if (!File.Exists(libPath)) - libPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, $"lib{libFileName}"); - - if (NativeLibrary.TryLoad(libPath, out var customHandle)) return customHandle; - - if (NativeLibrary.TryLoad(libraryName, out var handle)) return handle; - - Console.WriteLine($"Loading native library {libraryName} failed {File.Exists(libPath)}"); - return IntPtr.Zero; - }); - - _resolverSetAssemblies.Add(assembly.FullName); - } - catch (InvalidOperationException) - { - // This assembly already has a resolver — log and continue - ContainerLocator.Container.Resolve().Warning( - $"Skipping resolver setup for {assembly.FullName}, resolver already set."); - } - } - } } diff --git a/src/OneWare.Essentials/OneWare.Essentials.csproj b/src/OneWare.Essentials/OneWare.Essentials.csproj index e9f74ef02..963cbe70f 100644 --- a/src/OneWare.Essentials/OneWare.Essentials.csproj +++ b/src/OneWare.Essentials/OneWare.Essentials.csproj @@ -40,4 +40,8 @@ + + + + diff --git a/src/OneWare.Essentials/PackageManager/Compatibility/PluginCompatibilityChecker.cs b/src/OneWare.Essentials/PackageManager/Compatibility/PluginCompatibilityChecker.cs index cc4c7017b..83254883f 100644 --- a/src/OneWare.Essentials/PackageManager/Compatibility/PluginCompatibilityChecker.cs +++ b/src/OneWare.Essentials/PackageManager/Compatibility/PluginCompatibilityChecker.cs @@ -1,7 +1,4 @@ using System.Reflection; -using Microsoft.Extensions.Logging; -using OneWare.Essentials.Services; - namespace OneWare.Essentials.PackageManager.Compatibility; public class PluginCompatibilityChecker @@ -14,15 +11,13 @@ public static CompatibilityReport CheckCompatibilityPath(string path) if (!File.Exists(depFilePath)) { - ContainerLocator.Container.Resolve().Error("Compatibility Check failed: compatibility.txt not found in plugin folder"); return new CompatibilityReport(false, []); } return CheckCompatibility(File.ReadAllText(depFilePath)); } - catch (Exception e) + catch { - ContainerLocator.Container.Resolve().Error(e.Message, e); return new CompatibilityReport(false, []); } } @@ -36,7 +31,6 @@ public static CompatibilityReport CheckCompatibility(string? deps) if (deps == null) { - ContainerLocator.Container.Resolve().Error("Compatibility Check failed"); return new CompatibilityReport(false, records); } @@ -119,9 +113,8 @@ public static CompatibilityReport CheckCompatibility(string? deps) return new CompatibilityReport(isCompatible, records); } - catch (Exception e) + catch { - ContainerLocator.Container.Resolve().Error(e.Message, e); return new CompatibilityReport(false, []); } } diff --git a/src/OneWare.Essentials/Services/IOneWareCliModule.cs b/src/OneWare.Essentials/Services/IOneWareCliModule.cs new file mode 100644 index 000000000..4ff90b7a0 --- /dev/null +++ b/src/OneWare.Essentials/Services/IOneWareCliModule.cs @@ -0,0 +1,27 @@ +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; + +namespace OneWare.Essentials.Services; + +public interface IOneWareCliModule +{ + /// + /// Unique module ID. + /// + string Id { get; } + + /// + /// Module IDs that must be initialized before this one. + /// + IReadOnlyCollection Dependencies { get; } + + /// + /// Registers services into the dependency injection container. + /// + void RegisterServices(IServiceCollection services); + + /// + /// Registers commands into the CLI command tree. + /// + IReadOnlyList RegisterCommands(IServiceProvider serviceProvider); +} diff --git a/src/OneWare.Essentials/Services/ISettingsService.cs b/src/OneWare.Essentials/Services/ISettingsService.cs index d9c7131e2..357079106 100644 --- a/src/OneWare.Essentials/Services/ISettingsService.cs +++ b/src/OneWare.Essentials/Services/ISettingsService.cs @@ -124,6 +124,11 @@ public void RegisterTitledListBox(string category, string subCategory, string ke /// public void Save(string path, bool autoSave = true); + /// + /// Updates selected values in the latest version of a settings file. + /// + public void SaveValues(string path, IReadOnlyDictionary values, bool autoSave = true); + /// /// Runs an action once settings are loaded. /// diff --git a/src/OneWare.Essentials/Services/OneWareCliModuleBase.cs b/src/OneWare.Essentials/Services/OneWareCliModuleBase.cs new file mode 100644 index 000000000..194ce2598 --- /dev/null +++ b/src/OneWare.Essentials/Services/OneWareCliModuleBase.cs @@ -0,0 +1,20 @@ +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; + +namespace OneWare.Essentials.Services; + +public abstract class OneWareCliModuleBase : IOneWareCliModule +{ + public virtual string Id => GetType().Name; + + public virtual IReadOnlyCollection Dependencies => Array.Empty(); + + public virtual void RegisterServices(IServiceCollection services) + { + } + + public virtual IReadOnlyList RegisterCommands(IServiceProvider serviceProvider) + { + return Array.Empty(); + } +} diff --git a/src/OneWare.Settings/SettingsService.cs b/src/OneWare.Settings/SettingsService.cs index fb7b1aae1..83f4f9c11 100644 --- a/src/OneWare.Settings/SettingsService.cs +++ b/src/OneWare.Settings/SettingsService.cs @@ -260,17 +260,8 @@ public void Save(string path, bool autoSave = true) { try { - var saveD = _settings.ToDictionary(s => s.Key, s => s.Value.Value); - - foreach (var unregistered in _unregisteredSettings) saveD.TryAdd(unregistered.Key, unregistered.Value); - - if (_loadedSettings != null) - foreach (var (key, value) in _loadedSettings) - saveD.TryAdd(key, value); - - using var stream = File.Create(path); - JsonSerializer.Serialize(stream, saveD, saveD.GetType(), JsonSerializerOptions); - Saved?.Invoke(this, new SaveEventArgs(autoSave)); + var values = CreateSaveDictionary(); + WriteSettings(path, values, autoSave); } catch (Exception e) { @@ -278,6 +269,17 @@ public void Save(string path, bool autoSave = true) } } + public void SaveValues(string path, IReadOnlyDictionary values, bool autoSave = true) + { + using var fileLock = AcquireFileLock(path); + var savedValues = ReadSettings(path); + + foreach (var (key, value) in values) + savedValues[key] = value; + + WriteSettings(path, savedValues, autoSave, fileLock); + } + public void Reset(string key) { if (_settings.TryGetValue(key, out var setting)) setting.Value = setting.DefaultValue; @@ -295,6 +297,69 @@ public void WhenLoaded(Action action) _afterLoadingActions.Add(action); } + private Dictionary CreateSaveDictionary() + { + var saveValues = _settings.ToDictionary(s => s.Key, s => (object?)s.Value.Value); + + foreach (var unregistered in _unregisteredSettings) + saveValues.TryAdd(unregistered.Key, unregistered.Value); + + if (_loadedSettings != null) + foreach (var (key, value) in _loadedSettings) + saveValues.TryAdd(key, value); + + return saveValues; + } + + private static Dictionary ReadSettings(string path) + { + if (!File.Exists(path)) + return []; + + using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); + return JsonSerializer.Deserialize>(stream, JsonSerializerOptions) ?? []; + } + + private void WriteSettings(string path, Dictionary values, bool autoSave, + FileStream? fileLock = null) + { + using var acquiredLock = fileLock is null ? AcquireFileLock(path) : null; + var temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp"; + + try + { + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + JsonSerializer.Serialize(stream, values, JsonSerializerOptions); + stream.Flush(flushToDisk: true); + } + + File.Move(temporaryPath, path, overwrite: true); + Saved?.Invoke(this, new SaveEventArgs(autoSave)); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + private static FileStream AcquireFileLock(string path) + { + var lockPath = $"{path}.lock"; + var deadline = DateTime.UtcNow.AddSeconds(10); + + while (true) + try + { + return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException) when (DateTime.UtcNow < deadline) + { + Thread.Sleep(50); + } + } + public void RegisterTitledPath(string category, string subCategory, string key, string title, string description, string defaultValue, string? watermark, string? startDir, Func? validate) { diff --git a/studio/OneWare.Studio.Cli/CliHostFactory.cs b/studio/OneWare.Studio.Cli/CliHostFactory.cs new file mode 100644 index 000000000..c4c2b99b9 --- /dev/null +++ b/studio/OneWare.Studio.Cli/CliHostFactory.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OneWare.Core.ModuleLogic; +using OneWare.Core.Services; +using OneWare.Essentials.Services; +using OneWare.Settings; + +internal static class CliHostFactory +{ + public static CliHostBuilderContext Create() + { + var services = new ServiceCollection(); + var paths = new Paths("OneWare Studio", "avares://OneWare.Studio/Assets/icon.ico"); + var moduleCatalog = new OneWareCliModuleCatalog(); + var moduleManager = new OneWareCliModuleManager(moduleCatalog); + var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Information)); + var logger = loggerFactory.CreateLogger("OneWare.Studio.Cli"); + + moduleManager.SetLogger(logger); + + services.AddSingleton(paths); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(moduleCatalog); + services.AddSingleton(moduleManager); + services.AddSingleton(loggerFactory); + services.AddSingleton(logger); + + return new CliHostBuilderContext(services, paths, moduleCatalog, moduleManager, logger); + } +} + +internal sealed class CliHostContext( + ServiceProvider serviceProvider, + OneWareCliModuleCatalog moduleCatalog, + OneWareCliModuleManager moduleManager) : IDisposable +{ + public ServiceProvider ServiceProvider { get; } = serviceProvider; + public OneWareCliModuleCatalog ModuleCatalog { get; } = moduleCatalog; + public OneWareCliModuleManager ModuleManager { get; } = moduleManager; + + public void Dispose() + { + ServiceProvider.Dispose(); + } +} + +internal sealed class CliHostBuilderContext( + ServiceCollection services, + IPaths paths, + OneWareCliModuleCatalog moduleCatalog, + OneWareCliModuleManager moduleManager, + ILogger logger) +{ + public ServiceCollection Services { get; } = services; + public IPaths Paths { get; } = paths; + public OneWareCliModuleCatalog ModuleCatalog { get; } = moduleCatalog; + public OneWareCliModuleManager ModuleManager { get; } = moduleManager; + public ILogger Logger { get; } = logger; + + public CliHostContext Build() + { + ModuleManager.RegisterModuleServices(Services, ModuleCatalog.Modules); + + var serviceProvider = Services.BuildServiceProvider(); + ContainerLocator.SetContainer(serviceProvider); + return new CliHostContext(serviceProvider, ModuleCatalog, ModuleManager); + } +} diff --git a/studio/OneWare.Studio.Cli/CliModuleLoader.cs b/studio/OneWare.Studio.Cli/CliModuleLoader.cs new file mode 100644 index 000000000..d8cb6dae1 --- /dev/null +++ b/studio/OneWare.Studio.Cli/CliModuleLoader.cs @@ -0,0 +1,162 @@ +using System.CommandLine; +using System.Reflection; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using OneWare.Core.ModuleLogic; +using OneWare.Core.Services; +using OneWare.Essentials.PackageManager.Compatibility; +using OneWare.Essentials.Services; + +internal sealed class CliModuleLoader(CliHostBuilderContext cliHostBuilder) +{ + public void RegisterBuiltInCliModules( + Func> startStudio, + Func> stopStudio) + { + cliHostBuilder.ModuleCatalog.AddModule(new StudioCliModule(startStudio, stopStudio)); + } + + public void LoadBundledCliModules() + { + var entryAssemblyPath = Assembly.GetEntryAssembly()?.Location; + var bundledAssemblyPaths = Directory + .GetFiles(AppContext.BaseDirectory, "OneWare*.dll", SearchOption.TopDirectoryOnly) + .Where(path => !string.Equals( + Path.GetFullPath(path), + entryAssemblyPath, + StringComparison.OrdinalIgnoreCase)); + + var loadedModules = LoadCliModulesFromFiles(bundledAssemblyPaths); + foreach (var module in loadedModules) + cliHostBuilder.Logger.LogInformation("Bundled CLI module '{ModuleId}' loaded.", module.Id); + } + + public void LoadPluginCliModules() + { + foreach (var pluginPath in GetCliPluginDirectories()) + try + { + if (!IsCompatiblePlugin(pluginPath)) + continue; + + var loadedModules = LoadCliModulesFromPath(pluginPath); + foreach (var module in loadedModules) + cliHostBuilder.Logger.LogInformation( + "CLI module '{ModuleId}' loaded from '{PluginPath}'.", + module.Id, + pluginPath); + } + catch (Exception ex) + { + cliHostBuilder.Logger.LogWarning(ex, "Failed loading CLI plugin modules from '{PluginPath}'.", pluginPath); + } + } + + private bool IsCompatiblePlugin(string pluginPath) + { + var compatibilityFile = Path.Combine(pluginPath, "compatibility.txt"); + if (!File.Exists(compatibilityFile)) + { + ReportIncompatiblePlugin(pluginPath, "compatibility.txt is missing."); + return false; + } + + var compatibility = PluginCompatibilityChecker.CheckCompatibilityPath(pluginPath); + if (compatibility.IsCompatible) + return true; + + ReportIncompatiblePlugin(pluginPath, compatibility.Report); + return false; + } + + private void ReportIncompatiblePlugin(string pluginPath, string reason) + { + var message = $"Skipping incompatible CLI plugin '{pluginPath}': {reason}"; + cliHostBuilder.Logger.LogWarning("{Message}", message); + } + + private IEnumerable GetCliPluginDirectories() + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (Directory.Exists(cliHostBuilder.Paths.PluginsDirectory)) + foreach (var pluginDirectory in Directory.GetDirectories(cliHostBuilder.Paths.PluginsDirectory)) + { + var fullPath = Path.GetFullPath(pluginDirectory); + if (seen.Add(fullPath)) + yield return fullPath; + } + + var moduleValue = Environment.GetEnvironmentVariable("ONEWARE_MODULES"); + if (string.IsNullOrWhiteSpace(moduleValue)) + yield break; + + foreach (var modulePath in moduleValue.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var fullPath = Path.GetFullPath(modulePath); + if (Directory.Exists(fullPath) && seen.Add(fullPath)) + yield return fullPath; + } + } + + private IReadOnlyList LoadCliModulesFromPath(string path) + { + return LoadCliModulesFromFiles( + Directory.GetFiles(path, "*.dll", SearchOption.AllDirectories) + .Where(file => PluginAssemblyLoader.ShouldProbePluginAssembly(path, file)), + path); + } + + private IReadOnlyList LoadCliModulesFromFiles( + IEnumerable assemblyFiles, + string? pluginPath = null) + { + var assemblies = new List(); + var pluginAssemblies = new List(); + var addedAssemblyNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var loadedAssembliesByName = AppDomain.CurrentDomain.GetAssemblies() + .Select(assembly => new { Assembly = assembly, FullName = assembly.GetName().FullName }) + .Where(x => !string.IsNullOrWhiteSpace(x.FullName)) + .ToDictionary(x => x.FullName!, x => x.Assembly, StringComparer.OrdinalIgnoreCase); + + foreach (var file in assemblyFiles) + { + if (!PluginAssemblyLoader.TryGetManagedAssemblyName(file, out var assemblyName)) + continue; + + if (assemblyName.FullName is not { } fullName || !addedAssemblyNames.Add(fullName)) + continue; + + if (loadedAssembliesByName.TryGetValue(fullName, out var loadedAssembly)) + { + assemblies.Add(loadedAssembly); + continue; + } + + try + { + var assembly = Assembly.LoadFrom(file); + assemblies.Add(assembly); + pluginAssemblies.Add(assembly); + } + catch (Exception ex) + { + cliHostBuilder.Logger.LogWarning(ex, "Skipping CLI plugin assembly '{AssemblyFile}'.", Path.GetFileName(file)); + } + } + + if (pluginPath is not null && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + PluginNativeLibraryResolver.Configure( + pluginPath, + pluginAssemblies, + assembly => cliHostBuilder.Logger.LogWarning( + "Skipping resolver setup for {AssemblyName}, resolver already set.", + assembly.FullName)); + + var added = new List(); + foreach (var assembly in assemblies) + added.AddRange(cliHostBuilder.ModuleCatalog.AddModulesFromAssembly(assembly)); + + return added; + } +} diff --git a/studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj b/studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj new file mode 100644 index 000000000..f0fcd03e5 --- /dev/null +++ b/studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj @@ -0,0 +1,20 @@ + + + + Exe + OneWareCLI + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/studio/OneWare.Studio.Cli/Program.cs b/studio/OneWare.Studio.Cli/Program.cs new file mode 100644 index 000000000..cf09bf299 --- /dev/null +++ b/studio/OneWare.Studio.Cli/Program.cs @@ -0,0 +1,48 @@ +using System.CommandLine; +using OneWare.Core.Services; + +var bootstrapSymbols = OneWareStartupCommandLine.CreateSymbols("oneware:// URI to open"); +var bootstrapCommand = OneWareStartupCommandLine.CreateRootCommand(bootstrapSymbols); +bootstrapCommand.TreatUnmatchedTokensAsErrors = false; + +var bootstrapParseResult = bootstrapCommand.Parse(args); +OneWareStartupCommandLine.ApplyEnvironmentVariables(bootstrapParseResult, bootstrapSymbols); + +var cliSymbols = OneWareStartupCommandLine.CreateSymbols("oneware:// URI to open"); +var rootCommand = OneWareStartupCommandLine.CreateRootCommand(cliSymbols); +var studioProcessController = new StudioProcessController(); +var cliHostBuilder = CliHostFactory.Create(); +var cliModuleLoader = new CliModuleLoader(cliHostBuilder); + +cliModuleLoader.RegisterBuiltInCliModules( + (parseResult, openTarget, detach, cancellationToken) => + { + OneWareStartupCommandLine.ApplyEnvironmentVariables(parseResult, cliSymbols); + return studioProcessController.StartStudio(openTarget, detach, cancellationToken); + }, + studioProcessController.StopStudio); + +cliModuleLoader.LoadBundledCliModules(); +cliModuleLoader.LoadPluginCliModules(); + +using var cliHost = cliHostBuilder.Build(); +foreach (var command in cliHost.ModuleManager.RegisterCommands(cliHost.ServiceProvider)) + rootCommand.Subcommands.Add(command); + +if (OneWareStartupCommandLine.ContainsOneWareUriArgument(args)) +{ + rootCommand.SetAction(parseResult => + { + if (parseResult.GetValue(cliSymbols.OpenArgument) is null) + return Task.FromResult(0); + + OneWareStartupCommandLine.ApplyEnvironmentVariables(parseResult, cliSymbols); + return studioProcessController.StartStudio( + parseResult.GetValue(cliSymbols.OpenArgument), + false, + CancellationToken.None); + }); +} + +var parseResult = rootCommand.Parse(args); +return parseResult.Invoke(); diff --git a/studio/OneWare.Studio.Cli/StudioCliModule.cs b/studio/OneWare.Studio.Cli/StudioCliModule.cs new file mode 100644 index 000000000..3b8779f6b --- /dev/null +++ b/studio/OneWare.Studio.Cli/StudioCliModule.cs @@ -0,0 +1,39 @@ +using System.CommandLine; +using OneWare.Essentials.Services; + +internal sealed class StudioCliModule( + Func> startStudio, + Func> stopStudio) : OneWareCliModuleBase +{ + public override IReadOnlyList RegisterCommands(IServiceProvider serviceProvider) + { + var studioCommand = new Command("studio", "OneWare Studio commands"); + studioCommand.Aliases.Add("desktop"); + + var startStudioCommand = new Command("start", "Start OneWare Studio and optionally open a file or folder"); + var detachOption = new Option("--detach", "-d") + { + Description = "Start OneWare Studio detached from the current terminal." + }; + var openTargetArgument = new Argument("path") + { + Description = "File or folder to open. Starts OneWare Studio without opening a target when omitted.", + DefaultValueFactory = _ => null + }; + startStudioCommand.Options.Add(detachOption); + startStudioCommand.Arguments.Add(openTargetArgument); + startStudioCommand.SetAction((parseResult, cancellationToken) => + startStudio( + parseResult, + parseResult.GetValue(openTargetArgument), + parseResult.GetValue(detachOption), + cancellationToken)); + studioCommand.Subcommands.Add(startStudioCommand); + + var stopStudioCommand = new Command("stop", "Stop OneWare Studio"); + stopStudioCommand.SetAction((_, cancellationToken) => stopStudio(cancellationToken)); + studioCommand.Subcommands.Add(stopStudioCommand); + + return [studioCommand]; + } +} diff --git a/studio/OneWare.Studio.Cli/StudioProcessController.cs b/studio/OneWare.Studio.Cli/StudioProcessController.cs new file mode 100644 index 000000000..3ad107c04 --- /dev/null +++ b/studio/OneWare.Studio.Cli/StudioProcessController.cs @@ -0,0 +1,157 @@ +using System.Diagnostics; +using System.IO.Pipes; + +internal sealed class StudioProcessController +{ + private const string PipeName = "oneware-studio-ipc"; + private const string ShutdownMessage = "shutdown"; + private static readonly string LockFilePath = Path.Combine(Path.GetTempPath(), "OneWare", "oneware-studio.lock"); + private static readonly string ExecutableExtension = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + private static readonly string StudioExecutableName = $"OneWareStudio{ExecutableExtension}"; + private static readonly string CliExecutableName = $"oneware{ExecutableExtension}"; + + public Task StartStudio(string? openTarget, bool detach, CancellationToken cancellationToken) + { + var studioPath = Path.Combine(AppContext.BaseDirectory, StudioExecutableName); + var startInfo = new ProcessStartInfo(studioPath) + { + UseShellExecute = detach, + WorkingDirectory = Environment.CurrentDirectory + }; + + if (!string.IsNullOrWhiteSpace(openTarget)) + startInfo.ArgumentList.Add(openTarget); + + return Task.FromResult(Process.Start(startInfo) is null ? 1 : 0); + } + + public async Task StopStudio(CancellationToken cancellationToken) + { + using var studioProcess = TryGetRunningStudioProcess(); + if (studioProcess is null) + { + Console.Error.WriteLine("ONE WARE Studio is not running."); + return 0; + } + + if (await TrySendToExistingInstanceAsync(ShutdownMessage, cancellationToken)) + { + if (await WaitForExitAsync(studioProcess, TimeSpan.FromSeconds(10), cancellationToken)) + return 0; + + Console.Error.WriteLine("Timed out waiting for ONE WARE Studio to exit."); + } + else + { + Console.Error.WriteLine("Could not reach ONE WARE Studio over IPC."); + } + + return 1; + } + + private static Process? TryGetRunningStudioProcess() + { + if (TryReadRunningStudioProcessFromLockFile() is { } lockedProcess) + return lockedProcess; + + var expectedPaths = new HashSet(StringComparer.OrdinalIgnoreCase) + { + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, StudioExecutableName)), + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, CliExecutableName)) + }; + + foreach (var process in Process.GetProcesses()) + try + { + if (process.Id == Environment.ProcessId) + { + process.Dispose(); + continue; + } + + var processPath = process.MainModule?.FileName; + if (processPath is not null && expectedPaths.Contains(Path.GetFullPath(processPath))) + return process; + + process.Dispose(); + } + catch + { + process.Dispose(); + } + + return null; + } + + private static Process? TryReadRunningStudioProcessFromLockFile() + { + try + { + if (!File.Exists(LockFilePath)) + return null; + + var pidText = File.ReadAllText(LockFilePath).Trim(); + if (!int.TryParse(pidText, out var pid)) + return null; + + var process = Process.GetProcessById(pid); + if (!process.HasExited) + return process; + + process.Dispose(); + return null; + } + catch + { + return null; + } + } + + private static async Task TrySendToExistingInstanceAsync(string message, CancellationToken cancellationToken) + { + try + { + await using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); + using var connectCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectCancellation.CancelAfter(TimeSpan.FromSeconds(2)); + await client.ConnectAsync(connectCancellation.Token); + + await using var writer = new StreamWriter(client); + await writer.WriteAsync(message.AsMemory(), cancellationToken); + await writer.FlushAsync(cancellationToken); + + return true; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } + catch + { + return false; + } + } + + private static async Task WaitForExitAsync(Process process, TimeSpan timeout, CancellationToken cancellationToken) + { + if (process.HasExited) + return true; + + using var timeoutCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCancellation.CancelAfter(timeout); + + try + { + await process.WaitForExitAsync(timeoutCancellation.Token); + return true; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return process.HasExited; + } + catch (OperationCanceledException) + { + throw; + } + } +} diff --git a/studio/OneWare.Studio.Desktop.WindowsInstaller/StudioComponents.wxs b/studio/OneWare.Studio.Desktop.WindowsInstaller/StudioComponents.wxs index f6b59b6ee..4af25f5d2 100644 --- a/studio/OneWare.Studio.Desktop.WindowsInstaller/StudioComponents.wxs +++ b/studio/OneWare.Studio.Desktop.WindowsInstaller/StudioComponents.wxs @@ -97,6 +97,10 @@ + + + + diff --git a/studio/OneWare.Studio.Desktop.WindowsInstaller/oneware.cmd b/studio/OneWare.Studio.Desktop.WindowsInstaller/oneware.cmd new file mode 100644 index 000000000..ffd5e04ab --- /dev/null +++ b/studio/OneWare.Studio.Desktop.WindowsInstaller/oneware.cmd @@ -0,0 +1,2 @@ +@echo off +"%~dp0OneWareCLI.exe" %* diff --git a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj index ba730d82f..79222f9bd 100644 --- a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj +++ b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj @@ -1,4 +1,4 @@ - + @@ -53,6 +53,8 @@ + + @@ -60,10 +62,53 @@ - - + + + $(MSBuildProjectDirectory)\..\OneWare.Studio.Cli\bin\$(Configuration)\net10.0\ + $(StudioCliBuildDir)$(RuntimeIdentifier)\ + Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier) + $(StudioCliBuildProperties);SelfContained=$(SelfContained) + + + + + + + + + + + + + + $(MSBuildProjectDirectory)\..\OneWare.Studio.Cli\bin\$(Configuration)\net10.0\ + $(StudioCliBuildDir)$(RuntimeIdentifier)\ + Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier) + $(StudioCliBuildProperties);SelfContained=$(SelfContained) + + + + + + + + + + diff --git a/studio/OneWare.Studio.Desktop/Program.cs b/studio/OneWare.Studio.Desktop/Program.cs index f8ae1510e..39ccb5f41 100644 --- a/studio/OneWare.Studio.Desktop/Program.cs +++ b/studio/OneWare.Studio.Desktop/Program.cs @@ -11,12 +11,16 @@ using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Dialogs; using Avalonia.Media; using Avalonia.Threading; using Dock.Settings; using Microsoft.Extensions.Logging; +using OneWare.CloudIntegration; +using OneWare.CloudIntegration.Services; using OneWare.Core.Data; +using OneWare.Core.Services; using OneWare.Core.Views.Windows; using OneWare.Essentials.Helpers; using OneWare.Essentials.Services; @@ -224,6 +228,30 @@ private static void HandleOpenTarget(string target) var logger = ContainerLocator.Container?.Resolve(); logger?.Log($"Received IPC message: {target}"); + if (target == "shutdown") + { + logger?.Log("Shutting down via IPC request"); + + if (ContainerLocator.Container?.Resolve() is { } applicationStateService) + _ = applicationStateService.TryShutdownAsync(); + else if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopApp) + desktopApp.Shutdown(); + + return; + } + + if (target == OneWareCloudIntegrationModule.LogoutIpcMessage) + { + var settingsService = ContainerLocator.Container?.Resolve(); + var userId = settingsService?.GetSettingValue( + OneWareCloudIntegrationModule.OneWareAccountUserIdKey); + + if (!string.IsNullOrWhiteSpace(userId)) + ContainerLocator.Container?.Resolve().Logout(userId); + + return; + } + var mainWindow = ContainerLocator.Container?.Resolve(); if (mainWindow != null) { @@ -267,91 +295,12 @@ public static int Main(string[] args) { try { - Option dirOption = new("--oneware-dir") - { Description = "Path to documents directory for OneWare Studio. (optional)" }; - Option projectsDirOption = new("--oneware-projects-dir") - { Description = "Path to default projects directory for OneWare Studio. (optional)" }; - Option appdataDirOption = new("--oneware-appdata-dir") - { Description = "Path to application data directory for OneWare Studio. (optional)" }; - Option moduleOption = new("--modules") - { Description = "Adds plugin to OneWare Studio during initialization. (optional)" }; - Option autoLaunchOption = new("--autolaunch") - { - Description = - "Auto launches a specific action after OneWare Studio is loaded. Can be used by plugins (optional)" - }; - Option packageRepositoryOption = new("--package-repository") - { - Description = - "Overrides the package repository URL(s) used by OneWare Studio. Separate multiple URLs with ';'. (optional)" - }; - Option configurationProfileOption = new("--configuration-profile") - { - Description = - "Applies a configuration profile (settings, packages, package sources) at startup. Accepts a file path or an http(s) URL. (optional)" - }; - Argument openArgument = new("open") - { - Description = "File/Folder path or oneware:// URI to open", - DefaultValueFactory = x => null - }; - - RootCommand rootCommand = new() - { - Options = - { - dirOption, - appdataDirOption, - projectsDirOption, - moduleOption, - autoLaunchOption, - packageRepositoryOption, - configurationProfileOption - }, - Arguments = - { - openArgument - } - }; + var startupSymbols = OneWareStartupCommandLine.CreateSymbols(); + var rootCommand = OneWareStartupCommandLine.CreateRootCommand(startupSymbols); rootCommand.SetAction(parseResult => { - var dirValue = parseResult.GetValue(dirOption); - if (!string.IsNullOrEmpty(dirValue)) - Environment.SetEnvironmentVariable("ONEWARE_DIR", Path.GetFullPath(dirValue)); - - var projectsDirValue = parseResult.GetValue(projectsDirOption); - if (!string.IsNullOrEmpty(projectsDirValue)) - Environment.SetEnvironmentVariable("ONEWARE_PROJECTS_DIR", Path.GetFullPath(projectsDirValue)); - - var appdataDirValue = parseResult.GetValue(appdataDirOption); - if (!string.IsNullOrEmpty(appdataDirValue)) - Environment.SetEnvironmentVariable("ONEWARE_APPDATA_DIR", Path.GetFullPath(appdataDirValue)); - - var moduleValue = parseResult.GetValue(moduleOption); - if (!string.IsNullOrEmpty(moduleValue)) - Environment.SetEnvironmentVariable("ONEWARE_MODULES", moduleValue); - - var autoLaunchValue = parseResult.GetValue(autoLaunchOption); - if (!string.IsNullOrEmpty(autoLaunchValue)) - Environment.SetEnvironmentVariable("ONEWARE_AUTOLAUNCH", autoLaunchValue); - - var packageRepositoryValue = parseResult.GetValue(packageRepositoryOption); - if (!string.IsNullOrEmpty(packageRepositoryValue)) - Environment.SetEnvironmentVariable("ONEWARE_PACKAGE_REPOSITORY", packageRepositoryValue); - - var configurationProfileValue = parseResult.GetValue(configurationProfileOption); - if (!string.IsNullOrEmpty(configurationProfileValue)) - Environment.SetEnvironmentVariable("ONEWARE_CONFIGURATION_PROFILE", configurationProfileValue); - - var openValue = parseResult.GetValue(openArgument); - if (!string.IsNullOrEmpty(openValue)) - { - if (openValue.StartsWith("oneware://", StringComparison.OrdinalIgnoreCase)) - Environment.SetEnvironmentVariable("ONEWARE_OPEN_URL", openValue); - else if (File.Exists(openValue) || Directory.Exists(openValue)) - Environment.SetEnvironmentVariable("ONEWARE_OPEN_PATH", Path.GetFullPath(openValue)); - } + OneWareStartupCommandLine.ApplyEnvironmentVariables(parseResult, startupSymbols); }); var commandLineParseResult = rootCommand.Parse(args); commandLineParseResult.Invoke(); diff --git a/studio/OneWare.Studio.Desktop/com.one_ware.OneWare.desktop b/studio/OneWare.Studio.Desktop/com.one_ware.OneWare.desktop index 23f2ea6dd..2aa2e7303 100644 --- a/studio/OneWare.Studio.Desktop/com.one_ware.OneWare.desktop +++ b/studio/OneWare.Studio.Desktop/com.one_ware.OneWare.desktop @@ -1,9 +1,9 @@ [Desktop Entry] Name=OneWare Studio Comment=IDE for Electronics Development -TryExec=oneware +TryExec=oneware-studio GenericName=Development Environment -Exec=oneware %u +Exec=oneware-studio %u Icon=com.one_ware.OneWare Type=Application Terminal=false diff --git a/studio/OneWare.Studio.Desktop/oneware b/studio/OneWare.Studio.Desktop/oneware new file mode 100644 index 000000000..28909c157 --- /dev/null +++ b/studio/OneWare.Studio.Desktop/oneware @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/OneWareCLI" "$@" diff --git a/studio/OneWare.Studio.Desktop/oneware-studio b/studio/OneWare.Studio.Desktop/oneware-studio new file mode 100644 index 000000000..589312693 --- /dev/null +++ b/studio/OneWare.Studio.Desktop/oneware-studio @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/OneWareStudio" "$@" diff --git a/tests/OneWare.Studio.Desktop.UnitTests/PluginNativeLibraryResolverTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/PluginNativeLibraryResolverTests.cs new file mode 100644 index 000000000..87e86548a --- /dev/null +++ b/tests/OneWare.Studio.Desktop.UnitTests/PluginNativeLibraryResolverTests.cs @@ -0,0 +1,28 @@ +using System.IO; +using OneWare.Core.Services; +using OneWare.Essentials.Helpers; +using Xunit; + +namespace OneWare.Studio.Desktop.UnitTests; + +public class PluginNativeLibraryResolverTests +{ + [Fact] + public void GetCandidatePaths_PrioritizesPluginRuntimeNativeDirectory() + { + var pluginPath = Path.Combine(Path.GetTempPath(), "OneWarePlugin"); + var applicationBaseDirectory = Path.Combine(Path.GetTempPath(), "OneWareApplication"); + var libraryFileName = PlatformHelper.GetLibraryFileName("example"); + + var paths = PluginNativeLibraryResolver.GetCandidatePaths( + pluginPath, + "example", + applicationBaseDirectory); + + Assert.Equal( + Path.Combine(pluginPath, "runtimes", PlatformHelper.PlatformIdentifier, "native", libraryFileName), + paths[0]); + Assert.Equal(Path.Combine(pluginPath, libraryFileName), paths[2]); + Assert.Equal(Path.Combine(applicationBaseDirectory, libraryFileName), paths[4]); + } +}