whisper-key-local 0.5.2__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. whisper_key/assets/version.txt +1 -1
  2. whisper_key/audio_feedback.py +21 -20
  3. whisper_key/audio_recorder.py +2 -9
  4. whisper_key/clipboard_manager.py +35 -52
  5. whisper_key/config.defaults.yaml +22 -13
  6. whisper_key/config_manager.py +25 -9
  7. whisper_key/console_manager.py +2 -58
  8. whisper_key/hotkey_listener.py +55 -89
  9. whisper_key/instance_manager.py +9 -13
  10. whisper_key/main.py +28 -11
  11. whisper_key/platform/__init__.py +10 -0
  12. whisper_key/platform/macos/__init__.py +1 -0
  13. whisper_key/platform/macos/app.py +27 -0
  14. whisper_key/platform/macos/console.py +13 -0
  15. whisper_key/platform/macos/hotkeys.py +180 -0
  16. whisper_key/platform/macos/icons.py +11 -0
  17. whisper_key/platform/macos/instance_lock.py +31 -0
  18. whisper_key/platform/macos/keyboard.py +97 -0
  19. whisper_key/platform/macos/keycodes.py +17 -0
  20. whisper_key/platform/macos/paths.py +8 -0
  21. whisper_key/platform/macos/permissions.py +66 -0
  22. whisper_key/platform/windows/__init__.py +1 -0
  23. whisper_key/platform/windows/app.py +6 -0
  24. whisper_key/platform/windows/console.py +59 -0
  25. whisper_key/platform/windows/hotkeys.py +30 -0
  26. whisper_key/platform/windows/icons.py +11 -0
  27. whisper_key/platform/windows/instance_lock.py +14 -0
  28. whisper_key/platform/windows/keyboard.py +12 -0
  29. whisper_key/platform/windows/paths.py +8 -0
  30. whisper_key/platform/windows/permissions.py +6 -0
  31. whisper_key/state_manager.py +4 -4
  32. whisper_key/system_tray.py +30 -57
  33. whisper_key/terminal_ui.py +71 -0
  34. whisper_key/utils.py +8 -4
  35. whisper_key_local-0.6.0.dist-info/METADATA +159 -0
  36. whisper_key_local-0.6.0.dist-info/RECORD +47 -0
  37. {whisper_key_local-0.5.2.dist-info → whisper_key_local-0.6.0.dist-info}/WHEEL +1 -1
  38. whisper_key/assets/tray_idle.png +0 -0
  39. whisper_key/assets/tray_processing.png +0 -0
  40. whisper_key/assets/tray_recording.png +0 -0
  41. whisper_key_local-0.5.2.dist-info/METADATA +0 -130
  42. whisper_key_local-0.5.2.dist-info/RECORD +0 -29
  43. {whisper_key_local-0.5.2.dist-info → whisper_key_local-0.6.0.dist-info}/entry_points.txt +0 -0
  44. {whisper_key_local-0.5.2.dist-info → whisper_key_local-0.6.0.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,11 @@
1
1
  import logging
2
2
 
3
- from global_hotkeys import register_hotkeys, start_checking_hotkeys, stop_checking_hotkeys
4
-
3
+ from .platform import hotkeys
5
4
  from .state_manager import StateManager
6
5
 
7
- class HotkeyListener:
8
- def __init__(self, state_manager: StateManager, recording_hotkey: str,
9
- auto_enter_hotkey: str = None, auto_enter_enabled: bool = True,
6
+ class HotkeyListener:
7
+ def __init__(self, state_manager: StateManager, recording_hotkey: str,
8
+ auto_enter_hotkey: str = None, auto_enter_enabled: bool = True,
10
9
  stop_with_modifier_enabled: bool = False, cancel_combination: str = None):
11
10
  self.state_manager = state_manager
12
11
  self.recording_hotkey = recording_hotkey
@@ -14,38 +13,38 @@ class HotkeyListener:
14
13
  self.auto_enter_enabled = auto_enter_enabled
15
14
  self.stop_with_modifier_enabled = stop_with_modifier_enabled
16
15
  self.cancel_combination = cancel_combination
17
- self.stop_modifier_hotkey = None # Will be calculated from recording_hotkey
16
+ self.stop_modifier_hotkey = None
18
17
  self.modifier_key_released = True
19
18
  self.is_listening = False
20
19
  self.logger = logging.getLogger(__name__)
21
-
20
+
22
21
  self._setup_hotkeys()
23
-
22
+
24
23
  self.start_listening()
25
-
24
+
26
25
  def _setup_hotkeys(self):
27
26
  hotkey_configs = []
28
-
27
+
29
28
  hotkey_configs.append({
30
29
  'combination': self.recording_hotkey,
31
30
  'callback': self._standard_hotkey_pressed,
32
31
  'name': 'standard'
33
32
  })
34
-
33
+
35
34
  if self.auto_enter_enabled and self.auto_enter_hotkey:
36
35
  hotkey_configs.append({
37
36
  'combination': self.auto_enter_hotkey,
38
37
  'callback': self._auto_enter_hotkey_pressed,
39
38
  'name': 'auto-enter'
40
39
  })
41
-
40
+
42
41
  if self.cancel_combination:
43
42
  hotkey_configs.append({
44
43
  'combination': self.cancel_combination,
45
44
  'callback': self._cancel_hotkey_pressed,
46
45
  'name': 'cancel'
47
46
  })
48
-
47
+
49
48
  if self.stop_with_modifier_enabled:
50
49
  self.stop_modifier_hotkey = self._extract_first_modifier(self.recording_hotkey)
51
50
  if self.stop_modifier_hotkey:
@@ -55,151 +54,118 @@ class HotkeyListener:
55
54
  'release_callback': self._arm_stop_modifier_hotkey_on_release,
56
55
  'name': 'stop-modifier'
57
56
  })
