diff --git a/dvmconsole/Controls/ChannelBox.xaml.cs b/dvmconsole/Controls/ChannelBox.xaml.cs index cff31b0..48a10c5 100644 --- a/dvmconsole/Controls/ChannelBox.xaml.cs +++ b/dvmconsole/Controls/ChannelBox.xaml.cs @@ -619,6 +619,14 @@ namespace dvmconsole.Controls selectedChannelsManager.RemoveSelectedChannel(this); } + public void TriggerPTTState(bool pttState) + { + if (!IsSelected) + return; + PttState = pttState; + PTTButtonClicked?.Invoke(null, this); + } + /// /// /// diff --git a/dvmconsole/GlobalKeyboardHook.cs b/dvmconsole/GlobalKeyboardHook.cs new file mode 100644 index 0000000..ba0a727 --- /dev/null +++ b/dvmconsole/GlobalKeyboardHook.cs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +/** +* Digital Voice Modem - Desktop Dispatch Console +* CC-BY-SA-4.0 License. Use is subject to license terms. +* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +* +* @package DVM / Desktop Dispatch Console +* @license CC-BY-SA-4.0 License (https://creativecommons.org/licenses/by-sa/4.0/legalcode) +* Code copied in its entirety from https://stackoverflow.com/a/57710850 +* +*/ + + +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace dvmconsole; + +public class GlobalKeyboardHookEventArgs : HandledEventArgs +{ + public GlobalKeyboardHook.KeyboardState KeyboardState { get; private set; } + public GlobalKeyboardHook.LowLevelKeyboardInputEvent KeyboardData { get; private set; } + + public GlobalKeyboardHookEventArgs( + GlobalKeyboardHook.LowLevelKeyboardInputEvent keyboardData, + GlobalKeyboardHook.KeyboardState keyboardState) + { + KeyboardData = keyboardData; + KeyboardState = keyboardState; + } +} + +//Based on https://gist.github.com/Stasonix +public class GlobalKeyboardHook : IDisposable +{ + public event EventHandler KeyboardPressed; + + // EDT: Added an optional parameter (registeredKeys) that accepts keys to restict + // the logging mechanism. + /// + /// + /// + /// Keys that should trigger logging. Pass null for full logging. + public GlobalKeyboardHook(Keys[] registeredKeys = null) + { + RegisteredKeys = registeredKeys; + _windowsHookHandle = IntPtr.Zero; + _user32LibraryHandle = IntPtr.Zero; + HookProcHandle = LowLevelKeyboardProc; // we must keep alive _hookProc, because GC is not aware about SetWindowsHookEx behaviour. + + _user32LibraryHandle = LoadLibrary("User32"); + if (_user32LibraryHandle == IntPtr.Zero) + { + int errorCode = Marshal.GetLastWin32Error(); + throw new Win32Exception(errorCode, $"Failed to load library 'User32.dll'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}."); + } + + + + _windowsHookHandle = SetWindowsHookEx(WH_KEYBOARD_LL, HookProcHandle, _user32LibraryHandle, 0); + if (_windowsHookHandle == IntPtr.Zero) + { + int errorCode = Marshal.GetLastWin32Error(); + throw new Win32Exception(errorCode, $"Failed to adjust keyboard hooks for '{Process.GetCurrentProcess().ProcessName}'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}."); + } + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + // because we can unhook only in the same thread, not in garbage collector thread + if (_windowsHookHandle != IntPtr.Zero) + { + if (!UnhookWindowsHookEx(_windowsHookHandle)) + { + int errorCode = Marshal.GetLastWin32Error(); + throw new Win32Exception(errorCode, $"Failed to remove keyboard hooks for '{Process.GetCurrentProcess().ProcessName}'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}."); + } + _windowsHookHandle = IntPtr.Zero; + + // ReSharper disable once DelegateSubtraction + HookProcHandle -= LowLevelKeyboardProc; + } + } + + if (_user32LibraryHandle != IntPtr.Zero) + { + if (!FreeLibrary(_user32LibraryHandle)) // reduces reference to library by 1. + { + int errorCode = Marshal.GetLastWin32Error(); + throw new Win32Exception(errorCode, $"Failed to unload library 'User32.dll'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}."); + } + _user32LibraryHandle = IntPtr.Zero; + } + } + + ~GlobalKeyboardHook() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private IntPtr _windowsHookHandle; + private IntPtr _user32LibraryHandle; + public static HookProc HookProcHandle; + + public delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam); + + [DllImport("kernel32.dll")] + private static extern IntPtr LoadLibrary(string lpFileName); + + [DllImport("kernel32.dll", CharSet = CharSet.Auto)] + private static extern bool FreeLibrary(IntPtr hModule); + + /// + /// The SetWindowsHookEx function installs an application-defined hook procedure into a hook chain. + /// You would install a hook procedure to monitor the system for certain types of events. These events are + /// associated either with a specific thread or with all threads in the same desktop as the calling thread. + /// + /// hook type + /// hook procedure + /// handle to application instance + /// thread identifier + /// If the function succeeds, the return value is the handle to the hook procedure. + [DllImport("USER32", SetLastError = true)] + static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, int dwThreadId); + + /// + /// The UnhookWindowsHookEx function removes a hook procedure installed in a hook chain by the SetWindowsHookEx function. + /// + /// handle to hook procedure + /// If the function succeeds, the return value is true. + [DllImport("USER32", SetLastError = true)] + public static extern bool UnhookWindowsHookEx(IntPtr hHook); + + /// + /// The CallNextHookEx function passes the hook information to the next hook procedure in the current hook chain. + /// A hook procedure can call this function either before or after processing the hook information. + /// + /// handle to current hook + /// hook code passed to hook procedure + /// value passed to hook procedure + /// value passed to hook procedure + /// If the function succeeds, the return value is true. + [DllImport("USER32", SetLastError = true)] + static extern IntPtr CallNextHookEx(IntPtr hHook, int code, IntPtr wParam, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential)] + public struct LowLevelKeyboardInputEvent + { + /// + /// A virtual-key code. The code must be a value in the range 1 to 254. + /// + public int VirtualCode; + + // EDT: added a conversion from VirtualCode to Keys. + /// + /// The VirtualCode converted to typeof(Keys) for higher usability. + /// + public Keys Key { get { return (Keys)VirtualCode; } } + + /// + /// A hardware scan code for the key. + /// + public int HardwareScanCode; + + /// + /// The extended-key flag, event-injected Flags, context code, and transition-state flag. This member is specified as follows. An application can use the following values to test the keystroke Flags. Testing LLKHF_INJECTED (bit 4) will tell you whether the event was injected. If it was, then testing LLKHF_LOWER_IL_INJECTED (bit 1) will tell you whether or not the event was injected from a process running at lower integrity level. + /// + public int Flags; + + /// + /// The time stamp stamp for this message, equivalent to what GetMessageTime would return for this message. + /// + public int TimeStamp; + + /// + /// Additional information associated with the message. + /// + public IntPtr AdditionalInformation; + } + + public const int WH_KEYBOARD_LL = 13; + //const int HC_ACTION = 0; + + public enum KeyboardState + { + KeyDown = 0x0100, + KeyUp = 0x0101, + SysKeyDown = 0x0104, + SysKeyUp = 0x0105 + } + + // EDT: Replaced VkSnapshot(int) with RegisteredKeys(Keys[]) + public Keys[] RegisteredKeys; + const int KfAltdown = 0x2000; + public const int LlkhfAltdown = (KfAltdown >> 8); + EventHandler handler; + public IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam) + { + bool fEatKeyStroke = false; + + var wparamTyped = wParam.ToInt32(); + + handler = KeyboardPressed; + if (Enum.IsDefined(typeof(KeyboardState), wparamTyped)) + { + object o = Marshal.PtrToStructure(lParam, typeof(LowLevelKeyboardInputEvent)); + LowLevelKeyboardInputEvent p = (LowLevelKeyboardInputEvent)o; + + var eventArguments = new GlobalKeyboardHookEventArgs(p, (KeyboardState)wparamTyped); + + // EDT: Removed the comparison-logic from the usage-area so the user does not need to mess around with it. + // Either the incoming key has to be part of RegisteredKeys (see constructor on top) or RegisterdKeys + // has to be null for the event to get fired. + var key = (Keys)p.VirtualCode; + if (RegisteredKeys == null || RegisteredKeys.Contains(key)) + { + handler?.Invoke(this, eventArguments); + + fEatKeyStroke = eventArguments.Handled; + } + } + + return fEatKeyStroke ? (IntPtr)1 : CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam); + } +} \ No newline at end of file diff --git a/dvmconsole/KeyboardManager.cs b/dvmconsole/KeyboardManager.cs new file mode 100644 index 0000000..a3eba72 --- /dev/null +++ b/dvmconsole/KeyboardManager.cs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** +* Digital Voice Modem - Desktop Dispatch Console +* AGPLv3 Open Source. Use is subject to license terms. +* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +* +* @package DVM / Desktop Dispatch Console +* @license AGPLv3 License (https://opensource.org/licenses/AGPL-3.0) +* +* Copyright (C) 2025 Steven Jennison, KD8RHO +* +*/ + +using System.Collections.ObjectModel; +using System.Windows.Forms; + +namespace dvmconsole; + +public class KeyboardManager +{ + + /* + ** Properties + */ + public bool IsListening { get; private set; } = false; + + public event Action OnKeyEvent; + + private GlobalKeyboardHook listenHook; + private GlobalKeyboardHook.HookProc hookProcHandle; + /* + ** Methods + */ + public void SetListenKeys(List keys) + { + if (listenHook == null) + { + listenHook = new GlobalKeyboardHook(keys.ToArray()); + listenHook.KeyboardPressed += ListenHookOnKeyboardEvent; + hookProcHandle = GlobalKeyboardHook.HookProcHandle; + } + else + { + listenHook.RegisteredKeys = keys.ToArray(); + } + } + + private void ListenHookOnKeyboardEvent(object sender, GlobalKeyboardHookEventArgs e) + { + OnKeyEvent?.Invoke(e.KeyboardData.Key, e.KeyboardState); + } + + + /// + /// Gets the next key pressed globally, for use with user dialogs + /// + /// The next key pressed + public async Task GetNextKeyPress() + { + GlobalKeyboardHook universalHook = new GlobalKeyboardHook(); + Keys? result = null; + + universalHook.KeyboardPressed += onUniversalHookOnKeyboardPressed; + while (result == null) + { + await Task.Delay(100); + } + + universalHook.KeyboardPressed -= onUniversalHookOnKeyboardPressed; + return result.Value; + + void onUniversalHookOnKeyboardPressed(object sender, GlobalKeyboardHookEventArgs args) + { + if (args.KeyboardState == GlobalKeyboardHook.KeyboardState.KeyDown) + { + result = args.KeyboardData.Key; + // Stop listening for key presses on first key pressed + universalHook.KeyboardPressed -= onUniversalHookOnKeyboardPressed; + } + } + + } +} \ No newline at end of file diff --git a/dvmconsole/MainWindow.xaml b/dvmconsole/MainWindow.xaml index 114ee68..a878451 100644 --- a/dvmconsole/MainWindow.xaml +++ b/dvmconsole/MainWindow.xaml @@ -35,6 +35,7 @@ + @@ -44,6 +45,10 @@ + + + + diff --git a/dvmconsole/MainWindow.xaml.cs b/dvmconsole/MainWindow.xaml.cs index 0448ba0..09bb758 100644 --- a/dvmconsole/MainWindow.xaml.cs +++ b/dvmconsole/MainWindow.xaml.cs @@ -18,12 +18,10 @@ using System.IO; using System.Timers; using System.Windows; using System.Windows.Controls; +using System.Windows.Forms; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; - -using Microsoft.Win32; - using NAudio.Wave; using NWaves.Signals; @@ -40,6 +38,11 @@ using fnecore.DMR; using fnecore.P25; using fnecore.P25.KMM; using fnecore.P25.LC.TSBK; +using Application = System.Windows.Application; +using Cursors = System.Windows.Input.Cursors; +using MessageBox = System.Windows.MessageBox; +using MouseEventArgs = System.Windows.Input.MouseEventArgs; +using OpenFileDialog = Microsoft.Win32.OpenFileDialog; using System.Net.Sockets; namespace dvmconsole @@ -123,6 +126,7 @@ namespace dvmconsole private FneSystemManager fneSystemManager = new FneSystemManager(); private bool selectAll = false; + private KeyboardManager keyboardManager; private CancellationTokenSource maintainenceCancelToken = new CancellationTokenSource(); private Task maintainenceTask = null; @@ -146,13 +150,14 @@ namespace dvmconsole public MainWindow() { InitializeComponent(); - + this.keyboardManager = new KeyboardManager(); MinWidth = Width = MIN_WIDTH; MinHeight = Height = MIN_HEIGHT; DisableControls(); settingsManager.LoadSettings(); + InitializeKeyboardShortcuts(); callHistoryWindow = new CallHistoryWindow(settingsManager); selectedChannelsManager = new SelectedChannelsManager(); @@ -1169,6 +1174,7 @@ namespace dvmconsole menuToggleLockWidgets.IsChecked = settingsManager.LockWidgets; menuSnapCallHistory.IsChecked = settingsManager.SnapCallHistoryToWindow; menuTogglePTTMode.IsChecked = settingsManager.TogglePTTMode; + menuToggleGlobalPTTMode.IsChecked = settingsManager.GlobalPTTKeysAllChannels; menuKeepWindowOnTop.IsChecked = settingsManager.KeepWindowOnTop; if (!string.IsNullOrEmpty(settingsManager.LastCodeplugPath) && File.Exists(settingsManager.LastCodeplugPath)) @@ -2029,7 +2035,7 @@ namespace dvmconsole } /// - /// + /// Activates Global PTT after a click or keyboard shortcut /// /// /// @@ -2103,7 +2109,7 @@ namespace dvmconsole /// /// /// - private async void btnGlobalPtt_Click(object sender, RoutedEventArgs e) + private async void GlobalPTTActivate(object sender, RoutedEventArgs e) { if (globalPttState) await Task.Delay(500); @@ -2119,8 +2125,16 @@ namespace dvmconsole else btnGlobalPtt.Background = btnGlobalPttDefaultBg; }); + + primaryChannel.TriggerPTTState(globalPttState); + + return; + } - primaryChannel.PttButton_Click(sender, e); + + // Check for global PTT keys all preference, if not enabled, return early + if (!settingsManager.GlobalPTTKeysAllChannels) + { return; } @@ -2193,7 +2207,7 @@ namespace dvmconsole globalPttState = !globalPttState; - btnGlobalPtt_Click(sender, e); + GlobalPTTActivate(sender, e); } /// @@ -2207,12 +2221,12 @@ namespace dvmconsole if (settingsManager.TogglePTTMode) { globalPttState = !globalPttState; - btnGlobalPtt_Click(sender, e); + GlobalPTTActivate(sender, e); } else { globalPttState = true; - btnGlobalPtt_Click(sender, e); + GlobalPTTActivate(sender, e); } } @@ -2228,7 +2242,7 @@ namespace dvmconsole return; globalPttState = false; - btnGlobalPtt_Click(sender, e); + GlobalPTTActivate(sender, e); } /// @@ -2406,5 +2420,87 @@ namespace dvmconsole }); } } + + /// + /// Sets the global PTT keybind + /// Hooks a listener to listen for a keypress, then saves that as the global PTT keybind + /// Global PTT keybind is effectively the same as pressing the Global PTT button + /// + /// + /// + /// + private async void SetGlobalPTTKeybind(object sender, RoutedEventArgs e) + { + + // Create and show a MessageBox with no buttons or standard close behavior + Window messageBox = new Window + { + Width = 500, + Height = 150, + WindowStyle = WindowStyle.None, + ShowInTaskbar = false, + ResizeMode = ResizeMode.NoResize, + Topmost = true, + Background = System.Windows.Media.Brushes.White, + Content = new System.Windows.Controls.TextBlock + { + Text = "Press any key to set the Global PTT shortcut...", + HorizontalAlignment = System.Windows.HorizontalAlignment.Center, + VerticalAlignment = System.Windows.VerticalAlignment.Center, + FontSize = 16, + FontWeight = System.Windows.FontWeights.Bold, + } + }; + + // Center messageBox on the main window + messageBox.Owner = this; // Set the current window as owner + messageBox.WindowStartupLocation = WindowStartupLocation.CenterOwner; + + // Open and close the MessageBox after 500ms + messageBox.Show(); + Keys keyPress = await keyboardManager.GetNextKeyPress(); + messageBox.Close(); + settingsManager.GlobalPTTShortcut = keyPress; + InitializeKeyboardShortcuts(); + settingsManager.SaveSettings(); + MessageBox.Show("Global PTT shortcut set to " + keyPress.ToString(), "Success", MessageBoxButton.OK, MessageBoxImage.Information); + } + + + + /// + /// Initializes global keyboard shortcut listener + /// + private void InitializeKeyboardShortcuts() + { + var listeningKeys = new List { settingsManager.GlobalPTTShortcut }; + keyboardManager.SetListenKeys(listeningKeys); + // Clear event listener + keyboardManager.OnKeyEvent -= KeyboardManagerOnKeyEvent; + // Re-add listener + keyboardManager.OnKeyEvent += KeyboardManagerOnKeyEvent; + } + + private void KeyboardManagerOnKeyEvent(Keys pressedKey,GlobalKeyboardHook.KeyboardState state) + { + if (pressedKey == settingsManager.GlobalPTTShortcut) + { + if(state is GlobalKeyboardHook.KeyboardState.KeyDown or GlobalKeyboardHook.KeyboardState.SysKeyDown) + { + globalPttState = true; + GlobalPTTActivate(null, null); + } + else + { + globalPttState = false; + GlobalPTTActivate(null, null); + } + } + } + + private void ToggleGlobalPTTAllChannels_Click(object sender, RoutedEventArgs e) + { + settingsManager.GlobalPTTKeysAllChannels = !settingsManager.GlobalPTTKeysAllChannels; + } } // public partial class MainWindow : Window } // namespace dvmconsole diff --git a/dvmconsole/SettingsManager.cs b/dvmconsole/SettingsManager.cs index b6ac853..b565310 100644 --- a/dvmconsole/SettingsManager.cs +++ b/dvmconsole/SettingsManager.cs @@ -9,6 +9,7 @@ * * Copyright (C) 2024-2025 Caleb, K4PHP * Copyright (C) 2025 Bryan Biedenkapp, N2PLL +* Copyright (C) 2025 Steven Jennison, KD8RHO * */ @@ -16,6 +17,7 @@ using System.Diagnostics; using System.IO; using System.Reflection; +using System.Windows.Forms; using Newtonsoft.Json; using fnecore.Utility; @@ -135,6 +137,11 @@ namespace dvmconsole /// public bool SaveTraceLog { get; set; } + + public Keys GlobalPTTShortcut { get; set; } = Keys.None; + + + public bool GlobalPTTKeysAllChannels { get; set; } /* ** Methods */ @@ -166,6 +173,7 @@ namespace dvmconsole if (loadedSettings != null) { + GlobalPTTKeysAllChannels = loadedSettings.GlobalPTTKeysAllChannels; ShowSystemStatus = loadedSettings.ShowSystemStatus; ShowChannels = loadedSettings.ShowChannels; ShowAlertTones = loadedSettings.ShowAlertTones; @@ -202,6 +210,8 @@ namespace dvmconsole UserBackgroundImage = loadedSettings.UserBackgroundImage; SaveTraceLog = loadedSettings.SaveTraceLog; + GlobalPTTShortcut = loadedSettings.GlobalPTTShortcut; + if (SaveTraceLog) Log.SetupTextWriter(Environment.CurrentDirectory, "dvmconsole.log");