diff --git a/dangerzone/__init__.py b/dangerzone/__init__.py index be8ed79..c235e0a 100644 --- a/dangerzone/__init__.py +++ b/dangerzone/__init__.py @@ -1,146 +1,13 @@ -from PyQt5 import QtCore, QtWidgets import os import sys -import signal -import platform -import click -import time -import uuid -import subprocess - -from .global_common import GlobalCommon -from .main_window import MainWindow -from .docker_installer import ( - is_docker_installed, - is_docker_ready, - launch_docker_windows, - DockerInstaller, -) +from .container import container_main dangerzone_version = "0.1" - -class Application(QtWidgets.QApplication): - document_selected = QtCore.pyqtSignal(str) - application_activated = QtCore.pyqtSignal() - - def __init__(self): - QtWidgets.QApplication.__init__(self, sys.argv) - - def event(self, event): - # In macOS, handle the file open event - if event.type() == QtCore.QEvent.FileOpen: - self.document_selected.emit(event.file()) - return True - elif event.type() == QtCore.QEvent.ApplicationActivate: - self.application_activated.emit() - return True - - return QtWidgets.QApplication.event(self, event) - - -@click.command() -@click.option("--custom-container") # Use this container instead of flmcode/dangerzone -@click.argument("filename", required=False) -def main(custom_container, filename): - click.echo(f"dangerzone {dangerzone_version}") - - # Create the Qt app - app = Application() - app.setQuitOnLastWindowClosed(False) - - # GlobalCommon object - global_common = GlobalCommon(app) - - if custom_container: - # Do we have this container? - output = subprocess.check_output( - [global_common.container_runtime, "image", "ls", custom_container], - startupinfo=global_common.get_subprocess_startupinfo(), - ) - if custom_container.encode() not in output: - click.echo(f"Container '{container}' not found") - return - - global_common.custom_container = custom_container - - # Allow Ctrl-C to smoothly quit the program instead of throwing an exception - signal.signal(signal.SIGINT, signal.SIG_DFL) - - # If we're using Linux and docker, see if we need to add the user to the docker group - if ( - platform.system() == "Linux" - and global_common.container_runtime == "/usr/bin/docker" - ): - if not global_common.ensure_user_is_in_docker_group(): - click.echo("Failed to add user to docker group") - return - if not global_common.ensure_docker_service_is_started(): - click.echo("Failed to start docker service") - return - - # See if we need to install Docker... - if (platform.system() == "Darwin" or platform.system() == "Windows") and ( - not is_docker_installed(global_common) or not is_docker_ready(global_common) - ): - click.echo("Docker is either not installed or not running") - docker_installer = DockerInstaller(global_common) - docker_installer.start() - return - - closed_windows = {} - windows = {} - - def delete_window(window_id): - closed_windows[window_id] = windows[window_id] - del windows[window_id] - - # Open a document in a window - def select_document(filename=None): - if ( - len(windows) == 1 - and windows[list(windows.keys())[0]].common.document_filename == None - ): - window = windows[list(windows.keys())[0]] - else: - window_id = uuid.uuid4().hex - window = MainWindow(global_common, window_id) - window.delete_window.connect(delete_window) - windows[window_id] = window - - if filename: - # Validate filename - filename = os.path.abspath(os.path.expanduser(filename)) - try: - open(filename, "rb") - except FileNotFoundError: - click.echo("File not found") - return False - except PermissionError: - click.echo("Permission denied") - return False - window.common.document_filename = filename - window.doc_selection_widget.document_selected.emit() - - return True - - # Open a new window if not filename is passed - if filename is None: - select_document() - else: - # If filename is passed as an argument, open it - if not select_document(filename): - return True - - # Open a new window, if all windows are closed - def application_activated(): - if len(windows) == 0: - select_document() - - # If we get a file open event, open it - app.document_selected.connect(select_document) - - # If the application is activated and all windows are closed, open a new one - app.application_activated.connect(application_activated) - - sys.exit(app.exec_()) +# This is a hack for Windows and Mac to be able to run dangerzone-container, even though +# PyInstaller builds a single binary +if os.path.basename(sys.argv[0]) == "dangerzone-container": + main = container_main +else: + # If the binary isn't "dangerzone-contatiner", then launch the GUI + from .gui import gui_main as main diff --git a/dangerzone/container.py b/dangerzone/container.py new file mode 100644 index 0000000..878e10b --- /dev/null +++ b/dangerzone/container.py @@ -0,0 +1,115 @@ +import click +import platform +import subprocess +import sys +import pipes +import getpass + +# What is the container runtime for this platform? +if platform.system() == "Darwin": + container_runtime = "/usr/local/bin/docker" +elif platform.system() == "Windows": + container_runtime = "C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe" +else: + container_runtime = "/usr/bin/docker" + +# Define startupinfo for subprocesses +if platform.system() == "Windows": + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW +else: + startupinfo = None + + +def exec_container(args): + args = [container_runtime] + args + + args_str = " ".join(pipes.quote(s) for s in args) + sys.stdout.write(f"Executing: {args_str}\n\n") + sys.stdout.flush() + + with subprocess.Popen( + args, + stdin=None, + stdout=sys.stdout, + stderr=sys.stderr, + bufsize=1, + universal_newlines=True, + startupinfo=startupinfo, + ) as p: + p.communicate() + return p.returncode + + +@click.group() +def container_main(): + """ + Dangerzone container commands with elevated privileges. + Humans don't need to run this command by themselves. + """ + pass + + +@container_main.command() +@click.option("--container-name", default="flmcode/dangerzone") +def image_ls(container_name): + """docker image ls [container_name]""" + sys.exit(exec_container(["image", "ls", container_name])) + + +@container_main.command() +def pull(): + """docker pull flmcode/dangerzone""" + sys.exit(exec_container(["pull", "flmcode/dangerzone"])) + + +@container_main.command() +@click.option("--document-filename", required=True) +@click.option("--pixel-dir", required=True) +@click.option("--container-name", default="flmcode/dangerzone") +def document_to_pixels(document_filename, pixel_dir, container_name): + """docker run --network none -v [document_filename]:/tmp/input_file -v [pixel_dir]:/dangerzone [container_name] document-to-pixels""" + sys.exit( + exec_container( + [ + "run", + "--network", + "none", + "-v", + f"{document_filename}:/tmp/input_file", + "-v", + f"{pixel_dir}:/dangerzone", + container_name, + "document-to-pixels", + ] + ) + ) + + +@container_main.command() +@click.option("--pixel-dir", required=True) +@click.option("--safe-dir", required=True) +@click.option("--container-name", default="flmcode/dangerzone") +@click.option("--ocr", required=True) +@click.option("--ocr-lang", required=True) +def pixels_to_pdf(pixel_dir, safe_dir, container_name, ocr, ocr_lang): + """docker run --network none -v [pixel_dir]:/dangerzone -v [safe_dir]:/safezone [container_name] -e OCR=[ocr] -e OCR_LANGUAGE=[ocr_lang] pixels-to-pdf""" + sys.exit( + exec_container( + [ + "run", + "--network", + "none", + "-v", + f"{pixel_dir}:/dangerzone", + "-v", + f"{safe_dir}:/safezone", + "-e", + f"OCR={ocr}", + "-e", + f"OCR_LANGUAGE={ocr_lang}", + container_name, + "pixels-to-pdf", + ] + ) + ) diff --git a/dangerzone/docker_installer.py b/dangerzone/docker_installer.py index 641656e..b1f043a 100644 --- a/dangerzone/docker_installer.py +++ b/dangerzone/docker_installer.py @@ -8,29 +8,31 @@ import time import platform from PyQt5 import QtCore, QtGui, QtWidgets +from .container import container_runtime + def is_docker_installed(global_common): if platform.system() == "Darwin": # Does the docker binary exist? if os.path.isdir("/Applications/Docker.app") and os.path.exists( - global_common.container_runtime + container_runtime ): # Is it executable? - st = os.stat(global_common.container_runtime) + st = os.stat(container_runtime) return bool(st.st_mode & stat.S_IXOTH) if platform.system() == "Windows": - return os.path.exists(global_common.container_runtime) + return os.path.exists(container_runtime) return False def is_docker_ready(global_common): - # Run `docker ps` without an error + # Run `docker image ls` without an error try: + print(global_common.get_dangerzone_container_args()) subprocess.run( - [global_common.container_runtime, "ps"], - check=True, + global_common.get_dangerzone_container_args() + ["image-ls"], startupinfo=global_common.get_subprocess_startupinfo(), ) return True diff --git a/dangerzone/global_common.py b/dangerzone/global_common.py index e8fd1e8..95e6934 100644 --- a/dangerzone/global_common.py +++ b/dangerzone/global_common.py @@ -57,26 +57,12 @@ class GlobalCommon(object): # App data folder self.appdata_path = appdirs.user_config_dir("dangerzone") - # Container runtime - if platform.system() == "Darwin": - self.container_runtime = "/usr/local/bin/docker" - elif platform.system() == "Windows": - self.container_runtime = ( - "C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe" - ) - else: - # Linux - - # If this is fedora-like, use podman - if os.path.exists("/usr/bin/dnf"): - self.container_runtime = "/usr/bin/podman" - # Otherwise, use docker - else: - self.container_runtime = "/usr/bin/docker" - # In case we have a custom container self.custom_container = None + # dangerzone-container path + self.dz_container_path = self.get_dangerzone_container_path() + # Preload list of PDF viewers on computer self.pdf_viewers = self._find_pdf_viewers() @@ -278,6 +264,35 @@ class GlobalCommon(object): resource_path = os.path.join(prefix, filename) return resource_path + def get_dangerzone_container_path(self): + if getattr(sys, "dangerzone_dev", False): + # Look for resources directory relative to python file + return os.path.join( + os.path.dirname( + os.path.dirname( + os.path.abspath(inspect.getfile(inspect.currentframe())) + ) + ), + "dev_scripts", + "dangerzone-container", + ) + else: + if platform.system() == "Darwin": + return os.path.join(os.path.dirname(sys.executable), "dangerzone-container") + elif platform.system() == "Windows": + return os.path.join(os.path.dirname(sys.executable), "dangerzone-container.exe") + else: + return "/usr/bin/dangerzone-container" + + def get_dangerzone_container_args(self): + if platform.system() == "Linux": + if self.settings.get("linux_prefers_typing_password"): + return ["/usr/bin/pkexec", self.dz_container_path] + else: + return [self.dz_container_path] + else: + return [self.dz_container_path] + def get_window_icon(self): if platform.system() == "Windows": path = self.get_resource_path("dangerzone.ico") @@ -372,18 +387,40 @@ class GlobalCommon(object): return pdf_viewers - def ensure_user_is_in_docker_group(self): + def ensure_docker_group_preference(self): + # If the user prefers typing their password + if self.settings.get("linux_prefers_typing_password") == True: + return True + + # Get the docker group try: groupinfo = grp.getgrnam("docker") except: # Ignore if group is not found return True + # See if the user is in the group username = getpass.getuser() if username not in groupinfo.gr_mem: - # User is not in docker group, so prompt about adding the user to the docker group - message = "Dangerzone requires Docker.