58
-
59
- # More modifiers = higher priority
57
+
60
58
  hotkey_configs.sort(key=self._get_hotkey_combination_specificity, reverse=True)
61
-
59
+
62
60
  self.hotkey_bindings = []
63
61
  for config in hotkey_configs:
64
- formatted_hotkey = self._convert_hotkey_to_global_hotkeys_format(config['combination'])
65
-
66
- # Setup for global-hotkeys
67
- # Expected format: [hotkey, press_callback, release_callback, actuate_on_partial_release]
62
+ hotkey = config['combination'].lower().strip()
68
63
  self.hotkey_bindings.append([
69
- formatted_hotkey,
70
- config['callback'],
71
- config.get('release_callback') or None,
72
- False])
64
+ hotkey,
65
+ config['callback'],
66
+ config.get('release_callback') or None,
67
+ False
68
+ ])
69
+ self.logger.info(f"Configured {config['name']} hotkey: {hotkey}")
73
70
 
74
- self.logger.info(f"Configured {config['name']} hotkey: {config['combination']} -> {formatted_hotkey}")
75
-
76
71
  self.logger.info(f"Total hotkeys configured: {len(self.hotkey_bindings)}")
77
-
72
+
78
73
  def _get_hotkey_combination_specificity(self, hotkey_config: dict) -> int:
79
- """
80
- Returns specificity score to ensure combos with more keys take priority
81
- """
82
74
  combination = hotkey_config['combination'].lower()
83
75
  return len(combination.split('+'))
84
-
76
+
85
77
  def _standard_hotkey_pressed(self):
86
78
  self.logger.info(f"Standard hotkey pressed: {self.recording_hotkey}")
87
-
88
- # Disable stop-modifier until key is released (prevents immediate stopping)
79
+
89
80
  self.modifier_key_released = False
90
-
81
+
91
82
  self.state_manager.toggle_recording()
92
-
83
+
93
84
  def _auto_enter_hotkey_pressed(self):
94
85
  self.logger.info(f"Auto-enter hotkey pressed: {self.auto_enter_hotkey}")
95
-
86
+
96
87
  if not self.state_manager.audio_recorder.get_recording_status():
97
88
  self.logger.debug("Auto-enter hotkey ignored - not currently recording")
98
89
  return
99
-
90
+
100
91
  if not self.state_manager.clipboard_manager.auto_paste:
