Skip to content
Closed
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ Claude Code skills are provided as `/beutl-build`, `/beutl-test`, `/beutl-format
| `Beutl.ProjectSystem` | Project / document persistence |
| `Beutl.Editor` | Non-UI editor logic — undo/redo, packaging, editing-pipeline services (no Avalonia) |
| `Beutl.Editor.Components`, `Beutl.Controls` | Avalonia UI layer (views / controls / ViewModels) |
| `Beutl.Extensibility` | Plugin abstractions |
| `Beutl.Extensibility.Abstractions` | Lightweight base extension contracts (`Extension` / `ExtensionSettings` / `ExportAttribute`); depends only on `Beutl.Core` + `Beutl.Configuration` so a plugin can target the thin surface without the full UI/media assembly |
| `Beutl.Extensibility` | Plugin abstractions — UI / media / property-editor contracts layered on top of `Beutl.Extensibility.Abstractions` |
| `Beutl.NodeGraph` | Node editor |
| `Beutl.FFmpegIpc` | **MIT** IPC layer (transport: Protocol / Transport / SharedMemory). Also hosts the IPC client providers under `Providers/`, which translate frame/sample messages into `Beutl.Media` / `Beutl.Extensibility` types; that adapter role is why this project deliberately takes `ProjectReference`s to `Beutl.Engine` + `Beutl.Extensibility` rather than staying dependency-free. |
| `Beutl.FFmpegWorker` | **GPL** separate process; reach it only via IPC |
Expand Down
2 changes: 2 additions & 0 deletions Beutl.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<File Path="version.json" />
</Folder>
<Folder Name="/Tests/">
<Project Path="tests\Beutl.Extensibility.Abstractions.Tests\Beutl.Extensibility.Abstractions.Tests.csproj" />
<Project Path="tests/Beutl.Graphics3DTests/Beutl.Graphics3DTests.csproj" />
<Project Path="tests\Beutl.Benchmarks\Beutl.Benchmarks.csproj" />
<Project Path="tests\Beutl.Extensions.AVFoundation.Tests\Beutl.Extensions.AVFoundation.Tests.csproj" />
Expand All @@ -46,6 +47,7 @@
<Project Path="src\Beutl.Core\Beutl.Core.csproj" />
<Project Path="src\Beutl.Engine\Beutl.Engine.csproj" />
<Project Path="src\Beutl.ExceptionHandler\Beutl.ExceptionHandler.csproj" />
<Project Path="src\Beutl.Extensibility.Abstractions\Beutl.Extensibility.Abstractions.csproj" />
<Project Path="src\Beutl.Extensibility\Beutl.Extensibility.csproj" />
<Project Path="src\Beutl.Extensions.AVFoundation\Beutl.Extensions.AVFoundation.csproj" />
<Project Path="src\Beutl.Extensions.FFmpeg\Beutl.Extensions.FFmpeg.csproj" />
Expand Down
1 change: 1 addition & 0 deletions nukebuild/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ private string GetTFM()
[
("Beutl.Configuration", tfm),
("Beutl.Core", tfm),
("Beutl.Extensibility.Abstractions", tfm),
("Beutl.Extensibility", tfm),
("Beutl.Engine", tfm),
("Beutl.Engine.SourceGenerators", "netstandard2.0"),
Expand Down
6 changes: 6 additions & 0 deletions sdk/Beutl.Extensibility.Sdk/Sdk/Sdk.targets
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<PropertyGroup>
<BeutlAutoReferenceAll Condition="'$(BeutlAutoReferenceAll)' == ''">true</BeutlAutoReferenceAll>
<BeutlAutoReferenceExtensibility Condition="'$(BeutlAutoReferenceExtensibility)' == ''">$(BeutlAutoReferenceAll)</BeutlAutoReferenceExtensibility>
<BeutlAutoReferenceExtensibilityAbstractions Condition="'$(BeutlAutoReferenceExtensibilityAbstractions)' == ''">$(BeutlAutoReferenceAll)</BeutlAutoReferenceExtensibilityAbstractions>
<BeutlAutoReferenceProjectSystem Condition="'$(BeutlAutoReferenceProjectSystem)' == ''">$(BeutlAutoReferenceAll)</BeutlAutoReferenceProjectSystem>
<BeutlAutoReferenceNodeGraph Condition="'$(BeutlAutoReferenceNodeGraph)' == ''">$(BeutlAutoReferenceAll)</BeutlAutoReferenceNodeGraph>
<BeutlAutoReferenceEditor Condition="'$(BeutlAutoReferenceEditor)' == ''">$(BeutlAutoReferenceAll)</BeutlAutoReferenceEditor>
Expand All @@ -40,6 +41,9 @@

<ItemGroup Condition="'$(_PreventCopyInternalPackages)' == 'true'">
<PackageReference Include="Beutl.Extensibility" Version="$(BeutlPackagesVersion)" ExcludeAssets="runtime;native" Condition="'$(BeutlAutoReferenceExtensibility)' == 'true'" />
<!-- Reference directly even when it arrives transitively, so ExcludeAssets suppresses the
host-provided runtime asset under sideload. -->
<PackageReference Include="Beutl.Extensibility.Abstractions" Version="$(BeutlPackagesVersion)" ExcludeAssets="runtime;native" Condition="'$(BeutlAutoReferenceExtensibilityAbstractions)' == 'true' or '$(BeutlAutoReferenceExtensibility)' == 'true'" />
<PackageReference Include="Beutl.ProjectSystem" Version="$(BeutlPackagesVersion)" ExcludeAssets="runtime;native" Condition="'$(BeutlAutoReferenceProjectSystem)' == 'true'" />
<PackageReference Include="Beutl.NodeGraph" Version="$(BeutlPackagesVersion)" ExcludeAssets="runtime;native" Condition="'$(BeutlAutoReferenceNodeGraph)' == 'true'" />
<PackageReference Include="Beutl.Editor" Version="$(BeutlPackagesVersion)" ExcludeAssets="runtime;native" Condition="'$(BeutlAutoReferenceEditor)' == 'true'" />
Expand All @@ -54,6 +58,8 @@

<ItemGroup Condition="'$(_PreventCopyInternalPackages)' != 'true'">
<PackageReference Include="Beutl.Extensibility" Version="$(BeutlPackagesVersion)" Condition="'$(BeutlAutoReferenceExtensibility)' == 'true'" />
<!-- Thin author only; otherwise Beutl.Extensibility brings it transitively. -->
<PackageReference Include="Beutl.Extensibility.Abstractions" Version="$(BeutlPackagesVersion)" Condition="'$(BeutlAutoReferenceExtensibilityAbstractions)' == 'true' and '$(BeutlAutoReferenceExtensibility)' != 'true'" />
<PackageReference Include="Beutl.ProjectSystem" Version="$(BeutlPackagesVersion)" Condition="'$(BeutlAutoReferenceProjectSystem)' == 'true'" />
<PackageReference Include="Beutl.NodeGraph" Version="$(BeutlPackagesVersion)" Condition="'$(BeutlAutoReferenceNodeGraph)' == 'true'" />
<PackageReference Include="Beutl.Editor" Version="$(BeutlPackagesVersion)" Condition="'$(BeutlAutoReferenceEditor)' == 'true'" />
Expand Down
44 changes: 44 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,47 @@
</PropertyGroup>
</Project>
```

