diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index 2dc69cbc4..87c41a863 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,50 @@ 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. +- 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 +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 @@ -149,6 +209,21 @@ 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. + +#### `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. @@ -597,7 +672,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/docs/README.md b/docs/README.md index 5ff47be43..c1e6d16e9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -131,11 +131,32 @@ 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. `./run-fresh.sh` does this for you with a new temporary profile (pass OneWare Studio arguments after `--`, see `./run-fresh.sh --help`). +### 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. + +### 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 new file mode 100644 index 000000000..740a004ac --- /dev/null +++ b/src/OneWare.CloudIntegration/OneWareCloudCliModule.cs @@ -0,0 +1,120 @@ +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using OneWare.CloudIntegration.Services; +using OneWare.Essentials.Helpers; +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; + } + + await NotifyRunningStudioAsync(OneWareCloudIntegrationModule.LoginIpcCommand, userId, cancellationToken); + 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(OneWareCloudIntegrationModule.LogoutIpcCommand, null, 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(string command, string? argument, + CancellationToken cancellationToken) + { + 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 70d510788..ce1aad483 100644 --- a/src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs +++ b/src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs @@ -20,6 +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 LoginIpcCommand = "cloud-login"; + public const string LogoutIpcCommand = "cloud-logout"; public override void RegisterServices(IServiceCollection services) { @@ -55,6 +57,8 @@ public override void Initialize(IServiceProvider serviceProvider) DataContext = serviceProvider.Resolve() })); + RegisterIpcCommands(serviceProvider); + serviceProvider.Resolve().RegisterMenuItem("MainWindow_MainMenu/Help", new MenuItemModel("Feedback") { @@ -65,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.CloudIntegration/Services/OneWareCloudLoginService.cs b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs index 89e93bc22..3603ad2d0 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; @@ -765,11 +766,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/src/OneWare.Core/App.axaml.cs b/src/OneWare.Core/App.axaml.cs index 3029a9144..5847ce42b 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/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 902339b4b..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.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 6441599dd..26c17a3f1 100644 --- a/src/OneWare.Core/Services/PluginService.cs +++ b/src/OneWare.Core/Services/PluginService.cs @@ -22,10 +22,6 @@ public class PluginService : IPluginService private readonly IBinaryCacheService _binaryCacheService; private readonly string _pluginDirectory; - private readonly HashSet _resolverSetAssemblies = new(); - - private List _initAssemblies; - public PluginService(OneWareModuleCatalog moduleCatalog, OneWareModuleManager moduleManager, ModuleServiceRegistry moduleServiceRegistry, IPaths paths, IApplicationStateService applicationStateService, IBinaryCacheService binaryCacheService) @@ -37,8 +33,6 @@ public PluginService(OneWareModuleCatalog moduleCatalog, OneWareModuleManager mo _moduleServiceRegistry = moduleServiceRegistry; _applicationStateService = applicationStateService; - _initAssemblies = AppDomain.CurrentDomain.GetAssemblies().ToList(); - _pluginDirectory = Path.Combine(paths.SessionDirectory, "Plugins"); } @@ -46,8 +40,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); @@ -87,7 +80,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) { @@ -127,9 +125,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)) @@ -158,101 +156,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/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/OneWare.Essentials.csproj b/src/OneWare.Essentials/OneWare.Essentials.csproj index 78efcdc2a..ace280602 100644 --- a/src/OneWare.Essentials/OneWare.Essentials.csproj +++ b/src/OneWare.Essentials/OneWare.Essentials.csproj @@ -41,6 +41,7 @@ + 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/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/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.Desktop/Cli/CliHostFactory.cs b/studio/OneWare.Studio.Desktop/Cli/CliHostFactory.cs new file mode 100644 index 000000000..1c4b5201a --- /dev/null +++ b/studio/OneWare.Studio.Desktop/Cli/CliHostFactory.cs @@ -0,0 +1,77 @@ +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; +using OneWare.Core.Services; +using OneWare.Essentials.Services; +using OneWare.Settings; + +namespace OneWare.Studio.Desktop.Cli; + +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.Desktop/Cli/CliModuleLoader.cs b/studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs new file mode 100644 index 000000000..dc95968b9 --- /dev/null +++ b/studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs @@ -0,0 +1,168 @@ +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()); + cliHostBuilder.ModuleCatalog.AddModule(new OneWareCloudCliModule()); + } + + 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; + + 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; + } + + 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.Desktop/Cli/ExtensionStoreCliModule.cs b/studio/OneWare.Studio.Desktop/Cli/ExtensionStoreCliModule.cs new file mode 100644 index 000000000..0da67f8f0 --- /dev/null +++ b/studio/OneWare.Studio.Desktop/Cli/ExtensionStoreCliModule.cs @@ -0,0 +1,476 @@ +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; +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; + +namespace OneWare.Studio.Desktop.Cli; + +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.Desktop/Cli/StudioCli.cs b/studio/OneWare.Studio.Desktop/Cli/StudioCli.cs new file mode 100644 index 000000000..0803fded6 --- /dev/null +++ b/studio/OneWare.Studio.Desktop/Cli/StudioCli.cs @@ -0,0 +1,148 @@ +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. + // 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/StudioCliModule.cs b/studio/OneWare.Studio.Desktop/Cli/StudioCliModule.cs new file mode 100644 index 000000000..bffd4f9d3 --- /dev/null +++ b/studio/OneWare.Studio.Desktop/Cli/StudioCliModule.cs @@ -0,0 +1,21 @@ +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) + { + var stopStudioCommand = new Command("stop", "Stop OneWare Studio"); + stopStudioCommand.SetAction((_, cancellationToken) => stopStudio(cancellationToken)); + + return [stopStudioCommand]; + } +} diff --git a/studio/OneWare.Studio.Desktop/Cli/StudioProcessController.cs b/studio/OneWare.Studio.Desktop/Cli/StudioProcessController.cs new file mode 100644 index 000000000..69f38e4c1 --- /dev/null +++ b/studio/OneWare.Studio.Desktop/Cli/StudioProcessController.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using OneWare.Essentials.Helpers; + +namespace OneWare.Studio.Desktop.Cli; + +internal sealed class StudioProcessController(StudioInstanceChannel channel) +{ + 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 = true, + WorkingDirectory = Environment.CurrentDirectory + }; + + foreach (var arg in args) + startInfo.ArgumentList.Add(arg); + + using var process = Process.Start(startInfo); + return process is null ? 1 : 0; + } + + public async Task StopStudio(CancellationToken cancellationToken) + { + if (!channel.IsStudioRunning()) + { + Console.Error.WriteLine("ONE WARE Studio is not running."); + return 0; + } + + if (!await channel.SendCommandAsync(StudioInstanceChannel.ShutdownCommand, null, cancellationToken)) + { + Console.Error.WriteLine("Could not reach ONE WARE Studio over IPC."); + return 1; + } + + if (await channel.WaitForExitAsync(StopTimeout, cancellationToken)) + return 0; + + 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 4cc30417f..bc68f1a7c 100644 --- a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs +++ b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs @@ -25,6 +25,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; @@ -99,6 +100,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) @@ -160,37 +174,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/OneWare.Studio.Desktop.csproj b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj index 876ea4bc1..5128fdda0 100644 --- a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj +++ b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj @@ -1,4 +1,4 @@ - + @@ -45,6 +45,10 @@ + + + + @@ -61,10 +65,44 @@ - + - + + + + + + + + + + + + + + + + diff --git a/studio/OneWare.Studio.Desktop/Program.cs b/studio/OneWare.Studio.Desktop/Program.cs index de9149600..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; @@ -11,24 +11,28 @@ using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Dialogs; using Avalonia.Threading; using Dock.Settings; using Microsoft.Extensions.Logging; using OneWare.Core.Data; using OneWare.Core.Extensions; +using OneWare.Core.Services; using OneWare.Core.Views.Windows; using OneWare.Essentials.Helpers; using OneWare.Essentials.Services; +using OneWare.Studio.Desktop.Cli; 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() { @@ -102,159 +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 - { - 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) + _lockFileStream = InstanceChannel.TryAcquirePrimaryLock(); + if (_lockFileStream == null) { - 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(); + var applicationStateService = ContainerLocator.Container?.Resolve(); - var logger = ContainerLocator.Container?.Resolve(); - logger?.Log($"Received IPC message: {target}"); + ActivateMainWindow(); - var mainWindow = ContainerLocator.Container?.Resolve(); - if (mainWindow != null) - { - if (mainWindow.WindowState == WindowState.Minimized) - mainWindow.WindowState = WindowState.Normal; + // "activateWindow" is sent by older versions; it only brings the window to the front. + if (target == "activateWindow") + return; - 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}"); - - 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}"); } } @@ -265,135 +215,46 @@ public static int Main(string[] args) { StartupTimer.Mark("Main entered"); - 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 - }; + if (StudioCli.IsCliInvocation(args)) + return StudioCli.Run(args); - RootCommand rootCommand = new() - { - Options = - { - dirOption, - appdataDirOption, - projectsDirOption, - moduleOption, - autoLaunchOption, - packageRepositoryOption, - configurationProfileOption - }, - Arguments = - { - openArgument - } - }; + // 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); 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(); - if (args.LastOrDefault() is "--help" or "-h") return 0; - - // Check for single instance if (!TryBecomePrimaryInstance()) { - // Not the primary instance - try to forward the message - Console.WriteLine("Another instance is already running. Forwarding request..."); - - // Determine what to send to the existing instance - string? messageToSend = null; + Console.WriteLine("Forwarding request..."); - if (Environment.GetEnvironmentVariable("ONEWARE_OPEN_URL") is { } url) - messageToSend = url; - else if (Environment.GetEnvironmentVariable("ONEWARE_OPEN_PATH") is { } path) messageToSend = path; + var message = Environment.GetEnvironmentVariable("ONEWARE_OPEN_URL") + ?? Environment.GetEnvironmentVariable("ONEWARE_OPEN_PATH") + ?? StudioInstanceChannel.FormatCommand(StudioInstanceChannel.ActivateCommand); - 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/ExtensionStoreCliModuleTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/ExtensionStoreCliModuleTests.cs new file mode 100644 index 000000000..bbbe357f9 --- /dev/null +++ b/tests/OneWare.Studio.Desktop.UnitTests/ExtensionStoreCliModuleTests.cs @@ -0,0 +1,54 @@ +using System.CommandLine; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using OneWare.Studio.Desktop.Cli; +using Xunit; + +namespace OneWare.Studio.Desktop.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.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)); + } +} 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]); + } +} 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)); + } +}