101
92
  self.logger.debug("Auto-enter hotkey ignored - auto-paste is disabled")
102
93
  return
103
-
94
+
104
95
  if self.stop_with_modifier_enabled and not self.modifier_key_released:
105
96
  self.logger.debug("Auto-enter hotkey ignored - waiting for modifier key release")
106
97
  return
107
-
108
- # Disable stop-modifier until key is released
98
+
109
99
  self.modifier_key_released = False
110
-
100
+
111
101
  self.state_manager.stop_recording(use_auto_enter=True)
112
-
102
+
113
103
  def _cancel_hotkey_pressed(self):
114
104
  self.logger.info(f"Cancel hotkey pressed: {self.cancel_combination}")
115
105
  self.state_manager.cancel_recording_hotkey_pressed()
116
-
106
+
117
107
  def _stop_modifier_hotkey_pressed(self):
118
108
  self.logger.debug(f"Stop-modifier hotkey pressed: {self.stop_modifier_hotkey}, modifier_released={self.modifier_key_released}")
119
-
120
- # Only stop if the modifier key has been released since last full hotkey press
109
+
121
110
  if self.modifier_key_released:
122
111
  self.logger.info(f"Stop-modifier hotkey activated: {self.stop_modifier_hotkey}")
123
112
  self.state_manager.stop_recording()
124
113
  else:
125
114
  self.logger.debug("Stop-modifier ignored - waiting for key release first")
126
-
115
+
127
116
  def _arm_stop_modifier_hotkey_on_release(self):
128
117
  self.logger.debug(f"Stop-modifier key released: {self.stop_modifier_hotkey}")
129
118
  self.modifier_key_released = True
130
-
119
+
131
120
  def _extract_first_modifier(self, hotkey_str: str) -> str:
132
121
  parts = hotkey_str.lower().split('+')
133
122
  if len(parts) > 1:
134
123
  return parts[0].strip()
135
124
  return None
136
-
125
+
137
126
  def start_listening(self):
138
127
  if self.is_listening:
139
128
  return
140
-
141
- try:
142
- register_hotkeys(self.hotkey_bindings)
143
- start_checking_hotkeys()
129
+
130
+ try:
131
+ hotkeys.register(self.hotkey_bindings)
132
+ hotkeys.start()
144
133
  self.is_listening = True
145
-
134
+
146
135
  except Exception as e:
147
136
  self.logger.error(f"Failed to start hotkey listener: {e}")
148
137
  raise
149
-
138
+
150
139
  def stop_listening(self):
151
140
  if not self.is_listening:
152
141
  return
153
-
142
+
154
143
  try:
155
- stop_checking_hotkeys()
144
+ hotkeys.stop()
156
145
  self.is_listening = False
157
146
  self.logger.info("Hotkey listener stopped")
158
-
147
+
159
148
  except Exception as e:
160
149
  self.logger.error(f"Error stopping hotkey listener: {e}")
161
-
162
- def _convert_hotkey_to_global_hotkeys_format(self, hotkey_str: str) -> str:
163
-
164
- key_mapping = {
165
- 'ctrl': 'control',
166
- 'shift': 'shift',
167
- 'alt': 'alt',
168
- 'win': 'window',
169
- 'windows': 'window',
170
- 'cmd': 'window',
171
- 'super': 'window',
172
- 'space': 'space',
173
- 'enter': 'enter',
174
- 'esc': 'escape'
175
- }
176
-
177
- keys = hotkey_str.lower().split('+')
178
- converted_keys = []
179
-
180
- for key in keys:
181
- key = key.strip()
182
- converted_keys.append(key_mapping.get(key, key))
183
-
184
- return ' + '.join(converted_keys)
185
-
150
+
151
+
186
152
  def change_hotkey_config(self, setting: str, value):
187
153
  valid_settings = ['recording_hotkey', 'auto_enter_hotkey', 'auto_enter_enabled', 'stop_with_modifier_enabled', 'cancel_combination']
188
-
154
+
189
155
  if setting not in valid_settings:
190
156
  raise ValueError(f"Invalid setting '{setting}'. Valid options: {valid_settings}")
191
-
157
+
192
158
  old_value = getattr(self, setting)
