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