/ src / settings-ui / Settings.UI / ViewModels / ShortcutConflictViewModel.cs
ShortcutConflictViewModel.cs
  1  // Copyright (c) Microsoft Corporation
  2  // The Microsoft Corporation licenses this file to you under the MIT license.
  3  // See the LICENSE file in the project root for more information.
  4  
  5  using System;
  6  using System.Collections.Generic;
  7  using System.Collections.ObjectModel;
  8  using System.ComponentModel;
  9  using System.Globalization;
 10  using System.Linq;
 11  using System.Reflection;
 12  using System.Text.Json;
 13  using System.Text.Json.Serialization;
 14  using System.Text.Json.Serialization.Metadata;
 15  using System.Windows.Threading;
 16  using ManagedCommon;
 17  using Microsoft.PowerToys.Settings.UI.Helpers;
 18  using Microsoft.PowerToys.Settings.UI.Library;
 19  using Microsoft.PowerToys.Settings.UI.Library.Helpers;
 20  using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
 21  using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
 22  using Microsoft.PowerToys.Settings.UI.SerializationContext;
 23  using Microsoft.PowerToys.Settings.UI.Services;
 24  using Microsoft.Windows.ApplicationModel.Resources;
 25  
 26  namespace Microsoft.PowerToys.Settings.UI.ViewModels
 27  {
 28      public class ShortcutConflictViewModel : PageViewModelBase
 29      {
 30          private readonly SettingsFactory _settingsFactory;
 31          private readonly Func<string, int> _ipcMSGCallBackFunc;
 32          private readonly Dispatcher _dispatcher;
 33  
 34          private bool _disposed;
 35          private AllHotkeyConflictsData _conflictsData = new();
 36          private ObservableCollection<HotkeyConflictGroupData> _conflictItems = new();
 37          private ResourceLoader resourceLoader;
 38  
 39          public ShortcutConflictViewModel(
 40              SettingsUtils settingsUtils,
 41              ISettingsRepository<GeneralSettings> settingsRepository,
 42              Func<string, int> ipcMSGCallBackFunc)
 43          {
 44              _dispatcher = Dispatcher.CurrentDispatcher;
 45              _ipcMSGCallBackFunc = ipcMSGCallBackFunc ?? throw new ArgumentNullException(nameof(ipcMSGCallBackFunc));
 46              resourceLoader = ResourceLoaderInstance.ResourceLoader;
 47  
 48              // Create SettingsFactory
 49              _settingsFactory = new SettingsFactory(settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)));
 50          }
 51  
 52          public AllHotkeyConflictsData ConflictsData
 53          {
 54              get => _conflictsData;
 55              set
 56              {
 57                  if (Set(ref _conflictsData, value))
 58                  {
 59                      UpdateConflictItems();
 60                  }
 61              }
 62          }
 63  
 64          public ObservableCollection<HotkeyConflictGroupData> ConflictItems
 65          {
 66              get => _conflictItems;
 67              private set => Set(ref _conflictItems, value);
 68          }
 69  
 70          protected override string ModuleName => "ShortcutConflictsWindow";
 71  
 72          /// <summary>
 73          /// Ignore a specific HotkeySettings
 74          /// </summary>
 75          /// <param name="hotkeySettings">The HotkeySettings to ignore</param>
 76          public void IgnoreShortcut(HotkeySettings hotkeySettings)
 77          {
 78              if (hotkeySettings == null)
 79              {
 80                  return;
 81              }
 82  
 83              HotkeyConflictIgnoreHelper.AddToIgnoredList(hotkeySettings);
 84              GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
 85          }
 86  
 87          /// <summary>
 88          /// Remove a HotkeySettings from the ignored list
 89          /// </summary>
 90          /// <param name="hotkeySettings">The HotkeySettings to unignore</param>
 91          public void UnignoreShortcut(HotkeySettings hotkeySettings)
 92          {
 93              if (hotkeySettings == null)
 94              {
 95                  return;
 96              }
 97  
 98              HotkeyConflictIgnoreHelper.RemoveFromIgnoredList(hotkeySettings);
 99              GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
100          }
101  
102          private IHotkeyConfig GetModuleSettings(string moduleKey)
103          {
104              try
105              {
106                  // MouseWithoutBorders and Peek settings may be changed by the logic in the utility as machines connect.
107                  // We need to get a fresh version every time instead of using a repository.
108                  if (string.Equals(moduleKey, MouseWithoutBordersSettings.ModuleName, StringComparison.OrdinalIgnoreCase) ||
109                      string.Equals(moduleKey, PeekSettings.ModuleName, StringComparison.OrdinalIgnoreCase))
110                  {
111                      return _settingsFactory.GetFreshSettings(moduleKey);
112                  }
113  
114                  // For other modules, get the settings from SettingsRepository
115                  return _settingsFactory.GetSettings(moduleKey);
116              }
117              catch (Exception ex)
118              {
119                  System.Diagnostics.Debug.WriteLine($"Error loading settings for {moduleKey}: {ex.Message}");
120                  return null;
121              }
122          }
123  
124          protected override void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e)
125          {
126              _dispatcher.BeginInvoke(() =>
127              {
128                  ConflictsData = e.Conflicts ?? new AllHotkeyConflictsData();
129              });
130          }
131  
132          private void UpdateConflictItems()
133          {
134              var items = new ObservableCollection<HotkeyConflictGroupData>();
135  
136              ProcessConflicts(ConflictsData?.InAppConflicts, false, items);
137              ProcessConflicts(ConflictsData?.SystemConflicts, true, items);
138  
139              ConflictItems = items;
140              OnPropertyChanged(nameof(ConflictItems));
141          }
142  
143          private void ProcessConflicts(IEnumerable<HotkeyConflictGroupData> conflicts, bool isSystemConflict, ObservableCollection<HotkeyConflictGroupData> items)
144          {
145              if (conflicts == null)
146              {
147                  return;
148              }
149  
150              foreach (var conflict in conflicts)
151              {
152                  HotkeySettings hotkey = new(conflict.Hotkey.Win, conflict.Hotkey.Ctrl, conflict.Hotkey.Alt, conflict.Hotkey.Shift, conflict.Hotkey.Key);
153                  var isIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkey);
154                  conflict.ConflictIgnored = isIgnored;
155  
156                  ProcessConflictGroup(conflict, isSystemConflict, isIgnored);
157                  items.Add(conflict);
158              }
159          }
160  
161          private void ProcessConflictGroup(HotkeyConflictGroupData conflict, bool isSystemConflict, bool isIgnored)
162          {
163              foreach (var module in conflict.Modules)
164              {
165                  SetupModuleData(module, isSystemConflict, isIgnored);
166              }
167          }
168  
169          private void SetupModuleData(ModuleHotkeyData module, bool isSystemConflict, bool isIgnored)
170          {
171              try
172              {
173                  var settings = GetModuleSettings(module.ModuleName);
174                  var allHotkeyAccessors = settings.GetAllHotkeyAccessors();
175                  var hotkeyAccessor = allHotkeyAccessors[module.HotkeyID];
176  
177                  if (hotkeyAccessor != null)
178                  {
179                      // Get current hotkey settings (fresh from file) using the accessor's getter
180                      module.HotkeySettings = hotkeyAccessor.Value;
181                      module.HotkeySettings.ConflictDescription = isSystemConflict
182                          ? ResourceLoaderInstance.ResourceLoader.GetString("SysHotkeyConflictTooltipText")
183                          : ResourceLoaderInstance.ResourceLoader.GetString("InAppHotkeyConflictTooltipText");
184  
185                      // Set header using localization key
186                      module.Header = GetHotkeyLocalizationHeader(module.ModuleName, module.HotkeyID, hotkeyAccessor.LocalizationHeaderKey);
187                      module.IsSystemConflict = isSystemConflict;
188  
189                      // Set module display info
190                      var moduleType = settings.GetModuleType();
191                      module.ModuleType = moduleType;
192                      var displayName = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType));
193                      module.DisplayName = displayName;
194                      module.IconPath = ModuleHelper.GetModuleTypeFluentIconName(moduleType);
195  
196                      if (module.HotkeySettings != null)
197                      {
198                          SetConflictProperties(module.HotkeySettings, isSystemConflict);
199                      }
200  
201                      module.PropertyChanged -= OnModuleHotkeyDataPropertyChanged;
202                      module.PropertyChanged += OnModuleHotkeyDataPropertyChanged;
203                  }
204                  else
205                  {
206                      System.Diagnostics.Debug.WriteLine($"Could not find hotkey accessor for {module.ModuleName}.{module.HotkeyID}");
207                  }
208              }
209              catch (Exception ex)
210              {
211                  System.Diagnostics.Debug.WriteLine($"Error setting up module data for {module.ModuleName}: {ex.Message}");
212              }
213          }
214  
215          private void SetConflictProperties(HotkeySettings settings, bool isSystemConflict)
216          {
217              settings.HasConflict = true;
218              settings.IsSystemConflict = isSystemConflict;
219          }
220  
221          private void OnModuleHotkeyDataPropertyChanged(object sender, PropertyChangedEventArgs e)
222          {
223              if (sender is ModuleHotkeyData moduleData && e.PropertyName == nameof(ModuleHotkeyData.HotkeySettings))
224              {
225                  UpdateModuleHotkeySettings(moduleData.ModuleName, moduleData.HotkeyID, moduleData.HotkeySettings);
226              }
227          }
228  
229          private void UpdateModuleHotkeySettings(string moduleName, int hotkeyID, HotkeySettings newHotkeySettings)
230          {
231              try
232              {
233                  var settings = GetModuleSettings(moduleName);
234                  var accessors = settings.GetAllHotkeyAccessors();
235  
236                  var hotkeyAccessor = accessors[hotkeyID];
237  
238                  // Use the accessor's setter to update the hotkey settings
239                  hotkeyAccessor.Value = newHotkeySettings;
240  
241                  if (settings is ISettingsConfig settingsConfig)
242                  {
243                      // No need to save settings here, the runner will call module interface to save it
244                      // SaveSettingsToFile(settings);
245  
246                      // For PowerToys Run, we should set the 'HotkeyChanged' property here to avoid issue #41468
247                      if (string.Equals(moduleName, PowerLauncherSettings.ModuleName, StringComparison.OrdinalIgnoreCase))
248                      {
249                          if (settings is PowerLauncherSettings powerLauncherSettings)
250                          {
251                              powerLauncherSettings.Properties.HotkeyChanged = true;
252                          }
253                      }
254  
255                      // Send IPC notification using the same format as other ViewModels
256                      SendConfigMSG(settingsConfig, moduleName);
257  
258                      // Request updated conflicts after changing a hotkey
259                      GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
260                  }
261              }
262              catch (Exception ex)
263              {
264                  System.Diagnostics.Debug.WriteLine($"Error updating hotkey settings for {moduleName}.{hotkeyID}: {ex.Message}");
265              }
266          }
267  
268          /// <summary>
269          /// Sends IPC notification using the same format as other ViewModels
270          /// </summary>
271          private void SendConfigMSG(ISettingsConfig settingsConfig, string moduleName)
272          {
273              try
274              {
275                  var jsonTypeInfo = GetJsonTypeInfo(settingsConfig.GetType());
276                  var serializedSettings = jsonTypeInfo != null
277                      ? JsonSerializer.Serialize(settingsConfig, jsonTypeInfo)
278                      : JsonSerializer.Serialize(settingsConfig);
279  
280                  string ipcMessage;
281                  if (string.Equals(moduleName, "GeneralSettings", StringComparison.OrdinalIgnoreCase))
282                  {
283                      ipcMessage = string.Format(
284                          CultureInfo.InvariantCulture,
285                          "{{ \"general\": {0} }}",
286                          serializedSettings);
287                  }
288                  else
289                  {
290                      ipcMessage = string.Format(
291                          CultureInfo.InvariantCulture,
292                          "{{ \"powertoys\": {{ \"{0}\": {1} }} }}",
293                          moduleName,
294                          serializedSettings);
295                  }
296  
297                  var result = _ipcMSGCallBackFunc(ipcMessage);
298                  System.Diagnostics.Debug.WriteLine($"Sent IPC notification for {moduleName}, result: {result}");
299              }
300              catch (Exception ex)
301              {
302                  System.Diagnostics.Debug.WriteLine($"Error sending IPC notification for {moduleName}: {ex.Message}");
303              }
304          }
305  
306          private JsonTypeInfo GetJsonTypeInfo(Type settingsType)
307          {
308              try
309              {
310                  var contextType = typeof(SourceGenerationContextContext);
311                  var defaultProperty = contextType.GetProperty("Default", BindingFlags.Public | BindingFlags.Static);
312                  var defaultContext = defaultProperty?.GetValue(null) as JsonSerializerContext;
313  
314                  if (defaultContext != null)
315                  {
316                      var typeInfoProperty = contextType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
317                          .FirstOrDefault(p => p.PropertyType.IsGenericType &&
318                                             p.PropertyType.GetGenericTypeDefinition() == typeof(JsonTypeInfo<>) &&
319                                             p.PropertyType.GetGenericArguments()[0] == settingsType);
320  
321                      return typeInfoProperty?.GetValue(defaultContext) as JsonTypeInfo;
322                  }
323              }
324              catch (Exception ex)
325              {
326                  System.Diagnostics.Debug.WriteLine($"Error getting JsonTypeInfo for {settingsType.Name}: {ex.Message}");
327              }
328  
329              return null;
330          }
331  
332          private string GetHotkeyLocalizationHeader(string moduleName, int hotkeyID, string headerKey)
333          {
334              // Handle AdvancedPaste custom actions
335              if (string.Equals(moduleName, AdvancedPasteSettings.ModuleName, StringComparison.OrdinalIgnoreCase)
336                  && hotkeyID > 9)
337              {
338                  return headerKey;
339              }
340  
341              try
342              {
343                  return resourceLoader.GetString($"{headerKey}/Header");
344              }
345              catch (Exception ex)
346              {
347                  System.Diagnostics.Debug.WriteLine($"Error getting hotkey header for {moduleName}.{hotkeyID}: {ex.Message}");
348                  return headerKey; // Return the key itself as fallback
349              }
350          }
351  
352          protected override void Dispose(bool disposing)
353          {
354              if (!_disposed)
355              {
356                  if (disposing)
357                  {
358                      UnsubscribeFromEvents();
359                  }
360  
361                  _disposed = true;
362              }
363  
364              base.Dispose(disposing);
365          }
366  
367          private void UnsubscribeFromEvents()
368          {
369              try
370              {
371                  if (ConflictItems != null)
372                  {
373                      foreach (var conflictGroup in ConflictItems)
374                      {
375                          if (conflictGroup?.Modules != null)
376                          {
377                              foreach (var module in conflictGroup.Modules)
378                              {
379                                  if (module != null)
380                                  {
381                                      module.PropertyChanged -= OnModuleHotkeyDataPropertyChanged;
382                                  }
383                              }
384                          }
385                      }
386                  }
387              }
388              catch (Exception ex)
389              {
390                  System.Diagnostics.Debug.WriteLine($"Error unsubscribing from events: {ex.Message}");
391              }
392          }
393      }
394  }