193
-
159
+
194
160
  if old_value == value:
195
161
  return
196
-
162
+
197
163
  setattr(self, setting, value)
198
164
  self.logger.info(f"Changed {setting}: {old_value} -> {value}")
199
-
165
+
200
166
  self.stop_listening()
201
167
  self._setup_hotkeys()
202
168
  self.start_listening()
203
-
169
+
204
170
  def is_active(self) -> bool:
205
- return self.is_listening
171
+ return self.is_listening
@@ -2,35 +2,31 @@ import logging
2
2
  import sys
3
3
  import time
4
4
 
5
- import win32api
6
- import win32event
5
+ from .platform import instance_lock
7
6
 
8
7
  logger = logging.getLogger(__name__)
9
8
 
10
9
  def guard_against_multiple_instances(app_name: str = "WhisperKeyLocal"):
11
- mutex_name = f"{app_name}_SingleInstance"
12
-
13
10
  try:
14
- mutex_handle = win32event.CreateMutex(None, True, mutex_name)
15
-
16
- if win32api.GetLastError() == 183: # ERROR_ALREADY_EXISTS
11
+ mutex_handle = instance_lock.acquire_lock(app_name)
12
+
13
+ if mutex_handle is None:
17
14
  logger.info("Another instance detected")
18
15
  _exit_to_prevent_duplicate()
19
16
  else:
20
17
  logger.info("Primary instance acquired mutex")
21
- # Return the mutex handle so it stays alive until app exits
22
18
  return mutex_handle
23
-
19
+
24
20
  except Exception as e:
25
21
  logger.error(f"Error with single instance check: {e}")
26
22
  raise
27
23
 
28
24
  def _exit_to_prevent_duplicate():
29
- print("\nWhisper Key is already running!")
25
+ print("\nWhisper Key is already running!")
30
26
  print("\nThis app will close in 3 seconds...")
31
-
27
+
32
28
  for i in range(3, 0, -1):
33
29
  time.sleep(1)
34
-
30
+
35
31
  print("\nGoodbye!")
36
- sys.exit(0)
32
+ sys.exit(0)
whisper_key/main.py CHANGED
@@ -3,12 +3,16 @@
3
3
  from .utils import add_portaudio_dll_to_search_path
4
4
  add_portaudio_dll_to_search_path()
5
5
 
6
+ import argparse
6
7
  import logging
7
8
  import os
8
9
  import signal
9
10
  import sys
10
11
  import threading
11
12
 
13
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
14
+
15
+ from .platform import app, permissions
12
16
  from .config_manager import ConfigManager
13
17
  from .audio_recorder import AudioRecorder
14
18
  from .hotkey_listener import HotkeyListener
@@ -127,7 +131,7 @@ def setup_system_tray(tray_config, config_manager, state_manager, model_registry
127
131
  def setup_signal_handlers(shutdown_event):
128
132
  def signal_handler(signum, frame):
129
133
  shutdown_event.set()
130
-
134
+
131
135
  signal.signal(signal.SIGINT, signal_handler)
132
136
  signal.signal(signal.SIGTERM, signal_handler)
133
137
 
@@ -142,21 +146,28 @@ def setup_hotkey_listener(hotkey_config, state_manager):
142
146
  )
143
147
 
144
148
  def shutdown_app(hotkey_listener: HotkeyListener, state_manager: StateManager, logger: logging.Logger):
145
- # Stop hotkey listener first to prevent new events during shutdown
146
149
  try:
147
150
  if hotkey_listener and hotkey_listener.is_active():
148
151
  logger.info("Stopping hotkey listener...")
149
152
  hotkey_listener.stop_listening()
150
153
  except Exception as ex:
151
154
  logger.error(f"Error stopping hotkey listener: {ex}")
152
-
155
+
153
156
  if state_manager:
154
157
  state_manager.shutdown()
155
158
 
