diff --git a/vibrance.GUI/AMD/AmdDynamicVibranceProxy.cs b/vibrance.GUI/AMD/AmdDynamicVibranceProxy.cs index ca4742c..3f78330 100644 --- a/vibrance.GUI/AMD/AmdDynamicVibranceProxy.cs +++ b/vibrance.GUI/AMD/AmdDynamicVibranceProxy.cs @@ -154,6 +154,21 @@ private void OnWinEventHook(object sender, WinEventHookEventArgs e) if (applicationSetting != null) { + if (ProfileToggleHelper.IsSuppressed(applicationSetting.Name)) + { + // Toggled off by hotkey (upstream #143). Ignore this foreground event for + // this game entirely - deliberately NOT a fall-through to the restore branch + // below: the toggle itself already restored this display + // (ToggleForegroundProfile), and re-running the work-list restore on every + // alt-tab into a suppressed game would reach displays this game never even + // touched. + // + // Returns BEFORE "_gameScreen = screen" below: a suppressed game applies + // nothing here, so it must not become the screen a later resolution revert + // reasons about. + return; + } + Screen screen = Screen.FromHandle(e.Handle); _gameScreen = screen; @@ -263,11 +278,16 @@ private void RestoreWindowsVibranceLevel() return; } - // IAmdAdapter has no read-back to confirm a write landed, unlike NVIDIA's + // This restore path still has no read-back to confirm a write landed, unlike NVIDIA's // equalsDVCLevel/setDVCLevel pair - so, unlike NvidiaDynamicVibranceProxy's - // RestoreOneDisplay, every target is written unconditionally and cleared + // RestoreOneDisplay, every target here is written unconditionally and cleared // unconditionally, unable to tell "already correct" from "just fixed" or to retry a - // failure that has no way to be observed here. + // failure that has no way to be observed here. That is no longer true of + // IAmdAdapter.SetSaturationOnDisplay itself (upstream #143 gave it a real ADL_OK-based + // bool return) - it is just that THIS call site, deliberately, still ignores it: doing + // otherwise would make this drain conditionally, changing behaviour the pre-existing + // A1-A6 checks in VibranceRestoreFixture pin. ToggleForegroundProfile below is the one + // call site that actually reads the new return value. List targets = VibranceRestoreHelper.ComposeRestoreTargets(true, VibranceRestoreHelper.GetPrimaryDeviceName()); foreach (string deviceName in targets) { @@ -276,6 +296,94 @@ private void RestoreWindowsVibranceLevel() } } + /// + /// See IVibranceProxy.ToggleForegroundProfile for the full contract. Decide (pure) picks + /// the direction from our own recorded suppression state, never from a display read-back; + /// this method is only the write plus the flip. Unlike RestoreWindowsVibranceLevel above, + /// this DOES read IAmdAdapter.SetSaturationOnDisplay's new bool return - the toggle path + /// is the one place a false success genuinely matters, since flipping suppression on a + /// write that never landed would strand the game at whatever level it was already at + /// while telling the engine (and the user) the opposite. + /// + /// Branches on affectPrimaryMonitorOnly, mirroring OnWinEventHook's own apply branch + /// above - unlike NVIDIA, the AMD apply is NOT single-display with the flag off (the + /// DEFAULT): it writes every attached screen via SetSaturationOnAllDisplays and records + /// all of them. A toggle that only ever touched deviceName would write one display back + /// to the Windows level while every other monitor stayed at the game's saturation - with + /// the balloon claiming the profile was restored - for as long as the user stays in the + /// suppressed game, since the suppression gate returns early on every later event. + /// + public ProfileToggleResult ToggleForegroundProfile(IntPtr foregroundWindow, string processName, string processImagePath) + { + ProfileToggleDecision decision = ProfileToggleHelper.Decide( + _applicationSettings, processName, processImagePath, _vibranceInfo.isWindowsLevelKnown); + + if (decision.Action == ProfileToggleAction.None) + { + return ProfileToggleResult.NoConfiguredGameInForeground; + } + if (decision.Action == ProfileToggleAction.EngineNotReady) + { + return ProfileToggleResult.EngineNotReady; + } + + string deviceName = Screen.FromHandle(foregroundWindow).DeviceName; + string name = decision.Setting.Name; + + if (decision.Action == ProfileToggleAction.ApplyGameLevel) + { + if (_vibranceInfo.affectPrimaryMonitorOnly) + { + if (!_amdAdapter.SetSaturationOnDisplay(decision.Setting.IngameLevel, deviceName)) + { + return ProfileToggleResult.WriteFailed; + } + // Only the game's own screen was written - that is the only display owing a + // restore. + VibranceRestoreHelper.RecordGameLevelApplied(deviceName); + } + else + { + // The identical write SetSaturationOnAllDisplays makes internally + // (AmdAdapter32/64.cs: "SetSaturationOnDisplay(vibranceLevel, null)"), but + // through the named-display overload so the new ADL_OK-based bool return + // survives for this method to actually check - see its own header comment. + if (!_amdAdapter.SetSaturationOnDisplay(decision.Setting.IngameLevel, null)) + { + return ProfileToggleResult.WriteFailed; + } + // This really did write every attached display, not just the game's own - + // every one of them is recorded as owing a restore, mirroring the automatic + // apply branch above. + foreach (Screen attachedScreen in Screen.AllScreens) + { + VibranceRestoreHelper.RecordGameLevelApplied(attachedScreen.DeviceName); + } + } + ProfileToggleHelper.SetSuppressed(name, false); + return ProfileToggleResult.ToggledOn; + } + + if (_vibranceInfo.affectPrimaryMonitorOnly) + { + if (!_amdAdapter.SetSaturationOnDisplay(_vibranceInfo.userVibranceSettingDefault, deviceName)) + { + return ProfileToggleResult.WriteFailed; + } + VibranceRestoreHelper.ClearGameLevelRecord(deviceName); + } + else + { + if (!_amdAdapter.SetSaturationOnDisplay(_vibranceInfo.userVibranceSettingDefault, null)) + { + return ProfileToggleResult.WriteFailed; + } + VibranceRestoreHelper.ClearAllGameLevelRecords(); + } + ProfileToggleHelper.SetSuppressed(name, true); + return ProfileToggleResult.ToggledOff; + } + private void RestoreWindowsColorSettings() { //restores every screen whose gamma ramp this application actually captured a baseline diff --git a/vibrance.GUI/AMD/vendor/AmdAdapter32.cs b/vibrance.GUI/AMD/vendor/AmdAdapter32.cs index 03d4a24..ab97f5a 100644 --- a/vibrance.GUI/AMD/vendor/AmdAdapter32.cs +++ b/vibrance.GUI/AMD/vendor/AmdAdapter32.cs @@ -124,21 +124,48 @@ public void SetSaturationOnAllDisplays(int vibranceLevel) this.SetSaturationOnDisplay(vibranceLevel, null); } - public void SetSaturationOnDisplay(int vibranceLevel, string displayName) + public bool SetSaturationOnDisplay(int vibranceLevel, string displayName) { + // matchedAny/allSucceeded are closed over by the handler below, the same way the + // pre-existing lambda already closes over vibranceLevel/displayName - SetSaturation + // itself stays a void-returning Action, only what its handler does with the result + // changes. "No display matched" (matchedAny stays false) must report false, not the + // vacuous "true" an empty loop would otherwise imply - see IAmdAdapter's own comment. + bool matchedAny = false; + bool allSucceeded = true; SetSaturation((adlDisplayInfo, adlAdapterInfo, adapterIndex) => { int infoValue = adlDisplayInfo.DisplayID.DisplayLogicalIndex; bool adapterIsAssociatedWithDisplay = adapterIndex == adlDisplayInfo.DisplayID.DisplayLogicalAdapterIndex; if (adapterIsAssociatedWithDisplay && (adlAdapterInfo.DisplayName == displayName || displayName == null)) { - Adl.AdlDisplayColorSet( + matchedAny = true; + + // Adl.AdlDisplayColorSet can be null - IsFunctionValid (ADLCheckLibrary.cs) + // failed to resolve "ADL_Display_Color_Set" from the driver's DLL. The + // pre-existing call below was unguarded against that (a latent NRE); guarded + // here since this line is already being touched for the status-code fix. + if (Adl.AdlDisplayColorSet == null) + { + allSucceeded = false; + return; + } + + // AdlSuccess (= 0) is ADL_OK - reusing the constant this file already defines + // and already checks every other ADL return code against, rather than adding + // a second name for the same value. + int adlStatus = Adl.AdlDisplayColorSet( adapterIndex, infoValue, Adl.AdlDisplayColorSaturation, vibranceLevel); + if (adlStatus != Adl.AdlSuccess) + { + allSucceeded = false; + } } }); + return matchedAny && allSucceeded; } private void SetSaturation(Action handle) diff --git a/vibrance.GUI/AMD/vendor/AmdAdapter64.cs b/vibrance.GUI/AMD/vendor/AmdAdapter64.cs index 1a614bb..78c9f16 100644 --- a/vibrance.GUI/AMD/vendor/AmdAdapter64.cs +++ b/vibrance.GUI/AMD/vendor/AmdAdapter64.cs @@ -124,21 +124,48 @@ public void SetSaturationOnAllDisplays(int vibranceLevel) this.SetSaturationOnDisplay(vibranceLevel, null); } - public void SetSaturationOnDisplay(int vibranceLevel, string displayName) + public bool SetSaturationOnDisplay(int vibranceLevel, string displayName) { + // matchedAny/allSucceeded are closed over by the handler below, the same way the + // pre-existing lambda already closes over vibranceLevel/displayName - SetSaturation + // itself stays a void-returning Action, only what its handler does with the result + // changes. "No display matched" (matchedAny stays false) must report false, not the + // vacuous "true" an empty loop would otherwise imply - see IAmdAdapter's own comment. + bool matchedAny = false; + bool allSucceeded = true; SetSaturation((adlDisplayInfo, adlAdapterInfo, adapterIndex) => { int infoValue = adlDisplayInfo.DisplayID.DisplayLogicalIndex; bool adapterIsAssociatedWithDisplay = adapterIndex == adlDisplayInfo.DisplayID.DisplayLogicalAdapterIndex; if (adapterIsAssociatedWithDisplay && (adlAdapterInfo.DisplayName == displayName || displayName == null)) { - Adl.AdlDisplayColorSet( + matchedAny = true; + + // Adl.AdlDisplayColorSet can be null - IsFunctionValid (ADLCheckLibrary.cs) + // failed to resolve "ADL_Display_Color_Set" from the driver's DLL. The + // pre-existing call below was unguarded against that (a latent NRE); guarded + // here since this line is already being touched for the status-code fix. + if (Adl.AdlDisplayColorSet == null) + { + allSucceeded = false; + return; + } + + // AdlSuccess (= 0) is ADL_OK - reusing the constant this file already defines + // and already checks every other ADL return code against, rather than adding + // a second name for the same value. + int adlStatus = Adl.AdlDisplayColorSet( adapterIndex, infoValue, Adl.AdlDisplayColorSaturation, vibranceLevel); + if (adlStatus != Adl.AdlSuccess) + { + allSucceeded = false; + } } }); + return matchedAny && allSucceeded; } private void SetSaturation(Action handle) diff --git a/vibrance.GUI/AMD/vendor/IAmdAdapter.cs b/vibrance.GUI/AMD/vendor/IAmdAdapter.cs index d812893..1378d78 100644 --- a/vibrance.GUI/AMD/vendor/IAmdAdapter.cs +++ b/vibrance.GUI/AMD/vendor/IAmdAdapter.cs @@ -9,7 +9,14 @@ public interface IAmdAdapter : IDisposable { void SetSaturationOnAllDisplays(int vibranceLevel); - void SetSaturationOnDisplay(int vibranceLevel, string displayName); + /// + /// True only when at least one display actually matched displayName (or, for the + /// SetSaturationOnAllDisplays fan-out, at least one display existed at all) AND every ADL + /// call for a matched display returned ADL_OK. "No display matched, so nothing was even + /// attempted" must report false, not true - see the implementations for why that + /// distinction was previously unbuildable (this method used to return void). + /// + bool SetSaturationOnDisplay(int vibranceLevel, string displayName); bool IsAvailable(); diff --git a/vibrance.GUI/NVIDIA/NvidiaDynamicVibranceProxy.cs b/vibrance.GUI/NVIDIA/NvidiaDynamicVibranceProxy.cs index 86e326e..6abbe84 100644 --- a/vibrance.GUI/NVIDIA/NvidiaDynamicVibranceProxy.cs +++ b/vibrance.GUI/NVIDIA/NvidiaDynamicVibranceProxy.cs @@ -271,6 +271,22 @@ private static void OnWinEventHook(object sender, WinEventHookEventArgs e) if (applicationSetting != null) { + if (ProfileToggleHelper.IsSuppressed(applicationSetting.Name)) + { + // Toggled off by hotkey (upstream #143). Ignore this foreground event for + // this game entirely - deliberately NOT a fall-through to the restore branch + // below: the toggle itself already restored this display + // (ToggleForegroundProfile), and re-running the work-list restore on every + // alt-tab into a suppressed game would reach displays this game never even + // touched (VibranceRestoreHelper.ComposeRestoreTargets is scoped to the + // whole work-list, not to this one game). + // + // Returns BEFORE "_gameScreen = screen" below: a suppressed game applies + // nothing here, so it must not become the screen a later resolution revert + // reasons about. + return; + } + Screen screen = Screen.FromHandle(e.Handle); _gameScreen = screen; @@ -536,33 +552,84 @@ private static bool AllDisplaysAtLevel(INvidiaVibranceDevice device, IList return true; } - private static void RestoreOneDisplay(INvidiaVibranceDevice device, string deviceName, int windowsLevel) + /// + /// Restores deviceName to windowsLevel, exactly as before - returns true only once the + /// level is CONFIRMED landed (already there, or a write just succeeded), false when it is + /// still owed (unresolvable handle, or a failed write). The pre-existing foreach call + /// site in RestoreWindowsVibranceLevel ignores this return value, so that path's own + /// behaviour is unchanged; ToggleForegroundProfile is the new caller that actually reads + /// it, to decide whether the toggle's suppression flip is safe to make. + /// + private static bool RestoreOneDisplay(INvidiaVibranceDevice device, string deviceName, int windowsLevel) { int displayHandle = device.TryResolveDisplayHandle(deviceName); if (displayHandle == -1 || displayHandle == 0) { LogDisplayFailureOnce(deviceName, string.Format( "Could not resolve an NVIDIA display handle for screen {0}, its Windows vibrance level restore will retry on the next foreground change", deviceName)); - return; // stays on the work-list - see the class-level comment above. + return false; // stays on the work-list - see the class-level comment above. } if (device.IsAtLevel(displayHandle, windowsLevel)) { VibranceRestoreHelper.ClearGameLevelRecord(deviceName); ClearDisplayFailureLog(deviceName); - return; + return true; } if (device.SetLevel(displayHandle, windowsLevel)) { VibranceRestoreHelper.ClearGameLevelRecord(deviceName); ClearDisplayFailureLog(deviceName); + return true; } - else + + LogDisplayFailureOnce(deviceName, string.Format( + "Failed to restore the Windows vibrance level for screen {0}, it will retry on the next foreground change", deviceName)); + return false; + } + + /// + /// See IVibranceProxy.ToggleForegroundProfile for the full contract. Decide (pure) picks + /// the direction from our own recorded suppression state, never from a display read-back; + /// this method is only the write plus the flip. The restore direction goes through + /// RestoreOneDisplay - never RestoreWindowsVibranceLevel, which would also walk the whole + /// work-list plus the primary and restore displays this one game never touched. + /// + public ProfileToggleResult ToggleForegroundProfile(IntPtr foregroundWindow, string processName, string processImagePath) + { + ProfileToggleDecision decision = ProfileToggleHelper.Decide( + _applicationSettings, processName, processImagePath, _vibranceInfo.isWindowsLevelKnown); + + if (decision.Action == ProfileToggleAction.None) { - LogDisplayFailureOnce(deviceName, string.Format( - "Failed to restore the Windows vibrance level for screen {0}, it will retry on the next foreground change", deviceName)); + return ProfileToggleResult.NoConfiguredGameInForeground; + } + if (decision.Action == ProfileToggleAction.EngineNotReady) + { + return ProfileToggleResult.EngineNotReady; + } + + string deviceName = Screen.FromHandle(foregroundWindow).DeviceName; + string name = decision.Setting.Name; + + if (decision.Action == ProfileToggleAction.ApplyGameLevel) + { + if (!ApplyGameVibranceLevel(_device, deviceName, decision.Setting.IngameLevel)) + { + return ProfileToggleResult.WriteFailed; + } + VibranceRestoreHelper.RecordGameLevelApplied(deviceName); + ProfileToggleHelper.SetSuppressed(name, false); + return ProfileToggleResult.ToggledOn; + } + + if (!RestoreOneDisplay(_device, deviceName, _vibranceInfo.userVibranceSettingDefault)) + { + return ProfileToggleResult.WriteFailed; } + ProfileToggleHelper.SetSuppressed(name, true); + return ProfileToggleResult.ToggledOff; } private static void LogDisplayFailureOnce(string deviceName, string message) @@ -593,6 +660,10 @@ internal static void ResetForTests(INvidiaVibranceDevice device, VibranceInfo vi _gameScreen = null; _loggedDisplayFailures.Clear(); VibranceRestoreHelper.ResetForTests(); + // The toggle hotkey's own suppression state (upstream #143) - reset here too so a + // fixture check that only calls ResetForTests, without separately remembering to + // call ProfileToggleHelper.ResetForTests() itself, still starts from a clean slate. + ProfileToggleHelper.ResetForTests(); } // The production INvidiaVibranceDevice: the four native calls below against the real diff --git a/vibrance.GUI/Program.cs b/vibrance.GUI/Program.cs index edbceb4..c7aa088 100644 --- a/vibrance.GUI/Program.cs +++ b/vibrance.GUI/Program.cs @@ -31,6 +31,7 @@ static class Program private const string GammaDisplaySelfTestMessageBoxCaption = "vibranceGUI gamma restore hardware self test"; private const string ResolutionSelfTestMessageBoxCaption = "vibranceGUI resolution change self test"; private const string VibranceSelfTestMessageBoxCaption = "vibranceGUI vibrance restore self test"; + private const string HotkeySelfTestMessageBoxCaption = "vibranceGUI toggle hotkey self test"; private const string DisplayDriverUninstallerUrl = "http://www.guru3d.com/files-details/display-driver-uninstaller-download.html"; [STAThread] @@ -134,6 +135,19 @@ static void Main(string[] args) return; } + // Same placement again: ProfileToggleFixture only ever drives HotkeyRegistration + // through a fake IHotkeyRegistrar, and the suppression-gate/toggle-write checks + // through the vendor proxies' own ResetForTests seams and fake devices - so, like + // --selftest-resolution and --selftest-vibrance, there is deliberately no hardware + // variant of this one, and there must never be one. See that fixture's own header + // comment. + if (args.Contains("--selftest-profiletoggle")) + { + MessageBox.Show(string.Join(Environment.NewLine, ProfileToggleFixture.Run().ToArray()), + HotkeySelfTestMessageBoxCaption, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + // Unlike every self test above, this one can write to a real display's gamma ramp - it // runs the pure half first, then asks for confirmation before touching hardware, and // always restores what it found before returning. Opt in only; not part of diff --git a/vibrance.GUI/common/Definitions.cs b/vibrance.GUI/common/Definitions.cs index cc91d48..51ac8a4 100644 --- a/vibrance.GUI/common/Definitions.cs +++ b/vibrance.GUI/common/Definitions.cs @@ -19,6 +19,11 @@ public struct VibranceInfo public int userVibranceSettingDefault; public int userVibranceSettingActive; public String szGpuName; + // Written by SetShouldRun but never read back anywhere in this codebase - dead plumbing, + // not the toggle hotkey's per-game suppression state. That state is a per-profile set + // (ProfileToggleHelper._suppressedProfileNames, keyed by ApplicationSetting.Name), not a + // single global bool - a whole-engine pause was considered and rejected in favour of a + // per-game toggle, which this single field could never represent correctly. public bool shouldRun; public int sleepInterval; public List displayHandles; diff --git a/vibrance.GUI/common/HotkeyBinding.cs b/vibrance.GUI/common/HotkeyBinding.cs new file mode 100644 index 0000000..2d35419 --- /dev/null +++ b/vibrance.GUI/common/HotkeyBinding.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Windows.Forms; + +namespace vibrance.GUI.common +{ + /// + /// A parsed global hotkey binding: the RegisterHotKey modifier bits (already including + /// MOD_NOREPEAT - see HotkeyBindingParser.TryParse) plus the virtual-key code. Pure data - no + /// P/Invoke, no System.Runtime.InteropServices - so ProfileToggleFixture can construct and + /// compare these freely with no registrar seam involved at all. + /// + internal struct HotkeyBinding + { + internal uint Modifiers; + internal uint VirtualKey; + internal bool IsSet; + + // Same as the struct default (IsSet false) - written out as its own property so a caller + // never has to spell "default(HotkeyBinding)" or "new HotkeyBinding()" to mean "no + // binding configured". + internal static HotkeyBinding None + { + get { return new HotkeyBinding(); } + } + } + + /// + /// Parses/formats a HotkeyBinding to and from its canonical text, "Ctrl+Alt+Shift+Win+ + /// <KeyName>" - modifiers always in that fixed order, <KeyName> the Keys enum's + /// own name (e.g. "F9", "D1"). Round-trips: Format(TryParse(x)) == x for every canonical + /// string TryParse can produce. + /// + internal static class HotkeyBindingParser + { + // MOD_* (user32.h) - RegisterHotKey's own modifier bits. Not pulled from a shared Win32 + // constants file, because this codebase does not have one. + private const uint ModAlt = 0x0001; + private const uint ModControl = 0x0002; + private const uint ModShift = 0x0004; + private const uint ModWin = 0x0008; + + // MOD_NOREPEAT (user32.h, Vista+) - always OR'd into a parsed binding's Modifiers, never + // represented in the formatted text (see Format below). Without it, holding the key down + // fires WM_HOTKEY dozens of times a second and ToggleForegroundProfile flips the matched + // game's suppression state on every one of them. + internal const uint ModNoRepeat = 0x4000; + + private static readonly char[] Separator = { '+' }; + + /// + /// Parses text into binding. False (binding set to HotkeyBinding.None) for null, empty, + /// a binding with no key at all, an unrecognised token (never silently dropped), or a + /// purely numeric key token (Enum.TryParse<Keys>("1") would otherwise succeed as + /// Keys.LButton). Case-insensitive. + /// + internal static bool TryParse(string text, out HotkeyBinding binding) + { + binding = HotkeyBinding.None; + if (string.IsNullOrEmpty(text)) + { + return false; + } + + string[] tokens = text.Split(Separator, StringSplitOptions.None); + uint modifiers = 0; + Keys? key = null; + + foreach (string rawToken in tokens) + { + string token = rawToken.Trim(); + if (token.Length == 0) + { + // Catches both a trailing separator ("Ctrl+") and a doubled one. + return false; + } + + if (string.Equals(token, "Ctrl", StringComparison.OrdinalIgnoreCase)) + { + modifiers |= ModControl; + } + else if (string.Equals(token, "Alt", StringComparison.OrdinalIgnoreCase)) + { + modifiers |= ModAlt; + } + else if (string.Equals(token, "Shift", StringComparison.OrdinalIgnoreCase)) + { + modifiers |= ModShift; + } + else if (string.Equals(token, "Win", StringComparison.OrdinalIgnoreCase)) + { + modifiers |= ModWin; + } + else + { + if (key != null) + { + // A second non-modifier token - "Ctrl+F9+F10" - is an error, not a silent + // overwrite of the first. + return false; + } + + int numericProbe; + if (int.TryParse(token, out numericProbe)) + { + // Enum.TryParse("1") succeeds and yields Keys.LButton, since 1 is + // that value's underlying int - a purely numeric token is never a valid + // key name on its own. "D1"/"NumPad1" name the actual digit keys and are + // unaffected by this guard. + return false; + } + + Keys parsedKey; + if (!Enum.TryParse(token, true, out parsedKey) || !Enum.IsDefined(typeof(Keys), parsedKey)) + { + // An unrecognised token is an error - never silently dropped. + return false; + } + + // Keys packs modifier flags (Keys.Control, Keys.Shift, ...) into the same + // enum alongside KeyCode - masked off here so a key token can never smuggle a + // modifier bit past the fixed-order Modifiers this method already built above. + key = parsedKey & Keys.KeyCode; + } + } + + if (key == null) + { + // Modifiers with no key at all ("Ctrl", "Ctrl+Alt") is not a binding. + return false; + } + + HotkeyBinding parsed = new HotkeyBinding(); + parsed.Modifiers = modifiers | ModNoRepeat; + parsed.VirtualKey = (uint)key.Value; + parsed.IsSet = true; + binding = parsed; + return true; + } + + /// + /// The canonical text for binding, or "" for HotkeyBinding.None. Modifiers always appear + /// in the fixed Ctrl/Alt/Shift/Win order, regardless of the order TryParse originally + /// read them in; MOD_NOREPEAT is never represented here - see its own comment above. + /// + internal static string Format(HotkeyBinding binding) + { + if (!binding.IsSet) + { + return string.Empty; + } + + List parts = new List(); + if ((binding.Modifiers & ModControl) != 0) + { + parts.Add("Ctrl"); + } + if ((binding.Modifiers & ModAlt) != 0) + { + parts.Add("Alt"); + } + if ((binding.Modifiers & ModShift) != 0) + { + parts.Add("Shift"); + } + if ((binding.Modifiers & ModWin) != 0) + { + parts.Add("Win"); + } + parts.Add(((Keys)binding.VirtualKey).ToString()); + + return string.Join("+", parts.ToArray()); + } + } +} diff --git a/vibrance.GUI/common/HotkeyRegistration.cs b/vibrance.GUI/common/HotkeyRegistration.cs new file mode 100644 index 0000000..2490ba2 --- /dev/null +++ b/vibrance.GUI/common/HotkeyRegistration.cs @@ -0,0 +1,110 @@ +using System; + +namespace vibrance.GUI.common +{ + /// + /// Owns the lifecycle of the toggle hotkey's single OS registration. Apply/Release below are + /// the only two operations - deliberately not split into a "SetXxx" plus a separate + /// "RestoreXxx"-shaped pair: a two-call contract here could be invoked out of order, or with + /// the release half skipped, and silently leave a second registration behind. + /// + internal class HotkeyRegistration + { + // RegisterHotKey/UnregisterHotKey's own id parameter. One fixed value is enough - this + // application only ever registers a single hotkey; VibranceGUI.cs's WndProc dispatches + // WM_HOTKEY on wParam == HotkeyId. + internal const int HotkeyId = 1; + + /// + /// The single source of truth for "should a real OS registration exist right now" - + /// VibranceGUI.ApplyToggleHotkey calls this instead of inlining the ternary itself, so a + /// regression test can reach the actual gating expression production code runs (a fixture + /// cannot instantiate a real Form to reflect into ApplyToggleHotkey directly). The + /// checkbox gates registration, not just the presence of a saved binding - a binding can + /// be fully configured (and shown in the textbox) while the checkbox is still unchecked, + /// and must register nothing until the user turns it on. + /// + internal static HotkeyBinding EffectiveBinding(bool enabled, HotkeyBinding binding) + { + return (enabled && binding.IsSet) ? binding : HotkeyBinding.None; + } + + private readonly IHotkeyRegistrar _registrar; + + // The handle Apply last registered against - deliberately cached here rather than read + // back from the owning form when Release runs. See Release's own comment for why: the + // form's Handle is not stable for the form's lifetime. + private IntPtr _registeredHandle = IntPtr.Zero; + private bool _isRegistered; + + internal HotkeyRegistration(IHotkeyRegistrar registrar) + { + _registrar = registrar; + } + + internal bool IsRegistered + { + get { return _isRegistered; } + } + + /// + /// Releases whatever this instance currently has registered, then attempts to bind + /// against hWnd. Never leaves a stale registration behind: on any outcome other than + /// Registered, IsRegistered is false and nothing is registered - even if the PRIOR + /// binding (just released above) had succeeded. + /// + internal HotkeyRegistrationResult Apply(IntPtr hWnd, HotkeyBinding binding) + { + Release(); + + if (!binding.IsSet) + { + return HotkeyRegistrationResult.NotConfigured; + } + + HotkeyRegistrationResult result = _registrar.Register(hWnd, HotkeyId, binding.Modifiers, binding.VirtualKey); + + if (result == HotkeyRegistrationResult.Failed && (binding.Modifiers & HotkeyBindingParser.ModNoRepeat) != 0) + { + // Compatibility retry: some Windows builds reject MOD_NOREPEAT combined with + // certain virtual keys (ERROR_INVALID_PARAMETER, 87). IHotkeyRegistrar has no + // channel for the raw Win32 error code (see its own header comment), so this + // retries once, without the bit, on every generic Failed result - never on + // AlreadyOwnedByAnotherApplication, which is its own outcome and would fail the + // same way again regardless of MOD_NOREPEAT. + uint modifiersWithoutNoRepeat = binding.Modifiers & ~HotkeyBindingParser.ModNoRepeat; + result = _registrar.Register(hWnd, HotkeyId, modifiersWithoutNoRepeat, binding.VirtualKey); + } + + if (result == HotkeyRegistrationResult.Registered) + { + _registeredHandle = hWnd; + _isRegistered = true; + } + + return result; + } + + /// + /// Unregisters against the handle captured AT REGISTRATION TIME - never against a handle + /// passed in here, and never against the owning form's current Handle. Defensive, not a + /// fix for a confirmed live defect: a Form's Handle is not stable for its lifetime in + /// general - any property whose setter forces RecreateHandle (WinForms documents several) + /// would silently orphan a registration made against the old handle if Release ever read + /// a fresh one instead of the one Apply actually used. Caching it here is strictly more + /// correct than reading it fresh, independent of whether anything in THIS codebase + /// currently triggers a recreate. A no-op when nothing is currently registered. + /// + internal void Release() + { + if (!_isRegistered) + { + return; + } + + _registrar.Unregister(_registeredHandle, HotkeyId); + _isRegistered = false; + _registeredHandle = IntPtr.Zero; + } + } +} diff --git a/vibrance.GUI/common/IForegroundWindowReader.cs b/vibrance.GUI/common/IForegroundWindowReader.cs new file mode 100644 index 0000000..5447b58 --- /dev/null +++ b/vibrance.GUI/common/IForegroundWindowReader.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace vibrance.GUI.common +{ + /// + /// The seam between the toggle hotkey's WM_HOTKEY handler and the real foreground window - + /// same shape as IHotkeyRegistrar/IDisplayModeDevice/IGammaDevice: RealForegroundWindowReader + /// (below) is the only production implementation; ProfileToggleFixture never needs a fake of + /// this one at all, because it reflects into the real OnWinEventHook/ToggleForegroundProfile + /// with synthetic WinEventHookEventArgs/IntPtr values directly, the same way + /// VibranceRestoreFixture's N8 already does - unlike the six pre-existing AMD checks in that + /// file, none of ProfileToggleFixture's checks need a "did the real foreground window change + /// mid-test" Skip guard, because none of them read GetForegroundWindow() through this + /// interface at all. + /// + internal interface IForegroundWindowReader + { + bool TryGetForeground(out IntPtr hWnd, out string processName, out string processImagePath); + } + + /// + /// The only production IForegroundWindowReader - mirrors WinEventHook.WinEventProc's own + /// GetForegroundWindow/GetWindowThreadProcessId/PathResolver.TryGetProcessImagePath/ + /// Process.GetProcessById sequence and its same two tolerated exceptions (the process having + /// already exited between the two calls is not this class's problem to solve, only to not + /// crash on). + /// + internal class RealForegroundWindowReader : IForegroundWindowReader + { + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + public bool TryGetForeground(out IntPtr hWnd, out string processName, out string processImagePath) + { + hWnd = GetForegroundWindow(); + processName = null; + processImagePath = null; + + if (hWnd == IntPtr.Zero) + { + return false; + } + + uint processId; + GetWindowThreadProcessId(hWnd, out processId); + + // Same fallback WinEventHook.WinEventProc already applies: a protected or elevated + // process simply has no image path, not a failure worth aborting over. + if (!PathResolver.TryGetProcessImagePath((int)processId, out processImagePath)) + { + processImagePath = null; + } + + try + { + using (Process p = Process.GetProcessById((int)processId)) + { + processName = p.ProcessName; + } + } + catch (InvalidOperationException) + { + // The process property is not defined because the process has exited or it does + // not have an identifier. + return false; + } + catch (ArgumentException) + { + // The process specified by processId is not running. + return false; + } + + return true; + } + } +} diff --git a/vibrance.GUI/common/IHotkeyRegistrar.cs b/vibrance.GUI/common/IHotkeyRegistrar.cs new file mode 100644 index 0000000..604b3cf --- /dev/null +++ b/vibrance.GUI/common/IHotkeyRegistrar.cs @@ -0,0 +1,76 @@ +using System; +using System.Runtime.InteropServices; + +namespace vibrance.GUI.common +{ + /// + /// What Register actually did. Not a bool: a caller needs to tell "another application + /// already owns this exact key combination" apart from every other failure, since only the + /// first one is worth a different message to the user (and, at Apply's own call site, a + /// retry without MOD_NOREPEAT is worth attempting for the other case - see + /// HotkeyRegistration.Apply). + /// + internal enum HotkeyRegistrationResult + { + // No binding was configured at all - Register was never even called. + NotConfigured, + Registered, + AlreadyOwnedByAnotherApplication, + Failed + } + + /// + /// The seam between HotkeyRegistration's lifecycle/retry logic and the real + /// RegisterHotKey/UnregisterHotKey Win32 calls - same shape as IDisplayModeDevice + /// (ResolutionHelper.cs) and IGammaDevice (DeviceGammaRampHelper.cs): RealHotkeyRegistrar + /// (below) is the only production implementation; ProfileToggleFixture drives + /// HotkeyRegistration entirely against its own fake, so the regression suite never calls + /// RegisterHotKey for real - see that fixture's own header comment. + /// + internal interface IHotkeyRegistrar + { + HotkeyRegistrationResult Register(IntPtr hWnd, int id, uint modifiers, uint virtualKey); + void Unregister(IntPtr hWnd, int id); + } + + /// + /// The only production IHotkeyRegistrar - RegisterHotKey/UnregisterHotKey against a real + /// window handle. + /// + internal class RealHotkeyRegistrar : IHotkeyRegistrar + { + // ERROR_HOTKEY_ALREADY_REGISTERED (winerror.h) - another process already owns this exact + // (modifiers, virtualKey) combination. + private const int ErrorHotkeyAlreadyRegistered = 1409; + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + public HotkeyRegistrationResult Register(IntPtr hWnd, int id, uint modifiers, uint virtualKey) + { + bool succeeded = RegisterHotKey(hWnd, id, modifiers, virtualKey); + // Captured on the line immediately after the call, before anything else can + // overwrite the thread's last error - same discipline as Program.cs's + // adapterDetectionWin32Error capture. + int win32Error = Marshal.GetLastWin32Error(); + + if (succeeded) + { + return HotkeyRegistrationResult.Registered; + } + if (win32Error == ErrorHotkeyAlreadyRegistered) + { + return HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication; + } + return HotkeyRegistrationResult.Failed; + } + + public void Unregister(IntPtr hWnd, int id) + { + UnregisterHotKey(hWnd, id); + } + } +} diff --git a/vibrance.GUI/common/ISettingsController.cs b/vibrance.GUI/common/ISettingsController.cs index e62760d..57fa6e0 100644 --- a/vibrance.GUI/common/ISettingsController.cs +++ b/vibrance.GUI/common/ISettingsController.cs @@ -10,6 +10,10 @@ bool SetVibranceSettings(string windowsLevel, string affectPrimaryMonitorOnly, s bool SetVibranceSetting(string szKeyName, string value); GraphicsAdapter ReadGraphicsAdapterPreference(); bool SetGraphicsAdapterPreference(GraphicsAdapter graphicsAdapter); + string ReadToggleHotkey(); + bool SetToggleHotkey(string canonicalText); + bool ReadToggleHotkeyEnabled(); + bool SetToggleHotkeyEnabled(bool enabled); void ReadVibranceSettings(GraphicsAdapter graphicsAdapter, out int vibranceWindowsLevel, out bool affectPrimaryMonitorOnly, out bool neverSwitchResolution, out bool neverChangeColorSettings, out List applicationSettings, out int brightnessWindowsLevel, out int contrastWindowsLevel, out int gammaWindowsLevel); } diff --git a/vibrance.GUI/common/IVibranceProxy.cs b/vibrance.GUI/common/IVibranceProxy.cs index efe55c2..5aa75ff 100644 --- a/vibrance.GUI/common/IVibranceProxy.cs +++ b/vibrance.GUI/common/IVibranceProxy.cs @@ -1,8 +1,33 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using vibrance.GUI.NVIDIA; namespace vibrance.GUI.common { + /// + /// What ToggleForegroundProfile actually did - see IVibranceProxy.ToggleForegroundProfile's + /// own comment for the full contract. Public, not internal, because IVibranceProxy itself is + /// public and every type any of its members exposes has to be at least as accessible + /// (VibranceInfo and GraphicsAdapter are both public for the same reason). + /// + public enum ProfileToggleResult + { + // No configured profile matches the foreground window at all - a silent no-op. + NoConfiguredGameInForeground, + // A profile matched, but userVibranceSettingDefault is not known yet - a silent no-op, + // exactly as if nothing had matched (see VibranceInfo.isWindowsLevelKnown). + EngineNotReady, + // The matched profile is now running its game level again (it was suppressed before this + // call). + ToggledOn, + // The matched profile is now suppressed, forced to the Windows level (it was running + // normally before this call). + ToggledOff, + // A profile matched and was ready, but the write itself failed - suppression state is + // left exactly as it was; the caller may retry by pressing the hotkey again. + WriteFailed + } + public interface IVibranceProxy { void SetApplicationSettings(List refApplicationSettings); @@ -21,5 +46,17 @@ public interface IVibranceProxy void SetWindowsColorBrightness(int brightness); void SetWindowsColorContrast(int contrast); void SetWindowsColorGamma(int gamma); + + /// + /// Looks up whichever configured profile currently owns foregroundWindow + /// (ApplicationSettingMatcher.FindMatch, the same match rule the automatic WinEvent + /// handler uses) and flips it between its game level and the Windows level - see + /// ProfileToggleHelper.Decide for the pure decision this method turns into an actual + /// write. No match, or a profile matched too early for userVibranceSettingDefault to mean + /// anything yet, is a silent no-op: zero writes, suppression state untouched. The write + /// happens BEFORE suppression state ever flips - a failed write never leaves the engine + /// thinking a toggle landed that did not. + /// + ProfileToggleResult ToggleForegroundProfile(IntPtr foregroundWindow, string processName, string processImagePath); } } \ No newline at end of file diff --git a/vibrance.GUI/common/ProfileToggleFixture.cs b/vibrance.GUI/common/ProfileToggleFixture.cs new file mode 100644 index 0000000..0dded6b --- /dev/null +++ b/vibrance.GUI/common/ProfileToggleFixture.cs @@ -0,0 +1,1607 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Serialization; +using System.Windows.Forms; +using vibrance.GUI.AMD; +using vibrance.GUI.AMD.vendor; +using vibrance.GUI.NVIDIA; + +namespace vibrance.GUI.common +{ + /// + /// Regression coverage for the toggle hotkey feature (upstream #143, per-game suppression): + /// HotkeyBindingParser's parse/format round trip, HotkeyRegistration's release-then-register + /// lifecycle (R3 in particular - pins the release-then-register ordering across a handle + /// change, see HotkeyRegistration.Release's own comment for why the handle is cached rather + /// than read fresh), ProfileToggleHelper.Decide's pure decision, the per-game suppression gate both + /// vendor proxies' OnWinEventHook now open with, ToggleForegroundProfile's actual write, and + /// the settings round trip. No GUI, no live GPU driver, and - unlike every check here that + /// reflects into a real OnWinEventHook/ToggleForegroundProfile - this fixture NEVER calls the + /// real RegisterHotKey and must never grow a hardware variant: IHotkeyRegistrar is driven + /// exclusively through FakeHotkeyRegistrar below, exactly as VibranceRestoreFixture never + /// touches a real GPU and ResolutionChangeFixture never touches a real display. Run by + /// vibrance.GUI.exe --selftest-profiletoggle. + /// + public static class ProfileToggleFixture + { + [DllImport("user32.dll")] + private static extern IntPtr GetDesktopWindow(); + + public static List Run() + { + Checklist checklist = new Checklist(); + checklist.Lines.Add("vibranceGUI toggle hotkey self test"); + checklist.Lines.Add(string.Empty); + + RunParseFormatChecks(checklist); + RunRegistrationChecks(checklist); + RunDecideChecks(checklist); + RunToggleEffectChecks(checklist); + RunSuppressionGateChecks(checklist); + RunSuppressionCleanupChecks(checklist); + RunSettingsChecks(checklist); + + checklist.Lines.Add(string.Empty); + checklist.Lines.Add(string.Format("PASSED {0}/{1}", checklist.Passed, checklist.Total)); + return checklist.Lines; + } + + // ------------------------------------------------------------------ + // HotkeyBindingParser - pure, no registrar, no proxy. Unchanged from the discarded global- + // pause design; kept verbatim per the architect's instruction that this half survives. + // ------------------------------------------------------------------ + + private static void RunParseFormatChecks(Checklist checklist) + { + checklist.Lines.Add("HotkeyBindingParser.TryParse/Format (pure):"); + + CheckParseBasic(checklist); + CheckRoundTrip(checklist); + CheckInvalidInputsRejected(checklist); + CheckUnrecognisedTokenRejected(checklist); + CheckNumericTokenRejected(checklist); + CheckFormatNone(checklist); + CheckCaseInsensitiveParseNormalisedFormat(checklist); + CheckNoRepeatNeverFormatted(checklist); + + checklist.Lines.Add(string.Empty); + } + + // H1. Mutation this guards: drop "| ModNoRepeat" from TryParse. + private static void CheckParseBasic(Checklist checklist) + { + HotkeyBinding binding; + bool ok = HotkeyBindingParser.TryParse("Ctrl+Alt+F9", out binding); + const uint expectedModifiers = 0x0002 /* MOD_CONTROL */ | 0x0001 /* MOD_ALT */ | 0x4000 /* MOD_NOREPEAT */; + const uint expectedVirtualKey = 0x78; // VK_F9 + + checklist.Check(ok && binding.IsSet && binding.Modifiers == expectedModifiers && binding.VirtualKey == expectedVirtualKey, + string.Format("H1: \"Ctrl+Alt+F9\" parses to Modifiers=0x{0:X} (MOD_CONTROL|MOD_ALT|MOD_NOREPEAT), VirtualKey=0x{1:X} (VK_F9), got ok={2} Modifiers=0x{3:X} VirtualKey=0x{4:X}", + expectedModifiers, expectedVirtualKey, ok, binding.Modifiers, binding.VirtualKey)); + } + + // H2. Mutation this guards: emit the modifiers in TryParse's own read order instead of a + // fixed Ctrl/Alt/Shift/Win order in Format. + private static readonly string[] CanonicalStrings = + { + "F9", + "Ctrl+F9", + "Alt+F5", + "Shift+F12", + "Win+D1", + "Ctrl+Alt+Shift+Win+F9" + }; + + private static void CheckRoundTrip(Checklist checklist) + { + bool allRoundTrip = true; + string firstMismatch = null; + foreach (string canonical in CanonicalStrings) + { + HotkeyBinding binding; + bool parsed = HotkeyBindingParser.TryParse(canonical, out binding); + string formatted = HotkeyBindingParser.Format(binding); + if (!parsed || formatted != canonical) + { + allRoundTrip = false; + firstMismatch = string.Format("\"{0}\" -> parsed={1}, formatted=\"{2}\"", canonical, parsed, formatted); + break; + } + } + + checklist.Check(allRoundTrip, allRoundTrip + ? "H2: Format(TryParse(s)) == s over all 6 canonical strings" + : "H2: Format(TryParse(s)) == s over all 6 canonical strings - first mismatch: " + firstMismatch); + } + + // H3. Mutation this guards: drop the "no key at all" guard, letting "Ctrl" parse as a + // legal binding. + private static void CheckInvalidInputsRejected(Checklist checklist) + { + HotkeyBinding binding; + bool emptyOk = HotkeyBindingParser.TryParse("", out binding); + bool nullOk = HotkeyBindingParser.TryParse(null, out binding); + bool ctrlOnlyOk = HotkeyBindingParser.TryParse("Ctrl", out binding); + bool trailingSeparatorOk = HotkeyBindingParser.TryParse("Ctrl+", out binding); + + checklist.Check(!emptyOk && !nullOk && !ctrlOnlyOk && !trailingSeparatorOk, + string.Format("H3: \"\", null, \"Ctrl\", \"Ctrl+\" all fail to parse, got empty={0} null={1} ctrlOnly={2} trailingSeparator={3}", + emptyOk, nullOk, ctrlOnlyOk, trailingSeparatorOk)); + } + + // H4. Mutation this guards: silently drop an unrecognised token instead of failing. + private static void CheckUnrecognisedTokenRejected(Checklist checklist) + { + HotkeyBinding binding; + bool ok = HotkeyBindingParser.TryParse("Ctrl+Zzz+F9", out binding); + + checklist.Check(!ok, string.Format( + "H4: \"Ctrl+Zzz+F9\" fails to parse - an unrecognised token is an error, never silently dropped, got {0}", ok)); + } + + // H5. Mutation this guards: drop the numeric-token guard, letting Enum.TryParse + // turn "1" into Keys.LButton. + private static void CheckNumericTokenRejected(Checklist checklist) + { + HotkeyBinding numericBinding; + bool numericOk = HotkeyBindingParser.TryParse("Ctrl+1", out numericBinding); + + HotkeyBinding namedDigitBinding; + bool namedDigitOk = HotkeyBindingParser.TryParse("Ctrl+D1", out namedDigitBinding); + + checklist.Check(!numericOk, + string.Format("H5a: \"Ctrl+1\" fails to parse - a purely numeric token is never a valid key name on its own, got {0}", numericOk)); + checklist.Check(namedDigitOk && namedDigitBinding.VirtualKey == 0x31, + string.Format("H5b: \"Ctrl+D1\" parses with VirtualKey=0x31 (VK_1), got ok={0} VirtualKey=0x{1:X}", namedDigitOk, namedDigitBinding.VirtualKey)); + } + + // H6. Mutation this guards: return a non-empty placeholder for an unset binding instead + // of "". + private static void CheckFormatNone(Checklist checklist) + { + string formatted = HotkeyBindingParser.Format(HotkeyBinding.None); + checklist.Check(formatted == string.Empty, string.Format("H6: Format(HotkeyBinding.None) == \"\", got \"{0}\"", formatted)); + } + + // H7. Mutation this guards: compare tokens with StringComparison.Ordinal (case-sensitive) + // instead of OrdinalIgnoreCase. + private static void CheckCaseInsensitiveParseNormalisedFormat(Checklist checklist) + { + HotkeyBinding binding; + bool ok = HotkeyBindingParser.TryParse("ctrl+alt+f9", out binding); + string formatted = HotkeyBindingParser.Format(binding); + + checklist.Check(ok && formatted == "Ctrl+Alt+F9", + string.Format("H7: \"ctrl+alt+f9\" parses and formats back as the normalised \"Ctrl+Alt+F9\", got ok={0} formatted=\"{1}\"", ok, formatted)); + } + + // H8. Mutation this guards: add MOD_NOREPEAT to the list of modifier parts Format emits. + private static void CheckNoRepeatNeverFormatted(Checklist checklist) + { + HotkeyBinding binding; + HotkeyBindingParser.TryParse("Ctrl+F9", out binding); + bool noRepeatSetInternally = (binding.Modifiers & HotkeyBindingParser.ModNoRepeat) != 0; + string formatted = HotkeyBindingParser.Format(binding); + + checklist.Check(noRepeatSetInternally && formatted == "Ctrl+F9", + string.Format("H8: MOD_NOREPEAT is set internally (Modifiers=0x{0:X}) but the formatted text is exactly \"Ctrl+F9\", got \"{1}\"", + binding.Modifiers, formatted)); + } + + // ------------------------------------------------------------------ + // HotkeyRegistration - against FakeHotkeyRegistrar, never RegisterHotKey. R1-R8 unchanged + // from the discarded design; R9/R10 are new, covering the checkbox-gated registration + // expression VibranceGUI.ApplyToggleHotkey now uses. + // ------------------------------------------------------------------ + + private static void RunRegistrationChecks(Checklist checklist) + { + checklist.Lines.Add("HotkeyRegistration.Apply/Release (via FakeHotkeyRegistrar, never the real RegisterHotKey):"); + + CheckApplyNoneMakesNoRegisterCall(checklist); + CheckApplyValidBindingRegistersOnce(checklist); + CheckApplyOnNewHandleReleasesThePriorOne(checklist); + CheckApplyPropagatesAlreadyOwned(checklist); + CheckReleaseAfterFailedApplyMakesNoUnregisterCall(checklist); + CheckReleaseOnFreshInstanceMakesNoCalls(checklist); + CheckApplyNoneAfterSuccessReleasesWithoutRegistering(checklist); + CheckModNoRepeatCompatibilityRetry(checklist); + CheckCheckboxOffSuppressesRegistrationOfAValidBinding(checklist); + CheckCheckboxOnWithNoBindingMakesNoCall(checklist); + CheckShouldReleaseHotkeyOnFocusTransition(checklist); + + checklist.Lines.Add(string.Empty); + } + + private static HotkeyBinding ParseOrThrow(string canonicalText) + { + HotkeyBinding binding; + if (!HotkeyBindingParser.TryParse(canonicalText, out binding)) + { + throw new InvalidOperationException("Fixture setup error: \"" + canonicalText + "\" failed to parse"); + } + return binding; + } + + // R1. Mutation this guards: call _registrar.Register before checking binding.IsSet. + private static void CheckApplyNoneMakesNoRegisterCall(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + + HotkeyRegistrationResult result = registration.Apply((IntPtr)1, HotkeyBinding.None); + + checklist.Check(result == HotkeyRegistrationResult.NotConfigured && registrar.RegisterCalls.Count == 0, + string.Format("R1: Apply(h, None) returns NotConfigured with zero Register calls, got result={0} registerCalls={1}", + result, registrar.RegisterCalls.Count)); + } + + // R2 (PIN - not regression evidence, the ordinary successful path). + private static void CheckApplyValidBindingRegistersOnce(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + HotkeyBinding binding = ParseOrThrow("Ctrl+Alt+F9"); + + HotkeyRegistrationResult result = registration.Apply((IntPtr)1, binding); + + checklist.Check(result == HotkeyRegistrationResult.Registered && registrar.RegisterCalls.Count == 1 && registration.IsRegistered, + string.Format("R2 (pin): Apply(h, validBinding) makes exactly one Register call and returns Registered, got result={0} registerCalls={1} isRegistered={2}", + result, registrar.RegisterCalls.Count, registration.IsRegistered)); + } + + // R3, the highest-value check in this file. Mutation this guards: have Release() (called + // from inside Apply) read a fresh "hWnd" parameter instead of the handle cached at + // registration time. Pins the release-then-register ordering across a handle change - + // see HotkeyRegistration.Release's own comment for why caching it is the defensive + // choice regardless of whether anything in this codebase currently recreates the handle. + private static void CheckApplyOnNewHandleReleasesThePriorOne(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + IntPtr h1 = (IntPtr)111; + IntPtr h2 = (IntPtr)222; + HotkeyBinding a = ParseOrThrow("Ctrl+F9"); + HotkeyBinding b = ParseOrThrow("Alt+F10"); + + registration.Apply(h1, a); + registration.Apply(h2, b); + + bool sequenceMatches = registrar.RegisterCalls.Count == 2 && + registrar.RegisterCalls[0].HWnd == h1 && + registrar.RegisterCalls[1].HWnd == h2 && + registrar.UnregisterCalls.Count == 1 && + registrar.UnregisterCalls[0] == h1; + + checklist.Check(sequenceMatches, string.Format( + "R3: Apply(h1,a) then Apply(h2,b) records Register(h1), Unregister(h1), Register(h2) in that order - the release-then-register ordering across a handle change - got {0} Register call(s) on [{1}], {2} Unregister call(s) on [{3}]", + registrar.RegisterCalls.Count, DescribeHandles(registrar.RegisterCalls), + registrar.UnregisterCalls.Count, DescribeHandles(registrar.UnregisterCalls))); + } + + private static string DescribeHandles(List calls) + { + List parts = new List(); + foreach (FakeHotkeyRegistrar.RegisterCall call in calls) + { + parts.Add(call.HWnd.ToString()); + } + return string.Join(",", parts.ToArray()); + } + + private static string DescribeHandles(List handles) + { + List parts = new List(); + foreach (IntPtr handle in handles) + { + parts.Add(handle.ToString()); + } + return string.Join(",", parts.ToArray()); + } + + // R4. Mutation this guards: treat AlreadyOwnedByAnotherApplication the same as Registered. + private static void CheckApplyPropagatesAlreadyOwned(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + registrar.QueueResult(HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + HotkeyBinding binding = ParseOrThrow("Ctrl+F9"); + + HotkeyRegistrationResult result = registration.Apply((IntPtr)1, binding); + + checklist.Check(result == HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication && !registration.IsRegistered, + string.Format("R4: AlreadyOwnedByAnotherApplication propagates and IsRegistered stays false, got result={0} isRegistered={1}", + result, registration.IsRegistered)); + } + + // R5. Mutation this guards: drop Release's "if (!_isRegistered) return;" guard. + private static void CheckReleaseAfterFailedApplyMakesNoUnregisterCall(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + registrar.QueueResult(HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + registration.Apply((IntPtr)1, ParseOrThrow("Ctrl+F9")); + + registration.Release(); + + checklist.Check(registrar.UnregisterCalls.Count == 0, + string.Format("R5: Release() after a failed Apply makes zero Unregister calls, got {0}", registrar.UnregisterCalls.Count)); + } + + // R6. Same guard as R5, exercised on a completely untouched instance. + private static void CheckReleaseOnFreshInstanceMakesNoCalls(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + + registration.Release(); + + checklist.Check(registrar.RegisterCalls.Count == 0 && registrar.UnregisterCalls.Count == 0, + string.Format("R6: Release() on a fresh instance makes zero calls, got register={0} unregister={1}", + registrar.RegisterCalls.Count, registrar.UnregisterCalls.Count)); + } + + // R7. Mutation this guards: still call Register with a zeroed binding when applying None. + private static void CheckApplyNoneAfterSuccessReleasesWithoutRegistering(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + IntPtr h = (IntPtr)1; + registration.Apply(h, ParseOrThrow("Ctrl+F9")); + int registerCallsBefore = registrar.RegisterCalls.Count; + + HotkeyRegistrationResult result = registration.Apply(h, HotkeyBinding.None); + + checklist.Check(result == HotkeyRegistrationResult.NotConfigured && + registrar.UnregisterCalls.Count == 1 && + registrar.RegisterCalls.Count == registerCallsBefore, + string.Format("R7: Apply(h, None) after a success makes one Unregister call and zero new Register calls, got result={0} unregisterCalls={1} newRegisterCalls={2}", + result, registrar.UnregisterCalls.Count, registrar.RegisterCalls.Count - registerCallsBefore)); + } + + // R8. Mutation this guards: drop the MOD_NOREPEAT compatibility retry entirely. + private static void CheckModNoRepeatCompatibilityRetry(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + registrar.QueueResult(HotkeyRegistrationResult.Failed); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + HotkeyBinding binding = ParseOrThrow("Ctrl+F9"); + + HotkeyRegistrationResult result = registration.Apply((IntPtr)1, binding); + + bool secondCallDroppedNoRepeat = registrar.RegisterCalls.Count == 2 && + (registrar.RegisterCalls[0].Modifiers & HotkeyBindingParser.ModNoRepeat) != 0 && + (registrar.RegisterCalls[1].Modifiers & HotkeyBindingParser.ModNoRepeat) == 0; + + checklist.Check(result == HotkeyRegistrationResult.Registered && secondCallDroppedNoRepeat, + string.Format("R8: a Failed first Register call (MOD_NOREPEAT set) retries exactly once more without MOD_NOREPEAT and succeeds, got result={0} registerCalls={1}", + result, registrar.RegisterCalls.Count)); + } + + // R9. Mutation this guards: gate registration on binding.IsSet alone, ignoring "enabled" - + // the checkbox-off state VibranceGUI.ApplyToggleHotkey must respect. Drives the REAL + // production gate, HotkeyRegistration.EffectiveBinding - not a fixture-local copy of it; + // VibranceGUI.ApplyToggleHotkey calls this exact static method, so a mutation here is + // visible to the actual code path, not just to a mirror of it. + private static void CheckCheckboxOffSuppressesRegistrationOfAValidBinding(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + HotkeyBinding validBinding = ParseOrThrow("Ctrl+Alt+F9"); + + HotkeyBinding effective = HotkeyRegistration.EffectiveBinding(false, validBinding); + HotkeyRegistrationResult result = registration.Apply((IntPtr)1, effective); + + checklist.Check(result == HotkeyRegistrationResult.NotConfigured && registrar.RegisterCalls.Count == 0, + string.Format("R9: checkbox off + a valid saved combination makes zero Register calls, got result={0} registerCalls={1}", + result, registrar.RegisterCalls.Count)); + } + + // R10. Mutation this guards: throw (e.g. dereferencing a field on HotkeyBinding.None + // incorrectly) instead of cleanly returning NotConfigured when enabled but unbound. Same + // real-gate note as R9 above. + private static void CheckCheckboxOnWithNoBindingMakesNoCall(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + + bool threw = false; + HotkeyRegistrationResult result = HotkeyRegistrationResult.Failed; + try + { + HotkeyBinding effective = HotkeyRegistration.EffectiveBinding(true, HotkeyBinding.None); + result = registration.Apply((IntPtr)1, effective); + } + catch (Exception) + { + threw = true; + } + + checklist.Check(!threw && result == HotkeyRegistrationResult.NotConfigured && registrar.RegisterCalls.Count == 0, + string.Format("R10: checkbox on + no combination makes zero calls and never throws, got threw={0} result={1} registerCalls={2}", + threw, result, registrar.RegisterCalls.Count)); + } + + // B1. Drives VibranceGUI.ShouldReleaseHotkeyOnFocusTransition directly (same assembly, + // internal - no reflection needed) rather than a real Form: VibranceGUI's own constructor + // calls getProxy(...), touching a real vendor proxy, and FormatterServices. + // GetUninitializedObject is not safe for a Form-derived type (WinForms/Control internal + // state that only the real constructor sets up). This is the condition + // OnDeactivate/OnActivated both call before touching the hotkey registration - a + // real Form correctly wiring WM_ACTIVATE to those two overrides is a WinForms framework + // fact, not something this harness can verify headlessly, but the DECISION they both make + // is real production code and is what this pins. + // + // Mutation this guards: invert the comparison (or hardcode true/false) so OnDeactivate/ + // OnActivated stop distinguishing "the capture box has focus" from "anything else does". + private static void CheckShouldReleaseHotkeyOnFocusTransition(Checklist checklist) + { + TextBox captureBox = new TextBox(); + TextBox otherControl = new TextBox(); + + bool trueForTheCaptureBoxItself = VibranceGUI.ShouldReleaseHotkeyOnFocusTransition(captureBox, captureBox); + bool falseForADifferentControl = !VibranceGUI.ShouldReleaseHotkeyOnFocusTransition(otherControl, captureBox); + bool falseForNoActiveControlAtAll = !VibranceGUI.ShouldReleaseHotkeyOnFocusTransition(null, captureBox); + + checklist.Check(trueForTheCaptureBoxItself && falseForADifferentControl && falseForNoActiveControlAtAll, + string.Format("B1: ShouldReleaseHotkeyOnFocusTransition is true only when the capture box itself is the active control - got captureBox={0}, otherControl={1}, null={2}", + trueForTheCaptureBoxItself, !falseForADifferentControl, !falseForNoActiveControlAtAll)); + } + + // ------------------------------------------------------------------ + // ProfileToggleHelper.Decide - pure, no device, no Screen, no OS call. + // ------------------------------------------------------------------ + + private static void RunDecideChecks(Checklist checklist) + { + checklist.Lines.Add("ProfileToggleHelper.Decide (pure):"); + + CheckDecideMatchesByInstallDirectory(checklist); + CheckDecideNoMatch(checklist); + CheckDecideEmptyAndNullLists(checklist); + CheckDecideEngineNotReady(checklist); + CheckDecideDirectionBothWays(checklist); + CheckDecideMutatesNothing(checklist); + CheckDecideSuppressionIsCaseInsensitive(checklist); + CheckDecideNameBeatsLongerDirectoryMatch(checklist); + + checklist.Lines.Add(string.Empty); + } + + // D1. Mutation this guards: match on setting.Name alone, ignoring processImagePath - PR + // #153's bug. A profile the game finder only ever confirmed by install directory (a + // guessed executable, or a launcher/anti-cheat shim in the foreground instead of the game + // itself) must be just as reachable by the hotkey as by the automatic apply path. + private static void CheckDecideMatchesByInstallDirectory(Checklist checklist) + { + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "RealGameExeName"; + setting.InstallDirectory = "C:\\Games\\SomeGame"; + setting.IngameLevel = 40; + List settings = new List { setting }; + + ProfileToggleDecision decision = ProfileToggleHelper.Decide( + settings, "launcher", "C:\\Games\\SomeGame\\bin\\launcher.exe", true); + + checklist.Check(decision.Action == ProfileToggleAction.RestoreWindowsLevel && decision.Setting == setting, + string.Format("D1: a process whose name does not match but whose image path sits under the setting's InstallDirectory is still found, got Action={0}", + decision.Action)); + } + + // D2. Mutation this guards: return something other than None (e.g. throw, or default to + // the first setting) when nothing matches at all. + private static void CheckDecideNoMatch(Checklist checklist) + { + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "SomeGame"; + List settings = new List { setting }; + + ProfileToggleDecision decision = ProfileToggleHelper.Decide(settings, "UnrelatedProcess", null, true); + + checklist.Check(decision.Action == ProfileToggleAction.None, + string.Format("D2: no match at all -> None, got {0}", decision.Action)); + } + + // D3. Mutation this guards: dereference the list without a null check. + private static void CheckDecideEmptyAndNullLists(Checklist checklist) + { + bool threw = false; + ProfileToggleAction emptyAction = ProfileToggleAction.None; + ProfileToggleAction nullAction = ProfileToggleAction.None; + try + { + emptyAction = ProfileToggleHelper.Decide(new List(), "Anything", null, true).Action; + nullAction = ProfileToggleHelper.Decide(null, "Anything", null, true).Action; + } + catch (Exception) + { + threw = true; + } + + checklist.Check(!threw && emptyAction == ProfileToggleAction.None && nullAction == ProfileToggleAction.None, + string.Format("D3: an empty list and a null list both yield None with no throw, got threw={0} emptyAction={1} nullAction={2}", + threw, emptyAction, nullAction)); + } + + // D4. Mutation this guards: skip the isWindowsLevelKnown check and fall through to a + // real Action even when the Windows level is still the struct default 0 - reopens + // issue #60/#36 through the toggle's own door. + private static void CheckDecideEngineNotReady(Checklist checklist) + { + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "SomeGame"; + List settings = new List { setting }; + + ProfileToggleDecision decision = ProfileToggleHelper.Decide(settings, "SomeGame", null, false); + + checklist.Check(decision.Action == ProfileToggleAction.EngineNotReady, + string.Format("D4: a clean Name match with isWindowsLevelKnown false -> EngineNotReady, not a write action, got {0}", decision.Action)); + } + + // D5. Mutation this guards: invert (or hard-code) the direction rule. + private static void CheckDecideDirectionBothWays(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "SomeGameD5"; + List settings = new List { setting }; + + ProfileToggleDecision notSuppressed = ProfileToggleHelper.Decide(settings, "SomeGameD5", null, true); + ProfileToggleHelper.SetSuppressed("SomeGameD5", true); + ProfileToggleDecision suppressed = ProfileToggleHelper.Decide(settings, "SomeGameD5", null, true); + + checklist.Check(notSuppressed.Action == ProfileToggleAction.RestoreWindowsLevel && suppressed.Action == ProfileToggleAction.ApplyGameLevel, + string.Format("D5: not suppressed -> RestoreWindowsLevel, suppressed -> ApplyGameLevel, got notSuppressed={0} suppressed={1}", + notSuppressed.Action, suppressed.Action)); + + ProfileToggleHelper.ResetForTests(); + } + + // D6. Mutation this guards: have Decide itself call SetSuppressed (or otherwise mutate + // state) instead of leaving the flip to the caller, after a confirmed write. + private static void CheckDecideMutatesNothing(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "SomeGameD6"; + List settings = new List { setting }; + + int countBefore = ProfileToggleHelper.SuppressedCount; + ProfileToggleAction first = ProfileToggleHelper.Decide(settings, "SomeGameD6", null, true).Action; + ProfileToggleAction second = ProfileToggleHelper.Decide(settings, "SomeGameD6", null, true).Action; + ProfileToggleAction third = ProfileToggleHelper.Decide(settings, "SomeGameD6", null, true).Action; + int countAfter = ProfileToggleHelper.SuppressedCount; + + checklist.Check(countBefore == countAfter && first == second && second == third, + string.Format("D6: three identical Decide calls leave SuppressedCount unchanged ({0} -> {1}) and return the same Action every time ({2},{3},{4})", + countBefore, countAfter, first, second, third)); + } + + // D7. Mutation this guards: compare suppressed names with StringComparison.Ordinal. + private static void CheckDecideSuppressionIsCaseInsensitive(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "SomeGameD7"; + List settings = new List { setting }; + + ProfileToggleHelper.SetSuppressed("somegamed7", true); + ProfileToggleDecision decision = ProfileToggleHelper.Decide(settings, "SomeGameD7", null, true); + + checklist.Check(decision.Action == ProfileToggleAction.ApplyGameLevel, + string.Format("D7: suppressing \"somegamed7\" (different case) still suppresses the setting named \"SomeGameD7\", got {0}", decision.Action)); + + ProfileToggleHelper.ResetForTests(); + } + + // D8 (PIN). A Name match beats a longer directory match belonging to a DIFFERENT setting - + // proves Decide's pass-through of ApplicationSettingMatcher.FindMatch's own two-pass rule, + // not a Decide-specific behaviour. + private static void CheckDecideNameBeatsLongerDirectoryMatch(Checklist checklist) + { + ApplicationSetting nameMatch = new ApplicationSetting(); + nameMatch.Name = "TargetProcess"; + nameMatch.InstallDirectory = null; + + ApplicationSetting directoryMatch = new ApplicationSetting(); + directoryMatch.Name = "SomeOtherName"; + directoryMatch.InstallDirectory = "C:\\Games\\SomeGame\\A\\Much\\Longer\\Nested\\Directory"; + + List settings = new List { directoryMatch, nameMatch }; + + ProfileToggleDecision decision = ProfileToggleHelper.Decide( + settings, "TargetProcess", "C:\\Games\\SomeGame\\A\\Much\\Longer\\Nested\\Directory\\TargetProcess.exe", true); + + checklist.Check(decision.Setting == nameMatch, + "D8 (pin): an exact Name match wins over a longer InstallDirectory match belonging to a different setting"); + } + + // ------------------------------------------------------------------ + // ToggleForegroundProfile's actual write - both vendors, via ResetForTests/fake + // adapters/GetUninitializedObject. NVIDIA's ToggleForegroundProfile is an instance method + // purely because IVibranceProxy requires one; it (like OnWinEventHook) touches only this + // class's own static state, so GetUninitializedObject hands back a target to invoke it + // against without ever running the real constructor (which would call vibranceDLL.dll's + // initializeLibrary()). AMD's own state is per-instance, so its checks construct a real + // proxy around a fake IAmdAdapter instead (IAmdAdapter.IsAvailable() returning false keeps + // the constructor from installing a real, process-lifetime SetWinEventHook). + // ------------------------------------------------------------------ + + private static void RunToggleEffectChecks(Checklist checklist) + { + checklist.Lines.Add("ToggleForegroundProfile's write (both vendors, via fakes):"); + + CheckNvidiaToggleWritesOnlyTheGamesOwnDisplay(checklist); + CheckAmdToggleNeverWidensToAllDisplays(checklist); + CheckNvidiaToggleNoMatchMakesNoCallsOrStateChange(checklist); + CheckAmdToggleNoMatchMakesNoCallsOrStateChange(checklist); + CheckNvidiaToggleOnWriteFailureLeavesStateUnchanged(checklist); + CheckAmdToggleOnWriteFailureLeavesStateUnchanged(checklist); + CheckNvidiaToggleOffWriteFailureLeavesStateUnchanged(checklist); + CheckAmdToggleOffWriteFailureLeavesStateUnchanged(checklist); + CheckNvidiaToggleOffThenOnRoundTrip(checklist); + CheckNvidiaToggleOffOfNeverAppliedGameStillSucceeds(checklist); + CheckAmdToggleOffRespectsWideMode(checklist); + CheckAmdToggleOnRespectsWideMode(checklist); + + checklist.Lines.Add(string.Empty); + } + + private static object NewNvidiaInstance() + { + return FormatterServices.GetUninitializedObject(typeof(NvidiaDynamicVibranceProxy)); + } + + private static ProfileToggleResult InvokeNvidiaToggle(IntPtr hWnd, string processName, string processImagePath) + { + MethodInfo m = typeof(NvidiaDynamicVibranceProxy).GetMethod("ToggleForegroundProfile"); + return (ProfileToggleResult)m.Invoke(NewNvidiaInstance(), new object[] { hWnd, processName, processImagePath }); + } + + // T1, the check that matters most here. The work-list is seeded with BOTH dGame and + // dOther as GENUINELY held (VibranceRestoreHelper.RecordGameLevelApplied for both) - + // seeding dOther as a real work-list entry, not an unused decoy, is what makes the + // widening mutation below actually produce a second write; a decoy never on the work-list + // would leave that wrong implementation passing by accident. + // + // Mutation this guards: route the toggle's restore write through + // RestoreWindowsVibranceLevel instead of RestoreOneDisplay - the former walks the WHOLE + // work-list plus the primary, so it would write dOther too. + private static void CheckNvidiaToggleWritesOnlyTheGamesOwnDisplay(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT1"; + matchingSetting.IngameLevel = 50; + List settings = new List { matchingSetting }; + + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 30; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + const string dOther = "\\\\.\\DISPLAY_TESTONLY_T1_OTHER"; + VibranceRestoreHelper.RecordGameLevelApplied(dGame); + VibranceRestoreHelper.RecordGameLevelApplied(dOther); + + ProfileToggleResult result = InvokeNvidiaToggle(desktop, "TestGameT1", null); + + bool witness1 = device.SetLevelCalls.Count == 1 && device.SetLevelCalls[0] == device.HandleFor(dGame); + bool witness2 = device.ResolvedDeviceNames.Count == 1 && device.ResolvedDeviceNames[0] == dGame; + bool witness3 = VibranceRestoreHelper.HoldingCount == 1; + + checklist.Check(result == ProfileToggleResult.ToggledOff && witness1 && witness2 && witness3, + string.Format("T1: toggling off writes ONLY the game's own display (1 SetLevel call on dGame, 1 resolved device, dOther left on the work-list -> HoldingCount 1), got result={0} SetLevelCalls={1} ResolvedDeviceNames={2} HoldingCount={3}", + result, device.SetLevelCalls.Count, device.ResolvedDeviceNames.Count, VibranceRestoreHelper.HoldingCount)); + } + + // T2, AMD's counterpart. Mutation this guards: route the toggle's restore write through + // SetSaturationOnAllDisplays (the "likeliest wrong implementation") instead of + // SetSaturationOnDisplay(vibranceLevel, deviceName). + private static void CheckAmdToggleNeverWidensToAllDisplays(Checklist checklist) + { + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT2"; + matchingSetting.IngameLevel = 200; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + proxy.SetVibranceWindowsLevel(90); + // Narrow mode - B2 (off)/(on) below cover the wide-mode branch this proxy also has to + // respect; this check is specifically about the narrow, single-display overload. + proxy.SetAffectPrimaryMonitorOnly(true); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + + ProfileToggleResult result = proxy.ToggleForegroundProfile(desktop, "TestGameT2", null); + + checklist.Check(result == ProfileToggleResult.ToggledOff && + adapter.SetSaturationOnAllDisplaysCallCount == 0 && + adapter.SetSaturationOnDisplayNames.Count == 1 && adapter.SetSaturationOnDisplayNames[0] == dGame, + string.Format("T2: toggling off an AMD profile never calls SetSaturationOnAllDisplays (got {0} call(s)) and writes SetSaturationOnDisplay exactly once, to the game's own display, got result={1} perDisplayCalls={2}", + adapter.SetSaturationOnAllDisplaysCallCount, result, adapter.SetSaturationOnDisplayNames.Count)); + } + + // T3n. Mutation this guards: fall through to some write even when Decide returned None. + private static void CheckNvidiaToggleNoMatchMakesNoCallsOrStateChange(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 30; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, new List()); + + ProfileToggleResult result = InvokeNvidiaToggle(GetDesktopWindow(), "SomeOtherProcessT3", null); + + checklist.Check(result == ProfileToggleResult.NoConfiguredGameInForeground && device.TotalCallCount == 0 && ProfileToggleHelper.SuppressedCount == 0, + string.Format("T3n: no configured game in the foreground makes zero device calls and zero suppression-state change, got result={0} calls={1} suppressedCount={2}", + result, device.TotalCallCount, ProfileToggleHelper.SuppressedCount)); + } + + // T3a, AMD's counterpart. + private static void CheckAmdToggleNoMatchMakesNoCallsOrStateChange(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, new List()); + proxy.SetVibranceWindowsLevel(90); + + ProfileToggleResult result = proxy.ToggleForegroundProfile(GetDesktopWindow(), "SomeOtherProcessT3", null); + + checklist.Check(result == ProfileToggleResult.NoConfiguredGameInForeground && + adapter.SetSaturationOnAllDisplaysCallCount == 0 && adapter.SetSaturationOnDisplayNames.Count == 0 && + ProfileToggleHelper.SuppressedCount == 0, + string.Format("T3a: no configured game in the foreground makes zero adapter calls and zero suppression-state change, got result={0} allCalls={1} perDisplayCalls={2} suppressedCount={3}", + result, adapter.SetSaturationOnAllDisplaysCallCount, adapter.SetSaturationOnDisplayNames.Count, ProfileToggleHelper.SuppressedCount)); + } + + // T4n (ON-path failure). The game is already suppressed; toggling it back on fails to + // write - must report WriteFailed and leave it suppressed. Mutation this guards: flip + // suppression before checking the write's own return value. + private static void CheckNvidiaToggleOnWriteFailureLeavesStateUnchanged(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT4"; + matchingSetting.IngameLevel = 50; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 30; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + ProfileToggleHelper.SetSuppressed("TestGameT4", true); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + device.FailNextSetLevel(dGame); + + ProfileToggleResult result = InvokeNvidiaToggle(desktop, "TestGameT4", null); + + checklist.Check(result == ProfileToggleResult.WriteFailed && ProfileToggleHelper.IsSuppressed("TestGameT4"), + string.Format("T4n: a failed game-level write on the ON path returns WriteFailed and leaves the profile suppressed, got result={0} isSuppressed={1}", + result, ProfileToggleHelper.IsSuppressed("TestGameT4"))); + + ProfileToggleHelper.ResetForTests(); + } + + // T4a, AMD's counterpart. + private static void CheckAmdToggleOnWriteFailureLeavesStateUnchanged(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT4a"; + matchingSetting.IngameLevel = 210; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + proxy.SetVibranceWindowsLevel(90); + // Narrow mode - B2 (on) already covers the wide-mode write; this check is about the + // write-failure/state-preservation contract, not which overload gets called, so it is + // pinned to the mode whose failure injection targets a specific display name. + proxy.SetAffectPrimaryMonitorOnly(true); + ProfileToggleHelper.SetSuppressed("TestGameT4a", true); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + adapter.FailNextSetSaturationOnDisplay(dGame); + + ProfileToggleResult result = proxy.ToggleForegroundProfile(desktop, "TestGameT4a", null); + + checklist.Check(result == ProfileToggleResult.WriteFailed && ProfileToggleHelper.IsSuppressed("TestGameT4a"), + string.Format("T4a: a failed game-level write on the ON path returns WriteFailed and leaves the profile suppressed, got result={0} isSuppressed={1}", + result, ProfileToggleHelper.IsSuppressed("TestGameT4a"))); + + ProfileToggleHelper.ResetForTests(); + } + + // T5n (OFF-path failure). The game is running normally; toggling it off fails to write - + // must report WriteFailed and leave it un-suppressed. + private static void CheckNvidiaToggleOffWriteFailureLeavesStateUnchanged(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT5"; + matchingSetting.IngameLevel = 50; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 30; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + device.FailNextSetLevel(dGame); + + ProfileToggleResult result = InvokeNvidiaToggle(desktop, "TestGameT5", null); + + checklist.Check(result == ProfileToggleResult.WriteFailed && !ProfileToggleHelper.IsSuppressed("TestGameT5"), + string.Format("T5n: a failed Windows-level write on the OFF path returns WriteFailed and leaves the profile NOT suppressed, got result={0} isSuppressed={1}", + result, ProfileToggleHelper.IsSuppressed("TestGameT5"))); + } + + // T5a, AMD's counterpart. + private static void CheckAmdToggleOffWriteFailureLeavesStateUnchanged(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT5a"; + matchingSetting.IngameLevel = 210; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + proxy.SetVibranceWindowsLevel(90); + // Narrow mode - see the matching comment in CheckAmdToggleOnWriteFailureLeavesStateUnchanged. + proxy.SetAffectPrimaryMonitorOnly(true); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + adapter.FailNextSetSaturationOnDisplay(dGame); + + ProfileToggleResult result = proxy.ToggleForegroundProfile(desktop, "TestGameT5a", null); + + checklist.Check(result == ProfileToggleResult.WriteFailed && !ProfileToggleHelper.IsSuppressed("TestGameT5a"), + string.Format("T5a: a failed Windows-level write on the OFF path returns WriteFailed and leaves the profile NOT suppressed, got result={0} isSuppressed={1}", + result, ProfileToggleHelper.IsSuppressed("TestGameT5a"))); + + ProfileToggleHelper.ResetForTests(); + } + + // T6. A full off-then-on round trip: two writes, two different levels, ends un-suppressed. + private static void CheckNvidiaToggleOffThenOnRoundTrip(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT6"; + matchingSetting.IngameLevel = 55; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 20; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + + IntPtr desktop = GetDesktopWindow(); + + ProfileToggleResult offResult = InvokeNvidiaToggle(desktop, "TestGameT6", null); + ProfileToggleResult onResult = InvokeNvidiaToggle(desktop, "TestGameT6", null); + + checklist.Check(offResult == ProfileToggleResult.ToggledOff && onResult == ProfileToggleResult.ToggledOn && !ProfileToggleHelper.IsSuppressed("TestGameT6"), + string.Format("T6: off-then-on returns ToggledOff then ToggledOn and ends un-suppressed, got off={0} on={1} isSuppressed={2}", + offResult, onResult, ProfileToggleHelper.IsSuppressed("TestGameT6"))); + + bool sawBothLevels = device.SetLevelCalls.Count == 2; + checklist.Check(sawBothLevels, + string.Format("T6: exactly two SetLevel calls landed across the round trip (one per direction), got {0}", device.SetLevelCalls.Count)); + } + + // T8. A game that was suppressed WITHOUT ever having its game level actually applied + // (SetLevel never called for it before) still toggles off successfully via the read-back + // alone, when the display already happens to be at the Windows level. + private static void CheckNvidiaToggleOffOfNeverAppliedGameStillSucceeds(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT8"; + matchingSetting.IngameLevel = 50; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 30; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + device.SeedLevel(dGame, 30); // already at the Windows level - as if never actually applied to. + + ProfileToggleResult result = InvokeNvidiaToggle(desktop, "TestGameT8", null); + + checklist.Check(result == ProfileToggleResult.ToggledOff && device.SetLevelCalls.Count == 0 && ProfileToggleHelper.IsSuppressed("TestGameT8"), + string.Format("T8: toggling off a display already at the Windows level makes zero SetLevel calls (confirmed by read-back alone) but still reports ToggledOff and suppresses, got result={0} setLevelCalls={1} isSuppressed={2}", + result, device.SetLevelCalls.Count, ProfileToggleHelper.IsSuppressed("TestGameT8"))); + } + + // B2 (off). AMD's apply is NOT single-display with affectPrimaryMonitorOnly false (the + // DEFAULT) - unlike NVIDIA, it writes every attached screen (OnWinEventHook's own apply + // branch above). Mutation this guards: ignore the flag in the OFF direction and always + // write/clear only the one named display. Witnessed two ways: the adapter call itself + // must be the WIDE overload (SetSaturationOnDisplay(level, null), never + // SetSaturationOnAllDisplays - so the bool return still gets checked), and a work-list + // entry for a display OTHER than the resolved one must still be cleared - the narrow + // ClearGameLevelRecord(deviceName) would leave it behind, only the wide + // ClearAllGameLevelRecords() drains it. + private static void CheckAmdToggleOffRespectsWideMode(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + VibranceRestoreHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameB2Off"; + matchingSetting.IngameLevel = 220; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + // affectPrimaryMonitorOnly left at the struct default (false) - AMD's actual default, + // and the wide mode this check means to exercise. + proxy.SetVibranceWindowsLevel(90); + + const string otherDevice = "\\\\.\\DISPLAY_TESTONLY_B2OFF_OTHER"; + VibranceRestoreHelper.RecordGameLevelApplied(otherDevice); + + IntPtr desktop = GetDesktopWindow(); + ProfileToggleResult result = proxy.ToggleForegroundProfile(desktop, "TestGameB2Off", null); + + bool usedWideOverload = adapter.SetSaturationOnAllDisplaysCallCount == 0 && + adapter.SetSaturationOnDisplayNames.Count == 1 && adapter.SetSaturationOnDisplayNames[0] == null && + adapter.SetSaturationOnDisplayLevels[0] == 90; + + checklist.Check(result == ProfileToggleResult.ToggledOff && usedWideOverload && VibranceRestoreHelper.HoldingCount == 0, + string.Format("B2 (off): with affectPrimaryMonitorOnly false, toggling off calls SetSaturationOnDisplay(90, null) - never SetSaturationOnAllDisplays - and drains the WHOLE work-list (a display never named in the call), got result={0} allDisplaysCalls={1} perDisplayCalls={2} lastName={3} HoldingCount={4}", + result, adapter.SetSaturationOnAllDisplaysCallCount, adapter.SetSaturationOnDisplayNames.Count, + adapter.SetSaturationOnDisplayNames.Count > 0 ? (adapter.SetSaturationOnDisplayNames[0] ?? "null") : "(none)", + VibranceRestoreHelper.HoldingCount)); + + ProfileToggleHelper.ResetForTests(); + VibranceRestoreHelper.ResetForTests(); + } + + // B2 (on). The mirror-image defect: toggling a suppressed game back ON with the flag + // false must write (and record as owing a restore) every attached display, exactly like + // the automatic apply branch does - not just the screen the toggle happened to resolve + // foregroundWindow to. Witnessed via the real Screen.AllScreens count, so this passes + // regardless of how many monitors are actually attached to the machine running it. + private static void CheckAmdToggleOnRespectsWideMode(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + VibranceRestoreHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameB2On"; + matchingSetting.IngameLevel = 230; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + proxy.SetVibranceWindowsLevel(90); + ProfileToggleHelper.SetSuppressed("TestGameB2On", true); + + IntPtr desktop = GetDesktopWindow(); + ProfileToggleResult result = proxy.ToggleForegroundProfile(desktop, "TestGameB2On", null); + + bool usedWideOverload = adapter.SetSaturationOnAllDisplaysCallCount == 0 && + adapter.SetSaturationOnDisplayNames.Count == 1 && adapter.SetSaturationOnDisplayNames[0] == null && + adapter.SetSaturationOnDisplayLevels[0] == 230; + int expectedHoldingCount = Screen.AllScreens.Length; + + checklist.Check(result == ProfileToggleResult.ToggledOn && usedWideOverload && VibranceRestoreHelper.HoldingCount == expectedHoldingCount, + string.Format("B2 (on): with affectPrimaryMonitorOnly false, toggling on calls SetSaturationOnDisplay(230, null) - never SetSaturationOnAllDisplays - and records EVERY attached screen as owing a restore ({0} on this machine), got result={1} allDisplaysCalls={2} perDisplayCalls={3} lastName={4} HoldingCount={5}", + expectedHoldingCount, result, adapter.SetSaturationOnAllDisplaysCallCount, adapter.SetSaturationOnDisplayNames.Count, + adapter.SetSaturationOnDisplayNames.Count > 0 ? (adapter.SetSaturationOnDisplayNames[0] ?? "null") : "(none)", + VibranceRestoreHelper.HoldingCount)); + + ProfileToggleHelper.ResetForTests(); + VibranceRestoreHelper.ResetForTests(); + } + + private static AmdDynamicVibranceProxy BuildAmdProxy(FakeAmdAdapter adapter, List settings) + { + Dictionary>> windowsResolutionSettings = + new Dictionary>>(); + AmdDynamicVibranceProxy proxy = new AmdDynamicVibranceProxy(adapter, settings, windowsResolutionSettings); + proxy.SetNeverChangeColorSettings(true); + proxy.SetNeverSwitchResolution(true); + return proxy; + } + + // ------------------------------------------------------------------ + // Suppression gate - the real, private OnWinEventHook by reflection, both vendors. + // ------------------------------------------------------------------ + + private static void RunSuppressionGateChecks(Checklist checklist) + { + checklist.Lines.Add("Per-game suppression gate at the top of the apply branch (real OnWinEventHook via reflection, both vendors):"); + + CheckNvidiaSuppressedGameMakesNoCalls(checklist); + CheckNvidiaGameScreenUnchangedWhenSuppressed(checklist); + CheckNvidiaDifferentUnsuppressedGameStillApplies(checklist); + CheckNvidiaRestoreBranchStillRunsWhileSomethingIsSuppressed(checklist); + CheckNvidiaNotSuppressedAppliesNormally(checklist); + CheckAmdSuppressedGameMakesNoCalls(checklist); + + checklist.Lines.Add(string.Empty); + } + + // ------------------------------------------------------------------ + // Stranded-suppression cleanup - VibranceGUI.ClearSuppressionIfNameChanged, the real + // production method both the "remove program" and "Change executable..." call sites now + // route through (same assembly, internal - no reflection needed). Repro this closes: a + // game A gets suppressed, its profile is later removed (or edited so its Name changes), + // and a brand new, UNRELATED profile that happens to resolve to the same Name would + // otherwise silently start suppressed with no action from that user. + // ------------------------------------------------------------------ + + private static void RunSuppressionCleanupChecks(Checklist checklist) + { + checklist.Lines.Add("Stranded-suppression cleanup (VibranceGUI.ClearSuppressionIfNameChanged, real method, no reflection):"); + + CheckClearSuppressionOnRemoval(checklist); + CheckClearSuppressionOnRename(checklist); + CheckClearSuppressionLeavesADifferentSuppressionAlone(checklist); + + checklist.Lines.Add(string.Empty); + } + + // Mutation this guards: drop the removal call site's use of ClearSuppressionIfNameChanged + // entirely (newName: null models "this profile no longer exists at all"). + private static void CheckClearSuppressionOnRemoval(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + + ApplicationSetting removedSetting = new ApplicationSetting(); + removedSetting.Name = "TestGameCleanupRemoved"; + ProfileToggleHelper.SetSuppressed("TestGameCleanupRemoved", true); + + VibranceGUI.ClearSuppressionIfNameChanged(removedSetting, null); + + checklist.Check(!ProfileToggleHelper.IsSuppressed("TestGameCleanupRemoved"), + "Cleanup-1: removing a suppressed profile (newName: null) clears its suppression - a later, unrelated profile that resolves to the same Name must not silently inherit it"); + + ProfileToggleHelper.ResetForTests(); + } + + // Mutation this guards: compare FileName instead of Name (or skip the comparison and + // never clear at all) when deciding whether "Change executable..." actually moved this + // profile off its old suppression key. + private static void CheckClearSuppressionOnRename(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + + ApplicationSetting oldSetting = new ApplicationSetting(); + oldSetting.Name = "TestGameCleanupOldName"; + ProfileToggleHelper.SetSuppressed("TestGameCleanupOldName", true); + + VibranceGUI.ClearSuppressionIfNameChanged(oldSetting, "TestGameCleanupNewName"); + + checklist.Check(!ProfileToggleHelper.IsSuppressed("TestGameCleanupOldName"), + "Cleanup-2: \"Change executable...\" moving a suppressed profile to a new Name clears the suppression recorded under the OLD Name"); + + ProfileToggleHelper.ResetForTests(); + } + + // Mutation this guards: clear suppression unconditionally (or key it on something other + // than Name), wiping out an unrelated profile's own, legitimate suppression. + private static void CheckClearSuppressionLeavesADifferentSuppressionAlone(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + + ApplicationSetting oldSetting = new ApplicationSetting(); + oldSetting.Name = "TestGameCleanupA"; + ProfileToggleHelper.SetSuppressed("TestGameCleanupA", true); + ProfileToggleHelper.SetSuppressed("TestGameCleanupB", true); + + VibranceGUI.ClearSuppressionIfNameChanged(oldSetting, "TestGameCleanupANewName"); + + checklist.Check(!ProfileToggleHelper.IsSuppressed("TestGameCleanupA") && ProfileToggleHelper.IsSuppressed("TestGameCleanupB"), + "Cleanup-3: clearing profile A's suppression on rename leaves a DIFFERENT profile B's own suppression untouched"); + + ProfileToggleHelper.ResetForTests(); + } + + private static void InvokeNvidiaOnWinEventHook(string processName, IntPtr handle) + { + MethodInfo onWinEventHook = typeof(NvidiaDynamicVibranceProxy).GetMethod( + "OnWinEventHook", BindingFlags.NonPublic | BindingFlags.Static); + WinEventHookEventArgs args = new WinEventHookEventArgs { Handle = handle, ProcessName = processName, ProcessImagePath = null }; + onWinEventHook.Invoke(null, new object[] { null, args }); + } + + private static Screen GetNvidiaGameScreen() + { + FieldInfo f = typeof(NvidiaDynamicVibranceProxy).GetField("_gameScreen", BindingFlags.NonPublic | BindingFlags.Static); + return (Screen)f.GetValue(null); + } + + private static void SetNvidiaGameScreen(Screen value) + { + FieldInfo f = typeof(NvidiaDynamicVibranceProxy).GetField("_gameScreen", BindingFlags.NonPublic | BindingFlags.Static); + f.SetValue(null, value); + } + + // G1. Mutation this guards: remove the suppression gate entirely. + private static void CheckNvidiaSuppressedGameMakesNoCalls(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameG1"; + matchingSetting.IngameLevel = 40; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.neverChangeResolution = true; + vibranceInfo.neverChangeColorSettings = true; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + ProfileToggleHelper.SetSuppressed("TestGameG1", true); + + InvokeNvidiaOnWinEventHook("TestGameG1", GetDesktopWindow()); + + checklist.Check(device.TotalCallCount == 0, + string.Format("G1: a suppressed game's own foreground event makes zero device calls, got {0}", device.TotalCallCount)); + + ProfileToggleHelper.ResetForTests(); + } + + // G2. Mutation this guards: place the gate AFTER "_gameScreen = screen;" instead of + // before it - _gameScreen is forced to a known baseline (null) first, so this does not + // depend on how many real monitors are attached. + private static void CheckNvidiaGameScreenUnchangedWhenSuppressed(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameG2"; + matchingSetting.IngameLevel = 40; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.neverChangeResolution = true; + vibranceInfo.neverChangeColorSettings = true; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + ProfileToggleHelper.SetSuppressed("TestGameG2", true); + SetNvidiaGameScreen(null); + + InvokeNvidiaOnWinEventHook("TestGameG2", GetDesktopWindow()); + + checklist.Check(GetNvidiaGameScreen() == null, + "G2: _gameScreen is left exactly as it was (null) when the matched game is suppressed - a gate placed AFTER \"_gameScreen = screen\" would leave it non-null instead"); + + ProfileToggleHelper.ResetForTests(); + } + + // G3. Mutation this guards: key the gate on "any suppression exists at all" + // (ProfileToggleHelper.SuppressedCount > 0) instead of THIS setting's own name. + private static void CheckNvidiaDifferentUnsuppressedGameStillApplies(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting suppressedSetting = new ApplicationSetting(); + suppressedSetting.Name = "TestGameG3Suppressed"; + suppressedSetting.IngameLevel = 40; + ApplicationSetting unsuppressedSetting = new ApplicationSetting(); + unsuppressedSetting.Name = "TestGameG3Unsuppressed"; + unsuppressedSetting.IngameLevel = 60; + List settings = new List { suppressedSetting, unsuppressedSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.neverChangeResolution = true; + vibranceInfo.neverChangeColorSettings = true; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + ProfileToggleHelper.SetSuppressed("TestGameG3Suppressed", true); + + InvokeNvidiaOnWinEventHook("TestGameG3Unsuppressed", GetDesktopWindow()); + + checklist.Check(device.SetLevelCalls.Count == 1, + string.Format("G3: a DIFFERENT, unsuppressed game still applies normally while another profile is suppressed, got {0} SetLevel call(s)", device.SetLevelCalls.Count)); + + ProfileToggleHelper.ResetForTests(); + } + + // G4. Mutation this guards: gate the WHOLE handler (both branches) on any suppression + // existing, the shape of the discarded global-pause design's gate. + private static void CheckNvidiaRestoreBranchStillRunsWhileSomethingIsSuppressed(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting suppressedSetting = new ApplicationSetting(); + suppressedSetting.Name = "TestGameG4"; + suppressedSetting.IngameLevel = 40; + List settings = new List { suppressedSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.neverChangeResolution = true; + vibranceInfo.neverChangeColorSettings = true; + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 25; + // Scopes RestoreWindowsVibranceLevel to the work-list + primary + // (ComposeRestoreTargets/RestoreOneDisplay) - the branch this check actually means to + // exercise. Left at the struct default (false), it takes the OTHER restore branch + // instead, which only ever consults displayHandles (null here) and drains the whole + // work-list via ClearAllGameLevelRecords() without writing anything at all - a false + // "restore branch does nothing" that has nothing to do with the suppression gate. + vibranceInfo.affectPrimaryMonitorOnly = true; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + ProfileToggleHelper.SetSuppressed("TestGameG4", true); + + const string worklistDevice = "\\\\.\\DISPLAY_TESTONLY_G4"; + VibranceRestoreHelper.RecordGameLevelApplied(worklistDevice); + + // A non-matching process name (desktop, explorer, ...) drives the restore ("else") + // branch, not the apply branch the suppression gate sits in. + InvokeNvidiaOnWinEventHook("explorer", GetDesktopWindow()); + + checklist.Check(device.SetLevelCalls.Count > 0 && VibranceRestoreHelper.HoldingCount == 0, + string.Format("G4: the restore branch still runs (and drains the work-list) while a DIFFERENT profile is suppressed, got {0} SetLevel call(s), HoldingCount={1}", + device.SetLevelCalls.Count, VibranceRestoreHelper.HoldingCount)); + + ProfileToggleHelper.ResetForTests(); + } + + // G5 (PIN). The ordinary, not-suppressed apply path still works once the gate exists. + private static void CheckNvidiaNotSuppressedAppliesNormally(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameG5"; + matchingSetting.IngameLevel = 40; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.neverChangeResolution = true; + vibranceInfo.neverChangeColorSettings = true; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + + InvokeNvidiaOnWinEventHook("TestGameG5", GetDesktopWindow()); + + checklist.Check(device.SetLevelCalls.Count == 1, + string.Format("G5 (pin): not suppressed + a matched game applies the ingame level as today, got {0} SetLevel call(s)", device.SetLevelCalls.Count)); + } + + // G6, AMD's counterpart of G1. + private static void CheckAmdSuppressedGameMakesNoCalls(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameG6"; + matchingSetting.IngameLevel = 200; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + proxy.SetVibranceWindowsLevel(90); + ProfileToggleHelper.SetSuppressed("TestGameG6", true); + + MethodInfo onWinEventHook = typeof(AmdDynamicVibranceProxy).GetMethod("OnWinEventHook", BindingFlags.NonPublic | BindingFlags.Instance); + WinEventHookEventArgs args = new WinEventHookEventArgs { Handle = GetDesktopWindow(), ProcessName = "TestGameG6", ProcessImagePath = null }; + onWinEventHook.Invoke(proxy, new object[] { null, args }); + + checklist.Check(adapter.SetSaturationOnAllDisplaysCallCount == 0 && adapter.SetSaturationOnDisplayNames.Count == 0, + string.Format("G6: a suppressed AMD game's own foreground event makes zero adapter calls, got allCalls={0} perDisplayCalls={1}", + adapter.SetSaturationOnAllDisplaysCallCount, adapter.SetSaturationOnDisplayNames.Count)); + + ProfileToggleHelper.ResetForTests(); + } + + // ------------------------------------------------------------------ + // Settings round trip - a temp INI/XML pair, deleted in a finally, NEVER the user's real + // "%APPDATA%\vibranceGUI\vibranceGUI.ini". Both keys: toggleHotkey (canonical text) and + // toggleHotkeyEnabled (bool). + // ------------------------------------------------------------------ + + private static void RunSettingsChecks(Checklist checklist) + { + checklist.Lines.Add("SettingsController round trip, both keys (temp INI, never the user's real one):"); + + CheckSettingsRoundTripBothKeys(checklist); + CheckSettingsMissingKeysReadDefaults(checklist); + CheckSettingsCorruptValuesDoNotThrow(checklist); + + checklist.Lines.Add(string.Empty); + } + + private static string NewTempPath(string extension) + { + return Path.Combine(Path.GetTempPath(), "vibranceGUI-hotkey-fixture-" + Guid.NewGuid().ToString("N") + extension); + } + + private static void DeleteFileIfExists(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception) + { + // Best-effort temp file cleanup only - never let this mask a check's own result. + } + } + + // S1. Mutation this guards: have SetToggleHotkey/SetToggleHotkeyEnabled write under the + // wrong key name, or the readers read a different one. + private static void CheckSettingsRoundTripBothKeys(Checklist checklist) + { + string tempIni = NewTempPath(".ini"); + string tempXml = NewTempPath(".xml"); + try + { + SettingsController controller = new SettingsController(tempIni, tempXml); + bool wroteBinding = controller.SetToggleHotkey("Ctrl+Alt+F9"); + bool wroteEnabled = controller.SetToggleHotkeyEnabled(true); + string readBackBinding = controller.ReadToggleHotkey(); + bool readBackEnabled = controller.ReadToggleHotkeyEnabled(); + + checklist.Check(wroteBinding && wroteEnabled && readBackBinding == "Ctrl+Alt+F9" && readBackEnabled, + string.Format("S1: toggleHotkey and toggleHotkeyEnabled both round-trip byte for byte against a temp INI, got wroteBinding={0} wroteEnabled={1} readBackBinding=\"{2}\" readBackEnabled={3}", + wroteBinding, wroteEnabled, readBackBinding, readBackEnabled)); + } + finally + { + DeleteFileIfExists(tempIni); + DeleteFileIfExists(tempXml); + } + } + + // S2. Mutation this guards: default either reader's missing-key value to something other + // than ""/false. + private static void CheckSettingsMissingKeysReadDefaults(Checklist checklist) + { + string tempIni = NewTempPath(".ini"); + string tempXml = NewTempPath(".xml"); + try + { + SettingsController controller = new SettingsController(tempIni, tempXml); + // Writes a DIFFERENT key first, so the INI file exists but never contains either + // toggle-hotkey key. + controller.SetVibranceSetting("someOtherKey", "someOtherValue"); + + string readBackBinding = controller.ReadToggleHotkey(); + bool readBackEnabled = controller.ReadToggleHotkeyEnabled(); + HotkeyBinding parsedBinding; + bool parsed = HotkeyBindingParser.TryParse(readBackBinding, out parsedBinding); + + checklist.Check(readBackBinding == string.Empty && !parsed && !readBackEnabled, + string.Format("S2: missing keys read back as \"\"/false and never register, got readBackBinding=\"{0}\" parsed={1} readBackEnabled={2}", + readBackBinding, parsed, readBackEnabled)); + } + finally + { + DeleteFileIfExists(tempIni); + DeleteFileIfExists(tempXml); + } + } + + // S3. Mutation this guards: let either reader throw on a value the writers never + // themselves produced. + private static void CheckSettingsCorruptValuesDoNotThrow(Checklist checklist) + { + string tempIni = NewTempPath(".ini"); + string tempXml = NewTempPath(".xml"); + try + { + SettingsController controller = new SettingsController(tempIni, tempXml); + controller.SetToggleHotkey("Ctrl+NotARealKeyName"); + controller.SetVibranceSetting("toggleHotkeyEnabled", "NotABool"); + + bool threw = false; + HotkeyBinding binding = HotkeyBinding.None; + bool enabled = true; + try + { + string readBackBinding = controller.ReadToggleHotkey(); + HotkeyBindingParser.TryParse(readBackBinding, out binding); + enabled = controller.ReadToggleHotkeyEnabled(); + } + catch (Exception) + { + threw = true; + } + + checklist.Check(!threw && !binding.IsSet && !enabled, + string.Format("S3: a corrupt stored binding never throws and yields IsSet == false, and a corrupt bool string defaults to false, got threw={0} isSet={1} enabled={2}", + threw, binding.IsSet, enabled)); + } + finally + { + DeleteFileIfExists(tempIni); + DeleteFileIfExists(tempXml); + } + } + + // Records every (deviceName -> handle) resolution and every (handle -> level) write this + // fake is asked to make, plus a combined call counter (TotalCallCount) the gate checks + // above use for their "zero calls AT ALL" assertions. VibranceRestoreFixture's own + // FakeNvidiaVibranceDevice is a private nested class there and not reachable from this + // file, so this is a second, smaller copy of the same shape. + private class FakeNvidiaVibranceDevice : INvidiaVibranceDevice + { + private readonly Dictionary _handlesByDeviceName = new Dictionary(); + private readonly Dictionary _levelsByHandle = new Dictionary(); + private readonly HashSet _failNextSetLevel = new HashSet(); + private int _nextHandle = 1; + + public readonly List SetLevelCalls = new List(); + public readonly List ResolvedDeviceNames = new List(); + public int TotalCallCount; + + public int HandleFor(string deviceName) + { + return ResolveOrAssign(deviceName); + } + + public void SeedLevel(string deviceName, int level) + { + _levelsByHandle[ResolveOrAssign(deviceName)] = level; + } + + public void FailNextSetLevel(string deviceName) + { + _failNextSetLevel.Add(ResolveOrAssign(deviceName)); + } + + private int ResolveOrAssign(string deviceName) + { + int handle; + if (!_handlesByDeviceName.TryGetValue(deviceName, out handle)) + { + handle = _nextHandle++; + _handlesByDeviceName[deviceName] = handle; + } + return handle; + } + + public bool IsWindowActive(ref IntPtr hWnd) + { + TotalCallCount++; + return true; + } + + public int TryResolveDisplayHandle(string deviceName) + { + TotalCallCount++; + ResolvedDeviceNames.Add(deviceName); + if (string.IsNullOrEmpty(deviceName)) + { + return -1; + } + return ResolveOrAssign(deviceName); + } + + public bool IsAtLevel(int displayHandle, int level) + { + TotalCallCount++; + int current; + return _levelsByHandle.TryGetValue(displayHandle, out current) && current == level; + } + + public bool SetLevel(int displayHandle, int level) + { + TotalCallCount++; + SetLevelCalls.Add(displayHandle); + if (_failNextSetLevel.Remove(displayHandle)) + { + return false; + } + _levelsByHandle[displayHandle] = level; + return true; + } + } + + // Everything IAmdAdapter exposes, none of it touching real hardware - a second, smaller + // copy of VibranceRestoreFixture's own private nested fake, extended with one-shot + // per-display failure injection for T4a/T5a above. + private class FakeAmdAdapter : IAmdAdapter + { + private readonly HashSet _failNextSetSaturationOnDisplay = new HashSet(StringComparer.OrdinalIgnoreCase); + + public int SetSaturationOnAllDisplaysCallCount; + + public readonly List SetSaturationOnDisplayLevels = new List(); + public readonly List SetSaturationOnDisplayNames = new List(); + + public void FailNextSetSaturationOnDisplay(string displayName) + { + _failNextSetSaturationOnDisplay.Add(displayName ?? string.Empty); + } + + public void SetSaturationOnAllDisplays(int vibranceLevel) + { + SetSaturationOnAllDisplaysCallCount++; + } + + public bool SetSaturationOnDisplay(int vibranceLevel, string displayName) + { + SetSaturationOnDisplayLevels.Add(vibranceLevel); + SetSaturationOnDisplayNames.Add(displayName); + return !_failNextSetSaturationOnDisplay.Remove(displayName ?? string.Empty); + } + + public bool IsAvailable() + { + return false; + } + + public void Init() + { + } + + public void Dispose() + { + } + } + + // Records every Register/Unregister call this fake is asked to make, with a one-shot + // FIFO queue of forced results - mirrors ResolutionChangeFixture.FakeDisplayModeDevice's + // QueueResult shape. Never touches a real hotkey - see this file's own header comment. + private class FakeHotkeyRegistrar : IHotkeyRegistrar + { + public struct RegisterCall + { + public readonly IntPtr HWnd; + public readonly int Id; + public readonly uint Modifiers; + public readonly uint VirtualKey; + + public RegisterCall(IntPtr hWnd, int id, uint modifiers, uint virtualKey) + { + HWnd = hWnd; + Id = id; + Modifiers = modifiers; + VirtualKey = virtualKey; + } + } + + private readonly Queue _queuedResults = new Queue(); + + public readonly List RegisterCalls = new List(); + public readonly List UnregisterCalls = new List(); + + public void QueueResult(HotkeyRegistrationResult result) + { + _queuedResults.Enqueue(result); + } + + public HotkeyRegistrationResult Register(IntPtr hWnd, int id, uint modifiers, uint virtualKey) + { + RegisterCalls.Add(new RegisterCall(hWnd, id, modifiers, virtualKey)); + if (_queuedResults.Count > 0) + { + return _queuedResults.Dequeue(); + } + return HotkeyRegistrationResult.Registered; + } + + public void Unregister(IntPtr hWnd, int id) + { + UnregisterCalls.Add(hWnd); + } + } + + private class Checklist + { + public readonly List Lines = new List(); + public int Passed; + public int Total; + + public void Check(bool condition, string description) + { + Total++; + if (condition) + Passed++; + Lines.Add(string.Format("[{0}] {1}", condition ? "PASS" : "FAIL", description)); + } + + // Deliberately not counted in Total/Passed - see StabilityFixture.Checklist.Skip for + // the convention this follows. None of the checks above currently need it (unlike the + // pre-existing AMD checks in VibranceRestoreFixture, nothing here reads the real + // GetForegroundWindow()), but it is kept for parity with every other fixture's + // Checklist shape. + public void Skip(string description) + { + Lines.Add(string.Format("[SKIP] {0}", description)); + } + } + } +} diff --git a/vibrance.GUI/common/ProfileToggleHelper.cs b/vibrance.GUI/common/ProfileToggleHelper.cs new file mode 100644 index 0000000..3ab8334 --- /dev/null +++ b/vibrance.GUI/common/ProfileToggleHelper.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; + +namespace vibrance.GUI.common +{ + /// + /// What the toggle hotkey should do about the profile currently in the foreground, decided + /// with no device, no Screen and no OS call at all - see Decide below. + /// + internal enum ProfileToggleAction + { + // No configured profile matches the foreground window at all. + None, + // This profile is currently suppressed (forced to the Windows level) - the hotkey should + // write the game level and un-suppress it. + ApplyGameLevel, + // This profile is currently running normally - the hotkey should write the Windows level + // and suppress it. + RestoreWindowsLevel, + // A profile matched, but userVibranceSettingDefault is not known yet (SetVibranceWindowsLevel + // has never run this session) - writing either level would be the arbitrary-0 write + // issue #60/#36 was. + EngineNotReady + } + + internal struct ProfileToggleDecision + { + internal ProfileToggleAction Action; + internal ApplicationSetting Setting; + } + + /// + /// The toggle hotkey's own state and pure decision logic (upstream #143, per-game + /// suppression). No device, no Screen, no OS call, no I/O - the vendor proxies' own + /// ToggleForegroundProfile is what turns a ProfileToggleDecision into an actual write. + /// + internal static class ProfileToggleHelper + { + // The set of ApplicationSetting.Name values the user has toggled OFF by hotkey. Empty + // means every profile behaves exactly as it does today. Deliberately a suppression set + // and not an enablement set, so zero-initialised state is current behaviour - the same + // polarity lesson VibranceInfo.shouldRun's own comment describes, applied to a set + // instead of a single flag because this is now a per-game decision, not a global one. + // + // Keyed by Name, OrdinalIgnoreCase: Name is already this codebase's identity for a + // profile - it is what NameMatches compares (ApplicationSettingMatcher.cs:89-94) and + // what AddProgramsBulk dedupes on (VibranceGUI.cs). Deliberately NOT the + // ApplicationSetting reference: listApplications_DoubleClick removes and re-adds a NEW + // object on every edit (VibranceGUI.cs, listApplications_DoubleClick), so a held + // reference would go stale the next time that game's settings are edited. + // + // Everything here runs on the UI thread (WinEvent callbacks and the WM_HOTKEY handler + // both do), like VibranceRestoreHelper's own work-list beside it - deliberately + // unsynchronised for the same reason. + // + // This class has NO persistence code of any kind - there is no read path to review + // because there is no I/O here at all. Persistence of the hotkey binding itself lives in + // SettingsController; suppression state is intentionally NOT persisted (every launch + // starts with every profile un-suppressed, exactly like isPaused's would-have-been + // semantics in the discarded design). + // + // Deliberately NOT derived from VibranceRestoreHelper's own work-list: that drains on + // every alt-tab out of a game (see its own class comment), but a suppression must + // survive exactly that - the whole point is that the automatic restore-on-alt-tab-out + // keeps happening for a suppressed game (see the "restore branch stays ungated" note in + // both proxies' OnWinEventHook) while the apply-on-alt-tab-in does not. Two different + // lifetimes, so two different pieces of state. + private static readonly HashSet _suppressedProfileNames = + new HashSet(StringComparer.OrdinalIgnoreCase); + + internal static bool IsSuppressed(string name) + { + return !string.IsNullOrEmpty(name) && _suppressedProfileNames.Contains(name); + } + + internal static void SetSuppressed(string name, bool suppressed) + { + if (string.IsNullOrEmpty(name)) + { + return; + } + + if (suppressed) + { + _suppressedProfileNames.Add(name); + } + else + { + _suppressedProfileNames.Remove(name); + } + } + + internal static int SuppressedCount + { + get { return _suppressedProfileNames.Count; } + } + + // Exists for ProfileToggleFixture only - production code never needs to blank this out + // mid-run. Mirrors VibranceRestoreHelper.ResetForTests. + internal static void ResetForTests() + { + _suppressedProfileNames.Clear(); + } + + /// + /// The toggle hotkey's whole decision, made with no device, no Screen, no OS call and no + /// side effect - Decide never mutates _suppressedProfileNames itself; the caller flips it + /// only after a confirmed write (see ToggleForegroundProfile in both proxies). + /// + /// Direction comes from OUR OWN recorded intent (IsSuppressed), never from reading the + /// display's current level back - a read-back mis-decides the instant the game level and + /// the Windows level happen to coincide, or an external tool nudges the display between + /// events. + /// + /// Matches with the same overload the automatic WinEvent handlers use + /// (NvidiaDynamicVibranceProxy.OnWinEventHook), including processImagePath - a directory- + /// matched profile (no exact Name match) must be just as reachable by the hotkey as by + /// the automatic path, or a guessed executable is invisible to the toggle even though the + /// automatic apply already recognises it. Matching on Name alone here is PR #153's bug. + /// + internal static ProfileToggleDecision Decide(List settings, + string processName, string processImagePath, bool isWindowsLevelKnown) + { + ProfileToggleDecision decision = new ProfileToggleDecision(); + + ApplicationSetting setting = ApplicationSettingMatcher.FindMatch(settings, processName, processImagePath); + if (setting == null) + { + decision.Action = ProfileToggleAction.None; + return decision; + } + + if (!isWindowsLevelKnown) + { + decision.Action = ProfileToggleAction.EngineNotReady; + return decision; + } + + decision.Setting = setting; + decision.Action = IsSuppressed(setting.Name) ? ProfileToggleAction.ApplyGameLevel : ProfileToggleAction.RestoreWindowsLevel; + return decision; + } + } +} diff --git a/vibrance.GUI/common/SettingsController.cs b/vibrance.GUI/common/SettingsController.cs index 61b919e..a3d758c 100644 --- a/vibrance.GUI/common/SettingsController.cs +++ b/vibrance.GUI/common/SettingsController.cs @@ -38,11 +38,26 @@ private static extern bool WritePrivateProfileString(string lpAppName, const string SzKeyNameContrastWindowsLevel = "contrastWindowsLevel"; const string SzKeyNameGammaWindowsLevel = "gammaWindowsLevel"; const string SzKeyNameGraphicsAdapter = "graphicsAdapter"; + const string SzKeyNameToggleHotkey = "toggleHotkey"; + const string SzKeyNameToggleHotkeyEnabled = "toggleHotkeyEnabled"; private string _fileName = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData).ToString() + "\\vibranceGUI\\vibranceGUI.ini"; private string _fileNameApplicationSettings = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData).ToString() + "\\vibranceGUI\\applicationData.xml"; + public SettingsController() + { + } + + // Lets ProfileToggleFixture round-trip SetToggleHotkey/ReadToggleHotkey against a temp + // INI, never the user's real "%APPDATA%\vibranceGUI\vibranceGUI.ini" - see that fixture's + // own header comment. + internal SettingsController(string fileName, string applicationSettingsFileName) + { + _fileName = fileName; + _fileNameApplicationSettings = applicationSettingsFileName; + } + public bool SetVibranceSettings(string windowsLevel, string affectPrimaryMonitorOnly, string neverSwitchResolution, string neverChangeColorSettings, List applicationSettings, string brightnessWindowsLevel, string contrastWindowsLevel, string gammaWindowsLevel) @@ -138,6 +153,76 @@ public bool SetGraphicsAdapterPreference(GraphicsAdapter graphicsAdapter) return SetVibranceSetting(SzKeyNameGraphicsAdapter, graphicsAdapter.ToString()); } + /// + /// The toggle hotkey's canonical text (HotkeyBindingParser.Format's own output, e.g. + /// "Ctrl+Alt+F9"), or "" when the INI holds no binding - which is what every existing + /// installation looks like. Modelled exactly on ReadGraphicsAdapterPreference: read on + /// its own, not folded into ReadVibranceSettings' 8-parameter signature, since that + /// signature is shared by every existing call site and this feature has nothing to do + /// with vibrance levels. + /// + public string ReadToggleHotkey() + { + if (!IsFileExisting(_fileName)) + { + return string.Empty; + } + + StringBuilder szValueToggleHotkey = new StringBuilder(1024); + GetPrivateProfileString(SzSectionName, + SzKeyNameToggleHotkey, + "", + szValueToggleHotkey, + Convert.ToUInt32(szValueToggleHotkey.Capacity), + _fileName); + + return szValueToggleHotkey.ToString().Trim(); + } + + /// + /// Stores the toggle hotkey's canonical text. SetVibranceSetting is already a single-key + /// writer, so this is a thin, named wrapper over it - the same shape as + /// SetGraphicsAdapterPreference. + /// + public bool SetToggleHotkey(string canonicalText) + { + return SetVibranceSetting(SzKeyNameToggleHotkey, canonicalText ?? string.Empty); + } + + /// + /// Whether the toggle hotkey checkbox was checked, or false when the INI holds no + /// preference - which is what every existing installation looks like. A missing/corrupt + /// value defaults to false (disabled), the safer of the two: an unexpectedly-active + /// global hotkey is a worse first impression than one the user has to turn on themselves. + /// + public bool ReadToggleHotkeyEnabled() + { + if (!IsFileExisting(_fileName)) + { + return false; + } + + StringBuilder szValueToggleHotkeyEnabled = new StringBuilder(1024); + GetPrivateProfileString(SzSectionName, + SzKeyNameToggleHotkeyEnabled, + "False", + szValueToggleHotkeyEnabled, + Convert.ToUInt32(szValueToggleHotkeyEnabled.Capacity), + _fileName); + + bool enabled; + return bool.TryParse(szValueToggleHotkeyEnabled.ToString().Trim(), out enabled) && enabled; + } + + /// + /// Stores whether the toggle hotkey checkbox was checked - the same single-key writer + /// shape as SetToggleHotkey beside it. + /// + public bool SetToggleHotkeyEnabled(bool enabled) + { + return SetVibranceSetting(SzKeyNameToggleHotkeyEnabled, enabled.ToString()); + } + private bool PrepareFile() { if (!IsFileExisting(_fileName)) diff --git a/vibrance.GUI/common/StabilityFixture.cs b/vibrance.GUI/common/StabilityFixture.cs index d37be63..5680813 100644 --- a/vibrance.GUI/common/StabilityFixture.cs +++ b/vibrance.GUI/common/StabilityFixture.cs @@ -238,11 +238,14 @@ public void SetSaturationOnAllDisplays(int vibranceLevel) LastSetSaturationOnAllDisplaysLevel = vibranceLevel; } - public void SetSaturationOnDisplay(int vibranceLevel, string displayName) + // Always reports success (upstream #143 gave the real interface a bool return) - + // none of this file's own checks need a failure path, so behaviour here is unchanged. + public bool SetSaturationOnDisplay(int vibranceLevel, string displayName) { SetSaturationOnDisplayCallCount++; LastSetSaturationOnDisplayLevel = vibranceLevel; LastSetSaturationOnDisplayName = displayName; + return true; } public bool IsAvailable() diff --git a/vibrance.GUI/common/VibranceGUI.Designer.cs b/vibrance.GUI/common/VibranceGUI.Designer.cs index 5aac4ed..db4026c 100644 --- a/vibrance.GUI/common/VibranceGUI.Designer.cs +++ b/vibrance.GUI/common/VibranceGUI.Designer.cs @@ -66,6 +66,11 @@ private void InitializeComponent() this.groupBox10 = new System.Windows.Forms.GroupBox(); this.labelBrightness = new System.Windows.Forms.Label(); this.trackBarBrightness = new System.Windows.Forms.TrackBar(); + this.checkBoxToggleHotkeyEnabled = new System.Windows.Forms.CheckBox(); + this.labelToggleHotkey = new System.Windows.Forms.Label(); + this.textBoxToggleHotkey = new System.Windows.Forms.TextBox(); + this.buttonClearToggleHotkey = new System.Windows.Forms.Button(); + this.labelToggleHotkeyStatus = new System.Windows.Forms.Label(); this.contextMenuStrip.SuspendLayout(); this.groupBoxSettings.SuspendLayout(); this.groupBox3.SuspendLayout(); @@ -143,6 +148,11 @@ private void InitializeComponent() this.groupBoxSettings.Controls.Add(this.checkBoxPrimaryMonitorOnly); this.groupBoxSettings.Controls.Add(this.groupBox3); this.groupBoxSettings.Controls.Add(this.checkBoxAutostart); + this.groupBoxSettings.Controls.Add(this.checkBoxToggleHotkeyEnabled); + this.groupBoxSettings.Controls.Add(this.labelToggleHotkey); + this.groupBoxSettings.Controls.Add(this.textBoxToggleHotkey); + this.groupBoxSettings.Controls.Add(this.buttonClearToggleHotkey); + this.groupBoxSettings.Controls.Add(this.labelToggleHotkeyStatus); this.groupBoxSettings.Location = new System.Drawing.Point(15, 47); this.groupBoxSettings.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5); this.groupBoxSettings.Name = "groupBoxSettings"; @@ -213,7 +223,73 @@ private void InitializeComponent() this.trackBarWindowsLevel.Size = new System.Drawing.Size(196, 69); this.trackBarWindowsLevel.TabIndex = 0; this.trackBarWindowsLevel.Scroll += new System.EventHandler(this.trackBarWindowsLevel_Scroll); - // + // + // checkBoxToggleHotkeyEnabled + // + this.checkBoxToggleHotkeyEnabled.AutoSize = true; + this.checkBoxToggleHotkeyEnabled.Location = new System.Drawing.Point(300, 32); + this.checkBoxToggleHotkeyEnabled.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5); + this.checkBoxToggleHotkeyEnabled.Name = "checkBoxToggleHotkeyEnabled"; + this.checkBoxToggleHotkeyEnabled.Size = new System.Drawing.Size(248, 24); + this.checkBoxToggleHotkeyEnabled.TabIndex = 20; + this.checkBoxToggleHotkeyEnabled.Text = "Toggle game profile by hotkey"; + this.toolTip.SetToolTip(this.checkBoxToggleHotkeyEnabled, "When checked, the key combination below toggles the foreground game\'s profile b" + + "etween its game level and your Windows level."); + this.checkBoxToggleHotkeyEnabled.UseVisualStyleBackColor = true; + this.checkBoxToggleHotkeyEnabled.CheckedChanged += new System.EventHandler(this.checkBoxToggleHotkeyEnabled_CheckedChanged); + // + // labelToggleHotkey + // + this.labelToggleHotkey.AutoSize = true; + this.labelToggleHotkey.Location = new System.Drawing.Point(300, 60); + this.labelToggleHotkey.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + this.labelToggleHotkey.Name = "labelToggleHotkey"; + this.labelToggleHotkey.Size = new System.Drawing.Size(153, 20); + this.labelToggleHotkey.TabIndex = 21; + this.labelToggleHotkey.Text = "Toggle profile hotkey:"; + // + // textBoxToggleHotkey + // + this.textBoxToggleHotkey.Location = new System.Drawing.Point(300, 86); + this.textBoxToggleHotkey.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5); + this.textBoxToggleHotkey.Name = "textBoxToggleHotkey"; + this.textBoxToggleHotkey.ReadOnly = true; + this.textBoxToggleHotkey.Size = new System.Drawing.Size(240, 26); + this.textBoxToggleHotkey.TabIndex = 22; + this.toolTip.SetToolTip(this.textBoxToggleHotkey, "Click here, then press a key combination to bind a global hotkey that toggles t" + + "he foreground game\'s profile between its game level and your Windows level. A b" + + "are key with no modifier is legal but steals it from the game system-wide."); + this.textBoxToggleHotkey.Enter += new System.EventHandler(this.textBoxToggleHotkey_Enter); + this.textBoxToggleHotkey.KeyDown += new System.Windows.Forms.KeyEventHandler(this.textBoxToggleHotkey_KeyDown); + this.textBoxToggleHotkey.Leave += new System.EventHandler(this.textBoxToggleHotkey_Leave); + // + // buttonClearToggleHotkey + // + this.buttonClearToggleHotkey.Location = new System.Drawing.Point(548, 85); + this.buttonClearToggleHotkey.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5); + this.buttonClearToggleHotkey.Name = "buttonClearToggleHotkey"; + this.buttonClearToggleHotkey.Size = new System.Drawing.Size(44, 28); + this.buttonClearToggleHotkey.TabIndex = 23; + this.buttonClearToggleHotkey.Text = "X"; + this.toolTip.SetToolTip(this.buttonClearToggleHotkey, "Clears the toggle hotkey binding."); + this.buttonClearToggleHotkey.UseVisualStyleBackColor = true; + this.buttonClearToggleHotkey.Click += new System.EventHandler(this.buttonClearToggleHotkey_Click); + // + // labelToggleHotkeyStatus + // + this.labelToggleHotkeyStatus.AutoSize = true; + this.labelToggleHotkeyStatus.Location = new System.Drawing.Point(300, 118); + this.labelToggleHotkeyStatus.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // A non-zero MaximumSize.Width makes an AutoSize label WRAP instead of growing past + // it - defense against the longest status string ("No modifier: steals the key from + // the game.") clipping past groupBoxSettings' own right edge (this label starts at + // x=300 in a 600-wide box, leaving 292px before the border). + this.labelToggleHotkeyStatus.MaximumSize = new System.Drawing.Size(288, 0); + this.labelToggleHotkeyStatus.Name = "labelToggleHotkeyStatus"; + this.labelToggleHotkeyStatus.Size = new System.Drawing.Size(0, 20); + this.labelToggleHotkeyStatus.TabIndex = 24; + this.labelToggleHotkeyStatus.Text = ""; + // // statusLabel // this.statusLabel.AutoSize = true; @@ -576,6 +652,11 @@ private void InitializeComponent() private System.Windows.Forms.GroupBox groupBox10; private System.Windows.Forms.Label labelBrightness; private System.Windows.Forms.TrackBar trackBarBrightness; + private System.Windows.Forms.CheckBox checkBoxToggleHotkeyEnabled; + private System.Windows.Forms.Label labelToggleHotkey; + private System.Windows.Forms.TextBox textBoxToggleHotkey; + private System.Windows.Forms.Button buttonClearToggleHotkey; + private System.Windows.Forms.Label labelToggleHotkeyStatus; } } diff --git a/vibrance.GUI/common/VibranceGUI.cs b/vibrance.GUI/common/VibranceGUI.cs index 52ea23b..e48cb29 100644 --- a/vibrance.GUI/common/VibranceGUI.cs +++ b/vibrance.GUI/common/VibranceGUI.cs @@ -5,6 +5,7 @@ using System.Drawing; using System.IO; using System.Linq; +using System.Media; using System.Runtime.InteropServices; using System.Threading; using System.Windows.Forms; @@ -55,6 +56,44 @@ public partial class VibranceGUI : Form private const string ToolTipExecutableUnconfirmed = "Not detected yet. vibranceGUI has not seen this executable in the foreground, so this may be the wrong file. Double-click to change the executable."; + // WM_HOTKEY (winuser.h) - WndProc below dispatches on this with wParam == + // HotkeyRegistration.HotkeyId, the toggle hotkey's own fixed registration id. + private const int WmHotkey = 0x0312; + + // RegisterHotKey/WndProc, never a low-level keyboard hook - see HotkeyRegistration's own + // header comment for why. Constructed with the real registrar; ProfileToggleFixture + // drives HotkeyRegistration directly against a fake instead of through this form at all. + private readonly HotkeyRegistration _hotkeyRegistration = new HotkeyRegistration(new RealHotkeyRegistrar()); + private readonly IForegroundWindowReader _foregroundWindowReader = new RealForegroundWindowReader(); + private HotkeyBinding _toggleBinding = HotkeyBinding.None; + + // The checkbox's own state - the binding's presence is deliberately NOT the enable flag + // here (unlike the discarded global-pause design): a per-game toggle is significant + // enough, and a mis-hit hotkey costly enough (it suppresses a specific game's profile), + // that turning it on is its own explicit step. ApplyToggleHotkey only ever registers a + // real binding when this is true AND _toggleBinding.IsSet. + private bool _toggleHotkeyEnabled; + + // Guards the one-time balloon ApplyToggleHotkey raises for a registration failure that + // was not caused by an interactive user action (i.e. the settings-read registration + // point) - without this, a binding that keeps failing (another application owns it) would + // re-balloon on every settings reload. + private bool _hasShownHotkeyFailureBalloon; + + // Set around the ReadVibranceSettings-time "checkBoxToggleHotkeyEnabled.Checked = ..." + // assignment (below) so its own CheckedChanged handler - when the stored value happens to + // differ from the designer default and the setter actually raises the event - does not + // re-persist the exact value it was just given. Not needed for correctness (writing the + // same value back is harmless), only to avoid an INI write on every single startup. + private bool _isLoadingToggleHotkeyEnabled; + + // Same one-set-per-key dedup convention as NvidiaDynamicVibranceProxy's own + // _loggedDisplayFailures/LogDisplayFailureOnce - a no-op toggle press (no configured game + // in the foreground, or the engine is not ready yet) logs once per distinct process name, + // not once per press, so leaning on the key (even with MOD_NOREPEAT, which only throttles + // WM_HOTKEY's own repeat rate, not repeated presses) cannot spam vibranceGUI.log. + private readonly HashSet _loggedNoOpTogglePresses = new HashSet(StringComparer.OrdinalIgnoreCase); + public VibranceGUI( Func, Dictionary>>, IVibranceProxy> getProxy, GraphicsAdapter graphicsAdapter, @@ -133,6 +172,92 @@ public void SetAllowVisible(bool value) _allowVisible = value; } + /// + /// The first of the toggle hotkey's four registration points - see HotkeyRegistration's + /// own header comment and ApplyToggleHotkey below for the rest. The handle exists here + /// even under "-minimized" (SetVisibleCore above calls CreateHandle() when !_allowVisible), + /// but the binding itself is never set yet at this point (ReadVibranceSettings has not run) + /// - this call always returns NotConfigured without ever reaching RegisterHotKey. + /// + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + ApplyToggleHotkey(false); + } + + /// + /// One of the three layers that guarantee the toggle hotkey is unregistered - see + /// HotkeyRegistration's own header comment. Runs before the handle is actually destroyed, + /// which is what lets Release() unregister against the still-valid handle it cached at + /// registration time (see Release's own comment on why it never reads a fresh one). + /// + protected override void OnHandleDestroyed(EventArgs e) + { + _hotkeyRegistration.Release(); + base.OnHandleDestroyed(e); + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == WmHotkey && m.WParam == (IntPtr)HotkeyRegistration.HotkeyId) + { + OnToggleHotkeyPressed(); + } + base.WndProc(ref m); + } + + /// + /// Closes the real gap textBoxToggleHotkey's own Enter/Leave pair leaves open, measured + /// directly rather than assumed: Leave does NOT fire when another window takes activation + /// while the textbox still has focus, nor on Hide() (the minimise-to-tray path), nor on + /// WindowState = Minimized, nor on Close() with the textbox focused - only a focus change + /// to a SIBLING control raises it. Concretely: open settings, click the capture box to + /// look at the current binding (Enter releases it), then alt-tab away or minimise to tray + /// - without this override the hotkey stays unregistered for the rest of the session, + /// silently, while labelToggleHotkeyStatus keeps claiming "Hotkey registered.". + /// ApplyToggleHotkey(false), not (true): a deactivating form must not write the inline + /// status label - showInline is for a live edit, and this form is not being edited when + /// something else takes activation out from under it. + /// + protected override void OnDeactivate(EventArgs e) + { + if (ShouldReleaseHotkeyOnFocusTransition(this.ActiveControl, this.textBoxToggleHotkey)) + { + ApplyToggleHotkey(false); + } + base.OnDeactivate(e); + } + + /// + /// The other half of OnDeactivate above - also measured directly: after deactivate then + /// reactivate (or Hide() then Show()), textBoxToggleHotkey.Enter does NOT fire again, + /// because ActiveControl never actually changed. Re-applying on deactivation without + /// re-releasing here on activation would leave the hotkey live while the capture box has + /// focus, reopening the exact rebinding defect (PR #153's third one) the Enter/Leave pair + /// exists to fix in the first place. + /// + protected override void OnActivated(EventArgs e) + { + base.OnActivated(e); + if (ShouldReleaseHotkeyOnFocusTransition(this.ActiveControl, this.textBoxToggleHotkey)) + { + _hotkeyRegistration.Release(); + } + } + + /// + /// The condition both OnDeactivate and OnActivated above share, pulled out so + /// ProfileToggleFixture can call it directly (same assembly, internal) rather than + /// reflecting into a live Form - which this codebase deliberately never constructs in a + /// self test (VibranceGUI's own constructor calls getProxy(...), touching a real vendor + /// proxy). Pure: no WinForms focus system involved, just the one comparison both + /// overrides need to agree on. + /// + internal static bool ShouldReleaseHotkeyOnFocusTransition(Control activeControl, Control toggleHotkeyTextBox) + { + return activeControl == toggleHotkeyTextBox; + } + private void Form1_Load(object sender, EventArgs e) { SetGuiEnabledFlag(false); @@ -415,9 +540,346 @@ private void SetGuiEnabledFlag(bool flag) this.buttonRemoveProgram.Enabled = flag; this.checkBoxNeverChangeResolutions.Enabled = flag; this.checkBoxNeverChangeColorSettings.Enabled = flag; + this.checkBoxToggleHotkeyEnabled.Enabled = flag; + // AND'd with the checkbox's own state, not just flag alone - otherwise this + // would force the capture controls back on even while the user has left the + // checkbox unchecked (see checkBoxToggleHotkeyEnabled_CheckedChanged). + this.textBoxToggleHotkey.Enabled = flag && _toggleHotkeyEnabled; + this.buttonClearToggleHotkey.Enabled = flag && _toggleHotkeyEnabled; }); } + // ------------------------------------------------------------------ + // Toggle hotkey (upstream #143) - a global RegisterHotKey binding that flips the + // foreground game's profile between its game level and the Windows level. See + // HotkeyRegistration/HotkeyBinding/IHotkeyRegistrar for the seams this wiring drives, and + // ProfileToggleFixture for their regression coverage. + // ------------------------------------------------------------------ + + [DllImport("user32.dll")] + private static extern short GetKeyState(int nVirtKey); + + private const int VkLWin = 0x5B; + private const int VkRWin = 0x5C; + + // KeyEventArgs exposes Control/Alt/Shift directly (e.Control/e.Alt/e.Shift) but has no + // equivalent for the Windows key - Control.ModifierKeys does not cover it either. This is + // the same "high bit of GetKeyState" read Windows itself uses to answer "is this key down + // right now", scoped to just the two Win virtual-key codes. + private static bool IsWinKeyDown() + { + return (GetKeyState(VkLWin) & 0x8000) != 0 || (GetKeyState(VkRWin) & 0x8000) != 0; + } + + /// + /// Applies _toggleBinding against the form's own handle - see the class-level comment + /// above for the four points this is called from, and HotkeyRegistration.Apply for the + /// release-then-register contract underneath it. showInline routes a non-Registered + /// result to the settings-window status label (a live edit) instead of a one-time tray + /// balloon (an unattended registration point, e.g. the settings-read call site). + /// + private HotkeyRegistrationResult ApplyToggleHotkey(bool showInline) + { + // _v.GetVibranceInfo().isInitialized settles once, during the proxy's own + // constructor, and never flips back - a proxy that failed to initialize (bad driver, + // etc.) never will. Registering a hotkey the engine can never act on would still + // intercept that key combination system-wide, stealing it from a game that might + // legitimately want it, for a feature that can only ever no-op (see + // OnToggleHotkeyPressed's own guard) - not worth it just because a binding happens to + // be configured. + if (this.IsDisposed || !this.IsHandleCreated || _v == null || !_v.GetVibranceInfo().isInitialized) + { + return HotkeyRegistrationResult.NotConfigured; + } + + // The checkbox gates registration, not just the presence of a saved binding - a + // binding can be fully configured (and shown in the textbox) while the checkbox is + // still unchecked, and must register nothing until the user turns it on. + // HotkeyRegistration.EffectiveBinding is the real gate, called from here rather than + // inlined, so a fixture that cannot instantiate this Form still reaches the actual + // expression production code runs, not a copy of it. + HotkeyBinding effective = HotkeyRegistration.EffectiveBinding(_toggleHotkeyEnabled, _toggleBinding); + HotkeyRegistrationResult result = _hotkeyRegistration.Apply(this.Handle, effective); + + if (showInline) + { + ApplyToggleHotkeyStatusLabel(result); + } + else if ((result == HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication || + result == HotkeyRegistrationResult.Failed) && !_hasShownHotkeyFailureBalloon) + { + _hasShownHotkeyFailureBalloon = true; + this.notifyIcon.BalloonTipIcon = ToolTipIcon.Warning; + this.notifyIcon.BalloonTipText = result == HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication + ? string.Format("Could not register the toggle hotkey ({0}) - it is already in use by another application.", HotkeyBindingParser.Format(_toggleBinding)) + : string.Format("Could not register the toggle hotkey ({0}).", HotkeyBindingParser.Format(_toggleBinding)); + this.notifyIcon.ShowBalloonTip(250); + } + + return result; + } + + /// + /// The inline, synchronous feedback ApplyToggleHotkey(showInline: true) shows next to the + /// textbox. A successfully registered binding with no real modifier bit set (only + /// MOD_NOREPEAT, which is never user-visible - see HotkeyBindingParser) still shows a + /// warning instead of the plain success text: it is a legal binding, but one that steals + /// the key from the game system-wide the moment it is bound. + /// + private void ApplyToggleHotkeyStatusLabel(HotkeyRegistrationResult result) + { + switch (result) + { + case HotkeyRegistrationResult.NotConfigured: + this.labelToggleHotkeyStatus.ForeColor = SystemColors.ControlText; + this.labelToggleHotkeyStatus.Text = string.Empty; + return; + case HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication: + this.labelToggleHotkeyStatus.ForeColor = Color.Red; + this.labelToggleHotkeyStatus.Text = "Already in use by another application."; + return; + case HotkeyRegistrationResult.Failed: + this.labelToggleHotkeyStatus.ForeColor = Color.Red; + this.labelToggleHotkeyStatus.Text = "Could not register this hotkey."; + return; + } + + if ((_toggleBinding.Modifiers & ~HotkeyBindingParser.ModNoRepeat) == 0) + { + this.labelToggleHotkeyStatus.ForeColor = Color.DarkOrange; + this.labelToggleHotkeyStatus.Text = "No modifier: steals the key from the game."; + return; + } + + this.labelToggleHotkeyStatus.ForeColor = Color.Green; + this.labelToggleHotkeyStatus.Text = "Hotkey registered."; + } + + /// + /// Persists _toggleBinding's canonical text on its own, single-key write - deliberately + /// not routed through ForceSaveVibranceSettings/SaveVibranceSettings' debounced, + /// 8-parameter round trip, which this feature has nothing to do with. + /// + private void SaveToggleHotkeySetting() + { + try + { + new SettingsController().SetToggleHotkey(HotkeyBindingParser.Format(_toggleBinding)); + } + catch (Exception ex) + { + Log(ex); + } + } + + /// + /// Persists the checkbox's own checked state - the same single-key write shape as + /// SaveToggleHotkeySetting beside it, and for the same reason not routed through + /// ForceSaveVibranceSettings/SaveVibranceSettings. + /// + private void SaveToggleHotkeyEnabledSetting() + { + try + { + new SettingsController().SetToggleHotkeyEnabled(_toggleHotkeyEnabled); + } + catch (Exception ex) + { + Log(ex); + } + } + + /// + /// Disables (never hides) the capture controls when unchecked - PR #153 hides them + /// instead, which makes the layout jump and conceals the parked key combination from a + /// user who might just want to glance at what is currently bound. + /// + private void checkBoxToggleHotkeyEnabled_CheckedChanged(object sender, EventArgs e) + { + _toggleHotkeyEnabled = this.checkBoxToggleHotkeyEnabled.Checked; + this.textBoxToggleHotkey.Enabled = _toggleHotkeyEnabled; + this.buttonClearToggleHotkey.Enabled = _toggleHotkeyEnabled; + + if (_isLoadingToggleHotkeyEnabled) + { + // ReadVibranceSettings' own explicit ApplyToggleHotkey(false) call is what applies + // this on load - see its comment. Saving here too would just write back the exact + // value this handler was given, on every single startup. + return; + } + + SaveToggleHotkeyEnabledSetting(); + ApplyToggleHotkey(true); + } + + // Releases the live registration the moment the textbox gains focus, so the CURRENT + // binding stops intercepting keystrokes meant for this field - closes PR #153's third + // defect (you could not rebind to anything containing the key combination already bound, + // because pressing it fired WM_HOTKEY - and toggled the engine - instead of the textbox's + // own KeyDown). + private void textBoxToggleHotkey_Enter(object sender, EventArgs e) + { + _hotkeyRegistration.Release(); + } + + // The other half of the same fix: re-applies _toggleBinding on the way out, whether or + // not KeyDown below ever actually changed it - if the user just clicked in and back out + // again with no key pressed, Enter's Release() above would otherwise leave the ORIGINAL + // binding unregistered with nothing left to restore it. + private void textBoxToggleHotkey_Leave(object sender, EventArgs e) + { + ApplyToggleHotkey(true); + } + + private void textBoxToggleHotkey_KeyDown(object sender, KeyEventArgs e) + { + e.SuppressKeyPress = true; + e.Handled = true; + + Keys keyCode = e.KeyCode; + // A bare modifier press (Ctrl/Alt/Shift/Win alone, before the real key follows) is + // not a complete binding yet - wait for the key that follows it instead of parsing + // "Ctrl" on its own. + if (keyCode == Keys.ControlKey || keyCode == Keys.Menu || keyCode == Keys.ShiftKey || + keyCode == Keys.LWin || keyCode == Keys.RWin) + { + return; + } + + List parts = new List(); + if (e.Control) parts.Add("Ctrl"); + if (e.Alt) parts.Add("Alt"); + if (e.Shift) parts.Add("Shift"); + if (IsWinKeyDown()) parts.Add("Win"); + parts.Add(keyCode.ToString()); + + HotkeyBinding parsedBinding; + if (!HotkeyBindingParser.TryParse(string.Join("+", parts.ToArray()), out parsedBinding)) + { + // A token this handler itself just built (a real Keys.KeyCode name) should never + // fail to parse - defensive only. Leaves the field showing whatever was bound + // before, rather than clearing a working binding over a key this widget cannot + // recognise. + return; + } + + _toggleBinding = parsedBinding; + this.textBoxToggleHotkey.Text = HotkeyBindingParser.Format(_toggleBinding); + SaveToggleHotkeySetting(); + ApplyToggleHotkey(true); + } + + private void buttonClearToggleHotkey_Click(object sender, EventArgs e) + { + _toggleBinding = HotkeyBinding.None; + this.textBoxToggleHotkey.Text = string.Empty; + SaveToggleHotkeySetting(); + ApplyToggleHotkey(true); + } + + /// + /// The WM_HOTKEY handler WndProc dispatches to. Guarded the same way the trackbar/checkbox + /// handlers above are (see e.g. checkBoxPrimaryMonitorOnly_CheckedChanged): _v can be null, + /// or not yet initialized, for the whole span between the handle existing and + /// backgroundWorker_DoWork actually finishing - a hotkey press in that window is a no-op, + /// not a null-reference crash. A failed foreground read (_foregroundWindowReader) is the + /// same kind of no-op - nothing to name in a log line, so nothing is logged for it either. + /// + private void OnToggleHotkeyPressed() + { + if (_v == null || !_v.GetVibranceInfo().isInitialized) + { + return; + } + + IntPtr hWnd; + string processName; + string processImagePath; + if (!_foregroundWindowReader.TryGetForeground(out hWnd, out processName, out processImagePath)) + { + return; + } + + ProfileToggleResult result = _v.ToggleForegroundProfile(hWnd, processName, processImagePath); + ApplyProfileToggleFeedback(result, processName, hWnd); + } + + /// + /// Everything ToggleForegroundProfile's single return value drives, in one place - the + /// tray presentation derives from this one function, not a second .ico that does not + /// exist. A no-op result (no configured game in the foreground, or the engine is not + /// ready yet) is deliberately silent - no balloon, no sound - so a hotkey pressed while + /// browsing the desktop does not interrupt anything; it is still logged once per distinct + /// process name, so "why didn't my hotkey do anything" is answerable from the log without + /// needing a UI signal that would otherwise fire on every ordinary alt-tab. foregroundWindow + /// is resolved to a device name only in the WriteFailed case, which is the only one that + /// needs it - Screen.FromHandle is a real Win32 call, not worth paying on every no-op + /// press (by far the most common outcome: everything that is not a configured game). + /// + private void ApplyProfileToggleFeedback(ProfileToggleResult result, string processName, IntPtr foregroundWindow) + { + switch (result) + { + case ProfileToggleResult.NoConfiguredGameInForeground: + LogNoOpToggleOnce(processName, string.Format( + "Toggle hotkey pressed while \"{0}\" was in the foreground, which has no configured profile - ignored.", processName)); + return; + case ProfileToggleResult.EngineNotReady: + LogNoOpToggleOnce(processName, string.Format( + "Toggle hotkey pressed while \"{0}\" was in the foreground, but vibranceGUI has not finished starting up yet - ignored.", processName)); + return; + case ProfileToggleResult.WriteFailed: + string deviceName = Screen.FromHandle(foregroundWindow).DeviceName; + this.notifyIcon.BalloonTipIcon = ToolTipIcon.Warning; + this.notifyIcon.BalloonTipText = string.Format("Could not toggle \"{0}\"'s profile on display {1}.", processName, deviceName); + this.notifyIcon.ShowBalloonTip(250); + return; + } + + bool toggledOn = result == ProfileToggleResult.ToggledOn; + + // Deliberately does NOT write notifyIcon.Text: a per-game "vibranceGUI - X OFF" tray + // tooltip has nothing that ever resets it, so it would keep asserting X's state long + // after X exits (or after a different game takes the foreground). The balloon below + // is the right place for "X just toggled" - it is transient by nature, so it cannot + // go stale the way a durable tray tooltip would. + this.notifyIcon.BalloonTipIcon = ToolTipIcon.Info; + this.notifyIcon.BalloonTipText = toggledOn + ? string.Format("\"{0}\"'s profile is running again.", processName) + : string.Format("\"{0}\"'s profile is suppressed - back at your Windows level until toggled again.", processName); + this.notifyIcon.ShowBalloonTip(250); + + // Ship it, on, no setting - SystemSounds respects whatever sound scheme (including + // "No Sounds") the user already has picked in Windows, so the opt-out already exists + // at OS level. Plays asynchronously, never blocks WndProc. + // + // NOT Exclamation/Asterisk: checked against this machine's actual sound scheme via + // the registry (HKCU\AppEvents\Schemes\Apps\.Default\\.Current) rather than + // assumed, and SystemAsterisk and SystemExclamation both resolve to the exact same + // file ("Windows Background.wav") there - two "distinct" sounds that would actually + // be identical, exactly the failure mode to check for before shipping this. Hand + // resolves to a different file ("Windows Foreground.wav") on the same machine, so + // Hand/Asterisk is the pair actually used here. Re-verify on the machine this ships + // to if the two ever sound the same again - schemes vary. + if (toggledOn) + { + SystemSounds.Asterisk.Play(); + } + else + { + SystemSounds.Hand.Play(); + } + } + + private void LogNoOpToggleOnce(string processName, string message) + { + string key = processName ?? string.Empty; + if (_loggedNoOpTogglePresses.Add(key)) + { + Program.LogSafely(message); + } + } + private void CleanUp() { try @@ -443,6 +905,10 @@ private void CleanUp() // comment for why leaving it subscribed leaks this form and can fault at shutdown. SystemEvents.DisplaySettingsChanged -= OnDisplaySettingsChanged; ResolutionHelper.ResolutionChangeFailed -= OnResolutionChangeFailed; + // One of the three layers that guarantee the toggle hotkey is unregistered - see + // HotkeyRegistration's own header comment. Idempotent: OnHandleDestroyed (below, + // via the form's own Dispose chain) may already have done this. + _hotkeyRegistration.Release(); } } @@ -622,6 +1088,33 @@ private void ReadVibranceSettings(out int vibranceWindowsLevel, out bool affectP checkBoxPrimaryMonitorOnly.Checked = affectPrimaryMonitorOnly; checkBoxNeverChangeResolutions.Checked = neverSwitchResolution; checkBoxNeverChangeColorSettings.Checked = neverChangeColorSettings; + + // The real registration point for the toggle hotkey - OnHandleCreated's own call + // always finds _toggleBinding still at HotkeyBinding.None (this is the first time + // it is ever read from disk). backgroundWorker_DoWork busy-waits on + // "!IsHandleCreated" and only tests InvokeRequired afterward (see its own comment), + // so the handle is guaranteed to already exist on every path that reaches here - + // the InvokeRequired-without-a-handle trap this.IsHandleCreated guards elsewhere in + // this class cannot bite in this block. + HotkeyBinding parsedToggleBinding; + _toggleBinding = HotkeyBindingParser.TryParse(settingsController.ReadToggleHotkey(), out parsedToggleBinding) + ? parsedToggleBinding + : HotkeyBinding.None; + textBoxToggleHotkey.Text = HotkeyBindingParser.Format(_toggleBinding); + + _toggleHotkeyEnabled = settingsController.ReadToggleHotkeyEnabled(); + // Setting Checked to a value equal to its current (designer-default, unchecked) + // value does not raise CheckedChanged at all - the explicit ApplyToggleHotkey(false) + // call below is what actually applies it on that common path, not this line. When + // the stored value IS true, the setter does raise it - _isLoadingToggleHotkeyEnabled + // is what keeps that from writing the same value straight back to the INI. + _isLoadingToggleHotkeyEnabled = true; + checkBoxToggleHotkeyEnabled.Checked = _toggleHotkeyEnabled; + _isLoadingToggleHotkeyEnabled = false; + textBoxToggleHotkey.Enabled = _toggleHotkeyEnabled; + buttonClearToggleHotkey.Enabled = _toggleHotkeyEnabled; + ApplyToggleHotkey(false); + foreach (ApplicationSetting application in _applicationSettings.ToList()) { if (!File.Exists(application.FileName)) @@ -997,6 +1490,11 @@ private void listApplications_DoubleClick(object sender, EventArgs e) if (result == DialogResult.OK) { ApplicationSetting newSetting = settingsWindow.GetApplicationSetting(); + // "Change executable..." (or any edit that changes Name - Name is + // Path.GetFileNameWithoutExtension of the executable, VibranceSettings. + // resolveApplicationName) moves this profile off the Name the toggle hotkey's + // suppression set is keyed by. + ClearSuppressionIfNameChanged(actualSetting, newSetting.Name); RemoveSettingByFileName(originalFileName); RemoveSettingByFileName(newSetting.FileName); _applicationSettings.Add(newSetting); @@ -1046,13 +1544,36 @@ private void buttonRemoveProgram_Click(object sender, EventArgs e) listApplications.Items[i].ImageIndex--; removeApplicationListItem(eachItem); - _applicationSettings.Remove(_applicationSettings.FirstOrDefault(x => x.FileName.Equals(eachItem.Tag.ToString()))); + ApplicationSetting removedSetting = _applicationSettings.FirstOrDefault(x => x.FileName.Equals(eachItem.Tag.ToString())); + if (removedSetting != null) + { + ClearSuppressionIfNameChanged(removedSetting, null); + _applicationSettings.Remove(removedSetting); + } } RefreshUnconfirmedCache(); ForceSaveVibranceSettings(); } + /// + /// Clears any toggle-hotkey suppression recorded under oldSetting's own Name whenever + /// this profile is removed outright (newName: null) or edited such that Name no longer + /// matches (e.g. "Change executable..."). Without this, a stale suppression under the OLD + /// Name would silently apply to whatever unrelated profile happens to get that Name later + /// (e.g. two different games both shipping a "launcher.exe") - with no action from that + /// user and nothing in the UI explaining why it starts at the Windows level instead of + /// its own. Extracted so ProfileToggleFixture can call it directly (same assembly, + /// internal) instead of reflecting into either private UI handler. + /// + internal static void ClearSuppressionIfNameChanged(ApplicationSetting oldSetting, string newName) + { + if (oldSetting != null && !string.Equals(oldSetting.Name, newName, StringComparison.OrdinalIgnoreCase)) + { + ProfileToggleHelper.SetSuppressed(oldSetting.Name, false); + } + } + private void removeApplicationListItem(ListViewItem item) { Image img = this.listApplications.LargeImageList.Images[item.ImageIndex]; diff --git a/vibrance.GUI/common/VibranceRestoreFixture.cs b/vibrance.GUI/common/VibranceRestoreFixture.cs index 96234da..1c4b197 100644 --- a/vibrance.GUI/common/VibranceRestoreFixture.cs +++ b/vibrance.GUI/common/VibranceRestoreFixture.cs @@ -1015,10 +1015,14 @@ public void SetSaturationOnAllDisplays(int vibranceLevel) LastSetSaturationOnAllDisplaysLevel = vibranceLevel; } - public void SetSaturationOnDisplay(int vibranceLevel, string displayName) + // Always reports success (upstream #143 gave the real interface a bool return) - none + // of this file's own checks need a failure path (ProfileToggleFixture has its own, + // separate fake for that), so behaviour here is unchanged. + public bool SetSaturationOnDisplay(int vibranceLevel, string displayName) { SetSaturationOnDisplayLevels.Add(vibranceLevel); SetSaturationOnDisplayNames.Add(displayName); + return true; } public bool IsAvailable() diff --git a/vibrance.GUI/vibrance.GUI.csproj b/vibrance.GUI/vibrance.GUI.csproj index e5ce99f..b5800ff 100644 --- a/vibrance.GUI/vibrance.GUI.csproj +++ b/vibrance.GUI/vibrance.GUI.csproj @@ -172,6 +172,12 @@ + + + + + +