Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial support for IPP / driverless printing #2332

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
7 changes: 4 additions & 3 deletions debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ Depends: ${shlibs:Depends}, ${misc:Depends}, python3, python3-pyqt5, python3-pyq
Description: securedrop client for qubes workstation

Package: securedrop-export
Architecture: all
Depends: ${misc:Depends}, python3, udisks2, cups, printer-driver-brlaser, printer-driver-hpcups, system-config-printer, xpp, libcups2, gnome-disk-utility, libreoffice,
desktop-file-utils, shared-mime-info, libfile-mimeinfo-perl
Architecture: amd64
Depends: ${misc:Depends}, python3, udisks2, cups, cups-ipp-utils, printer-driver-brlaser, printer-driver-hpcups,
avahi-daemon, system-config-printer, libcups2, gnome-disk-utility, libreoffice,
desktop-file-utils, shared-mime-info, libfile-mimeinfo-perl, gir1.2-gtk-4.0
Description: Submission export scripts for SecureDrop Workstation
This package provides scripts used by the SecureDrop Qubes Workstation to
export submissions from the client to external storage, via the sd-export
Expand Down
1 change: 1 addition & 0 deletions debian/securedrop-export.install
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export/files/application-x-sd-export.xml usr/share/mime/packages
export/files/send-to-usb.desktop usr/share/applications
export/files/sd-logo.png usr/share/securedrop/icons
export/files/tcrypt.conf etc/udisks2
export/files/60-securedrop-export.preset usr/lib/systemd/system-preset
2 changes: 1 addition & 1 deletion debian/setup-venv.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
set -euxo pipefail

NAME=$1
if [[ $NAME == "client" ]]; then
if [[ $NAME == "client" || $NAME == "export" ]]; then
VENV_ARGS="--system-site-packages"
else
VENV_ARGS=""
Expand Down
4 changes: 4 additions & 0 deletions export/files/60-securedrop-export.preset
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# avahi-daemon so that driverless printers are detected when
# plugged in via USB, overriding 75-qubes-vm.preset

enable avahi-daemon.service
37 changes: 35 additions & 2 deletions export/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions export/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ python = "^3.11"
pexpect = "^4.9.0"

[tool.poetry.group.dev.dependencies]
# In production these two are installed using a system package
# so match those versions exactly
pygobject = [
{version = "=3.42.2", python = ">=3.11"}, # bookworm
]
pycairo = [
{version = "=1.20.1", python = ">=3.11"}, # bookworm
]

mypy = "^1.13.0"
types-setuptools = "^75.2.0"
pytest = "^8.3.3"
Expand Down
9 changes: 9 additions & 0 deletions export/securedrop_export/print/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from securedrop_export.exceptions import ExportException

from .status import Status


class PrinterNotFoundException(ExportException):
def __init__(self, sderror=None):
super().__init__()
self.sdstatus = Status.ERROR_PRINTER_NOT_FOUND
98 changes: 98 additions & 0 deletions export/securedrop_export/print/print_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import gi

gi.require_version("Gtk", "4.0")
import logging

from gi.repository import Gtk

from securedrop_export.exceptions import ExportException
from securedrop_export.print.status import Status

logger = logging.getLogger(__name__)


class GtkExceptionRaiser:
"""
Context manager to keep track of exceptions to be raised after GTK exits

This is a workaround for the fact that GTK does not behave like regular
libraries. Exceptions raised by the GUI code are always caught within GTK.
The context manager provides a way to store these exceptions.

Usage:

class SomeApplication(Gtk.Application):
def __init__(self, raise_later_func):
super().__init__()
self.raise_later_func = raise_later_func

[...]

def on_something_bad_happening(self):
self.raise_later_func(Exception("something happned"))
self.quit()

with GtkExceptionRaiser() as raise_later_func:
app = SomeApplication(raise_later_func)
app.run()
"""

def __init__(self):
self.exception_to_raise = None

def raise_later_func(self, exception):
self.exception_to_raise = exception

def __enter__(self):
return self.raise_later_func

def __exit__(self, exc_type, exc_val, exc_tb):
if self.exception_to_raise:
raise self.exception_to_raise


class PrintDialog(Gtk.Application):
def __init__(self, file_to_print, raise_later_func):
super().__init__(application_id="org.securedrop.PrintDialog")
self.file_to_print = file_to_print
self.raise_later_func = raise_later_func
self.connect("activate", self.on_activate)