156
- def main():
157
- mutex_handle = guard_against_multiple_instances()
158
-
159
- print(f"Starting Whisper Key [{get_version()}]... Local Speech-to-Text App...")
159
+ def main():
160
+ app.setup()
161
+
162
+ parser = argparse.ArgumentParser()
163
+ parser.add_argument('--test', action='store_true', help='Run as separate test instance')
164
+ args = parser.parse_args()
165
+
166
+ instance_name = "WhisperKeyLocal_test" if args.test else "WhisperKeyLocal"
167
+ mutex_handle = guard_against_multiple_instances(instance_name)
168
+
169
+ mode_label = " [TEST]" if args.test else ""
170
+ print(f"Starting Whisper Key [{get_version()}]{mode_label}... Local Speech-to-Text App...")
160
171
 
161
172
  shutdown_event = threading.Event()
162
173
  setup_signal_handlers(shutdown_event)
@@ -204,14 +215,20 @@ def main():
204
215
  state_manager.attach_components(audio_recorder, system_tray)
205
216
 
206
217
  hotkey_listener = setup_hotkey_listener(hotkey_config, state_manager)
207
-
218
+
208
219
  system_tray.start()
209
220
 
210
- print(f"🚀 Application ready! Press {beautify_hotkey(hotkey_config['recording_hotkey'])} to start recording.")
221
+ print(f"🚀 Application ready! Press [{beautify_hotkey(hotkey_config['recording_hotkey'])}] to start recording.", flush=True) # flush so headless agent can detect startup success
211
222
  print("Press Ctrl+C to quit.")
212
223
 
213
- while not shutdown_event.wait(timeout=0.1):
214
- pass
224
+ if clipboard_config['auto_paste']:
225
+ if not permissions.check_accessibility_permission():
226
+ if not permissions.handle_missing_permission(config_manager):
227
+ app.run_event_loop(shutdown_event)
228
+ return
229
+ clipboard_manager.update_auto_paste(False)
230
+
231
+ app.run_event_loop(shutdown_event)
215
232
 
216
233
  except KeyboardInterrupt:
217
234
  logger.info("Application shutting down...")