## Auto-referenced packages

By default the SDK references every Beutl package an extension typically needs. Each can be
opted out individually:

| Property | Package |
|---|---|
| `BeutlAutoReferenceExtensibility` | `Beutl.Extensibility` (full UI / media / property-editor surface) |
| `BeutlAutoReferenceExtensibilityAbstractions` | `Beutl.Extensibility.Abstractions` (base contracts only) |
| `BeutlAutoReferenceProjectSystem` | `Beutl.ProjectSystem` |
| `BeutlAutoReferenceNodeGraph` | `Beutl.NodeGraph` |
| `BeutlAutoReferenceEditor` | `Beutl.Editor` |
| `BeutlAutoReferenceSourceGenerators` | `Beutl.Engine.SourceGenerators` |

Set `BeutlAutoReferenceAll` to `false` to opt out of everything and then enable only what you need.

### Thin (abstractions-only) plugin

`Beutl.Extensibility.Abstractions` holds just the base extension contracts — `Extension`,
`ExtensionSettings`, and `ExportAttribute` — without pulling in Avalonia, SkiaSharp, or the rest
of the full `Beutl.Extensibility` surface. A plugin that only defines these contracts can drop the
heavy package and reference the abstractions instead.

Disabling `BeutlAutoReferenceExtensibility` alone is not enough: `BeutlAutoReferenceProjectSystem`,
`BeutlAutoReferenceNodeGraph`, and `BeutlAutoReferenceEditor` default to `true`, and those packages
reference `Beutl.Extensibility`, so it returns transitively. For a truly thin plugin, opt out of
everything with `BeutlAutoReferenceAll` and add only the abstractions back:

```xml
<Project Sdk="Beutl.Extensibility.Sdk/1.0.0">
<PropertyGroup>
<PackageId>Beutl.Extensions.MyExtension</PackageId>
<Version>1.0.0</Version>
<Authors>Your Name</Authors>
<!-- Drop every auto-reference, then keep only the lightweight base contracts. -->
<BeutlAutoReferenceAll>false</BeutlAutoReferenceAll>
<BeutlAutoReferenceExtensibilityAbstractions>true</BeutlAutoReferenceExtensibilityAbstractions>
</PropertyGroup>
</Project>
```

