diff --git a/choreographer/_browser/__init__.py b/choreographer/_browser/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/choreographer/_browser/_unix_pipe_chromium_wrapper.py b/choreographer/_browser/_unix_pipe_chromium_wrapper.py new file mode 100644 index 00000000..f36d461e --- /dev/null +++ b/choreographer/_browser/_unix_pipe_chromium_wrapper.py @@ -0,0 +1,49 @@ +""" +_unix_pipe_chromium_wrapper.py provides proper fds to chrome. + +By running chromium in a new process (this wrapper), we guarantee +the user hasn't stolen one of our desired file descriptors, which +the OS gives away first-come-first-serve everytime someone opens a +file. chromium demands we use 3 and 4. +""" + +import os + +# importing modules has side effects, so we do this before imports +# ruff: noqa: E402 + +# chromium reads on 3, writes on 4 +os.dup2(0, 3) # make our stdin their input +os.dup2(1, 4) # make our stdout their output + +_inheritable = True +os.set_inheritable(4, _inheritable) +os.set_inheritable(3, _inheritable) + +import signal +import subprocess +import sys +from functools import partial + +# we're a wrapper, the cli is everything that came after us +cli = sys.argv[1:] +process = subprocess.Popen(cli, pass_fds=(3, 4)) # noqa: S603 untrusted input + + +def kill_proc(process, _sig_num, _frame): + process.terminate() + process.wait(5) # 5 seconds to clean up nicely, it's a lot + process.kill() + + +kp = partial(kill_proc, process) +signal.signal(signal.SIGTERM, kp) +signal.signal(signal.SIGINT, kp) + +process.wait() + +# not great but it seems that +# pipe isn't always closed when chrome closes +# so we pretend to be chrome and send a bye instead +# also, above depends on async/sync, platform, etc +print("{bye}") diff --git a/choreographer/_browser/chromium.py b/choreographer/_browser/chromium.py new file mode 100644 index 00000000..c3dde512 --- /dev/null +++ b/choreographer/_browser/chromium.py @@ -0,0 +1,75 @@ +"""chromium.py provides a class proving tools for running chromium browsers.""" + +import os +import platform +import sys +from pathlib import Path + +# TODO(Andrew): move this to its own subpackage comm # noqa: FIX002, TD003 +from choreographer._pipe import Pipe, WebSocket + +if platform.system() == "Windows": + import msvcrt + + +class Chromium: + def __init__(self, pipe): + self._comm = pipe + # extra information from pipe + + # where do we get user data dir + def get_cli(self, temp_dir, **kwargs): + gpu_enabled = kwargs.pop("with_gpu", False) + headless = kwargs.pop("headless", True) + sandbox = kwargs.pop("with_sandbox", False) + if kwargs: + raise RuntimeError( + "Chromium.get_cli() received " f"invalid args: {kwargs.keys()}", + ) + path = None # TODO(Andrew): not legit # noqa: FIX002,TD003 + chromium_wrapper_path = Path(__file__).resolve().parent / "chromium_wrapper.py" + if platform.system() != "Windows": + cli = [ + sys.executable, + chromium_wrapper_path, + path, + ] + else: + cli = [ + path, + ] + + cli.extend( + [ + "--disable-breakpad", + "--allow-file-access-from-files", + "--enable-logging=stderr", + f"--user-data-dir={temp_dir}", + "--no-first-run", + "--enable-unsafe-swiftshader", + ], + ) + if not gpu_enabled: + cli.append("--disable-gpu") + if headless: + cli.append("--headless") + if not sandbox: + cli.append("--no-sandbox") + + if isinstance(self._comm, Pipe): + cli.append("--remote-debugging-pipe") + if platform.system() == "Windows": + _inheritable = True + write_handle = msvcrt.get_osfhandle(self._comm.from_choreo_to_external) + read_handle = msvcrt.get_osfhandle(self._comm.from_external_to_choreo) + os.set_handle_inheritable(write_handle, _inheritable) + os.set_handle_inheritable(read_handle, _inheritable) + cli += [ + f"--remote-debugging-io-pipes={read_handle!s},{write_handle!s}", + ] + elif isinstance(self._comm, WebSocket): + raise NotImplementedError("Websocket style comms are not implemented yet") + + +def get_env(): + return os.environ().copy() diff --git a/choreographer/_system_utils/_chrome_wrapper.py b/choreographer/_system_utils/_chrome_wrapper.py deleted file mode 100644 index bc9be127..00000000 --- a/choreographer/_system_utils/_chrome_wrapper.py +++ /dev/null @@ -1,117 +0,0 @@ -import os - -# importing modules has side effects, so we do this before imports - -# not everyone uses the wrapper as a process -if __name__ == "__main__": - # chromium reads on 3, writes on 4 - os.dup2(0, 3) # make our stdin their input - os.dup2(1, 4) # make our stdout their output - -import asyncio -import platform -import signal -import subprocess -import sys -from functools import partial - -_inheritable = True - -if platform.system() == "Windows": - import msvcrt -else: - os.set_inheritable(4, _inheritable) - os.set_inheritable(3, _inheritable) - - -def open_browser( # noqa: PLR0913 too many args in func - to_chromium, - from_chromium, - stderr=sys.stderr, - env=None, - loop=None, - *, - loop_hack=False, -): - path = env.get("BROWSER_PATH") - if not path: - raise RuntimeError("No browser path was passed to run") - - user_data_dir = env["USER_DATA_DIR"] - - cli = [ - path, - "--remote-debugging-pipe", - "--disable-breakpad", - "--allow-file-access-from-files", - "--enable-logging=stderr", - f"--user-data-dir={user_data_dir}", - "--no-first-run", - "--enable-unsafe-swiftshader", - ] - if not env.get("GPU_ENABLED", False): - cli.append("--disable-gpu") - if not env.get("SANDBOX_ENABLED", False): - cli.append("--no-sandbox") - - if "HEADLESS" in env: - cli.append("--headless") - - system_dependent = {} - if platform.system() == "Windows": - to_chromium_handle = msvcrt.get_osfhandle(to_chromium) - os.set_handle_inheritable(to_chromium_handle, _inheritable) - from_chromium_handle = msvcrt.get_osfhandle(from_chromium) - os.set_handle_inheritable(from_chromium_handle, _inheritable) - cli += [ - f"--remote-debugging-io-pipes={to_chromium_handle!s},{from_chromium_handle!s}", - ] - system_dependent["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - system_dependent["close_fds"] = False - else: - system_dependent["pass_fds"] = (to_chromium, from_chromium) - - if not loop: - return subprocess.Popen( # noqa: S603 input fine. - cli, - stderr=stderr, - **system_dependent, - ) - elif loop_hack: - - def run(): - return subprocess.Popen( # noqa: S603 input fine. - cli, - stderr=stderr, - **system_dependent, - ) - - return asyncio.to_thread(run) - else: - return asyncio.create_subprocess_exec( - cli[0], - *cli[1:], - stderr=stderr, - **system_dependent, - ) - - -def kill_proc(process, _sig_num, _frame): - process.terminate() - process.wait(5) # 5 seconds to clean up nicely, it's a lot - process.kill() - - -if __name__ == "__main__": - process = open_browser(to_chromium=3, from_chromium=4, env=os.environ) - kp = partial(kill_proc, process) - signal.signal(signal.SIGTERM, kp) - signal.signal(signal.SIGINT, kp) - - process.wait() - - # not great but it seems that - # pipe isn't always closed when chrome closes - # so we pretend to be chrome and send a bye instead - # also, above depends on async/sync, platform, etc - print("{bye}")