Click Ok to add your user to the 'docker' group. You will have to type your login password." - if Alert(self, message).launch(): + # User is not in the docker group, ask if they prefer typing their password + message = "Dangerzone requires Docker

In order to use Docker, your user must be in the 'docker' group or you'll need to type your password each time you run dangerzone.

Adding your user to the 'docker' group is more convenient but less secure, and will require just typing your password once. Which do you prefer?" + return_code = Alert( + self, + message, + ok_text="I'll type my password each time", + extra_button_text="Add my user to the 'docker' group", + ).launch() + if return_code == QtWidgets.QDialog.Accepted: + # Prefers typing password + self.settings.set("linux_prefers_typing_password", True) + self.settings.save() + return True + elif return_code == 2: + # Prefers being in the docker group + self.settings.set("linux_prefers_typing_password", False) + self.settings.save() + + # Add user to the docker group p = subprocess.run( [ "/usr/bin/pkexec", @@ -401,14 +438,17 @@ class GlobalCommon(object): message = "Failed to add your user to the 'docker' group, quitting." Alert(self, message).launch() - return False + return False + else: + # Cancel + return False return True def ensure_docker_service_is_started(self): if not is_docker_ready(self): - message = "Dangerzone requires Docker.

Docker should be installed, but it looks like it's not running in the background.

