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
30 changes: 23 additions & 7 deletions joinly/providers/browser/browser_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@

from playwright.async_api import Browser as PlaywrightBrowser
from playwright.async_api import BrowserContext, Page, Playwright, async_playwright
from playwright_stealth import Stealth

from joinly.utils.logging import LOGGING_TRACE

logger = logging.getLogger(__name__)

_CDP_RE = re.compile(r"DevTools listening on (ws://.*)")
_CDP_RE = re.compile(r"DevTools listening on ((?:ws|http)://.*)")


class BrowserSession:
Expand Down Expand Up @@ -42,7 +43,10 @@ async def __aenter__(self) -> Self:
"""Start and connect to the Playwright browser."""
self._playwright = await async_playwright().start()

bin_path = Path(self._playwright.chromium.executable_path)
bin_path = Path("/usr/bin/google-chrome")
if not bin_path.exists():
bin_path = Path(self._playwright.chromium.executable_path)

logger.debug("Chromium binary path: %s", bin_path)
if not bin_path.exists():
msg = "Chromium binary not found"
Expand All @@ -53,11 +57,10 @@ async def __aenter__(self) -> Self:
logger.debug("Profile directory created at: %s", self._profile_dir.name)

logger.debug("Launching Chromium browser.")
self._proc = await asyncio.create_subprocess_exec(
str(bin_path),
runner_path = Path(__file__).parent / "uc_runner.py"
chrome_args = [
f"--remote-debugging-port={self._cdp_port}",
f"--user-data-dir={self._profile_dir.name}",
"--use-fake-ui-for-media-stream",
"--alsa-output-device=pulse",
f"--alsa-input-device={self._env.get('PULSE_SOURCE')}",
"--autoplay-policy=no-user-gesture-required",
Expand All @@ -66,11 +69,9 @@ async def __aenter__(self) -> Self:
"--enable-usermedia-screen-capturing",
"--enable-features=WebRTCPipeWireCapturer",
"--ozone-platform=x11",
"--disable-gpu",
"--disable-focus-on-load",
"--window-size=1280,720",
"--lang=en-US",
"--test-type",
"--no-sandbox", # required for docker
"--disable-dev-shm-usage",
"--disable-gpu-sandbox",
Expand All @@ -80,6 +81,14 @@ async def __aenter__(self) -> Self:
"--force-device-scale-factor=1",
"--disable-features=TranslateUI,MediaRouter,WebRtcAutomaticGainControl",
"--disable-backgrounding-occluded-windows",
]

import json
import sys
self._proc = await asyncio.create_subprocess_exec(
sys.executable,
str(runner_path),
json.dumps(chrome_args),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
env=self._env,
Expand All @@ -105,16 +114,23 @@ async def __aenter__(self) -> Self:
cdp_endpoint
)
self._pw_context = self._pw_browser.contexts[0]
await self._pw_context.grant_permissions(['camera', 'microphone'])
await self._pw_context.tracing.start(screenshots=True, snapshots=True, sources=True)
self._default_page = (
self._pw_context.pages[0] if self._pw_context.pages else None
)
if self._default_page:
pass

logger.debug("Playwright started.")

return self

async def __aexit__(self, *exc: object) -> None:
"""Stop the browser."""
if self._pw_context:
await self._pw_context.tracing.stop(path="trace.zip")

logger.debug("Stopping browser.")

for page in self._pages:
Expand Down
37 changes: 34 additions & 3 deletions joinly/providers/browser/devices/pulse_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ def __init__(
Args:
env: Optional environment dictionary to set the audio server path.
"""
self._env: dict[str, str] = env if env is not None else {}
import os
self._env: dict[str, str] = env.copy() if env is not None else dict(os.environ)
self.socket_path: Path | None = None
self._dir: tempfile.TemporaryDirectory[str] | None = None
self._proc: asyncio.subprocess.Process | None = None
Expand All @@ -44,23 +45,53 @@ async def __aenter__(self) -> Self:
self._env[_RUNTIME_ENV_VAR] = self._dir.name
self._env[_SERVER_ENV_VAR] = f"unix:{self.socket_path}"
self._env[_AUTOSPAWN_ENV_VAR] = "1"
self._env["DBUS_SESSION_BUS_ADDRESS"] = "none"

script_file = Path(self._dir.name) / "default.pa"
script_file.write_text(f"load-module module-native-protocol-unix auth-anonymous=1 socket={self.socket_path}\n"
f"load-module module-null-sink sink_name=auto_null\n"
f"load-module module-always-sink\n")

logger.debug("Starting PulseAudio server under %s", self._dir.name)
child_env = self._env.copy()
child_env.pop(_SERVER_ENV_VAR, None)

self._proc = await asyncio.create_subprocess_exec(
"/usr/bin/pulseaudio",
"--daemonize=no",
"--exit-idle-time=-1",
"--file=/dev/null",
"--high-priority=no",
"--realtime=no",
"-n",
f"--file={script_file}",
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
env=self._env,
env=child_env,
start_new_session=True,
)

try:
await asyncio.wait_for(_wait_for_server(self.socket_path), timeout=5)
except TimeoutError as e:
msg = "PulseAudio server did not start in time"

import subprocess
ls_output = subprocess.run(["ls", "-la", self._dir.name], capture_output=True, text=True).stdout
msg += f"\nSocket path: {self.socket_path}. Directory contents:\n{ls_output}"

log_path = Path(self._dir.name) / "pulse.log"
if log_path.exists():
msg += f"\nPulse log:\n{log_path.read_text(errors='replace')}"

if self._proc.returncode is not None:
stderr_bytes = await self._proc.stderr.read()
msg += f"\nProcess exited with {self._proc.returncode}. Stderr: {stderr_bytes.decode(errors='replace')}"
else:
self._proc.terminate()
stderr_bytes = await self._proc.stderr.read()
msg += f"\nProcess was still running. Stderr: {stderr_bytes.decode(errors='replace')}"

logger.error(msg) # noqa: TRY400
self._proc.kill()
await self._proc.wait()
Expand Down
2 changes: 1 addition & 1 deletion joinly/providers/browser/meeting_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ async def _action_guard(
logger.exception(msg)
if isinstance(e, (ProviderNotSupportedError, ValueError)):
raise
raise RuntimeError(msg) from None
raise RuntimeError(msg) from e
else:
logger.info("Successfully performed '%s'.", action)

Expand Down
44 changes: 36 additions & 8 deletions joinly/providers/browser/platforms/google_meet.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,39 @@ async def join(
await page.goto(url, wait_until="load", timeout=20000)

name_field = page.get_by_placeholder(re.compile("name", re.IGNORECASE))
await name_field.fill(name, timeout=20000)

join_btn = page.get_by_role(
"button", name=re.compile(r"^(?!.*other ways).*join.*$", re.IGNORECASE)
)
await join_btn.click(timeout=1000)
try:
await name_field.wait_for(state="visible", timeout=10000)
box = await name_field.bounding_box()
if box:
await page.mouse.click(box["x"] + 10, box["y"] + 10)
await page.keyboard.type(name)
except Exception:
logger.warning("Could not interact with name field via mouse click.")

await page.wait_for_timeout(1000)

# Click the join button
join_btn = page.locator("button:has-text('Ask to join'), button:has-text('Join now')").first

try:
# wait for button to be enabled
for _ in range(10):
is_disabled = await join_btn.evaluate("node => node.disabled")
if not is_disabled:
break
await page.wait_for_timeout(500)

box = await join_btn.bounding_box()
if box:
await page.mouse.click(box["x"] + 10, box["y"] + 10)
else:
await join_btn.click(timeout=5000)
except Exception:
logger.warning("Playwright natural click failed, attempting JS click.")
await join_btn.evaluate("node => node.click()")

if not await self._check_joined(page):
await page.screenshot(path="join_failed_chrome.png")
msg = "Join check failed: Failed to join the Google Meet meeting."
raise RuntimeError(msg)

Expand Down Expand Up @@ -270,9 +295,12 @@ async def _check_joined(self, page: Page, timeout: float = 10) -> bool: # noqa:
bool: True if joined, False otherwise.
"""
locators = [
page.locator("div >> text=/asking to be let in/i"),
page.locator('[aria-label^="someone lets you in" i]'),
page.get_by_text(re.compile("asking to be let in", re.IGNORECASE)),
page.get_by_text(re.compile("asking to join", re.IGNORECASE)),
page.get_by_text(re.compile("when someone lets you in", re.IGNORECASE)),
page.locator('[aria-label*="someone lets you in" i]'),
page.get_by_role("button", name=re.compile(r"leave", re.IGNORECASE)),
page.get_by_role("button", name=re.compile(r"You're in the waiting room", re.IGNORECASE))
]

tasks = [
Expand Down
29 changes: 29 additions & 0 deletions joinly/providers/browser/uc_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import undetected_chromedriver as uc
import sys
import json
import time

def main():
if len(sys.argv) > 1:
args = json.loads(sys.argv[1])
else:
args = []

options = uc.ChromeOptions()
for arg in args:
options.add_argument(arg)

driver = uc.Chrome(options=options)
debugger_address = driver.capabilities['goog:chromeOptions']['debuggerAddress']

# Print the format expected by browser_session.py
print(f"DevTools listening on http://{debugger_address}", file=sys.stderr, flush=True)

try:
while True:
time.sleep(1)
except KeyboardInterrupt:
driver.quit()

if __name__ == '__main__':
main()
Loading