From 987d72e6e069c78d9821c87d03ca8d337c3b0ba3 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:51:43 +0200 Subject: [PATCH 01/11] Split runtime target resolution from feedback ports Addresses #117. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/RuntimeEnvironment.cs | 37 +++++++++++++++++++--------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/SightAdapt/RuntimeEnvironment.cs b/src/SightAdapt/RuntimeEnvironment.cs index ace2d75e..2eecab3d 100644 --- a/src/SightAdapt/RuntimeEnvironment.cs +++ b/src/SightAdapt/RuntimeEnvironment.cs @@ -1,32 +1,32 @@ namespace SightAdapt; -internal interface IRuntimeEnvironment +internal interface IRuntimeTargetResolver { nint ResolveTargetWindow(); bool IsSupportedTarget(nint targetWindow); ApplicationIdentity? ResolveIdentity(nint targetWindow); +} +internal interface IRuntimeFeedback +{ void ShowNotification(string message); void SynchronizeAutomaticMode(bool enabled); } -internal sealed class DelegateRuntimeEnvironment : IRuntimeEnvironment +internal sealed class DelegateRuntimeTargetResolver : + IRuntimeTargetResolver { private readonly Func _resolveTargetWindow; private readonly Func _isSupportedTarget; private readonly Func _resolveIdentity; - private readonly Action _showNotification; - private readonly Action _synchronizeAutomaticMode; - public DelegateRuntimeEnvironment( + public DelegateRuntimeTargetResolver( Func resolveTargetWindow, Func isSupportedTarget, - Func resolveIdentity, - Action showNotification, - Action synchronizeAutomaticMode) + Func resolveIdentity) { _resolveTargetWindow = resolveTargetWindow ?? throw new ArgumentNullException(nameof(resolveTargetWindow)); @@ -34,10 +34,6 @@ public DelegateRuntimeEnvironment( throw new ArgumentNullException(nameof(isSupportedTarget)); _resolveIdentity = resolveIdentity ?? throw new ArgumentNullException(nameof(resolveIdentity)); - _showNotification = showNotification ?? - throw new ArgumentNullException(nameof(showNotification)); - _synchronizeAutomaticMode = synchronizeAutomaticMode ?? - throw new ArgumentNullException(nameof(synchronizeAutomaticMode)); } public nint ResolveTargetWindow() => @@ -48,6 +44,23 @@ public bool IsSupportedTarget(nint targetWindow) => public ApplicationIdentity? ResolveIdentity(nint targetWindow) => _resolveIdentity(targetWindow); +} + +internal sealed class DelegateRuntimeFeedback : + IRuntimeFeedback +{ + private readonly Action _showNotification; + private readonly Action _synchronizeAutomaticMode; + + public DelegateRuntimeFeedback( + Action showNotification, + Action synchronizeAutomaticMode) + { + _showNotification = showNotification ?? + throw new ArgumentNullException(nameof(showNotification)); + _synchronizeAutomaticMode = synchronizeAutomaticMode ?? + throw new ArgumentNullException(nameof(synchronizeAutomaticMode)); + } public void ShowNotification(string message) => _showNotification(message); From 415a626bbc008ad87cc882647a199af05469aadb Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:52:06 +0200 Subject: [PATCH 02/11] Use named runtime target and feedback collaborators Addresses #117. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/RuntimeCoordinator.cs | 65 ++++++++++------------------ 1 file changed, 23 insertions(+), 42 deletions(-) diff --git a/src/SightAdapt/RuntimeCoordinator.cs b/src/SightAdapt/RuntimeCoordinator.cs index 4d4c25ba..9b047414 100644 --- a/src/SightAdapt/RuntimeCoordinator.cs +++ b/src/SightAdapt/RuntimeCoordinator.cs @@ -9,67 +9,48 @@ internal sealed class RuntimeCoordinator { private readonly SettingsCoordinator _settingsCoordinator; private readonly ApplicationStateController _stateController; - private readonly IRuntimeEnvironment _environment; + private readonly IRuntimeTargetResolver _targetResolver; + private readonly IRuntimeFeedback _feedback; private readonly RuntimeOverlayActivator _overlayActivator; private readonly AutomaticActivationService _automaticActivation; private readonly Func _readSettings; private bool _committingSettings; - public RuntimeCoordinator( - SettingsCoordinator settingsCoordinator, - ApplicationStateController stateController, - IRuntimeOverlay overlay, - Func resolveTargetWindow, - Func isSupportedTarget, - Func resolveIdentity, - Action showNotification, - Action synchronizeAutomaticMode) - : this( - settingsCoordinator, - stateController, - overlay, - new DelegateRuntimeEnvironment( - resolveTargetWindow, - isSupportedTarget, - resolveIdentity, - showNotification, - synchronizeAutomaticMode), - readSettings: null) - { - } - internal RuntimeCoordinator( SettingsCoordinator settingsCoordinator, ApplicationStateController stateController, IRuntimeOverlay overlay, - IRuntimeEnvironment environment, - Func? readSettings) + IRuntimeTargetResolver targetResolver, + IRuntimeFeedback feedback, + Func? readSettings = null) { _settingsCoordinator = settingsCoordinator ?? throw new ArgumentNullException(nameof(settingsCoordinator)); _stateController = stateController ?? throw new ArgumentNullException(nameof(stateController)); - _environment = environment ?? - throw new ArgumentNullException(nameof(environment)); + _targetResolver = targetResolver ?? + throw new ArgumentNullException(nameof(targetResolver)); + _feedback = feedback ?? + throw new ArgumentNullException(nameof(feedback)); _readSettings = readSettings ?? (() => _settingsCoordinator.Current); _overlayActivator = new RuntimeOverlayActivator( stateController, overlay, - environment); + feedback); _automaticActivation = new AutomaticActivationService( stateController, _overlayActivator, - environment); + targetResolver); } public void ToggleForActiveWindow() { var targetWindow = - _environment.ResolveTargetWindow(); + _targetResolver.ResolveTargetWindow(); if (targetWindow == nint.Zero) { - _environment.ShowNotification( + _feedback.ShowNotification( RuntimeMessages.NoSupportedWindow); return; } @@ -93,7 +74,7 @@ public void ToggleForActiveWindow() } var identity = - _environment.ResolveIdentity(targetWindow); + _targetResolver.ResolveIdentity(targetWindow); var assignment = identity is null ? null : ProfileResolver.FindAssignment( @@ -111,13 +92,13 @@ public void ToggleForActiveWindow() public void ToggleActiveApplicationAssignment() { var targetWindow = - _environment.ResolveTargetWindow(); + _targetResolver.ResolveTargetWindow(); var identity = targetWindow == nint.Zero ? null - : _environment.ResolveIdentity(targetWindow); + : _targetResolver.ResolveIdentity(targetWindow); if (identity is null) { - _environment.ShowNotification( + _feedback.ShowNotification( RuntimeMessages.IdentityUnavailable); return; } @@ -157,7 +138,7 @@ public void ToggleActiveApplicationAssignment() _automaticActivation.HandleSettingsChanged(settings); } - _environment.ShowNotification( + _feedback.ShowNotification( RuntimeMessages.AssignmentToggled(result)); } @@ -171,7 +152,7 @@ public void SetAutomaticMode(bool enabled) var settings = ReadSettings(); if (!commit.Succeeded) { - _environment.SynchronizeAutomaticMode( + _feedback.SynchronizeAutomaticMode( settings.AutomaticMode); ShowCommitError(commit.ErrorMessage); return; @@ -238,15 +219,15 @@ public void EmergencyDisable() if (commit.Succeeded) { - _environment.ShowNotification( + _feedback.ShowNotification( "All overlays were disabled. Automatic mode is off."); return; } var settings = ReadSettings(); - _environment.SynchronizeAutomaticMode( + _feedback.SynchronizeAutomaticMode( settings.AutomaticMode); - _environment.ShowNotification( + _feedback.ShowNotification( "All overlays were disabled for this session, but " + (commit.ErrorMessage ?? "automatic mode could not be saved.")); @@ -280,7 +261,7 @@ private SettingsCommitResult CommitSettings( private void ShowCommitError(string? message) { - _environment.ShowNotification( + _feedback.ShowNotification( string.IsNullOrWhiteSpace(message) ? RuntimeMessages.SettingsChangeFailed : message); From 37710b157d9d66715465fb3ac4dc884e7e2f840b Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:52:18 +0200 Subject: [PATCH 03/11] Limit automatic activation to runtime target resolution Addresses #117. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/AutomaticActivationService.cs | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/SightAdapt/AutomaticActivationService.cs b/src/SightAdapt/AutomaticActivationService.cs index 22078971..fae24616 100644 --- a/src/SightAdapt/AutomaticActivationService.cs +++ b/src/SightAdapt/AutomaticActivationService.cs @@ -4,19 +4,19 @@ internal sealed class AutomaticActivationService { private readonly ApplicationStateController _stateController; private readonly RuntimeOverlayActivator _overlayActivator; - private readonly IRuntimeEnvironment _environment; + private readonly IRuntimeTargetResolver _targetResolver; public AutomaticActivationService( ApplicationStateController stateController, RuntimeOverlayActivator overlayActivator, - IRuntimeEnvironment environment) + IRuntimeTargetResolver targetResolver) { _stateController = stateController ?? throw new ArgumentNullException(nameof(stateController)); _overlayActivator = overlayActivator ?? throw new ArgumentNullException(nameof(overlayActivator)); - _environment = environment ?? - throw new ArgumentNullException(nameof(environment)); + _targetResolver = targetResolver ?? + throw new ArgumentNullException(nameof(targetResolver)); } public void Evaluate( @@ -29,7 +29,7 @@ public void Evaluate( if (!settings.AutomaticMode || !_stateController.AllowsAutomaticActivation || currentState == ApplicationRunState.ManualActive || - !_environment.IsSupportedTarget(targetWindow)) + !_targetResolver.IsSupportedTarget(targetWindow)) { return; } @@ -47,7 +47,7 @@ public void Evaluate( } var identity = - _environment.ResolveIdentity(targetWindow); + _targetResolver.ResolveIdentity(targetWindow); if (identity is null) { DisableAutomaticOverlayIfActive(currentState); @@ -79,7 +79,7 @@ public bool IsConfigured( ArgumentNullException.ThrowIfNull(settings); var identity = - _environment.ResolveIdentity(targetWindow); + _targetResolver.ResolveIdentity(targetWindow); return identity is not null && ProfileResolver.FindEnabledAssignment( settings, @@ -101,7 +101,7 @@ ApplicationRunState.Emergency or _stateController.ClearAutomaticSuppression(); var targetWindow = - _environment.ResolveTargetWindow(); + _targetResolver.ResolveTargetWindow(); if (targetWindow != nint.Zero) { Evaluate(settings, targetWindow); @@ -138,7 +138,7 @@ public void HandleSettingsChanged( } var targetWindow = - _environment.ResolveTargetWindow(); + _targetResolver.ResolveTargetWindow(); if (targetWindow != nint.Zero) { Evaluate(settings, targetWindow); @@ -151,14 +151,14 @@ private void RefreshManualOverlay( var targetWindow = _stateController.Current.TargetWindow; if (targetWindow == nint.Zero || - !_environment.IsSupportedTarget(targetWindow)) + !_targetResolver.IsSupportedTarget(targetWindow)) { _overlayActivator.Disable(); return; } var identity = - _environment.ResolveIdentity(targetWindow); + _targetResolver.ResolveIdentity(targetWindow); var assignment = identity is null ? null : ProfileResolver.FindAssignment( From 1afe74dda3f563cf26d21340e5ce97a2aabe6694 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:54:18 +0200 Subject: [PATCH 04/11] Define expected runtime overlay availability failure Addresses #118. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/RuntimeOverlay.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/SightAdapt/RuntimeOverlay.cs b/src/SightAdapt/RuntimeOverlay.cs index cc74b1aa..bff30f63 100644 --- a/src/SightAdapt/RuntimeOverlay.cs +++ b/src/SightAdapt/RuntimeOverlay.cs @@ -1,5 +1,21 @@ namespace SightAdapt; +internal sealed class RuntimeOverlayUnavailableException : + Exception +{ + public RuntimeOverlayUnavailableException(string message) + : base(message) + { + } + + public RuntimeOverlayUnavailableException( + string message, + Exception innerException) + : base(message, innerException) + { + } +} + internal sealed class OverlayActivationRequest { public OverlayActivationRequest( From bbe6f0c96079a51f4ff73fe9ca76cdf378a52743 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:54:28 +0200 Subject: [PATCH 05/11] Classify target disappearance as operational overlay failure Addresses #118. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/OverlaySession.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SightAdapt/OverlaySession.cs b/src/SightAdapt/OverlaySession.cs index 570c33f2..ace0a57a 100644 --- a/src/SightAdapt/OverlaySession.cs +++ b/src/SightAdapt/OverlaySession.cs @@ -318,7 +318,7 @@ private void ActivateNew( primary.Show(); if (primary.IsDisposed) { - throw new InvalidOperationException( + throw new RuntimeOverlayUnavailableException( "The overlay target became unavailable during activation."); } From cafe51e815497af40799c7a527106c7d8fb90e36 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:55:00 +0200 Subject: [PATCH 06/11] Classify and diagnose runtime overlay activation failures Addresses #117 and #118. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/RuntimeOverlayActivator.cs | 77 +++++++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/src/SightAdapt/RuntimeOverlayActivator.cs b/src/SightAdapt/RuntimeOverlayActivator.cs index 95124b24..f3599155 100644 --- a/src/SightAdapt/RuntimeOverlayActivator.cs +++ b/src/SightAdapt/RuntimeOverlayActivator.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; + namespace SightAdapt; internal enum RuntimeActivationMode @@ -10,19 +12,19 @@ internal sealed class RuntimeOverlayActivator { private readonly ApplicationStateController _stateController; private readonly IRuntimeOverlay _overlay; - private readonly IRuntimeEnvironment _environment; + private readonly IRuntimeFeedback _feedback; public RuntimeOverlayActivator( ApplicationStateController stateController, IRuntimeOverlay overlay, - IRuntimeEnvironment environment) + IRuntimeFeedback feedback) { _stateController = stateController ?? throw new ArgumentNullException(nameof(stateController)); _overlay = overlay ?? throw new ArgumentNullException(nameof(overlay)); - _environment = environment ?? - throw new ArgumentNullException(nameof(environment)); + _feedback = feedback ?? + throw new ArgumentNullException(nameof(feedback)); } public IRuntimeOverlay Overlay => _overlay; @@ -72,8 +74,15 @@ public void Activate( } } catch (Exception exception) + when (IsExpectedOperationalFailure(exception)) { - _overlay.Disable(); + ReportActivationFailure( + exception, + activationMode, + targetWindow, + expectedOperationalFailure: true); + DisableAfterFailure(exception); + var message = RuntimeMessages.OverlayCreationFailed(exception); _stateController.SetFault( @@ -81,7 +90,17 @@ public void Activate( activationMode == RuntimeActivationMode.Automatic ? targetWindow : nint.Zero); - _environment.ShowNotification(message); + _feedback.ShowNotification(message); + } + catch (Exception exception) + { + ReportActivationFailure( + exception, + activationMode, + targetWindow, + expectedOperationalFailure: false); + DisableAfterFailure(exception); + throw; } } @@ -103,4 +122,50 @@ public void DisableForExit() _overlay.Disable(); _stateController.SetInactive(); } + + private static bool IsExpectedOperationalFailure( + Exception exception) + { + return exception is Win32Exception or + RuntimeOverlayUnavailableException; + } + + private static void ReportActivationFailure( + Exception exception, + RuntimeActivationMode activationMode, + nint targetWindow, + bool expectedOperationalFailure) + { + Diagnostics.Report( + nameof(RuntimeOverlayActivator), + "Activate overlay", + DiagnosticSeverity.Error, + expectedOperationalFailure + ? DiagnosticFailurePolicy.Recovered + : DiagnosticFailurePolicy.Critical, + $"Overlay activation failed in {activationMode} mode " + + $"for target 0x{targetWindow.ToInt64():X}.", + exception); + } + + private void DisableAfterFailure( + Exception primaryException) + { + try + { + _overlay.Disable(); + } + catch (Exception cleanupException) + { + Diagnostics.Report( + nameof(RuntimeOverlayActivator), + "Clean up failed activation", + DiagnosticSeverity.Warning, + DiagnosticFailurePolicy.BestEffort, + "Overlay cleanup failed after activation failure; " + + $"the primary {primaryException.GetType().Name} " + + "remains authoritative.", + cleanupException); + } + } } From 7db8887783913d5235699d511a448f65ba1d2807 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:55:10 +0200 Subject: [PATCH 07/11] Compose named runtime target and feedback adapters Addresses #117. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/SightAdaptContext.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/SightAdapt/SightAdaptContext.cs b/src/SightAdapt/SightAdaptContext.cs index 27b64fb9..54a429a7 100644 --- a/src/SightAdapt/SightAdaptContext.cs +++ b/src/SightAdapt/SightAdaptContext.cs @@ -36,15 +36,21 @@ public SightAdaptContext() EmergencyDisable, ExitThread); + var runtimeTargetResolver = + new DelegateRuntimeTargetResolver( + _foregroundTracker.ResolveTargetWindow, + ForegroundWindowTracker.IsSupportedTarget, + ResolveApplicationIdentity); + var runtimeFeedback = + new DelegateRuntimeFeedback( + _tray.ShowNotification, + _tray.SetAutomaticMode); _runtimeCoordinator = new RuntimeCoordinator( _settingsCoordinator, _stateController, _overlayController, - _foregroundTracker.ResolveTargetWindow, - ForegroundWindowTracker.IsSupportedTarget, - ResolveApplicationIdentity, - _tray.ShowNotification, - _tray.SetAutomaticMode); + runtimeTargetResolver, + runtimeFeedback); _hotkeys = new HotkeyWindow(HandleHotkey); _settingsCoordinator.Changed += SettingsChanged; From 4ec533c0d7db016e23dda43a8461c972859d792e Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:55:26 +0200 Subject: [PATCH 08/11] Update runtime coordinator tests for split ports Addresses #117. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- .../RuntimeCoordinatorTests.cs | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/tests/SightAdapt.Tests/RuntimeCoordinatorTests.cs b/tests/SightAdapt.Tests/RuntimeCoordinatorTests.cs index 7877a203..43451c8e 100644 --- a/tests/SightAdapt.Tests/RuntimeCoordinatorTests.cs +++ b/tests/SightAdapt.Tests/RuntimeCoordinatorTests.cs @@ -131,22 +131,26 @@ public void ForegroundEvaluationReadsOneSettingsSnapshot() using var context = new RuntimeTestContext(); context.AddEnabledAssignment(); var reads = 0; - var environment = new DelegateRuntimeEnvironment( - () => context.Target, - target => target == context.Target, - target => target == context.Target - ? new ApplicationIdentity( - "Reader", - "reader.exe", - @"C:\Apps\reader.exe") - : null, - _ => { }, - _ => { }); + var targetResolver = + new DelegateRuntimeTargetResolver( + () => context.Target, + target => target == context.Target, + target => target == context.Target + ? new ApplicationIdentity( + "Reader", + "reader.exe", + @"C:\Apps\reader.exe") + : null); + var feedback = + new DelegateRuntimeFeedback( + _ => { }, + _ => { }); var coordinator = new RuntimeCoordinator( context.Settings, context.State, context.Overlay, - environment, + targetResolver, + feedback, () => { reads++; @@ -182,17 +186,23 @@ public RuntimeTestContext() "settings.json"))); State = new ApplicationStateController(); Overlay = new FakeRuntimeOverlay(); + var targetResolver = + new DelegateRuntimeTargetResolver( + () => Target, + target => target != nint.Zero, + target => target == Target + ? _identity + : null); + var feedback = + new DelegateRuntimeFeedback( + Notifications.Add, + SynchronizedAutomaticModes.Add); Coordinator = new RuntimeCoordinator( Settings, State, Overlay, - () => Target, - target => target != nint.Zero, - target => target == Target - ? _identity - : null, - Notifications.Add, - SynchronizedAutomaticModes.Add); + targetResolver, + feedback); } public nint Target { get; } = (nint)100; From 3f01652bb9ddc56a856b83763f6942e530d66c7e Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:56:04 +0200 Subject: [PATCH 09/11] Test runtime activation failure classification Addresses #118. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- .../RuntimeOverlayActivatorTests.cs | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 tests/SightAdapt.Tests/RuntimeOverlayActivatorTests.cs diff --git a/tests/SightAdapt.Tests/RuntimeOverlayActivatorTests.cs b/tests/SightAdapt.Tests/RuntimeOverlayActivatorTests.cs new file mode 100644 index 00000000..e2fc5a14 --- /dev/null +++ b/tests/SightAdapt.Tests/RuntimeOverlayActivatorTests.cs @@ -0,0 +1,248 @@ +using System.ComponentModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace SightAdapt.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class RuntimeOverlayActivatorTests +{ + private static readonly nint Target = (nint)0x1234; + + [TestMethod] + public void ExpectedNativeFailureIsDiagnosedAndTransitionsToFault() + { + var state = new ApplicationStateController(); + var overlay = new ThrowingOverlay + { + ActivateException = new Win32Exception( + 5, + "native failure"), + }; + var feedback = new RecordingFeedback(); + var sink = new RecordingSink(); + using var scope = Diagnostics.UseSink(sink); + var activator = new RuntimeOverlayActivator( + state, + overlay, + feedback); + + activator.Activate( + new SightAdaptSettings(), + Target, + RuntimeActivationMode.Automatic, + assignment: null); + + Assert.AreEqual( + ApplicationRunState.Fault, + state.Current.Kind); + Assert.IsTrue( + state.IsAutomaticSuppressedFor(Target)); + Assert.AreEqual(1, feedback.Notifications.Count); + Assert.AreEqual(1, overlay.DisableCount); + var failure = sink.Events.Single( + item => item.Operation == "Activate overlay"); + Assert.AreEqual( + DiagnosticFailurePolicy.Recovered, + failure.FailurePolicy); + Assert.IsInstanceOfType( + failure.Exception); + StringAssert.Contains( + failure.Message, + "Automatic"); + StringAssert.Contains( + failure.Message, + "0x1234"); + } + + [TestMethod] + public void ContractFailureIsDiagnosedCleanedAndRethrown() + { + var state = new ApplicationStateController(); + var expected = + new InvalidOperationException( + "contract failure"); + var overlay = new ThrowingOverlay + { + ActivateException = expected, + }; + var feedback = new RecordingFeedback(); + var sink = new RecordingSink(); + using var scope = Diagnostics.UseSink(sink); + var activator = new RuntimeOverlayActivator( + state, + overlay, + feedback); + + var thrown = + Assert.ThrowsException( + () => activator.Activate( + new SightAdaptSettings(), + Target, + RuntimeActivationMode.Manual, + assignment: null)); + + Assert.AreSame(expected, thrown); + Assert.AreEqual(1, overlay.DisableCount); + Assert.AreEqual( + ApplicationRunState.Inactive, + state.Current.Kind); + Assert.AreEqual(0, feedback.Notifications.Count); + var failure = sink.Events.Single( + item => item.Operation == "Activate overlay"); + Assert.AreEqual( + DiagnosticFailurePolicy.Critical, + failure.FailurePolicy); + Assert.AreSame(expected, failure.Exception); + } + + [TestMethod] + public void CleanupFailureDoesNotReplaceOperationalFailure() + { + var state = new ApplicationStateController(); + var primary = new Win32Exception( + 87, + "primary failure"); + var cleanup = + new InvalidOperationException( + "cleanup failure"); + var overlay = new ThrowingOverlay + { + ActivateException = primary, + DisableException = cleanup, + }; + var feedback = new RecordingFeedback(); + var sink = new RecordingSink(); + using var scope = Diagnostics.UseSink(sink); + var activator = new RuntimeOverlayActivator( + state, + overlay, + feedback); + + activator.Activate( + new SightAdaptSettings(), + Target, + RuntimeActivationMode.Automatic, + assignment: null); + + Assert.AreEqual( + ApplicationRunState.Fault, + state.Current.Kind); + StringAssert.Contains( + state.Current.Message ?? string.Empty, + "primary failure"); + Assert.AreEqual(1, feedback.Notifications.Count); + Assert.AreEqual(2, sink.Events.Count); + Assert.AreSame( + primary, + sink.Events.Single( + item => item.Operation == + "Activate overlay").Exception); + Assert.AreSame( + cleanup, + sink.Events.Single( + item => item.Operation == + "Clean up failed activation").Exception); + } + + [TestMethod] + public void SuccessfulActivationSetsRequestedRuntimeState() + { + var state = new ApplicationStateController(); + var overlay = new ThrowingOverlay(); + var feedback = new RecordingFeedback(); + var sink = new RecordingSink(); + using var scope = Diagnostics.UseSink(sink); + var activator = new RuntimeOverlayActivator( + state, + overlay, + feedback); + + activator.Activate( + new SightAdaptSettings(), + Target, + RuntimeActivationMode.Manual, + assignment: null); + + Assert.AreEqual( + ApplicationRunState.ManualActive, + state.Current.Kind); + Assert.AreEqual(Target, state.Current.TargetWindow); + Assert.AreEqual(1, overlay.ActivationCount); + Assert.AreEqual(0, overlay.DisableCount); + Assert.AreEqual(0, feedback.Notifications.Count); + Assert.AreEqual(0, sink.Events.Count); + } + + private sealed class ThrowingOverlay : + IRuntimeOverlay + { + public Exception? ActivateException { get; init; } + + public Exception? DisableException { get; init; } + + public bool IsActive { get; private set; } + + public nint TargetWindow { get; private set; } + + public int ActivationCount { get; private set; } + + public int DisableCount { get; private set; } + + public void Activate( + OverlayActivationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ActivationCount++; + if (ActivateException is not null) + { + throw ActivateException; + } + + IsActive = true; + TargetWindow = request.TargetWindow; + } + + public void Disable() + { + DisableCount++; + if (DisableException is not null) + { + throw DisableException; + } + + IsActive = false; + TargetWindow = nint.Zero; + } + } + + private sealed class RecordingFeedback : + IRuntimeFeedback + { + public List Notifications { get; } = []; + + public List AutomaticModes { get; } = []; + + public void ShowNotification(string message) + { + Notifications.Add(message); + } + + public void SynchronizeAutomaticMode(bool enabled) + { + AutomaticModes.Add(enabled); + } + } + + private sealed class RecordingSink : + IDiagnosticSink + { + public List Events { get; } = []; + + public void Write( + DiagnosticEvent diagnosticEvent) + { + Events.Add(diagnosticEvent); + } + } +} From dc653b2f0e3a111b02ee33299f55c8b841445a96 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:56:41 +0200 Subject: [PATCH 10/11] Update menu runtime tests for split ports Addresses #117. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- .../RuntimeMenuProfileTests.cs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/SightAdapt.Tests/RuntimeMenuProfileTests.cs b/tests/SightAdapt.Tests/RuntimeMenuProfileTests.cs index e1f0cd0a..bf6e29e2 100644 --- a/tests/SightAdapt.Tests/RuntimeMenuProfileTests.cs +++ b/tests/SightAdapt.Tests/RuntimeMenuProfileTests.cs @@ -83,17 +83,23 @@ public RuntimeTestContext() _directory, "settings.json"))); Overlay = new FakeRuntimeOverlay(); + var targetResolver = + new DelegateRuntimeTargetResolver( + () => Target, + target => target == Target, + target => target == Target + ? _identity + : null); + var feedback = + new DelegateRuntimeFeedback( + _ => { }, + _ => { }); Coordinator = new RuntimeCoordinator( Settings, new ApplicationStateController(), Overlay, - () => Target, - target => target == Target, - target => target == Target - ? _identity - : null, - _ => { }, - _ => { }); + targetResolver, + feedback); } public nint Target { get; } = (nint)100; From 78306e631271eef997ee570ff79d7ced90f5a5ac Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:57:07 +0200 Subject: [PATCH 11/11] Document split runtime ports and activation failure policy Addresses #117 and #118. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/ARCHITECTURE.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed4d855f..25cc95ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,6 +73,8 @@ A failed mutation or failed write does not replace committed settings and does n | Persisted JSON DTOs and legacy-field migration | `PersistedSettingsMapper` | | Schema, profile, assignment and reference normalization | explicit `ISettingsNormalizationPass` implementations | | Runtime command/event façade | `RuntimeCoordinator` | +| Runtime target discovery and identity resolution | `IRuntimeTargetResolver` | +| Runtime user feedback and automatic-mode synchronization | `IRuntimeFeedback` | | Overlay activation and automatic evaluation use cases | `RuntimeOverlayActivator` and `AutomaticActivationService` | | Application assignment mutations and overlay scope | `ApplicationAssignmentService` | | Visual-profile lifecycle and tuning | `VisualProfileManagementService` | @@ -124,6 +126,15 @@ Each registered `VisualProfileDefinition` is the authority for its transform's e `ApplicationIdentityCache` is an optimization, not a product source of truth. Entries are keyed by both PID and process creation time so a reused PID cannot inherit another process lifetime's identity. +## Runtime ports + +Runtime orchestration uses two narrow ports instead of one mixed environment abstraction: + +- `IRuntimeTargetResolver` owns target-window resolution, target support checks and application identity lookup; +- `IRuntimeFeedback` owns user notifications and synchronization of the tray's automatic-mode state. + +`SightAdaptContext` is the composition root that creates the concrete delegate adapters. Runtime services can substitute either collaborator independently in tests, and `RuntimeCoordinator` no longer exposes the previous five-delegate constructor. + ## Foreground and overlay lifecycle The foreground tracker polls every 75 ms and publishes only a changed supported application handle. Native `#32768` popup-menu windows are deliberately rejected as application targets, so opening a menu does not replace the active assignment. When an enabled assignment exists, the context resolves the application profile, the inherited or explicit menu profile, and the overlay scope. @@ -160,7 +171,9 @@ Raw imports exist only in `NativeInterop`. Production components depend on focus - **transient** geometry, positioning, and source-update failures are diagnosed, hide the overlay, and allow a later timer tick to recover; - **best effort** cleanup failures are diagnosed without replacing the primary application failure. -Every failure is emitted through the structured `Diagnostics` authority. Native menu detection, menu-overlay creation, and cross-filter refresh are subordinate operations. Their failure closes transient menu overlays and restores the primary overlay's self-filter instead of disabling the application correction. +Every native failure is emitted through the structured `Diagnostics` authority. `RuntimeOverlayActivator` also classifies top-level activation failures: expected native or target-availability failures are diagnosed, cleaned up best-effort, transitioned into a safe `Fault` state and surfaced through `IRuntimeFeedback`; contract or programming failures are diagnosed, cleaned up without replacing the primary exception, and rethrown. Automatic activation retains the failed target as the suppression target so a failing foreground window is not immediately retried in a loop. + +Native menu detection, menu-overlay creation, and cross-filter refresh are subordinate operations. Their failure closes transient menu overlays and restores the primary overlay's self-filter instead of disabling the application correction. `ShowWindow` and `InvalidateRect` are handled explicitly at their call sites because their Boolean return values do not represent a standard extended-error success contract.