def on_activate(self, app):
window = Gtk.Window(application=app)
window.hide()
self.dialog = Gtk.PrintUnixDialog.new("Print Document", window)
self.dialog.connect("response", self.on_response)
self.dialog.connect("close", self.quit)
self.dialog.show()

def on_response(self, parent_widget, response_id):
if response_id == Gtk.ResponseType.OK:
self.dialog.hide()
settings = self.dialog.get_settings()
printer = self.dialog.get_selected_printer()
page_setup = self.dialog.get_page_setup()
job = Gtk.PrintJob.new("print job", printer, settings, page_setup)
job.set_source_file(self.file_to_print)
job.send(self.on_job_complete, user_data=None)
elif response_id == Gtk.ResponseType.CANCEL:
# FIXME should this exist or should it simply cancel and not report errors
self.raise_later_func(
ExportException(sdstatus=Status.ERROR_PRINT, sderror="User canceled dialog")
)
self.quit()
elif response_id == Gtk.ResponseType.DELETE_EVENT:
self.quit()

def on_job_complete(self, print_job, user_data, error):
if error:
self.raise_later_func(
ExportException(sdstatus=Status.ERROR_PRINT, sderror=error.message)
)
self.quit()


def open_print_dialog(file_to_print):
with GtkExceptionRaiser() as raise_later_func:
app = PrintDialog(file_to_print, raise_later_func)
app.run()
125 changes: 64 additions & 61 deletions export/securedrop_export/print/service.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import logging
import os
import signal
import subprocess
import time
from pathlib import Path

from securedrop_export.directory import safe_mkdir
from securedrop_export.exceptions import ExportException, TimeoutException, handler
from securedrop_export.exceptions import ExportException

from .exceptions import PrinterNotFoundException
from .print_dialog import open_print_dialog
from .status import Status

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -113,62 +113,73 @@ def printer_test(self) -> Status:
# a success status here
return Status.PRINT_TEST_PAGE_SUCCESS

def _wait_for_print(self):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed the _wait_for_print() method because the print dialog can already obtains some eventual errors occurred during the print job. So my implementation simply exits the dialog either with some error or success.

Proposed Troubleshooting Workflow

But not all failures were marked as errors by the print dialog. For example, I tried to print with an open tray and both dialogs were closed (PRINT_SUCCESS), but the printer errored out. Luckily, there is some information on the top right-hand corner of the screen:

Screenshot_2025-01-17_12-59-10

If I wanted to troubleshoot, I could click on the little "printer" icon in the tray menu and it would open the queued print documents and some actions to troubleshoot:

Screenshot_2025-01-17_12-59-31

Instead of waiting for the document to be printed with a spinning icon in the client dialog, I'd propose that we close all dialogs if successfully sent the job to the printer tools. What this means .

I guess ultimately this a UX question. Should I re-implement a way to have the client print dialog hanging / waiting while a document is fully printed, or should all print-related dialogs close as soon as the print is initiated and we let the users troubleshoot via messages in the right-hand corner and the respective print queue utility (accessed from the tray menu)?

"""
Use lpstat to ensure the job was fully transfered to the printer
Return True if print was successful, otherwise throw ExportException.
Currently, the handler `handler` is defined in `exceptions.py`.
"""
signal.signal(signal.SIGALRM, handler)
signal.alarm(self.printer_wait_timeout)
printer_idle_string = f"printer {self.printer_name} is idle"
while True:
try:
logger.info(f"Running lpstat waiting for printer {self.printer_name}")
output = subprocess.check_output(["lpstat", "-p", self.printer_name])
if printer_idle_string in output.decode("utf-8"):
logger.info("Print completed")
return True
else:
time.sleep(5)
except subprocess.CalledProcessError:
raise ExportException(sdstatus=Status.ERROR_PRINT)
except TimeoutException:
logger.error(f"Timeout waiting for printer {self.printer_name}")
raise ExportException(sdstatus=Status.ERROR_PRINT)
return True

def _check_printer_setup(self) -> None:
"""
Check printer setup.
Raise ExportException if supported setup is not found.
"""
legacy_printers = False
logger.info("Searching for printer")

try:
logger.info("Searching for printer")
output = subprocess.check_output(["sudo", "lpinfo", "-v"])
printers = [x for x in output.decode("utf-8").split() if "usb://" in x]
if not printers:
logger.info("No usb printers connected")
raise ExportException(sdstatus=Status.ERROR_PRINTER_NOT_FOUND)
printers = self._get_printers_ipp()
except PrinterNotFoundException:
printers = self._get_printers_legacy()
legacy_printers = True