Click Ok to try starting the docker service. You will have to type your login password." - if Alert(self, message).launch(): + message = "Dangerzone requires Docker

Docker should be installed, but it looks like it's not running in the background.

Click Ok to try starting the docker service. You will have to type your login password." + if Alert(self, message).launch() == QtWidgets.QDialog.Accepted: p = subprocess.run( [ "/usr/bin/pkexec", @@ -440,7 +480,7 @@ class GlobalCommon(object): class Alert(QtWidgets.QDialog): - def __init__(self, common, message): + def __init__(self, common, message, ok_text="Ok", extra_button_text=None): super(Alert, self).__init__() self.common = common @@ -470,22 +510,38 @@ class Alert(QtWidgets.QDialog): message_layout = QtWidgets.QHBoxLayout() message_layout.addWidget(logo) - message_layout.addWidget(label) + message_layout.addSpacing(10) + message_layout.addWidget(label, stretch=1) - ok_button = QtWidgets.QPushButton("Ok") - ok_button.clicked.connect(self.accept) + ok_button = QtWidgets.QPushButton(ok_text) + ok_button.clicked.connect(self.clicked_ok) + if extra_button_text: + extra_button = QtWidgets.QPushButton(extra_button_text) + extra_button.clicked.connect(self.clicked_extra) cancel_button = QtWidgets.QPushButton("Cancel") - cancel_button.clicked.connect(self.reject) + cancel_button.clicked.connect(self.clicked_cancel) buttons_layout = QtWidgets.QHBoxLayout() buttons_layout.addStretch() buttons_layout.addWidget(ok_button) + if extra_button_text: + buttons_layout.addWidget(extra_button) buttons_layout.addWidget(cancel_button) layout = QtWidgets.QVBoxLayout() layout.addLayout(message_layout) + layout.addSpacing(10) layout.addLayout(buttons_layout) self.setLayout(layout) + def clicked_ok(self): + self.done(QtWidgets.QDialog.Accepted) + + def clicked_extra(self): + self.done(2) + + def clicked_cancel(self): + self.done(QtWidgets.QDialog.Rejected) + def launch(self): - return self.exec_() == QtWidgets.QDialog.Accepted + return self.exec_() diff --git a/dangerzone/gui.py b/dangerzone/gui.py new file mode 100644 index 0000000..7aaf6dc --- /dev/null +++ b/dangerzone/gui.py @@ -0,0 +1,140 @@ +from PyQt5 import QtCore, QtWidgets +import os +import sys +import signal +import platform +import click +import time +import uuid +import subprocess + +from .global_common import GlobalCommon +from .main_window import MainWindow +from .docker_installer import ( + is_docker_installed, + is_docker_ready, + launch_docker_windows, + DockerInstaller, +) +from .container import container_runtime + + +class Application(QtWidgets.QApplication): + document_selected = QtCore.pyqtSignal(str) + application_activated = QtCore.pyqtSignal() + + def __init__(self): + QtWidgets.QApplication.__init__(self, sys.argv) + + def event(self, event): + # In macOS, handle the file open event + if event.type() == QtCore.QEvent.FileOpen: + self.document_selected.emit(event.file()) + return True + elif event.type() == QtCore.QEvent.ApplicationActivate: + self.application_activated.emit() + return True + + return QtWidgets.QApplication.event(self, event) + + +@click.command() +@click.option("--custom-container") # Use this container instead of flmcode/dangerzone +@click.argument("filename", required=False) +def gui_main(custom_container, filename): + # Create the Qt app + app = Application() + app.setQuitOnLastWindowClosed(False) + + # GlobalCommon object + global_common = GlobalCommon(app) + + if custom_container: + # Do we have this container? + output = subprocess.check_output( + global_common.get_dangerzone_container_args() + + ["image-ls", custom_container], + startupinfo=global_common.get_subprocess_startupinfo(), + ) + if custom_container.encode() not in output: + click.echo(f"Container '{container}' not found") + return + + global_common.custom_container = custom_container + + # Allow Ctrl-C to smoothly quit the program instead of throwing an exception + signal.signal(signal.SIGINT, signal.SIG_DFL) + + # If we're using Linux and docker, see if we need to add the user to the docker group or if the user prefers typing their password + if platform.system() == "Linux" and container_runtime == "/usr/bin/docker": + if not global_common.ensure_docker_group_preference(): + return + if not global_common.ensure_docker_service_is_started(): + click.echo("Failed to start docker service") + return + + # See if we need to install Docker... + if (platform.system() == "Darwin" or platform.system() == "Windows") and ( + not is_docker_installed(global_common) or not is_docker_ready(global_common) + ): + click.echo("Docker is either not installed or not running") + docker_installer = DockerInstaller(global_common) + docker_installer.start() + return + + closed_windows = {} + windows = {} + + def delete_window(window_id): + closed_windows[window_id] = windows[window_id] + del windows[window_id] + + # Open a document in a window + def select_document(filename=None): + if ( + len(windows) == 1 + and windows[list(windows.keys())[0]].common.document_filename == None + ): + window = windows[list(windows.keys())[0]] + else: + window_id = uuid.uuid4().hex + window = MainWindow(global_common, window_id) + window.delete_window.connect(delete_window) + windows[window_id] = window + + if filename: + # Validate filename + filename = os.path.abspath(os.path.expanduser(filename)) + try: + open(filename, "rb") + except FileNotFoundError: + click.echo("File not found") + return False + except PermissionError: + click.echo("Permission denied") + return False + window.common.document_filename = filename + window.doc_selection_widget.document_selected.emit() + + return True + + # Open a new window if not filename is passed + if filename is None: + select_document() + else: + # If filename is passed as an argument, open it + if not select_document(filename): + return True + + # Open a new window, if all windows are closed + def application_activated(): + if len(windows) == 0: + select_document() + + # If we get a file open event, open it + app.document_selected.connect(select_document) + + # If the application is activated and all windows are closed, open a new one + app.application_activated.connect(application_activated) + + sys.exit(app.exec_()) diff --git a/dangerzone/settings.py b/dangerzone/settings.py index 3e0d0a7..556f63a 100644 --- a/dangerzone/settings.py +++ b/dangerzone/settings.py @@ -20,6 +20,7 @@ class Settings: "open": True, "open_app": default_pdf_viewer, "update_container": True, + "linux_prefers_typing_password": None, } self.load() diff --git a/dangerzone/settings_widget.py b/dangerzone/settings_widget.py index 3e79020..eecdbbc 100644 --- a/dangerzone/settings_widget.py +++ b/dangerzone/settings_widget.py @@ -141,7 +141,12 @@ class SettingsWidget(QtWidgets.QWidget): self.update_checkbox.hide() else: output = subprocess.check_output( - [self.global_common.container_runtime, "image", "ls", self.global_common.get_container_name()], + self.global_common.get_dangerzone_container_args() + + [ + "image-ls", + "--container-name", + self.global_common.get_container_name(), + ], startupinfo=self.global_common.get_subprocess_startupinfo(), ) if b"dangerzone" not in output: diff --git a/dangerzone/tasks.py b/dangerzone/tasks.py index a3379c2..fae33ae 100644 --- a/dangerzone/tasks.py +++ b/dangerzone/tasks.py @@ -16,12 +16,8 @@ class TaskBase(QtCore.QThread): super(TaskBase, self).__init__() def exec_container(self, args): - args = [self.global_common.container_runtime] + args - args_str = " ".join(pipes.quote(s) for s in args) - - print() - print(f"Executing: {args_str}") - output = f"Executing: {args_str}\n\n" + args = self.global_common.get_dangerzone_container_args() + args + output = "" self.update_details.emit(output) with subprocess.Popen( @@ -38,9 +34,12 @@ class TaskBase(QtCore.QThread): print(line, end="") self.update_details.emit(output) - output += p.stderr.read() + stderr = p.stderr.read() + output += stderr + print(stderr) self.update_details.emit(output) + print("") return p.returncode, output @@ -55,7 +54,7 @@ class PullImageTask(TaskBase): "Pulling container image (this might take a few minutes)" ) self.update_details.emit("") - args = ["pull", "flmcode/dangerzone"] + args = ["pull"] returncode, _ = self.exec_container(args) if returncode != 0: @@ -78,15 +77,13 @@ class ConvertToPixels(TaskBase): def run(self): self.update_label.emit("Converting document to pixels") args = [ - "run", - "--network", - "none", - "-v", - f"{self.common.document_filename}:/tmp/input_file", - "-v", - f"{self.common.pixel_dir.name}:/dangerzone", - self.global_common.get_container_name(), "document-to-pixels", + "--document-filename", + self.common.document_filename, + "--pixel-dir", + self.common.pixel_dir.name, + "--container-name", + self.global_common.get_container_name(), ] returncode, output = self.exec_container(args) @@ -173,29 +170,27 @@ class ConvertToPDF(TaskBase): self.update_label.emit("Converting pixels to safe PDF") # Build environment variables list - envs = [] if self.global_common.settings.get("ocr"): - envs += ["-e", "OCR=1"] + ocr = "1" else: - envs += ["-e", "OCR=0"] - envs += [ - "-e", - f"OCR_LANGUAGE={self.global_common.ocr_languages[self.global_common.settings.get('ocr_language')]}", + ocr = "0" + ocr_lang = self.global_common.ocr_languages[ + self.global_common.settings.get("ocr_language") ] - args = ( - [ - "run", - "--network", - "none", - "-v", - f"{self.common.pixel_dir.name}:/dangerzone", - "-v", - f"{self.common.safe_dir.name}:/safezone", - ] - + envs - + [self.global_common.get_container_name(), "pixels-to-pdf",] - ) + args = [ + "pixels-to-pdf", + "--pixel-dir", + self.common.pixel_dir.name, + "--safe-dir", + self.common.safe_dir.name, + "--container-name", + self.global_common.get_container_name(), + "--ocr", + ocr, + "--ocr-lang", + ocr_lang, + ] returncode, output = self.exec_container(args) if returncode != 0: diff --git a/dev_scripts/dangerzone b/dev_scripts/dangerzone index fe8511e..f1f3613 100755 --- a/dev_scripts/dangerzone +++ b/dev_scripts/dangerzone @@ -3,8 +3,10 @@ # Load dangerzone module and resources from the source code tree import os, sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.dangerzone_dev = True import dangerzone -dangerzone.main() \ No newline at end of file + +dangerzone.main() diff --git a/dev_scripts/dangerzone-container b/dev_scripts/dangerzone-container new file mode 120000 index 0000000..2fe47df --- /dev/null +++ b/dev_scripts/dangerzone-container @@ -0,0 +1 @@ +dangerzone \ No newline at end of file diff --git a/install/linux/media.firstlook.dangerzone-container.policy b/install/linux/media.firstlook.dangerzone-container.policy new file mode 100644 index 0000000..4e0a847 --- /dev/null +++ b/install/linux/media.firstlook.dangerzone-container.policy @@ -0,0 +1,16 @@ + + + + + Run Dangerzone Container + Dangerzone needs you to authenticate to run containers + + auth_admin_keep + auth_admin_keep + auth_admin_keep + + /usr/bin/dangerzone-container + + diff --git a/setup.py b/setup.py index 3fe975d..21a4741 100644 --- a/setup.py +++ b/setup.py @@ -29,11 +29,20 @@ setuptools.setup( ["install/linux/media.firstlook.dangerzone.png"], ), ("share/dangerzone", file_list("share")), + ( + "share/polkit-1/actions", + ["install/linux/media.firstlook.dangerzone-container.policy"], + ), ], classifiers=[ "Programming Language :: Python", "Intended Audience :: End Users/Desktop", "Operating System :: OS Independent", ], - entry_points={"console_scripts": ["dangerzone = dangerzone:main"]}, + entry_points={ + "console_scripts": [ + "dangerzone = dangerzone:main", + "dangerzone-container = dangerzone:container_main", + ] + }, )