@@ -0,0 +1,10 @@
1
+ import platform as _platform
2
+
3
+ PLATFORM = 'macos' if _platform.system() == 'Darwin' else 'windows'
4
+ IS_MACOS = PLATFORM == 'macos'
5
+ IS_WINDOWS = PLATFORM == 'windows'
6
+
7
+ if IS_MACOS:
8
+ from .macos import instance_lock, console, keyboard, hotkeys, paths, app, permissions, icons
9
+ else:
10
+ from .windows import instance_lock, console, keyboard, hotkeys, paths, app, permissions, icons
@@ -0,0 +1 @@
1
+ from . import instance_lock, console, keyboard, hotkeys, paths, permissions
@@ -0,0 +1,27 @@
1
+ from AppKit import NSApplication, NSApplicationActivationPolicyAccessory, NSEventMaskAny, NSDefaultRunLoopMode
2
+ from Foundation import NSDate, NSObject
3
+
4
+ class AppDelegate(NSObject):
5
+ def applicationSupportsSecureRestorableState_(self, app):
6
+ return True
7
+
8
+ _delegate = None
9
+
10
+ def setup():
11
+ global _delegate
12
+ app = NSApplication.sharedApplication()
13
+ app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
14
+ _delegate = AppDelegate.alloc().init()
15
+ app.setDelegate_(_delegate)
16
+
17
+ def run_event_loop(shutdown_event):
18
+ app = NSApplication.sharedApplication()
19
+ while not shutdown_event.is_set():
20
+ event = app.nextEventMatchingMask_untilDate_inMode_dequeue_(
21
+ NSEventMaskAny,
22
+ NSDate.dateWithTimeIntervalSinceNow_(0.1),
23
+ NSDefaultRunLoopMode,
24
+ True
25
+ )
26
+ if event:
27
+ app.sendEvent_(event)
@@ -0,0 +1,13 @@
1
+ import logging
2
+
3
+ class ConsoleManager:
4
+ def __init__(self, config: dict, is_executable_mode: bool = False):
5
+ self.logger = logging.getLogger(__name__)
6
+ self.config = config
7
+ self.is_executable_mode = is_executable_mode
8
+
9
+ def show_console(self):
10
+ return True
11
+
12
+ def hide_console(self):
13
+ return True
@@ -0,0 +1,180 @@
1
+ import logging
2
+ import threading
3
+ from dataclasses import dataclass, field
4
+ from typing import Callable
5
+
6
+ from AppKit import NSEvent
7
+
8
+ from .keycodes import KEY_CODES
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ NSKeyDownMask = 1 << 10
13
+ NSFlagsChangedMask = 1 << 12
14
+
15
+ MODIFIER_FLAGS = {
16
+ 'ctrl': 1 << 18,
17
+ 'control': 1 << 18,
18
+ 'cmd': 1 << 20,
19
+ 'command': 1 << 20,
20
+ 'option': 1 << 19,
21
+ 'alt': 1 << 19,
22
+ 'shift': 1 << 17,
23
+ 'fn': 1 << 23,
24
+ 'function': 1 << 23,
25
+ }
26
+
27
+ MODIFIER_MASK = (1 << 18) | (1 << 20) | (1 << 19) | (1 << 17) | (1 << 23)
28
+
29
+
30
+ @dataclass
31
+ class ParsedBinding:
32
+ original: str
33
+ modifiers: int
34
+ keycode: int | None
35
+ press_callback: Callable
36
+ release_callback: Callable | None
37
+ is_active: bool = field(default=False)
38
+
39
+
40
+ class ModifierStateTracker:
41
+ def __init__(self):
42
+ self.previous_flags = 0
43
+
44
+ def update(self, new_flags: int) -> tuple[int, int, int, int]:
45
+ new_flags = new_flags & MODIFIER_MASK
46
+ old_flags = self.previous_flags
47
+ pressed = new_flags & ~old_flags
48
+ released = old_flags & ~new_flags
49
+ self.previous_flags = new_flags
50
+ return old_flags, new_flags, pressed, released
51
+
52
+ def reset(self):
53
+ self.previous_flags = 0
54
+
55
+
56
+ _monitor = None
57
+ _bindings: list[ParsedBinding] = []
58
+ _state = ModifierStateTracker()
59
+
60
+
61
+ def _parse_hotkey_string(hotkey_str: str) -> tuple[int, int | None]:
62
+ parts = [p.strip().lower() for p in hotkey_str.split('+')]
63
+
64
+ modifiers = 0
65
+ keycode = None
66
+
67
+ for part in parts:
68
+ if part in ('win', 'window', 'windows', 'super'):
69
+ continue
70
+
71
+ if part in MODIFIER_FLAGS:
72
+ modifiers |= MODIFIER_FLAGS[part]
73
+ elif part in KEY_CODES:
74
+ keycode = KEY_CODES[part]
75
+ else:
76
+ logger.warning(f"Unknown key in hotkey string: {part}")
77
+
78
+ return modifiers, keycode
79
+
80
+
81
+ def _parse_binding(binding: list) -> ParsedBinding:
82
+ hotkey_str = binding[0]
83
+ press_cb = binding[1]
84
+ release_cb = binding[2] if len(binding) > 2 else None
85
+
86
+ modifiers, keycode = _parse_hotkey_string(hotkey_str)
87
+
88
+ return ParsedBinding(
89
+ original=hotkey_str,
90
+ modifiers=modifiers,
91
+ keycode=keycode,
92
+ press_callback=press_cb,
93
+ release_callback=release_cb,
94
+ )
95
+
96
+
97
+ def _handle_flags_changed(event):
98
+ old_flags, new_flags, pressed, released = _state.update(event.modifierFlags())
99
+
100
+ for binding in _bindings:
101
+ if binding.keycode is not None:
102
+ continue
103
+
104
+ if new_flags == binding.modifiers and old_flags != binding.modifiers:
105
+ logger.debug(f"Modifier-only hotkey pressed: {binding.original}")
106
+ binding.is_active = True
107
+ try:
108
+ threading.Thread(target=binding.press_callback, daemon=True).start()
109
+ except Exception as e:
110
+ logger.error(f"Error in press callback for {binding.original}: {e}")
111
+
112
+ elif binding.is_active and (released & binding.modifiers):
113
+ logger.debug(f"Modifier-only hotkey released: {binding.original}")
114
+ binding.is_active = False
115
+ if binding.release_callback:
116
+ try:
117
+ threading.Thread(target=binding.release_callback, daemon=True).start()
118
+ except Exception as e:
119
+ logger.error(f"Error in release callback for {binding.original}: {e}")
120
+
121
+
122
+ def _handle_key_down(event):
123
+ current_flags = event.modifierFlags() & MODIFIER_MASK
124
+ key_code = event.keyCode()
125
+
126
+ logger.debug(f"KeyDown: keycode={key_code}, flags={current_flags:#x}")
127
+
128
+ for binding in _bindings:
129
+ if binding.keycode is None:
130
+ continue
131
+
132
+ if key_code == binding.keycode and current_flags == binding.modifiers:
133
+ logger.debug(f"Traditional hotkey pressed: {binding.original}")
134
+ try:
135
+ threading.Thread(target=binding.press_callback, daemon=True).start()
136
+ except Exception as e:
137
+ logger.error(f"Error in press callback for {binding.original}: {e}")
138
+ return
139
+
140
+
141
+ def _handle_event(event):
142
+ event_type = event.type()
143
+
144
+ if event_type == 12: # NSFlagsChanged
145
+ _handle_flags_changed(event)
146
+ elif event_type == 10: # NSKeyDown
147
+ _handle_key_down(event)
148
+
149
+
150
+ def register(bindings: list):
151
+ global _bindings
152
+ _bindings = [_parse_binding(b) for b in bindings]
153
+ logger.info(f"Registered {len(_bindings)} hotkey bindings")
154
+ for b in _bindings:
155
+ binding_type = "modifier-only" if b.keycode is None else "traditional"
156
+ logger.debug(f" {b.original} -> modifiers={b.modifiers:#x}, keycode={b.keycode} ({binding_type})")
157
+
158
+
159
+ def start():
160
+ global _monitor
161
+ _state.reset()
162
+
163
+ mask = NSKeyDownMask | NSFlagsChangedMask
164
+ _monitor = NSEvent.addGlobalMonitorForEventsMatchingMask_handler_(mask, _handle_event)
165
+
166
+ if _monitor is None:
167
+ logger.error("Failed to create event monitor - check Accessibility permissions in System Settings > Privacy & Security > Accessibility")
168
+ else:
169
+ logger.info("NSEvent hotkey monitor started")
170
+
171
+
172
+ def stop():
173
+ global _monitor
174
+ if _monitor:
175
+ NSEvent.removeMonitor_(_monitor)
176
+ _monitor = None
177
+ logger.info("NSEvent hotkey monitor stopped")
178
+
179
+ for binding in _bindings:
180
+ binding.is_active = False
@@ -0,0 +1,11 @@
1
+ from pathlib import Path
2
+ from PIL import Image
3
+
4
+ ASSETS_DIR = Path(__file__).parent / "assets"
5
+
6
+ def get_tray_icons() -> dict:
7
+ return {
8
+ "idle": Image.open(ASSETS_DIR / "tray_idle.png"),
9
+ "recording": Image.open(ASSETS_DIR / "tray_recording.png"),
10
+ "processing": Image.open(ASSETS_DIR / "tray_processing.png"),
11
+ }
@@ -0,0 +1,31 @@
1
+ import fcntl
2
+ import os
3
+ from pathlib import Path
4
+
5
+ _lock_file = None
6
+
7
+ def acquire_lock(app_name: str):
8
+ global _lock_file
9
+ lock_path = Path.home() / f".{app_name}.lock"
10
+
11
+ try:
12
+ _lock_file = open(lock_path, 'w')
13
+ fcntl.flock(_lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
14
+ _lock_file.write(str(os.getpid()))
15
+ _lock_file.flush()
16
+ return _lock_file
17
+ except (IOError, OSError):
18
+ if _lock_file:
19
+ _lock_file.close()
20
+ _lock_file = None
21
+ return None
22
+
23
+ def release_lock(handle):
24
+ global _lock_file
25
+ if handle:
26
+ try:
27
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
28
+ handle.close()
29
+ except:
30
+ pass
31
+ _lock_file = None