supported_printers = [
p for p in printers if any(sub in p for sub in self.SUPPORTED_PRINTERS)
]
if not supported_printers:
logger.info(f"{printers} are unsupported printers")
raise ExportException(sdstatus=Status.ERROR_PRINTER_NOT_SUPPORTED)
if not printers:
logger.info("No supported printers connected")
raise ExportException(sdstatus=Status.ERROR_PRINTER_NOT_FOUND)

if len(printers) > 1:
logger.info("Too many printers connected")
raise ExportException(sdstatus=Status.ERROR_MULTIPLE_PRINTERS_FOUND)

if len(supported_printers) > 1:
logger.info("Too many usb printers connected")
raise ExportException(sdstatus=Status.ERROR_MULTIPLE_PRINTERS_FOUND)
printer_uri = printers[0]
if legacy_printers: # IPP printers are auto-detected by the print dialog
self._setup_printer(printer_uri)

printer_uri = printers[0]
printer_ppd = self._install_printer_ppd(printer_uri)
self._setup_printer(printer_uri, printer_ppd)
def _get_printers_legacy(self) -> list[str]:
logger.info("Searching for legacy printers")
try:
output = subprocess.check_output(["sudo", "lpinfo", "-v"])
except subprocess.CalledProcessError as e:
logger.error(e)
raise ExportException(sdstatus=Status.ERROR_UNKNOWN)

discovered_printers = [x for x in output.decode("utf-8").split() if "usb://" in x]
supported_printers = [
p for p in discovered_printers if any(sub in p for sub in self.SUPPORTED_PRINTERS)
]
unsupported_printers = [p for p in discovered_printers if p not in supported_printers]

if not supported_printers:
if unsupported_printers:
logger.info(f"{', '.join(unsupported_printers)} are unsupported printers")
raise ExportException(sdstatus=Status.ERROR_PRINTER_NOT_SUPPORTED)
else:
logger.info("No legacy (PDP) printers were found")
raise PrinterNotFoundException()

return supported_printers

def _get_printers_ipp(self) -> list[str]:
logger.info("Searching for IPP printers (driverless)")
try:
discovered_printers = subprocess.check_output(
["ippfind"], universal_newlines=True
).split()
logger.debug(f"Found IPP printers: {', '.join(discovered_printers)}")
except subprocess.CalledProcessError as ex:
if ex.returncode == 1: # Did not find any match
logger.debug("No IPP printers were found")
raise PrinterNotFoundException()
else:
logger.error("'ippfind' command failed")
raise ExportException(sdstatus=Status.ERROR_PRINTER_URI)

return discovered_printers

def _get_printer_uri(self) -> str:
"""
Get the URI via lpinfo. Only accept URIs of supported printers.
Expand Down Expand Up @@ -202,6 +213,9 @@ def _get_printer_uri(self) -> str:
return printer_uri

def _install_printer_ppd(self, uri):
"""
Discovery and installation of PPD driver (for legacy printers)
"""
if not any(x in uri for x in self.SUPPORTED_PRINTERS):
logger.error(f"Cannot install printer ppd for unsupported printer: {uri}")
raise ExportException(sdstatus=Status.ERROR_PRINTER_NOT_SUPPORTED)
Expand Down Expand Up @@ -230,8 +244,9 @@ def _install_printer_ppd(self, uri):

return printer_ppd

def _setup_printer(self, printer_uri, printer_ppd):
def _setup_printer(self, printer_uri):
# Add the printer using lpadmin
printer_ppd = self._install_printer_ppd(printer_uri)
logger.info(f"Setting up printer {self.printer_name}")
self.check_output_and_stderr(
command=[
Expand Down Expand Up @@ -390,20 +405,8 @@ def _print_file(self, file_to_print: Path):
logger.error(f"Something went wrong: {file_to_print} not found")
raise ExportException(sdstatus=Status.ERROR_PRINT)

logger.info(f"Sending file to printer {self.printer_name}")
try:
# We can switch to using libreoffice --pt $printer_cups_name
# here, and either print directly (headless) or use the GUI
subprocess.check_call(
["xpp", "-P", self.printer_name, file_to_print],
)
except subprocess.CalledProcessError as e:
raise ExportException(sdstatus=Status.ERROR_PRINT, sderror=e.output)

# This is an addition to ensure that the entire print job is transferred over.
# If the job is not fully transferred within the timeout window, the user
# will see an error message.
self._wait_for_print()
logger.info("Opening print dialog")
open_print_dialog(str(file_to_print))

def check_output_and_stderr(
self, command: str, error_status: Status, ignore_stderr_startswith=None
Expand Down
Loading