583 lines
19 KiB
C#
583 lines
19 KiB
C#
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<KBDLLHOOKSTRUCT>(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<Keys, Keys>(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<Func<string>> _requests = new();
|
|
private readonly BlockingCollection<string> _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<string> 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<char, bool> 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);
|
|
}
|
|
}
|
|
} |