KeyboardManagerViewModel.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.Diagnostics; 8 using System.Globalization; 9 using System.IO; 10 using System.Linq; 11 using System.Threading; 12 using System.Threading.Tasks; 13 using System.Windows.Input; 14 15 using global::PowerToys.GPOWrapper; 16 using ManagedCommon; 17 using Microsoft.PowerToys.Settings.UI.Library; 18 using Microsoft.PowerToys.Settings.UI.Library.Helpers; 19 using Microsoft.PowerToys.Settings.UI.Library.Interfaces; 20 using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands; 21 using Microsoft.PowerToys.Settings.Utilities; 22 using Microsoft.Win32; 23 24 namespace Microsoft.PowerToys.Settings.UI.ViewModels 25 { 26 public partial class KeyboardManagerViewModel : Observable 27 { 28 private GeneralSettings GeneralSettingsConfig { get; set; } 29 30 private readonly SettingsUtils _settingsUtils; 31 32 private const string PowerToyName = KeyboardManagerSettings.ModuleName; 33 private const string JsonFileType = ".json"; 34 35 // Default editor path. Can be removed once the new WinUI3 editor is released. 36 private const string KeyboardManagerEditorPath = "KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe"; 37 38 // New WinUI3 editor path. Still in development and do NOT use it in production. 39 private const string KeyboardManagerEditorUIPath = "KeyboardManagerEditorUI\\PowerToys.KeyboardManagerEditorUI.exe"; 40 41 private Process editor; 42 43 private enum KeyboardManagerEditorType 44 { 45 KeyEditor = 0, 46 ShortcutEditor, 47 } 48 49 private GpoRuleConfigured _enabledGpoRuleConfiguration; 50 private bool _enabledStateIsGPOConfigured; 51 private bool _isEnabled; 52 53 public KeyboardManagerSettings Settings { get; set; } 54 55 private ICommand _remapKeyboardCommand; 56 private ICommand _editShortcutCommand; 57 private KeyboardManagerProfile _profile; 58 59 private Func<string, int> SendConfigMSG { get; } 60 61 private Func<List<KeysDataModel>, int> FilterRemapKeysList { get; } 62 63 public KeyboardManagerViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, Func<List<KeysDataModel>, int> filterRemapKeysList) 64 { 65 ArgumentNullException.ThrowIfNull(settingsRepository); 66 67 GeneralSettingsConfig = settingsRepository.SettingsConfig; 68 69 InitializeEnabledValue(); 70 71 // set the callback functions value to handle outgoing IPC message. 72 SendConfigMSG = ipcMSGCallBackFunc; 73 FilterRemapKeysList = filterRemapKeysList; 74 75 _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); 76 77 if (_settingsUtils.SettingsExists(PowerToyName)) 78 { 79 try 80 { 81 Settings = _settingsUtils.GetSettingsOrDefault<KeyboardManagerSettings>(PowerToyName); 82 } 83 catch (Exception e) 84 { 85 Logger.LogError($"Exception encountered while reading {PowerToyName} settings.", e); 86 #if DEBUG 87 if (e is ArgumentException || e is ArgumentNullException || e is PathTooLongException) 88 { 89 throw; 90 } 91 #endif 92 } 93 94 // Load profile. 95 if (!LoadProfile()) 96 { 97 _profile = new KeyboardManagerProfile(); 98 } 99 } 100 else 101 { 102 Settings = new KeyboardManagerSettings(); 103 _settingsUtils.SaveSettings(Settings.ToJsonString(), PowerToyName); 104 } 105 } 106 107 private void InitializeEnabledValue() 108 { 109 _enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredKeyboardManagerEnabledValue(); 110 if (_enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) 111 { 112 // Get the enabled state from GPO. 113 _enabledStateIsGPOConfigured = true; 114 _isEnabled = _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; 115 } 116 else 117 { 118 _isEnabled = GeneralSettingsConfig.Enabled.KeyboardManager; 119 } 120 } 121 122 public bool Enabled 123 { 124 get 125 { 126 return _isEnabled; 127 } 128 129 set 130 { 131 if (_enabledStateIsGPOConfigured) 132 { 133 // If it's GPO configured, shouldn't be able to change this state. 134 return; 135 } 136 137 if (_isEnabled != value) 138 { 139 _isEnabled = value; 140 141 GeneralSettingsConfig.Enabled.KeyboardManager = value; 142 OnPropertyChanged(nameof(Enabled)); 143 144 if (!Enabled && editor != null) 145 { 146 editor.CloseMainWindow(); 147 } 148 149 OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig); 150 151 SendConfigMSG(outgoing.ToString()); 152 } 153 } 154 } 155 156 public bool IsEnabledGpoConfigured 157 { 158 get => _enabledStateIsGPOConfigured; 159 } 160 161 // store remappings 162 public List<KeysDataModel> RemapKeys 163 { 164 get 165 { 166 if (_profile != null) 167 { 168 return _profile.RemapKeys.InProcessRemapKeys.Concat(_profile.RemapKeysToText.InProcessRemapKeys).ToList(); 169 } 170 else 171 { 172 return new List<KeysDataModel>(); 173 } 174 } 175 } 176 177 public static List<AppSpecificKeysDataModel> CombineShortcutLists(List<KeysDataModel> globalShortcutList, List<AppSpecificKeysDataModel> appSpecificShortcutList) 178 { 179 string allAppsDescription = "All Apps"; 180 try 181 { 182 var resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; 183 allAppsDescription = resourceLoader.GetString("KeyboardManager_All_Apps_Description"); 184 } 185 catch (Exception ex) 186 { 187 Logger.LogError("Couldn't get translation for All Apps mention in KBM page.", ex); 188 } 189 190 if (globalShortcutList == null && appSpecificShortcutList == null) 191 { 192 return new List<AppSpecificKeysDataModel>(); 193 } 194 else if (globalShortcutList == null) 195 { 196 return appSpecificShortcutList; 197 } 198 else if (appSpecificShortcutList == null) 199 { 200 return globalShortcutList.ConvertAll(x => new AppSpecificKeysDataModel { OriginalKeys = x.OriginalKeys, NewRemapKeys = x.NewRemapKeys, NewRemapString = x.NewRemapString, RunProgramFilePath = x.RunProgramFilePath, OperationType = x.OperationType, OpenUri = x.OpenUri, SecondKeyOfChord = x.SecondKeyOfChord, RunProgramArgs = x.RunProgramArgs, TargetApp = allAppsDescription }).ToList(); 201 } 202 else 203 { 204 return globalShortcutList.ConvertAll(x => new AppSpecificKeysDataModel { OriginalKeys = x.OriginalKeys, NewRemapKeys = x.NewRemapKeys, NewRemapString = x.NewRemapString, RunProgramFilePath = x.RunProgramFilePath, OperationType = x.OperationType, OpenUri = x.OpenUri, SecondKeyOfChord = x.SecondKeyOfChord, RunProgramArgs = x.RunProgramArgs, TargetApp = allAppsDescription }).Concat(appSpecificShortcutList).ToList(); 205 } 206 } 207 208 public List<AppSpecificKeysDataModel> RemapShortcuts 209 { 210 get 211 { 212 if (_profile != null) 213 { 214 return CombineShortcutLists(_profile.RemapShortcuts.GlobalRemapShortcuts, _profile.RemapShortcuts.AppSpecificRemapShortcuts).Concat(CombineShortcutLists(_profile.RemapShortcutsToText.GlobalRemapShortcuts, _profile.RemapShortcutsToText.AppSpecificRemapShortcuts)).ToList(); 215 } 216 else 217 { 218 return new List<AppSpecificKeysDataModel>(); 219 } 220 } 221 } 222 223 public ICommand RemapKeyboardCommand => _remapKeyboardCommand ?? (_remapKeyboardCommand = new RelayCommand(OnRemapKeyboard)); 224 225 public ICommand EditShortcutCommand => _editShortcutCommand ?? (_editShortcutCommand = new RelayCommand(OnEditShortcut)); 226 227 public void OnRemapKeyboard() 228 { 229 OpenEditor((int)KeyboardManagerEditorType.KeyEditor); 230 } 231 232 public void OnEditShortcut() 233 { 234 OpenEditor((int)KeyboardManagerEditorType.ShortcutEditor); 235 } 236 237 private static void BringProcessToFront(Process process) 238 { 239 if (process == null) 240 { 241 return; 242 } 243 244 IntPtr handle = process.MainWindowHandle; 245 if (NativeMethods.IsIconic(handle)) 246 { 247 NativeMethods.ShowWindow(handle, NativeMethods.SWRESTORE); 248 } 249 250 NativeMethods.SetForegroundWindow(handle); 251 } 252 253 private void OpenEditor(int type) 254 { 255 try 256 { 257 if (editor != null && editor.HasExited) 258 { 259 Logger.LogInfo($"Previous instance of {PowerToyName} editor exited"); 260 editor = null; 261 } 262 263 if (editor != null) 264 { 265 Logger.LogInfo($"The {PowerToyName} editor instance {editor.Id} exists. Bringing the process to the front"); 266 BringProcessToFront(editor); 267 return; 268 } 269 270 // Launch the new editor if: 271 // 1. the experimentation toggle is enabled in the settings 272 // 2. the new WinUI3 editor is enabled in the registry. The registry value does not exist by default and is only used for development purposes 273 string editorPath = KeyboardManagerEditorPath; 274 try 275 { 276 // Check if the experimentation toggle is enabled in the settings 277 var settingsUtils = SettingsUtils.Default; 278 bool isExperimentationEnabled = SettingsRepository<GeneralSettings>.GetInstance(settingsUtils).SettingsConfig.EnableExperimentation; 279 280 // Only read the registry value if the experimentation toggle is enabled 281 if (isExperimentationEnabled) 282 { 283 // Read the registry value to determine which editor to launch 284 var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\PowerToys\Keyboard Manager"); 285 if (key != null && (int?)key.GetValue("UseNewEditor") == 1) 286 { 287 editorPath = KeyboardManagerEditorUIPath; 288 } 289 290 // Close the registry key 291 key?.Close(); 292 } 293 } 294 catch (Exception e) 295 { 296 // Fall back to the default editor path if any exception occurs 297 Logger.LogError("Failed to launch the new WinUI3 Editor", e); 298 } 299 300 string path = Path.Combine(Environment.CurrentDirectory, editorPath); 301 Logger.LogInfo($"Starting {PowerToyName} editor from {path}"); 302 303 // InvariantCulture: type represents the KeyboardManagerEditorType enum value 304 editor = Process.Start(path, $"{type.ToString(CultureInfo.InvariantCulture)} {Environment.ProcessId}"); 305 } 306 catch (Exception e) 307 { 308 Logger.LogError($"Exception encountered when opening an {PowerToyName} editor", e); 309 } 310 } 311 312 public void NotifyFileChanged() 313 { 314 OnPropertyChanged(nameof(RemapKeys)); 315 OnPropertyChanged(nameof(RemapShortcuts)); 316 } 317 318 public void RefreshEnabledState() 319 { 320 InitializeEnabledValue(); 321 OnPropertyChanged(nameof(Enabled)); 322 } 323 324 public bool LoadProfile() 325 { 326 var success = true; 327 var readSuccessfully = false; 328 329 // The KBM process out of runner doesn't create the default.json file if it does not exist. 330 string fileName = Settings.Properties.ActiveConfiguration.Value + JsonFileType; 331 var profileExists = false; 332 333 try 334 { 335 // retry loop for reading 336 CancellationTokenSource ts = new CancellationTokenSource(); 337 Task t = Task.Run(() => 338 { 339 while (!readSuccessfully && !ts.IsCancellationRequested) 340 { 341 profileExists = _settingsUtils.SettingsExists(PowerToyName, fileName); 342 if (!profileExists) 343 { 344 break; 345 } 346 else 347 { 348 try 349 { 350 _profile = _settingsUtils.GetSettingsOrDefault<KeyboardManagerProfile>(PowerToyName, fileName); 351 readSuccessfully = true; 352 } 353 catch (Exception e) 354 { 355 Logger.LogError($"Exception encountered when reading {PowerToyName} settings", e); 356 } 357 } 358 359 if (!readSuccessfully) 360 { 361 Task.Delay(500, ts.Token).Wait(ts.Token); 362 } 363 } 364 }); 365 366 var completedInTime = t.Wait(3000, ts.Token); 367 ts.Cancel(); 368 ts.Dispose(); 369 370 if (readSuccessfully) 371 { 372 FilterRemapKeysList(_profile?.RemapKeys?.InProcessRemapKeys); 373 FilterRemapKeysList(_profile?.RemapKeysToText?.InProcessRemapKeys); 374 } 375 else 376 { 377 success = false; 378 } 379 380 if (!completedInTime) 381 { 382 Logger.LogError($"Timeout encountered when loading {PowerToyName} profile"); 383 } 384 } 385 catch (Exception e) 386 { 387 // Failed to load the configuration. 388 Logger.LogError($"Exception encountered when loading {PowerToyName} profile", e); 389 success = false; 390 } 391 392 if (!profileExists) 393 { 394 Logger.LogInfo($"Couldn't load {PowerToyName} profile because it doesn't exist"); 395 } 396 else if (!success) 397 { 398 Logger.LogError($"Couldn't load {PowerToyName} profile"); 399 } 400 401 return success; 402 } 403 } 404 }