When `BeutlAutoReferenceExtensibility` is left enabled, the abstractions arrive transitively, so
`BeutlAutoReferenceExtensibilityAbstractions` only adds a direct reference for the thin case above.
1 change: 1 addition & 0 deletions src/Beutl.Api/Services/CoreLibraries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public static IEnumerable<Dependency> CollectRuntimeDependencies()
case "Beutl.Embedding.MediaFoundation" when OperatingSystem.IsWindows():
case "Beutl.Extensions.AVFoundation" when OperatingSystem.IsMacOS():
case "Beutl.Engine":
case "Beutl.Extensibility.Abstractions":
case "Beutl.Extensibility":
case "Beutl.Language":
case "Beutl.NodeGraph":
Expand Down
5 changes: 3 additions & 2 deletions src/Beutl.Api/Services/LocalPackage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ public LocalPackage(NuspecReader nuspecReader)
if (nearest != null)
{
PackageDependencyGroup depGroup = depGroups.First(v => v.TargetFramework == nearest);
PackageDependency? sdkDep = depGroup.Packages.FirstOrDefault(v => v.Id == "Beutl.Sdk")
?? depGroup.Packages.FirstOrDefault(v => v.Id == "Beutl.Extensibility");
PackageDependency? sdkDep = depGroup.Packages.FirstOrDefault(v => StringComparer.OrdinalIgnoreCase.Equals(v.Id, "Beutl.Sdk"))
?? depGroup.Packages.FirstOrDefault(v => StringComparer.OrdinalIgnoreCase.Equals(v.Id, "Beutl.Extensibility"))
?? depGroup.Packages.FirstOrDefault(v => StringComparer.OrdinalIgnoreCase.Equals(v.Id, "Beutl.Extensibility.Abstractions"));
if (sdkDep != null)
{
TargetVersion = sdkDep.VersionRange.ToShortString();
Expand Down
24 changes: 20 additions & 4 deletions src/Beutl.Api/Services/PackageManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Platform;
using Beutl.Api.Objects;
Expand All @@ -26,6 +27,11 @@
{
private readonly ILogger _logger = Log.CreateLogger<PackageManager>();
private readonly ConcurrentDictionary<int, LoadedPackageInfo> _loadedPackages = new();
// Captures the publisher subscribed at setup so cleanup can unsubscribe even if extension.Settings is later swapped.
private sealed record SettingsSubscription(ExtensionSettings Settings, EventHandler Handler);

// Weak key so a leftover entry can't pin the extension's collectible AssemblyLoadContext and block unload.
private readonly ConditionalWeakTable<Extension, SettingsSubscription> _settingsChangedHandlers = new();
private readonly ExtensionSettingsStore _settingsStore = new();

public IEnumerable<LocalPackage> LoadedPackage => _loadedPackages.Values.Select(x => x.Package);
Expand Down Expand Up @@ -160,7 +166,7 @@

foreach (PackageIdentity packageId in packages)
{
string directory = Helper.PackagePathResolver.GetInstalledPath(packageId);

Check warning on line 169 in src/Beutl.Api/Services/PackageManager.cs

View workflow job for this annotation

GitHub Actions / build

Converting null literal or possible null value to non-nullable type.
if (Directory.Exists(directory))
{
var reader = new PackageFolderReader(directory);
Expand Down Expand Up @@ -492,21 +498,31 @@
{
if (extension.Settings is { } settings)
{
// Unsubscribe before Restore: it raises ConfigurationChanged, which a stale handler
// would turn into a Save of partially-restored state.
if (_settingsChangedHandlers.TryGetValue(extension, out SettingsSubscription? previous))
{
_settingsChangedHandlers.Remove(extension);
previous.Settings.ConfigurationChanged -= previous.Handler;
}

_settingsStore.Restore(extension, settings);

EventHandler handler = (_, _) => _settingsStore.Save(extension, settings);
extension.SettingsChangedHandler = handler;
_settingsChangedHandlers.AddOrUpdate(extension, new SettingsSubscription(settings, handler));
settings.ConfigurationChanged += handler;
_logger.LogInformation("Settings restored for extension {ExtensionName}", extension.GetType().Name);
}
}

private void CleanupExtensionSettings(Extension extension)
{
if (extension.Settings is { } settings && extension.SettingsChangedHandler is { } handler)
// Unsubscribe from the captured publisher (extension.Settings may have changed) so the
// handler stops keeping the collectible AssemblyLoadContext alive.
if (_settingsChangedHandlers.TryGetValue(extension, out SettingsSubscription? subscription))
{
settings.ConfigurationChanged -= handler;
extension.SettingsChangedHandler = null;
_settingsChangedHandlers.Remove(extension);
subscription.Settings.ConfigurationChanged -= subscription.Handler;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\Beutl.Configuration\Beutl.Configuration.csproj" />
<ProjectReference Include="..\Beutl.Core\Beutl.Core.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
namespace Beutl.Extensibility;

// 拡張機能の基本クラス
public abstract class Extension
{
public virtual string Name => GetType().Name;
Expand All @@ -9,8 +8,6 @@ public abstract class Extension

public virtual ExtensionSettings? Settings { get; }

internal EventHandler? SettingsChangedHandler { get; set; }

/// <summary>
/// Called once when the extension is loaded. Override to perform initialization.
/// If this method throws, <see cref="Unload"/> is called to roll back any partial
Expand Down
1 change: 1 addition & 0 deletions src/Beutl.Extensibility/Beutl.Extensibility.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\Beutl.Extensibility.Abstractions\Beutl.Extensibility.Abstractions.csproj" />
<ProjectReference Include="..\Beutl.Core\Beutl.Core.csproj" />
<ProjectReference Include="..\Beutl.Engine\Beutl.Engine.csproj" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TargetFrameworks>net10.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
</ItemGroup>

<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Beutl.Extensibility.Abstractions\Beutl.Extensibility.Abstractions.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Reflection;

using Beutl.Extensibility;

namespace Beutl.Extensibility.Abstractions.Tests;

[TestFixture]
public class ExtensibilityAbstractionsAssemblyTests
{
Comment thread
yuto-trd marked this conversation as resolved.
[Test]
public void BaseExtensionContracts_LiveInAbstractionsAssembly()
{
Assert.That(typeof(Extension).Assembly.GetName().Name, Is.EqualTo("Beutl.Extensibility.Abstractions"));
Assert.That(typeof(ExtensionSettings).Assembly, Is.SameAs(typeof(Extension).Assembly));
Assert.That(typeof(ExportAttribute).Assembly, Is.SameAs(typeof(Extension).Assembly));
}

[Test]
public void MinimalExtension_CanUseOnlyAbstractionsProject()
{
var extension = new MinimalExtension();

Assert.That(extension.Name, Is.EqualTo(nameof(MinimalExtension)));
Assert.That(extension.Settings, Is.TypeOf<MinimalSettings>());
}

[Test]
public void AbstractionsAssembly_DoesNotPullInHeavyImplementationDependencies()
{
// Walk the full closure, not just direct references: GetReferencedAssemblies() is pruned of
// unused references, so a heavy but not-type-referenced dependency would slip a direct-only check.
HashSet<string> closure = CollectReferencedAssemblyClosure(typeof(Extension).Assembly);

// The thin layer must never reach the heavy UI/media layer, directly or transitively.
string[] heavyAssemblies =
[
"Beutl.Extensibility",
"Beutl.Engine",
"Avalonia",
"FluentAvaloniaUI",
"Microsoft.CodeAnalysis.CSharp.Scripting",
"SkiaSharp",
"SkiaSharp.HarfBuzz",
"Vortice.XAudio2",
];
foreach (string heavy in heavyAssemblies)
{
Assert.That(closure, Does.Not.Contain(heavy), $"Abstractions must not depend on {heavy}.");
}

// Closed allowlist so any new Beutl.* assembly entering the closure fails even when the
// blocklist above doesn't name it.
string[] allowedBeutlAssemblies =
[
"Beutl.Extensibility.Abstractions",
"Beutl.Core",
"Beutl.Configuration",
"Beutl.Utilities",
"Beutl.Language",
];
string[] beutlInClosure = [.. closure.Where(x => x.StartsWith("Beutl.", StringComparison.Ordinal))];
Assert.That(beutlInClosure, Is.SubsetOf(allowedBeutlAssemblies));
}

private static HashSet<string> CollectReferencedAssemblyClosure(Assembly root)
{
var seen = new HashSet<string>(StringComparer.Ordinal) { root.GetName().Name! };
var queue = new Queue<Assembly>();
queue.Enqueue(root);

while (queue.Count > 0)
{
foreach (AssemblyName reference in queue.Dequeue().GetReferencedAssemblies())
{
if (!seen.Add(reference.Name!))
{
continue;
}

try
{
queue.Enqueue(Assembly.Load(reference));
}
catch
{
// Name is already recorded; an assembly absent from the test output just can't be walked further.
}
}
}

return seen;
}

private sealed class MinimalExtension : Extension
{
public override ExtensionSettings Settings { get; } = new MinimalSettings();
}

private sealed class MinimalSettings : ExtensionSettings
{
}
}
Loading
Loading