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.
- whisper_key/assets/version.txt +1 -1
- whisper_key/audio_feedback.py +21 -20
- whisper_key/audio_recorder.py +2 -9
- whisper_key/clipboard_manager.py +35 -52
- whisper_key/config.defaults.yaml +22 -13
- whisper_key/config_manager.py +25 -9
- whisper_key/console_manager.py +2 -58
- whisper_key/hotkey_listener.py +55 -89
- whisper_key/instance_manager.py +9 -13
- whisper_key/main.py +28 -11
- whisper_key/platform/__init__.py +10 -0
- whisper_key/platform/macos/__init__.py +1 -0
- whisper_key/platform/macos/app.py +27 -0
- whisper_key/platform/macos/console.py +13 -0
- whisper_key/platform/macos/hotkeys.py +180 -0
- whisper_key/platform/macos/icons.py +11 -0
- whisper_key/platform/macos/instance_lock.py +31 -0
- whisper_key/platform/macos/keyboard.py +97 -0
- whisper_key/platform/macos/keycodes.py +17 -0
- whisper_key/platform/macos/paths.py +8 -0
- whisper_key/platform/macos/permissions.py +66 -0
- whisper_key/platform/windows/__init__.py +1 -0
- whisper_key/platform/windows/app.py +6 -0
- whisper_key/platform/windows/console.py +59 -0
- whisper_key/platform/windows/hotkeys.py +30 -0
- whisper_key/platform/windows/icons.py +11 -0
- whisper_key/platform/windows/instance_lock.py +14 -0
- whisper_key/platform/windows/keyboard.py +12 -0
- whisper_key/platform/windows/paths.py +8 -0
- whisper_key/platform/windows/permissions.py +6 -0
- whisper_key/state_manager.py +4 -4
- whisper_key/system_tray.py +30 -57
- whisper_key/terminal_ui.py +71 -0
- whisper_key/utils.py +8 -4
- whisper_key_local-0.6.0.dist-info/METADATA +159 -0
- whisper_key_local-0.6.0.dist-info/RECORD +47 -0
- {whisper_key_local-0.5.2.dist-info → whisper_key_local-0.6.0.dist-info}/WHEEL +1 -1
- whisper_key/assets/tray_idle.png +0 -0
- whisper_key/assets/tray_processing.png +0 -0
- whisper_key/assets/tray_recording.png +0 -0
- whisper_key_local-0.5.2.dist-info/METADATA +0 -130
- whisper_key_local-0.5.2.dist-info/RECORD +0 -29
- {whisper_key_local-0.5.2.dist-info → whisper_key_local-0.6.0.dist-info}/entry_points.txt +0 -0
- {whisper_key_local-0.5.2.dist-info → whisper_key_local-0.6.0.dist-info}/top_level.txt +0 -0
whisper_key/hotkey_listener.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
-
from
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
whisper_key/instance_manager.py
CHANGED
|
@@ -2,35 +2,31 @@ import logging
|
|
|
2
2
|
import sys
|
|
3
3
|
import time
|
|
4
4
|
|
|
5
|
-
import
|
|
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 =
|
|
15
|
-
|
|
16
|
-
if
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|