diff --git a/debian/control b/debian/control index 03ecfff7c..0665922b2 100644 --- a/debian/control +++ b/debian/control @@ -13,10 +13,10 @@ Depends: ${shlibs:Depends}, ${misc:Depends}, python3, python3-pyqt5, python3-pyq Description: securedrop client for qubes workstation Package: securedrop-export -Architecture: all +Architecture: amd64 Depends: ${misc:Depends}, python3, udisks2, cups, cups-ipp-utils, printer-driver-brlaser, printer-driver-hpcups, - avahi-daemon, system-config-printer, xpp, libcups2, gnome-disk-utility, libreoffice, - desktop-file-utils, shared-mime-info, libfile-mimeinfo-perl + 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 diff --git a/export/build-requirements.txt b/export/build-requirements.txt index 393535f6a..6dd8c5d0e 100644 --- a/export/build-requirements.txt +++ b/export/build-requirements.txt @@ -1,2 +1,4 @@ pexpect==4.9.0 --hash=sha256:5760fc48f9eb64fabd910e0b8f16b4c831e3ede32a3059ef7252e89a38950646 ptyprocess==0.7.0 --hash=sha256:320c49e0aea7441a2e2a47bfc655442f1c4e9d27dc8cf2b905832934af942761 +pycairo==1.27.0 --hash=sha256:43b8f610ef329dfbc0a9431cc174044c52b78c6e02a5d2842c67edba1c10a51c +pygobject==3.50.0 --hash=sha256:cb02f8b467eab914788fee782a66e79e7d3599821e3279298bb2a27089288138 diff --git a/export/poetry.lock b/export/poetry.lock index e3ca53171..415e06468 100644 --- a/export/poetry.lock +++ b/export/poetry.lock @@ -1,9 +1,10 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry and should not be changed by hand. [[package]] name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -22,6 +23,7 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte name = "boltons" version = "21.0.0" description = "When they're not builtins, they're boltons." +category = "dev" optional = false python-versions = "*" files = [ @@ -33,6 +35,7 @@ files = [ name = "bracex" version = "2.4" description = "Bash style brace expander." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -44,6 +47,7 @@ files = [ name = "certifi" version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -55,6 +59,7 @@ files = [ name = "charset-normalizer" version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" optional = false python-versions = ">=3.7.0" files = [ @@ -154,6 +159,7 @@ files = [ name = "click" version = "8.1.7" description = "Composable command line interface toolkit" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -168,6 +174,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "click-option-group" version = "0.5.6" description = "Option groups missing in Click" +category = "dev" optional = false python-versions = ">=3.6,<4" files = [ @@ -187,6 +194,7 @@ tests-cov = ["coverage", "coveralls", "pytest", "pytest-cov"] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -198,6 +206,7 @@ files = [ name = "coverage" version = "7.6.4" description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=3.9" files = [ @@ -272,6 +281,7 @@ toml = ["tomli"] name = "defusedxml" version = "0.7.1" description = "XML bomb protection for Python stdlib modules" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -283,6 +293,7 @@ files = [ name = "deprecated" version = "1.2.14" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -300,6 +311,7 @@ dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] name = "exceptiongroup" version = "1.2.1" description = "Backport of PEP 654 (exception groups)" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -314,6 +326,7 @@ test = ["pytest (>=6)"] name = "face" version = "22.0.0" description = "A command-line application framework (and CLI parser). Friendly for users, full-featured for developers." +category = "dev" optional = false python-versions = "*" files = [ @@ -328,6 +341,7 @@ boltons = ">=20.0.0" name = "glom" version = "22.1.0" description = "A declarative object transformer and formatter, for conglomerating nested data." +category = "dev" optional = false python-versions = "*" files = [ @@ -347,6 +361,7 @@ yaml = ["PyYAML"] name = "googleapis-common-protos" version = "1.65.0" description = "Common protobufs used in Google APIs" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -364,6 +379,7 @@ grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -375,6 +391,7 @@ files = [ name = "importlib-metadata" version = "7.1.0" description = "Read metadata from Python packages" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -394,6 +411,7 @@ testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -405,6 +423,7 @@ files = [ name = "jsonschema" version = "4.19.2" description = "An implementation of JSON Schema validation for Python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -426,6 +445,7 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- name = "jsonschema-specifications" version = "2023.7.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -440,6 +460,7 @@ referencing = ">=0.28.0" name = "markdown-it-py" version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -464,6 +485,7 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -475,6 +497,7 @@ files = [ name = "mypy" version = "1.13.0" description = "Optional static typing for Python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -527,6 +550,7 @@ reports = ["lxml"] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -538,6 +562,7 @@ files = [ name = "opentelemetry-api" version = "1.25.0" description = "OpenTelemetry Python API" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -553,6 +578,7 @@ importlib-metadata = ">=6.0,<=7.1" name = "opentelemetry-exporter-otlp-proto-common" version = "1.25.0" description = "OpenTelemetry Protobuf encoding" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -567,6 +593,7 @@ opentelemetry-proto = "1.25.0" name = "opentelemetry-exporter-otlp-proto-http" version = "1.25.0" description = "OpenTelemetry Collector Protobuf over HTTP Exporter" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -587,6 +614,7 @@ requests = ">=2.7,<3.0" name = "opentelemetry-instrumentation" version = "0.46b0" description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -603,6 +631,7 @@ wrapt = ">=1.0.0,<2.0.0" name = "opentelemetry-instrumentation-requests" version = "0.46b0" description = "OpenTelemetry requests instrumentation" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -623,6 +652,7 @@ instruments = ["requests (>=2.0,<3.0)"] name = "opentelemetry-proto" version = "1.25.0" description = "OpenTelemetry Python Proto" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -637,6 +667,7 @@ protobuf = ">=3.19,<5.0" name = "opentelemetry-sdk" version = "1.25.0" description = "OpenTelemetry Python SDK" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -653,6 +684,7 @@ typing-extensions = ">=3.7.4" name = "opentelemetry-semantic-conventions" version = "0.46b0" description = "OpenTelemetry Semantic Conventions" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -667,6 +699,7 @@ opentelemetry-api = "1.25.0" name = "opentelemetry-util-http" version = "0.46b0" description = "Web util for OpenTelemetry" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -678,6 +711,7 @@ files = [ name = "packaging" version = "23.2" description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -689,6 +723,7 @@ files = [ name = "peewee" version = "3.17.0" description = "a little orm" +category = "dev" optional = false python-versions = "*" files = [ @@ -699,6 +734,7 @@ files = [ name = "pexpect" version = "4.9.0" description = "Pexpect allows easy control of interactive console applications." +category = "main" optional = false python-versions = "*" files = [ @@ -713,6 +749,7 @@ ptyprocess = ">=0.5" name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -728,6 +765,7 @@ testing = ["pytest", "pytest-benchmark"] name = "protobuf" version = "4.25.5" description = "" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -748,6 +786,7 @@ files = [ name = "ptyprocess" version = "0.7.0" description = "Run a subprocess in a pseudo terminal" +category = "main" optional = false python-versions = "*" files = [ @@ -755,10 +794,32 @@ files = [ {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, ] +[[package]] +name = "pycairo" +version = "1.27.0" +description = "Python interface for cairo" +category = "main" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pycairo-1.27.0-cp310-cp310-win32.whl", hash = "sha256:e20f431244634cf244ab6b4c3a2e540e65746eed1324573cf291981c3e65fc05"}, + {file = "pycairo-1.27.0-cp310-cp310-win_amd64.whl", hash = "sha256:03bf570e3919901572987bc69237b648fe0de242439980be3e606b396e3318c9"}, + {file = "pycairo-1.27.0-cp311-cp311-win32.whl", hash = "sha256:9a9b79f92a434dae65c34c830bb9abdbd92654195e73d52663cbe45af1ad14b2"}, + {file = "pycairo-1.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:d40a6d80b15dacb3672dc454df4bc4ab3988c6b3f36353b24a255dc59a1c8aea"}, + {file = "pycairo-1.27.0-cp312-cp312-win32.whl", hash = "sha256:e2239b9bb6c05edae5f3be97128e85147a155465e644f4d98ea0ceac7afc04ee"}, + {file = "pycairo-1.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:27cb4d3a80e3b9990af552818515a8e466e0317063a6e61585533f1a86f1b7d5"}, + {file = "pycairo-1.27.0-cp313-cp313-win32.whl", hash = "sha256:01505c138a313df2469f812405963532fc2511fb9bca9bdc8e0ab94c55d1ced8"}, + {file = "pycairo-1.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:b0349d744c068b6644ae23da6ada111c8a8a7e323b56cbce3707cba5bdb474cc"}, + {file = "pycairo-1.27.0-cp39-cp39-win32.whl", hash = "sha256:f9ca8430751f1fdcd3f072377560c9e15608b9a42d61375469db853566993c9b"}, + {file = "pycairo-1.27.0-cp39-cp39-win_amd64.whl", hash = "sha256:1b1321652a6e27c4de3069709b1cae22aed2707fd8c5e889c04a95669228af2a"}, + {file = "pycairo-1.27.0.tar.gz", hash = "sha256:5cb21e7a00a2afcafea7f14390235be33497a2cce53a98a19389492a60628430"}, +] + [[package]] name = "pygments" version = "2.16.1" description = "Pygments is a syntax highlighting package written in Python." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -769,10 +830,25 @@ files = [ [package.extras] plugins = ["importlib-metadata"] +[[package]] +name = "pygobject" +version = "3.50.0" +description = "Python bindings for GObject Introspection" +category = "main" +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "pygobject-3.50.0.tar.gz", hash = "sha256:4500ad3dbf331773d8dedf7212544c999a76fc96b63a91b3dcac1e5925a1d103"}, +] + +[package.dependencies] +pycairo = ">=1.16" + [[package]] name = "pytest" version = "8.3.3" description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -793,6 +869,7 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments name = "pytest-cov" version = "6.0.0" description = "Pytest plugin for measuring coverage." +category = "dev" optional = false python-versions = ">=3.9" files = [ @@ -811,6 +888,7 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] name = "pytest-mock" version = "3.14.0" description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -828,6 +906,7 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] name = "referencing" version = "0.30.2" description = "JSON Referencing + Python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -843,6 +922,7 @@ rpds-py = ">=0.7.0" name = "requests" version = "2.31.0" description = "Python HTTP for Humans." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -864,6 +944,7 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "rich" version = "13.5.3" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "dev" optional = false python-versions = ">=3.7.0" files = [ @@ -882,6 +963,7 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] name = "rpds-py" version = "0.12.0" description = "Python bindings to Rust's persistent data structures (rpds)" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -990,6 +1072,7 @@ files = [ name = "ruamel-yaml" version = "0.17.40" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +category = "dev" optional = false python-versions = ">=3" files = [ @@ -1008,6 +1091,7 @@ jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] name = "ruamel-yaml-clib" version = "0.2.8" description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1067,6 +1151,7 @@ files = [ name = "semgrep" version = "1.95.0" description = "Lightweight static analysis for many languages. Find bug variants with patterns that look like source code." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1105,6 +1190,7 @@ wcmatch = ">=8.3,<9.0" name = "setuptools" version = "75.3.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1119,12 +1205,13 @@ cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (>=1.12.0,<1.13.0)", "pytest-mypy"] [[package]] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1136,6 +1223,7 @@ files = [ name = "types-pexpect" version = "4.9.0.20240806" description = "Typing stubs for pexpect" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1147,6 +1235,7 @@ files = [ name = "types-setuptools" version = "75.2.0.20241025" description = "Typing stubs for setuptools" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1158,6 +1247,7 @@ files = [ name = "typing-extensions" version = "4.8.0" description = "Backported and Experimental Type Hints for Python 3.8+" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1169,6 +1259,7 @@ files = [ name = "urllib3" version = "2.2.1" description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1186,6 +1277,7 @@ zstd = ["zstandard (>=0.18.0)"] name = "wcmatch" version = "8.5" description = "Wildcard/glob file name matcher." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1200,6 +1292,7 @@ bracex = ">=2.1.1" name = "wrapt" version = "1.16.0" description = "Module for decorators, wrappers and monkey patching." +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1279,6 +1372,7 @@ files = [ name = "zipp" version = "3.20.2" description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1297,4 +1391,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "9f7314221516e99a4c6e35e2711e8e6367835291d2eeb520e4c141b3aa722c85" +content-hash = "02fee64e15a2c1af8b7f2e8ba930176bd234072d4833116d321348e1c043c295" diff --git a/export/pyproject.toml b/export/pyproject.toml index 92c3f6e53..9671f22f2 100644 --- a/export/pyproject.toml +++ b/export/pyproject.toml @@ -9,6 +9,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.11" pexpect = "^4.9.0" +pygobject = "^3.50.0" [tool.poetry.group.dev.dependencies] mypy = "^1.13.0" diff --git a/export/securedrop_export/print/print_dialog.py b/export/securedrop_export/print/print_dialog.py new file mode 100644 index 000000000..2a8defce5 --- /dev/null +++ b/export/securedrop_export/print/print_dialog.py @@ -0,0 +1,42 @@ +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, Gio + +import sys + + +def open_print_dialog(): + app = PrintDialog() + app.run() + + +class PrintDialog(Gtk.Application): + def __init__(self): + super().__init__(application_id="org.securedrop.PrintDialog") + self.connect("activate", self.on_activate) + + def on_activate(self, app): + window = Gtk.Window(application=app) + self.dialog = Gtk.PrintUnixDialog.new("Print Document", window) + self.dialog.connect("response", self.on_response) + self.dialog.show() + window.hide() + + + def on_response(self, parent_widget, response_id): + if response_id == Gtk.ResponseType.OK: + print(f"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("/home/user/dangerzone/tests/test_docs/sample-pdf.pdf") + job.send(self.on_job_complete, user_data=None) + elif response_id == Gtk.ResponseType.APPLY: # Preview (if available) + pass + elif response_id == Gtk.ResponseType.CANCEL: + pass + + def on_job_complete(self, print_job, user_data, error): + print(f"ERROR {error}") diff --git a/export/securedrop_export/print/service.py b/export/securedrop_export/print/service.py index bab59e623..07cfc2ac6 100644 --- a/export/securedrop_export/print/service.py +++ b/export/securedrop_export/print/service.py @@ -7,6 +7,7 @@ from securedrop_export.directory import safe_mkdir from securedrop_export.exceptions import ExportException, TimeoutException, handler +from securedrop_export.print.print_dialog import open_print_dialog from .status import Status @@ -421,12 +422,9 @@ def _print_file(self, file_to_print: Path): 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], - ) + open_print_dialog() except subprocess.CalledProcessError as e: raise ExportException(sdstatus=Status.ERROR_PRINT, sderror=e.output) diff --git a/pyproject.toml b/pyproject.toml index 73e2f6f0d..9078f8cee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ license = "AGPLv3+" [tool.poetry.dependencies] python = "^3.11" +pygobject = "^3.50.0" [tool.poetry.group.dev.dependencies] ruff = "^0.6.4"