Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions ducktrack/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
73 changes: 55 additions & 18 deletions ducktrack/obs_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import time
from platform import system

from obsws_python.error import OBSSDKRequestError
import obsws_python as obs
import psutil

Expand All @@ -17,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)
Expand Down Expand Up @@ -64,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.")
Expand All @@ -83,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):
Expand All @@ -98,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"]
Expand Down Expand Up @@ -149,16 +182,20 @@ 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()

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()
Expand Down Expand Up @@ -197,4 +234,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
return scaled_width, scaled_height