diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f18f4af3..36d105fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Built with Unity 6.5 ### Added +- Extensible shared plugin settings sections for paths and multiple mixer channels, with canonical plugin-owned values and provider validation for global edits. + - Smooth 100 ms master fades when opening and closing the Player pause menu, with rapid-toggle cancellation and independent sound previews. - Player-owned stereo DSP with per-bus/output parametric EQ, linked soft-knee compression, a shared Signalsmith FDN room return, and final linked sample-peak limiting. Sound settings offer Reference, Desktop Room, Headphones and Night presets, live preview/Apply/Cancel, per-table overrides, meters and bounded preset copy/paste. Listening preferences stay outside `.vpe` tables; physical SSF remains a separate milestone. diff --git a/VisualPinball.Plugins.Test/GlobalSettingsTests.cs b/VisualPinball.Plugins.Test/GlobalSettingsTests.cs index ff7a76d3e..76d177605 100644 --- a/VisualPinball.Plugins.Test/GlobalSettingsTests.cs +++ b/VisualPinball.Plugins.Test/GlobalSettingsTests.cs @@ -43,7 +43,93 @@ public void OnlyExplicitSupportedVisibleAndCompatibleFieldsAppear() var found = PluginGlobalSettingBinding.Discover(new[] { first, second, unsupported, game, unbounded, absent, broken }, _store, "windows", PluginGlobalSections.VolumeMixer, diagnostics.Add); Assert.That(found.Select(item => item.Id), Is.EqualTo(new[] { "second/value", "first/value" })); Assert.That(diagnostics.Count, Is.EqualTo(3)); - Assert.That(PluginGlobalSettingBinding.Discover(new[] { first }, _store, "windows", "unknown"), Is.Empty); + } + [Test] + public void PathsAcceptFilesAndFoldersWithoutConstructingPlugins() + { + var factory = new TestFactory("library") { Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("", "", + new PluginField("root", "Archive folder", SettingKind.Folder) { GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.Paths) }, + new PluginField("image", "Disk image", SettingKind.File) { GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.Paths) }, + new PluginField("invalid", "Wrong type", SettingKind.Boolean) { GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.Paths) }, + new PluginField("game", "Game folder", SettingKind.Folder) { Scope = ConfigurationScope.GameOnly, GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.Paths) })) }; + var diagnostics = new List(); + Assert.That(PluginGlobalSettingBinding.Discover(new[] { factory }, _store, "windows", PluginGlobalSections.Paths, diagnostics.Add).Select(item => item.Field.Id), Is.EqualTo(new[] { "image", "root" })); + Assert.That(diagnostics.Count, Is.EqualTo(2)); + Assert.That(factory.Constructions, Is.Zero); + } + [TestCase(-1)] + [TestCase(999)] + public void UnsupportedSectionsAreIgnoredWithoutHidingPluginSettings(int value) + { + var section = (PluginGlobalSections)value; + var future = new PluginField("future", "Future preference", SettingKind.Boolean) { GlobalSetting = new PluginGlobalSetting(section) }; + var factory = Factory(); + factory.Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("", "", Volume(), future)); + var diagnostics = new List(); + Assert.That(PluginGlobalSettingBinding.Discover(new[] { factory }, _store, "windows", section, diagnostics.Add), Is.Empty); + Assert.That(PluginGlobalSettingBinding.Discover(new[] { factory }, _store, "windows", PluginGlobalSections.VolumeMixer).Single().Field.Id, Is.EqualTo("value")); + Assert.That(factory.Schema.Fields, Does.Contain(future)); + Assert.That(diagnostics, Is.Empty); + Assert.That(factory.Constructions, Is.Zero); + } + [Test] + public void MultipleMixerChannelsHaveDistinctDefaultLabels() + { + var first = Volume("music"); var second = new PluginField("speech", "Speech", SettingKind.Integer) { + Minimum = 0, Maximum = 100, GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.VolumeMixer) }; + var factory = new TestFactory("audio") { Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("", "", first, second)) }; + var bindings = PluginGlobalSettingBinding.Discover(new[] { factory }, _store, "windows", PluginGlobalSections.VolumeMixer); + Assert.That(bindings.Select(binding => binding.Label).Distinct().Count(), Is.EqualTo(2)); + } + [Test] + public void SharedPathEditsHonorProviderValidation() + { + var factory = new TestFactory("library") { Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("", "", + new PluginField("root", "Folder", SettingKind.Folder) { Default = "", GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.Paths) })), + SettingsProvider = new RejectingPaths() }; + var host = Host(factory); + var commit = host.CommitGlobalSettingAsync(factory, "root", new JValue("unsafe")); PluginHostTests.Run(host, commit); + Assert.That(commit.Result.Success, Is.False); + Assert.That(File.Exists(_store.SettingsPath(factory.Descriptor)), Is.False); + } + private sealed class RejectingPaths : IPluginSettingsProvider + { + public System.Threading.Tasks.Task DiscoverAsync(string provider, PluginChoiceRequest request, CancellationToken cancellation) + => System.Threading.Tasks.Task.FromResult(new PluginChoiceCatalog("", Array.Empty())); + public System.Threading.Tasks.Task RunActionAsync(string action, PluginChoiceRequest request, CancellationToken cancellation) + => System.Threading.Tasks.Task.FromResult(new PluginActionResult(false, "")); + public IReadOnlyList Validate(PluginChoiceRequest request) => new[] { new PluginValidationIssue("root", "Rejected by provider") }; + } + [Test] + public void SharedValidationRunsOnOwnerAndKeepsUnrelatedOfflinePreferencesEditable() + { + var owner = Thread.CurrentThread.ManagedThreadId; var calls = 0; var factory = Factory(); + factory.SettingsProvider = new ValidationProvider(request => { + Assert.That(Thread.CurrentThread.ManagedThreadId, Is.EqualTo(owner)); calls++; + return new[] { new PluginValidationIssue("path", "The media drive is offline") }; + }); + var host = Host(factory); var commit = host.CommitGlobalSettingAsync(factory, "value", new JValue(37)); PluginHostTests.Run(host, commit); + Assert.That(commit.Result.Success, Is.True); Assert.That(calls, Is.EqualTo(2)); + Assert.That(_store.Effective(factory.Descriptor, factory.Schema).Integer("value"), Is.EqualTo(37)); + } + [Test] + public void SharedEditCannotIntroduceAnErrorReportedOnADependentField() + { + var factory = Factory(); factory.SettingsProvider = new ValidationProvider(request => request.Settings.Integer("value") > 50 + ? new[] { new PluginValidationIssue("path", "This value conflicts with the selected output") } : Array.Empty()); + var initial = new PluginSettingsSnapshot(1); initial.Set("value", 20); _store.Save(factory.Descriptor, factory.Schema, initial); + var host = Host(factory); var commit = host.CommitGlobalSettingAsync(factory, "value", new JValue(80)); PluginHostTests.Run(host, commit); + Assert.That(commit.Result.Success, Is.False); Assert.That(_store.Effective(factory.Descriptor, factory.Schema).Integer("value"), Is.EqualTo(20)); + } + private sealed class ValidationProvider : IPluginSettingsProvider + { + private readonly Func> _validate; + internal ValidationProvider(Func> validate) => _validate = validate; + public IReadOnlyList Validate(PluginChoiceRequest request) => _validate(request); + public System.Threading.Tasks.Task DiscoverAsync(string provider, PluginChoiceRequest request, CancellationToken cancellation) + => System.Threading.Tasks.Task.FromResult(new PluginChoiceCatalog("", Array.Empty())); + public System.Threading.Tasks.Task RunActionAsync(string action, PluginChoiceRequest request, CancellationToken cancellation) + => System.Threading.Tasks.Task.FromResult(new PluginActionResult(false, "")); } [Test] public void OrdinaryDraftCannotOverwriteANewerMixerValueAndResetIsExplicit() diff --git a/VisualPinball.Plugins.Test/LaunchCheckTests.cs b/VisualPinball.Plugins.Test/LaunchCheckTests.cs new file mode 100644 index 000000000..85da35174 --- /dev/null +++ b/VisualPinball.Plugins.Test/LaunchCheckTests.cs @@ -0,0 +1,321 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// SPDX-License-Identifier: MIT + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace VisualPinball.Plugins.Test +{ + [TestFixture] + public sealed class LaunchCheckTests + { + private string _directory = null!; + private readonly GameIdentity _game = new GameIdentity("test", "game"); + [SetUp] public void SetUp() => _directory = Path.Combine(Path.GetTempPath(), "vpe-launch-tests", Guid.NewGuid().ToString("N")); + [TearDown] public void TearDown() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } + private PluginHost Host(params IPlayerPluginFactory[] factories) => new PluginHost(factories, new PluginConfigurationStore(_directory), new OwnerScheduler(), new PluginCapabilities(), new PluginResourceRegistry(), "windows"); + private static void Run(PluginHost host, Task task) => PluginHostTests.Run(host, task); + + [Test] + public void UnmetRequirementsAreCollectedBeforeAnyPluginIsConstructed() + { + var first = new CheckFactory("first") { Issues = new[] { new PluginValidationIssue("rom", "Prepare ROM") } }; + var second = new CheckFactory("second") { Issues = new[] { new PluginValidationIssue("path", "Choose a path") } }; + var optional = new TestFactory("optional"); var host = Host(first, second, optional); + var check = host.CheckLaunchAsync(_game, "test.vpe", "Test", default); Run(host, check); + Assert.That(check.Result.Select(b => b.Plugin.Id), Is.EqualTo(new[] { "first", "second" })); + Assert.That(check.Result[0].Issues[0].Field, Is.EqualTo("rom")); + Assert.Throws(() => Run(host, host.PrepareAndStartAsync(_game, "test.vpe", "Test", "controller", 1, default))); + Assert.That(first.Inner.Constructions + second.Inner.Constructions + optional.Constructions, Is.Zero); + Assert.That(host.IsRunning, Is.False); Assert.That(host.IsTransitioning, Is.False); + } + + [Test] + public void DisabledAndUnrelatedPluginsDoNotBlockLaunch() + { + var disabled = new CheckFactory("disabled"); var unrelated = new CheckFactory("unrelated") { Applies = false }; + var host = Host(disabled, unrelated); host.Configuration.SetEnabled(disabled.Descriptor, false, _game); + Run(host, host.PrepareAndStartAsync(_game, "test.vpe", "Test", "controller", 1, default)); + Assert.That(disabled.Checks + unrelated.Checks, Is.Zero); Assert.That(host.IsRunning, Is.True); + Run(host, host.StopGameAsync()); + } + + [Test] + public void ChecksReceiveEffectiveGameSettingsAndPackageOnOwnerThread() + { + var factory = new CheckFactory("settings"); + factory.Inner.Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Test", "", new PluginField("path", "Path", SettingKind.Text) { Default = "default", Scope = ConfigurationScope.GlobalAndGame })); + var host = Host(factory); var values = new PluginSettingsSnapshot(1, new JObject { ["path"] = "per-game" }); + host.Configuration.Save(factory.Descriptor, factory.Schema, values, _game); + var task = host.CheckLaunchAsync(_game, "selected.vpe", "Selected", default); Run(host, task); + Assert.That(task.Result, Is.Empty); Assert.That(factory.Thread, Is.EqualTo(Thread.CurrentThread.ManagedThreadId)); + Assert.That(factory.Context!.Settings.String("path"), Is.EqualTo("per-game")); + Assert.That(factory.Context.Game, Is.EqualTo(_game)); Assert.That(factory.Context.Platform, Is.EqualTo("windows")); + Assert.That(factory.Context.Package.Path, Is.EqualTo("selected.vpe")); + } + + [Test] + public void UnsupportedRequiredPluginBlocksOnlyItsGames() + { + var factory = new CheckFactory("platform"); factory.Descriptor.Platforms = new[] { "linux" }; var host = Host(factory); + var check = host.CheckLaunchAsync(_game, "test", "Test", default); Run(host, check); + Assert.That(check.Result, Has.Count.EqualTo(1)); Assert.That(factory.Checks, Is.Zero); + factory.Applies = false; + check = host.CheckLaunchAsync(_game, "test", "Test", default); Run(host, check); Assert.That(check.Result, Is.Empty); + } + + [Test] + public void CheckExceptionsFailClosedAndCanBeRetriedAfterCorrection() + { + var factory = new CheckFactory("throws") { Hook = (_, __) => throw new IOException("Cannot read ROM") }; var host = Host(factory); + var task = host.CheckLaunchAsync(_game, "test", "Test", default); Run(host, task); + Assert.That(task.Result.Single().Issues.Single().Message, Is.EqualTo("Cannot read ROM")); + factory.Hook = null; + task = host.CheckLaunchAsync(_game, "test", "Test", default); Run(host, task); Assert.That(task.Result, Is.Empty); + } + + [Test] + public void ACheckThatIgnoresCancellationIsNotInvokedAgainUntilItFinishes() + { + var completion = new TaskCompletionSource>(); + var factory = new CheckFactory("hung") { Hook = (_, __) => completion.Task }; var host = Host(factory); host.StartupTimeout = TimeSpan.FromMilliseconds(25); + var task = host.CheckLaunchAsync(_game, "test", "Test", default); Run(host, task); Assert.That(task.Result, Has.Count.EqualTo(1)); + task = host.CheckLaunchAsync(_game, "test", "Test", default); Run(host, task); + Assert.That(task.Result, Has.Count.EqualTo(1)); Assert.That(factory.Checks, Is.EqualTo(1)); + completion.SetResult(Array.Empty()); factory.Hook = null; + task = host.CheckLaunchAsync(_game, "test", "Test", default); Run(host, task); Assert.That(task.Result, Is.Empty); + } + + [Test] + public void ACheckThatHonorsTheDeadlineReportsAnActionableTimeout() + { + var factory = new CheckFactory("deadline") { Hook = async (_, token) => { await Task.Delay(Timeout.Infinite, token); return Array.Empty(); } }; + var host = Host(factory); host.StartupTimeout = TimeSpan.FromMilliseconds(25); + var check = host.CheckLaunchAsync(_game, "test", "Test", default); Run(host, check); + Assert.That(check.Result.Single().Issues.Single().Message, Does.Contain("did not finish in time")); + } + + [TestCase(false)] + [TestCase(true)] + public void BusyHostOnlyBlocksGamesWithApplicableChecks(bool applies) + { + var completion = new TaskCompletionSource(); + var slow = new TestFactory("slow") { InitializeCompletion = completion.Task }; + var required = new CheckFactory("required") { Applies = applies }; var host = Host(slow, required); + host.StartupTimeout = TimeSpan.FromSeconds(5); + var starting = host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default); + host.StartupTimeout = TimeSpan.FromMilliseconds(25); + try { + var check = host.CheckLaunchAsync(_game, "next", "Next", default); Run(host, check); + Assert.That(check.Result.Count, Is.EqualTo(applies ? 1 : 0)); + if (applies) Assert.That(check.Result[0].Issues[0].Message, Does.Contain("previous game is stopping")); + } finally { completion.TrySetResult(true); Run(host, starting); Run(host, host.StopGameAsync()); } + } + + [Test] + public void CallerCancellationStopsTheCheckAndReleasesTheLifecycleGate() + { + using var cancellation = new CancellationTokenSource(); + var factory = new CheckFactory("cancel") { Hook = async (_, token) => { cancellation.Cancel(); await Task.Delay(Timeout.Infinite, token); return Array.Empty(); } }; + var host = Host(factory); Assert.Catch(() => Run(host, host.CheckLaunchAsync(_game, "test", "Test", cancellation.Token))); + Assert.That(host.IsTransitioning, Is.False); factory.Hook = null; + var task = host.CheckLaunchAsync(_game, "test", "Test", default); Run(host, task); Assert.That(task.Result, Is.Empty); + } + + [TestCase(false)] + [TestCase(true)] + public void StopDuringLaunchCheckCannotResumeStartup(bool emergency) + { + var completion = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken checkToken = default; + var factory = new CheckFactory("required") { Hook = (_, token) => { checkToken = token; return completion.Task; } }; + var host = Host(factory); host.StartupTimeout = TimeSpan.FromSeconds(5); host.CleanupTimeout = TimeSpan.FromMilliseconds(25); + var starting = host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default); + try { + Assert.That(factory.Checks, Is.EqualTo(1)); + if (emergency) host.EmergencyStop(); else Run(host, host.StopGameAsync()); + Assert.That(checkToken.IsCancellationRequested, Is.True, "Stopping must cancel the pending launch check."); + completion.SetResult(Array.Empty()); + Assert.Catch(() => Run(host, starting)); + Assert.That(factory.Inner.Constructions, Is.Zero); + Assert.That(host.IsRunning, Is.False); Assert.That(host.IsTransitioning, Is.False); + } finally { + completion.TrySetResult(Array.Empty()); + try { Run(host, starting); } catch (OperationCanceledException) { } + Run(host, host.StopGameAsync()); + } + } + + [TestCase("success")] + [TestCase("error")] + [TestCase("cancel")] + [TestCase("timeout")] + public void PreflightReplaysHeldStateOnOwnerThread(string outcome) + { + var factory = new CheckFactory("required"); var host = Host(factory); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + var completion = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + factory.Hook = (_, __) => completion.Task; + host.StartupTimeout = TimeSpan.FromMilliseconds(100); + using var cancellation = new CancellationTokenSource(); + var callCount = factory.Inner.Calls.Count; + var check = host.CheckLaunchAsync(_game, "next", "Next", cancellation.Token); + try { + Assert.That(host.IsTransitioning, Is.True); + host.GameReady(); host.PauseChanged(true); host.PauseChanged(false); host.FocusChanged(false); + Assert.That(factory.Inner.Calls.Count, Is.EqualTo(callCount)); + switch (outcome) { + case "success": completion.SetResult(Array.Empty()); break; + case "error": completion.SetException(new IOException("Cannot read next game")); break; + case "cancel": cancellation.Cancel(); break; + } + if (outcome == "cancel") Assert.Catch(() => Run(host, check)); + else { + Run(host, check); + Assert.That(check.Result.Count, Is.EqualTo(outcome == "success" ? 0 : 1)); + } + Assert.That(factory.Inner.Calls.Skip(callCount), Is.EqualTo(new[] { "required:ready", "required:resume", "required:blur" })); + Assert.That(factory.Inner.FocusThread, Is.EqualTo(Thread.CurrentThread.ManagedThreadId)); + Assert.That(host.IsRunning, Is.True); Assert.That(host.IsTransitioning, Is.False); + } finally { + completion.TrySetResult(Array.Empty()); + Run(host, host.StopGameAsync()); + } + } + + [TestCase(false)] + [TestCase(true)] + public void StopDuringPreviousGameCleanupCannotResumeStartup(bool emergency) + { + var factory = new CheckFactory("required"); var host = Host(factory); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + factory.Inner.StopCompletion = completion.Task; + host.StartupTimeout = host.CleanupTimeout = TimeSpan.FromSeconds(5); + var starting = host.PrepareAndStartAsync(_game, "next", "Next", "controller", 2, default); + try { + Assert.That(factory.Inner.Calls, Does.Contain("required:stop")); + StopImmediately(host, emergency); + completion.SetResult(true); + Assert.Catch(() => Run(host, starting)); + Assert.That(factory.Inner.Constructions, Is.EqualTo(1)); Assert.That(factory.Checks, Is.EqualTo(1)); + Assert.That(host.IsRunning, Is.False); Assert.That(host.IsTransitioning, Is.False); + } finally { + completion.TrySetResult(true); + try { Run(host, starting); } catch (OperationCanceledException) { } + Run(host, host.StopGameAsync()); + } + } + + [TestCase(false)] + [TestCase(true)] + public void StopWhileStartupWaitsForPreflightCannotResumeStartup(bool emergency) + { + var factory = new CheckFactory("required"); var host = Host(factory); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + var completion = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + factory.Hook = (_, __) => completion.Task; + var check = host.CheckLaunchAsync(_game, "next", "Next", default); + var starting = host.PrepareAndStartAsync(_game, "next", "Next", "controller", 2, default); + try { + Assert.That(factory.Checks, Is.EqualTo(2)); Assert.That(starting.IsCompleted, Is.False); + StopImmediately(host, emergency); + factory.Hook = null; completion.SetResult(Array.Empty()); Run(host, check); + Assert.Catch(() => Run(host, starting)); + Assert.That(factory.Inner.Constructions, Is.EqualTo(1)); Assert.That(factory.Checks, Is.EqualTo(2)); + Assert.That(host.IsRunning, Is.False); Assert.That(host.IsTransitioning, Is.False); + } finally { + completion.TrySetResult(Array.Empty()); Run(host, check); + try { Run(host, starting); } catch (OperationCanceledException) { } + Run(host, host.StopGameAsync()); + } + } + + private static void StopImmediately(PluginHost host, bool emergency) + { + if (emergency) { host.EmergencyStop(); return; } + using var cleanup = new CancellationTokenSource(); cleanup.Cancel(); + Run(host, host.StopGameAsync(cleanup.Token)); + } + + [Test] + public void RequiredPluginFailureDuringPreparationStopsEveryPlugin() + { + var factory = new CheckFactory("required"); factory.Inner.FailAt = "prepare"; + var optional = new TestFactory("optional"); var host = Host(factory, optional); + Assert.Throws(() => Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default))); + Assert.That(host.IsRunning, Is.False); Assert.That(optional.Calls, Does.Contain("optional:dispose")); + Assert.That(factory.Inner.Calls, Does.Contain("required:dispose")); + } + + [Test] + public void LateRequirementFailureUnwindsBeforeDisposingApplicationResources() + { + var factory = new CheckFactory("required") { BlockDuringInitialize = true }; var host = Host(factory); + var blocked = Assert.Throws(() => Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default))); + Assert.That(blocked!.Blockers.Single().Issues.Single().Field, Is.EqualTo("rom")); + Assert.That(blocked.Blockers.Single().Game, Is.EqualTo(_game)); + Assert.That(factory.Inner.Calls, Is.EqualTo(new[] { "shutdown", "release", "dispose" })); + Assert.That(host.IsRunning, Is.False); Assert.That(host.IsTransitioning, Is.False); + } + + [Test] + public void AsyncCheckReturnsToOwnerBeforeInvokingTheNextFactory() + { + var first = new CheckFactory("async") { Hook = (_, __) => Task.Run>(() => Array.Empty()) }; + var second = new CheckFactory("owner"); var host = Host(first, second); + var check = host.CheckLaunchAsync(_game, "test", "Test", default); Run(host, check); + Assert.That(check.Result, Is.Empty); Assert.That(second.Thread, Is.EqualTo(Thread.CurrentThread.ManagedThreadId)); + } + + [Test] + public void PreflightDoesNotStopAnAlreadyRunningGame() + { + var factory = new CheckFactory("required"); var host = Host(factory); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + factory.Issues = new[] { new PluginValidationIssue("rom", "Missing for next game") }; + var task = host.CheckLaunchAsync(_game, "other", "Other", default); Run(host, task); + Assert.That(task.Result, Has.Count.EqualTo(1)); Assert.That(host.IsRunning, Is.True); + Assert.That(host.Statuses[factory.Descriptor.Id].State, Is.EqualTo(PluginState.Running)); Run(host, host.StopGameAsync()); + } + + private sealed class CheckFactory : IPlayerPluginFactory, IPluginLaunchCheck + { + internal readonly TestFactory Inner; + internal bool Applies = true; + internal bool BlockDuringInitialize; + internal int Checks, Thread; + internal PluginLaunchContext? Context; + internal IReadOnlyList Issues = Array.Empty(); + internal Func>>? Hook; + internal CheckFactory(string id) => Inner = new TestFactory(id); + public PluginDescriptor Descriptor => Inner.Descriptor; + public PluginSettingsSchema Schema => Inner.Schema; + public IPluginSettingsProvider? SettingsProvider => null; + public IPlayerPlugin Create(PluginApplicationContext context) + { + if (!BlockDuringInitialize) return Inner.Create(context); + context.Lifetime.Own(new Subscription(() => Inner.Calls.Add("release"))); + return new LateBlockingPlugin(this); + } + public bool AppliesTo(GameIdentity game) => Applies; + public Task> CheckLaunchAsync(PluginLaunchContext context, CancellationToken cancellation) + { Checks++; Thread = System.Threading.Thread.CurrentThread.ManagedThreadId; Context = context; return Hook?.Invoke(context, cancellation) ?? Task.FromResult(Issues); } + private sealed class LateBlockingPlugin : PlayerPlugin + { + private readonly CheckFactory _factory; + internal LateBlockingPlugin(CheckFactory factory) => _factory = factory; + public override Task InitializeAsync(CancellationToken cancellation) => throw new PluginLaunchBlockedException(_factory.Descriptor, "ROM disappeared", "rom"); + public override Task ShutdownAsync(CancellationToken cancellation) { _factory.Inner.Calls.Add("shutdown"); return Task.CompletedTask; } + public override void Dispose() => _factory.Inner.Calls.Add("dispose"); + } + } + } +} diff --git a/VisualPinball.Plugins.Test/LaunchCheckTests.cs.meta b/VisualPinball.Plugins.Test/LaunchCheckTests.cs.meta new file mode 100644 index 000000000..29cbc0c42 --- /dev/null +++ b/VisualPinball.Plugins.Test/LaunchCheckTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fdfd8a101b5d4c8e815500642e34ca69 diff --git a/VisualPinball.Plugins.Test/PluginHostTests.cs b/VisualPinball.Plugins.Test/PluginHostTests.cs index 6e86f2a9a..463238e4b 100644 --- a/VisualPinball.Plugins.Test/PluginHostTests.cs +++ b/VisualPinball.Plugins.Test/PluginHostTests.cs @@ -292,6 +292,7 @@ internal sealed class TestFactory : IPlayerPluginFactory /// Work the plugin reports as still running after its hooks returned. internal Task? QuiescenceTask; internal int PrepareThread; + internal int FocusThread; internal bool AsyncInitialize; internal bool CleanupWasCanceled; internal bool NeverFinishStop; @@ -319,6 +320,7 @@ private class TestPlugin : PlayerPlugin, IPluginReconfiguration, IPluginTick, IP public override Task StartGameAsync(CancellationToken token) { Call("start"); _factory.CancelDuringStart?.Cancel(); token.ThrowIfCancellationRequested(); return Task.CompletedTask; } public override void GameReady() => Call("ready"); public override void PauseChanged(bool paused) => Call(paused ? "pause" : "resume"); + public override void FocusChanged(bool focused) { _factory.FocusThread = Thread.CurrentThread.ManagedThreadId; Call(focused ? "focus" : "blur"); } public void Tick(double delta) => Call("tick"); public Task ApplyAsync(PluginSettingsSnapshot settings, CancellationToken cancellation) { Call("apply:" + settings.Integer("value")); _factory.ApplyAction?.Invoke(settings.Integer("value")); return _factory.ApplyCompletion ?? Task.FromResult(new PluginActionResult(true, "")); } public override Task StopGameAsync(CancellationToken token) { _factory.CleanupWasCanceled = token.IsCancellationRequested; Call("stop"); return _factory.StopCompletion ?? (_factory.NeverFinishStop ? new TaskCompletionSource().Task : Task.CompletedTask); } diff --git a/VisualPinball.Plugins.Test/SetupSettingsTests.cs b/VisualPinball.Plugins.Test/SetupSettingsTests.cs new file mode 100644 index 000000000..4eafdd207 --- /dev/null +++ b/VisualPinball.Plugins.Test/SetupSettingsTests.cs @@ -0,0 +1,71 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// SPDX-License-Identifier: MIT + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace VisualPinball.Plugins.Test +{ + [TestFixture] + public sealed class SetupSettingsTests + { + private string _directory = null!; + [SetUp] public void SetUp() => _directory = Path.Combine(Path.GetTempPath(), "vpe-setup-tests", Guid.NewGuid().ToString("N")); + [TearDown] public void TearDown() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } + private PluginHost Host(TestFactory factory) => new PluginHost(new[] { factory }, new PluginConfigurationStore(_directory), new OwnerScheduler(), new PluginCapabilities(), new PluginResourceRegistry(), "windows"); + [Test] + public void RequiredSettingsCanBeSavedOneAtATimeWithoutLosingOtherValues() + { + var factory = new TestFactory("setup") { Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Setup", "", + new PluginField("first", "First", SettingKind.Text) { Default = "", Validate = value => string.IsNullOrEmpty(value?.ToString()) ? "Choose first" : null }, + new PluginField("second", "Second", SettingKind.Text) { Default = "", Validate = value => string.IsNullOrEmpty(value?.ToString()) ? "Choose second" : null })) }; + var host = Host(factory); + var save = host.CommitSettingAsync(factory, "first", new JValue("one")); PluginHostTests.Run(host, save); + Assert.That(save.Result.Success, Is.True, save.Result.Message); + Assert.That(factory.Schema.Validate(host.Configuration.Effective(factory.Descriptor, factory.Schema)).Count, Is.EqualTo(1)); + save = host.CommitSettingAsync(factory, "second", new JValue("two")); PluginHostTests.Run(host, save); + Assert.That(save.Result.Success, Is.True); var saved = host.Configuration.Effective(factory.Descriptor, factory.Schema); + Assert.That(saved.String("first"), Is.EqualTo("one")); Assert.That(factory.Schema.Validate(saved), Is.Empty); + } + [Test] + public void GameSetupWritesOnlyThatGameAndChecksDynamicChoices() + { + var provider = new Provider(); + var factory = new TestFactory("setup") { SettingsProvider = provider, Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Setup", "", + new PluginField("image", "Image", SettingKind.Choice) { Scope = ConfigurationScope.GameOnly, Provider = "images", Default = "" })) }; + var host = Host(factory); var game = new GameIdentity("test", "game"); var package = new PluginPackageReference("selected.vpe", "Selected"); + var save = host.CommitSettingAsync(factory, "image", new JValue("gone"), game, package); PluginHostTests.Run(host, save); + Assert.That(save.Result.Success, Is.False); Assert.That(provider.Package, Is.SameAs(package)); + save = host.CommitSettingAsync(factory, "image", new JValue("available"), game, package); PluginHostTests.Run(host, save); + Assert.That(save.Result.Success, Is.True); Assert.That(host.Configuration.Effective(factory.Descriptor, factory.Schema, game).String("image"), Is.EqualTo("available")); + Assert.That(host.Configuration.Read(factory.Descriptor, factory.Schema).Values, Is.Empty); + Assert.Throws(() => host.CommitSettingAsync(factory, "image", new JValue("available"))); + } + [Test] + public void ActionProgressReachesTheCallerWithoutSavingDrafts() + { + var provider = new Provider(); var factory = new TestFactory("setup") { SettingsProvider = provider }; + var host = Host(factory); using var session = new PluginSettingsSession(factory, host.Configuration, "windows"); + var progress = new ProgressSink(); var run = session.RunActionAsync("prepare", default, progress); PluginHostTests.Run(host, run); + Assert.That(run.Result.Success, Is.True); Assert.That(progress.Last.Fraction, Is.EqualTo(.5)); Assert.That(progress.Last.Message, Is.EqualTo("Preparing files")); + Assert.That(Directory.Exists(_directory), Is.False); + } + private sealed class ProgressSink : IProgress + { internal PluginActionProgress Last; public void Report(PluginActionProgress value) => Last = value; } + private sealed class Provider : IPluginSettingsProvider + { + internal PluginPackageReference? Package; + public Task DiscoverAsync(string provider, PluginChoiceRequest request, CancellationToken cancellation) + { Package = request.Package; return Task.FromResult(new PluginChoiceCatalog("one", new[] { new PluginChoice("available", "Available") })); } + public IReadOnlyList Validate(PluginChoiceRequest request) => Array.Empty(); + public Task RunActionAsync(string action, PluginChoiceRequest request, CancellationToken cancellation) + { request.Progress?.Report(new PluginActionProgress(.5, "Preparing files")); return Task.FromResult(new PluginActionResult(true, "Ready")); } + } + } +} diff --git a/VisualPinball.Plugins.Test/SetupSettingsTests.cs.meta b/VisualPinball.Plugins.Test/SetupSettingsTests.cs.meta new file mode 100644 index 000000000..2e1d7991b --- /dev/null +++ b/VisualPinball.Plugins.Test/SetupSettingsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 54ee43bf4b2844e0915b2eda833a5522 diff --git a/VisualPinball.Plugins/Api/PluginLaunchCheck.cs b/VisualPinball.Plugins/Api/PluginLaunchCheck.cs new file mode 100644 index 000000000..bc000b579 --- /dev/null +++ b/VisualPinball.Plugins/Api/PluginLaunchCheck.cs @@ -0,0 +1,62 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace VisualPinball.Plugins +{ + /// Optional factory contract for requirements that must be met before the Player imports a table. + /// Checks run on the owner thread with committed settings, without constructing plugins or a table scene. + /// Keep them read-only, honor cancellation, and move filesystem work to a worker. A check that ignores cancellation + /// prevents further checks of that plugin, for any game, until it finishes. An applicable, enabled plugin with + /// this contract must also start successfully; optional integrations without this contract retain fault isolation. + public interface IPluginLaunchCheck + { + /// Whether this plugin is required for the selected game. Called before settings or platform validation. + bool AppliesTo(GameIdentity game); + /// Returns unmet requirements, with setting field IDs where possible; an empty list permits launch. + Task> CheckLaunchAsync(PluginLaunchContext context, CancellationToken cancellation); + } + + /// The selected package and effective committed settings, available before a table scene exists. + public sealed class PluginLaunchContext + { + public GameIdentity Game { get; } + public PluginPackageReference Package { get; } + public PluginSettingsSnapshot Settings { get; } + public string Platform { get; } + public PluginLaunchContext(GameIdentity game, PluginPackageReference package, PluginSettingsSnapshot settings, string platform) + { Game = game; Package = package; Settings = settings; Platform = platform; } + } + + /// Unmet requirements belonging to one plugin, for the Player's settings page. + public sealed class PluginLaunchBlocker + { + public PluginDescriptor Plugin { get; } + public IReadOnlyList Issues { get; } + /// The checked game, so setup can edit its settings without rereading ambiguous package metadata. + public GameIdentity? Game { get; } + public PluginLaunchBlocker(PluginDescriptor plugin, IEnumerable issues, GameIdentity? game = null) + { Plugin = plugin; Issues = Array.AsReadOnly(issues.ToArray()); Game = game; } + } + + /// Stops table activation when a requirement fails, including a race detected during plugin preparation. + public sealed class PluginLaunchBlockedException : Exception + { + public IReadOnlyList Blockers { get; } + public PluginLaunchBlockedException(IEnumerable blockers) : this(blockers.ToArray()) { } + private PluginLaunchBlockedException(PluginLaunchBlocker[] blockers) + : base(string.Join("\n", blockers.SelectMany(blocker => blocker.Issues.Select(issue => blocker.Plugin.Name + ": " + issue.Message)))) + { Blockers = Array.AsReadOnly(blockers); } + public PluginLaunchBlockedException(PluginDescriptor plugin, string message, string field = "") + : this(new[] { new PluginLaunchBlocker(plugin, new[] { new PluginValidationIssue(field, message) }) }) { } + internal PluginLaunchBlockedException ForGame(GameIdentity game) + => new PluginLaunchBlockedException(Blockers.Select(blocker => new PluginLaunchBlocker(blocker.Plugin, blocker.Issues, blocker.Game ?? game))); + } +} diff --git a/VisualPinball.Plugins/Api/PluginLaunchCheck.cs.meta b/VisualPinball.Plugins/Api/PluginLaunchCheck.cs.meta new file mode 100644 index 000000000..142c434af --- /dev/null +++ b/VisualPinball.Plugins/Api/PluginLaunchCheck.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 85d153d44c034486a06edc532151997d diff --git a/VisualPinball.Plugins/Configuration/PluginGlobalSetting.cs b/VisualPinball.Plugins/Configuration/PluginGlobalSetting.cs index c5c536558..24cf5c6b3 100644 --- a/VisualPinball.Plugins/Configuration/PluginGlobalSetting.cs +++ b/VisualPinball.Plugins/Configuration/PluginGlobalSetting.cs @@ -34,7 +34,7 @@ public enum PluginFieldPresentation /// A shared page collects one kind of setting from every installed plugin, so the user can adjust them in one place instead of /// visiting each plugin's settings page. /// - public static class PluginGlobalSections + public enum PluginGlobalSections { /// /// The Player's global volume mixer, which lists the table's mechanical sounds next to every plugin's volume. Fields exposed here @@ -42,7 +42,9 @@ public static class PluginGlobalSections /// (minimum below maximum); skips any other field /// with a diagnostic. PinMAME's ROM volume and PinUP's media volume appear here. /// - public const string VolumeMixer = "audio.mixer"; + VolumeMixer = 0, + /// File and folder preferences contributed to the Player's Paths page. + Paths = 1 } /// /// Asks the Player to also show a plugin field on a shared page, such as the global volume mixer, in addition to the plugin's own @@ -66,8 +68,8 @@ public static class PluginGlobalSections /// public sealed class PluginGlobalSetting { - /// The shared page the field appears on, one of the constants. - public string Section { get; } + /// The shared page the field appears on. Unsupported sections are ignored by the host. + public PluginGlobalSections Section { get; } /// /// Label shown on the shared page. Empty (the default) uses the plugin's , which reads better /// on the mixer than a generic field label such as "Volume". @@ -78,10 +80,11 @@ public sealed class PluginGlobalSetting /// to 0. PinMAME uses 20 and PinUP 30. /// public int Order { get; set; } + /// Optional grouping hint within the shared page. The Player owns its presentation. + public string Group { get; set; } = ""; /// Creates an exposure on the given shared page. - /// One of the constants. - /// is null. - public PluginGlobalSetting(string section) => Section = section ?? throw new ArgumentNullException(nameof(section)); + /// The shared Player page. + public PluginGlobalSetting(PluginGlobalSections section) => Section = section; } /// An alias identifies a field in its owning factory, never a second copy of its value. /// @@ -106,7 +109,9 @@ public sealed class PluginGlobalSettingBinding /// Text shown on the shared page: , or the plugin's /// when that is empty. /// - public string Label => Field.GlobalSetting!.Label.Length == 0 ? Factory.Descriptor.Name : Field.GlobalSetting.Label; + public string Label => Field.GlobalSetting!.Label.Length != 0 ? Field.GlobalSetting.Label + : Factory.Schema.Fields.Count(field => field.GlobalSetting?.Section == Field.GlobalSetting.Section) > 1 + ? Factory.Descriptor.Name + " ยท " + Field.Label : Factory.Descriptor.Name; internal PluginGlobalSettingBinding(IPlayerPluginFactory factory, PluginField field) { Factory = factory; Field = field; } /// /// Finds every field the given plugins expose on a shared page, in display order. @@ -127,11 +132,12 @@ public sealed class PluginGlobalSettingBinding /// /// The current platform ID (windows, macos, linux), matched against . /// - /// The shared page, one of the constants. + /// The shared Player page. Unsupported values return no bindings. /// Optional receiver of messages about skipped plugins and invalid exposures. /// The bindings to show, possibly empty. - public static IReadOnlyList Discover(IEnumerable factories, PluginConfigurationStore store, string platform, string section, Action? diagnostic = null) + public static IReadOnlyList Discover(IEnumerable factories, PluginConfigurationStore store, string platform, PluginGlobalSections section, Action? diagnostic = null) { + if (section != PluginGlobalSections.VolumeMixer && section != PluginGlobalSections.Paths) return Array.Empty(); var bindings = new List(); foreach (var factory in factories) { if (factory.Descriptor.Platforms.Count > 0 && !factory.Descriptor.Platforms.Contains(platform)) continue; @@ -142,7 +148,7 @@ public static IReadOnlyList Discover(IEnumerable Discover(IEnumerable binding.Field.GlobalSetting!.Order).ThenBy(binding => binding.Id, StringComparer.Ordinal).ToArray(); } - private static bool BoundedNumber(PluginField field) => (field.Kind == SettingKind.Integer || field.Kind == SettingKind.Number) - && field.Minimum.HasValue && field.Maximum.HasValue && double.IsFinite(field.Minimum.Value) && double.IsFinite(field.Maximum.Value) && field.Minimum < field.Maximum; + private static bool Accepts(PluginGlobalSections section, PluginField field) => field.Scope != ConfigurationScope.GameOnly && section switch { + PluginGlobalSections.VolumeMixer => (field.Kind == SettingKind.Integer || field.Kind == SettingKind.Number) + && field.Minimum.HasValue && field.Maximum.HasValue && double.IsFinite(field.Minimum.Value) + && double.IsFinite(field.Maximum.Value) && field.Minimum < field.Maximum, + PluginGlobalSections.Paths => field.Kind == SettingKind.File || field.Kind == SettingKind.Folder, + _ => false + }; } } diff --git a/VisualPinball.Plugins/Configuration/PluginSchema.cs b/VisualPinball.Plugins/Configuration/PluginSchema.cs index 752d73604..85d6f410d 100644 --- a/VisualPinball.Plugins/Configuration/PluginSchema.cs +++ b/VisualPinball.Plugins/Configuration/PluginSchema.cs @@ -133,6 +133,11 @@ public sealed class PluginField /// PUPVideos library folder. /// public bool FullWidth { get; set; } + /// Optional previous setting in guided launch setup. Enables changing an earlier choice when setup resumes at this field. + public string SetupPrevious { get; set; } = ""; + /// For an Action field, runs it without another confirmation when a launch check requires it during guided setup. + /// Defaults to false. Normal settings pages still require pressing the action button. A failed action waits for an explicit retry. + public bool SetupAutoRun { get; set; } /// Consecutive fields with the same non-empty hint share a row. The Player owns their sizing and appearance. /// The B2S plugin puts its fade-in and fade-out times on one row with Row = "fade". public string Row { get; set; } = ""; @@ -627,6 +632,8 @@ public sealed class PluginChoiceRequest public GameIdentity? Game { get; } /// The selected table package, when the request concerns one; null for global settings. public PluginPackageReference? Package { get; set; } + /// Optional progress sink for a user-requested action. Report from any thread; the caller owns dispatch. + public IProgress? Progress { get; set; } /// /// The effective settings to work with, including unsaved edits from the settings page. Use these, not the saved values, so that /// catalogs follow what the user just changed (for example PinUP lists the packs of the library folder in the draft). @@ -663,6 +670,16 @@ public sealed class PluginChoiceCatalog public string Revision { get; } /// The items in display order, including unavailable ones. public IReadOnlyList Choices { get; } + /// Explains how to get choices when none are available; shown instead of an empty selector. + public string EmptyMessage { get; set; } = ""; + /// Optional guidance when selectable items exist, replacing empty-source instructions during launch setup. + public string AvailableMessage { get; set; } = ""; + /// Explains a saved choice absent from an incomplete catalog, if the default offline-source message does not apply. + public string MissingSelectionMessage { get; set; } = ""; + /// Optional HTTPS page where the user can obtain the items this catalog requires. + public string SourceUrl { get; set; } = ""; + /// Label for the optional source-page button. + public string SourceLabel { get; set; } = "Open download page"; /// False when a runtime-only catalog cannot be inspected while its game is stopped. /// /// A saved selection missing from an incomplete catalog is kept and not reported as an error. @@ -679,12 +696,20 @@ public sealed class PluginChoiceCatalog public PluginChoice? Find(string id) => Choices.FirstOrDefault(choice => choice.Id == id); } + /// Progress of a settings action, with a fraction from zero to one and a user-facing message. + public readonly struct PluginActionProgress + { + public double Fraction { get; } + public string Message { get; } + public PluginActionProgress(double fraction, string message) { Fraction = fraction; Message = message; } + } + /// The outcome of a settings action, a reconfiguration or a settings commit. /// /// Plugins return it from , and /// . The host and the settings session return it when they save settings. /// - public readonly struct PluginActionResult + public readonly struct PluginActionResult { /// Whether the operation succeeded. A failed reconfiguration must leave the plugin as it was. public bool Success { get; } diff --git a/VisualPinball.Plugins/Configuration/PluginSettingsSession.cs b/VisualPinball.Plugins/Configuration/PluginSettingsSession.cs index 03b656bab..ded6c728f 100644 --- a/VisualPinball.Plugins/Configuration/PluginSettingsSession.cs +++ b/VisualPinball.Plugins/Configuration/PluginSettingsSession.cs @@ -177,10 +177,14 @@ public Task DiscoverAsync(string provider, CancellationToke /// /// was cancelled; the draft is then left unchanged. /// - public async Task RunActionAsync(string action, CancellationToken cancellation) + public Task RunActionAsync(string action, CancellationToken cancellation) + => RunActionAsync(action, cancellation, null); + /// Runs a settings action with optional progress reporting, without saving draft settings. + public async Task RunActionAsync(string action, CancellationToken cancellation, IProgress? progress) { if (_factory.SettingsProvider == null) return new PluginActionResult(false, "This action is unavailable."); - var result = await _factory.SettingsProvider.RunActionAsync(action, Request(Effective), cancellation); + var request = Request(Effective); request.Progress = progress; + var result = await _factory.SettingsProvider.RunActionAsync(action, request, cancellation); cancellation.ThrowIfCancellationRequested(); if (result.Success && result.Changes != null) foreach (var field in _factory.Schema.Fields) { if (Game.HasValue ? field.Scope == ConfigurationScope.GlobalOnly : field.Scope == ConfigurationScope.GameOnly) continue; @@ -284,7 +288,7 @@ private void Rebase(PluginSettingsSnapshot frozen, HashSet frozenResets) if (reset) _resetFields.Add(id); } } - private static IEnumerable<(PluginField Field, JToken? Value, string Path)> ChoiceBindings(IEnumerable fields, PluginSettingsSnapshot snapshot, string prefix = "") + internal static IEnumerable<(PluginField Field, JToken? Value, string Path)> ChoiceBindings(IEnumerable fields, PluginSettingsSnapshot snapshot, string prefix = "") { foreach (var field in fields) { if (!field.Visible(snapshot)) continue; diff --git a/VisualPinball.Plugins/README.md b/VisualPinball.Plugins/README.md index 1846f8d12..15d845bd2 100644 --- a/VisualPinball.Plugins/README.md +++ b/VisualPinball.Plugins/README.md @@ -6,6 +6,10 @@ An optional integration package registers an `IPlayerPluginFactory`. Its descrip ## Lifecycle +Launch issues identify the missing field and carry the host-verified game identity. The Player can guide the user through those fields without importing the scene or rereading package identity. `PluginField.SetupPrevious` declares an earlier step the user can revisit, including when setup starts at an action. `PluginHost.CommitSettingAsync` saves one scoped field while preserving unrelated existing validation issues and checking fresh choice availability. `PluginChoiceCatalog.EmptyMessage`, `MissingSelectionMessage`, `SourceUrl` and `SourceLabel` explain unavailable downloads; only HTTPS source links are opened by the shared Player UI. Actions can report `PluginActionProgress` through `PluginChoiceRequest.Progress` and the progress overload of `PluginSettingsSession.RunActionAsync`. + +Factories may implement `IPluginLaunchCheck` to declare mandatory, game-specific prerequisites. `AppliesTo` selects the games that require the integration. `PluginHost.CheckLaunchAsync` validates committed settings and calls the asynchronous, read-only check before the Player imports the scene; it returns plugin descriptors and field-specific issues for the settings UI. Checks do not construct plugins or publish game resources. Disabled factories are skipped; unsupported required plugins, check errors and timeouts block launch. `PrepareAndStartAsync` checks again and requires applicable launch-check plugins to reach Running before table activation. A runtime hook may throw `PluginLaunchBlockedException` for a requirement that changed after preflight; all started plugins are cleaned up and the exception reaches the Player. Other plugin failures keep their existing isolation. + The host resolves dependencies before numeric order, checks every phase for cycles, and blocks affected plugins while independent integrations continue. Hooks start on the owner thread. An asynchronous hook returning from worker code must switch through `PluginApplicationContext.Scheduler` before accessing Unity. Prepare and Start run while the restored table is inactive, before its gamelogic can emit its first event. Ready follows `Player.OnPlayerStarted`; pause, resume, focus, tick and render use their own phase order. Instances live for one game. The application context scope covers initialization through shutdown; the game scope covers preparation through stop. Own subscriptions, publications and handles in these scopes. Cleanup unwinds actual startup order in reverse, uses fresh cancellation deadlines, and continues after failures. An unresponsive asynchronous hook cannot hold the host forever. Its plugin ID remains quarantined until the abandoned hook, deferred native work and shutdown finish, preventing overlapping instances. Healthy plugins keep ticking and rendering while an unrelated integration is being reconfigured or cleaned up. `IPluginEmergencyStop` must close event admission immediately and arrange safe deferred native disposal when a worker is still active. Native devices must retain exclusive output leases until their worker has stopped. Implement `IPluginDeferredCleanup.Quiescence` to report that lifetime to the host. @@ -26,7 +30,9 @@ The Player owns every configuration control, including standard fields, typed re Ordinary fields place their label and editor on one line. `PluginField.FullWidth` requests a label above a wide editor, for example a PAC key or library path. Consecutive fields with the same non-empty `Row` hint share a line, such as left/top or width/height. These are presentation hints; they neither change saved values nor require a schema migration. The Player owns spacing, styling and the concrete controls. -An optional `PluginField.GlobalSetting` exposes the same canonical field on a shared Player page. This is independent of its storage scope: `GlobalOnly` alone does not expose a field. The first section, `PluginGlobalSections.VolumeMixer` (`audio.mixer`), accepts bounded numeric fields with a global scope. The Player discovers installed, supported integrations, orders their entries by the exposure's `Order`, and supplies the controls. `Presentation = PluginFieldPresentation.Slider` and `Unit = "%"` request the same numeric slider in the ordinary plugin form. A `GameOnly` field cannot be globally exposed. Unknown sections are ignored; invalid exposures and unreadable profiles are reported without removing other integrations from the mixer. +An optional `PluginField.GlobalSetting` exposes the same canonical field on a shared Player page, independently of its storage scope. The `PluginGlobalSections` enum identifies these pages: `VolumeMixer` accepts bounded numeric gain controls; `Paths` accepts file and folder controls. A plugin may declare several mixer channels with distinct stable field IDs. Explicit exposure labels override the defaults; multiple fields in one section otherwise include the field label so channels remain distinguishable. The Player discovers supported installed plugins without constructing their runtimes, owns all controls, and writes to the plugin's existing configuration. `GameOnly` fields cannot be exposed globally, and editing a `GlobalAndGame` default preserves per-game overrides. Fields exposed on unsupported enum values remain available on the plugin page and are omitted from unsupported shared pages. + +`PluginGlobalSettingBinding.Discover` accepts a section enum value and validates its compatible field kinds. Additional shared pages can be added as enum members without changing plugin persistence or lifecycle; older hosts ignore values they do not support. Shared-page edits run provider validation on the owner thread and use the same commit path, including live-update rollback. An existing unrelated validation issue does not prevent editing another shared field; errors on the edited field and newly introduced cross-field issues still block the change. File/folder controls save on confirmation; mixer drags are coalesced. `ConfigurationChange` remains independent of where a field appears. The mixer commits individual field patches through the host. Ordinary plugin drafts also save only fields edited since opening, merged into the latest profile before validation, so an open form cannot overwrite a newer mixer value accidentally. Reset explicitly removes the scoped values. `SettingsCommitted` lets a view refresh unedited fields while preserving its draft. The application owns and coalesces mixer writes; leaving a page flushes them, and game preparation waits for that flush. There is no second copy of plugin volume in Player preferences. diff --git a/VisualPinball.Plugins/Runtime/PluginHost.Launch.cs b/VisualPinball.Plugins/Runtime/PluginHost.Launch.cs new file mode 100644 index 000000000..c8a137731 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginHost.Launch.cs @@ -0,0 +1,105 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace VisualPinball.Plugins +{ + public sealed partial class PluginHost + { + private readonly Dictionary _pendingLaunchChecks = new Dictionary(); + + /// Checks required plugins without loading the table, starting plugins, or stopping a running game. + /// Disabled plugins are skipped. Errors and timeouts block launch; cancellation propagates to the caller. + public async Task> CheckLaunchAsync(GameIdentity game, string packagePath, string title, CancellationToken cancellation) + { + await Scheduler.Switch(); + cancellation.ThrowIfCancellationRequested(); + var candidates = LaunchCheckCandidates(game); + if (candidates.Count == 0) return Array.Empty(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + deadline.CancelAfter(StartupTimeout); + try { await _operation.WaitAsync(deadline.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (!cancellation.IsCancellationRequested) { + await Scheduler.Switch(); + cancellation.ThrowIfCancellationRequested(); + return candidates.Select(factory => new PluginLaunchBlocker(factory.Descriptor, new[] { + new PluginValidationIssue("", "Plugin settings are still being saved or a previous game is stopping. Try launching again.") + }, game)).ToArray(); + } + await Scheduler.Switch(); + try { return await CheckLaunchCoreAsync(game, packagePath, title, cancellation).ConfigureAwait(false); } + finally { await Scheduler.Switch(); _operation.Release(); ReplayState(); } + } + + private IReadOnlyList LaunchCheckCandidates(GameIdentity game) + { + var candidates = new List(); + foreach (var factory in _factories.Values) { + if (factory is not IPluginLaunchCheck check) continue; + try { if (!Configuration.Enabled(factory.Descriptor, game) || !check.AppliesTo(game)) continue; } + catch { /* A broken predicate or settings read must become a blocker in the guarded check. */ } + candidates.Add(factory); + } + return candidates; + } + + private async Task> CheckLaunchCoreAsync(GameIdentity game, string packagePath, string title, CancellationToken cancellation) + { + var blockers = new List(); + foreach (var factory in _factories.Values) { + await Scheduler.Switch(); + cancellation.ThrowIfCancellationRequested(); + if (factory is not IPluginLaunchCheck check) continue; + var descriptor = factory.Descriptor; + var issues = new List(); + try { + if (!Configuration.Enabled(descriptor, game) || !check.AppliesTo(game)) continue; + if (_abandoned.ContainsKey(descriptor.Id)) throw new InvalidOperationException("The previous plugin instance is still stopping. Wait or restart the Player."); + if (descriptor.Platforms.Count > 0 && !descriptor.Platforms.Contains(Platform)) throw new InvalidOperationException("This game's required plugin is unavailable on " + Platform + "."); + if (_pendingLaunchChecks.TryGetValue(descriptor.Id, out var pending) && !pending.IsCompleted) throw new InvalidOperationException("The previous launch check is still finishing. Try again shortly."); + if (Configuration.Read(descriptor, factory.Schema).SchemaVersion > factory.Schema.Version || Configuration.Read(descriptor, factory.Schema, game).SchemaVersion > factory.Schema.Version) throw new PluginConfigurationException("Configuration requires a newer plugin version."); + var settings = Configuration.Effective(descriptor, factory.Schema, game); + var package = new PluginPackageReference(packagePath, title); + issues.AddRange(factory.Schema.Validate(settings)); + issues.AddRange(factory.SettingsProvider?.Validate(new PluginChoiceRequest(game, settings, Platform) { Package = package }) ?? Array.Empty()); + if (issues.Count == 0) { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + deadline.CancelAfter(StartupTimeout); + var hook = check.CheckLaunchAsync(new PluginLaunchContext(game, package, settings, Platform), deadline.Token); + _pendingLaunchChecks[descriptor.Id] = hook; + if (await Task.WhenAny(hook, Task.Delay(Timeout.Infinite, deadline.Token)).ConfigureAwait(false) != hook) { + _ = hook.ContinueWith(done => { var ignored = done.Exception; }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + cancellation.ThrowIfCancellationRequested(); + throw new TimeoutException("The launch check did not finish in time. Try again after it stops."); + } + issues.AddRange(await hook.ConfigureAwait(false)); + } + } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { throw; } + catch (OperationCanceledException) { issues.Add(new PluginValidationIssue("", "The launch check did not finish in time. Try again after it stops.")); } + catch (Exception error) { issues.Add(new PluginValidationIssue("", error.Message)); } + await Scheduler.Switch(); + cancellation.ThrowIfCancellationRequested(); + if (issues.Count > 0) blockers.Add(new PluginLaunchBlocker(descriptor, issues, game)); + } + return blockers; + } + + private void RequireLaunchPluginsStarted(GameIdentity game) + { + var blockers = new List(); + foreach (var factory in _factories.Values) { + if (factory is IPluginLaunchCheck check && Configuration.Enabled(factory.Descriptor, game) && check.AppliesTo(game) + && _statuses[factory.Descriptor.Id].State != PluginState.Running) + blockers.Add(new PluginLaunchBlocker(factory.Descriptor, new[] { new PluginValidationIssue("", _statuses[factory.Descriptor.Id].Message) }, game)); + } + if (blockers.Count > 0) throw new PluginLaunchBlockedException(blockers); + } + } +} diff --git a/VisualPinball.Plugins/Runtime/PluginHost.Launch.cs.meta b/VisualPinball.Plugins/Runtime/PluginHost.Launch.cs.meta new file mode 100644 index 000000000..8c256ebf9 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginHost.Launch.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8cdcf3e6b56143aa8da293ccdf799321 diff --git a/VisualPinball.Plugins/Runtime/PluginHost.Settings.cs b/VisualPinball.Plugins/Runtime/PluginHost.Settings.cs index 3ab870c82..03a303932 100644 --- a/VisualPinball.Plugins/Runtime/PluginHost.Settings.cs +++ b/VisualPinball.Plugins/Runtime/PluginHost.Settings.cs @@ -70,7 +70,18 @@ public Task CommitGlobalSettingAsync(IPlayerPluginFactory fa var patch = new PluginSettingsSnapshot(factory.Schema.Version); patch.Set(fieldId, value); return CommitSettingsCoreAsync(factory, patch, null, cancellation, new[] { fieldId }, field); } - private async Task CommitSettingsCoreAsync(IPlayerPluginFactory factory, PluginSettingsSnapshot draft, GameIdentity? game, CancellationToken cancellation, IReadOnlyCollection? edited, PluginField? single = null) + /// Saves one setting during guided setup. Validates that field, its choices and new cross-field issues; + /// existing unrelated issues may remain until later steps. Scope and live-setting rules are unchanged. + public Task CommitSettingAsync(IPlayerPluginFactory factory, string fieldId, JToken? value, + GameIdentity? game = null, PluginPackageReference? package = null, CancellationToken cancellation = default) + { + var field = factory.Schema.Fields.FirstOrDefault(item => item.Id == fieldId) ?? throw new ArgumentException("Unknown setting."); + if (game.HasValue ? field.Scope == ConfigurationScope.GlobalOnly : field.Scope == ConfigurationScope.GameOnly) throw new ArgumentException("This setting cannot be edited in this scope."); + if (field.Kind == SettingKind.Action || field.Kind == SettingKind.Status || field.Kind == SettingKind.Preview) throw new ArgumentException("This field does not store a setting."); + var patch = new PluginSettingsSnapshot(factory.Schema.Version); patch.Set(fieldId, value); + return CommitSettingsCoreAsync(factory, patch, game, cancellation, new[] { fieldId }, field, package); + } + private async Task CommitSettingsCoreAsync(IPlayerPluginFactory factory, PluginSettingsSnapshot draft, GameIdentity? game, CancellationToken cancellation, IReadOnlyCollection? edited, PluginField? single = null, PluginPackageReference? package = null) { if (!_factories.TryGetValue(factory.Descriptor.Id, out var installed) || installed != factory) throw new ArgumentException("The factory is not installed in this host."); var patch = draft.Copy(); var fields = (edited ?? factory.Schema.Fields.Select(field => field.Id).ToArray()).ToArray(); @@ -85,18 +96,42 @@ private async Task CommitSettingsCoreAsync(IPlayerPluginFact entry = _entered.FirstOrDefault(candidate => candidate.Factory == factory && IsHealthy(candidate) && Available(candidate)); GameIdentity? active = _activeGame; var prepared = await Task.Run(() => { + var before = single == null ? null : Configuration.Effective(factory.Descriptor, factory.Schema, game); var latest = Configuration.MergePatch(factory.Descriptor, factory.Schema, patch, fields, game); var global = game.HasValue ? Configuration.Read(factory.Descriptor, factory.Schema) : latest; var effective = game.HasValue ? PluginConfigurationStore.Resolve(factory.Schema, global, latest) : PluginConfigurationStore.Resolve(factory.Schema, global); var runtime = active.HasValue && (!game.HasValue || game.Value.Equals(active.Value)) ? PluginConfigurationStore.Resolve(factory.Schema, global, game.HasValue ? latest : Configuration.Read(factory.Descriptor, factory.Schema, active)) : effective; - return (Effective: effective, Runtime: runtime); + return (Effective: effective, Runtime: runtime, Before: before); }, cancellation).ConfigureAwait(false); await Scheduler.Switch(); cancellation.ThrowIfCancellationRequested(); var validation = single == null ? factory.Schema : new PluginSettingsSchema(factory.Schema.Version, new PluginSettingsGroup("", "", single)); var issues = validation.Validate(prepared.Effective); if (issues.Count > 0) return new PluginActionResult(false, string.Join("\n", issues.Select(issue => issue.Field + ": " + issue.Message))); + // Providers validate on the owner thread. Existing unrelated errors must not disable other shared controls. + if (single != null && factory.SettingsProvider != null) { + var before = factory.SettingsProvider.Validate(new PluginChoiceRequest(game, prepared.Before!, Platform) { Package = package }).ToArray(); + var providerIssues = factory.SettingsProvider.Validate(new PluginChoiceRequest(game, prepared.Effective, Platform) { Package = package }) + .Where(issue => string.IsNullOrEmpty(issue.Field) || issue.Field == single.Id || issue.Field.StartsWith(single.Id + ".", StringComparison.Ordinal) + || issue.Field.StartsWith(single.Id + "[", StringComparison.Ordinal) + || !before.Any(previousIssue => previousIssue.Field == issue.Field && previousIssue.Message == issue.Message)).ToArray(); + cancellation.ThrowIfCancellationRequested(); + if (providerIssues.Length > 0) return new PluginActionResult(false, string.Join("\n", providerIssues.Select(issue => issue.Field + ": " + issue.Message))); + } if (single != null && !single.Visible(prepared.Effective)) return new PluginActionResult(false, "This setting is currently unavailable."); + if (single != null && factory.SettingsProvider != null) { + var request = new PluginChoiceRequest(game, prepared.Effective, Platform, Resources.Snapshot()) { Package = package }; + foreach (var binding in PluginSettingsSession.ChoiceBindings(new[] { single }, prepared.Effective)) { + var selections = binding.Field.Kind == SettingKind.ScreenLayout ? (binding.Value as JArray)?.OfType().Select(item => item.Value("screen") ?? "").ToArray() ?? Array.Empty() + : binding.Field.Multiple ? (binding.Value as JArray)?.Select(item => item.ToString()).ToArray() ?? Array.Empty() : new[] { binding.Value?.ToString() ?? "" }; + var catalog = await factory.SettingsProvider.DiscoverAsync(binding.Field.Provider, request, cancellation).ConfigureAwait(false); + await Scheduler.Switch(); cancellation.ThrowIfCancellationRequested(); + foreach (var selected in selections.Where(value => value.Length > 0)) { + var choice = catalog.Find(selected); + if (choice != null && !choice.Available || choice == null && catalog.IsComplete) return new PluginActionResult(false, choice?.UnavailableReason ?? "The selected item is no longer available. Refresh the list and choose again."); + } + } + } previous = entry?.Settings; PluginSettingsSnapshot? live = null; if (entry != null && previous != null && active.HasValue && (!game.HasValue || game.Value.Equals(active.Value))) { diff --git a/VisualPinball.Plugins/Runtime/PluginHost.cs b/VisualPinball.Plugins/Runtime/PluginHost.cs index bdeeb03ee..ca4328d6a 100644 --- a/VisualPinball.Plugins/Runtime/PluginHost.cs +++ b/VisualPinball.Plugins/Runtime/PluginHost.cs @@ -102,6 +102,7 @@ internal void Track(Task hook) private readonly Func _pluginLog; private readonly Action _error; private CancellationTokenSource? _gameCancellation; + private int _stopVersion; private bool _running; private bool _ready; private bool? _paused; @@ -254,6 +255,8 @@ public PluginHost(IEnumerable factories, PluginConfigurati /// /// Stops the current game's plugins, if any, then selects, creates and starts the plugins for a new game. Plugin failures do /// not throw: they are reported in , and the game runs with the remaining plugins. + /// Factories implementing opt into mandatory launch requirements: unmet requirements + /// or failure to start an applicable, enabled plugin throw and stop all plugins. /// /// /// The host first decides which plugins run. Plugins that are disabled for the game, unsupported on , @@ -292,7 +295,8 @@ public PluginHost(IEnumerable factories, PluginConfigurati /// /// /// A task that completes when startup has finished. It also completes, without starting anything, when another operation - /// still holds the host after ; every plugin is then . + /// still holds the host after ; every plugin is then . + /// A stop request during that wait cancels startup instead. /// /// /// was cancelled, or the game was stopped by or @@ -300,22 +304,31 @@ public PluginHost(IEnumerable factories, PluginConfigurati /// public async Task PrepareAndStartAsync(GameIdentity identity, string packagePath, string title, string controllerId, long session, CancellationToken cancellation) { + int stopVersion = Volatile.Read(ref _stopVersion); using var gateDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation); gateDeadline.CancelAfter(StartupTimeout); try { await _operation.WaitAsync(gateDeadline.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellation.IsCancellationRequested) { await Scheduler.Switch(); + if (stopVersion != Volatile.Read(ref _stopVersion)) throw new OperationCanceledException("Game startup was stopped."); foreach (var factory in _factories.Values) SetStatus(factory.Descriptor.Id, PluginState.Blocked, "Plugin host is still stopping a previous operation."); + RequireLaunchPluginsStarted(identity); return; } await Scheduler.Switch(); try { await StopCoreAsync(CancellationToken.None).ConfigureAwait(false); await Scheduler.Switch(); - _gameCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + _gameCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + // A stop may have arrived while waiting for the gate or cleaning up the previous game. + if (stopVersion != Volatile.Read(ref _stopVersion)) _gameCancellation.Cancel(); + var gameToken = _gameCancellation.Token; + var blockers = await CheckLaunchCoreAsync(identity, packagePath, title, gameToken).ConfigureAwait(false); + await Scheduler.Switch(); + gameToken.ThrowIfCancellationRequested(); + if (blockers.Count > 0) throw new PluginLaunchBlockedException(blockers); _activeGame = identity; _hostCapabilities = Capabilities.Ids.ToArray(); - var gameToken = _gameCancellation.Token; var selected = Select(identity); foreach (var descriptor in PluginOrderResolver.Resolve(selected, PluginPhase.Initialize, _hostCapabilities)) { gameToken.ThrowIfCancellationRequested(); @@ -333,6 +346,10 @@ public async Task PrepareAndStartAsync(GameIdentity identity, string packagePath await InvokeAsync(entry, PluginPhase.Initialize, token => plugin.InitializeAsync(token), gameToken).ConfigureAwait(false); await Scheduler.Switch(); } catch (OperationCanceledException) when (gameToken.IsCancellationRequested) { throw; } + catch (PluginLaunchBlockedException) { + if (!_entered.Any(entry => entry.ApplicationScope == application)) SafeDispose(application); + throw; + } catch (Exception error) { await Scheduler.Switch(); if (!(error is HookDeadlineException)) Fault(descriptor.Id, PluginPhase.Initialize, error); @@ -358,6 +375,7 @@ public async Task PrepareAndStartAsync(GameIdentity identity, string packagePath await Scheduler.Switch(); if (phase == PluginPhase.Start) SetStatus(descriptor.Id, PluginState.Running, "Running", phase); } catch (OperationCanceledException) when (gameToken.IsCancellationRequested) { throw; } + catch (PluginLaunchBlockedException) { throw; } catch (Exception error) { await Scheduler.Switch(); if (!(error is HookDeadlineException)) Fault(descriptor.Id, phase, error); @@ -366,12 +384,15 @@ public async Task PrepareAndStartAsync(GameIdentity identity, string packagePath } } } - _running = true; + RequireLaunchPluginsStarted(identity); + gameToken.ThrowIfCancellationRequested(); + _running = true; RebuildDispatch(); - } catch { + } catch (Exception error) { await Scheduler.Switch(); await StopCoreAsync(CancellationToken.None).ConfigureAwait(false); await Scheduler.Switch(); + if (error is PluginLaunchBlockedException blocked) throw blocked.ForGame(identity); throw; } finally { _operation.Release(); ReplayState(); } } @@ -533,6 +554,7 @@ public PluginConfigurationException(string message) : base(message) { } /// A task that completes when every plugin of the game is stopped or quarantined. public async Task StopGameAsync(CancellationToken cleanup = default) { + Interlocked.Increment(ref _stopVersion); _gameCancellation?.Cancel(); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cleanup); deadline.CancelAfter(CleanupTimeout); @@ -735,6 +757,7 @@ private void ReplayState() public void EmergencyStop() { Scheduler.VerifyOwner(); + Interlocked.Increment(ref _stopVersion); _running = false; _gameCancellation?.Cancel(); _dispatch.Clear(); foreach (var entry in _entered.AsEnumerable().Reverse()) { Emergency(entry);