Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 85 additions & 8 deletions docs/PluginDevelopment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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<Command> 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):

```
Expand Down Expand Up @@ -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<IIpcCommandService>()
.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<T>()` or `ContainerLocator.Current` to resolve OneWare services.

## Styling
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<user>`) 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:<name>`), 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 |
Expand Down
120 changes: 120 additions & 0 deletions src/OneWare.CloudIntegration/OneWareCloudCliModule.cs
Original file line number Diff line number Diff line change
@@ -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<OneWareCloudLoginService>();
}

public override IReadOnlyList<Command> 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<int> LoginAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var loginService = serviceProvider.GetRequiredService<OneWareCloudLoginService>();

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<string>(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<int> LogoutAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var paths = serviceProvider.GetRequiredService<IPaths>();
var userId = settingsService.GetSettingValue<string>(OneWareCloudIntegrationModule.OneWareAccountUserIdKey);

if (string.IsNullOrWhiteSpace(userId))
{
Console.WriteLine("OneWare Cloud is already logged out.");
return 0;
}

try
{
serviceProvider.GetRequiredService<OneWareCloudLoginService>().Logout(userId);
settingsService.SaveValues(paths.SettingsPath,
new Dictionary<string, object?>
{
[OneWareCloudIntegrationModule.OneWareAccountUserIdKey] =
settingsService.GetSettingValue<string>(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<ISettingsService>();
var paths = serviceProvider.GetRequiredService<IPaths>();

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.");
}
}
30 changes: 30 additions & 0 deletions src/OneWare.CloudIntegration/OneWareCloudIntegrationModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -55,6 +57,8 @@ public override void Initialize(IServiceProvider serviceProvider)
DataContext = serviceProvider.Resolve<OneWareCloudAccountFlyoutViewModel>()
}));

RegisterIpcCommands(serviceProvider);

serviceProvider.Resolve<IWindowService>().RegisterMenuItem("MainWindow_MainMenu/Help",
new MenuItemModel("Feedback")
{
Expand All @@ -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<IIpcCommandService>();
var loginService = serviceProvider.Resolve<OneWareCloudLoginService>();
var settingsService = serviceProvider.Resolve<ISettingsService>();

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<IWindowService>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -249,8 +250,8 @@
{
try
{
var plaintext = ProtectedData.Unprotect(File.ReadAllBytes(tokenPath), null,

Check warning on line 253 in src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs

View workflow job for this annotation

GitHub Actions / build

This call site is reachable on all platforms. 'ProtectedData.Unprotect(byte[], byte[]?, DataProtectionScope)' is only supported on: 'windows'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)
DataProtectionScope.CurrentUser);

Check warning on line 254 in src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs

View workflow job for this annotation

GitHub Actions / build

This call site is reachable on all platforms. 'DataProtectionScope.CurrentUser' is only supported on: 'windows'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)
return Encoding.UTF8.GetString(plaintext);
}
catch (Exception e)
Expand Down Expand Up @@ -765,11 +766,17 @@
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
{
Expand Down
1 change: 1 addition & 0 deletions src/OneWare.Core/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ protected virtual void RegisterServices(IServiceCollection services)
services.AddSingleton<IProjectManagerService, ProjectManagerService>();
services.AddSingleton<ILanguageManager, LanguageManager>();
services.AddSingleton<IApplicationStateService, ApplicationStateService>();
services.AddSingleton<IIpcCommandService, IpcCommandService>();
services.AddSingleton<IMainDockService, MainDockService>();
services.AddSingleton<IWindowService, WindowService>();
services.AddSingleton<BackupService>();
Expand Down
3 changes: 3 additions & 0 deletions src/OneWare.Core/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("OneWare.Studio.Desktop.UnitTests")]
Loading
Loading