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 1/9] 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]); + } +} From 72988629d2a430e1fb0fb915eca23afa26bf6f25 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:13:00 +0200 Subject: [PATCH 2/9] Load CLI modules with GUI dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- studio/OneWare.Studio.Cli/CliModuleLoader.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/studio/OneWare.Studio.Cli/CliModuleLoader.cs b/studio/OneWare.Studio.Cli/CliModuleLoader.cs index d8cb6dae1..8308f5e76 100644 --- a/studio/OneWare.Studio.Cli/CliModuleLoader.cs +++ b/studio/OneWare.Studio.Cli/CliModuleLoader.cs @@ -65,7 +65,19 @@ private bool IsCompatiblePlugin(string pluginPath) if (compatibility.IsCompatible) return true; - ReportIncompatiblePlugin(pluginPath, compatibility.Report); + var blockingIssues = compatibility.Issues + .Where(issue => issue.Kind != CompatibilityRecordKind.MissingDependency) + .ToArray(); + + if (blockingIssues.Length == 0) + { + cliHostBuilder.Logger.LogInformation( + "Loading CLI modules from '{PluginPath}' without unavailable GUI dependencies.", + pluginPath); + return true; + } + + ReportIncompatiblePlugin(pluginPath, new CompatibilityReport(false, blockingIssues).Report); return false; } From 7e946d15dc7dcd3bd6d54017eb3170950d390a72 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:00:03 +0200 Subject: [PATCH 3/9] Merge --- OneWare.slnx | 1 + src/OneWare.Core/OneWare.Core.csproj | 2 +- studio/OneWare.Studio.Cli/CliModuleLoader.cs | 1 + .../ExtensionStoreCliModule.cs | 468 ++++++++++++++++++ .../OneWare.Studio.Cli.csproj | 1 + studio/OneWare.Studio.Cli/Program.cs | 3 +- .../ExtensionStoreCliModuleTests.cs | 52 ++ .../OneWare.Studio.Cli.UnitTests.csproj | 15 + 8 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 studio/OneWare.Studio.Cli/ExtensionStoreCliModule.cs create mode 100644 tests/OneWare.Studio.Cli.UnitTests/ExtensionStoreCliModuleTests.cs create mode 100644 tests/OneWare.Studio.Cli.UnitTests/OneWare.Studio.Cli.UnitTests.csproj diff --git a/OneWare.slnx b/OneWare.slnx index f4f478a1b..d02e054d7 100644 --- a/OneWare.slnx +++ b/OneWare.slnx @@ -141,6 +141,7 @@ + diff --git a/src/OneWare.Core/OneWare.Core.csproj b/src/OneWare.Core/OneWare.Core.csproj index 45b335fac..faf2fa768 100644 --- a/src/OneWare.Core/OneWare.Core.csproj +++ b/src/OneWare.Core/OneWare.Core.csproj @@ -21,7 +21,7 @@ - + diff --git a/studio/OneWare.Studio.Cli/CliModuleLoader.cs b/studio/OneWare.Studio.Cli/CliModuleLoader.cs index 8308f5e76..15f522d64 100644 --- a/studio/OneWare.Studio.Cli/CliModuleLoader.cs +++ b/studio/OneWare.Studio.Cli/CliModuleLoader.cs @@ -14,6 +14,7 @@ public void RegisterBuiltInCliModules( Func> stopStudio) { cliHostBuilder.ModuleCatalog.AddModule(new StudioCliModule(startStudio, stopStudio)); + cliHostBuilder.ModuleCatalog.AddModule(new ExtensionStoreCliModule()); } public void LoadBundledCliModules() diff --git a/studio/OneWare.Studio.Cli/ExtensionStoreCliModule.cs b/studio/OneWare.Studio.Cli/ExtensionStoreCliModule.cs new file mode 100644 index 000000000..89a8e3025 --- /dev/null +++ b/studio/OneWare.Studio.Cli/ExtensionStoreCliModule.cs @@ -0,0 +1,468 @@ +using System.Collections.ObjectModel; +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using OneWare.CloudIntegration; +using OneWare.Essentials.Enums; +using OneWare.Essentials.Models; +using OneWare.Essentials.PackageManager; +using OneWare.Essentials.PackageManager.Compatibility; +using OneWare.Essentials.Services; +using OneWare.PackageManager.Installers; +using OneWare.PackageManager.Services; + +public sealed class ExtensionStoreCliModule : OneWareCliModuleBase +{ + private const string PluginType = "Plugin"; + private const string PublicRepository = + "https://raw.githubusercontent.com/one-ware/OneWare.PublicPackages/main/oneware-packages.json"; + + public override string Id => "extension-store"; + + public override void RegisterServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(provider => new CliCompositeServiceProvider(provider)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + public override IReadOnlyList RegisterCommands(IServiceProvider serviceProvider) + { + var extensions = new Command("extensions", "Search and manage installed OneWare extensions"); + extensions.Aliases.Add("extension"); + + var search = new Command("search", "Search available extensions"); + var searchQuery = new Argument("query") + { + Description = "Text to match in an extension ID, name, or description", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = _ => null + }; + search.Arguments.Add(searchQuery); + search.SetAction((parseResult, cancellationToken) => + SearchAsync(serviceProvider, parseResult.GetValue(searchQuery), cancellationToken)); + + var list = new Command("list", "List installed extensions"); + list.Aliases.Add("installed"); + list.SetAction((_, cancellationToken) => ListAsync(serviceProvider, cancellationToken)); + + var install = new Command("install", "Install an extension"); + var installId = new Argument("id") { Description = "Extension ID" }; + var installVersion = new Option("--version") { Description = "Version to install" }; + installVersion.Aliases.Add("-v"); + var installPrerelease = new Option("--prerelease") + { + Description = "Allow a prerelease version when no version is supplied" + }; + var installIgnoreCompatibility = new Option("--ignore-compatibility") + { + Description = "Install even when the extension is incompatible with this Studio version" + }; + install.Arguments.Add(installId); + install.Options.Add(installVersion); + install.Options.Add(installPrerelease); + install.Options.Add(installIgnoreCompatibility); + install.SetAction((parseResult, cancellationToken) => InstallAsync( + serviceProvider, + parseResult.GetValue(installId) ?? string.Empty, + parseResult.GetValue(installVersion), + parseResult.GetValue(installPrerelease), + parseResult.GetValue(installIgnoreCompatibility), + cancellationToken)); + + var update = new Command("update", "Update one extension, or all installed extensions when no ID is supplied"); + var updateId = new Argument("id") + { + Description = "Installed extension ID", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = _ => null + }; + var updatePrerelease = new Option("--prerelease") { Description = "Allow prerelease updates" }; + var updateIgnoreCompatibility = new Option("--ignore-compatibility") + { + Description = "Update even when an extension is incompatible with this Studio version" + }; + update.Arguments.Add(updateId); + update.Options.Add(updatePrerelease); + update.Options.Add(updateIgnoreCompatibility); + update.SetAction((parseResult, cancellationToken) => UpdateAsync( + serviceProvider, + parseResult.GetValue(updateId), + parseResult.GetValue(updatePrerelease), + parseResult.GetValue(updateIgnoreCompatibility), + cancellationToken)); + + var uninstall = new Command("uninstall", "Uninstall an extension"); + uninstall.Aliases.Add("remove"); + var uninstallId = new Argument("id") { Description = "Installed extension ID" }; + uninstall.Arguments.Add(uninstallId); + uninstall.SetAction((parseResult, cancellationToken) => + UninstallAsync(serviceProvider, parseResult.GetValue(uninstallId) ?? string.Empty, cancellationToken)); + + extensions.Subcommands.Add(search); + extensions.Subcommands.Add(list); + extensions.Subcommands.Add(install); + extensions.Subcommands.Add(update); + extensions.Subcommands.Add(uninstall); + return [extensions]; + } + + public static bool IsExtensionStoreCommand(IEnumerable args) + { + var arguments = args.ToArray(); + if (arguments.Any(arg => string.Equals(arg, "oneai", StringComparison.OrdinalIgnoreCase))) + return false; + + return arguments.Any(arg => string.Equals(arg, "extensions", StringComparison.OrdinalIgnoreCase) || + string.Equals(arg, "extension", StringComparison.OrdinalIgnoreCase)); + } + + private static async Task SearchAsync(IServiceProvider services, string? query, CancellationToken cancellationToken) + { + var packages = await RefreshAsync(services, cancellationToken); + if (packages is null) + return 1; + + var matches = packages.Values + .Where(IsPlugin) + .Where(state => string.IsNullOrWhiteSpace(query) || Matches(state.Package, query)) + .OrderBy(state => state.Package.Name ?? state.Package.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (matches.Length == 0) + { + Console.WriteLine("No matching extensions found."); + return 0; + } + + foreach (var state in matches) + Console.WriteLine($"{Display(state.Package.Id)}\t{Display(state.Package.Name)}\t{Display(LatestVersion(state.Package))}\t{state.Status}"); + + return 0; + } + + private static async Task ListAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var packages = await RefreshAsync(services, cancellationToken); + if (packages is null) + return 1; + + var installed = packages.Values + .Where(IsPlugin) + .Where(state => state.InstalledVersion is not null) + .OrderBy(state => state.Package.Name ?? state.Package.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (installed.Length == 0) + { + Console.WriteLine("No extensions are installed."); + return 0; + } + + foreach (var state in installed) + Console.WriteLine($"{Display(state.Package.Id)}\t{Display(state.Package.Name)}\t{Display(state.InstalledVersion?.Version)}\t{state.Status}"); + + return 0; + } + + private static async Task InstallAsync(IServiceProvider services, string id, string? version, + bool prerelease, bool ignoreCompatibility, CancellationToken cancellationToken) + { + var packages = await RefreshAsync(services, cancellationToken); + if (packages is null) + return 1; + + if (!packages.TryGetValue(id, out var state) || !IsPlugin(state)) + { + Console.Error.WriteLine($"Extension '{Display(id)}' was not found."); + return 1; + } + + var selectedVersion = FindVersion(state.Package, version); + if (version is not null && selectedVersion is null) + { + Console.Error.WriteLine($"Version '{Display(version)}' is not available for extension '{Display(id)}'."); + return 1; + } + + var result = await services.GetRequiredService() + .InstallAsync(id, selectedVersion, prerelease, ignoreCompatibility, cancellationToken); + return ReportOperation("install", id, result); + } + + private static async Task UpdateAsync(IServiceProvider services, string? id, bool prerelease, + bool ignoreCompatibility, CancellationToken cancellationToken) + { + var packages = await RefreshAsync(services, cancellationToken); + if (packages is null) + return 1; + + var selected = string.IsNullOrWhiteSpace(id) + ? packages.Values.Where(IsPlugin).Where(state => state.InstalledVersion is not null) + .Where(state => prerelease || HasUpdate(state)).ToArray() + : packages.TryGetValue(id, out var state) && IsPlugin(state) && state.InstalledVersion is not null + ? [state] + : []; + + if (selected.Length == 0) + { + Console.Error.WriteLine(string.IsNullOrWhiteSpace(id) + ? "No extension updates are available." + : $"Installed extension '{Display(id)}' was not found."); + return string.IsNullOrWhiteSpace(id) ? 0 : 1; + } + + if (!string.IsNullOrWhiteSpace(id) && !prerelease && !HasUpdate(selected[0])) + { + Console.WriteLine($"Extension '{Display(id)}' is already up to date."); + return 0; + } + + var packageService = services.GetRequiredService(); + var exitCode = 0; + foreach (var extension in selected) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = await packageService.UpdateAsync(extension.Package.Id!, null, prerelease, + ignoreCompatibility, cancellationToken); + exitCode = Math.Max(exitCode, ReportOperation("update", extension.Package.Id!, result)); + } + + return exitCode; + } + + private static async Task UninstallAsync(IServiceProvider services, string id, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var packages = await RefreshAsync(services, cancellationToken); + if (packages is null) + return 1; + + if (!packages.TryGetValue(id, out var state) || !IsPlugin(state) || state.InstalledVersion is null) + { + Console.Error.WriteLine($"Installed extension '{Display(id)}' was not found."); + return 1; + } + + var removed = await services.GetRequiredService().RemoveAsync(id); + if (!removed) + { + Console.Error.WriteLine($"Unable to uninstall extension '{Display(id)}'."); + return 1; + } + + Console.WriteLine($"Uninstalled extension '{Display(id)}'."); + return 0; + } + + private static async Task?> RefreshAsync( + IServiceProvider services, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + ConfigurePackageSources(services); + var packageService = services.GetRequiredService(); + packageService.RegisterInstaller(PluginType); + if (!await packageService.RefreshAsync(true)) + { + Console.Error.WriteLine("Unable to refresh the extension catalog."); + return null; + } + + return packageService.Packages; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + Console.Error.WriteLine("Unable to initialize the extension catalog."); + return null; + } + } + + private static void ConfigurePackageSources(IServiceProvider services) + { + var settings = services.GetRequiredService(); + var paths = services.GetRequiredService(); + + if (!settings.HasSetting(OneWareCloudIntegrationModule.OneWareCloudHostKey)) + settings.Register(OneWareCloudIntegrationModule.OneWareCloudHostKey, OneWareCloudIntegrationModule.OfficialHost); + if (!settings.HasSetting("PackageManager_Sources")) + settings.Register("PackageManager_Sources", new ObservableCollection()); + if (!settings.HasSetting("PackageManager_OnlyCustomSources")) + settings.Register("PackageManager_OnlyCustomSources", false); + + settings.Load(paths.SettingsPath); + + var sources = Environment.GetEnvironmentVariable("ONEWARE_PACKAGE_REPOSITORY"); + var repositories = !string.IsNullOrWhiteSpace(sources) + ? sources.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : [ + new Uri(new Uri(settings.GetSettingValue(OneWareCloudIntegrationModule.OneWareCloudHostKey)), + "api/studio/packages").AbsoluteUri, + PublicRepository + ]; + + services.GetRequiredService().RegisterPackageRepositoryWithFallback(repositories); + } + + private static bool IsPlugin(IPackageState state) + { + return string.Equals(state.Package.Type, PluginType, StringComparison.OrdinalIgnoreCase); + } + + private static bool Matches(Package package, string query) + { + return package.Id?.Contains(query, StringComparison.OrdinalIgnoreCase) == true || + package.Name?.Contains(query, StringComparison.OrdinalIgnoreCase) == true || + package.Description?.Contains(query, StringComparison.OrdinalIgnoreCase) == true; + } + + private static bool HasUpdate(IPackageState state) + { + return state.Status is PackageStatus.UpdateAvailable or PackageStatus.UpdateAvailablePrerelease; + } + + private static PackageVersion? FindVersion(Package package, string? requestedVersion) + { + return requestedVersion is null + ? null + : package.Versions?.FirstOrDefault(version => + string.Equals(version.Version, requestedVersion, StringComparison.OrdinalIgnoreCase)); + } + + private static string? LatestVersion(Package package) + { + return package.Versions?.LastOrDefault()?.Version; + } + + private static int ReportOperation(string operation, string id, PackageInstallResult result) + { + switch (result.Status) + { + case PackageInstallResultReason.Installed: + Console.WriteLine($"{char.ToUpperInvariant(operation[0])}{operation[1..]}d extension '{Display(id)}'."); + return 0; + case PackageInstallResultReason.AlreadyInstalled: + Console.WriteLine($"Extension '{Display(id)}' is already up to date."); + return 0; + case PackageInstallResultReason.Incompatible: + Console.Error.WriteLine($"Extension '{Display(id)}' is incompatible with this Studio version."); + if (!string.IsNullOrWhiteSpace(result.CompatibilityRecord?.Report)) + Console.Error.WriteLine(Display(result.CompatibilityRecord.Report)); + return 1; + default: + Console.Error.WriteLine($"Unable to {operation} extension '{Display(id)}'."); + return 1; + } + } + + private static string Display(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? "-" + : new string(value.Select(character => char.IsControl(character) ? ' ' : character).ToArray()); + } +} + +internal sealed class CliPluginPackageInstaller(IHttpService httpService) : PackageInstallerBase +{ + public override string GetExtractionPath(Package package, IPaths paths) + { + if (string.IsNullOrWhiteSpace(package.Id)) + throw new InvalidOperationException("Package Id is required."); + + return Path.Combine(paths.PluginsDirectory, package.Id); + } + + public override async Task CheckCompatibilityAsync(Package package, PackageVersion version, + CancellationToken cancellationToken = default) + { + if (version.CompatibilityUrl is null && package.SourceUrl is null) + return new CompatibilityReport(true); + + var compatibilityUrl = version.CompatibilityUrl ?? + $"{package.SourceUrl}/{version.Version}/compatibility.txt"; + var compatibility = await httpService.DownloadTextAsync(compatibilityUrl, cancellationToken: cancellationToken); + return compatibility is null + ? new CompatibilityReport(true) + : PluginCompatibilityChecker.CheckCompatibility(compatibility); + } + + public override Task InstallAsync(PackageInstallContext context, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new PackageInstallerResult(PackageStatus.Installed)); + } + + public override Task RemoveAsync(PackageInstallContext context, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new PackageInstallerResult(PackageStatus.Available)); + } +} + +internal sealed class CliCompositeServiceProvider(IServiceProvider provider) : ICompositeServiceProvider +{ + public object? GetService(Type serviceType) => provider.GetService(serviceType); + + public bool IsService(Type serviceType) => provider.GetService(serviceType) is not null; +} + +internal sealed class CliApplicationStateService : IApplicationStateService +{ + private readonly List>> _shutdownTasks = []; + + public bool ShutdownComplete { get; private set; } + public ObservableCollection CurrentNotifications { get; } = []; + public ApplicationProcess ActiveProcess { get; private set; } = new() { State = AppState.Idle, StatusMessage = "Ready" }; + + public ApplicationProcess AddState(string status, AppState state, Action? terminate = null) + { + ActiveProcess = new ApplicationProcess { StatusMessage = status, State = state, Terminate = terminate }; + return ActiveProcess; + } + + public void RemoveState(ApplicationProcess key, string finishMessage = "Done") + { + key.FinishMessage = finishMessage; + ActiveProcess = new ApplicationProcess { State = AppState.Idle, StatusMessage = finishMessage }; + } + + public Task TerminateActiveDialogAsync() + { + ActiveProcess.Terminate?.Invoke(); + return Task.CompletedTask; + } + + public void RegisterAutoLaunchAction(Action action) { } + public void RegisterPathLaunchAction(Action action) { } + public void RegisterUrlLaunchAction(string key, Action action) { } + public void RegisterShutdownAction(Action action) { } + public void RegisterShutdownTask(Func> task) => _shutdownTasks.Add(task); + public void ExecuteAutoLaunchActions(string? value) { } + public void ExecutePathLaunchActions(string? value) { } + public void ExecuteUrlLaunchActions(Uri uri) { } + + public async Task TryShutdownAsync() + { + foreach (var task in _shutdownTasks) + if (!await task()) + return false; + + ShutdownComplete = true; + return true; + } + + public Task TryRestartAsync() => TryShutdownAsync(); + public void AddNotification(ApplicationNotification notification) => CurrentNotifications.Add(notification); + public void ClearNotifications() => CurrentNotifications.Clear(); +} diff --git a/studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj b/studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj index f0fcd03e5..aee5fd9c8 100644 --- a/studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj +++ b/studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj @@ -15,6 +15,7 @@ + diff --git a/studio/OneWare.Studio.Cli/Program.cs b/studio/OneWare.Studio.Cli/Program.cs index cf09bf299..9423223e7 100644 --- a/studio/OneWare.Studio.Cli/Program.cs +++ b/studio/OneWare.Studio.Cli/Program.cs @@ -23,7 +23,8 @@ studioProcessController.StopStudio); cliModuleLoader.LoadBundledCliModules(); -cliModuleLoader.LoadPluginCliModules(); +if (!ExtensionStoreCliModule.IsExtensionStoreCommand(args)) + cliModuleLoader.LoadPluginCliModules(); using var cliHost = cliHostBuilder.Build(); foreach (var command in cliHost.ModuleManager.RegisterCommands(cliHost.ServiceProvider)) diff --git a/tests/OneWare.Studio.Cli.UnitTests/ExtensionStoreCliModuleTests.cs b/tests/OneWare.Studio.Cli.UnitTests/ExtensionStoreCliModuleTests.cs new file mode 100644 index 000000000..bb5bf04b4 --- /dev/null +++ b/tests/OneWare.Studio.Cli.UnitTests/ExtensionStoreCliModuleTests.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.DependencyInjection; +using System.CommandLine; +using Xunit; + +namespace OneWare.Studio.Cli.UnitTests; + +public class ExtensionStoreCliModuleTests +{ + [Fact] + public void RegisterCommands_RegistersExtensionStoreOperations() + { + using var services = CreateServiceProvider(); + var commands = new ExtensionStoreCliModule().RegisterCommands(services); + + var extensions = Assert.Single(commands); + Assert.Equal("extensions", extensions.Name); + Assert.Contains("extension", extensions.Aliases); + Assert.Equal( + ["search", "list", "install", "update", "uninstall"], + extensions.Subcommands.Select(command => command.Name)); + } + + [Theory] + [InlineData(new[] { "extensions", "update" }, true)] + [InlineData(new[] { "extension", "uninstall", "sample" }, true)] + [InlineData(new[] { "oneai", "train", "project.oneai", "extensions" }, false)] + [InlineData(new[] { "studio", "start" }, false)] + public void IsExtensionStoreCommand_IdentifiesStoreCommands(string[] args, bool expected) + { + Assert.Equal(expected, ExtensionStoreCliModule.IsExtensionStoreCommand(args)); + } + + [Theory] + [InlineData("search")] + [InlineData("update")] + public void RegisterCommands_AllowsOptionalSearchAndUpdateIds(string subcommand) + { + using var services = CreateServiceProvider(); + var extensions = Assert.Single(new ExtensionStoreCliModule().RegisterCommands(services)); + var root = new RootCommand(); + root.Subcommands.Add(extensions); + + var result = root.Parse(["extensions", subcommand]); + + Assert.Empty(result.Errors); + } + + private static ServiceProvider CreateServiceProvider() + { + return new ServiceCollection().BuildServiceProvider(); + } +} diff --git a/tests/OneWare.Studio.Cli.UnitTests/OneWare.Studio.Cli.UnitTests.csproj b/tests/OneWare.Studio.Cli.UnitTests/OneWare.Studio.Cli.UnitTests.csproj new file mode 100644 index 000000000..aa574b5a3 --- /dev/null +++ b/tests/OneWare.Studio.Cli.UnitTests/OneWare.Studio.Cli.UnitTests.csproj @@ -0,0 +1,15 @@ + + + + + net10.0 + False + True + enable + enable + + + + + + From 10ea1ffdaa993c2bfdf2db55f6e6e0098aa0d05e Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:09:25 +0200 Subject: [PATCH 4/9] Fix: Merge --- studio/OneWare.Studio.Desktop/Program.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/studio/OneWare.Studio.Desktop/Program.cs b/studio/OneWare.Studio.Desktop/Program.cs index e568b8f87..a1c8078eb 100644 --- a/studio/OneWare.Studio.Desktop/Program.cs +++ b/studio/OneWare.Studio.Desktop/Program.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Dialogs; using Avalonia.Threading; using Dock.Settings; @@ -221,6 +222,18 @@ 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; + } + var mainWindow = ContainerLocator.Container?.Resolve(); if (mainWindow != null) { From 779d7c69b6c9b372d14b0548fe4c3ba78925ead4 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:33:53 +0200 Subject: [PATCH 5/9] Start Studio with RootCommand. --- studio/OneWare.Studio.Cli/CliModuleLoader.cs | 6 ++-- studio/OneWare.Studio.Cli/Program.cs | 37 +++++++++----------- studio/OneWare.Studio.Cli/StudioCliModule.cs | 30 ++-------------- 3 files changed, 20 insertions(+), 53 deletions(-) diff --git a/studio/OneWare.Studio.Cli/CliModuleLoader.cs b/studio/OneWare.Studio.Cli/CliModuleLoader.cs index 15f522d64..88c268916 100644 --- a/studio/OneWare.Studio.Cli/CliModuleLoader.cs +++ b/studio/OneWare.Studio.Cli/CliModuleLoader.cs @@ -9,11 +9,9 @@ internal sealed class CliModuleLoader(CliHostBuilderContext cliHostBuilder) { - public void RegisterBuiltInCliModules( - Func> startStudio, - Func> stopStudio) + public void RegisterBuiltInCliModules(Func> stopStudio) { - cliHostBuilder.ModuleCatalog.AddModule(new StudioCliModule(startStudio, stopStudio)); + cliHostBuilder.ModuleCatalog.AddModule(new StudioCliModule(stopStudio)); cliHostBuilder.ModuleCatalog.AddModule(new ExtensionStoreCliModule()); } diff --git a/studio/OneWare.Studio.Cli/Program.cs b/studio/OneWare.Studio.Cli/Program.cs index 9423223e7..3dfc7f1df 100644 --- a/studio/OneWare.Studio.Cli/Program.cs +++ b/studio/OneWare.Studio.Cli/Program.cs @@ -8,19 +8,20 @@ var bootstrapParseResult = bootstrapCommand.Parse(args); OneWareStartupCommandLine.ApplyEnvironmentVariables(bootstrapParseResult, bootstrapSymbols); -var cliSymbols = OneWareStartupCommandLine.CreateSymbols("oneware:// URI to open"); +var cliSymbols = OneWareStartupCommandLine.CreateSymbols( + "File, folder or oneware:// URI to open. Starts OneWare Studio without opening a target when omitted."); var rootCommand = OneWareStartupCommandLine.CreateRootCommand(cliSymbols); +var detachOption = new Option("--detach", "-d") +{ + Description = "Start OneWare Studio detached from the current terminal." +}; +rootCommand.Options.Add(detachOption); + 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.RegisterBuiltInCliModules(studioProcessController.StopStudio); cliModuleLoader.LoadBundledCliModules(); if (!ExtensionStoreCliModule.IsExtensionStoreCommand(args)) @@ -30,20 +31,14 @@ foreach (var command in cliHost.ModuleManager.RegisterCommands(cliHost.ServiceProvider)) rootCommand.Subcommands.Add(command); -if (OneWareStartupCommandLine.ContainsOneWareUriArgument(args)) +rootCommand.SetAction((parseResult, cancellationToken) => { - 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); - }); -} + OneWareStartupCommandLine.ApplyEnvironmentVariables(parseResult, cliSymbols); + return studioProcessController.StartStudio( + parseResult.GetValue(cliSymbols.OpenArgument), + parseResult.GetValue(detachOption), + cancellationToken); +}); var parseResult = rootCommand.Parse(args); return parseResult.Invoke(); diff --git a/studio/OneWare.Studio.Cli/StudioCliModule.cs b/studio/OneWare.Studio.Cli/StudioCliModule.cs index 3b8779f6b..700831a37 100644 --- a/studio/OneWare.Studio.Cli/StudioCliModule.cs +++ b/studio/OneWare.Studio.Cli/StudioCliModule.cs @@ -1,39 +1,13 @@ using System.CommandLine; using OneWare.Essentials.Services; -internal sealed class StudioCliModule( - Func> startStudio, - Func> stopStudio) : OneWareCliModuleBase +internal sealed class StudioCliModule(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]; + return [stopStudioCommand]; } } From 2216af4b8ef5a201749b58d5b77c1bfc5b1ecc04 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Mon, 19 Oct 2026 10:52:55 +0200 Subject: [PATCH 6/9] cli simplify --- .github/workflows/publish-studio-linux.yaml | 2 - OneWare.slnx | 2 - docs/PluginDevelopment.md | 3 + docs/README.md | 12 ++ snap/snapcraft.yaml | 9 -- .../OneWare.Studio.Cli.csproj | 21 --- studio/OneWare.Studio.Cli/Program.cs | 44 ------ .../StudioComponents.wxs | 4 - .../oneware.cmd | 2 - .../Cli}/CliHostFactory.cs | 8 + .../Cli}/CliModuleLoader.cs | 25 ++- .../Cli}/ExtensionStoreCliModule.cs | 8 + .../OneWare.Studio.Desktop/Cli/StudioCli.cs | 143 ++++++++++++++++++ .../Cli}/StudioCliModule.cs | 8 + .../Cli}/StudioProcessController.cs | 27 +++- .../OneWare.Studio.Desktop.csproj | 82 +++++----- studio/OneWare.Studio.Desktop/Program.cs | 11 +- .../com.one_ware.OneWare.desktop | 4 +- studio/OneWare.Studio.Desktop/oneware | 2 - studio/OneWare.Studio.Desktop/oneware-studio | 2 - .../OneWare.Studio.Cli.UnitTests.csproj | 15 -- .../ExtensionStoreCliModuleTests.cs | 6 +- .../StudioCliTests.cs | 25 +++ 23 files changed, 286 insertions(+), 179 deletions(-) delete mode 100644 studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj delete mode 100644 studio/OneWare.Studio.Cli/Program.cs delete mode 100644 studio/OneWare.Studio.Desktop.WindowsInstaller/oneware.cmd rename studio/{OneWare.Studio.Cli => OneWare.Studio.Desktop/Cli}/CliHostFactory.cs (93%) rename studio/{OneWare.Studio.Cli => OneWare.Studio.Desktop/Cli}/CliModuleLoader.cs (90%) rename studio/{OneWare.Studio.Cli => OneWare.Studio.Desktop/Cli}/ExtensionStoreCliModule.cs (99%) create mode 100644 studio/OneWare.Studio.Desktop/Cli/StudioCli.cs rename studio/{OneWare.Studio.Cli => OneWare.Studio.Desktop/Cli}/StudioCliModule.cs (73%) rename studio/{OneWare.Studio.Cli => OneWare.Studio.Desktop/Cli}/StudioProcessController.cs (86%) delete mode 100644 studio/OneWare.Studio.Desktop/oneware delete mode 100644 studio/OneWare.Studio.Desktop/oneware-studio delete mode 100644 tests/OneWare.Studio.Cli.UnitTests/OneWare.Studio.Cli.UnitTests.csproj rename tests/{OneWare.Studio.Cli.UnitTests => OneWare.Studio.Desktop.UnitTests}/ExtensionStoreCliModuleTests.cs (94%) create mode 100644 tests/OneWare.Studio.Desktop.UnitTests/StudioCliTests.cs diff --git a/.github/workflows/publish-studio-linux.yaml b/.github/workflows/publish-studio-linux.yaml index a0287edcb..b16745861 100644 --- a/.github/workflows/publish-studio-linux.yaml +++ b/.github/workflows/publish-studio-linux.yaml @@ -37,8 +37,6 @@ 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 9b5b2af11..a09d498e3 100644 --- a/OneWare.slnx +++ b/OneWare.slnx @@ -130,7 +130,6 @@ - @@ -143,7 +142,6 @@ - diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index bf4c35d58..0281322b8 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -142,6 +142,9 @@ You can generate `compatibility.txt` automatically during build by marking depen - `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. +- Top-level command names must be plain words (letters, digits, `-`, `_`). The Studio executable only switches to + CLI mode when the first argument looks like a command name, so names containing `.`, `/` or `:` would be treated + as a file to open in the GUI. 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 diff --git a/docs/README.md b/docs/README.md index 887185c1b..0b42ff3dd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -114,10 +114,22 @@ Debug builds add developer tooling: | `--autolaunch` | `ONEWARE_AUTOLAUNCH` | Run an action registered by a plugin after startup | | `--package-repository` | `ONEWARE_PACKAGE_REPOSITORY` | Override the package repository URL(s), separated by `;` | | `--configuration-profile` | `ONEWARE_CONFIGURATION_PROFILE` | Apply a configuration profile (see the [README](../README.md#configuration-profiles)) | +| `-C`, `--working-directory` | – | Change the working directory before anything else runs | Using a separate `--oneware-dir` and `--oneware-appdata-dir` gives you a clean, isolated profile for testing without touching your normal installation. +### CLI commands + +The Studio executable also hosts headless commands (`stop`, `extensions …`, `cloud login|logout`, plus any +`IOneWareCliModule` from installed plugins). `studio/OneWare.Studio.Desktop/Cli/StudioCli.cs` decides before +Avalonia starts: if the first positional argument looks like a command name (letters, digits, `-`, `_`) and is +not an existing file or folder, or `--help`/`--version` is passed, the CLI runs and the GUI is never started. +Anything else (no arguments, a path or a `oneware://` URL) starts the GUI as before. + +On Windows, `OneWareStudio.exe` is a GUI-subsystem app, so the publish step also creates `oneware.exe`, a copy +with the console subsystem. Use it from terminals; without a command it starts `OneWareStudio.exe` detached. + ### Where things are stored | What | Default location | diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index f290aa73c..d3c37c3f4 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -17,10 +17,6 @@ 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 @@ -77,17 +73,12 @@ 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/studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj b/studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj deleted file mode 100644 index aee5fd9c8..000000000 --- a/studio/OneWare.Studio.Cli/OneWare.Studio.Cli.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exe - OneWareCLI - net10.0 - enable - enable - - - - - - - - - - - - - diff --git a/studio/OneWare.Studio.Cli/Program.cs b/studio/OneWare.Studio.Cli/Program.cs deleted file mode 100644 index 3dfc7f1df..000000000 --- a/studio/OneWare.Studio.Cli/Program.cs +++ /dev/null @@ -1,44 +0,0 @@ -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( - "File, folder or oneware:// URI to open. Starts OneWare Studio without opening a target when omitted."); -var rootCommand = OneWareStartupCommandLine.CreateRootCommand(cliSymbols); -var detachOption = new Option("--detach", "-d") -{ - Description = "Start OneWare Studio detached from the current terminal." -}; -rootCommand.Options.Add(detachOption); - -var studioProcessController = new StudioProcessController(); -var cliHostBuilder = CliHostFactory.Create(); -var cliModuleLoader = new CliModuleLoader(cliHostBuilder); - -cliModuleLoader.RegisterBuiltInCliModules(studioProcessController.StopStudio); - -cliModuleLoader.LoadBundledCliModules(); -if (!ExtensionStoreCliModule.IsExtensionStoreCommand(args)) - cliModuleLoader.LoadPluginCliModules(); - -using var cliHost = cliHostBuilder.Build(); -foreach (var command in cliHost.ModuleManager.RegisterCommands(cliHost.ServiceProvider)) - rootCommand.Subcommands.Add(command); - -rootCommand.SetAction((parseResult, cancellationToken) => -{ - OneWareStartupCommandLine.ApplyEnvironmentVariables(parseResult, cliSymbols); - return studioProcessController.StartStudio( - parseResult.GetValue(cliSymbols.OpenArgument), - parseResult.GetValue(detachOption), - cancellationToken); -}); - -var parseResult = rootCommand.Parse(args); -return parseResult.Invoke(); diff --git a/studio/OneWare.Studio.Desktop.WindowsInstaller/StudioComponents.wxs b/studio/OneWare.Studio.Desktop.WindowsInstaller/StudioComponents.wxs index 4af25f5d2..f6b59b6ee 100644 --- a/studio/OneWare.Studio.Desktop.WindowsInstaller/StudioComponents.wxs +++ b/studio/OneWare.Studio.Desktop.WindowsInstaller/StudioComponents.wxs @@ -97,10 +97,6 @@ - - - - diff --git a/studio/OneWare.Studio.Desktop.WindowsInstaller/oneware.cmd b/studio/OneWare.Studio.Desktop.WindowsInstaller/oneware.cmd deleted file mode 100644 index ffd5e04ab..000000000 --- a/studio/OneWare.Studio.Desktop.WindowsInstaller/oneware.cmd +++ /dev/null @@ -1,2 +0,0 @@ -@echo off -"%~dp0OneWareCLI.exe" %* diff --git a/studio/OneWare.Studio.Cli/CliHostFactory.cs b/studio/OneWare.Studio.Desktop/Cli/CliHostFactory.cs similarity index 93% rename from studio/OneWare.Studio.Cli/CliHostFactory.cs rename to studio/OneWare.Studio.Desktop/Cli/CliHostFactory.cs index c4c2b99b9..1c4b5201a 100644 --- a/studio/OneWare.Studio.Cli/CliHostFactory.cs +++ b/studio/OneWare.Studio.Desktop/Cli/CliHostFactory.cs @@ -1,3 +1,9 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OneWare.Core.ModuleLogic; @@ -5,6 +11,8 @@ using OneWare.Essentials.Services; using OneWare.Settings; +namespace OneWare.Studio.Desktop.Cli; + internal static class CliHostFactory { public static CliHostBuilderContext Create() diff --git a/studio/OneWare.Studio.Cli/CliModuleLoader.cs b/studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs similarity index 90% rename from studio/OneWare.Studio.Cli/CliModuleLoader.cs rename to studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs index 88c268916..dc95968b9 100644 --- a/studio/OneWare.Studio.Cli/CliModuleLoader.cs +++ b/studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs @@ -1,33 +1,28 @@ +using System; +using System.Collections.Generic; using System.CommandLine; +using System.IO; +using System.Linq; using System.Reflection; using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using OneWare.CloudIntegration; using OneWare.Core.ModuleLogic; using OneWare.Core.Services; using OneWare.Essentials.PackageManager.Compatibility; using OneWare.Essentials.Services; +namespace OneWare.Studio.Desktop.Cli; + internal sealed class CliModuleLoader(CliHostBuilderContext cliHostBuilder) { public void RegisterBuiltInCliModules(Func> stopStudio) { cliHostBuilder.ModuleCatalog.AddModule(new StudioCliModule(stopStudio)); cliHostBuilder.ModuleCatalog.AddModule(new ExtensionStoreCliModule()); - } - - 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); + cliHostBuilder.ModuleCatalog.AddModule(new OneWareCloudCliModule()); } public void LoadPluginCliModules() diff --git a/studio/OneWare.Studio.Cli/ExtensionStoreCliModule.cs b/studio/OneWare.Studio.Desktop/Cli/ExtensionStoreCliModule.cs similarity index 99% rename from studio/OneWare.Studio.Cli/ExtensionStoreCliModule.cs rename to studio/OneWare.Studio.Desktop/Cli/ExtensionStoreCliModule.cs index 89a8e3025..0da67f8f0 100644 --- a/studio/OneWare.Studio.Cli/ExtensionStoreCliModule.cs +++ b/studio/OneWare.Studio.Desktop/Cli/ExtensionStoreCliModule.cs @@ -1,5 +1,11 @@ +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.CommandLine; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using OneWare.CloudIntegration; using OneWare.Essentials.Enums; @@ -10,6 +16,8 @@ using OneWare.PackageManager.Installers; using OneWare.PackageManager.Services; +namespace OneWare.Studio.Desktop.Cli; + public sealed class ExtensionStoreCliModule : OneWareCliModuleBase { private const string PluginType = "Plugin"; diff --git a/studio/OneWare.Studio.Desktop/Cli/StudioCli.cs b/studio/OneWare.Studio.Desktop/Cli/StudioCli.cs new file mode 100644 index 000000000..8eb4574f4 --- /dev/null +++ b/studio/OneWare.Studio.Desktop/Cli/StudioCli.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using OneWare.Core.Services; + +namespace OneWare.Studio.Desktop.Cli; + +/// +/// Hosts the headless CLI commands inside the Studio executable. +/// Invocations without a subcommand (no args, a path or a oneware:// URI) continue to start the GUI. +/// +internal static class StudioCli +{ + private const string ConsoleAliasName = "oneware"; + + private static readonly HashSet HelpOrVersionTokens = new(StringComparer.OrdinalIgnoreCase) + { + "-h", "--help", "-?", "/?", "/h", "--version" + }; + + /// + /// True when running as the Windows console-subsystem alias (oneware.exe) of OneWareStudio.exe. + /// + public static bool IsWindowsConsoleAlias => + OperatingSystem.IsWindows() && + string.Equals(Path.GetFileNameWithoutExtension(Environment.ProcessPath), ConsoleAliasName, + StringComparison.OrdinalIgnoreCase); + + /// + /// Decides without loading any modules whether the arguments target a CLI command. + /// The first positional token is treated as a command if it looks like a command name and is not an + /// existing file or directory. Help and version requests are always handled by the CLI. + /// + public static bool IsCliInvocation(IReadOnlyList args) + { + if (args.Any(HelpOrVersionTokens.Contains)) + return true; + + var symbols = OneWareStartupCommandLine.CreateSymbols(); + var valueOptions = new HashSet(StringComparer.Ordinal); + foreach (var option in OneWareStartupCommandLine.CreateRootCommand(symbols).Options) + { + valueOptions.Add(option.Name); + foreach (var alias in option.Aliases) + valueOptions.Add(alias); + } + + for (var i = 0; i < args.Count; i++) + { + var token = args[i]; + + if (token == "--") + return false; + + if (token.StartsWith('-')) + { + if (valueOptions.Contains(token)) + i++; + continue; + } + + return LooksLikeCommandName(token); + } + + return false; + } + + public static int Run(string[] args) + { + if (OperatingSystem.IsWindows()) + AttachToParentConsole(); + + try + { + // Apply startup options first, so paths (appdata, plugins, settings) resolve like in the GUI. + var bootstrapSymbols = OneWareStartupCommandLine.CreateSymbols(); + var bootstrapCommand = OneWareStartupCommandLine.CreateRootCommand(bootstrapSymbols); + bootstrapCommand.TreatUnmatchedTokensAsErrors = false; + OneWareStartupCommandLine.ApplyEnvironmentVariables(bootstrapCommand.Parse(args), bootstrapSymbols); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + + var symbols = OneWareStartupCommandLine.CreateSymbols( + "File, folder or oneware:// URI to open. Starts OneWare Studio when no command is given."); + var rootCommand = OneWareStartupCommandLine.CreateRootCommand(symbols); + rootCommand.Description = "OneWare Studio. Run without a command to start the IDE."; + + var studioProcessController = new StudioProcessController(); + var cliHostBuilder = CliHostFactory.Create(); + var cliModuleLoader = new CliModuleLoader(cliHostBuilder); + + cliModuleLoader.RegisterBuiltInCliModules(studioProcessController.StopStudio); + if (!ExtensionStoreCliModule.IsExtensionStoreCommand(args)) + cliModuleLoader.LoadPluginCliModules(); + + using var cliHost = cliHostBuilder.Build(); + foreach (var command in cliHost.ModuleManager.RegisterCommands(cliHost.ServiceProvider)) + rootCommand.Subcommands.Add(command); + + // Only reached when the first token looked like a command but no command matched it. + rootCommand.SetAction(parseResult => + { + var token = parseResult.GetValue(symbols.OpenArgument); + Console.Error.WriteLine( + $"'{token}' is not a OneWare command or an existing file. Run '{ConsoleAliasName} --help' for usage."); + return 1; + }); + + return rootCommand.Parse(args).Invoke(); + } + + private static bool LooksLikeCommandName(string token) + { + return token.Length > 0 && + char.IsLetter(token[0]) && + token.All(c => char.IsLetterOrDigit(c) || c is '-' or '_') && + !File.Exists(token) && + !Directory.Exists(token); + } + + private static void AttachToParentConsole() + { + // OneWareStudio.exe is a GUI-subsystem app. When started from a terminal, attach to it so output is visible. + // oneware.exe (console subsystem) already has a console. + if (GetConsoleWindow() == IntPtr.Zero) + AttachConsole(AttachParentProcess); + } + + private const int AttachParentProcess = -1; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(int processId); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetConsoleWindow(); +} diff --git a/studio/OneWare.Studio.Cli/StudioCliModule.cs b/studio/OneWare.Studio.Desktop/Cli/StudioCliModule.cs similarity index 73% rename from studio/OneWare.Studio.Cli/StudioCliModule.cs rename to studio/OneWare.Studio.Desktop/Cli/StudioCliModule.cs index 700831a37..bffd4f9d3 100644 --- a/studio/OneWare.Studio.Cli/StudioCliModule.cs +++ b/studio/OneWare.Studio.Desktop/Cli/StudioCliModule.cs @@ -1,6 +1,14 @@ +using System; +using System.Collections.Generic; using System.CommandLine; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using OneWare.Essentials.Services; +namespace OneWare.Studio.Desktop.Cli; + internal sealed class StudioCliModule(Func> stopStudio) : OneWareCliModuleBase { public override IReadOnlyList RegisterCommands(IServiceProvider serviceProvider) diff --git a/studio/OneWare.Studio.Cli/StudioProcessController.cs b/studio/OneWare.Studio.Desktop/Cli/StudioProcessController.cs similarity index 86% rename from studio/OneWare.Studio.Cli/StudioProcessController.cs rename to studio/OneWare.Studio.Desktop/Cli/StudioProcessController.cs index 3ad107c04..7a2cfbb3a 100644 --- a/studio/OneWare.Studio.Cli/StudioProcessController.cs +++ b/studio/OneWare.Studio.Desktop/Cli/StudioProcessController.cs @@ -1,5 +1,13 @@ +using System; +using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.IO.Pipes; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace OneWare.Studio.Desktop.Cli; internal sealed class StudioProcessController { @@ -10,19 +18,24 @@ internal sealed class StudioProcessController private static readonly string StudioExecutableName = $"OneWareStudio{ExecutableExtension}"; private static readonly string CliExecutableName = $"oneware{ExecutableExtension}"; - public Task StartStudio(string? openTarget, bool detach, CancellationToken cancellationToken) + public static string StudioExecutablePath => Path.Combine(AppContext.BaseDirectory, StudioExecutableName); + + /// + /// Starts the GUI executable as a separate process (used by the Windows console alias). + /// + public static int StartStudio(IEnumerable args) { - var studioPath = Path.Combine(AppContext.BaseDirectory, StudioExecutableName); - var startInfo = new ProcessStartInfo(studioPath) + var startInfo = new ProcessStartInfo(StudioExecutablePath) { - UseShellExecute = detach, + UseShellExecute = false, WorkingDirectory = Environment.CurrentDirectory }; - if (!string.IsNullOrWhiteSpace(openTarget)) - startInfo.ArgumentList.Add(openTarget); + foreach (var arg in args) + startInfo.ArgumentList.Add(arg); - return Task.FromResult(Process.Start(startInfo) is null ? 1 : 0); + using var process = Process.Start(startInfo); + return process is null ? 1 : 0; } public async Task StopStudio(CancellationToken cancellationToken) diff --git a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj index 79222f9bd..7cc59f6e5 100644 --- a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj +++ b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj @@ -45,6 +45,10 @@ + + + + @@ -53,8 +57,6 @@ - - @@ -62,53 +64,37 @@ - - - $(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 a1c8078eb..142eac113 100644 --- a/studio/OneWare.Studio.Desktop/Program.cs +++ b/studio/OneWare.Studio.Desktop/Program.cs @@ -22,6 +22,7 @@ using OneWare.Core.Views.Windows; using OneWare.Essentials.Helpers; using OneWare.Essentials.Services; +using OneWare.Studio.Desktop.Cli; namespace OneWare.Studio.Desktop; @@ -278,6 +279,14 @@ public static int Main(string[] args) try { StartupTimer.Mark("Main entered"); + + if (StudioCli.IsCliInvocation(args)) + return StudioCli.Run(args); + + // oneware.exe is a console-subsystem copy for terminals; start the GUI detached from it. + if (StudioCli.IsWindowsConsoleAlias) + return StudioProcessController.StartStudio(args); + var startupSymbols = OneWareStartupCommandLine.CreateSymbols(); var rootCommand = OneWareStartupCommandLine.CreateRootCommand(startupSymbols); @@ -288,8 +297,6 @@ public static int Main(string[] args) var commandLineParseResult = rootCommand.Parse(args); commandLineParseResult.Invoke(); - if (args.LastOrDefault() is "--help" or "-h") return 0; - // Check for single instance if (!TryBecomePrimaryInstance()) { diff --git a/studio/OneWare.Studio.Desktop/com.one_ware.OneWare.desktop b/studio/OneWare.Studio.Desktop/com.one_ware.OneWare.desktop index 2aa2e7303..23f2ea6dd 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-studio +TryExec=oneware GenericName=Development Environment -Exec=oneware-studio %u +Exec=oneware %u Icon=com.one_ware.OneWare Type=Application Terminal=false diff --git a/studio/OneWare.Studio.Desktop/oneware b/studio/OneWare.Studio.Desktop/oneware deleted file mode 100644 index 28909c157..000000000 --- a/studio/OneWare.Studio.Desktop/oneware +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec "$(dirname "$0")/OneWareCLI" "$@" diff --git a/studio/OneWare.Studio.Desktop/oneware-studio b/studio/OneWare.Studio.Desktop/oneware-studio deleted file mode 100644 index 589312693..000000000 --- a/studio/OneWare.Studio.Desktop/oneware-studio +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec "$(dirname "$0")/OneWareStudio" "$@" diff --git a/tests/OneWare.Studio.Cli.UnitTests/OneWare.Studio.Cli.UnitTests.csproj b/tests/OneWare.Studio.Cli.UnitTests/OneWare.Studio.Cli.UnitTests.csproj deleted file mode 100644 index aa574b5a3..000000000 --- a/tests/OneWare.Studio.Cli.UnitTests/OneWare.Studio.Cli.UnitTests.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - - net10.0 - False - True - enable - enable - - - - - - diff --git a/tests/OneWare.Studio.Cli.UnitTests/ExtensionStoreCliModuleTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/ExtensionStoreCliModuleTests.cs similarity index 94% rename from tests/OneWare.Studio.Cli.UnitTests/ExtensionStoreCliModuleTests.cs rename to tests/OneWare.Studio.Desktop.UnitTests/ExtensionStoreCliModuleTests.cs index bb5bf04b4..bbbe357f9 100644 --- a/tests/OneWare.Studio.Cli.UnitTests/ExtensionStoreCliModuleTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/ExtensionStoreCliModuleTests.cs @@ -1,8 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; using System.CommandLine; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using OneWare.Studio.Desktop.Cli; using Xunit; -namespace OneWare.Studio.Cli.UnitTests; +namespace OneWare.Studio.Desktop.UnitTests; public class ExtensionStoreCliModuleTests { diff --git a/tests/OneWare.Studio.Desktop.UnitTests/StudioCliTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/StudioCliTests.cs new file mode 100644 index 000000000..7cef46842 --- /dev/null +++ b/tests/OneWare.Studio.Desktop.UnitTests/StudioCliTests.cs @@ -0,0 +1,25 @@ +using OneWare.Studio.Desktop.Cli; +using Xunit; + +namespace OneWare.Studio.Desktop.UnitTests; + +public class StudioCliTests +{ + [Theory] + [InlineData(new string[0], false)] + [InlineData(new[] { "oneware://open?x=1" }, false)] + [InlineData(new[] { "design.vhd" }, false)] + [InlineData(new[] { "./project" }, false)] + [InlineData(new[] { "--oneware-dir", "extensions" }, false)] + [InlineData(new[] { "--autolaunch", "action" }, false)] + [InlineData(new[] { "extensions", "list" }, true)] + [InlineData(new[] { "--oneware-dir", "/tmp/x", "stop" }, true)] + [InlineData(new[] { "-C", "/tmp", "cloud", "login" }, true)] + [InlineData(new[] { "--help" }, true)] + [InlineData(new[] { "design.vhd", "-h" }, true)] + [InlineData(new[] { "--version" }, true)] + public void IsCliInvocation_SeparatesCommandsFromGuiStartup(string[] args, bool expected) + { + Assert.Equal(expected, StudioCli.IsCliInvocation(args)); + } +} From e71f5bfb18f3a2f231e6a1ea5a8c62485a4a1644 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Mon, 19 Oct 2026 15:08:27 +0200 Subject: [PATCH 7/9] progress --- docs/PluginDevelopment.md | 21 ++ docs/README.md | 9 + .../OneWareCloudCliModule.cs | 34 +-- .../OneWareCloudIntegrationModule.cs | 31 +- src/OneWare.Core/App.axaml.cs | 1 + src/OneWare.Core/OneWare.Core.csproj | 1 - .../Services/IpcCommandService.cs | 39 +++ .../Helpers/StudioInstanceChannel.cs | 285 ++++++++++++++++++ .../Services/IIpcCommandService.cs | 18 ++ .../OneWare.Studio.Desktop/Cli/StudioCli.cs | 7 +- .../Cli/StudioProcessController.cs | 146 ++------- .../DesktopStudioApp.cs | 76 +++-- studio/OneWare.Studio.Desktop/Program.cs | 244 +++++---------- .../StudioInstanceChannelTests.cs | 136 +++++++++ .../IpcCommandServiceTests.cs | 42 +++ 15 files changed, 750 insertions(+), 340 deletions(-) create mode 100644 src/OneWare.Core/Services/IpcCommandService.cs create mode 100644 src/OneWare.Essentials/Helpers/StudioInstanceChannel.cs create mode 100644 src/OneWare.Essentials/Services/IIpcCommandService.cs create mode 100644 tests/OneWare.Essentials.UnitTests/StudioInstanceChannelTests.cs create mode 100644 tests/OneWare.Studio.Desktop.UnitTests/IpcCommandServiceTests.cs diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index 0281322b8..29736967e 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -150,6 +150,20 @@ CLI modules are intentionally headless: they should not depend on Avalonia UI st desktop-only lifecycles. Prefer shared services for reusable logic and keep CLI-specific behavior in the command layer. +To update a running Studio from a CLI command, register a handler in your GUI module and send the command from +the CLI module. Handlers run on the UI thread; prefix command names with your plugin id to avoid collisions. + +```csharp +// GUI module (Initialize) +serviceProvider.Resolve() + .RegisterCommand("myplugin-reload", argument => ReloadAsync(argument)); + +// CLI command +var channel = StudioInstanceChannel.Default; +if (channel.IsStudioRunning()) + await channel.SendCommandAsync("myplugin-reload", "some-argument", cancellationToken); +``` + Use `serviceProvider.Resolve()` or `ContainerLocator.Current` to resolve OneWare services. ## Styling @@ -203,6 +217,13 @@ The following sections document the core services and their key functions. - `RegisterCommands(IServiceProvider)`: return top-level CLI commands for registration. - Module type: must be public, non-abstract, and expose a parameterless constructor for CLI discovery. +#### `IIpcCommandService` (src/OneWare.Essentials/Services/IIpcCommandService.cs) + +- `RegisterCommand(name, handler)`: handles a command sent to the running Studio (UI thread, optional string argument). +- `ExecuteAsync(name, argument)`: runs a registered handler; returns false for unknown commands. +- Send commands with `StudioInstanceChannel.Default.SendCommandAsync` (`src/OneWare.Essentials/Helpers/StudioInstanceChannel.cs`), + which also offers `IsStudioRunning()` and `WaitForExitAsync()`. + #### `IPluginService` (src/OneWare.Essentials/Services/IPluginService.cs) - `InstalledPlugins`: current plugin list. diff --git a/docs/README.md b/docs/README.md index 0b42ff3dd..926e43546 100644 --- a/docs/README.md +++ b/docs/README.md @@ -130,6 +130,15 @@ Anything else (no arguments, a path or a `oneware://` URL) starts the GUI as bef On Windows, `OneWareStudio.exe` is a GUI-subsystem app, so the publish step also creates `oneware.exe`, a copy with the console subsystem. Use it from terminals; without a command it starts `OneWareStudio.exe` detached. +### Single instance and IPC + +Only one Studio runs per user. `StudioInstanceChannel` (`src/OneWare.Essentials/Helpers`) holds an exclusive lock +file and a named pipe (a Unix socket on Linux/macOS), both scoped to the current user: `%TEMP%\OneWare` on Windows, +`$XDG_RUNTIME_DIR/oneware` (or `/tmp/oneware-`) on Linux and `$TMPDIR/OneWare` on macOS. A second start +forwards its path or `oneware://` URL to the running instance and exits. CLI commands use the same channel to send +commands (`cmd:`), for example `oneware stop` sends `shutdown` and waits for the lock to be released. +Messages that arrive while Studio is still starting are queued until startup content has loaded. + ### Where things are stored | What | Default location | diff --git a/src/OneWare.CloudIntegration/OneWareCloudCliModule.cs b/src/OneWare.CloudIntegration/OneWareCloudCliModule.cs index 34d7abdd8..740a004ac 100644 --- a/src/OneWare.CloudIntegration/OneWareCloudCliModule.cs +++ b/src/OneWare.CloudIntegration/OneWareCloudCliModule.cs @@ -1,7 +1,7 @@ using System.CommandLine; -using System.IO.Pipes; using Microsoft.Extensions.DependencyInjection; using OneWare.CloudIntegration.Services; +using OneWare.Essentials.Helpers; using OneWare.Essentials.Services; namespace OneWare.CloudIntegration; @@ -51,6 +51,7 @@ private static async Task LoginAsync(IServiceProvider serviceProvider, Canc return 1; } + await NotifyRunningStudioAsync(OneWareCloudIntegrationModule.LoginIpcCommand, userId, cancellationToken); Console.WriteLine("Logged in to OneWare Cloud."); return 0; } @@ -79,7 +80,7 @@ private static async Task LogoutAsync(IServiceProvider serviceProvider, Can settingsService.GetSettingValue(OneWareCloudIntegrationModule.OneWareAccountUserIdKey) }, autoSave: false); - await NotifyRunningStudioAsync(cancellationToken); + await NotifyRunningStudioAsync(OneWareCloudIntegrationModule.LogoutIpcCommand, null, cancellationToken); Console.WriteLine("Logged out of OneWare Cloud."); return 0; } @@ -105,26 +106,15 @@ private static void EnsureSettingsInitialized(IServiceProvider serviceProvider) settingsService.Load(paths.SettingsPath); } - private static async Task NotifyRunningStudioAsync(CancellationToken cancellationToken) + private static async Task NotifyRunningStudioAsync(string command, string? argument, + 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. - } + var channel = StudioInstanceChannel.Default; + if (!channel.IsStudioRunning()) + return; + + if (!await channel.SendCommandAsync(command, argument, cancellationToken)) + Console.Error.WriteLine( + "Could not notify the running ONE WARE Studio. Restart it to apply the account change."); } } diff --git a/src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs b/src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs index 8a67f9bf8..ce1aad483 100644 --- a/src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs +++ b/src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs @@ -20,7 +20,8 @@ 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 const string LoginIpcCommand = "cloud-login"; + public const string LogoutIpcCommand = "cloud-logout"; public override void RegisterServices(IServiceCollection services) { @@ -56,6 +57,8 @@ public override void Initialize(IServiceProvider serviceProvider) DataContext = serviceProvider.Resolve() })); + RegisterIpcCommands(serviceProvider); + serviceProvider.Resolve().RegisterMenuItem("MainWindow_MainMenu/Help", new MenuItemModel("Feedback") { @@ -66,6 +69,32 @@ public override void Initialize(IServiceProvider serviceProvider) ); } + // Keeps a running Studio in sync with `oneware cloud login/logout`; otherwise its next settings save + // would restore the previous account. + private static void RegisterIpcCommands(IServiceProvider serviceProvider) + { + var ipcCommands = serviceProvider.Resolve(); + var loginService = serviceProvider.Resolve(); + var settingsService = serviceProvider.Resolve(); + + ipcCommands.RegisterCommand(LoginIpcCommand, async userId => + { + if (string.IsNullOrWhiteSpace(userId)) + return; + + settingsService.SetSettingValue(OneWareAccountUserIdKey, userId); + await loginService.RefreshFromUserIdAsync(userId); + }); + + ipcCommands.RegisterCommand(LogoutIpcCommand, _ => + { + if (loginService.UserId is { } userId) + loginService.Logout(userId); + + return Task.CompletedTask; + }); + } + public static async Task OpenFeedbackDialogAsync() { var windowService = ContainerLocator.Container.Resolve(); diff --git a/src/OneWare.Core/App.axaml.cs b/src/OneWare.Core/App.axaml.cs index d5eda8534..90029911a 100644 --- a/src/OneWare.Core/App.axaml.cs +++ b/src/OneWare.Core/App.axaml.cs @@ -88,6 +88,7 @@ protected virtual void RegisterServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/OneWare.Core/OneWare.Core.csproj b/src/OneWare.Core/OneWare.Core.csproj index faf2fa768..bc2a065e3 100644 --- a/src/OneWare.Core/OneWare.Core.csproj +++ b/src/OneWare.Core/OneWare.Core.csproj @@ -21,7 +21,6 @@ - diff --git a/src/OneWare.Core/Services/IpcCommandService.cs b/src/OneWare.Core/Services/IpcCommandService.cs new file mode 100644 index 000000000..3ee781df5 --- /dev/null +++ b/src/OneWare.Core/Services/IpcCommandService.cs @@ -0,0 +1,39 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using OneWare.Essentials.Services; + +namespace OneWare.Core.Services; + +public class IpcCommandService(ILogger logger) : IIpcCommandService +{ + private readonly ConcurrentDictionary> _handlers = + new(StringComparer.OrdinalIgnoreCase); + + public void RegisterCommand(string name, Func handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(handler); + + _handlers[name] = handler; + } + + public async Task ExecuteAsync(string name, string? argument) + { + if (!_handlers.TryGetValue(name, out var handler)) + { + logger.LogWarning("Received unknown IPC command '{Command}'.", name); + return false; + } + + try + { + await handler(argument); + } + catch (Exception e) + { + logger.LogError(e, "IPC command '{Command}' failed.", name); + } + + return true; + } +} diff --git a/src/OneWare.Essentials/Helpers/StudioInstanceChannel.cs b/src/OneWare.Essentials/Helpers/StudioInstanceChannel.cs new file mode 100644 index 000000000..300df4722 --- /dev/null +++ b/src/OneWare.Essentials/Helpers/StudioInstanceChannel.cs @@ -0,0 +1,285 @@ +using System.IO.Pipes; +using System.Text; + +namespace OneWare.Essentials.Helpers; + +/// +/// Per-user single-instance lock and IPC channel of the running OneWare Studio. +/// Used by the desktop app (lock + server) and by CLI modules or a second instance to talk to it. +/// +public sealed class StudioInstanceChannel +{ + /// + /// Prefix that marks an IPC command. Messages without it are treated as a path or oneware:// URL to open. + /// + public const string CommandPrefix = "cmd:"; + + /// + /// Brings the main window to the front. + /// + public const string ActivateCommand = "activate"; + + /// + /// Requests a regular shutdown (unsaved changes may still be confirmed by the user). + /// + public const string ShutdownCommand = "shutdown"; + + private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(2); + + public StudioInstanceChannel(string lockFilePath, string pipeName) + { + LockFilePath = lockFilePath; + PipeName = pipeName; + } + + /// + /// Channel used by OneWare Studio for the current user. + /// + public static StudioInstanceChannel Default { get; } = CreateDefault(); + + public string LockFilePath { get; } + + /// + /// Pipe name, or the absolute socket path on Unix. + /// + public string PipeName { get; } + + public static string FormatCommand(string name, string? argument = null) + { + if (string.IsNullOrWhiteSpace(name) || name.Contains('\n')) + throw new ArgumentException("Invalid command name.", nameof(name)); + + return argument is null ? $"{CommandPrefix}{name}" : $"{CommandPrefix}{name}\n{argument}"; + } + + public static bool TryParseCommand(string message, out string name, out string? argument) + { + name = string.Empty; + argument = null; + + if (!message.StartsWith(CommandPrefix, StringComparison.Ordinal)) + return false; + + var body = message[CommandPrefix.Length..]; + var separator = body.IndexOf('\n'); + name = (separator < 0 ? body : body[..separator]).Trim(); + argument = separator < 0 ? null : body[(separator + 1)..]; + return name.Length > 0; + } + + /// + /// Tries to become the primary instance. Returns the held lock, or null if another instance owns it. + /// Keep the returned stream open for the lifetime of the app. + /// + public FileStream? TryAcquirePrimaryLock() + { + Directory.CreateDirectory(Path.GetDirectoryName(LockFilePath)!); + + // A short retry covers the window in which another process is only probing the lock. + for (var attempt = 0; attempt < 3; attempt++) + { + try + { + var stream = new FileStream(LockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, + FileShare.None, 1, FileOptions.DeleteOnClose); + var pid = Encoding.UTF8.GetBytes(Environment.ProcessId.ToString()); + stream.SetLength(0); + stream.Write(pid); + stream.Flush(); + return stream; + } + catch (IOException) + { + Thread.Sleep(100); + } + } + + return null; + } + + /// + /// True when another process currently holds the primary instance lock. + /// + public bool IsStudioRunning() + { + if (!File.Exists(LockFilePath)) + return false; + + try + { + using var probe = new FileStream(LockFilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + return false; + } + catch (FileNotFoundException) + { + return false; + } + catch (DirectoryNotFoundException) + { + return false; + } + catch (IOException) + { + return true; + } + catch (UnauthorizedAccessException) + { + // Windows reports a lock file that is pending deletion (instance is exiting) as access denied. + return true; + } + } + + /// + /// Waits until no process holds the primary instance lock. + /// + public async Task WaitForExitAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + var deadline = DateTime.UtcNow + timeout; + while (IsStudioRunning()) + { + if (DateTime.UtcNow >= deadline) + return false; + + await Task.Delay(100, cancellationToken); + } + + return true; + } + + /// + /// Sends a command to the running instance. Returns false when no instance could be reached. + /// + public Task SendCommandAsync(string name, string? argument = null, + CancellationToken cancellationToken = default) + { + return SendMessageAsync(FormatCommand(name, argument), cancellationToken); + } + + /// + /// Sends a raw message (command, path or oneware:// URL). Returns false when no instance could be reached. + /// + public async Task SendMessageAsync(string message, CancellationToken cancellationToken = default) + { + try + { + await using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + using var connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectTimeout.CancelAfter(DefaultConnectTimeout); + await client.ConnectAsync(connectTimeout.Token); + + await using var writer = new StreamWriter(client, new UTF8Encoding(false)); + await writer.WriteAsync(message.AsMemory(), cancellationToken); + await writer.FlushAsync(cancellationToken); + return true; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or TimeoutException) + { + return false; + } + } + + /// + /// Receives messages until cancelled. is called on a background thread. + /// + public async Task RunServerAsync(Action onMessage, Action? onError = null, + CancellationToken cancellationToken = default) + { + NamedPipeServerStream? server = null; + try + { + while (!cancellationToken.IsCancellationRequested) + try + { + server ??= CreateServer(); + await server.WaitForConnectionAsync(cancellationToken); + + // Create the next instance before releasing this one. On Unix the listening socket is closed + // when the last instance is disposed, which would drop clients that already connected. + await using var connected = server; + server = null; + server = CreateServer(); + + using var reader = new StreamReader(connected, Encoding.UTF8); + var message = await reader.ReadToEndAsync(cancellationToken); + + if (!string.IsNullOrWhiteSpace(message)) + onMessage(message); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + if (cancellationToken.IsCancellationRequested) + break; + + onError?.Invoke(ex); + if (server != null) + { + await server.DisposeAsync(); + server = null; + } + + await Task.Delay(1000, cancellationToken).ContinueWith(_ => { }, TaskScheduler.Default); + } + } + finally + { + if (server != null) + await server.DisposeAsync(); + } + } + + private NamedPipeServerStream CreateServer() + { + return new NamedPipeServerStream(PipeName, PipeDirection.In, NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + } + + private static StudioInstanceChannel CreateDefault() + { + var directory = GetUserRuntimeDirectory(); + var pipeName = OperatingSystem.IsWindows() + ? $"oneware-studio-ipc-{SanitizeName(Environment.UserName)}" + : Path.Combine(directory, "ipc.sock"); + + return new StudioInstanceChannel(Path.Combine(directory, "oneware-studio.lock"), pipeName); + } + + private static string GetUserRuntimeDirectory() + { + // %TEMP% (Windows) and $TMPDIR (macOS) are already per user; /tmp on Linux is shared. + if (!OperatingSystem.IsLinux()) + return Path.Combine(Path.GetTempPath(), "OneWare"); + + var runtimeDirectory = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + var directory = !string.IsNullOrWhiteSpace(runtimeDirectory) && Directory.Exists(runtimeDirectory) + ? Path.Combine(runtimeDirectory, "oneware") + : Path.Combine(Path.GetTempPath(), $"oneware-{SanitizeName(Environment.UserName)}"); + + try + { + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch (Exception) + { + // Creation is retried (and reported) when the lock is acquired. + } + + return directory; + } + + private static string SanitizeName(string value) + { + var sanitized = new string(value.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '_').ToArray()); + return string.IsNullOrEmpty(sanitized) ? "user" : sanitized; + } +} diff --git a/src/OneWare.Essentials/Services/IIpcCommandService.cs b/src/OneWare.Essentials/Services/IIpcCommandService.cs new file mode 100644 index 000000000..303f65539 --- /dev/null +++ b/src/OneWare.Essentials/Services/IIpcCommandService.cs @@ -0,0 +1,18 @@ +namespace OneWare.Essentials.Services; + +/// +/// Handles commands sent to the running OneWare Studio over +/// (for example by CLI modules). Handlers run on the UI thread. +/// +public interface IIpcCommandService +{ + /// + /// Registers a handler for a command name. A later registration replaces an earlier one. + /// + void RegisterCommand(string name, Func handler); + + /// + /// Runs the handler registered for . Returns false if no handler is registered. + /// + Task ExecuteAsync(string name, string? argument); +} diff --git a/studio/OneWare.Studio.Desktop/Cli/StudioCli.cs b/studio/OneWare.Studio.Desktop/Cli/StudioCli.cs index 8eb4574f4..0803fded6 100644 --- a/studio/OneWare.Studio.Desktop/Cli/StudioCli.cs +++ b/studio/OneWare.Studio.Desktop/Cli/StudioCli.cs @@ -129,15 +129,20 @@ private static void AttachToParentConsole() { // OneWareStudio.exe is a GUI-subsystem app. When started from a terminal, attach to it so output is visible. // oneware.exe (console subsystem) already has a console. - if (GetConsoleWindow() == IntPtr.Zero) + // Output that is already redirected (pipes, files, IDE runners) must keep its handles. + if (GetConsoleWindow() == IntPtr.Zero && GetStdHandle(StdOutputHandle) == IntPtr.Zero) AttachConsole(AttachParentProcess); } private const int AttachParentProcess = -1; + private const int StdOutputHandle = -11; [DllImport("kernel32.dll", SetLastError = true)] private static extern bool AttachConsole(int processId); [DllImport("kernel32.dll")] private static extern IntPtr GetConsoleWindow(); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetStdHandle(int stdHandle); } diff --git a/studio/OneWare.Studio.Desktop/Cli/StudioProcessController.cs b/studio/OneWare.Studio.Desktop/Cli/StudioProcessController.cs index 7a2cfbb3a..69f38e4c1 100644 --- a/studio/OneWare.Studio.Desktop/Cli/StudioProcessController.cs +++ b/studio/OneWare.Studio.Desktop/Cli/StudioProcessController.cs @@ -2,32 +2,34 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.IO.Pipes; -using System.Linq; using System.Threading; using System.Threading.Tasks; +using OneWare.Essentials.Helpers; namespace OneWare.Studio.Desktop.Cli; -internal sealed class StudioProcessController +internal sealed class StudioProcessController(StudioInstanceChannel channel) { - 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}"; + private static readonly TimeSpan StopTimeout = TimeSpan.FromSeconds(10); + + private static readonly string StudioExecutableName = + OperatingSystem.IsWindows() ? "OneWareStudio.exe" : "OneWareStudio"; + + public StudioProcessController() : this(StudioInstanceChannel.Default) + { + } public static string StudioExecutablePath => Path.Combine(AppContext.BaseDirectory, StudioExecutableName); /// /// Starts the GUI executable as a separate process (used by the Windows console alias). + /// Shell execute keeps the GUI from inheriting the terminal's handles. /// public static int StartStudio(IEnumerable args) { var startInfo = new ProcessStartInfo(StudioExecutablePath) { - UseShellExecute = false, + UseShellExecute = true, WorkingDirectory = Environment.CurrentDirectory }; @@ -40,131 +42,23 @@ public static int StartStudio(IEnumerable args) public async Task StopStudio(CancellationToken cancellationToken) { - using var studioProcess = TryGetRunningStudioProcess(); - if (studioProcess is null) + if (!channel.IsStudioRunning()) { 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 + if (!await channel.SendCommandAsync(StudioInstanceChannel.ShutdownCommand, null, cancellationToken)) { Console.Error.WriteLine("Could not reach ONE WARE Studio over IPC."); + return 1; } - 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); + if (await channel.WaitForExitAsync(StopTimeout, cancellationToken)) + return 0; - try - { - await process.WaitForExitAsync(timeoutCancellation.Token); - return true; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return process.HasExited; - } - catch (OperationCanceledException) - { - throw; - } + Console.Error.WriteLine( + "ONE WARE Studio is still running. If it asks about unsaved changes, confirm the dialog in Studio."); + return 1; } } diff --git a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs index 83d4ee9eb..b4c0973c0 100644 --- a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs +++ b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs @@ -24,6 +24,7 @@ using OneWare.Core.Views.Windows; using OneWare.Cpp; using OneWare.Essentials.Enums; +using OneWare.Essentials.Helpers; using OneWare.Essentials.Models; using OneWare.Essentials.PackageManager; using OneWare.Essentials.Services; @@ -98,6 +99,19 @@ protected override void OnInitializationCompleted() Services.Resolve().RegisterPathLaunchAction(x => _ = PathOpenTaskAsync(x)); Services.Resolve().RegisterShutdownAction(Program.ReleaseLock); + + var ipcCommands = Services.Resolve(); + ipcCommands.RegisterCommand(StudioInstanceChannel.ActivateCommand, _ => + { + Program.ActivateMainWindow(); + return Task.CompletedTask; + }); + ipcCommands.RegisterCommand(StudioInstanceChannel.ShutdownCommand, _ => + { + Services.Resolve().Log("Shutting down via IPC request"); + Program.ActivateMainWindow(); + return Services.Resolve().TryShutdownAsync(); + }); } private async Task PathOpenTaskAsync(string? path) @@ -151,37 +165,45 @@ protected override AvaloniaObject CreateShell() protected override async Task LoadContentAsync() { - var cloudHost = Services.Resolve() - .GetSettingValue(OneWareCloudIntegrationModule.OneWareCloudHostKey); - - var repositories = - Environment.GetEnvironmentVariable("ONEWARE_PACKAGE_REPOSITORY") is { } repositoryOverride && - !string.IsNullOrWhiteSpace(repositoryOverride) - ? repositoryOverride - .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - : [ - new Uri(new Uri(cloudHost), "api/studio/packages").AbsoluteUri, - "https://raw.githubusercontent.com/one-ware/OneWare.PublicPackages/main/oneware-packages.json" - ]; - - Services.Resolve().RegisterPackageRepositoryWithFallback(repositories); + try + { + var cloudHost = Services.Resolve() + .GetSettingValue(OneWareCloudIntegrationModule.OneWareCloudHostKey); + + var repositories = + Environment.GetEnvironmentVariable("ONEWARE_PACKAGE_REPOSITORY") is { } repositoryOverride && + !string.IsNullOrWhiteSpace(repositoryOverride) + ? repositoryOverride + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : [ + new Uri(new Uri(cloudHost), "api/studio/packages").AbsoluteUri, + "https://raw.githubusercontent.com/one-ware/OneWare.PublicPackages/main/oneware-packages.json" + ]; + + Services.Resolve().RegisterPackageRepositoryWithFallback(repositories); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime) + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime) + { + var key = Services.Resolve() + .AddState("Loading last projects...", AppState.Loading); + await Services.Resolve().OpenLastProjectsFileAsync(); + Services.Resolve().InitializeContent(); + Services.Resolve().RemoveState(key, "Projects loaded!"); + Services.Resolve()?.Log("Loading last projects finished"); + + if (Environment.GetEnvironmentVariable("ONEWARE_OPEN_PATH") is { } pathOpen) + Services.Resolve().ExecutePathLaunchActions(pathOpen); + } + + // trigger AutoLaunch Actions + await base.LoadContentAsync(); + } + finally { - var key = Services.Resolve() - .AddState("Loading last projects...", AppState.Loading); - await Services.Resolve().OpenLastProjectsFileAsync(); - Services.Resolve().InitializeContent(); - Services.Resolve().RemoveState(key, "Projects loaded!"); - Services.Resolve()?.Log("Loading last projects finished"); - - if (Environment.GetEnvironmentVariable("ONEWARE_OPEN_PATH") is { } pathOpen) - Services.Resolve().ExecutePathLaunchActions(pathOpen); + // Messages from other instances or CLI commands are queued until startup content is loaded. + Program.SignalIpcReady(); } - // trigger AutoLaunch Actions - await base.LoadContentAsync(); - var settingsService = Services.Resolve(); var packageService = Services.Resolve(); diff --git a/studio/OneWare.Studio.Desktop/Program.cs b/studio/OneWare.Studio.Desktop/Program.cs index 142eac113..155221214 100644 --- a/studio/OneWare.Studio.Desktop/Program.cs +++ b/studio/OneWare.Studio.Desktop/Program.cs @@ -3,7 +3,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; -using System.IO.Pipes; +using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using System.Text; @@ -28,10 +28,11 @@ namespace OneWare.Studio.Desktop; internal abstract class Program { - private const string PipeName = "oneware-studio-ipc"; + private static readonly StudioInstanceChannel InstanceChannel = StudioInstanceChannel.Default; + private static readonly Queue PendingIpcMessages = new(); private static FileStream? _lockFileStream; private static CancellationTokenSource? _ipcCancellation; - private static string LockFilePath => Path.Combine(Path.GetTempPath(), "OneWare", "oneware-studio.lock"); + private static bool _ipcReady; public static void ReleaseLock() { @@ -105,171 +106,105 @@ private static bool TryBecomePrimaryInstance() { try { - Directory.CreateDirectory(Path.GetDirectoryName(LockFilePath) ?? Path.GetTempPath()); - - _lockFileStream = new FileStream( - LockFilePath, - FileMode.OpenOrCreate, - FileAccess.ReadWrite, - FileShare.None, - 32, - FileOptions.DeleteOnClose); - - var pidBytes = Encoding.UTF8.GetBytes(Environment.ProcessId.ToString()); - _lockFileStream.Write(pidBytes, 0, pidBytes.Length); - _lockFileStream.Flush(); - - Console.WriteLine($"Successfully acquired lock. PID: {Environment.ProcessId}"); - return true; - } - catch (IOException) - { - // File is locked by another process - check if that process is still running - try + _lockFileStream = InstanceChannel.TryAcquirePrimaryLock(); + if (_lockFileStream == null) { - if (File.Exists(LockFilePath)) - { - var pidString = File.ReadAllText(LockFilePath).Trim(); - if (int.TryParse(pidString, out var existingPid)) - try - { - Process.GetProcessById(existingPid); - Console.WriteLine($"Another instance is running (PID: {existingPid})"); - return false; // Process is still running - } - catch (ArgumentException) - { - // Process doesn't exist - stale lock file - Console.WriteLine("Stale lock file detected, cleaning up..."); - File.Delete(LockFilePath); - return TryBecomePrimaryInstance(); // Retry - } - } - } - catch (Exception ex) - { - Console.WriteLine($"Error checking existing process: {ex.Message}"); + Console.WriteLine("Another instance is already running."); + return false; } - return false; + Console.WriteLine($"Successfully acquired lock. PID: {Environment.ProcessId}"); + return true; } catch (Exception ex) { + // Without a usable lock we cannot detect other instances, so start normally. Console.WriteLine($"Failed to create lock file: {ex.Message}"); return true; } } - private static async Task TrySendToExistingInstanceAsync(string message) + /// + /// Called once the app can handle IPC messages. Messages received earlier are processed now. + /// Must be called on the UI thread. + /// + public static void SignalIpcReady() + { + _ipcReady = true; + while (PendingIpcMessages.TryDequeue(out var message)) + HandleIpcMessage(message); + } + + private static void ReceiveIpcMessage(string message) + { + Dispatcher.UIThread.Post(() => + { + if (_ipcReady) + HandleIpcMessage(message); + else + PendingIpcMessages.Enqueue(message); + }); + } + + private static void HandleIpcMessage(string message) { try { - await using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); - await client.ConnectAsync(2000); + var logger = ContainerLocator.Container?.Resolve(); + logger?.Log($"Received IPC message: {message}"); - await using var writer = new StreamWriter(client, Encoding.UTF8); - await writer.WriteAsync(message); - await writer.FlushAsync(); + if (StudioInstanceChannel.TryParseCommand(message, out var command, out var argument)) + { + _ = ContainerLocator.Container?.Resolve().ExecuteAsync(command, argument); + return; + } - return true; + HandleOpenTarget(message); } - catch + catch (Exception ex) { - return false; + var logger = ContainerLocator.Container?.Resolve(); + logger?.Error($"Error handling IPC message: {ex.Message}", ex); } } - private static async Task RunIpcServerAsync(CancellationToken cancellationToken) + internal static void ActivateMainWindow() { - while (!cancellationToken.IsCancellationRequested) - try - { - await using var server = new NamedPipeServerStream( - PipeName, - PipeDirection.In, - 1, - PipeTransmissionMode.Byte, - PipeOptions.Asynchronous); - - await server.WaitForConnectionAsync(cancellationToken); + var mainWindow = ContainerLocator.Container?.Resolve(); + if (mainWindow == null) + return; - using var reader = new StreamReader(server, Encoding.UTF8); - var message = await reader.ReadToEndAsync(cancellationToken); + if (mainWindow.WindowState == WindowState.Minimized) + mainWindow.WindowState = WindowState.Normal; - if (!string.IsNullOrWhiteSpace(message)) - Dispatcher.UIThread.Post(() => { HandleOpenTarget(message); }); - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - if (!cancellationToken.IsCancellationRequested) - { - Console.WriteLine($"IPC Server error: {ex.Message}"); - await Task.Delay(1000, cancellationToken); // Wait before retry - } - } + mainWindow.Activate(); } private static void HandleOpenTarget(string target) { - try - { - if (string.IsNullOrWhiteSpace(target)) - return; - - 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(); + var logger = ContainerLocator.Container?.Resolve(); + var applicationStateService = ContainerLocator.Container?.Resolve(); - return; - } + ActivateMainWindow(); - var mainWindow = ContainerLocator.Container?.Resolve(); - if (mainWindow != null) - { - if (mainWindow.WindowState == WindowState.Minimized) - mainWindow.WindowState = WindowState.Normal; - - mainWindow.Activate(); - } - - if (target == "activateWindow") - { - // Just activate the window - } - else if (target.StartsWith("oneware://", StringComparison.OrdinalIgnoreCase)) - { - logger?.Log($"Opening URL: {target}"); - ContainerLocator.Container?.Resolve() - .ExecuteUrlLaunchActions(new Uri(target)); - } - else if (File.Exists(target) || Directory.Exists(target)) - { - var fullPath = Path.GetFullPath(target); - logger?.Log($"Opening path: {fullPath}"); + // "activateWindow" is sent by older versions; it only brings the window to the front. + if (target == "activateWindow") + return; - ContainerLocator.Container?.Resolve().ExecutePathLaunchActions(fullPath); - } - else - { - logger?.Warning($"Target not found or invalid: {target}"); - } + if (target.StartsWith("oneware://", StringComparison.OrdinalIgnoreCase)) + { + logger?.Log($"Opening URL: {target}"); + applicationStateService?.ExecuteUrlLaunchActions(new Uri(target)); } - catch (Exception ex) + else if (File.Exists(target) || Directory.Exists(target)) { - var logger = ContainerLocator.Container?.Resolve(); - logger?.Error($"Error handling IPC message: {ex.Message}", ex); + var fullPath = Path.GetFullPath(target); + logger?.Log($"Opening path: {fullPath}"); + applicationStateService?.ExecutePathLaunchActions(fullPath); + } + else + { + logger?.Warning($"Target not found or invalid: {target}"); } } @@ -297,44 +232,29 @@ public static int Main(string[] args) var commandLineParseResult = rootCommand.Parse(args); commandLineParseResult.Invoke(); - // Check for single instance if (!TryBecomePrimaryInstance()) { - // Not the primary instance - try to forward the message - Console.WriteLine("Another instance is already running. Forwarding request..."); + Console.WriteLine("Forwarding request..."); - // Determine what to send to the existing instance - string? messageToSend = null; + var message = Environment.GetEnvironmentVariable("ONEWARE_OPEN_URL") + ?? Environment.GetEnvironmentVariable("ONEWARE_OPEN_PATH") + ?? StudioInstanceChannel.FormatCommand(StudioInstanceChannel.ActivateCommand); - if (Environment.GetEnvironmentVariable("ONEWARE_OPEN_URL") is { } url) - messageToSend = url; - else if (Environment.GetEnvironmentVariable("ONEWARE_OPEN_PATH") is { } path) messageToSend = path; - - try + if (InstanceChannel.SendMessageAsync(message).GetAwaiter().GetResult()) { - var sendTask = TrySendToExistingInstanceAsync(messageToSend ?? "activateWindow"); - var sendSuccess = sendTask.Wait(TimeSpan.FromSeconds(5)); - - if (sendSuccess) - { - Console.WriteLine("Request forwarded successfully."); - return 0; - } - - Console.WriteLine("Failed to forward request to existing instance."); - } - catch (Exception ex) - { - Console.WriteLine($"Error forwarding request: {ex.Message}"); + Console.WriteLine("Request forwarded successfully."); + return 0; } - return 0; + Console.Error.WriteLine("Failed to forward request to the running OneWare Studio instance."); + return 1; } // We are the primary instance - start IPC server _ipcCancellation = new CancellationTokenSource(); - - _ = Task.Run(() => RunIpcServerAsync(_ipcCancellation.Token)); + var ipcToken = _ipcCancellation.Token; + _ = Task.Run(() => InstanceChannel.RunServerAsync(ReceiveIpcMessage, + ex => Console.WriteLine($"IPC Server error: {ex.Message}"), ipcToken)); var appBuilder = BuildAvaloniaApp(); StartupTimer.Mark("Settings loaded, Avalonia setup started"); diff --git a/tests/OneWare.Essentials.UnitTests/StudioInstanceChannelTests.cs b/tests/OneWare.Essentials.UnitTests/StudioInstanceChannelTests.cs new file mode 100644 index 000000000..cb6d9ccb4 --- /dev/null +++ b/tests/OneWare.Essentials.UnitTests/StudioInstanceChannelTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using OneWare.Essentials.Helpers; +using Xunit; + +namespace OneWare.Essentials.UnitTests; + +public sealed class StudioInstanceChannelTests : IDisposable +{ + private const int MessageBurst = 20; + + private readonly string _directory = + Path.Combine(Path.GetTempPath(), $"ow-ipc-{Guid.NewGuid():N}"[..20]); + + private StudioInstanceChannel CreateChannel() + { + Directory.CreateDirectory(_directory); + var pipeName = OperatingSystem.IsWindows() + ? $"oneware-test-{Guid.NewGuid():N}" + : Path.Combine(_directory, "ipc.sock"); + return new StudioInstanceChannel(Path.Combine(_directory, "studio.lock"), pipeName); + } + + public void Dispose() + { + try + { + Directory.Delete(_directory, true); + } + catch (IOException) + { + } + } + + [Theory] + [InlineData("shutdown", null)] + [InlineData("cloud-login", "user-123")] + [InlineData("cloud-login", "line1\nline2")] + [InlineData("cloud-login", "")] + public void FormatCommand_RoundTrips(string name, string? argument) + { + var message = StudioInstanceChannel.FormatCommand(name, argument); + + Assert.True(StudioInstanceChannel.TryParseCommand(message, out var parsedName, out var parsedArgument)); + Assert.Equal(name, parsedName); + Assert.Equal(argument, parsedArgument); + } + + [Theory] + [InlineData("/home/user/project.fpgaproj")] + [InlineData("oneware://open?x=1")] + [InlineData("activateWindow")] + [InlineData("cmd:")] + public void TryParseCommand_RejectsNonCommands(string message) + { + Assert.False(StudioInstanceChannel.TryParseCommand(message, out _, out _)); + } + + [Fact] + public void Lock_IsDetectedWhileHeldAndReleasedOnDispose() + { + var channel = CreateChannel(); + Assert.False(channel.IsStudioRunning()); + + var primaryLock = channel.TryAcquirePrimaryLock(); + Assert.NotNull(primaryLock); + Assert.True(channel.IsStudioRunning()); + Assert.Null(channel.TryAcquirePrimaryLock()); + + primaryLock.Dispose(); + Assert.False(channel.IsStudioRunning()); + } + + [Fact] + public async Task WaitForExit_ReturnsFalseWhileLockIsHeld() + { + var channel = CreateChannel(); + using var primaryLock = channel.TryAcquirePrimaryLock(); + + Assert.False(await channel.WaitForExitAsync(TimeSpan.FromMilliseconds(250))); + } + + [Fact] + public async Task SendCommand_ReachesServer() + { + var channel = CreateChannel(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var messages = new List(); + var server = Task.Run(() => channel.RunServerAsync(message => + { + lock (messages) + { + messages.Add(message); + if (messages.Count == MessageBurst + 1) + received.TrySetResult(message); + } + }, cancellationToken: cts.Token)); + + Assert.True(await SendWithRetryAsync(channel, () => channel.SendCommandAsync("cloud-login", "user-1"))); + for (var i = 0; i < MessageBurst; i++) + Assert.True(await channel.SendMessageAsync($"/tmp/project{i}")); + + await received.Task.WaitAsync(cts.Token); + Assert.Equal(StudioInstanceChannel.FormatCommand("cloud-login", "user-1"), messages[0]); + Assert.Equal(MessageBurst + 1, messages.Count); + + await cts.CancelAsync(); + await server; + } + + [Fact] + public async Task SendCommand_ReturnsFalseWithoutServer() + { + var channel = CreateChannel(); + + Assert.False(await channel.SendCommandAsync(StudioInstanceChannel.ShutdownCommand)); + } + + private static async Task SendWithRetryAsync(StudioInstanceChannel channel, Func> send) + { + // The server may not be listening yet. + for (var i = 0; i < 20; i++) + { + if (await send()) + return true; + await Task.Delay(100); + } + + return false; + } +} diff --git a/tests/OneWare.Studio.Desktop.UnitTests/IpcCommandServiceTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/IpcCommandServiceTests.cs new file mode 100644 index 000000000..9fad12ca0 --- /dev/null +++ b/tests/OneWare.Studio.Desktop.UnitTests/IpcCommandServiceTests.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using OneWare.Core.Services; +using Xunit; + +namespace OneWare.Studio.Desktop.UnitTests; + +public class IpcCommandServiceTests +{ + [Fact] + public async Task ExecuteAsync_DispatchesToRegisteredHandler() + { + var service = new IpcCommandService(NullLogger.Instance); + string? received = null; + service.RegisterCommand("cloud-login", argument => + { + received = argument; + return Task.CompletedTask; + }); + + Assert.True(await service.ExecuteAsync("CLOUD-LOGIN", "user-1")); + Assert.Equal("user-1", received); + } + + [Fact] + public async Task ExecuteAsync_ReturnsFalseForUnknownCommand() + { + var service = new IpcCommandService(NullLogger.Instance); + + Assert.False(await service.ExecuteAsync("missing", null)); + } + + [Fact] + public async Task ExecuteAsync_SwallowsHandlerExceptions() + { + var service = new IpcCommandService(NullLogger.Instance); + service.RegisterCommand("fail", _ => throw new InvalidOperationException()); + + Assert.True(await service.ExecuteAsync("fail", null)); + } +} From 0677dcd38ce83d099052f3b446954d756f3510e1 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:23:52 +0200 Subject: [PATCH 8/9] Fix: CLI Login. --- .../Services/OneWareCloudLoginService.cs | 11 +++++++++-- .../OneWare.Studio.Desktop.csproj | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs index 6b7970935..4abb382fc 100644 --- a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs +++ b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs @@ -10,6 +10,7 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Web; +using Avalonia; using Avalonia.Threading; using GitCredentialManager; using Microsoft.Extensions.Logging; @@ -761,11 +762,17 @@ private async Task ExchangeCodeForTokensAsync(string code, string authProv return false; } - await Dispatcher.UIThread.InvokeAsync(async () => + async Task PersistAsync() { await SaveCredentialsAsync(accessToken, refreshToken); _settingService.Save(_paths.SettingsPath); - }); + } + + // The CLI runs without an Avalonia app, so its dispatcher never processes queued work. + if (Application.Current == null) + await PersistAsync(); + else + await Dispatcher.UIThread.InvokeAsync(PersistAsync); } else { diff --git a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj index 7cc59f6e5..94fba7217 100644 --- a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj +++ b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj @@ -74,6 +74,13 @@ + + + + + From eb984f22b45dd2f811947eed75c676664a82fe28 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:32:38 +0200 Subject: [PATCH 9/9] Fix: Double Name --- studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj index 94fba7217..af031c6bb 100644 --- a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj +++ b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj @@ -68,13 +68,13 @@ Windows: OneWareStudio.exe is a GUI-subsystem app, so it cannot write to the calling terminal. Ship a copy of the same apphost as oneware.exe with the console subsystem for CLI usage. --> - -