diff --git a/.gitignore b/.gitignore index db5ccce..71a00e0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ obj/ *_wpftmp.csproj # Build Output +/artifacts/ publish/ publish-minimal/ publish-small/ diff --git a/README.md b/README.md index 7182cce..e2810da 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ That is the whole fix. Everything below is optional. - Restores corrections automatically on startup, display changes, resume, and driver or game ramp resets. - Provides a perceptual night mode built on CAT16 chromatic adaptation, with fixed-time or sun-position schedules, a manual override, perceptually uniform mired-space fades, and an Ultra Night profile for maximum blue suppression. - Adds per-game Game Lab profiles with calibrated shadow visibility, Reference, Competitive Clarity, Cinematic HDR, and spectral Night Ops intents, multi-display window targeting, and a Gameplay Lock that holds schedule-derived output steady during play. +- Offers an opt-in "Apply while the app is running, even without focus" setting under each profile's Advanced controls. Choose "One specific display" for apps such as MPV on a second monitor. Focused profiles take priority; otherwise the first matching running profile is used. Background app starts and exits are detected within about three seconds. - Checks the Windows-visible game signal chain at launch, including SDR/HDR expectation, DXGI color space, output bit depth, HDR headroom, and measured-calibration status. The dashboard keeps a live receipt of the active intent and every finding. - Auto-disables night mode for chosen applications while they are in the foreground, either on every monitor or only on the displays the application's window covers. Gamma correction and installed calibration profiles stay active. - Supports SDR and HDR colorimeter calibration through ArgyllCMS, including native Windows MHC2 profile installation and verification. diff --git a/docs/gamer-mode-design.md b/docs/gamer-mode-design.md index 02853e2..44457e7 100644 --- a/docs/gamer-mode-design.md +++ b/docs/gamer-mode-design.md @@ -11,7 +11,9 @@ That boundary matters. Anti-cheat systems see an ordinary desktop color-manageme 3. Gloam captures a session record for each selected display and runs the signal checks. 4. The profile contributes gamma, linear-light shadow visibility, and its night policy to the normal calibration settings. 5. The existing coalescer applies only the newest state. The ramp guard continues to restore corrections after a driver or game clears them. -6. Leaving the game clears the session and restores the monitor's ordinary settings. +6. Leaving the game clears the session and restores the monitor's ordinary settings, unless the profile uses "Apply while the app is running, even without focus". + +The optional running policy checks enabled profiles every two seconds and verifies their executable paths. It retains the profile until the app exits, including while minimized when targeting one specific display or all displays. Window targeting uses the running process's main window bounds. A focused profile takes priority; otherwise the first matching running profile in saved order owns the session. Repeated checks preserve the captured Gameplay Lock state. Pause and the emergency hotkey bypass running profiles too. `Win + Shift + G` opens a topmost Game Lab window and keeps the game that yielded focus as the policy target. A newly added profile can therefore activate before the console closes. Field edits are coalesced for 180 ms, persisted, and sent through the foreground resolver. Running executable choices refresh every 2.5 seconds without resetting typed text or an open picker. Repeated focus events preserve the original session time and locked color temperature. diff --git a/docs/release-notes/v1.9.3.md b/docs/release-notes/v1.9.3.md new file mode 100644 index 0000000..d9fb053 --- /dev/null +++ b/docs/release-notes/v1.9.3.md @@ -0,0 +1,7 @@ +# Gloam 1.9.3 + +- Keep an app's profile active while you work on another screen. In the profile's Advanced controls, enable **Apply while the app is running** and choose its target display. +- Find your Gloam version in the tray menu, Dashboard, and Game Lab. +- Gameplay Lock stays steady during background checks, and your usual display settings return when the app closes. + +Thanks for the suggestion in [#9](https://github.com/halideworks/gloam/issues/9)! diff --git a/src/Gloam.Core/GamerMode.cs b/src/Gloam.Core/GamerMode.cs index 2134ebd..1bc8531 100644 --- a/src/Gloam.Core/GamerMode.cs +++ b/src/Gloam.Core/GamerMode.cs @@ -136,6 +136,7 @@ public sealed class GamerProfileRule public string? ExecutablePath { get; set; } public string DisplayName { get; set; } = string.Empty; public bool Enabled { get; set; } = true; + public bool ApplyWhileRunning { get; set; } public GamerPictureIntent PictureIntent { get; set; } = GamerPictureIntent.CompetitiveClarity; public GamerDisplayScope DisplayScope { get; set; } = GamerDisplayScope.WindowDisplays; public string? MonitorDevicePath { get; set; } @@ -184,6 +185,7 @@ public sealed class GamerProfileRule ExecutablePath = ExecutablePath, DisplayName = DisplayName, Enabled = Enabled, + ApplyWhileRunning = ApplyWhileRunning, PictureIntent = PictureIntent, DisplayScope = DisplayScope, MonitorDevicePath = MonitorDevicePath, @@ -239,7 +241,7 @@ public GamerProfileRule Sanitized() return copy; } - public bool SemanticallyEquals(GamerProfileRule? other) + public bool SemanticallyEquals(GamerProfileRule? other, bool includeRecency = true) { if (other == null) return false; GamerProfileRule a = Sanitized(); @@ -248,6 +250,7 @@ public bool SemanticallyEquals(GamerProfileRule? other) && string.Equals(a.ExecutablePath, b.ExecutablePath, StringComparison.OrdinalIgnoreCase) && string.Equals(a.DisplayName, b.DisplayName, StringComparison.Ordinal) && a.Enabled == b.Enabled + && a.ApplyWhileRunning == b.ApplyWhileRunning && a.PictureIntent == b.PictureIntent && a.DisplayScope == b.DisplayScope && string.Equals(a.MonitorDevicePath, b.MonitorDevicePath, StringComparison.OrdinalIgnoreCase) @@ -265,7 +268,7 @@ public bool SemanticallyEquals(GamerProfileRule? other) && a.PaperWhiteNits == b.PaperWhiteNits && a.PeakNits == b.PeakNits && a.BlackLevelNits == b.BlackLevelNits - && a.LastUsedUtc == b.LastUsedUtc; + && (!includeRecency || a.LastUsedUtc == b.LastUsedUtc); } public bool IsHdrCapable() => diff --git a/src/Gloam.Core/GamerModeCoordinator.cs b/src/Gloam.Core/GamerModeCoordinator.cs index 2355c5a..90e22c1 100644 --- a/src/Gloam.Core/GamerModeCoordinator.cs +++ b/src/Gloam.Core/GamerModeCoordinator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; @@ -8,6 +9,8 @@ namespace Gloam.Core { + public sealed record RunningAppObservation(string AppName, string? ExecutablePath, Dxgi.RECT? Bounds); + public sealed record GamerPolicyChange( string ForegroundApp, int NightBlockedDisplayCount, @@ -26,6 +29,8 @@ public sealed class GamerModeCoordinator : IDisposable private readonly SettingsManager _settings; private readonly GammaApplyService _applyService; private readonly Func> _monitorSnapshot; + private readonly Func> _runningAppSnapshot; + private readonly Timer _runningAppTimer; private readonly LatestValueCoalescer _foregroundCoalescer; private readonly object _lastLock = new(); private readonly object _evaluationLock = new(); @@ -46,11 +51,13 @@ private sealed record ForegroundObservation( public GamerModeCoordinator( SettingsManager settings, GammaApplyService applyService, - Func> monitorSnapshot) + Func> monitorSnapshot, + Func>? runningAppSnapshot = null) { _settings = settings ?? throw new ArgumentNullException(nameof(settings)); _applyService = applyService ?? throw new ArgumentNullException(nameof(applyService)); _monitorSnapshot = monitorSnapshot ?? throw new ArgumentNullException(nameof(monitorSnapshot)); + _runningAppSnapshot = runningAppSnapshot ?? (() => CaptureRunningApps(_settings.GamerProfiles)); _foregroundCoalescer = new LatestValueCoalescer( (_, observation, token) => { @@ -62,6 +69,8 @@ public GamerModeCoordinator( }); _settings.GamerSettingsChanged += OnGamerSettingsChanged; _settings.MonitorProfileChanged += OnMonitorProfileChanged; + _runningAppTimer = new Timer(_ => RefreshRunningApps(), null, + TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); } public event Action? PolicyChanged; @@ -107,28 +116,70 @@ public void ObserveForeground(string appName, string? executablePath, Dxgi.RECT? : TimeSpan.FromMilliseconds(250); var observation = new ForegroundObservation(appName, executablePath, appBounds, delay); - lock (_lastLock) _last = observation; - _foregroundCoalescer.Submit(ForegroundKey, observation); + lock (_lastLock) + { + _last = observation; + _foregroundCoalescer.Submit(ForegroundKey, observation); + } } /// Re-runs the latest foreground decision without focus churn. public void ReevaluateLatest(bool immediate = false) { + if (Volatile.Read(ref _disposed) != 0) return; ForegroundObservation observation; - lock (_lastLock) observation = _last; - if (immediate) - { - _foregroundCoalescer.Cancel(ForegroundKey); - Evaluate(observation with { SettleDelay = TimeSpan.Zero }); - } - else + lock (_lastLock) { - _foregroundCoalescer.Submit(ForegroundKey, observation); + observation = _last; + if (immediate) + _foregroundCoalescer.Cancel(ForegroundKey); + else + { + _foregroundCoalescer.Submit(ForegroundKey, observation); + return; + } } + Evaluate(observation); } public void CancelPendingForeground() => _foregroundCoalescer.Cancel(ForegroundKey); + internal void RefreshRunningApps() + { + if (Enabled && _settings.GamerProfiles.Any(profile => profile.Enabled && profile.ApplyWhileRunning)) + ReevaluateLatest(); + } + + internal static IReadOnlyList CaptureRunningApps(IEnumerable profiles) + { + var apps = new List(); + foreach (string appName in profiles + .Where(profile => profile.Enabled && profile.ApplyWhileRunning && + GamerExecutableSafety.IsSafeProfileTarget(profile.AppName, profile.DisplayName)) + .Select(profile => profile.AppName).Distinct(StringComparer.OrdinalIgnoreCase)) + { + foreach (Process process in Process.GetProcessesByName(Path.GetFileNameWithoutExtension(appName))) + { + using (process) + { + try + { + string? path = process.MainModule?.FileName; + IntPtr window = process.MainWindowHandle; + Dxgi.RECT? bounds = window != IntPtr.Zero && User32.GetWindowRect(window, out var rect) + ? rect : null; + apps.Add(new RunningAppObservation(appName, path, bounds)); + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + // Exited or inaccessible processes cannot prove executable identity. + } + } + } + } + return apps; + } + /// /// In-memory fail-safe used by the panic hotkey. It works even if the settings /// directory is unavailable, ensuring a saved profile cannot re-arm itself after @@ -161,6 +212,7 @@ private void Evaluate(ForegroundObservation observation) { lock (_evaluationLock) { + if (Volatile.Read(ref _disposed) != 0) return; IReadOnlyList activeMonitors = _monitorSnapshot() ?? Array.Empty(); AppExclusionRule? exclusion = _settings.ExcludedApps.FirstOrDefault(rule => @@ -184,10 +236,16 @@ private void Evaluate(ForegroundObservation observation) GamerProfileRule? profile = null; if (Enabled) { - profile = _settings.GamerProfiles.FirstOrDefault(candidate => + IReadOnlyList profiles = _settings.GamerProfiles; + IReadOnlyList runningApps = profiles.Any(candidate => + candidate.Enabled && candidate.ApplyWhileRunning) + ? _runningAppSnapshot() : Array.Empty(); + profile = profiles.FirstOrDefault(candidate => candidate.Enabled && GamerExecutableSafety.IsSafeProfileTarget(candidate.AppName, candidate.DisplayName) && - MatchesForeground(candidate, observation.AppName, observation.ExecutablePath)); + MatchesForeground(candidate, observation.AppName, observation.ExecutablePath) && + (!candidate.ApplyWhileRunning || runningApps.Any(app => + MatchesForeground(candidate, app.AppName, app.ExecutablePath)))); if (profile != null) { foreach (MonitorInfo monitor in activeMonitors) @@ -196,13 +254,33 @@ private void Evaluate(ForegroundObservation observation) assignments.Add(new GamerSessionAssignment(monitor, profile)); } } + if (assignments.Count == 0) + { + foreach (GamerProfileRule candidate in profiles.Where(candidate => + candidate.Enabled && candidate.ApplyWhileRunning)) + { + foreach (RunningAppObservation app in runningApps.Where(app => + MatchesForeground(candidate, app.AppName, app.ExecutablePath))) + { + foreach (MonitorInfo monitor in activeMonitors) + { + if (TargetsMonitor(candidate, monitor, app.Bounds, activeMonitors.Count) && + assignments.All(assignment => assignment.Monitor.HMonitor != monitor.HMonitor)) + assignments.Add(new GamerSessionAssignment(monitor, candidate)); + } + } + if (assignments.Count == 0) continue; + profile = candidate; + break; + } + } } bool blockChanged = _applyService.UpdateBlockedMonitors(blocked); bool gamerChanged = _applyService.UpdateActiveGamerSessions(assignments); if (gamerChanged && profile != null && assignments.Count > 0) { - string activatedApp = observation.AppName; + string activatedApp = profile.AppName; _ = Task.Run(() => _settings.MarkGamerProfileUsed(activatedApp, DateTime.UtcNow)); } @@ -286,7 +364,9 @@ public void Dispose() if (Interlocked.Exchange(ref _disposed, 1) != 0) return; _settings.GamerSettingsChanged -= OnGamerSettingsChanged; _settings.MonitorProfileChanged -= OnMonitorProfileChanged; + _runningAppTimer.Dispose(); _foregroundCoalescer.Dispose(); + lock (_evaluationLock) { } } } } diff --git a/src/Gloam.Core/GammaApplyService.cs b/src/Gloam.Core/GammaApplyService.cs index 21dc24a..740b3f6 100644 --- a/src/Gloam.Core/GammaApplyService.cs +++ b/src/Gloam.Core/GammaApplyService.cs @@ -179,7 +179,7 @@ public bool UpdateActiveGamerSessions(IEnumerable? assig int signalKey = GamerSignalKey(monitor, monitorProfile, calibrationStatus); if (_activeGamerSessions.TryGetValue(monitor.HMonitor, out var previous) && - previous.Profile.SemanticallyEquals(profile) && + previous.Profile.SemanticallyEquals(profile, includeRecency: false) && previous.SignalKey == signalKey && string.Equals(previous.Monitor.MonitorDevicePath, monitor.MonitorDevicePath, StringComparison.OrdinalIgnoreCase)) @@ -264,7 +264,7 @@ private static bool GamerSessionMapsEqual( foreach (var pair in right) { if (!left.TryGetValue(pair.Key, out var existing)) return false; - if (!existing.Profile.SemanticallyEquals(pair.Value.Profile) || + if (!existing.Profile.SemanticallyEquals(pair.Value.Profile, includeRecency: false) || existing.SignalKey != pair.Value.SignalKey || !string.Equals(existing.Monitor.MonitorDevicePath, pair.Value.Monitor.MonitorDevicePath, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Gloam/DashboardWindow.xaml b/src/Gloam/DashboardWindow.xaml index 7005c62..e77e73a 100644 --- a/src/Gloam/DashboardWindow.xaml +++ b/src/Gloam/DashboardWindow.xaml @@ -260,7 +260,7 @@ - @@ -910,6 +910,9 @@ ItemsSource="{Binding DataContext.GamerMode.AvailableDisplays, RelativeSource={RelativeSource AncestorType=Window}}" DisplayMemberPath="Label" SelectedValuePath="DevicePath" SelectedValue="{Binding MonitorDevicePath, Mode=TwoWay}"/> + diff --git a/src/Gloam/GameLabWindow.xaml b/src/Gloam/GameLabWindow.xaml index a81236e..1448b3c 100644 --- a/src/Gloam/GameLabWindow.xaml +++ b/src/Gloam/GameLabWindow.xaml @@ -538,7 +538,12 @@ + + diff --git a/src/Gloam/Gloam.csproj b/src/Gloam/Gloam.csproj index 573d638..137fcaf 100644 --- a/src/Gloam/Gloam.csproj +++ b/src/Gloam/Gloam.csproj @@ -20,7 +20,7 @@ - 1.9.2 + 1.9.3 $(Version).0 $(Version).0 $(Version) diff --git a/src/Gloam/ViewModels/DashboardViewModel.cs b/src/Gloam/ViewModels/DashboardViewModel.cs index 0ed9ee7..1c16f1a 100644 --- a/src/Gloam/ViewModels/DashboardViewModel.cs +++ b/src/Gloam/ViewModels/DashboardViewModel.cs @@ -168,7 +168,7 @@ public bool IsGamerModeEnabled public string GamerModeStateTitle => IsGamerModeEnabled ? "GAME MODE · ON" : "GAME MODE · PAUSED"; public string GamerModeStateDetails => IsGamerModeEnabled - ? "Saved looks switch on when each game takes focus." + ? "Saved looks activate on focus, or while running when enabled in a profile." : "All game profiles are bypassed. Your settings stay saved."; public string GamerModeToggleLabel => IsGamerModeEnabled ? "Pause all profiles" : "Resume game mode"; diff --git a/src/Gloam/ViewModels/GamerModeItem.cs b/src/Gloam/ViewModels/GamerModeItem.cs index 958583d..65a9a4d 100644 --- a/src/Gloam/ViewModels/GamerModeItem.cs +++ b/src/Gloam/ViewModels/GamerModeItem.cs @@ -662,6 +662,17 @@ public GamerPictureIntent PictureIntent } } + public bool ApplyWhileRunning + { + get => _profile.ApplyWhileRunning; + set + { + if (_profile.ApplyWhileRunning == value) return; + _profile.ApplyWhileRunning = value; + OnPropertyChanged(); + } + } + public GamerDisplayScope DisplayScope { get => _profile.DisplayScope; diff --git a/src/Gloam/ViewModels/TrayViewModel.cs b/src/Gloam/ViewModels/TrayViewModel.cs index 13d2a6c..6abf883 100644 --- a/src/Gloam/ViewModels/TrayViewModel.cs +++ b/src/Gloam/ViewModels/TrayViewModel.cs @@ -250,7 +250,7 @@ private void OpenMatchDisplays() var matchWindow = new DisplayMatchWindow(_monitorManager, _settingsManager, () => OnUiThread(ApplyAll)); matchWindow.Show(); } - + // The downloaded-and-ready update awaiting apply. Null until one is detected and // fully downloaded. Scheduled silently for the next normal app exit/restart. private UpdateInfo? _pendingUpdate; @@ -455,7 +455,7 @@ private static void OnUiThread(Action action) Log.Error($"TrayViewModel.OnUiThread: {ex.Message}"); } } - + private void RegisterHotkeys() { if (_hotkeyManager == null) return; @@ -463,18 +463,18 @@ private void RegisterHotkeys() // Win+Shift+F1 -> Gamma 2.2 int id22 = _hotkeyManager.Register(Key.F1, ModifierKeys.Windows | ModifierKeys.Shift); if (id22 > 0) _hotkeyActions[id22] = m => ApplyProfile(m, GammaMode.Gamma22); - + // Win+Shift+F2 -> Gamma 2.4 int id24 = _hotkeyManager.Register(Key.F2, ModifierKeys.Windows | ModifierKeys.Shift); if (id24 > 0) _hotkeyActions[id24] = m => ApplyProfile(m, GammaMode.Gamma24); - + // Win+Shift+F3 -> Default int idDef = _hotkeyManager.Register(Key.F3, ModifierKeys.Windows | ModifierKeys.Shift); if (idDef > 0) _hotkeyActions[idDef] = m => ApplyProfile(m, GammaMode.WindowsDefault); - + // Panic: Win+Shift+F4 _panicId = _hotkeyManager.Register(Key.F4, ModifierKeys.Windows | ModifierKeys.Shift); - + // Night Mode Toggle: Win+Shift+N _nightModeToggleId = _hotkeyManager.Register(Key.N, ModifierKeys.Windows | ModifierKeys.Shift); @@ -491,7 +491,7 @@ private void OnHotkeyPressed(int id) PanicAll(); return; } - + if (id == _nightModeToggleId) { // Toggle night mode on/off. Not persisted across restarts (avoids a stuck-off @@ -516,7 +516,7 @@ private void OnHotkeyPressed(int id) var monitor = GetFocusedMonitor(); if (monitor != null) { - action(monitor); + action(monitor); } else { @@ -526,7 +526,7 @@ private void OnHotkeyPressed(int id) } } } - + private MonitorInfo? GetFocusedMonitor() { // A global hotkey should act on the monitor the user is pointing at: the @@ -688,11 +688,11 @@ private void CancelPendingForegroundPolicy() private void ApplyProfile(MonitorInfo monitor, GammaMode mode) { RequestApply(monitor, mode); - + // Refresh to update checkmarks RefreshMonitors(); } - + public void RequestApply( MonitorInfo monitor, GammaMode mode, @@ -792,7 +792,7 @@ private void ToggleStartup() // items out from under the still-open tray menu. if (_startupItem != null) _startupItem.Header = StartupLabel(); } - + private void OnMonitorProfileChanged(MonitorInfo monitor, GammaMode mode) { // Persist to settings @@ -944,10 +944,11 @@ public void RefreshMonitors() } var monitors = enumerated; Log.Info($"TrayViewModel: Enumerated {monitors.Count} monitors."); - + TrayItems.Add(new ActionViewModel(TrayToolTipText, null, staysOpenOnClick: true)); + if (monitors.Count == 0) { - TrayItems.Add(new ActionViewModel("No Monitors Found", RefreshCommand)); + TrayItems.Add(new ActionViewModel("No Monitors Found", RefreshCommand)); } else { @@ -959,7 +960,7 @@ public void RefreshMonitors() _gameModeItem = new ActionViewModel(GamerModeLabel(), ToggleGamerModeCommand, staysOpenOnClick: true); TrayItems.Add(_gameModeItem); TrayItems.Add(new ActionViewModel("───────────", null)); - + int index = 1; foreach (var m in monitors) { @@ -969,14 +970,14 @@ public void RefreshMonitors() { m.CurrentGamma = savedMode.Value; } - else if (!string.IsNullOrEmpty(m.MonitorDevicePath) && + else if (!string.IsNullOrEmpty(m.MonitorDevicePath) && _savedConfigs.TryGetValue(m.MonitorDevicePath, out var saved)) { // Fallback to in-memory cache m.CurrentGamma = saved.CurrentGamma; m.SdrWhiteLevel = saved.SdrWhiteLevel; } - + // Update saved mapping if (!string.IsNullOrEmpty(m.MonitorDevicePath)) { @@ -992,7 +993,7 @@ public void RefreshMonitors() index++; } } - + // Startup toggle with checkmark _startupItem = new ActionViewModel(StartupLabel(), StartupCommand, staysOpenOnClick: true); TrayItems.Add(_startupItem); diff --git a/tests/Gloam.Tests/DashboardAppPickerWpfTests.cs b/tests/Gloam.Tests/DashboardAppPickerWpfTests.cs index 01d0dee..4d4d9ac 100644 --- a/tests/Gloam.Tests/DashboardAppPickerWpfTests.cs +++ b/tests/Gloam.Tests/DashboardAppPickerWpfTests.cs @@ -57,6 +57,8 @@ public void Picker_StaysOpenAcrossLiveRefresh_AndAddButtonPersistsTypedApp() Pump(TimeSpan.FromMilliseconds(100)); var viewModel = Assert.IsType(window.DataContext); + Assert.Equal(update.DisplayVersion, + Assert.IsType(window.FindName("AppVersionLabel")).Text); var exclusionItem = Assert.Single(viewModel.Items.OfType()); exclusionItem.RunningApps.Add("photoshop.exe"); @@ -113,6 +115,15 @@ public void Picker_StaysOpenAcrossLiveRefresh_AndAddButtonPersistsTypedApp() editor.SetExecutablePath(Path.Combine(root, "games", "arena.exe")); editor.Enabled = true; Pump(TimeSpan.FromMilliseconds(350)); + viewModel.IsGameProfileAdvancedExpanded = true; + window.UpdateLayout(); + var dashboardRunning = Assert.IsType( + FindVisualChild(window, "ApplyWhileRunningBox")); + Assert.False(dashboardRunning.IsChecked); + dashboardRunning.IsChecked = true; + Pump(TimeSpan.FromMilliseconds(350)); + Assert.True(editor.ApplyWhileRunning); + Assert.True(Assert.Single(settings.GamerProfiles).ApplyWhileRunning); Assert.Equal("Night Play", editor.AvailablePictureIntents[3].ToString()); @@ -260,6 +271,12 @@ public void Picker_StaysOpenAcrossLiveRefresh_AndAddButtonPersistsTypedApp() AssertAligned(targetDisplaysBounds.Left, peakNitsBounds.Left, melanopicBounds.Left); AssertAligned(targetDisplaysBounds.Right, peakNitsBounds.Right, melanopicBounds.Right); Assert.True(paperWhiteBounds.Right < peakNitsBounds.Left); + var runningBox = Assert.IsType( + FindVisualChild(viewport, "ApplyWhileRunningBox")); + Assert.True(runningBox.IsChecked); + runningBox.IsChecked = false; + Pump(TimeSpan.FromMilliseconds(350)); + Assert.False(settings.GamerProfiles.Single(profile => profile.AppName == "arena.exe").ApplyWhileRunning); SaveScreenshotIfRequested(gameLabWindow, "game-lab-advanced.png"); advanced.IsExpanded = false; @@ -396,7 +413,8 @@ private static void RunSta(Action body) { try { body(); } catch (Exception ex) { failure = ExceptionDispatchInfo.Capture(ex); } - }) { IsBackground = true }; + }) + { IsBackground = true }; thread.SetApartmentState(ApartmentState.STA); thread.Start(); thread.Join(); diff --git a/tests/Gloam.Tests/RunningProfileTests.cs b/tests/Gloam.Tests/RunningProfileTests.cs new file mode 100644 index 0000000..3be658e --- /dev/null +++ b/tests/Gloam.Tests/RunningProfileTests.cs @@ -0,0 +1,284 @@ +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using Gloam.Core; +using Gloam.Interop; +using Gloam.ViewModels; +using Xunit; + +namespace Gloam.Tests +{ + public sealed class RunningProfileTests + { + [Fact] + public void ApplyWhileRunning_DefaultsOffAndSurvivesEditorCloneAndPersistence() + { + var original = JsonSerializer.Deserialize("{\"AppName\":\"mpv.exe\"}")!; + Assert.False(original.ApplyWhileRunning); + var editor = new GamerProfileEditorItem(original); + string? changedProperty = null; + editor.PropertyChanged += (_, args) => changedProperty = args.PropertyName; + editor.ApplyWhileRunning = true; + Assert.Equal(nameof(editor.ApplyWhileRunning), changedProperty); + GamerProfileRule edited = editor.ToRule(); + Assert.True(edited.Clone().ApplyWhileRunning); + Assert.False(original.SemanticallyEquals(edited)); + Assert.True(edited.SemanticallyEquals(edited.Clone())); + GamerProfileRule recent = edited.Clone(); + recent.LastUsedUtc = DateTime.UtcNow; + Assert.False(edited.SemanticallyEquals(recent)); + Assert.True(edited.SemanticallyEquals(recent, includeRecency: false)); + recent.GameplayLock = !edited.GameplayLock; + Assert.False(edited.SemanticallyEquals(recent, includeRecency: false)); + + WithSettings(settings => + { + edited.ExecutablePath = @"C:\Players\mpv.exe"; + Assert.True(settings.TrySetGamerProfiles(new[] { edited })); + Assert.True(Assert.Single(new SettingsManager().GamerProfiles).ApplyWhileRunning); + }); + } + + [Theory] + [InlineData(GamerDisplayScope.SpecificDisplay, false, 1)] + [InlineData(GamerDisplayScope.AllDisplays, false, 2)] + [InlineData(GamerDisplayScope.WindowDisplays, true, 1)] + [InlineData(GamerDisplayScope.WindowDisplays, false, 0)] + public void BackgroundProfile_StartsWithoutFocusAndRestoresAfterExit( + GamerDisplayScope scope, bool hasWindow, int expectedDisplays) + { + WithSettings(settings => + { + var monitors = Monitors(); + GamerProfileRule profile = Profile("mpv.exe"); + profile.DisplayScope = scope; + Assert.True(settings.TrySetGamerProfiles(new[] { profile })); + Assert.True(settings.TrySetGamerModeEnabled(true)); + using var night = new NightModeService(new NightModeSettings()); + using var apply = new GammaApplyService(new DispwinRunner(), settings, night, + (_, _) => GamerCalibrationStatus.None); + IReadOnlyList running = Array.Empty(); + using var coordinator = new GamerModeCoordinator(settings, apply, () => monitors, () => running); + coordinator.ObserveForeground("editor.exe", @"C:\Tools\editor.exe", monitors[0].MonitorBounds); + coordinator.ReevaluateLatest(immediate: true); + Assert.Empty(coordinator.ActiveSessions); + + running = new[] { new RunningAppObservation(profile.AppName, profile.ExecutablePath, + hasWindow ? monitors[1].MonitorBounds : null) }; + coordinator.RefreshRunningApps(); + Assert.True(coordinator.WaitForIdle(TimeSpan.FromSeconds(5))); + Assert.Equal(expectedDisplays, coordinator.ActiveSessions.Count); + if (expectedDisplays == 1) + Assert.Equal("display-2", Assert.Single(coordinator.ActiveSessions).MonitorDevicePath); + DateTime? started = coordinator.ActiveSessions.FirstOrDefault()?.StartedUtc; + coordinator.ReevaluateLatest(immediate: true); + Assert.Equal(started, coordinator.ActiveSessions.FirstOrDefault()?.StartedUtc); + + running = Array.Empty(); + coordinator.RefreshRunningApps(); + Assert.True(coordinator.WaitForIdle(TimeSpan.FromSeconds(5))); + Assert.Empty(coordinator.ActiveSessions); + }); + } + + [Fact] + public void BackgroundProfile_RespectsPathPauseDisablePanicAndForegroundPriority() + { + WithSettings(settings => + { + var monitors = Monitors(); + var background = Profile("mpv.exe"); + var focused = Profile("arena.exe"); + focused.ApplyWhileRunning = false; + focused.MonitorDevicePath = "display-1"; + Assert.True(settings.TrySetGamerProfiles(new[] { background, focused })); + Assert.True(settings.TrySetGamerModeEnabled(true)); + using var night = new NightModeService(new NightModeSettings()); + using var apply = new GammaApplyService(new DispwinRunner(), settings, night, + (_, _) => GamerCalibrationStatus.None); + IReadOnlyList running = new[] + { + new RunningAppObservation("mpv.exe", @"D:\Other\mpv.exe", null), + new RunningAppObservation("arena.exe", focused.ExecutablePath, null) + }; + using var coordinator = new GamerModeCoordinator(settings, apply, () => monitors, () => running); + coordinator.ReevaluateLatest(immediate: true); + Assert.Empty(coordinator.ActiveSessions); + running = new[] { new RunningAppObservation("MPV.EXE", background.ExecutablePath, null) }; + coordinator.ReevaluateLatest(immediate: true); + Assert.Equal("mpv.exe", Assert.Single(coordinator.ActiveSessions).AppName); + + coordinator.ObserveForeground(focused.AppName, focused.ExecutablePath, monitors[0].MonitorBounds); + coordinator.ReevaluateLatest(immediate: true); + Assert.Equal("arena.exe", Assert.Single(coordinator.ActiveSessions).AppName); + coordinator.ObserveForeground("editor.exe", null, monitors[0].MonitorBounds); + coordinator.ReevaluateLatest(immediate: true); + Assert.Equal("mpv.exe", Assert.Single(coordinator.ActiveSessions).AppName); + + Assert.True(coordinator.TrySetEnabled(false)); + coordinator.ReevaluateLatest(immediate: true); + Assert.Empty(coordinator.ActiveSessions); + Assert.True(coordinator.TrySetEnabled(true)); + coordinator.ReevaluateLatest(immediate: true); + Assert.Single(coordinator.ActiveSessions); + background.Enabled = false; + Assert.True(settings.TrySetGamerProfiles(new[] { background, focused })); + coordinator.ReevaluateLatest(immediate: true); + Assert.Empty(coordinator.ActiveSessions); + background.Enabled = true; + Assert.True(settings.TrySetGamerProfiles(new[] { background, focused })); + coordinator.ReevaluateLatest(immediate: true); + Assert.Single(coordinator.ActiveSessions); + coordinator.EmergencySuspend(); + coordinator.ReevaluateLatest(immediate: true); + Assert.Empty(coordinator.ActiveSessions); + }); + } + + [Fact] + public void ConcurrentPollingAndFocus_KeepTheLatestForegroundAndPauseClearsIt() + { + WithSettings(settings => + { + var first = Profile("first.exe"); + var second = Profile("second.exe"); + settings.SetGamerProfiles(new[] { first, second }); + settings.SetGamerModeEnabled(true); + using var night = new NightModeService(new NightModeSettings()); + using var apply = new GammaApplyService(new DispwinRunner(), settings, night, + (_, _) => GamerCalibrationStatus.None); + using var coordinator = new GamerModeCoordinator(settings, apply, Monitors, () => new[] + { + new RunningAppObservation(first.AppName, first.ExecutablePath, null), + new RunningAppObservation(second.AppName, second.ExecutablePath, null) + }); + for (int round = 0; round < 12; round++) + { + Parallel.For(0, 64, i => + { + if (i % 2 == 0) coordinator.RefreshRunningApps(); + else + { + GamerProfileRule profile = i % 4 == 1 ? first : second; + coordinator.ObserveForeground(profile.AppName, profile.ExecutablePath, null); + } + }); + Assert.True(coordinator.WaitForIdle(TimeSpan.FromSeconds(5))); + Assert.Equal(coordinator.LastExternalForegroundApp, + Assert.Single(coordinator.ActiveSessions).AppName); + } + Parallel.Invoke(() => coordinator.TrySetEnabled(false), + () => coordinator.ObserveForeground(first.AppName, first.ExecutablePath, null)); + Assert.Empty(coordinator.ActiveSessions); + Assert.True(coordinator.WaitForIdle(TimeSpan.FromSeconds(5))); + Assert.Empty(coordinator.ActiveSessions); + }); + } + + [Fact] + public void FocusedRunningProfile_ExitsWhileGloamHasFocus() + { + WithSettings(settings => + { + var profile = Profile("mpv.exe"); + settings.SetGamerProfiles(new[] { profile }); + settings.SetGamerModeEnabled(true); + using var night = new NightModeService(new NightModeSettings()); + using var apply = new GammaApplyService(new DispwinRunner(), settings, night, + (_, _) => GamerCalibrationStatus.None); + IReadOnlyList running = new[] + { + new RunningAppObservation(profile.AppName, profile.ExecutablePath, null) + }; + using var coordinator = new GamerModeCoordinator(settings, apply, Monitors, () => running); + coordinator.ObserveForeground(profile.AppName, profile.ExecutablePath, Monitors()[1].MonitorBounds); + coordinator.ReevaluateLatest(immediate: true); + Assert.Single(coordinator.ActiveSessions); + coordinator.ObserveForeground("Gloam.exe", null, null); + Assert.Equal("mpv.exe", coordinator.LastExternalForegroundApp); + running = Array.Empty(); + coordinator.RefreshRunningApps(); + Assert.True(coordinator.WaitForIdle(TimeSpan.FromSeconds(5))); + Assert.Empty(coordinator.ActiveSessions); + }); + } + + [Fact] + public void Polling_DetectsBackgroundLaunchWithoutForegroundEventAndStopsOnDispose() + { + WithSettings(settings => + { + var profile = Profile("mpv.exe"); + settings.SetGamerProfiles(new[] { profile }); + settings.SetGamerModeEnabled(true); + using var night = new NightModeService(new NightModeSettings()); + using var apply = new GammaApplyService(new DispwinRunner(), settings, night, + (_, _) => GamerCalibrationStatus.None); + using var activated = new ManualResetEventSlim(); + int snapshots = 0; + using var coordinator = new GamerModeCoordinator(settings, apply, Monitors, () => + { + Interlocked.Increment(ref snapshots); + return new[] { new RunningAppObservation(profile.AppName, profile.ExecutablePath, null) }; + }); + coordinator.PolicyChanged += _ => activated.Set(); + Assert.True(activated.Wait(TimeSpan.FromSeconds(6)), "Background polling did not activate the profile."); + Assert.Equal("mpv.exe", Assert.Single(coordinator.ActiveSessions).AppName); + coordinator.Dispose(); + int disposedSnapshots = snapshots; + coordinator.RefreshRunningApps(); + coordinator.ReevaluateLatest(immediate: true); + Assert.Equal(disposedSnapshots, snapshots); + }); + } + + [Fact] + public void NativeProcessSnapshot_ResolvesExecutableAndSkipsDisabledProfiles() + { + using Process process = Process.GetCurrentProcess(); + var profile = Profile(process.ProcessName + ".exe"); + profile.ExecutablePath = process.MainModule!.FileName; + Assert.Contains(GamerModeCoordinator.CaptureRunningApps(new[] { profile }), app => + GamerModeCoordinator.MatchesForeground(profile, app.AppName, app.ExecutablePath)); + profile.Enabled = false; + Assert.Empty(GamerModeCoordinator.CaptureRunningApps(new[] { profile })); + profile.Enabled = true; + profile.ApplyWhileRunning = false; + Assert.Empty(GamerModeCoordinator.CaptureRunningApps(new[] { profile })); + } + + private static GamerProfileRule Profile(string name) => new() + { + AppName = name, + ExecutablePath = @"C:\Apps\" + name, + ApplyWhileRunning = true, + DisplayScope = GamerDisplayScope.SpecificDisplay, + MonitorDevicePath = "display-2" + }; + + private static MonitorInfo[] Monitors() => new[] + { + new MonitorInfo { HMonitor = (IntPtr)101, MonitorDevicePath = "display-1", + MonitorBounds = new Dxgi.RECT { Left = 0, Top = 0, Right = 1920, Bottom = 1080 } }, + new MonitorInfo { HMonitor = (IntPtr)102, MonitorDevicePath = "display-2", + MonitorBounds = new Dxgi.RECT { Left = 1920, Top = 0, Right = 3840, Bottom = 1080 } } + }; + + private static void WithSettings(Action body) + { + string oldData = AppPaths.DataDir; + string oldRoaming = AppPaths.RoamingDataDir; + string root = Path.Combine(Path.GetTempPath(), "GloamRunningProfileTests", Guid.NewGuid().ToString("N")); + try + { + AppPaths.UseDataDirectoriesForCurrentProcess(root, Path.Combine(root, "roaming")); + body(new SettingsManager()); + } + finally + { + AppPaths.UseDataDirectoriesForCurrentProcess(oldData, oldRoaming); + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + } +}