Click-to-Call-Tray/Program.cs
krjan02 62caf3a610
All checks were successful
Build and Relase / build-release (push) Successful in 1m10s
Build and Relase / create-release (push) Successful in 12s
fixed update check
2025-11-14 09:44:36 +01:00

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);
}
}
}