using Click_to_Call_Tray; using Click_to_Call_Tray.Properties; using Microsoft.VisualBasic.ApplicationServices; using Microsoft.Win32; using System; using System.Collections.Concurrent; using System.ComponentModel; using System.Diagnostics; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; using System.Runtime.InteropServices; using System.Security.Policy; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using System.Windows.Forms.Design.Behavior; namespace Click_to_Call_Tray { internal static class Program { private static HotkeyManager _hotkeyManager; private static ClipboardWorker _clipboardWorker; internal static Keys CurrentHotkey { get; private set; } [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool FreeConsole(); [STAThread] static void Main() { // Load hotkey from config; be robust with parsing; fall back to F11. var initialHotkey = HotkeyStringConverter.TryParse(IniConfig.ReadHotkey(), out var parsed) ? parsed : Keys.F11; CurrentHotkey = initialHotkey; // Start the clipboard STA worker _clipboardWorker = new ClipboardWorker(); // Start the low-level keyboard hook manager _hotkeyManager = new HotkeyManager(CurrentHotkey); _hotkeyManager.HotkeyPressed += OnHotkeyPressed; var currentTag = UpdateCheck.GetCurrentVersion(); Task.Run(() => UpdateCheck.CheckUpdate()); ApplicationConfiguration.Initialize(); Application.Run(new CallTrayContext(currentTag)); _hotkeyManager?.Dispose(); _clipboardWorker?.Dispose(); FreeConsole(); } internal static void SetNewHotkey(Keys newHotkey) { CurrentHotkey = newHotkey; IniConfig.WriteHotkey(HotkeyStringConverter.ToString(newHotkey)); if (_hotkeyManager != null) { _hotkeyManager.CurrentHotkey = newHotkey; } } private static void OnHotkeyPressed() { // Run outside the hook thread, quickly Task.Run(async () => { try { // Copy selection SendKeys.SendWait("^c"); // Give the clipboard a brief moment to update and then try to read it with retries await Task.Delay(80).ConfigureAwait(false); string text = await _clipboardWorker.GetTextWithRetryAsync(retryCount: 10, delayMs: 50).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(text)) return; string cleaned = PhoneNumberUtil.CleanNumber(text); if (!PhoneNumberUtil.IsValidNumber(cleaned)) { // Optional: Console feedback Console.WriteLine($"Invalid number format: {cleaned}"); return; } var url = $"tel:{cleaned}"; try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); Console.WriteLine($"Initiated call to: {cleaned}"); } catch (Win32Exception wex) { Console.WriteLine($"Failed to start tel: handler. {wex.Message}"); } } catch (Exception ex) { Console.WriteLine($"Unexpected error handling hotkey: {ex.Message}"); } }); } } internal sealed class HotkeyManager : IDisposable { private const int WH_KEYBOARD_LL = 13; private const int WM_KEYDOWN = 0x0100; private const int WM_SYSKEYDOWN = 0x0104; private readonly LowLevelKeyboardProc _proc; private IntPtr _hookId = IntPtr.Zero; public event Action HotkeyPressed; public Keys CurrentHotkey { get; set; } public HotkeyManager(Keys initialHotkey) { CurrentHotkey = initialHotkey; _proc = HookCallback; _hookId = SetHook(_proc); } public void Dispose() { if (_hookId != IntPtr.Zero) { UnhookWindowsHookEx(_hookId); _hookId = IntPtr.Zero; } } private IntPtr SetHook(LowLevelKeyboardProc proc) { // For low-level hooks, it's okay to pass hMod = IntPtr.Zero return SetWindowsHookEx(WH_KEYBOARD_LL, proc, IntPtr.Zero, 0); } private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (nCode >= 0 && (wParam == (IntPtr)WM_KEYDOWN || wParam == (IntPtr)WM_SYSKEYDOWN)) { var kbData = Marshal.PtrToStructure(lParam); var key = (Keys)kbData.vkCode; // Build the composite key including modifiers Keys composite = Keys.None; if (Control.ModifierKeys.HasFlag(Keys.Control)) composite |= Keys.Control; if (Control.ModifierKeys.HasFlag(Keys.Shift)) composite |= Keys.Shift; if (Control.ModifierKeys.HasFlag(Keys.Alt)) composite |= Keys.Alt; composite |= key; if (AreHotkeysEqual(composite, CurrentHotkey)) { // Fire on background to avoid blocking hook thread Task.Run(() => HotkeyPressed?.Invoke()); // Swallow the key to prevent it from reaching the foreground app return (IntPtr)1; } } return CallNextHookEx(_hookId, nCode, wParam, lParam); } private static bool AreHotkeysEqual(Keys a, Keys b) { // Normalize to compare modifiers + key var norm = new Func(k => { var mods = k & (Keys.Control | Keys.Shift | Keys.Alt); var keyOnly = k & Keys.KeyCode; return mods | keyOnly; }); return norm(a) == norm(b); } #region Interop [StructLayout(LayoutKind.Sequential)] private struct KBDLLHOOKSTRUCT { public uint vkCode; public uint scanCode; public uint flags; public uint time; public IntPtr dwExtraInfo; } private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool UnhookWindowsHookEx(IntPtr hhk); #endregion } internal sealed class ClipboardWorker : IDisposable { private readonly Thread _thread; private readonly BlockingCollection> _requests = new(); private readonly BlockingCollection _responses = new(); private volatile bool _running = true; public ClipboardWorker() { _thread = new Thread(Worker) { IsBackground = true }; _thread.SetApartmentState(ApartmentState.STA); _thread.Start(); } private void Worker() { try { while (_running) { var work = _requests.Take(); try { var result = work?.Invoke(); _responses.Add(result); } catch { _responses.Add(null); } } } catch { // Exit thread } } public Task GetTextWithRetryAsync(int retryCount = 8, int delayMs = 40) { return Task.Run(async () => { for (int i = 0; i < retryCount; i++) { string text = GetTextOnce(); if (!string.IsNullOrWhiteSpace(text)) return text; await Task.Delay(delayMs).ConfigureAwait(false); } return string.Empty; }); } private string GetTextOnce() { if (!_running) return string.Empty; _requests.Add(() => { try { return Clipboard.ContainsText() ? Clipboard.GetText() : string.Empty; } catch (ExternalException) { return string.Empty; } catch { return string.Empty; } }); return _responses.Take(); } public void Dispose() { _running = false; try { _requests.CompleteAdding(); } catch { } try { _responses.CompleteAdding(); } catch { } } } internal static class PhoneNumberUtil { // Remove anything except digits and '+'. public static string CleanNumber(string input) { if (string.IsNullOrWhiteSpace(input)) return string.Empty; // Normalize unicode spaces etc. var normalized = input.Trim(); // Remove specific common optional notation "(0)" normalized = normalized.Replace("(0)", "", StringComparison.Ordinal); var sb = new StringBuilder(normalized.Length); foreach (var ch in normalized) { if ((ch >= '0' && ch <= '9') || ch == '+') sb.Append(ch); } // Prevent multiple '+' or '+' not at start string result = sb.ToString(); if (result.IndexOf('+') > 0) result = result.Replace("+", ""); if (result.Count(c => c == '+') > 1) result = result.TrimStart('+').Insert(0, "+"); return result; } // Simple and robust: allow + and 6-20 digits public static bool IsValidNumber(string number) { if (string.IsNullOrWhiteSpace(number)) return false; return Regex.IsMatch(number, @"^\+?\d{6,20}$"); } private static int Count(this string s, Func predicate) { int c = 0; foreach (var ch in s) if (predicate(ch)) c++; return c; } private static int Count(this string s, char match) { int c = 0; foreach (var ch in s) if (ch == match) c++; return c; } } internal static class HotkeyStringConverter { public static bool TryParse(string input, out Keys keys) { keys = Keys.None; if (string.IsNullOrWhiteSpace(input)) return false; // Try enum parse which supports comma-separated flags (e.g., "Control, Shift, H") if (Enum.TryParse(typeof(Keys), input, true, out var value)) { keys = (Keys)value; return true; } // Also accept forms like "Ctrl+Shift+H" var cleaned = input.Replace("Ctrl", "Control", StringComparison.OrdinalIgnoreCase) .Replace('+', ','); if (Enum.TryParse(typeof(Keys), cleaned, true, out value)) { keys = (Keys)value; return true; } // Function keys shortcut e.g., "F1" if (input.StartsWith("F", StringComparison.OrdinalIgnoreCase) && int.TryParse(input.AsSpan(1), out int f) && f >= 1 && f <= 24) { keys = Keys.F1 + (f - 1); return true; } return false; } public static string ToString(Keys keys) { return keys.ToString(); } } public class CallTrayContext : ApplicationContext { [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] static extern bool AllocConsole(); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] static extern bool FreeConsole(); private NotifyIcon _trayIcon; private bool _console = false; public CallTrayContext(string currentTag) { var contextMenu = new ContextMenuStrip(); contextMenu.Items.Add(CreateDisabledMenuItem($"krjan02 ©{DateTime.Now.Year}")); contextMenu.Items.Add(CreateMenuItem("Debug Console", DisplayConsole)); contextMenu.Items.Add(CreateHotkeyMenu()); contextMenu.Items.Add(CreateMenuItem("Check for Updates", CheckForUpdates)); contextMenu.Items.Add(new ToolStripSeparator()); contextMenu.Items.Add(CreateMenuItem("Exit", Exit)); _trayIcon = new NotifyIcon() { Icon = Resources.TrayIcon, ContextMenuStrip = contextMenu, Visible = true, Text = $"Click-to-Call-Tray {currentTag}" }; } private ToolStripMenuItem CreateHotkeyMenu() { var hotkeyMenu = new ToolStripMenuItem("Set Hotkey"); // F1..F12 for (int i = 1; i <= 12; i++) { var key = Keys.F1 + (i - 1); string text = $"F{i}"; var item = CreateMenuItem(text, SetHotkey_Click, key); hotkeyMenu.DropDownItems.Add(item); } // Custom var customKeyItem = new ToolStripMenuItem(); customKeyItem.Click += SetCustomHotkey_Click; UpdateCustomHotkeyMenuItem(customKeyItem); hotkeyMenu.DropDownItems.Add(customKeyItem); // Mark current selection CheckCurrentHotkey(hotkeyMenu); return hotkeyMenu; } private void UpdateCustomHotkeyMenuItem(ToolStripMenuItem customKeyItem) { var raw = IniConfig.ReadHotkey(); Keys current = HotkeyStringConverter.TryParse(raw, out var parsed) ? parsed : Keys.F11; bool isDefaultFn = IsDefaultFunctionKey(current); customKeyItem.Text = isDefaultFn ? "Custom Hotkey [None]" : $"Custom Hotkey [{HotkeyStringConverter.ToString(current)}]"; customKeyItem.Tag = current; customKeyItem.Checked = !isDefaultFn; if (customKeyItem.OwnerItem is ToolStripMenuItem parentItem && !isDefaultFn) { foreach (ToolStripItem item in parentItem.DropDownItems) { if (item is ToolStripMenuItem menuSubItem && menuSubItem.Tag is Keys kTag) { menuSubItem.Checked = kTag == current; } } } } private ToolStripMenuItem CreateDisabledMenuItem(string text) { return new ToolStripMenuItem(text) { Enabled = false }; } private ToolStripMenuItem CreateMenuItem(string text, EventHandler handler, object tag = null) { var item = new ToolStripMenuItem(text); item.Click += handler; item.Tag = tag; return item; } private void CheckCurrentHotkey(ToolStripMenuItem parentMenu) { var raw = IniConfig.ReadHotkey(); Keys current = HotkeyStringConverter.TryParse(raw, out var parsed) ? parsed : Keys.F11; foreach (ToolStripItem item in parentMenu.DropDownItems) { if (item is ToolStripMenuItem menuSubItem && menuSubItem.Tag is Keys k) { menuSubItem.Checked = k == current; } } } private void SetHotkey_Click(object sender, EventArgs e) { var clickedItem = (ToolStripMenuItem)sender; if (clickedItem.Tag is not Keys newHotkey) return; Program.SetNewHotkey(newHotkey); var parentMenu = (ToolStripMenuItem)clickedItem.OwnerItem; CheckCurrentHotkey(parentMenu); UpdateCustomHotkeyMenuItem(parentMenu.DropDownItems[^1] as ToolStripMenuItem); MessageBox.Show($"Hotkey successfully changed to {newHotkey}. Please re-select the phone number text before pressing {newHotkey}.", "Hotkey Updated"); } private void SetCustomHotkey_Click(object sender, EventArgs e) { using (HotkeyRecorderForm recorder = new HotkeyRecorderForm()) { if (recorder.ShowDialog() == DialogResult.OK) { Keys newHotkey = recorder.RecordedKeys; Program.SetNewHotkey(newHotkey); if (sender is ToolStripMenuItem customItem) { UpdateCustomHotkeyMenuItem(customItem); // Also uncheck function key items if (customItem.OwnerItem is ToolStripMenuItem parent) foreach (ToolStripItem it in parent.DropDownItems) if (it is ToolStripMenuItem mi && mi.Tag is Keys k && IsDefaultFunctionKey(k)) mi.Checked = false; } MessageBox.Show($"Hotkey successfully changed to {newHotkey}. Please re-select the phone number text before pressing the new hotkey.", "Hotkey Updated"); } } } private bool IsDefaultFunctionKey(Keys keys) { return keys >= Keys.F1 && keys <= Keys.F24; } private void DisplayConsole(object sender, EventArgs e) { // Show console if not already allocated if (_console) FreeConsole(); else AllocConsole(); _console = !_console; } private void CheckForUpdates(object sender, EventArgs e) { if (!UpdateCheck.CheckUpdate()) { MessageBox.Show("No update available.", "Update Status"); } } private void Exit(object sender, EventArgs e) { _trayIcon.Visible = false; _trayIcon.Dispose(); Application.Exit(); } protected override void Dispose(bool disposing) { if (disposing) { _trayIcon?.Dispose(); } base.Dispose(disposing); } } }