From 73c1b9a16728c29a5265aacb451a7f90fe734ecf Mon Sep 17 00:00:00 2001 From: ruiqurm Date: Thu, 15 May 2025 15:28:31 +0000 Subject: [PATCH 1/5] fix latest obs sdk error. --- ducktrack/obs_client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ducktrack/obs_client.py b/ducktrack/obs_client.py index 7eee738..fd34bb2 100644 --- a/ducktrack/obs_client.py +++ b/ducktrack/obs_client.py @@ -3,6 +3,7 @@ import time from platform import system +from obsws_python.error import OBSSDKRequestError import obsws_python as obs import psutil @@ -149,9 +150,9 @@ def on_record_state_changed(data): try: self.req_client.set_input_mute("Mic/Aux", muted=True) - except obs.error.OBSSDKRequestError : - # In case there is no Mic/Aux input, this will throw an error + except OBSSDKRequestError: pass + # In case there is no Mic/Aux input, this will throw an error def start_recording(self): self.req_client.start_record() @@ -197,4 +198,4 @@ def _scale_resolution(base_width: int, base_height: int, target_width: int, tar scaled_height = int((target_area / aspect_ratio) ** 0.5) scaled_width = int(aspect_ratio * scaled_height) - return scaled_width, scaled_height \ No newline at end of file + return scaled_width, scaled_height From 52355486f42fc68b935a00f723098a060d97a731 Mon Sep 17 00:00:00 2001 From: Broyojo Date: Tue, 21 Jul 2026 23:13:51 -0700 Subject: [PATCH 2/5] Make OBS startup and connection more robust - Retry the OBS websocket connection with a readiness probe, since OBS may still be starting up when DuckTrack launches it - Launch OBS via `open -a` on macOS so permission prompts work, and quit it gracefully via AppleScript (terminating the `open` process does nothing) - Continue with the current OBS profile instead of crashing when the computer_tracker profile can't be created or switched to - Show a one-time notice on macOS explaining the required permissions Adapted from the huyouare/DuckTrack fork. Co-authored-by: Jesse Hu <3528340+huyouare@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- ducktrack/app.py | 23 ++++++++++++-- ducktrack/obs_client.py | 66 +++++++++++++++++++++++++++++++---------- 2 files changed, 72 insertions(+), 17 deletions(-) diff --git a/ducktrack/app.py b/ducktrack/app.py index 4936451..0ab7ac5 100644 --- a/ducktrack/app.py +++ b/ducktrack/app.py @@ -2,7 +2,7 @@ import sys from platform import system -from PyQt6.QtCore import QTimer, pyqtSlot +from PyQt6.QtCore import QSettings, QTimer, pyqtSlot from PyQt6.QtGui import QAction, QIcon from PyQt6.QtWidgets import (QApplication, QCheckBox, QDialog, QFileDialog, QFormLayout, QLabel, QLineEdit, QMenu, @@ -52,10 +52,29 @@ def __init__(self, app: QApplication): self.init_tray() self.init_window() - + + if system() == "Darwin": + self.show_macos_permissions_notice() + if not is_obs_running(): self.obs_process = open_obs() + def show_macos_permissions_notice(self): + settings = QSettings("TheDuckAI", "DuckTrack") + if settings.value("shown_macos_permissions_notice", False, type=bool): + return + QMessageBox.information( + self, + "Permissions Required", + "DuckTrack needs several macOS permissions to record correctly:\n\n" + "1. Screen Recording (for OBS) - to capture your screen\n" + "2. Accessibility - to track mouse movements\n" + "3. Input Monitoring - to track keyboard events\n\n" + "If recordings come out empty, check System Settings > Privacy & Security " + "and make sure DuckTrack and OBS are allowed." + ) + settings.setValue("shown_macos_permissions_notice", True) + def init_window(self): self.setWindowTitle("DuckTrack") layout = QVBoxLayout(self) diff --git a/ducktrack/obs_client.py b/ducktrack/obs_client.py index fd34bb2..7df697b 100644 --- a/ducktrack/obs_client.py +++ b/ducktrack/obs_client.py @@ -18,7 +18,19 @@ def is_obs_running() -> bool: raise Exception("Could not check if OBS is running already. Please check manually.") def close_obs(obs_process: subprocess.Popen): - if obs_process: + if system() == "Darwin": + # OBS is launched with `open -a`, so obs_process is not the OBS process + # itself and terminating it would do nothing - quit OBS via AppleScript + try: + subprocess.run(["osascript", "-e", 'tell application "OBS" to quit'], timeout=10, check=False) + except subprocess.TimeoutExpired: + pass + for _ in range(10): + if not is_obs_running(): + return + time.sleep(0.5) + subprocess.run(["killall", "OBS"], check=False) + elif obs_process: obs_process.terminate() try: obs_process.wait(timeout=5) @@ -65,6 +77,10 @@ def open_obs() -> subprocess.Popen: # you have to change the working directory first for OBS to find the correct locale on windows os.chdir(os.path.dirname(obs_path)) obs_path = os.path.basename(obs_path) + if system() == "Darwin": + # `open -a` launches OBS the way macOS expects, which keeps the + # screen recording & accessibility permission prompts working + return subprocess.Popen(["open", "-a", "OBS", "--args", "--startreplaybuffer", "--minimize-to-tray"]) return subprocess.Popen([obs_path, "--startreplaybuffer", "--minimize-to-tray"]) except: raise Exception("Failed to find OBS, please open OBS manually.") @@ -84,10 +100,22 @@ def __init__( output_height=720, ): self.metadata = metadata - - self.req_client = obs.ReqClient() - self.event_client = obs.EventClient() - + + # OBS may still be starting up (e.g. when DuckTrack just launched it), + # so retry the websocket connection until it is ready to take requests + max_attempts = 5 + for attempt in range(1, max_attempts + 1): + try: + self.req_client = obs.ReqClient() + self.event_client = obs.EventClient() + print("connected to OBS version:", self.req_client.get_version().obs_version) + break + except Exception as e: + if attempt == max_attempts: + raise Exception("Could not connect to OBS. Please check that it is running and that the websocket server is enabled.") from e + print(f"waiting for OBS websocket (attempt {attempt}/{max_attempts}): {e}") + time.sleep(2) + self.record_state_events = {} def on_record_state_changed(data): @@ -99,15 +127,19 @@ def on_record_state_changed(data): self.event_client.callback.register(on_record_state_changed) - self.old_profile = self.req_client.get_profile_list().current_profile_name - - if "computer_tracker" not in self.req_client.get_profile_list().profiles: - self.req_client.create_profile("computer_tracker") - else: - self.req_client.set_current_profile("computer_tracker") - self.req_client.create_profile("temp") - self.req_client.remove_profile("temp") - self.req_client.set_current_profile("computer_tracker") + self.old_profile = None + try: + self.old_profile = self.req_client.get_profile_list().current_profile_name + + if "computer_tracker" not in self.req_client.get_profile_list().profiles: + self.req_client.create_profile("computer_tracker") + else: + self.req_client.set_current_profile("computer_tracker") + self.req_client.create_profile("temp") + self.req_client.remove_profile("temp") + self.req_client.set_current_profile("computer_tracker") + except OBSSDKRequestError as e: + print(f"warning: could not switch to the computer_tracker profile, continuing with the current profile: {e}") base_width = metadata["screen_width"] base_height = metadata["screen_height"] @@ -159,7 +191,11 @@ def start_recording(self): def stop_recording(self): self.req_client.stop_record() - self.req_client.set_current_profile(self.old_profile) # restore old profile + if self.old_profile: + try: + self.req_client.set_current_profile(self.old_profile) # restore old profile + except OBSSDKRequestError as e: + print(f"warning: could not restore the previous OBS profile: {e}") def pause_recording(self): self.req_client.pause_record() From 55ea1d454e0d609fea8e6fe43227fb59dfd6b08e Mon Sep 17 00:00:00 2001 From: Broyojo Date: Tue, 21 Jul 2026 23:15:20 -0700 Subject: [PATCH 3/5] Add ctrl+alt+r global hotkey to start/stop recording The hotkey listener runs on a pynput listener thread, so it emits a Qt signal to make toggle_record run on the main thread. Left/right modifier variants (ctrl_l, alt_r, ...) are folded into their generic key so combinations match across platforms. Feature adapted from the jxbb824/DuckTrack fork, reimplemented with the existing pynput-based listener instead of the `keyboard` library, which requires root on macOS and Linux. Co-authored-by: Xirui Jiang Co-Authored-By: Claude Fable 5 --- README.md | 2 +- ducktrack/app.py | 13 ++++++++++++- ducktrack/keycomb.py | 22 +++++++++++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f9058e1..781e608 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ You can run the app like any other desktop app on your computer. If you decided ### Recording -From the app tray or GUI, you can start and stop a recording as well as pause and resume a recording. Pausing and resuming is important for when you want to hide sensitive information like credit card of login credentials. You can optionally name your recording and give it a description upon stopping a recording. You can also view your recordings by pressing the "Show Recordings" option. +From the app tray or GUI, you can start and stop a recording as well as pause and resume a recording. Pausing and resuming is important for when you want to hide sensitive information like credit card of login credentials. You can optionally name your recording and give it a description upon stopping a recording. You can also view your recordings by pressing the "Show Recordings" option. Alternatively, press `ctrl`+`alt`+`r` from anywhere to start or stop a recording. ### Playback diff --git a/ducktrack/app.py b/ducktrack/app.py index 0ab7ac5..1ed26c1 100644 --- a/ducktrack/app.py +++ b/ducktrack/app.py @@ -2,13 +2,14 @@ import sys from platform import system -from PyQt6.QtCore import QSettings, QTimer, pyqtSlot +from PyQt6.QtCore import QSettings, QTimer, pyqtSignal, pyqtSlot from PyQt6.QtGui import QAction, QIcon from PyQt6.QtWidgets import (QApplication, QCheckBox, QDialog, QFileDialog, QFormLayout, QLabel, QLineEdit, QMenu, QMessageBox, QPushButton, QSystemTrayIcon, QTextEdit, QVBoxLayout, QWidget) +from .keycomb import KeyCombinationListener from .obs_client import close_obs, is_obs_running, open_obs from .playback import Player, get_latest_recording from .recorder import Recorder @@ -43,6 +44,10 @@ def get_values(self): return self.title_input.text(), self.description_input.toPlainText() class MainInterface(QWidget): + # emitted by the hotkey listener thread so that toggle_record + # always runs on the Qt main thread + toggle_record_requested = pyqtSignal() + def __init__(self, app: QApplication): super().__init__() self.tray = QSystemTrayIcon(QIcon(resource_path("assets/duck.png"))) @@ -59,6 +64,11 @@ def __init__(self, app: QApplication): if not is_obs_running(): self.obs_process = open_obs() + self.toggle_record_requested.connect(self.toggle_record) + self.hotkey_listener = KeyCombinationListener() + self.hotkey_listener.add_comb(("ctrl", "alt", "r"), self.toggle_record_requested.emit) + self.hotkey_listener.start() + def show_macos_permissions_notice(self): settings = QSettings("TheDuckAI", "DuckTrack") if settings.value("shown_macos_permissions_notice", False, type=bool): @@ -189,6 +199,7 @@ def quit(self): self.toggle_record() if hasattr(self, "obs_process"): close_obs(self.obs_process) + self.hotkey_listener.stop() self.app.quit() def closeEvent(self, event): diff --git a/ducktrack/keycomb.py b/ducktrack/keycomb.py index 4f05c11..89adc57 100644 --- a/ducktrack/keycomb.py +++ b/ducktrack/keycomb.py @@ -2,12 +2,27 @@ from .util import name_to_key +# pynput reports left/right variants (e.g. ctrl_l) on some platforms, +# so fold them into the generic modifier for combination matching +CANONICAL_KEY_NAMES = { + "ctrl_l": "ctrl", "ctrl_r": "ctrl", + "alt_l": "alt", "alt_r": "alt", "alt_gr": "alt", + "shift_l": "shift", "shift_r": "shift", + "cmd_l": "cmd", "cmd_r": "cmd", +} + +def canonicalize_key(key): + name = getattr(key, "name", None) + if name in CANONICAL_KEY_NAMES: + return name_to_key(CANONICAL_KEY_NAMES[name]) + return key + class KeyCombinationListener: """ Simple and bad key combination listener. """ - + def __init__(self): self.current_keys = set() self.callbacks = {} @@ -17,12 +32,13 @@ def add_comb(self, keys, callback): self.callbacks[tuple([name_to_key(key_name) for key_name in sorted(keys)])] = callback def on_key_press(self, key): - self.current_keys.add(key) + self.current_keys.add(canonicalize_key(key)) for comb, callback in self.callbacks.items(): if all(k in self.current_keys for k in comb): return callback() def on_key_release(self, key): + key = canonicalize_key(key) if key in self.current_keys: self.current_keys.remove(key) @@ -30,4 +46,4 @@ def start(self): self.listener.start() def stop(self): - self.listener.stop() \ No newline at end of file + self.listener.stop() From 2cf4d05e10f4a96e67454c40f02477876957572e Mon Sep 17 00:00:00 2001 From: Broyojo Date: Tue, 21 Jul 2026 23:21:36 -0700 Subject: [PATCH 4/5] Use pynput GlobalHotKeys and canonical() for reliable combo matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Held modifiers change the character pynput reports (ctrl+r arrives as '\x12' on Windows, alt+r as '®' on macOS), which breaks naive key-set matching. GlobalHotKeys handles this internally for the record hotkey, and KeyCombinationListener now normalizes via listener.canonical() for the shift+esc playback combo. Co-Authored-By: Claude Fable 5 --- ducktrack/app.py | 6 +++--- ducktrack/keycomb.py | 20 +++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/ducktrack/app.py b/ducktrack/app.py index 1ed26c1..70670b7 100644 --- a/ducktrack/app.py +++ b/ducktrack/app.py @@ -9,7 +9,8 @@ QMessageBox, QPushButton, QSystemTrayIcon, QTextEdit, QVBoxLayout, QWidget) -from .keycomb import KeyCombinationListener +from pynput.keyboard import GlobalHotKeys + from .obs_client import close_obs, is_obs_running, open_obs from .playback import Player, get_latest_recording from .recorder import Recorder @@ -65,8 +66,7 @@ def __init__(self, app: QApplication): self.obs_process = open_obs() self.toggle_record_requested.connect(self.toggle_record) - self.hotkey_listener = KeyCombinationListener() - self.hotkey_listener.add_comb(("ctrl", "alt", "r"), self.toggle_record_requested.emit) + self.hotkey_listener = GlobalHotKeys({"++r": self.toggle_record_requested.emit}) self.hotkey_listener.start() def show_macos_permissions_notice(self): diff --git a/ducktrack/keycomb.py b/ducktrack/keycomb.py index 89adc57..e3a3ba5 100644 --- a/ducktrack/keycomb.py +++ b/ducktrack/keycomb.py @@ -11,13 +11,6 @@ "cmd_l": "cmd", "cmd_r": "cmd", } -def canonicalize_key(key): - name = getattr(key, "name", None) - if name in CANONICAL_KEY_NAMES: - return name_to_key(CANONICAL_KEY_NAMES[name]) - return key - - class KeyCombinationListener: """ Simple and bad key combination listener. @@ -31,14 +24,23 @@ def __init__(self): def add_comb(self, keys, callback): self.callbacks[tuple([name_to_key(key_name) for key_name in sorted(keys)])] = callback + def canonicalize_key(self, key): + # listener.canonical undoes the effect of held modifiers on the + # reported character (e.g. ctrl+r arriving as '\x12') + key = self.listener.canonical(key) + name = getattr(key, "name", None) + if name in CANONICAL_KEY_NAMES: + return name_to_key(CANONICAL_KEY_NAMES[name]) + return key + def on_key_press(self, key): - self.current_keys.add(canonicalize_key(key)) + self.current_keys.add(self.canonicalize_key(key)) for comb, callback in self.callbacks.items(): if all(k in self.current_keys for k in comb): return callback() def on_key_release(self, key): - key = canonicalize_key(key) + key = self.canonicalize_key(key) if key in self.current_keys: self.current_keys.remove(key) From e8707684c7f3186189032ed2a4aa2de1d7df6d9b Mon Sep 17 00:00:00 2001 From: Broyojo Date: Tue, 21 Jul 2026 23:28:43 -0700 Subject: [PATCH 5/5] Canonicalize stored combos and add keycomb tests pynput's listener.canonical() converts special keys like Key.esc into bare vk KeyCodes, so canonicalizing only the incoming presses broke matching against combos stored as Key values. Canonicalize the combo keys at registration time too, so both sides compare in canonical form. Caught by the new tests, which cover generic and left/right modifier variants, shifted characters, release handling, and the ++r hotkey spec used in app.py. Co-Authored-By: Claude Fable 5 --- ducktrack/keycomb.py | 13 +++++----- tests/test_keycomb.py | 57 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 tests/test_keycomb.py diff --git a/ducktrack/keycomb.py b/ducktrack/keycomb.py index e3a3ba5..8a6e85c 100644 --- a/ducktrack/keycomb.py +++ b/ducktrack/keycomb.py @@ -22,16 +22,17 @@ def __init__(self): self.listener = Listener(on_press=self.on_key_press, on_release=self.on_key_release) def add_comb(self, keys, callback): - self.callbacks[tuple([name_to_key(key_name) for key_name in sorted(keys)])] = callback + # store combos in canonical form so they compare against canonicalized presses + self.callbacks[tuple([self.canonicalize_key(name_to_key(key_name)) for key_name in sorted(keys)])] = callback def canonicalize_key(self, key): - # listener.canonical undoes the effect of held modifiers on the - # reported character (e.g. ctrl+r arriving as '\x12') - key = self.listener.canonical(key) + # fold left/right modifier variants first, then let listener.canonical + # undo the effect of held modifiers on the reported character + # (e.g. ctrl+r arriving as '\x12') name = getattr(key, "name", None) if name in CANONICAL_KEY_NAMES: - return name_to_key(CANONICAL_KEY_NAMES[name]) - return key + key = name_to_key(CANONICAL_KEY_NAMES[name]) + return self.listener.canonical(key) def on_key_press(self, key): self.current_keys.add(self.canonicalize_key(key)) diff --git a/tests/test_keycomb.py b/tests/test_keycomb.py new file mode 100644 index 0000000..6c26d19 --- /dev/null +++ b/tests/test_keycomb.py @@ -0,0 +1,57 @@ +from pynput.keyboard import HotKey, Key, KeyCode + +from ducktrack.keycomb import KeyCombinationListener + + +def make_listener(keys): + listener = KeyCombinationListener() + fired = [] + listener.add_comb(keys, lambda: fired.append(1)) + return listener, fired + + +def test_combo_fires_with_generic_modifier(): + listener, fired = make_listener(("shift", "esc")) + listener.on_key_press(Key.shift) + listener.on_key_press(Key.esc) + assert fired == [1] + + +def test_combo_fires_with_left_right_modifier_variants(): + listener, fired = make_listener(("shift", "esc")) + listener.on_key_press(Key.shift_l) + listener.on_key_press(Key.esc) + assert fired == [1] + + +def test_release_clears_pressed_state(): + listener, fired = make_listener(("shift", "esc")) + listener.on_key_press(Key.shift_l) + listener.on_key_release(Key.shift_l) + listener.on_key_press(Key.esc) + assert fired == [] + assert listener.current_keys == {listener.canonicalize_key(Key.esc)} + + +def test_shifted_character_is_canonicalized(): + # with shift held, pynput reports 'R' instead of 'r' + listener, fired = make_listener(("shift", "r")) + listener.on_key_press(Key.shift) + listener.on_key_press(KeyCode.from_char("R")) + assert fired == [1] + + +def test_non_matching_combo_does_not_fire(): + listener, fired = make_listener(("shift", "esc")) + listener.on_key_press(Key.ctrl) + listener.on_key_press(Key.esc) + assert fired == [] + + +def test_record_hotkey_spec_parses_and_fires(): + # same spec as the GlobalHotKeys binding in app.py + fired = [] + hotkey = HotKey(HotKey.parse("++r"), lambda: fired.append(1)) + for key in [Key.ctrl, Key.alt, KeyCode.from_char("r")]: + hotkey.press(key) + assert fired == [1]