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.
-->
-
-