diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4f122101b..bb1d9ee10 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,9 +58,28 @@ jobs:
registry_token: ${{ secrets.GITHUB_TOKEN }}
windows:
- runs-on: windows-latest
+ needs:
+ - build-container-image
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: windows-2022
+ - runner: windows-2025
env:
- DUMMY_CONVERSION: 1
+ # NOTE: We have to set the encoding for this run to UTF-8, else we get an
+ # enoding error when Dangerzone attempts to display its banner, since the
+ # default seems to be CP-1252:
+ #
+ # File "D:\a\dangerzone\dangerzone\dangerzone\cli.py", line 225, in display_banner
+ # print(Back.BLACK + Fore.YELLOW + Style.DIM + "\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e")
+ # ~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ # File "C:\hostedtoolcache\windows\Python\3.13.7\x64\Lib\encodings\cp1252.py", line 19, in encode
+ # return codecs.charmap_encode(input,self.errors,encoding_table)[0]
+ # ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ # UnicodeEncodeError: 'charmap' codec can't encode characters in position 14-41: character maps to
+ PYTHONIOENCODING: UTF-8
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
@@ -75,12 +94,25 @@ jobs:
path: |
share/tessdata/
share/vendor/
+ share/machine.tar
key: v1-mazette-windows-${{ hashFiles('./mazette.lock') }}
- name: Install mazette assets
if: steps.cache-mazette.outputs.cache-hit != 'true'
run: poetry run mazette install
- name: Check cosign is present
run: ls share/vendor
+ - name: Restore container image
+ uses: actions/cache/restore@v4
+ with:
+ path: |-
+ share/container.tar
+ share/freedomofpress-dangerzone.pub
+ share/image-name.txt
+ enableCrossOsArchive: true
+ fail-on-cache-miss: true
+ key: v6-container-${{ needs.build-container-image.outputs.image_uri }}
+ - name: Smoke test
+ run: poetry run .\dev_scripts\dangerzone-cli.bat .\tests\test_docs\sample-pdf.pdf --ocr-lang eng --debug
- name: Run CLI tests
run: poetry run make test
- name: Set up .NET CLI environment
@@ -95,6 +127,7 @@ jobs:
# NOTE: This also builds the .exe internally.
run: poetry run .\install\windows\build-app.bat
- name: Upload MSI installer
+ if: matrix.runner == 'windows-2025'
uses: actions/upload-artifact@v4
with:
name: Dangerzone.msi
@@ -104,16 +137,18 @@ jobs:
macOS:
name: "macOS (${{ matrix.arch }})"
+ needs:
+ - build-container-image
runs-on: ${{ matrix.runner }}
strategy:
+ fail-fast: false
matrix:
include:
- - runner: macos-latest # CPU type: Apple Silicon (M1)
- arch: arch64
+ # See https://github.com/abiosoft/colima/issues/970
+ - runner: macos-15
+ arch: arm64
- runner: macos-13 # CPU type: Intel x86_64
arch: x86_64
- env:
- DUMMY_CONVERSION: 1
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
@@ -128,12 +163,34 @@ jobs:
path: |
share/tessdata/
share/vendor/
+ share/machine.tar
key: v1-mazette-darwin-${{ matrix.arch }}-${{ hashFiles('./mazette.lock') }}
- name: Install mazette assets
if: steps.cache-mazette.outputs.cache-hit != 'true'
run: poetry run mazette install
+ - name: Check cosign is present
+ run: ls share/vendor
+ - name: Restore container image
+ uses: actions/cache/restore@v4
+ with:
+ path: |-
+ share/container.tar
+ share/freedomofpress-dangerzone.pub
+ share/image-name.txt
+ enableCrossOsArchive: true
+ fail-on-cache-miss: true
+ key: v6-container-${{ needs.build-container-image.outputs.image_uri }}
+ - name: Smoke test
+ # Nested virtualization does not work on M1 CPUs.
+ continue-on-error: ${{ matrix.arch == 'arm64'}}
+ run: poetry run ./dev_scripts/dangerzone-cli ./tests/test_docs/sample-pdf.pdf --ocr-lang eng --debug
- name: Run CLI tests
- run: poetry run make test
+ run: |
+ # Nested virtualization does not work on M1 CPUs.
+ if [ ${{ matrix.arch }} == 'arm64' ]; then
+ export DUMMY_CONVERSION=1
+ fi
+ poetry run make test
- name: Build macOS app
run: poetry run python ./install/macos/build-app.py
- name: Upload macOS app
@@ -271,7 +328,7 @@ jobs:
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} \
--version ${{ matrix.version }} \
- run dangerzone-cli dangerzone/tests/test_docs/sample-pdf.pdf --ocr-lang eng
+ run dangerzone-cli dangerzone/tests/test_docs/sample-pdf.pdf --ocr-lang eng --debug
- name: Check that the Dangerzone GUI imports work
run: |
@@ -365,7 +422,7 @@ jobs:
- name: Run a test command
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} --version ${{ matrix.version }} \
- run dangerzone-cli dangerzone/tests/test_docs/sample-pdf.pdf --ocr-lang eng
+ run dangerzone-cli dangerzone/tests/test_docs/sample-pdf.pdf --ocr-lang eng --debug
- name: Check that the Dangerzone GUI imports work
run: |
diff --git a/.gitignore b/.gitignore
index 796278c59..746bc2094 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@ pip-wheel-metadata/
share/python-wheels/
share/tessdata/
share/vendor/
+share/machine.tar
*.egg-info/
.installed.cfg
*.egg
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e90ccada7..9cf6b884c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,21 @@ since 0.4.1, and this project adheres to [Semantic Versioning](https://semver.or
- Sign the sandbox/container images and automatically upgrade them to their latest version
([#1006](https://github.com/freedomofpress/dangerzone/issues/1006)).
Read more about this feature [in our docs](https://github.com/freedomofpress/dangerzone/blob/main/docs/independent-container-updates.md).
+- Make Dangerzone use an embedded version of Podman under the hood
+ ([#1145](https://github.com/freedomofpress/dangerzone/issues/1145))
+- Bundle Podman images for Windows and macOS alongside our application
+ ([#1170](https://github.com/freedomofpress/dangerzone/issues/1170))
+- Introduce a new CLI helper called `dangerzone-machine` to manage the Podman
+ machine the Dangerzone uses under the hood
+ ([#1172](https://github.com/freedomofpress/dangerzone/issues/1172))
+- Capture all the command outputs in the logs ([#1236](https://github.com/freedomofpress/dangerzone/issues/1172))
+
+### Removed
+
+- Docker Desktop is no longer required to run Dangerzone. In fact, they are no
+ longer compatible, due to some changes in the bundled container image.
+ Instead, Podman Desktop is used under the hood
+ ([#118](https://github.com/freedomofpress/dangerzone/issues/118))
### Fixed
@@ -30,6 +45,8 @@ since 0.4.1, and this project adheres to [Semantic Versioning](https://semver.or
- Improve our release instructions by splitting the large `RELEASE.md` file
into distinct docs, whose instructions can be executed sequentially
([#1212](https://github.com/freedomofpress/dangerzone/pull/1212))
+- Run our full CI test suite on Windows and macOS GitHub runners
+ ([#1009](https://github.com/freedomofpress/dangerzone/issues/1009))
### Removed
diff --git a/Makefile b/Makefile
index e4ae23094..109200518 100644
--- a/Makefile
+++ b/Makefile
@@ -1,20 +1,12 @@
LARGE_TEST_REPO_DIR:=tests/test_docs_large
GIT_DESC=$$(git describe)
JUNIT_FLAGS := --capture=sys -o junit_logging=all
-MYPY_ARGS := --ignore-missing-imports \
- --disallow-incomplete-defs \
- --disallow-untyped-defs \
- --show-error-codes \
- --warn-unreachable \
- --warn-unused-ignores \
- --exclude $(LARGE_TEST_REPO_DIR)/*.py
.PHONY: lint
lint: ## Check the code for linting, formatting, and typing issues with ruff and mypy
ruff check
ruff format --check
- mypy $(MYPY_ARGS) dangerzone
- mypy $(MYPY_ARGS) tests
+ mypy dangerzone tests
.PHONY: fix
fix: ## apply all the suggestions from ruff
diff --git a/assets/design-system.html b/assets/design-system.html
new file mode 100644
index 000000000..771c8846a
--- /dev/null
+++ b/assets/design-system.html
@@ -0,0 +1,305 @@
+
+
+
+
+
+ Dangerzone Color Showcase
+
+
+
+
Dangerzone Color Showcase
+
+
+
+
Light Mode
+
+
+
Status Messages
+
Working: Checking for updates...
+
Error: Startup failed
+
Success: Ready
+
+
+
+
Icons & SVGs
+
+
spinner.svg
+
info-circle.svg
+
icon.png
+
status_unconverted.png
+
status_converting.png
+
status_failed.png
+
status_safe.png
+
hamburger_menu.svg
+
hamburger_menu_update_success.svg
+
hamburger_menu_update_error.svg
+
+
+
+
+
Initial State
+
+
+ Initializing Dangerzone VM
+
+
+
+
+
Document Selection
+
+
+
Drag and drop documents here
or
+
+
+
+
+
+
Settings
+
+ 2 documents selected
+
+
+ Save as
+ document-1
+
+ illegal character: /
+
+
This is a warning message.
+
+
+
+
+
+
+
Conversion Window
+
+
+ document1.pdf
+
+
+
+
+ image.jpg
+ Conversion successful
+
+
+
+ archive.zip
+ File type not supported
+
+
+
+
+
+
+
Dark Mode
+
+
+
Status Messages
+
Working: Checking for updates...
+
Error: Startup failed
+
Success: Ready
+
+
+
+
Icons & SVGs
+
+
spinner-dark.svg
+
info-circle-dark.svg
+
icon.png
+
status_unconverted.png
+
status_converting.png
+
status_failed.png
+
status_safe.png
+
hamburger_menu.svg
+
hamburger_menu_update_success.svg
+
hamburger_menu_update_error.svg
+
+
+
+
+
Initial State
+
+
+ Initializing Dangerzone VM
+
+
+
+
+
Document Selection
+
+
+
Drag and drop documents here
or
+
+
+
+
+
+
Settings
+
+ 2 documents selected
+
+
+ Save as
+ document-1
+
+ illegal character: /
+
+
This is a warning message.
+
+
+
+
+
+
+
Conversion Window
+
+
+ document1.pdf
+
+
+
+
+ image.jpg
+ Conversion successful
+
+
+
+ archive.zip
+ File type not supported
+
+
+
+
+
+
diff --git a/dangerzone/__init__.py b/dangerzone/__init__.py
index b466e5f7d..ba3a73e7c 100644
--- a/dangerzone/__init__.py
+++ b/dangerzone/__init__.py
@@ -4,6 +4,11 @@
logger = logging.getLogger(__name__)
+# Patch the stdlib early in the import tree, so we're able to log all calls done in the background
+from . import capture_output
+
+capture_output.patch_stdlib()
+
# Call freeze_support() to avoid passing unknown options to the subprocess.
# See https://github.com/freedomofpress/dangerzone/issues/873
import multiprocessing
diff --git a/dangerzone/capture_output.py b/dangerzone/capture_output.py
new file mode 100644
index 000000000..88a14167f
--- /dev/null
+++ b/dangerzone/capture_output.py
@@ -0,0 +1,113 @@
+import io
+import logging
+import shlex
+import subprocess
+import threading
+from io import IOBase
+from typing import Any, Union
+
+# This module provides patching utilities to redirect the standard output and
+# standard errors to a log that can be displayed inside the application.
+
+
+log = logging.getLogger()
+
+original_subprocess_run = subprocess.run
+original_subprocess_popen = subprocess.Popen
+
+
+def _decode_if_needed(input: Union[bytes, str]) -> str:
+ if type(input) == bytes:
+ return input.decode()
+ else:
+ return str(input)
+
+
+class PatchedPopen(original_subprocess_popen):
+ def __init__( # type: ignore[no-untyped-def]
+ self, *args, **kwargs
+ ):
+ """Patch the subprocess.Popen to generate log entries:
+
+ - If stdout and stderr are defined, don't alter the behavior ;
+ - Otherwise, create PIPEs for stdout and stderr, and then start threads in
+ the background to consume stdout and stdin, and pass it to `log.debug()`
+ """
+ stdout = kwargs.get("stdout")
+ stderr = kwargs.get("stderr")
+ log.debug(f"Running: {shlex.join(args[0])}")
+ if stdout is not None or stderr is not None:
+ super().__init__(*args, **kwargs)
+ return
+
+ # Read the stdout and stderr as streams (text=True and bufsize=1)
+ kwargs["stdout"] = subprocess.PIPE
+ kwargs["stderr"] = subprocess.PIPE
+ kwargs["text"] = True
+ kwargs["bufsize"] = 1
+
+ super().__init__(*args, **kwargs)
+
+ def _consume_pipe(pipe: IOBase) -> None:
+ for line in iter(pipe.readline, ""):
+ log.debug(_decode_if_needed(line))
+
+ # Create threads to read the stdout and stderr
+ thread_out = threading.Thread(target=_consume_pipe, args=(self.stdout,))
+ thread_err = threading.Thread(target=_consume_pipe, args=(self.stderr,))
+
+ original_process_poll = self.poll
+
+ # Patch the .poll() method to wait for the threads to finish
+ def patched_poll(*args, **kwargs): # type: ignore[no-untyped-def]
+ returncode = original_process_poll(*args, **kwargs)
+ if returncode is not None:
+ for t in (thread_out, thread_err):
+ t.join()
+ return returncode
+
+ self.poll = patched_poll
+
+ # Start the threads
+ for t in (thread_out, thread_err):
+ t.daemon = True
+ t.start()
+
+ # Set process.std{out,err} to None
+ # to prevent the original implementation from consuming
+ # the pipes in the .communicate() method.
+ self.stdout = None
+ self.stderr = None
+
+
+def patched_subprocess_run( # type: ignore[no-untyped-def]
+ *args, **kwargs
+) -> subprocess.CompletedProcess:
+ """
+ Patch subprocess.run to log stdout and stderr and the command that was run.
+ """
+ try:
+ process = original_subprocess_run(
+ *args,
+ **kwargs,
+ )
+ except subprocess.CalledProcessError as e:
+ log.exception(e)
+ raise
+
+ # process.std{out,err} is set to `None` by the patched Popen when reading the
+ # streams as it comes. If it is set here, it means it is not logged
+ # elsewhere, so do it.
+ if process.stdout is not None:
+ log.debug(_decode_if_needed(process.stdout))
+ if process.stderr is not None:
+ log.debug(_decode_if_needed(process.stderr))
+
+ log.debug(f"Process returncode: {process.returncode}")
+ return process
+
+
+def patch_stdlib() -> None:
+ """Patch the subprocess.run and subprocess.Popen to log its results using log.debug()"""
+ subprocess.run = patched_subprocess_run
+ subprocess.Popen = PatchedPopen # type:ignore
diff --git a/dangerzone/cli.py b/dangerzone/cli.py
index 66e23cb9e..52596f3cb 100644
--- a/dangerzone/cli.py
+++ b/dangerzone/cli.py
@@ -1,16 +1,18 @@
import logging
+import platform
import sys
from typing import List, Optional
import click
from colorama import Back, Fore, Style
-from . import args, errors
+from . import args, errors, shutdown, startup
from .document import ARCHIVE_SUBDIR, SAFE_EXTENSION
from .isolation_provider.container import Container
from .isolation_provider.dummy import Dummy
from .isolation_provider.qubes import Qubes, is_qubes_native_conversion
from .logic import DangerzoneCore
+from .podman.machine import PodmanMachineManager
from .settings import Settings
from .updater import install
from .util import get_version, replace_control_chars
@@ -59,6 +61,18 @@ def print_header(s: str) -> None:
" let Dangerzone use the default runtime for this OS"
),
)
+@click.option(
+ "--linger",
+ flag_value=True,
+ help=(
+ "Do not stop the Podman machine VM that Dangerzone uses to run containers,"
+ " after the conversions have completed. This is useful if you want to run"
+ " multiple conversions in a row, since the startup of the VM takes some time."
+ " If you choose to let the Podman machine linger, you will need to stop it"
+ " manually with `dangerzone-machine stop`. This option affects only"
+ " Windows/macOS platforms."
+ ),
+)
@click.version_option(version=get_version(), message="%(version)s")
@errors.handle_document_errors
def cli_main(
@@ -69,10 +83,11 @@ def cli_main(
dummy_conversion: bool,
debug: bool,
set_container_runtime: Optional[str] = None,
+ linger: bool = False,
) -> None:
setup_logging()
display_banner()
- settings = Settings()
+ settings = Settings(debug=debug)
if set_container_runtime:
if set_container_runtime == "default":
settings.unset_custom_runtime()
@@ -117,14 +132,27 @@ def cli_main(
click.echo(f"{dangerzone.ocr_languages[lang]}: {lang}")
sys.exit(1)
- # Ensure container is installed
+ tasks = []
if dangerzone.isolation_provider.requires_install():
- install()
+ tasks = [
+ startup.MachineStopOthersTask(),
+ startup.MachineInitTask(),
+ startup.MachineStartTask(),
+ startup.UpdateCheckTask(),
+ startup.ContainerInstallTask(),
+ ]
- # Convert the document
- print_header("Converting document to safe PDF")
+ try:
+ startup.StartupLogic(tasks=tasks).run()
+ print_header("Converting document(s) to safe PDF")
+ dangerzone.convert_documents(ocr_lang)
+ finally:
+ if dangerzone.isolation_provider.requires_install() and not linger:
+ task_container_stop = shutdown.ContainerStopTask()
+ task_machine_stop = shutdown.MachineStopTask()
+ tasks = [task_container_stop, task_machine_stop]
+ shutdown.ShutdownLogic(tasks=tasks).run()
- dangerzone.convert_documents(ocr_lang)
documents_safe = dangerzone.get_safe_documents()
documents_failed = dangerzone.get_failed_documents()
@@ -143,8 +171,7 @@ def cli_main(
for document in documents_failed:
click.echo(replace_control_chars(document.input_filename))
sys.exit(1)
- else:
- sys.exit(0)
+ sys.exit(0)
args.override_parser_and_check_suspicious_options(cli_main)
diff --git a/dangerzone/container_utils.py b/dangerzone/container_utils.py
index 8af16e114..014b55744 100644
--- a/dangerzone/container_utils.py
+++ b/dangerzone/container_utils.py
@@ -5,59 +5,36 @@
import platform
import shutil
import subprocess
+import sys
from pathlib import Path, PurePosixPath
from typing import IO, Callable, Iterable, List, Optional, Tuple, Union
+from dangerzone.podman.errors.exceptions import PodmanNotInstalled
+
from . import errors
+from .capture_output import original_subprocess_popen as Popen
+from .podman.command import PodmanCommand
+from .podman.errors import CommandError
from .settings import Settings
-from .util import get_cache_dir, get_resource_path, get_subprocess_startupinfo
+from .util import (
+ get_cache_dir,
+ get_resource_path,
+ get_subprocess_startupinfo,
+ get_version,
+)
# Keep the name of the old container here to be able to get rid of it later
OLD_CONTAINER_NAME = "dangerzone.rocks/dangerzone"
+CONTAINER_PREFIX = "dangerzone-"
+CONTAINERS_CONF_PATH = get_cache_dir() / "containers.conf"
+SECCOMP_PATH = get_cache_dir() / "shared" / "seccomp.gvisor.json"
+PODMAN_MACHINE_PREFIX = "dz-internal-"
+PODMAN_MACHINE_NAME = f"{PODMAN_MACHINE_PREFIX}{get_version()}"
+TIMEOUT_KILL = 5 # Timeout in seconds until the kill command returns.
log = logging.getLogger(__name__)
-class Runtime(object):
- """Represents the container runtime to use.
-
- - It can be specified via the settings, using the "container_runtime" key,
- which should point to the full path of the runtime;
- - If the runtime is not specified via the settings, it defaults
- to "podman" on Linux and "docker" on macOS and Windows.
- """
-
- def __init__(self) -> None:
- settings = Settings()
-
- if settings.custom_runtime_specified():
- self.path = Path(settings.get("container_runtime"))
- if not self.path.exists():
- raise errors.UnsupportedContainerRuntime(self.path)
- self.name = self.path.stem
- else:
- self.name = self.get_default_runtime_name()
- self.path = Runtime.path_from_name(self.name)
-
- if self.name not in ("podman", "docker"):
- raise errors.UnsupportedContainerRuntime(self.name)
-
- @staticmethod
- def path_from_name(name: str) -> Path:
- name_path = Path(name)
- if name_path.is_file():
- return name_path
- else:
- runtime = shutil.which(name_path)
- if runtime is None:
- raise errors.NoContainerTechException(name)
- return Path(runtime)
-
- @staticmethod
- def get_default_runtime_name() -> str:
- return "podman" if platform.system() == "Linux" else "docker"
-
-
# subprocess.run with the correct startupinfo for Windows.
# We use a partial here to better profit from type checking
subprocess_run = functools.partial(
@@ -65,7 +42,7 @@ def get_default_runtime_name() -> str:
)
-def get_runtime_version(runtime: Optional[Runtime] = None) -> Tuple[int, int]:
+def get_runtime_version() -> Tuple[int, int]:
"""Get the major/minor parts of the Docker/Podman version.
Some of the operations we perform in this module rely on some Podman features
@@ -74,23 +51,15 @@ def get_runtime_version(runtime: Optional[Runtime] = None) -> Tuple[int, int]:
just knowing the major and minor version, since writing/installing a full-blown
semver parser is an overkill.
"""
- runtime = runtime or Runtime()
-
# Get the Docker/Podman version, using a Go template.
- if runtime.name == "podman":
- query = "{{.Client.Version}}"
- else:
- query = "{{.Server.Version}}"
+ podman = init_podman_command()
+ query = "{{.Client.Version}}"
- cmd = [str(runtime.path), "version", "-f", query]
try:
- version = subprocess_run(
- cmd,
- capture_output=True,
- check=True,
- ).stdout.decode() # type:ignore[attr-defined]
+ version = podman.run(["version", "-f", query])
+ assert isinstance(version, str)
except Exception as e:
- msg = f"Could not get the version of the {runtime.name.capitalize()} tool: {e}"
+ msg = f"Could not get the version of Podman: {e}"
raise RuntimeError(msg) from e
# Parse this version and return the major/minor parts, since we don't need the
@@ -100,13 +69,22 @@ def get_runtime_version(runtime: Optional[Runtime] = None) -> Tuple[int, int]:
return (int(major), int(minor))
except Exception as e:
msg = (
- f"Could not parse the version of the {runtime.name.capitalize()} tool"
- f" (found: '{version}') due to the following error: {e}"
+ f"Could not parse the version of Podman (found: '{version}') due to the"
+ f" following error: {e}"
)
raise RuntimeError(msg)
-def make_seccomp_json_accessible(runtime: Runtime) -> Union[Path, PurePosixPath]:
+def get_podman_path() -> Optional[Path]:
+ podman_bin = "podman"
+ if platform.system() == "Linux":
+ return None # Use default Podman location
+ elif platform.system() == "Windows":
+ podman_bin += ".exe"
+ return get_resource_path("vendor") / "podman" / podman_bin
+
+
+def make_seccomp_json_accessible() -> Union[Path, PurePosixPath]:
"""Ensure that the bundled seccomp profile is accessible by the runtime.
On Linux platforms, this method is basically a no-op since there's no VM
@@ -127,7 +105,7 @@ def make_seccomp_json_accessible(runtime: Runtime) -> Union[Path, PurePosixPath]
[2] Read about the 'volumes=' config in
https://github.com/containers/common/blob/main/docs/containers.conf.5.md#machine-table
"""
- if runtime.name == "podman" and get_runtime_version(runtime) < (4, 0):
+ if get_runtime_version() < (4, 0):
# On OSes that use:
#
# * crun < 0.19
@@ -155,54 +133,174 @@ def make_seccomp_json_accessible(runtime: Runtime) -> Union[Path, PurePosixPath]
else:
src = get_resource_path("seccomp.gvisor.json")
- if platform.system() == "Linux" or runtime.name == "docker":
+ if platform.system() == "Linux":
return src
- elif runtime.name == "podman":
- dst = get_cache_dir() / "seccomp.gvisor.json"
- dst.parent.mkdir(parents=True, exist_ok=True)
+ else:
+ SECCOMP_PATH.parent.mkdir(parents=True, exist_ok=True)
# This file will be overwritten on every conversion, which is unnecessary, but
# the copy operation should be quick.
- shutil.copy(src, dst)
+ shutil.copy(src, SECCOMP_PATH)
if platform.system() == "Windows":
# Translate the Windows path on the host to the WSL2 path on the VM. That
# is, change backslashes to forward slashes, and replace 'C:/' with
# '/mnt/c'.
- subpath = dst.relative_to("C:\\").as_posix()
+ subpath = SECCOMP_PATH.relative_to("C:\\").as_posix()
return PurePosixPath("/mnt/c") / subpath
- return dst
+ return SECCOMP_PATH
+
+
+def create_containers_conf() -> Path:
+ # Determine path of vendored Podman helpers.
+ #
+ # We cannot simply use the vendored Podman binary in order to start a Podman
+ # machine, because it needs to use some other utilities as well (vfkit, gvproxy).
+ # Since we can't install these utilities in $PATH, we have to pass them via the
+ # `helper_binaries_dir` config option. Read more about this field in this section:
+ # https://github.com/containers/common/blob/main/docs/containers.conf.5.md#engine-table
+ podman_path = get_podman_path()
+ assert isinstance(podman_path, Path)
+ helper_binaries_dir = str(podman_path.parent)
+ helper_binaries_dir = helper_binaries_dir.replace("\\", "\\\\")
+
+ # Determine volumes of Podman machine.
+ #
+ # By default, Podman machines boot with a permissive view of the host's filesystem.
+ # We want to limit this access as much as possible using the `volumes` config
+ # option, and specifically mounting only the seccomp policy file as read-only.
+ #
+ # Note that the following option does not affect Windows users, because WSL2 will
+ # always mount C: into the VM. Read more in:
+ # https://github.com/freedomofpress/dangerzone/issues/1171#issuecomment-3279044187
+ volume = f"{SECCOMP_PATH.parent}:{SECCOMP_PATH.parent}:ro"
+ volume = volume.replace("\\", "\\\\")
+ SECCOMP_PATH.parent.mkdir(parents=True, exist_ok=True)
+
+ # Determine CPU count.
+ #
+ # Because the Podman machine is short-lived, we can employ more CPU cores than the
+ # default for the duration of the conversion.
+ cpu_count = os.cpu_count() or 1
+
+ content = f"""\
+[engine]
+helper_binaries_dir=["{helper_binaries_dir}"]
+
+[machine]
+cpus={cpu_count}
+volumes=["{volume}"]
+"""
+ # FIXME: Do not unconditionally write to this file.
+ dst = CONTAINERS_CONF_PATH
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ dst.write_text(content)
+ return dst
+
+
+@functools.cache
+def init_podman_command() -> PodmanCommand:
+ podman_path: Optional[Path]
+ settings = Settings()
+
+ if settings.custom_runtime_specified():
+ podman_path = Path(settings.get("container_runtime"))
+ if not podman_path.exists():
+ raise errors.UnsupportedContainerRuntime(podman_path)
else:
- # Amusingly, that's an actual runtime error...
- raise RuntimeError(f"Unexpected runtime: '{runtime.name}'")
+ podman_path = get_podman_path()
+
+ options = env = None
+ if platform.system() != "Linux" and not settings.custom_runtime_specified():
+ env = os.environ.copy()
+ env["CONTAINERS_CONF"] = str(create_containers_conf())
+ options = PodmanCommand.GlobalOptions(
+ connection=PODMAN_MACHINE_NAME,
+ storage_opt="overlay.mount_program=/usr/bin/fuse-overlayfs",
+ )
+ if settings.debug:
+ options.log_level = "debug"
+
+ try:
+ return PodmanCommand(path=podman_path, env=env, options=options)
+ except PodmanNotInstalled:
+ if getattr(sys, "dangerzone_dev", False):
+ raise errors.ContainerException(
+ "It seems that Podman is not present in your development environment."
+ " You can run `mazette install` to download and install it."
+ f" Expected path: {podman_path}"
+ )
+ else:
+ raise errors.ContainerException(
+ "Dangerzone could not find the Podman binary locally, which"
+ " is necessary to start containers. This binary should be included as"
+ " part of the installation, so the fact that it's missing indicates"
+ " that your installation may be broken. You can try reinstalling"
+ " Dangerzone, but if the problem persists, please contact us."
+ )
def list_image_digests() -> List[str]:
"""Get the digests of all loaded Dangerzone images."""
- runtime = Runtime()
+ podman = init_podman_command()
return (
- subprocess.check_output(
+ podman.run(
[
- str(runtime.path),
"image",
"list",
"--format",
"{{ .Digest }}",
expected_image_name(),
],
- text=True,
- startupinfo=get_subprocess_startupinfo(),
)
- .strip()
+ .strip() # type: ignore [union-attr]
+ .split()
+ )
+
+
+def list_containers() -> List[str]:
+ """Get all the Dangerzone containers."""
+ podman = init_podman_command()
+ containers = (
+ podman.run(
+ [
+ "ps",
+ "-a",
+ "--format",
+ "{{ .Names }}",
+ ],
+ )
+ .strip() # type: ignore [union-attr]
.split()
)
+ return [cont for cont in containers if cont.startswith(CONTAINER_PREFIX)]
+
+
+def kill_container(name: str) -> None:
+ """Terminate a spawned container."""
+ podman = init_podman_command()
+ try:
+ # We do not check the exit code of the process here, since the container may
+ # have stopped right before invoking this command. In that case, the
+ # command's output will contain some error messages, so we capture them in
+ # order to silence them.
+ #
+ # NOTE: We specify a timeout for this command, since we've seen it hang
+ # indefinitely for specific files. See:
+ # https://github.com/freedomofpress/dangerzone/issues/854
+ podman.run(["kill", name], check=False, timeout=TIMEOUT_KILL)
+ except subprocess.TimeoutExpired:
+ log.warning(f"Could not kill container '{name}' within {TIMEOUT_KILL} seconds")
+ except Exception as e:
+ log.exception(
+ f"Unexpected error occurred while killing container '{name}': {str(e)}"
+ )
def add_image_tag(image_id: str, new_tag: str) -> None:
"""Add a tag to the Dangerzone image."""
- runtime = Runtime()
+ podman = init_podman_command()
log.debug(f"Adding tag '{new_tag}' to image '{image_id}'")
- subprocess.check_output(
- [str(runtime.path), "tag", image_id, new_tag],
- startupinfo=get_subprocess_startupinfo(),
+ podman.run(
+ ["tag", image_id, new_tag],
)
@@ -215,13 +313,10 @@ def delete_image_digests(
if not full_digests:
log.debug("Skipping image digest deletion: nothing to remove")
return
- runtime = Runtime()
+ podman = init_podman_command()
log.warning(f"Deleting container images: {' '.join(full_digests)}")
try:
- subprocess.check_output(
- [str(runtime.path), "rmi", "--force", *full_digests],
- startupinfo=get_subprocess_startupinfo(),
- )
+ podman.run(["rmi", "--force", *full_digests])
except Exception as e:
log.warning(
f"Couldn't delete container images '{' '.join(full_digests)}', so leaving it there."
@@ -238,17 +333,12 @@ def clear_old_images(digest_to_keep: str) -> None:
def load_image_tarball(tarball_path: Optional[Path] = None) -> None:
- runtime = Runtime()
log.info("Installing Dangerzone container image...")
+ podman = init_podman_command()
if not tarball_path:
tarball_path = get_resource_path("container.tar")
try:
- res = subprocess_run(
- [str(runtime.path), "load", "-i", str(tarball_path)],
- startupinfo=get_subprocess_startupinfo(),
- capture_output=True,
- check=True,
- )
+ res = podman.run(["load", "-i", str(tarball_path)])
except subprocess.CalledProcessError as e:
if e.stderr:
error = e.stderr.decode()
@@ -263,32 +353,23 @@ def tag_image_by_digest(digest: str, tag: str) -> None:
"""Tag a container image by digest.
The sha256: prefix should be omitted from the digest.
"""
- runtime = Runtime()
+ podman = init_podman_command()
image_id = get_image_id_by_digest(digest)
- cmd = [str(runtime.path), "tag", image_id, tag]
- log.debug(" ".join(cmd))
- subprocess_run(cmd, check=True)
+ podman.run(["tag", image_id, tag])
def get_image_id_by_digest(digest: str) -> str:
"""Get an image ID from a digest.
The sha256: prefix should be omitted from the digest.
"""
- runtime = Runtime()
# There is a "digest" filter that you can use with
# "podman images -f digest:", but it's only available
# for podman >=4.4 (and bookworm ships 4.3)
# So, fallback on the json format instead
- cmd = [
- str(runtime.path),
- "images",
- "--format",
- "json",
- ]
- log.debug(" ".join(cmd))
- process = subprocess_run(cmd, check=True, capture_output=True)
-
- images = json.loads(process.stdout.decode().strip()) # type:ignore[attr-defined]
+ podman = init_podman_command()
+ res = podman.run(["images", "--format", "json"])
+ assert isinstance(res, str)
+ images = json.loads(res)
filtered_images = [
image["Id"] for image in images if image["Digest"] == f"sha256:{digest}"
]
@@ -305,29 +386,13 @@ def expected_image_name() -> str:
return image_name_path.read_text().strip("\n")
-def container_pull(
- image: str, manifest_digest: str, callback: Optional[Callable] = None
-) -> None:
+def container_pull(image: str, manifest_digest: str) -> None:
"""Pull a container image from a registry."""
- runtime = Runtime()
- cmd = [str(runtime.path), "pull", f"{image}@sha256:{manifest_digest}"]
- process = subprocess.Popen(
- cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- text=True,
- bufsize=1,
- )
-
- if callback:
- for line in process.stdout: # type: ignore
- callback(line)
-
- process.wait()
- if process.returncode != 0:
- raise errors.ContainerPullException(
- f"Could not pull the container image: {process.returncode}"
- )
+ podman = init_podman_command()
+ try:
+ podman.run(["pull", f"{image}@sha256:{manifest_digest}"], capture_output=False)
+ except CommandError:
+ raise errors.ContainerPullException("Could not pull the container image")
def get_local_image_digest(image: Optional[str] = None) -> str:
@@ -340,19 +405,12 @@ def get_local_image_digest(image: Optional[str] = None) -> str:
# update scenario.
# `podman inspect` is avoided here as it returns the digest of the
# architecture-bound image.
-
- runtime = Runtime()
- cmd = [str(runtime.path), "images", expected_image, "--format", "{{.Digest}}"]
- log.debug(" ".join(cmd))
- result = subprocess_run(
- cmd,
- capture_output=True,
- check=True,
- )
- output = result.stdout.decode().strip().split("\n") # type:ignore[attr-defined]
+ podman = init_podman_command()
+ res = podman.run(["images", expected_image, "--format", "{{.Digest}}"])
+ assert isinstance(res, str)
# In some cases, the output can be multiple lines with the same digest
# sets are used to reduce them.
- lines = set(output)
+ lines = set(res.split("\n"))
if len(lines) < 1:
raise errors.ImageNotPresentException(
f"The image {expected_image} does not exist locally"
diff --git a/dangerzone/errors.py b/dangerzone/errors.py
index 21fe807e2..6ecd5e0d2 100644
--- a/dangerzone/errors.py
+++ b/dangerzone/errors.py
@@ -156,3 +156,7 @@ class UnsupportedContainerRuntime(ContainerException):
class ContainerPullException(ContainerException):
pass
+
+
+class OtherMachineRunningError(ContainerException):
+ pass
diff --git a/dangerzone/gui/__init__.py b/dangerzone/gui/__init__.py
index 4183bf657..d252c488e 100644
--- a/dangerzone/gui/__init__.py
+++ b/dangerzone/gui/__init__.py
@@ -30,7 +30,6 @@
from ..util import get_resource_path, get_version
from .logic import DangerzoneGui
from .main_window import MainWindow
-from .updater import UpdaterThread
log = logging.getLogger(__name__)
@@ -154,26 +153,13 @@ def gui_main(dummy_conversion: bool, filenames: Optional[List[str]]) -> bool:
def open_files(filenames: List[str] = []) -> None:
documents = [Document(filename) for filename in filenames]
- window.content_widget.doc_selection_widget.documents_selected.emit(documents)
+ window.conversion_widget.doc_selection_widget.documents_selected.emit(documents)
window = MainWindow(dangerzone)
-
- # Check for updates
- log.debug("Setting up Dangerzone updater in a separate thread")
- updater = UpdaterThread(dangerzone)
- window.register_update_handler(updater.finished)
-
- should_check = updater.should_check_for_updates()
-
- if should_check:
- log.debug("Checking for updates")
- updater.start()
- else:
- log.debug("Will not check for updates, based on updater settings")
-
settings = Settings()
updates_enabled = bool(settings.get("updater_check_all"))
window.toggle_updates_action.setChecked(updates_enabled)
+ window.startup_thread.start()
if filenames:
open_files(filenames)
@@ -181,6 +167,7 @@ def open_files(filenames: List[str] = []) -> None:
# MacOS: Open a new window, if all windows are closed
def application_activated() -> None:
window.show()
+ window.adjustSize()
# If we get a file open event, open it
app.document_selected.connect(open_files)
diff --git a/dangerzone/gui/log_window.py b/dangerzone/gui/log_window.py
new file mode 100644
index 000000000..4df992881
--- /dev/null
+++ b/dangerzone/gui/log_window.py
@@ -0,0 +1,117 @@
+import logging
+import typing
+from typing import Optional
+
+if typing.TYPE_CHECKING:
+ from PySide2 import QtCore, QtWidgets
+else:
+ try:
+ from PySide6 import QtCore, QtWidgets
+ except ImportError:
+ from PySide2 import QtCore, QtWidgets
+
+from dangerzone.gui.widgets import TracebackWidget
+
+
+class LogSignal(QtCore.QObject):
+ new_record = QtCore.Signal(str)
+
+
+class LogHandler(logging.Handler):
+ """Capture application logs and emit them as signals.
+
+ This Qt object is responsible for capturing all application logs, and emitting them
+ as signals, so that other widgets can show them to the user.
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._log_signal = LogSignal()
+
+ @property
+ def new_record(self) -> QtCore.SignalInstance:
+ return self._log_signal.new_record
+
+ def emit(self, record: logging.LogRecord) -> None:
+ msg = self.format(record)
+ self._log_signal.new_record.emit(msg + "\n")
+
+
+class LogWindow(QtWidgets.QDialog):
+ """Show logs and startup activity.
+
+ Define a widget where the user can see more details about the following:
+ * Application logs
+ * Status of startup tasks
+ """
+
+ def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("Dangerzone Background Task Logs")
+ self.setMinimumSize(600, 400)
+
+ self.label = QtWidgets.QLabel()
+ self.label.setAlignment(QtCore.Qt.AlignCenter)
+ self.label.setTextFormat(QtCore.Qt.RichText)
+ self.label.setOpenExternalLinks(True)
+ self.label.setWordWrap(True)
+
+ self.traceback_widget = TracebackWidget()
+ self.traceback_widget.setVisible(True) # Always visible in the log window
+
+ layout = QtWidgets.QVBoxLayout()
+ layout.addWidget(self.label)
+ layout.addWidget(self.traceback_widget)
+ self.setLayout(layout)
+
+ def handle_startup_begin(self) -> None:
+ self.label.setText("Dangerzone is starting up…")
+
+ def handle_shutdown_begin(self) -> None:
+ self.label.setText("Dangerzone is shutting down…")
+
+ def handle_task_machine_init(self) -> None:
+ self.label.setText(
+ "Initializing the Dangerzone VM This might take a few minutes…"
+ )
+
+ def handle_task_machine_start(self) -> None:
+ self.label.setText("Starting the Dangerzone VM…")
+
+ def handle_task_machine_stop_others(self) -> None:
+ self.label.setText("Stopping other Podman VMs…")
+
+ def handle_task_update_check(self) -> None:
+ self.label.setText("Checking for updates…")
+
+ def handle_task_container_install(self) -> None:
+ self.label.setText(
+ "Installing the Dangerzone sandbox This might take a few minutes…"
+ )
+
+ def handle_task_container_stop(self) -> None:
+ self.label.setText("Stopping the Dangerzone sandbox…")
+
+ def handle_task_machine_init_failed(self, error: str) -> None:
+ self.label.setText("Initializing the Dangerzone VM… failed")
+
+ def handle_task_machine_start_failed(self, error: str) -> None:
+ self.label.setText("Starting the Dangerzone VM… failed")
+
+ def handle_task_machine_stop_others_failed(self, error: str) -> None:
+ self.label.setText("Stopping other Podman VMs… failed")
+
+ def handle_task_machine_stop(self) -> None:
+ self.label.setText("Stopping Dangerzone VM…")
+
+ def handle_task_update_check_failed(self, error: str) -> None:
+ self.label.setText("Checking for updates… failed")
+
+ def handle_task_container_install_failed(self, error: str) -> None:
+ self.label.setText("Installing the Dangerzone sandbox… failed")
+
+ def handle_startup_success(self) -> None:
+ self.label.setText("Dangerzone is ready")
+
+ def append_log(self, line: str) -> None:
+ self.traceback_widget.process_output(line)
diff --git a/dangerzone/gui/logic.py b/dangerzone/gui/logic.py
index ee4ecca68..e0f36813e 100644
--- a/dangerzone/gui/logic.py
+++ b/dangerzone/gui/logic.py
@@ -8,7 +8,7 @@
import typing
from collections import OrderedDict
from pathlib import Path
-from typing import Optional
+from typing import Any, Optional
from colorama import Fore
@@ -171,6 +171,7 @@ def __init__(
has_cancel: bool = True,
cancel_text: str = "Cancel",
extra_button_text: Optional[str] = None,
+ checkbox_text: Optional[str] = None,
) -> None:
super().__init__()
self.dangerzone = dangerzone
@@ -204,12 +205,19 @@ def __init__(
self.cancel_button = QtWidgets.QPushButton(cancel_text)
self.cancel_button.clicked.connect(self.clicked_cancel)
+ self.checkbox: Optional[QtWidgets.QCheckBox] = None
+ if checkbox_text:
+ self.checkbox = QtWidgets.QCheckBox(checkbox_text)
+
buttons_layout = self.create_buttons_layout()
layout = QtWidgets.QVBoxLayout()
layout.addLayout(message_layout)
layout.addSpacing(10)
layout.addLayout(buttons_layout)
+ if self.checkbox:
+ layout.addSpacing(10)
+ layout.addWidget(self.checkbox)
self.setLayout(layout)
def create_buttons_layout(self) -> QtWidgets.QHBoxLayout:
@@ -241,11 +249,11 @@ def launch(self) -> int:
class Alert(Dialog):
- def __init__( # type: ignore [no-untyped-def]
+ def __init__(
self,
- *args,
+ *args: Any,
message: str = "",
- **kwargs,
+ **kwargs: Any,
) -> None:
self.message = message
kwargs.setdefault("title", "dangerzone")
diff --git a/dangerzone/gui/main_window.py b/dangerzone/gui/main_window.py
index 41db82073..d5b4c76ce 100644
--- a/dangerzone/gui/main_window.py
+++ b/dangerzone/gui/main_window.py
@@ -1,3 +1,4 @@
+import functools
import io
import logging
import os
@@ -8,25 +9,29 @@
from pathlib import Path
from typing import Callable, List, Optional, Union
+from dangerzone.gui import shutdown, startup
+from dangerzone.gui.updater import prompt_for_checks
from dangerzone.updater.releases import EmptyReport, ErrorReport, ReleaseReport
+from ..podman.machine import PodmanMachineManager
+
# FIXME: See https://github.com/freedomofpress/dangerzone/issues/320 for more details.
if typing.TYPE_CHECKING:
from PySide2 import QtCore, QtGui, QtSvg, QtWidgets
from PySide2.QtCore import Qt
- from PySide2.QtGui import QTextCursor
- from PySide2.QtWidgets import QAction, QTextEdit
+ from PySide2.QtSvg import QSvgWidget
+ from PySide2.QtWidgets import QAction
else:
try:
from PySide6 import QtCore, QtGui, QtSvg, QtWidgets
from PySide6.QtCore import Qt
- from PySide6.QtGui import QAction, QTextCursor
- from PySide6.QtWidgets import QTextEdit
+ from PySide6.QtGui import QAction
+ from PySide6.QtSvgWidgets import QSvgWidget
except ImportError:
from PySide2 import QtCore, QtGui, QtSvg, QtWidgets
from PySide2.QtCore import Qt
- from PySide2.QtGui import QTextCursor
- from PySide2.QtWidgets import QAction, QTextEdit
+ from PySide2.QtSvg import QSvgWidget
+ from PySide2.QtWidgets import QAction
from .. import errors
from ..document import SAFE_EXTENSION, Document
@@ -40,7 +45,9 @@
get_installation_strategy,
)
from ..util import format_exception, get_resource_path, get_version
+from .log_window import LogHandler, LogWindow
from .logic import Alert, CollapsibleBox, DangerzoneGui, UpdateDialog
+from .widgets import TracebackWidget
log = logging.getLogger(__name__)
@@ -63,6 +70,14 @@
about updates.
"""
+MACHINE_NEEDS_STOP_MSG = """\
+
Dangerzone has detected that a Podman machine with name '{machine_name}' is already
+running in your system. Unfortunately, this machine needs to stop so that Dangerzone can
+run.
+
You can either let Dangerzone stop this machine for you, or quit Dangerzone and
+handle it manually.
+"""
+
HAMBURGER_MENU_SIZE = 30
@@ -82,6 +97,31 @@ def load_svg_image(filename: str, width: int, height: int) -> QtGui.QPixmap:
return pixmap
+def animate_svg_image(
+ filename: str, width: int, height: int, fps: int = 20
+) -> QSvgWidget:
+ """Load and animate an SVG image.
+
+ Qt has rudimentary support for SVG animations [1], that basically boil down to SVGs
+ that use the `animateTransform` element [2,3]. The rest of the SVGs that use a
+ different `animate*` property will NOT be animated.
+
+ This function animates SVGs using 20FPS by default. We have experimented with higher
+ values, and we're seeing a noticeable CPU overhead, so we **strongly** suggest
+ keeping this number low.
+
+ [1] https://doc.qt.io/qt-6/svgrendering.html#rendering-svg-files
+ [2] https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/animateTransform
+ [3] https://github.com/yjg30737/pyqt-animated-svg-example
+ """
+ path = get_resource_path(filename)
+ svg_widget = QSvgWidget()
+ svg_widget.renderer().setFramesPerSecond(fps)
+ svg_widget.load(str(path))
+ svg_widget.setFixedSize(width, height)
+ return svg_widget
+
+
def get_supported_extensions() -> List[str]:
supported_ext = [
".pdf",
@@ -121,6 +161,96 @@ def get_supported_extensions() -> List[str]:
return supported_ext
+class StatusBar(QtWidgets.QWidget):
+ def __init__(self, dangerzone: DangerzoneGui) -> None:
+ super(StatusBar, self).__init__()
+ self.dangerzone = dangerzone
+
+ if self.dangerzone.app.os_color_mode.value == "dark":
+ spinner_svg = "spinner-dark.svg"
+ info_svg = "info-circle-dark.svg"
+ else:
+ spinner_svg = "spinner.svg"
+ info_svg = "info-circle.svg"
+
+ self.spinner = animate_svg_image(spinner_svg, width=15, height=15)
+ self.message = QtWidgets.QLabel("")
+ self.info_icon = QtWidgets.QToolButton()
+ self.info_icon.setIcon(
+ QtGui.QIcon(load_svg_image(info_svg, width=15, height=15))
+ )
+ self.info_icon.setIconSize(QtCore.QSize(15, 15))
+ self.info_icon.setStyleSheet("QToolButton { border: none; }")
+
+ layout = QtWidgets.QHBoxLayout()
+ layout.addStretch()
+ layout.addWidget(self.spinner)
+ layout.addWidget(self.message)
+ layout.addWidget(self.info_icon)
+ self.setLayout(layout)
+
+ def _update_style(self) -> None:
+ # Required when dynamically changing properties. See:
+ # https://wiki.qt.io/Dynamic_Properties_and_Stylesheets#Limitations
+ self.message.style().unpolish(self.message)
+ self.message.style().polish(self.message)
+ self.message.update()
+
+ def set_status_ok(self, message: str) -> None:
+ self.spinner.hide()
+ self.info_icon.hide()
+ self.message.setProperty("style", "status-success")
+ self._update_style()
+ self.message.setText(message)
+
+ def set_status_working(self, message: str) -> None:
+ self.spinner.show()
+ self.info_icon.show()
+ self.message.setProperty("style", "status-attention")
+ self._update_style()
+ self.message.setText(message)
+
+ def set_status_error(self, message: str) -> None:
+ self.spinner.hide()
+ self.info_icon.show()
+ self.message.setProperty("style", "status-error")
+ self._update_style()
+ self.message.setText(message)
+
+ def handle_startup_begin(self) -> None:
+ self.set_status_working("Starting")
+
+ def handle_shutdown_begin(self) -> None:
+ self.set_status_working("Shutting down")
+
+ def handle_task_machine_init(self) -> None:
+ self.set_status_working("Initializing Dangerzone VM")
+
+ def handle_task_machine_start(self) -> None:
+ self.set_status_working("Starting Dangerzone VM")
+
+ def handle_task_machine_stop_others(self) -> None:
+ self.set_status_working("Stopping other Podman VMs")
+
+ def handle_task_machine_stop(self) -> None:
+ self.set_status_working("Stopping Dangerzone VM")
+
+ def handle_task_update_check(self) -> None:
+ self.set_status_working("Checking for updates")
+
+ def handle_task_container_install(self) -> None:
+ self.set_status_working("Installing Dangerzone sandbox")
+
+ def handle_task_container_stop(self) -> None:
+ self.set_status_working("Stopping Dangerzone sandbox")
+
+ def handle_startup_error(self) -> None:
+ self.set_status_error("Startup failed")
+
+ def handle_startup_success(self) -> None:
+ self.set_status_ok("")
+
+
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, dangerzone: DangerzoneGui) -> None:
super(MainWindow, self).__init__()
@@ -135,7 +265,7 @@ def __init__(self, dangerzone: DangerzoneGui) -> None:
if platform.system() == "Darwin":
# FIXME have a different height for macOS due to font-size inconsistencies
# https://github.com/freedomofpress/dangerzone/issues/270
- self.setMinimumHeight(470)
+ self.setMinimumHeight(550)
else:
self.setMinimumHeight(430)
@@ -178,6 +308,9 @@ def __init__(self, dangerzone: DangerzoneGui) -> None:
bool(self.dangerzone.settings.get("updater_check_all"))
)
+ # Add the "View logs" action
+ view_logs_action = hamburger_menu.addAction("View logs")
+
# Add the "Exit" action
hamburger_menu.addSeparator()
exit_action = hamburger_menu.addAction("Exit")
@@ -189,38 +322,33 @@ def __init__(self, dangerzone: DangerzoneGui) -> None:
) # balance out hamburger to keep logo centered
header_layout.addStretch()
header_layout.addWidget(logo)
- header_layout.addSpacing(10)
+ font_metrics = header_label.fontMetrics()
+ m_width = font_metrics.horizontalAdvance("m")
+ header_layout.addSpacing(int(m_width / 2))
header_layout.addWidget(header_label)
header_layout.addWidget(header_version_label)
header_layout.addStretch()
header_layout.addWidget(self.hamburger_button)
- header_layout.addSpacing(15)
+ header_layout.addSpacing(m_width)
- # Content widget, contains all the window content except waiting widget
- self.content_widget = ContentWidget(self.dangerzone)
-
- if self.dangerzone.isolation_provider.requires_install():
- # Waiting widget replaces content widget while container runtime isn't available
- self.waiting_widget: WaitingWidget = WaitingWidgetContainer(self.dangerzone)
- self.waiting_widget.finished.connect(self.waiting_finished)
- else:
- # Don't wait with dummy converter and on Qubes.
- self.waiting_widget = WaitingWidget()
- self.dangerzone.is_waiting_finished = True
-
- # Only use the waiting widget if container runtime isn't available
- if self.dangerzone.is_waiting_finished:
- self.waiting_widget.hide()
- self.content_widget.show()
+ # Content and waiting widget
+ self.conversion_widget = ConversionWidget(self.dangerzone)
+ self.waiting_widget = WaitingWidget()
+ if self.dangerzone.settings.get("successful_first_run") == get_version():
+ self.show_conversion_widget()
else:
- self.waiting_widget.show()
- self.content_widget.hide()
+ # Do not show the waiting widget, if we have not performed a successful
+ # conversion for this Dangerzone version.
+ self.hide_conversion_widget()
# Layout
layout = QtWidgets.QVBoxLayout()
layout.addLayout(header_layout)
layout.addWidget(self.waiting_widget, stretch=1)
- layout.addWidget(self.content_widget, stretch=1)
+ layout.addWidget(self.conversion_widget, stretch=1)
+
+ self.status_bar = StatusBar(self.dangerzone)
+ layout.addWidget(self.status_bar)
central_widget = QtWidgets.QWidget()
central_widget.setLayout(layout)
@@ -231,20 +359,125 @@ def __init__(self, dangerzone: DangerzoneGui) -> None:
# This allows us to make QSS rules conditional on the OS color mode.
self.setProperty("OSColorMode", self.dangerzone.app.os_color_mode.value)
- if hasattr(self.dangerzone.isolation_provider, "check_docker_desktop_version"):
- try:
- is_version_valid, version = (
- self.dangerzone.isolation_provider.check_docker_desktop_version()
- )
- if not is_version_valid:
- self.handle_docker_desktop_version_check(is_version_valid, version)
- except errors.UnsupportedContainerRuntime as e:
- pass # It's caught later in the flow.
- except errors.NoContainerTechException as e:
- pass # It's caught later in the flow.
+ # Log window
+ self.log_window = LogWindow(self)
+ view_logs_action.triggered.connect(self.log_window.show)
+ self.status_bar.info_icon.clicked.connect(self.toggle_log_window)
+
+ # Configure logging to the log window
+ log_handler = LogHandler()
+ log_handler.new_record.connect(self.log_window.append_log)
+ logging.getLogger().addHandler(log_handler)
+ logging.getLogger().setLevel(logging.DEBUG)
+
+ # Start startup thread
+ log.debug("Starting Dangerzone background tasks")
+ task_machine_stop_others = startup.MachineStopOthersTask()
+ task_machine_init = startup.MachineInitTask()
+ task_machine_start = startup.MachineStartTask()
+ task_update_check = startup.UpdateCheckTask()
+ task_container_install = startup.ContainerInstallTask()
+ if dangerzone.isolation_provider.requires_install():
+ tasks = [
+ task_machine_stop_others,
+ task_machine_init,
+ task_machine_start,
+ task_update_check,
+ task_container_install,
+ ]
+ else:
+ tasks = [task_update_check]
+ self.startup_thread = startup.StartupThread(tasks, raise_on_error=False) # type: ignore [arg-type]
+ self.startup_thread.succeeded.connect(self.waiting_finished)
+ self.startup_thread.starting.connect(self.status_bar.handle_startup_begin)
+ self.startup_thread.starting.connect(self.waiting_widget.handle_start)
+ self.startup_thread.succeeded.connect(self.status_bar.handle_startup_success)
+ self.startup_thread.succeeded.connect(self.log_window.handle_startup_success)
+ self.startup_thread.succeeded.connect(self.show_conversion_widget)
+ self.startup_thread.failed.connect(self.status_bar.handle_startup_error)
+
+ task_machine_init.starting.connect(self.status_bar.handle_task_machine_init)
+ task_machine_init.starting.connect(self.log_window.handle_task_machine_init)
+ task_machine_init.failed.connect(
+ self.log_window.handle_task_machine_init_failed
+ )
+
+ task_machine_start.starting.connect(self.status_bar.handle_task_machine_start)
+ task_machine_start.starting.connect(self.log_window.handle_task_machine_start)
+ task_machine_start.failed.connect(
+ self.log_window.handle_task_machine_start_failed
+ )
+
+ task_machine_stop_others.starting.connect(
+ self.status_bar.handle_task_machine_stop_others
+ )
+ task_machine_stop_others.starting.connect(
+ self.log_window.handle_task_machine_stop_others
+ )
+ task_machine_stop_others.failed.connect(
+ self.log_window.handle_task_machine_stop_others_failed
+ )
+ task_machine_stop_others.needs_user_input.connect(
+ self.handle_needs_user_input_stop_others
+ )
+
+ task_update_check.starting.connect(self.status_bar.handle_task_update_check)
+ task_update_check.starting.connect(self.log_window.handle_task_update_check)
+ task_update_check.failed.connect(
+ self.log_window.handle_task_update_check_failed
+ )
+ task_update_check.failed.connect(self.handle_update_check_failed)
+ task_update_check.app_update_available.connect(self.handle_app_update_available)
+ task_update_check.container_update_available.connect(
+ self.handle_container_update_available
+ )
+ task_update_check.completed.connect(self.handle_update_check_completed)
+ task_update_check.needs_user_input.connect(self.handle_needs_user_input)
+
+ task_container_install.starting.connect(
+ self.status_bar.handle_task_container_install
+ )
+ task_container_install.starting.connect(
+ self.log_window.handle_task_container_install
+ )
+ task_container_install.failed.connect(
+ self.log_window.handle_task_container_install_failed
+ )
self.show()
+ def exit(self, ret: int) -> None:
+ log.debug(f"Shutting down Dangerzone with exit code {ret}")
+ self.dangerzone.app.exit(ret)
+
+ def begin_shutdown(self, ret: int) -> None:
+ log.debug(f"Starting the shutdown process with exit code {ret}")
+ if not self.dangerzone.isolation_provider.requires_install():
+ return self.exit(ret)
+
+ task_container_stop = shutdown.ContainerStopTask()
+ task_machine_stop = shutdown.MachineStopTask()
+ tasks = [task_container_stop, task_machine_stop]
+
+ self.shutdown_thread = shutdown.ShutdownThread(tasks) # type: ignore [arg-type]
+ self.shutdown_thread.starting.connect(self.status_bar.handle_shutdown_begin)
+ self.shutdown_thread.succeeded.connect(
+ functools.partial(self.finish_shutdown, ret=ret)
+ )
+ self.shutdown_thread.failed.connect(
+ functools.partial(self.finish_shutdown, ret=3)
+ )
+ task_container_stop.starting.connect(self.status_bar.handle_task_container_stop)
+ task_container_stop.starting.connect(self.log_window.handle_task_container_stop)
+ task_machine_stop.starting.connect(self.status_bar.handle_task_machine_stop)
+ task_machine_stop.starting.connect(self.log_window.handle_task_machine_stop)
+ self.shutdown_thread.start()
+
+ def finish_shutdown(self, ret: int) -> None:
+ log.debug(f"Finalizing Dangerzone shutdown")
+ self.shutdown_thread.wait()
+ self.exit(ret)
+
def show_update_success(self) -> None:
"""Inform the user about a new Dangerzone release."""
version = self.dangerzone.settings.get("updater_latest_version")
@@ -296,141 +529,136 @@ def toggle_updates_triggered(self) -> None:
self.dangerzone.settings.set("updater_check_all", check)
self.dangerzone.settings.save()
- def handle_docker_desktop_version_check(
- self, is_version_valid: bool, version: str
- ) -> None:
+ def handle_update_check_failed(self, error: str) -> None:
+ log.error(f"Encountered an error during an update check: {error}")
+ errors = self.dangerzone.settings.get("updater_errors") + 1
+ self.dangerzone.settings.set("updater_errors", errors)
+ self.dangerzone.settings.save()
+ self.updater_error = error
+
+ # If we encounter more than three errors in a row, show a red notification
+ # bubble. This way, we don't inform the user about intermittent errors.
+ if errors < 3:
+ log.debug(
+ f"Will not show an error yet since number of errors is low ({errors})"
+ )
+ return
+
hamburger_menu = self.hamburger_button.menu()
+ self.hamburger_button.setIcon(
+ QtGui.QIcon(
+ load_svg_image("hamburger_menu_update_error.svg", width=64, height=64)
+ )
+ )
sep = hamburger_menu.insertSeparator(hamburger_menu.actions()[0])
- upgrade_action = QAction("Docker Desktop should be upgraded", hamburger_menu)
- upgrade_action.setIcon(
+ # FIXME: Add red bubble next to the text.
+ error_action = QAction("Update error", hamburger_menu)
+ error_action.setIcon(
QtGui.QIcon(
load_svg_image(
"hamburger_menu_update_dot_error.svg", width=64, height=64
)
)
)
+ error_action.triggered.connect(self.show_update_error)
+ hamburger_menu.insertAction(sep, error_action)
- message = """
-
A new version of Docker Desktop is available. Please upgrade your system.
- Keeping Docker Desktop up to date allows you to have more confidence that your documents are processed safely.
- """
- self.alert = Alert(
- self.dangerzone,
- title="Upgrade Docker Desktop",
- message=message,
- ok_text="Ok",
- has_cancel=False,
+ def handle_app_update_available(self, report: ReleaseReport) -> None:
+ log.debug(f"New Dangerzone release: {report.version}")
+ self.dangerzone.settings.set("updater_latest_version", report.version)
+ self.dangerzone.settings.set("updater_latest_changelog", report.changelog)
+ self.dangerzone.settings.save()
+ self.hamburger_button.setIcon(
+ QtGui.QIcon(
+ load_svg_image("hamburger_menu_update_success.svg", width=64, height=64)
+ )
)
- def _launch_alert() -> None:
- if self.alert:
- self.alert.launch()
-
- upgrade_action.triggered.connect(_launch_alert)
- hamburger_menu.insertAction(sep, upgrade_action)
-
- self.hamburger_button.setIcon(
+ hamburger_menu = self.hamburger_button.menu()
+ sep = hamburger_menu.insertSeparator(hamburger_menu.actions()[0])
+ success_action = QAction("New version available", hamburger_menu)
+ success_action.setIcon(
QtGui.QIcon(
- load_svg_image("hamburger_menu_update_error.svg", width=64, height=64)
+ load_svg_image(
+ "hamburger_menu_update_dot_available.svg",
+ width=64,
+ height=64,
+ )
)
)
+ success_action.triggered.connect(self.show_update_success)
+ hamburger_menu.insertAction(sep, success_action)
- def handle_updates(
- self, report: Union[ReleaseReport, EmptyReport, ErrorReport]
- ) -> None:
- """Handle update reports from the update checker thread."""
- # If there are no new updates, reset the error counter (if any) and return.
- if isinstance(report, EmptyReport):
- self.dangerzone.settings.set("updater_errors", 0, autosave=True)
- return
+ def handle_container_update_available(self, report: ReleaseReport) -> None:
+ log.debug(f"New container image is available")
- hamburger_menu = self.hamburger_button.menu()
+ def handle_update_check_completed(self) -> None:
+ self.dangerzone.settings.set("updater_errors", 0)
+ self.dangerzone.settings.save()
- if isinstance(report, ErrorReport):
- log.error(f"Encountered an error during an update check: {report.error}")
- errors = self.dangerzone.settings.get("updater_errors") + 1
- self.dangerzone.settings.set("updater_errors", errors)
- self.dangerzone.settings.save()
- self.updater_error = report.error
-
- # If we encounter more than three errors in a row, show a red notification
- # bubble. This way, we don't inform the user about intermittent errors.
- if errors < 3:
- log.debug(
- f"Will not show an error yet since number of errors is low ({errors})"
- )
- return
+ def handle_needs_user_input(self) -> None:
+ check = prompt_for_checks(self.dangerzone)
+ if check is not None:
+ self.dangerzone.settings.set("updater_check_all", check, autosave=True)
- self.hamburger_button.setIcon(
- QtGui.QIcon(
- load_svg_image(
- "hamburger_menu_update_error.svg", width=64, height=64
- )
- )
- )
- sep = hamburger_menu.insertSeparator(hamburger_menu.actions()[0])
- # FIXME: Add red bubble next to the text.
- error_action = QAction("Update error", hamburger_menu)
- error_action.setIcon(
- QtGui.QIcon(
- load_svg_image(
- "hamburger_menu_update_dot_error.svg", width=64, height=64
- )
- )
- )
- error_action.triggered.connect(self.show_update_error)
- hamburger_menu.insertAction(sep, error_action)
- if isinstance(report, ReleaseReport):
- log.debug(f"Handling new version: {report.version}")
- self.dangerzone.settings.set("updater_errors", 0)
- if report.new_github_release:
- log.debug(f"New Dangerzone release: {report.version}")
- self.dangerzone.settings.set("updater_latest_version", report.version)
+ def handle_needs_user_input_stop_others(self, req: startup.PromptRequest) -> None:
+ machine_name = req.req_data["name"]
+ log.debug(f"Prompting user to stop Podman machine '{machine_name}'")
+ alert = Alert(
+ self.dangerzone,
+ title="Detected running Podman machine",
+ message=MACHINE_NEEDS_STOP_MSG.format(machine_name=machine_name),
+ ok_text="Stop the Podman machine",
+ cancel_text="Quit Dangerzone",
+ checkbox_text="Remember my choice",
+ )
+ assert alert.checkbox is not None
+ result = alert.launch()
+
+ if result == Alert.Accepted:
+ if alert.checkbox.isChecked():
self.dangerzone.settings.set(
- "updater_latest_changelog", report.changelog
+ "stop_other_podman_machines",
+ "always",
+ autosave=True,
)
- self.hamburger_button.setIcon(
- QtGui.QIcon(
- load_svg_image(
- "hamburger_menu_update_success.svg", width=64, height=64
- )
- )
+ req.reply(True)
+ else:
+ if alert.checkbox.isChecked():
+ self.dangerzone.settings.set(
+ "stop_other_podman_machines",
+ "never",
+ autosave=True,
)
+ req.reply(False)
+ self.startup_thread.wait()
+ self.begin_shutdown(ret=2)
- sep = hamburger_menu.insertSeparator(hamburger_menu.actions()[0])
- success_action = QAction("New version available", hamburger_menu)
- success_action.setIcon(
- QtGui.QIcon(
- load_svg_image(
- "hamburger_menu_update_dot_available.svg",
- width=64,
- height=64,
- )
- )
- )
- success_action.triggered.connect(self.show_update_success)
- hamburger_menu.insertAction(sep, success_action)
- if report.container_image_bump:
- # XXX Check what's the installation strategy, and apply it.
- # For now, don't do anything, it will be done on the next run.
- # To apply this, we will need to:
- # - Ensure if an update is already ongoing, in case just let it happen.
- # - Show the install dialog again here, with a specific message saying
- # that a sandbox update has been detected.
- pass
-
- # FIXME: Save the settings to the filesystem only when they have really changed,
- # maybe with a dirty bit.
- self.dangerzone.settings.save()
-
- def register_update_handler(self, signal: QtCore.SignalInstance) -> None:
- signal.connect(self.handle_updates)
+ def hide_conversion_widget(self) -> None:
+ self.waiting_widget.show()
+ self.conversion_widget.hide()
+
+ def show_conversion_widget(self) -> None:
+ self.waiting_widget.hide()
+ self.conversion_widget.show()
def waiting_finished(self) -> None:
+ log.debug("Startup tasks have finished")
self.dangerzone.is_waiting_finished = True
- self.waiting_widget.hide()
- self.content_widget.show()
+
+ if self.conversion_widget.documents_list.conversion_pending:
+ log.debug("Starting pending conversion")
+ self.conversion_widget.documents_list.start_conversion()
+
+ def toggle_log_window(self) -> None:
+ if self.log_window.isVisible():
+ self.log_window.hide()
+ else:
+ self.log_window.show()
+
+ def show_log_window_if_hidden(self) -> None:
+ if not self.log_window.isVisible():
+ self.log_window.show()
def closeEvent(self, e: QtGui.QCloseEvent) -> None:
self.alert = Alert(
@@ -440,282 +668,50 @@ def closeEvent(self, e: QtGui.QCloseEvent) -> None:
)
converting_docs = self.dangerzone.get_converting_documents()
failed_docs = self.dangerzone.get_failed_documents()
- if not converting_docs:
- e.accept()
- if failed_docs:
- self.dangerzone.app.exit(1)
- else:
- self.dangerzone.app.exit(0)
- else:
+
+ if converting_docs:
accept_exit = self.alert.launch()
if not accept_exit:
e.ignore()
return
- else:
- e.accept()
-
- self.dangerzone.app.exit(2)
-
-class InstallContainerThread(QtCore.QThread):
- finished = QtCore.Signal(str)
- process_stdout = QtCore.Signal(str)
-
- def __init__(
- self,
- dangerzone: DangerzoneGui,
- installation_strategy: InstallationStrategy,
- ) -> None:
- super(InstallContainerThread, self).__init__()
- self.dangerzone = dangerzone
- self.installation_strategy = installation_strategy
-
- def run(self) -> None:
- error = None
- try:
- if self.dangerzone.isolation_provider.requires_install():
- apply_installation_strategy(
- self.installation_strategy, callback=self.process_stdout.emit
- )
- except Exception as e:
- log.error("Container installation problem")
- error = format_exception(e)
- finally:
- self.finished.emit(error)
+ ret = 2 if converting_docs else 1 if failed_docs else 0
+ # We are ignoring the close event, because we want to show progress messages in
+ # the status bar, while Dangerzone closes. Once the shutdown task is done, it
+ # will close the application.
+ e.ignore()
+ self.begin_shutdown(ret)
+ # TODO: Handle gracefully the case of a running startup thread as well.
class WaitingWidget(QtWidgets.QWidget):
- finished = QtCore.Signal()
-
- def __init__(self) -> None:
- super(WaitingWidget, self).__init__()
-
-
-class TracebackWidget(QTextEdit):
- """Reusable component to present tracebacks to the user.
-
- By default, the widget is initialized but does not appear.
- You need to call `.set_content("traceback")` on it so the
- traceback is displayed.
- """
-
def __init__(self) -> None:
- super(TracebackWidget, self).__init__()
- # Error
- self.setReadOnly(True)
- self.setVisible(False)
- self.setProperty("style", "traceback")
- # Enable copying
- self.setTextInteractionFlags(Qt.TextSelectableByMouse)
-
- self.current_output = ""
-
- def set_content(self, error: Optional[str] = None) -> None:
- if error:
- self.setPlainText(error)
- self.setVisible(True)
-
- def process_output(self, line: str) -> None:
- self.setVisible(True)
- self.current_output += line
- self.setText(self.current_output)
- cursor = self.textCursor()
- cursor.movePosition(QTextCursor.MoveOperation.End)
- self.setTextCursor(cursor)
-
-
-class WaitingWidgetContainer(WaitingWidget):
- # These are the possible states that the WaitingWidget can show.
- #
- # Windows and macOS states:
- # - "not_installed"
- # - "not_running"
- # - "install_container"
- #
- # Linux states
- # - "install_container"
-
- def _create_button(
- self, label: str, event: Callable, hide: bool = False
- ) -> QtWidgets.QWidget:
- button = QtWidgets.QPushButton(label)
- button.clicked.connect(event)
- buttons_layout = QtWidgets.QHBoxLayout()
- buttons_layout.addStretch()
- buttons_layout.addWidget(button)
- buttons_layout.addStretch()
-
- widget = QtWidgets.QWidget()
- widget.setLayout(buttons_layout)
- if hide:
- widget.hide()
- return widget
-
- def _hide_buttons(self) -> None:
- self.button_check.hide()
- self.button_cancel.hide()
-
- def __init__(self, dangerzone: DangerzoneGui) -> None:
- super(WaitingWidgetContainer, self).__init__()
- self.dangerzone = dangerzone
-
+ super().__init__()
self.label = QtWidgets.QLabel()
self.label.setAlignment(QtCore.Qt.AlignCenter)
self.label.setTextFormat(QtCore.Qt.RichText)
self.label.setOpenExternalLinks(True)
- self.label.setStyleSheet("QLabel { font-size: 20px; }")
-
- # Buttons
- self.button_check = self._create_button("Check Again", self.check_state)
- self.button_cancel = self._create_button(
- "Cancel", self.cancel_install, hide=True
- )
-
- self.traceback = TracebackWidget()
-
- # Layout
+ self.label.setWordWrap(True)
layout = QtWidgets.QVBoxLayout()
- layout.addStretch()
layout.addWidget(self.label)
- layout.addWidget(self.traceback)
- layout.addStretch()
- layout.addWidget(self.button_check)
- layout.addWidget(self.button_cancel)
- layout.addStretch()
self.setLayout(layout)
- # Check the state
- self.check_state()
-
- def check_state(self) -> None:
- state: Optional[str] = None
- error: Optional[str] = None
-
- try:
- self.dangerzone.isolation_provider.is_available()
- except errors.NoContainerTechException as e:
- log.error(str(e))
- state = "not_installed"
- except errors.NotAvailableContainerTechException as e:
- log.error(str(e))
- state = "not_running"
- error = e.error
- except Exception as e:
- log.error(str(e))
- state = "not_running"
- error = format_exception(e)
- else:
- state = "install_container"
-
- # Update the state
- self.state_change(state, error)
-
- def cancel_install(self) -> None:
- self.install_container_t.terminate()
- self.finished.emit()
-
- def show_error(self, msg: str, details: Optional[str] = None) -> None:
- self.label.setText(msg)
- show_traceback = details is not None
- if show_traceback:
- self.traceback.set_content(details)
- self.traceback.setVisible(show_traceback)
- self.button_check.show()
-
- def show_message(self, msg: str) -> None:
- self.label.setText(msg)
- self.traceback.setVisible(False)
- self._hide_buttons()
-
- def installation_finished(self, error: Optional[str] = None) -> None:
- if error:
- msg = (
- "During installation of the dangerzone image, "
- "the following error occured:"
- )
- self.show_error(msg, error)
- else:
- self.finished.emit()
-
- def state_change(self, state: str, error: Optional[str] = None) -> None:
- custom_runtime = self.dangerzone.settings.custom_runtime_specified()
-
- if state == "not_installed":
- if custom_runtime:
- self.show_error(
- "We could not find the container runtime defined in your settings
"
- "Please check your settings, install it if needed, and retry."
- )
- elif platform.system() == "Linux":
- self.show_error(
- "Dangerzone requires Podman
"
- "Install it and retry."
- )
- else:
- self.show_error(
- "Dangerzone requires Docker Desktop
"
- "Download Docker Desktop"
- ", install it, and open it."
- )
-
- elif state == "not_running":
- if custom_runtime:
- self.show_error(
- "We were unable to start the container runtime defined in your settings
"
- "Please check your settings, install it if needed, and retry."
- )
- elif platform.system() == "Linux":
- # "not_running" here means that the `podman image ls` command failed.
- self.show_error(
- "Dangerzone requires Podman
"
- "Podman is installed but cannot run properly. See errors below",
- error,
- )
- else:
- self.show_error(
- "Dangerzone requires Docker Desktop
"
- "Docker is installed but isn't running.
"
- "Open Docker and make sure it's running in the background.",
- error,
- )
- else:
- strategy = get_installation_strategy()
- if strategy == InstallationStrategy.DO_NOTHING:
- message = "Nothing to do"
- self.installation_finished()
- # FIXME we should be able to return directly here
- # but for some reason it's not working when I just
- # do self.finished.emit()
- if strategy == InstallationStrategy.INSTALL_REMOTE_CONTAINER:
- message = (
- "Downloading and upgrading the Dangerzone container image.
"
- "This might take a few minutes..."
- )
- elif strategy == InstallationStrategy.INSTALL_LOCAL_CONTAINER:
- message = (
- "Installing the Dangerzone container image.
"
- "This might take a few minutes..."
- )
- self.traceback.setVisible(True)
- self.show_message(message)
- self.button_cancel.show()
- self.button_check.hide()
-
- # TODO: Do not run the thread if we know there is nothing to install
- self.install_container_t = InstallContainerThread(self.dangerzone, strategy)
- self.install_container_t.finished.connect(self.installation_finished)
-
- self.install_container_t.process_stdout.connect(
- self.traceback.process_output
- )
- self.install_container_t.start()
+ def handle_start(self) -> None:
+ # FIXME: The following message is a placeholder, we need to find a more
+ # descriptive one.
+ self.label.setText(
+ "Oh hi there!
"
+ "First time, huh?
"
+ "Welcome! I'm afraid you gonna have to wait a bit. "
+ "Check the bottom-right corner for a progress report"
+ )
-class ContentWidget(QtWidgets.QWidget):
+class ConversionWidget(QtWidgets.QWidget):
documents_added = QtCore.Signal(list)
def __init__(self, dangerzone: DangerzoneGui) -> None:
- super(ContentWidget, self).__init__()
+ super(ConversionWidget, self).__init__()
self.dangerzone = dangerzone
self.conversion_started = False
@@ -968,7 +964,7 @@ def __init__(self, dangerzone: DangerzoneGui) -> None:
self.safe_extension.setStyleSheet("margin-left: -6px;") # no left margin
self.safe_extension.textChanged.connect(self.update_ui)
self.safe_extension_invalid = QtWidgets.QLabel("")
- self.safe_extension_invalid.setStyleSheet("color: red")
+ self.safe_extension_invalid.setProperty("style", "status-error")
self.safe_extension_invalid.hide()
self.safe_extension_name_layout = QtWidgets.QHBoxLayout()
self.safe_extension_name_layout.setSpacing(0)
@@ -998,7 +994,7 @@ def __init__(self, dangerzone: DangerzoneGui) -> None:
self.save_browse_button = QtWidgets.QPushButton("Choose...")
self.save_browse_button.clicked.connect(self.select_output_directory)
self.save_location_layout = QtWidgets.QHBoxLayout()
- self.save_location_layout.setContentsMargins(20, 0, 0, 0)
+ self.save_location_layout.setContentsMargins(0, 0, 0, 0)
self.save_location_layout.addWidget(self.save_label)
self.save_location_layout.addWidget(self.save_location)
self.save_location_layout.addWidget(self.save_browse_button)
@@ -1009,7 +1005,9 @@ def __init__(self, dangerzone: DangerzoneGui) -> None:
save_group_box = QtWidgets.QGroupBox()
save_group_box.setLayout(save_group_box_innner_layout)
save_group_box_layout = QtWidgets.QHBoxLayout()
- save_group_box_layout.setContentsMargins(20, 0, 0, 0)
+ font_metrics = self.save_label.fontMetrics()
+ m_width = font_metrics.horizontalAdvance("m")
+ save_group_box_layout.setContentsMargins(m_width, 0, 0, 0)
save_group_box_layout.addWidget(save_group_box)
self.radio_move_untrusted = QtWidgets.QRadioButton(
"Move original documents to 'unsafe' subdirectory"
@@ -1019,9 +1017,14 @@ def __init__(self, dangerzone: DangerzoneGui) -> None:
self.save_label.clicked.connect(
lambda: self.radio_save_to.setChecked(True)
) # select the radio button when label is clicked
- self.radio_save_to.setMinimumHeight(30) # make the QTextEdit fully visible
- self.radio_save_to.setLayout(self.save_location_layout)
- save_group_box_innner_layout.addWidget(self.radio_save_to)
+
+ radio_save_to_widget = QtWidgets.QWidget()
+ radio_save_to_layout = QtWidgets.QHBoxLayout()
+ radio_save_to_layout.setContentsMargins(0, 0, 0, 0)
+ radio_save_to_layout.addWidget(self.radio_save_to)
+ radio_save_to_layout.addLayout(self.save_location_layout)
+ radio_save_to_widget.setLayout(radio_save_to_layout)
+ save_group_box_innner_layout.addWidget(radio_save_to_widget)
# Open safe document
if platform.system() in ["Darwin", "Windows"]:
@@ -1074,12 +1077,16 @@ def __init__(self, dangerzone: DangerzoneGui) -> None:
layout_docs_selected.addWidget(self.change_selection_button)
layout_docs_selected.addStretch()
layout.addLayout(layout_docs_selected)
- layout.addSpacing(20)
+ font_metrics = self.docs_selected_label.fontMetrics()
+ m_width = font_metrics.horizontalAdvance("m")
+ layout.addSpacing(m_width)
layout.addLayout(self.safe_extension_layout)
layout.addLayout(save_group_box_layout)
layout.addLayout(open_layout)
layout.addLayout(ocr_layout)
- layout.addSpacing(20)
+ font_metrics = self.docs_selected_label.fontMetrics()
+ m_width = font_metrics.horizontalAdvance("m")
+ layout.addSpacing(m_width)
layout.addLayout(button_layout)
layout.addStretch()
self.setLayout(layout)
@@ -1295,6 +1302,7 @@ def __init__(self, dangerzone: DangerzoneGui) -> None:
self.dangerzone = dangerzone
self.docs_list: List[Document] = []
self.docs_list_widget_map: dict[Document, DocumentWidget] = {}
+ self.conversion_pending = False
# Initialize thread_pool only on the first conversion
# to ensure docker-daemon detection logic runs first
@@ -1318,9 +1326,17 @@ def documents_added(self, docs: List[Document]) -> None:
self.docs_list_widget_map[document] = widget
def start_conversion(self) -> None:
+ if not self.dangerzone.is_waiting_finished:
+ log.debug("Background task not finished, pending conversion")
+ self.conversion_pending = True
+ return
+
+ self.conversion_pending = False
+ log.debug("Starting conversion")
if not self.thread_pool_initized:
max_jobs = self.dangerzone.isolation_provider.get_max_parallel_conversions()
self.thread_pool = ThreadPool(max_jobs)
+ self.thread_pool_initized = True
for doc in self.docs_list:
task = ConvertTask(self.dangerzone, doc, self.get_ocr_lang())
@@ -1422,6 +1438,13 @@ def all_done(self) -> None:
if self.error:
return
+ if self.dangerzone.settings.get("successful_first_run") != get_version():
+ self.dangerzone.settings.set(
+ "successful_first_run",
+ get_version(),
+ autosave=True,
+ )
+
# Open
if self.dangerzone.settings.get("open"):
self.dangerzone.open_pdf_viewer(self.document.output_filename)
diff --git a/dangerzone/gui/shutdown.py b/dangerzone/gui/shutdown.py
new file mode 100644
index 000000000..1e4aed28a
--- /dev/null
+++ b/dangerzone/gui/shutdown.py
@@ -0,0 +1,22 @@
+from .. import shutdown
+from . import startup as gui_startup
+
+
+class MachineStopTask(
+ gui_startup.GUIMixin,
+ shutdown.MachineStopTask,
+ metaclass=gui_startup._MetaConflictResolver,
+):
+ pass
+
+
+class ContainerStopTask(
+ gui_startup.GUIMixin,
+ shutdown.ContainerStopTask,
+ metaclass=gui_startup._MetaConflictResolver,
+):
+ pass
+
+
+class ShutdownThread(shutdown.ShutdownMixin, gui_startup.RunnerThread):
+ pass
diff --git a/dangerzone/gui/startup.py b/dangerzone/gui/startup.py
new file mode 100644
index 000000000..dd6cbb655
--- /dev/null
+++ b/dangerzone/gui/startup.py
@@ -0,0 +1,142 @@
+import typing
+
+if typing.TYPE_CHECKING:
+ from PySide2 import QtCore, QtWidgets
+else:
+ try:
+ from PySide6 import QtCore, QtWidgets
+ except ImportError:
+ from PySide2 import QtCore, QtWidgets
+
+from .. import startup
+from ..updater.releases import EmptyReport, ErrorReport, ReleaseReport
+
+
+class _MetaConflictResolver(type(QtCore.QObject), type(startup.Task)): # type: ignore [misc]
+ pass
+
+
+class GUIMixin(QtCore.QObject):
+ # XXX: There's some metaclass voodoo going on, and turns out that you have to define
+ # signals at the class level, and subclasses do not share the same ones.
+ skipped = QtCore.Signal()
+ starting = QtCore.Signal()
+ failed = QtCore.Signal(str)
+ succeeded = QtCore.Signal()
+ completed = QtCore.Signal()
+
+ def __init__(self) -> None:
+ QtCore.QObject.__init__(self)
+
+ def handle_skip(self) -> None:
+ self.skipped.emit()
+ self.completed.emit()
+ super().handle_skip() # type: ignore [misc]
+
+ def handle_start(self) -> None:
+ self.starting.emit()
+ super().handle_start() # type: ignore [misc]
+
+ def handle_error(self, e: Exception) -> None:
+ self.failed.emit(str(e))
+ super().handle_error(e) # type: ignore [misc]
+
+ def handle_success(self) -> None:
+ self.succeeded.emit()
+ self.completed.emit()
+ super().handle_success() # type: ignore [misc]
+
+
+# GUI-fied basic tasks
+
+
+class PromptRequest:
+ """A request for prompting a user, with bidirectional input."""
+
+ def __init__(self) -> None:
+ self.req_data: typing.Any = None
+ self.resp_data: typing.Any = None
+ self.sem = QtCore.QSemaphore(0)
+
+ def ask(
+ self,
+ signal: QtCore.SignalInstance,
+ data: typing.Any = None,
+ ) -> typing.Any:
+ self.req_data = data
+ signal.emit(self)
+ self.sem.acquire()
+ return self.resp_data
+
+ def reply(self, data: typing.Any = None) -> None:
+ self.resp_data = data
+ self.sem.release()
+
+
+class MachineStopOthersTask(
+ GUIMixin, startup.MachineStopOthersTask, metaclass=_MetaConflictResolver
+):
+ needs_user_input = QtCore.Signal(object) # PromptRequest
+
+ def prompt_user(self, machine_name: str) -> bool:
+ return PromptRequest().ask(self.needs_user_input, data={"name": machine_name})
+
+
+class MachineInitTask(
+ GUIMixin, startup.MachineInitTask, metaclass=_MetaConflictResolver
+):
+ pass
+
+
+class MachineStartTask(
+ GUIMixin, startup.MachineStartTask, metaclass=_MetaConflictResolver
+):
+ pass
+
+
+class ContainerInstallTask(
+ GUIMixin, startup.ContainerInstallTask, metaclass=_MetaConflictResolver
+):
+ pass
+
+
+class UpdateCheckTask(
+ GUIMixin, startup.UpdateCheckTask, metaclass=_MetaConflictResolver
+):
+ needs_user_input = QtCore.Signal()
+ app_update_available = QtCore.Signal(object)
+ container_update_available = QtCore.Signal(object)
+
+ def prompt_user(self) -> None:
+ super().prompt_user()
+ self.needs_user_input.emit()
+
+ def handle_app_update(self, report: ReleaseReport) -> None:
+ self.app_update_available.emit(report)
+ super().handle_app_update(report)
+
+ def handle_container_update(self, report: ReleaseReport) -> None:
+ self.container_update_available.emit(report)
+ super().handle_container_update(report)
+
+
+class RunnerThread(startup.Runner, QtCore.QThread):
+ starting = QtCore.Signal()
+ failed = QtCore.Signal(str)
+ succeeded = QtCore.Signal()
+
+ def handle_start(self) -> None:
+ self.starting.emit()
+ super().handle_start()
+
+ def handle_error(self, task: startup.Task, e: Exception) -> None:
+ self.failed.emit(str(e))
+ super().handle_error(task, e)
+
+ def handle_success(self) -> None:
+ self.succeeded.emit()
+ super().handle_success()
+
+
+class StartupThread(startup.StartupMixin, RunnerThread):
+ pass
diff --git a/dangerzone/gui/updater.py b/dangerzone/gui/updater.py
index 1f9c80260..7b4cc9185 100644
--- a/dangerzone/gui/updater.py
+++ b/dangerzone/gui/updater.py
@@ -60,59 +60,22 @@ def create_buttons_layout(self) -> QtWidgets.QHBoxLayout:
return buttons_layout
-class UpdaterThread(QtCore.QThread):
- """Check asynchronously for Dangerzone updates.
+def prompt_for_checks(dangerzone: DangerzoneGui) -> Optional[bool]:
+ """Check for Dangerzone updates.
- The Updater class is mainly responsible for
- asking the user if they want to enable update checks or not.
-
- Since checking for updates is a task that may take some time, we perform it
- asynchronously, in a Qt thread.
-
- When finished, this thread triggers a signal with the results.
+ This function is responsible for asking the user if they want to enable
+ update checks or not, and then performing the update check.
"""
- finished = QtCore.Signal(object)
-
- def __init__(self, dangerzone: DangerzoneGui):
- super().__init__()
- self.dangerzone = dangerzone
-
- @property
- def check(self) -> Optional[bool]:
- return self.dangerzone.settings.get("updater_check")
-
- @check.setter
- def check(self, val: Optional[bool]) -> None:
- self.dangerzone.settings.set("updater_check", val, autosave=True)
-
- def prompt_for_checks(self) -> Optional[bool]:
- """Ask the user if they want to be informed about Dangerzone updates."""
- log.debug("Prompting the user for update checks")
- prompt = UpdateCheckPrompt(
- self.dangerzone,
- message=MSG_CONFIRM_UPDATE_CHECKS,
- ok_text=OK_TEXT,
- cancel_text=CANCEL_TEXT,
- )
- check = prompt.launch()
- if not check and prompt.x_pressed:
- return None
+ log.debug("Prompting the user for update checks")
+ prompt = UpdateCheckPrompt(
+ dangerzone,
+ message=MSG_CONFIRM_UPDATE_CHECKS,
+ ok_text=OK_TEXT,
+ cancel_text=CANCEL_TEXT,
+ )
+ check = prompt.launch()
+ if check is not None and not prompt.x_pressed:
return bool(check)
-
- def should_check_for_updates(self) -> bool:
- try:
- should_check: Optional[bool] = releases.should_check_for_updates(
- self.dangerzone.settings
- )
- except errors.NeedUserInput:
- should_check = self.prompt_for_checks()
- if should_check is not None:
- self.dangerzone.settings.set(
- "updater_check_all", should_check, autosave=True
- )
- return bool(should_check)
-
- def run(self) -> None:
- has_updates = releases.check_for_updates(self.dangerzone.settings)
- self.finished.emit(has_updates)
+ else:
+ return None
diff --git a/dangerzone/gui/widgets.py b/dangerzone/gui/widgets.py
new file mode 100644
index 000000000..5cef6fcf8
--- /dev/null
+++ b/dangerzone/gui/widgets.py
@@ -0,0 +1,43 @@
+import typing
+from typing import Optional
+
+if typing.TYPE_CHECKING:
+ from PySide2.QtCore import Qt
+ from PySide2.QtGui import QTextCursor
+ from PySide2.QtWidgets import QTextEdit
+else:
+ try:
+ from PySide6.QtCore import Qt
+ from PySide6.QtGui import QTextCursor
+ from PySide6.QtWidgets import QTextEdit
+ except ImportError:
+ from PySide2.QtCore import Qt
+ from PySide2.QtGui import QTextCursor
+ from PySide2.QtWidgets import QTextEdit
+
+
+class TracebackWidget(QTextEdit):
+ """Reusable component to present tracebacks to the user.
+
+ By default, the widget is initialized but does not appear. You need to call
+ `.process_output(msg)` on it so the traceback is displayed.
+ """
+
+ def __init__(self) -> None:
+ super(TracebackWidget, self).__init__()
+ # Error
+ self.setReadOnly(True)
+ self.setVisible(False)
+ self.setProperty("style", "traceback")
+ # Enable copying
+ self.setTextInteractionFlags(Qt.TextSelectableByMouse)
+
+ self.current_output = ""
+
+ def process_output(self, line: str) -> None:
+ self.setVisible(True)
+ self.current_output += line
+ self.setText(self.current_output)
+ cursor = self.textCursor()
+ cursor.movePosition(QTextCursor.MoveOperation.End)
+ self.setTextCursor(cursor)
diff --git a/dangerzone/isolation_provider/base.py b/dangerzone/isolation_provider/base.py
index b0121d9e0..ff8c593a3 100644
--- a/dangerzone/isolation_provider/base.py
+++ b/dangerzone/isolation_provider/base.py
@@ -256,11 +256,6 @@ def requires_install(self) -> bool:
"""Whether this isolation provider needs an installation step"""
pass
- @abstractmethod
- def is_available(self) -> bool:
- """Whether the backing implementation of the isolation provider is available."""
- pass
-
@abstractmethod
def get_max_parallel_conversions(self) -> int:
pass
diff --git a/dangerzone/isolation_provider/container.py b/dangerzone/isolation_provider/container.py
index c4b1f76d0..4be6a77eb 100644
--- a/dangerzone/isolation_provider/container.py
+++ b/dangerzone/isolation_provider/container.py
@@ -7,7 +7,7 @@
from typing import Callable, List, Optional, Tuple
from .. import container_utils, errors
-from ..container_utils import Runtime, make_seccomp_json_accessible, subprocess_run
+from ..container_utils import make_seccomp_json_accessible, subprocess_run
from ..document import Document
from ..settings import Settings
from ..updater import (
@@ -23,7 +23,6 @@
)
from .base import IsolationProvider, terminate_process_group
-TIMEOUT_KILL = 5 # Timeout in seconds until the kill command returns.
MINIMUM_DOCKER_DESKTOP = {
"Darwin": "4.43.1",
"Windows": "4.43.1",
@@ -62,21 +61,10 @@ def get_runtime_security_args() -> List[str]:
* Do not map the host user to the container, with `--userns nomap` (available
from Podman 4.1 onwards)
"""
- runtime = Runtime()
- if runtime.name == "podman":
- security_args = ["--log-driver", "none"]
- security_args += ["--security-opt", "no-new-privileges"]
- if container_utils.get_runtime_version() >= (4, 1):
- # We perform a platform check to avoid the following Podman Desktop
- # error on Windows:
- #
- # Error: nomap is only supported in rootless mode
- #
- # See also: https://github.com/freedomofpress/dangerzone/issues/1127
- if platform.system() != "Windows":
- security_args += ["--userns", "nomap"]
- else:
- security_args = ["--security-opt=no-new-privileges:true"]
+ security_args = ["--log-driver", "none"]
+ security_args += ["--security-opt", "no-new-privileges"]
+ if container_utils.get_runtime_version() >= (4, 1):
+ security_args += ["--userns", "nomap"]
# We specify a custom seccomp policy uniformly, because on certain container
# engines the default policy might not allow the `ptrace(2)` syscall [1]. Our
@@ -84,7 +72,7 @@ def get_runtime_security_args() -> List[str]:
#
# [1] https://github.com/freedomofpress/dangerzone/issues/846
# [2] https://github.com/containers/common/blob/d3283f8401eeeb21f3c59a425b5461f069e199a7/pkg/seccomp/seccomp.json
- seccomp_json_path = make_seccomp_json_accessible(runtime)
+ seccomp_json_path = make_seccomp_json_accessible()
security_args += ["--security-opt", f"seccomp={seccomp_json_path}"]
security_args += ["--cap-drop", "all"]
@@ -100,82 +88,19 @@ def get_runtime_security_args() -> List[str]:
def requires_install() -> bool:
return True
- @staticmethod
- def is_available() -> bool:
- runtime = Runtime()
-
- # Can we run `docker/podman image ls` without an error
- with subprocess.Popen(
- [str(runtime.path), "image", "ls"],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.PIPE,
- startupinfo=get_subprocess_startupinfo(),
- ) as p:
- _, stderr = p.communicate()
- if p.returncode != 0:
- raise errors.NotAvailableContainerTechException(
- runtime.name, stderr.decode()
- )
- return True
-
- def check_docker_desktop_version(self) -> Tuple[bool, str]:
- # On windows and darwin, check that the minimum version is met
- version = ""
- runtime = Runtime()
- runtime_is_docker = runtime.name == "docker"
- platform_is_not_linux = platform.system() != "Linux"
-
- if runtime_is_docker and platform_is_not_linux:
- with subprocess.Popen(
- ["docker", "version", "--format", "{{.Server.Platform.Name}}"],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- startupinfo=get_subprocess_startupinfo(),
- ) as p:
- stdout, stderr = p.communicate()
- if p.returncode != 0:
- # When an error occurs, consider that the check went
- # through, as we're checking for installation compatibiliy
- # somewhere else already
- return True, version
- # The output is like "Docker Desktop 4.35.1 (173168)"
- version = stdout.decode().replace("Docker Desktop", "").split()[0]
- if version < MINIMUM_DOCKER_DESKTOP[platform.system()]:
- return False, version
- return True, version
-
def doc_to_pixels_container_name(self, document: Document) -> str:
"""Unique container name for the doc-to-pixels phase."""
- return f"dangerzone-doc-to-pixels-{document.id}"
+ return f"{container_utils.CONTAINER_PREFIX}doc-to-pixels-{document.id}"
def pixels_to_pdf_container_name(self, document: Document) -> str:
"""Unique container name for the pixels-to-pdf phase."""
- return f"dangerzone-pixels-to-pdf-{document.id}"
-
- def exec(
- self,
- args: List[str],
- ) -> subprocess.Popen:
- args_str = " ".join(shlex.quote(s) for s in args)
- log.info("> " + args_str)
-
- return subprocess.Popen(
- args,
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=self.proc_stderr,
- startupinfo=startupinfo,
- # Start the conversion process in a new session, so that we can later on
- # kill the process group, without killing the controlling script.
- start_new_session=True,
- )
+ return f"{container_utils.CONTAINER_PREFIX}pixels-to-pdf-{document.id}"
def exec_container(
self,
command: List[str],
name: str,
) -> subprocess.Popen:
- runtime = Runtime()
container_name = container_utils.expected_image_name()
image_digest = container_utils.get_local_image_digest()
if not bypass_signature_checks():
@@ -199,43 +124,19 @@ def exec_container(
+ image_name
+ command
)
- return self.exec([str(runtime.path)] + args)
-
- def kill_container(self, name: str) -> None:
- """Terminate a spawned container.
-
- We choose to terminate spawned containers using the `kill` action that the
- container runtime provides, instead of terminating the process that spawned
- them. The reason is that this process is not always tied to the underlying
- container. For instance, in Docker containers, this process is actually
- connected to the Docker daemon, and killing it will just close the associated
- standard streams.
- """
- runtime = Runtime()
- cmd = [str(runtime.path), "kill", name]
- try:
- # We do not check the exit code of the process here, since the container may
- # have stopped right before invoking this command. In that case, the
- # command's output will contain some error messages, so we capture them in
- # order to silence them.
- #
- # NOTE: We specify a timeout for this command, since we've seen it hang
- # indefinitely for specific files. See:
- # https://github.com/freedomofpress/dangerzone/issues/854
- subprocess_run(
- cmd,
- capture_output=True,
- startupinfo=get_subprocess_startupinfo(),
- timeout=TIMEOUT_KILL,
- )
- except subprocess.TimeoutExpired:
- log.warning(
- f"Could not kill container '{name}' within {TIMEOUT_KILL} seconds"
- )
- except Exception as e:
- log.exception(
- f"Unexpected error occurred while killing container '{name}': {str(e)}"
- )
+ podman = container_utils.init_podman_command()
+ proc = podman.run(
+ args,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=self.proc_stderr,
+ # Start the conversion process in a new session, so that we can later on
+ # kill the process group, without killing the controlling script.
+ start_new_session=True,
+ wait=False,
+ )
+ assert isinstance(proc, subprocess.Popen)
+ return proc
def start_doc_to_pixels_proc(self, document: Document) -> subprocess.Popen:
# Convert document to pixels
@@ -254,8 +155,15 @@ def terminate_doc_to_pixels_proc(
# 1. Kill the container, and check that it has exited.
# 2. Gracefully terminate the conversion process, in case it's stuck on I/O
#
+ # We choose to terminate spawned containers using first the `kill` action that
+ # the container runtime provides, instead of terminating the process that
+ # spawned them. The reason is that this process is not always tied to the
+ # underlying container. For instance, in Docker containers, this process is
+ # actually connected to the Docker daemon, and killing it will just close the
+ # associated standard streams.
+ #
# See also https://github.com/freedomofpress/dangerzone/issues/791
- self.kill_container(self.doc_to_pixels_container_name(document))
+ container_utils.kill_container(self.doc_to_pixels_container_name(document))
terminate_process_group(p)
def ensure_stop_doc_to_pixels_proc( # type: ignore [no-untyped-def]
@@ -268,37 +176,14 @@ def ensure_stop_doc_to_pixels_proc( # type: ignore [no-untyped-def]
# after a podman kill / docker kill invocation, this will likely be the case,
# else the container runtime (Docker/Podman) has experienced a problem, and we
# should report it.
- runtime = Runtime()
+ podman = container_utils.init_podman_command()
name = self.doc_to_pixels_container_name(document)
- all_containers = subprocess_run(
- [str(runtime.path), "ps", "-a"],
- capture_output=True,
- startupinfo=get_subprocess_startupinfo(),
- )
- if name in all_containers.stdout.decode(): # type:ignore[attr-defined]
+ all_containers = podman.run(["ps", "-a"])
+ assert isinstance(all_containers, str)
+ if name in all_containers:
log.warning(f"Container '{name}' did not stop gracefully")
def get_max_parallel_conversions(self) -> int:
# FIXME hardcoded 1 until length conversions are better handled
# https://github.com/freedomofpress/dangerzone/issues/257
return 1
- runtime = Runtime() # type: ignore [unreachable]
-
- n_cpu = 1
- if platform.system() == "Linux":
- # if on linux containers run natively
- cpu_count = os.cpu_count()
- if cpu_count is not None:
- n_cpu = cpu_count
-
- elif runtime.name == "docker":
- # For Windows and MacOS containers run in VM
- # So we obtain the CPU count for the VM
- n_cpu_str = subprocess.check_output(
- [str(runtime.path), "info", "--format", "{{.NCPU}}"],
- text=True,
- startupinfo=get_subprocess_startupinfo(),
- )
- n_cpu = int(n_cpu_str.strip())
-
- return 2 * n_cpu + 1
diff --git a/dangerzone/isolation_provider/dummy.py b/dangerzone/isolation_provider/dummy.py
index a913698dd..dd884c2d2 100644
--- a/dangerzone/isolation_provider/dummy.py
+++ b/dangerzone/isolation_provider/dummy.py
@@ -37,10 +37,6 @@ def __init__(self) -> None:
)
super().__init__()
- @staticmethod
- def is_available() -> bool:
- return True
-
@staticmethod
def requires_install() -> bool:
return False
diff --git a/dangerzone/isolation_provider/qubes.py b/dangerzone/isolation_provider/qubes.py
index 0b53e0f7d..e5452a73d 100644
--- a/dangerzone/isolation_provider/qubes.py
+++ b/dangerzone/isolation_provider/qubes.py
@@ -18,10 +18,6 @@
class Qubes(IsolationProvider):
"""Uses a disposable qube for performing the conversion"""
- @staticmethod
- def is_available() -> bool:
- return True
-
@staticmethod
def requires_install() -> bool:
return False
diff --git a/dangerzone/podman/__init__.py b/dangerzone/podman/__init__.py
new file mode 100644
index 000000000..d3fd78c7e
--- /dev/null
+++ b/dangerzone/podman/__init__.py
@@ -0,0 +1,2 @@
+from .command import PodmanCommand
+from .machine import PodmanMachineManager
diff --git a/dangerzone/podman/cli.py b/dangerzone/podman/cli.py
new file mode 100755
index 000000000..2198bbb1e
--- /dev/null
+++ b/dangerzone/podman/cli.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import logging
+import os
+import sys
+
+import click
+
+from .errors import PodmanError
+from .machine import PodmanMachineManager
+
+logger = logging.getLogger(__name__)
+
+
+@click.group()
+@click.option(
+ "--log-level",
+ default="info",
+ type=click.Choice(["debug", "info", "warning", "error", "critical"]),
+ help="Set the logging level.",
+)
+def main(log_level: str) -> None:
+ """Manage Dangerzone Podman machines."""
+ logging.basicConfig(level=getattr(logging, log_level.upper()), stream=sys.stderr)
+
+
+@main.command()
+def list() -> None:
+ """List Dangerzone Podman machines."""
+ try:
+ manager = PodmanMachineManager()
+ machines = manager.list()
+ if machines:
+ for machine in machines:
+ running_status = "Running" if machine.get("Running") else "Stopped"
+ click.echo(f"Name: {machine.get('Name')}, Status: {running_status}")
+ else:
+ click.echo("No Dangerzone Podman machines found.")
+ except PodmanError as e:
+ click.echo(f"❌ {e}")
+ raise click.Abort()
+
+
+@main.command()
+@click.option("--cpus", type=int, help="Number of CPUs to allocate.")
+@click.option("--memory", type=int, help="Amount of memory in bytes.")
+@click.option(
+ "--timezone", type=str, default="Etc/UTC", help="Timezone for the machine."
+)
+def init(cpus: int, memory: int, timezone: str) -> None:
+ """Initialize a Dangerzone Podman machine."""
+ try:
+ manager = PodmanMachineManager()
+ manager.init(cpus=cpus, memory=memory, timezone=timezone)
+ click.echo(f"Machine initialized: {manager.name}")
+ except PodmanError as e:
+ click.echo(f"❌ {e}")
+ raise click.Abort()
+
+
+@main.command()
+def start() -> None:
+ """Start the Dangerzone Podman machine."""
+ try:
+ manager = PodmanMachineManager()
+ manager.start()
+ click.echo(f"Machine started: {manager.name}")
+ except PodmanError as e:
+ click.echo(f"❌ {e}")
+ raise click.Abort()
+
+
+@main.command()
+def stop() -> None:
+ """Stop the Dangerzone Podman machine."""
+ try:
+ manager = PodmanMachineManager()
+ manager.stop()
+ click.echo(f"Machine stopped: {manager.name}")
+ except PodmanError as e:
+ click.echo(f"❌ {e}")
+ raise click.Abort()
+
+
+@main.command()
+@click.option("-f", "--force", is_flag=True, help="Force removal without prompt.")
+def remove(force: bool) -> None:
+ """Remove the Dangerzone Podman machine."""
+ try:
+ manager = PodmanMachineManager()
+ if not force:
+ click.confirm(
+ f"Are you sure you want to remove machine '{manager.name}'?",
+ abort=True,
+ )
+ manager.remove()
+ click.echo(f"Machine removed: {manager.name}")
+ except PodmanError as e:
+ click.echo(f"❌ {e}")
+ raise click.Abort()
+
+
+@main.command()
+@click.option("-f", "--force", is_flag=True, help="Force reset without prompt.")
+def reset(force: bool) -> None:
+ """Reset all Podman machines."""
+ try:
+ if not force:
+ click.confirm(
+ "Are you sure you want to reset all Podman machines? This is a destructive action.",
+ abort=True,
+ )
+ manager = PodmanMachineManager()
+ manager.reset()
+ click.echo("Podman machines reset.")
+ except PodmanError as e:
+ click.echo(f"❌ {e}")
+ raise click.Abort()
+
+
+@main.command(context_settings=dict(ignore_unknown_options=True, allow_extra_args=True))
+@click.pass_context
+def raw(ctx) -> None: # type: ignore [no-untyped-def]
+ """Run a raw Podman command."""
+ try:
+ manager = PodmanMachineManager()
+ output = manager.run_raw_podman_command(ctx.args)
+ except PodmanError as e:
+ click.echo(f"❌ {e}")
+ raise click.Abort()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dangerzone/podman/command/__init__.py b/dangerzone/podman/command/__init__.py
new file mode 100644
index 000000000..5086d550b
--- /dev/null
+++ b/dangerzone/podman/command/__init__.py
@@ -0,0 +1,9 @@
+# TODO: Consider replacing this module with an official package, once this code gets
+# merged to `containers/podman-py`.
+#
+# See https://github.com/freedomofpress/dangerzone/issues/1227
+
+from .cli_runner import GlobalOptions
+from .command import PodmanCommand
+
+__all__ = ["PodmanCommand", "GlobalOptions"]
diff --git a/dangerzone/podman/command/cli_runner.py b/dangerzone/podman/command/cli_runner.py
new file mode 100644
index 000000000..237cbdcb2
--- /dev/null
+++ b/dangerzone/podman/command/cli_runner.py
@@ -0,0 +1,269 @@
+import dataclasses
+import logging
+import os
+import platform
+import shlex
+import shutil
+import subprocess
+from pathlib import Path
+from typing import Optional, Union
+
+from .. import errors
+
+logger = logging.getLogger("podman.command.cli_runner")
+
+
+@dataclasses.dataclass
+class GlobalOptions:
+ """Global options for Podman commands.
+
+ Attributes:
+ cdi_spec_dir (Union[str, Path, list[str], list[Path], None]): The CDI spec directory path (can be a list of paths).
+ cgroup_manager: CGroup manager to use.
+ config: Location of config file, mainly for Docker compatibility.
+ conmon: Path to the conmon binary.
+ connection: Connection to use for remote Podman.
+ events_backend: Backend to use for storing events.
+ hooks_dir: Directory for hooks (can be a list of directories).
+ identity: Path to SSH identity file.
+ imagestore: Path to the image store.
+ log_level: Logging level.
+ module: Load a containers.conf module.
+ network_cmd_path: Path to slirp4netns command.
+ network_config_dir: Path to network config directory.
+ remote: When true, access to the Podman service is remote.
+ root: Storage root dir in which data, including images, is stored
+ runroot: Storage state directory where all state information is stored
+ runtime: Name or path of the OCI runtime.
+ runtime_flag: Global flags for the container runtime
+ ssh: Change SSH mode.
+ storage_driver: Storage driver to use.
+ storage_opt: Storage options.
+ syslog: Output logging information to syslog as well as the console.
+ tmpdir: Path to the tmp directory, for libpod runtime content.
+ transient_store: Whether to use a transient store.
+ url: URL for Podman service.
+ volumepath: Volume directory where builtin volume information is stored
+ """
+
+ cdi_spec_dir: Union[str, Path, list[str], list[Path], None] = None
+ cgroup_manager: Union[str, None] = None
+ config: Union[str, Path, None] = None
+ conmon: Union[str, Path, None] = None
+ connection: Union[str, None] = None
+ events_backend: Union[str, None] = None
+ hooks_dir: Union[str, Path, list[str], list[Path], None] = None
+ identity: Union[str, Path, None] = None
+ imagestore: Union[str, None] = None
+ log_level: Union[str, None] = None
+ module: Union[str, None] = None
+ network_cmd_path: Union[str, Path, None] = None
+ network_config_dir: Union[str, Path, None] = None
+ remote: Union[bool, None] = None
+ root: Union[str, Path, None] = None
+ runroot: Union[str, Path, None] = None
+ runtime: Union[str, Path, None] = None
+ runtime_flag: Union[str, list[str], None] = None
+ ssh: Union[str, None] = None
+ storage_driver: Union[str, None] = None
+ storage_opt: Union[str, list[str], None] = None
+ syslog: Union[bool, None] = None
+ tmpdir: Union[str, Path, None] = None
+ transient_store: Union[bool, None] = False
+ url: Union[str, None] = None
+ volumepath: Union[str, Path, None] = None
+
+
+def get_subprocess_startupinfo():
+ if platform.system() == "Windows":
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ return startupinfo
+ else:
+ return None
+
+
+class Runner:
+ """Runner class to execute Podman commands.
+
+ Attributes:
+ podman_path (Path): Path to the Podman executable.
+ privileged (bool): Whether to run commands with elevated privileges.
+ options (GlobalOptions): Global options for Podman commands.
+ env (dict): Environment variables for the subprocess.
+ """
+
+ def __init__(
+ self,
+ path: Optional[Path] = None,
+ privileged: bool = False,
+ options: Optional[GlobalOptions] = None,
+ env: Optional[dict] = None,
+ ):
+ """Initialize the Runner.
+
+ Args:
+ path (Path, optional): Path to the Podman executable. Defaults to the system path.
+ privileged (bool, optional): Whether to run commands with elevated privileges. Defaults to False.
+ options (GlobalOptions, optional): Global options for Podman commands. Defaults to None.
+ env (dict, optional): Environment variables for the subprocess. Defaults to None.
+
+ Raises:
+ errors.PodmanNotInstalled: If Podman is not installed.
+ """
+ if path is None:
+ path = shutil.which("podman")
+ if path is None:
+ raise errors.PodmanNotInstalled()
+ path = Path(path)
+
+ self.podman_path = path
+ if privileged and platform.system() == "Windows":
+ raise errors.PodmanError("Cannot run privileged Podman command on Windows")
+ self.privileged = privileged
+ self.options = options
+ self.env = env
+
+ def display(self, cmd):
+ """Display a list of command-line options as a single command invocation."""
+ parts = [str(part) for part in cmd]
+ return shlex.join(parts)
+
+ def format_cli_opts(self, *args, **kwargs) -> list[str]:
+ """Format Pythonic arguments into command-line options for the Podman command.
+
+ Args:
+ *args: Positional arguments to format.
+ **kwargs: Keyword arguments to format.
+
+ Returns:
+ list[str]: A list of formatted command-line options.
+ """
+ cmd = []
+ # Positional arguments (*args) are added as is, provided that they are
+ # defined.
+ for arg in args:
+ if arg is not None:
+ cmd.append(arg)
+
+ for arg, value in kwargs.items():
+ option_name = "--" + arg.replace("_", "-")
+ if value is True:
+ # Options like cli_flag=True get converted to ["--cli-flag"].
+ cmd.append(option_name)
+ elif isinstance(value, list):
+ # Options like cli_flag=["foo", "bar"] get converted to
+ # ["--cli-flag", "foo", "--cli-flag", "bar"].
+ for v in value:
+ cmd += [option_name, str(v)]
+ elif value is not None and value is not False:
+ # Options like cli_flag="foo" get converted to
+ # ["--cli-flag", "foo"].
+ cmd += [option_name, str(value)]
+ return cmd
+
+ def construct(self, *args, **kwargs) -> list[str]:
+ """Construct the full command to run.
+
+ Construct the base Podman command, along with the global CLI options.
+ Then, format the Pythonic arguments for the Podman command
+ (*args/**kwargs) and append them to the final command.
+
+ Args:
+ *args: Positional arguments for the command.
+ **kwargs: Keyword arguments for the command.
+
+ Returns:
+ list[str]: The constructed command as a list of strings.
+ """
+ cmd = []
+ if self.privileged:
+ cmd.append("sudo")
+
+ cmd.append(str(self.podman_path))
+
+ if self.options:
+ cmd += self.format_cli_opts(**dataclasses.asdict(self.options))
+
+ cmd += self.format_cli_opts(*args, **kwargs)
+ return cmd
+
+ def run(
+ self,
+ cmd: list[str],
+ *,
+ check: bool = True,
+ capture_output=True,
+ wait=True,
+ **skwargs,
+ ) -> Union[str, subprocess.Popen, None]:
+ """Run the specified Podman command.
+
+ Args:
+ cmd (list[str]): The command to run, as a list of strings.
+ check (bool, optional): Whether to check for errors. Defaults to True.
+ capture_output (bool, optional): Whether to capture output. Defaults to True.
+ wait (bool, optional): Whether to wait for the command to complete. Defaults to True.
+ **skwargs: Additional keyword arguments for subprocess.
+
+ Returns:
+ Optional[str]: The output of the command if captured, otherwise the
+ subprocess.Popen instance.
+
+ Raises:
+ errors.CommandError: If the command fails.
+ """
+ cmd = self.construct() + cmd
+ return self.run_raw(
+ cmd, check=check, capture_output=capture_output, wait=wait, **skwargs
+ )
+
+ def run_raw(
+ self,
+ cmd: list[str],
+ *,
+ check: bool = True,
+ capture_output=True,
+ stdin=subprocess.DEVNULL,
+ wait=True,
+ **skwargs,
+ ) -> Union[str, subprocess.Popen, None]:
+ """Run the command without additional construction. Mostly for internal use.
+
+ Args:
+ cmd (list[str]): The full command to run.
+ check (bool, optional): Whether to check for errors. Defaults to True.
+ capture_output (bool, optional): Whether to capture output. Defaults to True.
+ stdin: Control the process' stdin. Disabled by default, to avoid hanging commands.
+ wait (bool, optional): Whether to wait for the command to complete. Defaults to True.
+ **skwargs: Additional keyword arguments for subprocess.
+
+ Returns:
+ Optional[str]: The output of the command if captured, otherwise the
+ subprocess.Popen instance.
+
+ Raises:
+ errors.CommandError: If the command fails.
+ """
+ skwargs.setdefault("startupinfo", get_subprocess_startupinfo())
+ if not wait:
+ skwargs.setdefault("stdin", stdin)
+ return subprocess.Popen(
+ cmd,
+ env=self.env,
+ **skwargs,
+ )
+
+ try:
+ skwargs.setdefault("check", check)
+ skwargs.setdefault("capture_output", capture_output)
+ skwargs.setdefault("stdin", stdin)
+ ret = subprocess.run(
+ cmd,
+ env=self.env,
+ **skwargs,
+ )
+ except subprocess.CalledProcessError as e:
+ raise errors.CommandError(e) from e
+ if capture_output:
+ return ret.stdout.decode().rstrip()
diff --git a/dangerzone/podman/command/command.py b/dangerzone/podman/command/command.py
new file mode 100644
index 000000000..8412d7ddb
--- /dev/null
+++ b/dangerzone/podman/command/command.py
@@ -0,0 +1,225 @@
+import contextlib
+import platform
+import subprocess
+import time
+from pathlib import Path
+from typing import Optional, Union
+
+# FIXME: We are commenting out the following import, because it requires the Python
+# Podman client, which Dangerzone does not have as a dependency yet.
+#
+# The side-effect of this change is that we can't start the Podman REST API service, but
+# we don't need this feature yet.
+#
+# from podman import client
+from .. import errors
+from . import cli_runner, machine_manager
+
+
+class PodmanCommand:
+ """Main class for executing Podman commands.
+
+ Attributes:
+ runner (cli_runner.Runner): The runner instance to execute commands.
+ machine (machine_manager.MachineManager): Manager for machine operations.
+ """
+
+ GlobalOptions = cli_runner.GlobalOptions
+
+ def __init__(
+ self,
+ path: Optional[Path] = None,
+ privileged: bool = False,
+ options: Optional[cli_runner.GlobalOptions] = None,
+ env: Optional[dict] = None,
+ ):
+ """Initialize the PodmanCommand.
+
+ Args:
+ path (Path, optional): Path to the Podman executable. Defaults to None.
+ privileged (bool, optional): Whether to run commands with elevated privileges. Defaults to False.
+ options (cli_runner.GlobalOptions, optional): Global options for Podman commands. Defaults to a new instance of GlobalOptions.
+ env (dict, optional): Environment variables for the subprocess. Defaults to None.
+ """
+ if options is None:
+ options = cli_runner.GlobalOptions()
+ self.runner = cli_runner.Runner(
+ path=path,
+ privileged=privileged,
+ options=options,
+ env=env,
+ )
+ self.machine = machine_manager.MachineManager(self.runner)
+ self.proc_service: Optional[subprocess.Popen] = None
+
+ def run(
+ self,
+ cmd: list[str],
+ *,
+ check: bool = True,
+ capture_output=True,
+ wait=True,
+ **skwargs,
+ ) -> Union[str, subprocess.Popen]:
+ """Run the specified Podman command.
+
+ Args:
+ cmd (list[str]): The command to run, as a list of strings.
+ check (bool, optional): Whether to check for errors. Defaults to True.
+ capture_output (bool, optional): Whether to capture output. Defaults to True.
+ wait (bool, optional): Whether to wait for the command to complete. Defaults to True.
+ **skwargs: Additional keyword arguments for subprocess.
+
+ Returns:
+ Optional[str]: The output of the command if captured, otherwise the
+ subprocess.Popen instance.
+
+ Raises:
+ errors.CommandError: If the command fails.
+ """
+ return self.runner.run(
+ cmd=cmd, check=check, capture_output=capture_output, wait=wait, **skwargs
+ )
+
+ @property
+ def options(self) -> cli_runner.GlobalOptions:
+ """Returns the global options for this Podman command instance."""
+ return self.runner.options
+
+ def start_service(
+ self,
+ uri: Optional[str] = None,
+ time: Optional[int] = None,
+ cors: Optional[str] = None,
+ **skwargs,
+ ) -> None:
+ """Start the Podman system service.
+
+ This method starts a REST API using Podman's `system service` command.
+ This method is available only on Linux systems.
+
+ Args:
+ uri (str, optional): The URI for the service. Uses the default URI if not specified.
+ time (str, optional): How long should the service be up. Default is 5 seconds.
+ cors (str, optional): CORS settings for the service.
+ **skwargs: Additional keyword arguments for subprocess.
+
+ Returns:
+ subprocess.Popen: The process handle of the `podman system service`
+ command.
+ """
+ if platform.system() != "Linux":
+ raise errors.PodmanError(
+ "The `podman system service` command is available only on Linux systems"
+ )
+
+ cmd = self.runner.construct("system", "service", uri, time=time, cors=cors)
+ proc = self.runner.run_raw(cmd, wait=False, **skwargs)
+ assert isinstance(proc, subprocess.Popen)
+ self.proc_service = proc
+
+ def stop_service(
+ self,
+ timeout: Optional[int] = None,
+ ) -> int:
+ """Stop the Podman system service.
+
+ This method stops the Podman REST API service.
+
+ Args:
+ proc (subprocess.Popen): The process handle for Podman's system service.
+ timeout (int, optional): How long to wait until the service stops.
+
+ Returns:
+ int: The exit code of the service process.
+ """
+ if self.proc_service is None:
+ raise errors.PodmanError(
+ "The Podman service has not started yet, so there's nothing to stop"
+ )
+
+ self.proc_service.terminate()
+ try:
+ ret = self.proc_service.wait(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ self.proc_service.kill()
+ ret = self.proc_service.wait()
+ self.proc_service = None
+ return ret
+
+ def wait_for_service(
+ self,
+ uri: str,
+ timeout: Optional[int] = None,
+ check_interval: float = 0.1,
+ ):
+ """Wait for the Podman system service to be operational.
+
+ This method checks two things; if the system service is still running,
+ and if we can ping it successfully.
+
+ Args:
+ uri (str): The URI for the service.
+ proc (subprocess.Popen): The process handle for Podman's system service.
+ timeout (int, optional): How long to wait until the service is operational
+ check_interval (float): The interval between health checks
+
+ Returns:
+ int: The exit code of the service process.
+ """
+ if self.proc_service is None:
+ raise errors.PodmanError(
+ "The Podman service has not started yet, so there's nothing to wait"
+ )
+
+ start = time.monotonic()
+ with client.PodmanClient(base_url=uri) as c:
+ while True:
+ if timeout and time.monotonic() - start > timeout:
+ raise errors.ServiceTimeout(timeout)
+
+ ret = self.proc_service.poll()
+ if ret is not None:
+ raise errors.ServiceTerminated(ret)
+
+ try:
+ if c.ping():
+ break
+ except errors.APIError:
+ pass
+ time.sleep(check_interval)
+
+ @contextlib.contextmanager
+ def service(
+ self,
+ uri: str,
+ cors: Optional[str] = None,
+ ping_timeout: Optional[int] = None,
+ stop_timeout: Optional[int] = None,
+ **skwargs,
+ ):
+ """Manage the Podman system service.
+
+ This method starts a REST API using Podman's `system service` command
+ and yields the process back to the user. Once the user does not need
+ the REST API any more, it stops the Podman service.
+
+ Args:
+ uri (str): The URI for the service.
+ cors (str, optional): CORS settings for the service.
+ ping_timeout (int, optional): How long to wait until the service is up.
+ stop_timeout (int, optional): How long to wait until the service stops.
+ **skwargs: Additional keyword arguments for subprocess.
+
+ Returns:
+ subprocess.Popen: The process handle of the `podman system service` command.
+ """
+ self.start_service(uri=uri, time=0, cors=cors, **skwargs)
+ try:
+ self.wait_for_service(uri, timeout=ping_timeout)
+ except (errors.ServiceTimeout, errors.ServiceTerminated):
+ self.stop_service(timeout=stop_timeout)
+ raise
+
+ yield
+ self.stop_service(timeout=stop_timeout)
diff --git a/dangerzone/podman/command/machine_manager.py b/dangerzone/podman/command/machine_manager.py
new file mode 100644
index 000000000..41a465f6f
--- /dev/null
+++ b/dangerzone/podman/command/machine_manager.py
@@ -0,0 +1,147 @@
+import builtins
+import json
+import subprocess
+from pathlib import Path
+from typing import Optional, Union
+
+from . import cli_runner
+
+
+class MachineManager:
+ """Manager for handling Podman machine operations.
+
+ Attributes:
+ runner (cli_runner.Runner): The runner instance to execute commands.
+ """
+
+ def __init__(self, runner: cli_runner.Runner):
+ """Initialize the MachineManager.
+
+ Args:
+ runner (cli_runner.Runner): The runner instance to execute commands.
+ """
+ self.runner = runner
+
+ def list(self, all_providers: bool = False, **skwargs) -> dict:
+ """List all machines.
+
+ Args:
+ all_providers (bool, optional): Whether to include all providers. Defaults to False.
+ **skwargs: Additional keyword arguments for subprocess.
+
+ Returns:
+ dict: A dictionary containing the list of machines in JSON format.
+ """
+ cmd = self.runner.construct(
+ "machine", "list", format="json", all_providers=all_providers, **skwargs
+ )
+ return json.loads(self.runner.run_raw(cmd))
+
+ def init(
+ self,
+ name: Optional[str] = None,
+ cpus: Optional[int] = None,
+ disk_size: Optional[int] = None,
+ ignition_path: Optional[Path] = None,
+ image: Union[str, Path, None] = None,
+ memory: Optional[int] = None,
+ now: bool = False,
+ playbook: Optional[str] = None,
+ rootful: Optional[bool] = False,
+ timezone: Optional[str] = None,
+ usb: Optional[str] = None,
+ user_mode_networking: bool = False,
+ username: Optional[str] = None,
+ volume: Union[str, builtins.list[str], None] = None,
+ **skwargs,
+ ) -> Union[str, subprocess.Popen]:
+ """Initialize a new machine.
+
+ Args:
+ name (str, optional): Name of the machine.
+ cpus (int, optional): Number of CPUs to allocate.
+ disk_size (int, optional): Size of the disk in bytes.
+ ignition_path (Path, optional): Path to the ignition file.
+ image (Union[str, Path], optional): Image to use for the machine.
+ memory (int, optional): Amount of memory in bytes.
+ now (bool, optional): Whether to start the machine immediately. Defaults to False.
+ playbook (str, optional): Path to an Ansible playbook file.
+ rootful (bool, optional): Whether to create a rootful machine. Defaults to False.
+ timezone (str, optional): Timezone for the machine.
+ usb (str, optional): USB device to attach.
+ user_mode_networking (bool, optional): Whether to use user mode networking. Defaults to False.
+ username (str, optional): Username for the machine.
+ volume (str | list[str], optional): Volume to attach to the machine.
+ **skwargs: Additional keyword arguments for subprocess.
+
+ Returns:
+ Optional[str]: The output of the command if captured, otherwise the
+ subprocess.Popen instance.
+ """
+ cmd = self.runner.construct(
+ "machine",
+ "init",
+ name,
+ cpus=cpus,
+ disk_size=disk_size,
+ ignition_path=ignition_path,
+ image=image,
+ memory=memory,
+ now=now,
+ playbook=playbook,
+ rootful=rootful,
+ timezone=timezone,
+ usb=usb,
+ user_mode_networking=user_mode_networking,
+ username=username,
+ volume=volume,
+ )
+ return self.runner.run_raw(cmd, **skwargs)
+
+ def start(self, name: Optional[str] = None, **skwargs) -> None:
+ """Start a machine.
+
+ Args:
+ name (str, optional): Name of the machine to start.
+ """
+ cmd = self.runner.construct("machine", "start", name)
+ self.runner.run_raw(cmd, **skwargs)
+
+ def stop(self, name: Optional[str] = None, **skwargs) -> None:
+ """Stop a machine.
+
+ Args:
+ name (str, optional): Name of the machine to stop.
+ """
+ cmd = self.runner.construct("machine", "stop", name)
+ self.runner.run_raw(cmd, **skwargs)
+
+ def remove(
+ self,
+ name: Optional[str] = None,
+ save_image: bool = False,
+ save_ignition: bool = False,
+ **skwargs,
+ ) -> None:
+ """Remove a machine.
+
+ Args:
+ name (str, optional): Name of the machine to remove.
+ save_image (bool, optional): Whether to save the machine's image. Defaults to False.
+ save_ignition (bool, optional): Whether to save the ignition file. Defaults to False.
+ """
+ cmd = self.runner.construct(
+ "machine",
+ "rm",
+ name,
+ save_image=save_image,
+ save_ignition=save_ignition,
+ force=True,
+ **skwargs,
+ )
+ self.runner.run_raw(cmd, **skwargs)
+
+ def reset(self, **skwargs) -> None:
+ """Reset Podman machines and environment."""
+ cmd = self.runner.construct("machine", "reset", force=True)
+ self.runner.run_raw(cmd, **skwargs)
diff --git a/dangerzone/podman/errors/__init__.py b/dangerzone/podman/errors/__init__.py
new file mode 100644
index 000000000..d5026bab6
--- /dev/null
+++ b/dangerzone/podman/errors/__init__.py
@@ -0,0 +1,134 @@
+"""Podman API errors Package.
+
+Import exceptions from 'importlib' are used to differentiate between APIConnection
+and PodmanClient errors. Therefore, installing both APIConnection and PodmanClient
+is not supported. PodmanClient related errors take precedence over APIConnection ones.
+
+ApiConnection and associated classes have been deprecated.
+"""
+
+import warnings
+from http.client import HTTPException
+
+# isort: unique-list
+__all__ = [
+ "APIError",
+ "CommandError",
+ "BuildError",
+ "ContainerError",
+ "DockerException",
+ "ImageNotFound",
+ "InvalidArgument",
+ "NotFound",
+ "NotFoundError",
+ "PodmanError",
+ "PodmanNotInstalled",
+ "ServiceTerminatedServiceTimeout",
+ "StreamParseError",
+]
+
+try:
+ from .exceptions import (
+ APIError,
+ BuildError,
+ CommandError,
+ ContainerError,
+ DockerException,
+ InvalidArgument,
+ NotFound,
+ PodmanError,
+ PodmanNotInstalled,
+ ServiceTerminated,
+ ServiceTimeout,
+ StreamParseError,
+ )
+except ImportError:
+ pass
+
+
+class NotFoundError(HTTPException):
+ """HTTP request returned a http.HTTPStatus.NOT_FOUND.
+
+ Deprecated.
+ """
+
+ def __init__(self, message, response=None):
+ super().__init__(message)
+ self.response = response
+ warnings.warn(
+ "APIConnection() and supporting classes.",
+ PendingDeprecationWarning,
+ stacklevel=2,
+ )
+
+
+# If found, use new ImageNotFound otherwise old class
+try:
+ from .exceptions import ImageNotFound
+except ImportError:
+
+ class ImageNotFound(NotFoundError): # type: ignore[no-redef]
+ """HTTP request returned a http.HTTPStatus.NOT_FOUND.
+
+ Specialized for Image not found. Deprecated.
+ """
+
+
+class NetworkNotFound(NotFoundError):
+ """Network request returned a http.HTTPStatus.NOT_FOUND.
+
+ Deprecated.
+ """
+
+
+class ContainerNotFound(NotFoundError):
+ """HTTP request returned a http.HTTPStatus.NOT_FOUND.
+
+ Specialized for Container not found. Deprecated.
+ """
+
+
+class PodNotFound(NotFoundError):
+ """HTTP request returned a http.HTTPStatus.NOT_FOUND.
+
+ Specialized for Pod not found. Deprecated.
+ """
+
+
+class ManifestNotFound(NotFoundError):
+ """HTTP request returned a http.HTTPStatus.NOT_FOUND.
+
+ Specialized for Manifest not found. Deprecated.
+ """
+
+
+class RequestError(HTTPException):
+ """Podman service reported issue with the request.
+
+ Deprecated.
+ """
+
+ def __init__(self, message, response=None):
+ super().__init__(message)
+ self.response = response
+ warnings.warn(
+ "APIConnection() and supporting classes.",
+ PendingDeprecationWarning,
+ stacklevel=2,
+ )
+
+
+class InternalServerError(HTTPException):
+ """Podman service reported an internal error.
+
+ Deprecated.
+ """
+
+ def __init__(self, message, response=None):
+ super().__init__(message)
+ self.response = response
+ warnings.warn(
+ "APIConnection() and supporting classes.",
+ PendingDeprecationWarning,
+ stacklevel=2,
+ )
diff --git a/dangerzone/podman/errors/exceptions.py b/dangerzone/podman/errors/exceptions.py
new file mode 100644
index 000000000..afe54cc3c
--- /dev/null
+++ b/dangerzone/podman/errors/exceptions.py
@@ -0,0 +1,180 @@
+"""Podman API Errors."""
+
+import subprocess
+from collections.abc import Iterable
+from typing import TYPE_CHECKING, Optional, Union
+
+from requests import Response
+from requests.exceptions import HTTPError
+
+# Break circular import
+if TYPE_CHECKING:
+ from podman.api.client import APIResponse
+ from podman.domain.containers import Container
+
+
+class APIError(HTTPError):
+ """Wraps HTTP errors for processing by the API and clients."""
+
+ def __init__(
+ self,
+ message: str,
+ response: Union[Response, "APIResponse", None] = None,
+ explanation: Optional[str] = None,
+ ):
+ """Initialize APIError.
+
+ Args:
+ message: Message from service. Default: response.text, may be enhanced or wrapped by
+ bindings
+ response: HTTP Response from service.
+ explanation: An enhanced or wrapped version of message with additional context.
+ """
+ super().__init__(message, response=response)
+ self.explanation = explanation
+
+ def __str__(self):
+ msg = super().__str__()
+
+ if self.response is not None:
+ msg = self.response.reason
+
+ if self.is_client_error():
+ msg = f"{self.status_code} Client Error: {msg}"
+
+ elif self.is_server_error():
+ msg = f"{self.status_code} Server Error: {msg}"
+
+ if self.explanation:
+ msg = f"{msg} ({self.explanation})"
+
+ return msg
+
+ @property
+ def status_code(self):
+ """Optional[int]: HTTP status code from response."""
+ if self.response is not None:
+ return self.response.status_code
+ return None
+
+ def is_error(self) -> bool:
+ """Returns True when HTTP operation resulted in an error."""
+ return self.is_client_error() or self.is_server_error()
+
+ def is_client_error(self) -> bool:
+ """Returns True when request is incorrect."""
+ return 400 <= (self.status_code or 0) < 500
+
+ def is_server_error(self) -> bool:
+ """Returns True when error occurred in service."""
+ return 500 <= (self.status_code or 0) < 600
+
+
+class NotFound(APIError):
+ """Resource not found on Podman service.
+
+ Named for compatibility.
+ """
+
+
+class ImageNotFound(APIError):
+ """Image not found on Podman service."""
+
+
+class DockerException(Exception):
+ """Base class for exception hierarchy.
+
+ Provided for compatibility.
+ """
+
+
+class PodmanError(DockerException):
+ """Base class for PodmanPy exceptions."""
+
+
+class BuildError(PodmanError):
+ """Error occurred during build operation."""
+
+ def __init__(self, reason: str, build_log: Iterable[str]) -> None:
+ """Initialize BuildError.
+
+ Args:
+ reason: describes the error
+ build_log: build log output
+ """
+ super().__init__(reason)
+ self.msg = reason
+ self.build_log = build_log
+
+
+class ContainerError(PodmanError):
+ """Represents a container that has exited with a non-zero exit code."""
+
+ def __init__(
+ self,
+ container: "Container",
+ exit_status: int,
+ command: Union[str, list[str]],
+ image: str,
+ stderr: Optional[Iterable[str]] = None,
+ ): # pylint: disable=too-many-positional-arguments
+ """Initialize ContainerError.
+
+ Args:
+ container: Container that reported error.
+ exit_status: Non-zero status code from Container exit.
+ command: Command passed to container when created.
+ image: Name of image that was used to create container.
+ stderr: Errors reported by Container.
+ """
+ err = f": {stderr}" if stderr is not None else ""
+ msg = (
+ f"Command '{command}' in image '{image}' returned non-zero exit "
+ f"status {exit_status}{err}"
+ )
+
+ super().__init__(msg)
+
+ self.container = container
+ self.exit_status: int = exit_status
+ self.command = command
+ self.image = image
+ self.stderr = stderr
+
+
+class InvalidArgument(PodmanError):
+ """Parameter to method/function was not valid."""
+
+
+class StreamParseError(RuntimeError):
+ def __init__(self, reason):
+ self.msg = reason
+
+
+class PodmanNotInstalled(PodmanError):
+ def __init__(self) -> None:
+ msg = f"The Podman command is not installed in the system"
+ super().__init__(msg)
+
+
+class CommandError(PodmanError):
+ def __init__(self, error: subprocess.CalledProcessError) -> None:
+ self.error = error
+ msg = f"The Podman process failed with the following error: {error}"
+ if self.error.stdout:
+ msg += f"\nStdout: {self.error.stdout}"
+ if self.error.stderr:
+ msg += f"\nStderr: {self.error.stderr}"
+ super().__init__(msg)
+
+
+class ServiceTimeout(PodmanError):
+ def __init__(self, timeout: int) -> None:
+ msg = f"The Podman service failed to reply to a ping within {timeout} seconds"
+ super().__init__(msg)
+
+
+class ServiceTerminated(PodmanError):
+ def __init__(self, code: int) -> None:
+ msg = f"The Podman service has terminated with error code {code}"
+ super().__init__(msg)
diff --git a/dangerzone/podman/machine.py b/dangerzone/podman/machine.py
new file mode 100644
index 000000000..d052dfc56
--- /dev/null
+++ b/dangerzone/podman/machine.py
@@ -0,0 +1,151 @@
+import contextlib
+import functools
+import json
+import logging
+import os
+import platform
+import subprocess
+import tempfile
+import time
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Union
+
+from .. import container_utils, util
+from ..errors import OtherMachineRunningError
+from .command import PodmanCommand
+from .errors import CommandError, PodmanError, PodmanNotInstalled
+
+logger = logging.getLogger(__name__)
+
+
+class PodmanMachineManager:
+ """Manages the lifecycle of Dangerzone's Podman machines."""
+
+ def __init__(self) -> None:
+ """Initialize the PodmanMachineManager."""
+ self.name = container_utils.PODMAN_MACHINE_NAME
+ self.prefix = container_utils.PODMAN_MACHINE_PREFIX
+
+ @functools.cached_property
+ def podman(self) -> PodmanCommand:
+ """Instantiate a PodmanCommand class."""
+ return container_utils.init_podman_command()
+
+ def _get_machine_image_path(self) -> Path:
+ """Get the path to the machine image."""
+ return util.get_resource_path("machine.tar")
+
+ def _get_existing_dangerzone_machines(self) -> List[Dict]:
+ """Get a list of existing Dangerzone machines."""
+ try:
+ machines = self.podman.machine.list()
+ return [m for m in machines if m.get("Name", "").startswith(self.prefix)]
+ except (CommandError, json.JSONDecodeError):
+ return []
+
+ def _remove_stale_machines(self, existing_machines: List[Dict]) -> None:
+ """Remove stale Dangerzone machines."""
+ for machine in existing_machines:
+ name = machine.get("Name")
+ if name and name != self.name:
+ logger.info(f"Removing stale Podman machine: {name}")
+ try:
+ self.remove(name=name)
+ except CommandError as e:
+ logger.warning(f"Failed to remove stale machine {name}: {e}")
+
+ def list_other_running_machines(self) -> List[str]:
+ """List other running Podman machines, excluding the expected one."""
+ other_running_machines = []
+ try:
+ machines = self.podman.machine.list()
+ for machine in machines:
+ name = machine.get("Name")
+ if name and name != self.name and machine.get("Running"):
+ other_running_machines.append(name)
+ except (CommandError, json.JSONDecodeError):
+ pass
+ return other_running_machines
+
+ def init(
+ self,
+ cpus: Optional[int] = None,
+ memory: Optional[int] = None,
+ timezone: str = "Etc/UTC", # Do not leak local timezone
+ ) -> None:
+ """Initialize a new Podman machine."""
+ existing_machines = self._get_existing_dangerzone_machines()
+ self._remove_stale_machines(existing_machines)
+
+ if any(m.get("Name") == self.name for m in existing_machines):
+ logger.info(f"Podman machine '{self.name}' already exists.")
+ return
+
+ logger.info(f"Initializing Podman machine: {self.name}")
+ self.podman.machine.init(
+ name=self.name,
+ cpus=cpus,
+ memory=memory,
+ timezone=timezone,
+ image=self._get_machine_image_path(),
+ capture_output=False,
+ )
+ logger.info(f"Podman machine '{self.name}' initialized successfully.")
+
+ def start(self, name: Optional[str] = None) -> None:
+ """Start a Podman machine."""
+ if name is None:
+ name = self.name
+
+ if platform.system() == "Darwin":
+ other_running_machines = self.list_other_running_machines()
+ if other_running_machines:
+ raise OtherMachineRunningError(
+ f"Other Podman machines are running: {', '.join(other_running_machines)}"
+ )
+
+ logger.info(f"Starting Podman machine: {name}")
+ try:
+ self.podman.machine.start(name=name, capture_output=False)
+ logger.info(f"Podman machine '{name}' started successfully.")
+ except CommandError as e:
+ for m in self._get_existing_dangerzone_machines():
+ if m.get("Name") == self.name and m.get("Running"):
+ logger.info(f"Podman machine '{name}' is already running")
+ return
+ raise
+
+ def stop(
+ self,
+ name: Optional[str] = None,
+ wait: bool = True,
+ ) -> None:
+ """Stop a Podman machine."""
+ if name is None:
+ name = self.name
+ logger.info(f"Stopping Podman machine: {name}")
+ self.podman.machine.stop(name=name, wait=wait)
+ if wait:
+ logger.info(f"Podman machine '{name}' stopped successfully.")
+
+ def remove(self, name: Optional[str] = None) -> None:
+ """Remove a Podman machine."""
+ if name is None:
+ name = self.name
+ logger.info(f"Removing Podman machine: {name}")
+ self.podman.machine.remove(name=name)
+ logger.info(f"Podman machine '{name}' removed successfully.")
+
+ def reset(self) -> None:
+ """Reset all Podman machines."""
+ logger.info("Resetting all Podman machines.")
+ self.podman.machine.reset()
+ logger.info("Podman machines reset successfully.")
+
+ def list(self) -> List[Dict]:
+ """List all Dangerzone machines."""
+ return self._get_existing_dangerzone_machines()
+
+ def run_raw_podman_command(self, args: List[str]) -> Union[str, subprocess.Popen]:
+ """Run a raw Podman command."""
+ return self.podman.run(args, stdin=None, capture_output=False)
diff --git a/dangerzone/settings.py b/dangerzone/settings.py
index 2ee771108..936d70064 100644
--- a/dangerzone/settings.py
+++ b/dangerzone/settings.py
@@ -2,11 +2,13 @@
import logging
import os
import platform
+import shutil
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict
from packaging import version
+from . import errors
from .document import SAFE_EXTENSION
from .util import get_config_dir, get_version
@@ -24,12 +26,13 @@ class Settings:
# setting `Settings._singleton = None` will force a new instance
_singleton = None
- def __new__(cls) -> "Settings":
+ def __new__(cls, *args: list, **kwargs: dict) -> "Settings":
if cls._singleton is None:
cls._singleton = super(Settings, cls).__new__(cls)
return cls._singleton
- def __init__(self) -> None:
+ def __init__(self, debug: bool = False) -> None:
+ self.debug = debug
self.settings_filename = get_config_dir() / SETTINGS_FILENAME
self.default_settings: Dict[str, Any] = self.generate_default_settings()
# Singletons call multiple times the __init__ method
@@ -53,20 +56,30 @@ def generate_default_settings(cls) -> Dict[str, Any]:
"updater_latest_changelog": "",
"updater_remote_log_index": 0,
"updater_errors": 0,
+ "stop_other_podman_machines": "ask",
+ "successful_first_run": None,
}
def custom_runtime_specified(self) -> bool:
return "container_runtime" in self.settings
def set_custom_runtime(self, runtime: str, autosave: bool = False) -> Path:
- from .container_utils import Runtime # Avoid circular import
-
- container_runtime = Runtime.path_from_name(runtime)
+ container_runtime = self.path_from_name(runtime)
self.settings["container_runtime"] = str(container_runtime)
if autosave:
self.save()
return container_runtime
+ def path_from_name(self, name: str) -> Path:
+ name_path = Path(name)
+ if name_path.is_file():
+ return name_path
+ else:
+ runtime = shutil.which(name_path)
+ if runtime is None:
+ raise errors.NoContainerTechException(name)
+ return Path(runtime)
+
def unset_custom_runtime(self) -> None:
self.settings.pop("container_runtime")
self.save()
diff --git a/dangerzone/shutdown.py b/dangerzone/shutdown.py
new file mode 100644
index 000000000..f4709dba0
--- /dev/null
+++ b/dangerzone/shutdown.py
@@ -0,0 +1,47 @@
+import logging
+import platform
+import typing
+
+from . import container_utils, startup
+from .podman.machine import PodmanMachineManager
+
+logger = logging.getLogger(__name__)
+
+
+class MachineStopTask(startup.Task):
+ can_fail = True
+ name = "Stopping Dangerzone VM"
+
+ def should_skip(self) -> bool:
+ return platform.system() == "Linux"
+
+ def run(self) -> None:
+ PodmanMachineManager().stop()
+
+
+class ContainerStopTask(startup.Task):
+ can_fail = True
+ name = "Stopping the sandbox"
+
+ def run(self) -> None:
+ # In practice, we don't expect more than 1 container in flight.
+ for cont in container_utils.list_containers():
+ container_utils.kill_container(cont)
+
+
+class ShutdownMixin:
+ def handle_start_custom(self) -> None:
+ logger.info("Shutting down Dangerzone")
+
+ def handle_error_custom(self, task: startup.Task, e: Exception) -> None:
+ logger.error(
+ f"Encountered an error in task '{task.name}', while shutting down Dangerzone."
+ f" Resuming..."
+ )
+
+ def handle_success_custom(self) -> None:
+ logger.info("Dangerzone's shutdown tasks have finished successfully")
+
+
+class ShutdownLogic(startup.Runner, ShutdownMixin):
+ pass
diff --git a/dangerzone/startup.py b/dangerzone/startup.py
new file mode 100644
index 000000000..521e689d6
--- /dev/null
+++ b/dangerzone/startup.py
@@ -0,0 +1,261 @@
+import abc
+import logging
+import platform
+import typing
+
+if typing.TYPE_CHECKING:
+ from PySide2 import QtCore
+else:
+ try:
+ from PySide6 import QtCore
+ except ImportError:
+ from PySide2 import QtCore
+
+
+from . import errors, settings
+from .podman.machine import PodmanMachineManager
+from .updater import (
+ ErrorReport,
+ InstallationStrategy,
+ ReleaseReport,
+ installer,
+ releases,
+)
+from .updater import (
+ errors as updater_errors,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class Task(abc.ABC):
+ can_fail = False
+
+ def should_skip(self) -> bool:
+ return False
+
+ @abc.abstractproperty
+ def name(self) -> str:
+ pass
+
+ def handle_skip(self) -> None:
+ logger.info(f"Task '{self.name}' will be skipped")
+
+ def handle_start(self) -> None:
+ logger.info(f"Task '{self.name}' is starting...")
+
+ def handle_error(self, e: Exception) -> None:
+ """Handle task errors.
+
+ Do not raise an exception here, so that the error handler of StartupLogic can
+ run.
+ """
+ logger.error(f"Task '{self.name}' failed with error: {str(e)}", exc_info=e)
+
+ def handle_success(self) -> None:
+ logger.info(f"Task '{self.name}' completed successfully!")
+ pass
+
+ @abc.abstractmethod
+ def run(self) -> None:
+ pass
+
+
+#############
+# Basic tasks
+#############
+
+
+class MachineInitTask(Task):
+ name = "Initializing Dangerzone VM"
+
+ def should_skip(self) -> bool:
+ return platform.system() == "Linux"
+
+ def run(self) -> None:
+ PodmanMachineManager().init()
+
+
+class MachineStartTask(Task):
+ name = "Starting Dangerzone VM"
+
+ def should_skip(self) -> bool:
+ return platform.system() == "Linux"
+
+ def run(self) -> None:
+ PodmanMachineManager().start()
+
+
+class MachineStopOthersTask(Task):
+ name = "Stopping other Podman VMs"
+
+ def fail(self, message: str): # type: ignore [no-untyped-def]
+ raise errors.OtherMachineRunningError(message)
+
+ def should_skip(self) -> bool:
+ if platform.system() in ["Linux", "Windows"]:
+ # * On Linux, there are no Podman machines
+ # * On Windows, WSL allows multiple VMs:
+ # https://github.com/containers/podman/issues/18415
+ # * On macOS, only one Podman machine can run:
+ # https://docs.podman.io/en/v5.2.2/markdown/podman-machine-start.1.html
+ return True
+
+ other_running_machines = PodmanMachineManager().list_other_running_machines()
+ if not other_running_machines:
+ return True
+ assert len(other_running_machines) == 1
+ machine_name = other_running_machines[0]
+ logger.info(
+ f"Dangerzone has detected that a Podman machine with name '{machine_name}'"
+ " is already running in your system. This machine needs to stop so that"
+ " Dangerzone can run."
+ )
+
+ stop_setting = settings.Settings().get("stop_other_podman_machines")
+
+ if stop_setting == "always":
+ logger.info(
+ "Stopping the Podman machine because the user has asked us to remember their choice"
+ )
+ return False
+ elif stop_setting == "never":
+ self.fail(
+ "Another Podman machine is running and Dangerzone is configured to not stop it."
+ )
+ elif stop_setting == "ask":
+ logger.debug("We need to prompt the user to stop the other Podman machine")
+ stop = self.prompt_user(machine_name)
+ if not stop:
+ self.fail(
+ f"User decided to quit Dangerzone instead of stopping Podman"
+ f" machine '{machine_name}'."
+ )
+ # NOTE: This is required only for testing. Else, we expect it will raise
+ # an exception.
+ return True
+ else:
+ return False
+
+ raise Exception(
+ "BUG: Dangerzone cannot decide how to handle running Podman machine"
+ )
+
+ def prompt_user(self, machine_name: str) -> bool:
+ """Return whether the user has accepted to stop the machine or not."""
+ return self.fail(
+ f"Dangerzone has detected that a Podman machine with name '{machine_name}'"
+ " is already running in the system, but cannot prompt the user to stop it."
+ )
+
+ def run(self) -> None:
+ other_running_machines = PodmanMachineManager().list_other_running_machines()
+ for machine_name in other_running_machines:
+ logger.info(f"Stopping other Podman machine: {machine_name}")
+ PodmanMachineManager().stop(name=machine_name)
+
+ # Verify no other machines are running
+ if PodmanMachineManager().list_other_running_machines():
+ raise RuntimeError("Failed to stop all other running Podman machines.")
+
+
+class ContainerInstallTask(Task):
+ name = "Configuring Dangerzone sandbox"
+
+ def should_skip(self) -> bool:
+ return installer.get_installation_strategy() == InstallationStrategy.DO_NOTHING
+
+ def run(self) -> None:
+ installer.install()
+
+
+class UpdateCheckTask(Task):
+ can_fail = True
+ name = "Check for updates"
+
+ def should_skip(self) -> bool:
+ try:
+ return not releases.should_check_for_updates(settings.Settings())
+ except updater_errors.NeedUserInput:
+ self.prompt_user()
+ return True
+
+ def run(self) -> None:
+ report = releases.check_for_updates(settings.Settings())
+ if isinstance(report, ReleaseReport):
+ if report.new_github_release:
+ self.handle_app_update(report)
+ if report.container_image_bump:
+ self.handle_container_update(report)
+ elif isinstance(report, ErrorReport):
+ raise RuntimeError(report.error)
+
+ def prompt_user(self) -> None:
+ pass
+
+ def handle_app_update(self, report: ReleaseReport) -> None:
+ logger.info(f"Dangerzone {report.version} is out and can be installed")
+
+ def handle_container_update(self, report: ReleaseReport) -> None:
+ logger.info(f"There is an update for the Dangerzone sandbox")
+
+
+class Runner:
+ def __init__(self, tasks: list[Task], raise_on_error: bool = True) -> None:
+ self.tasks = tasks
+ self.raise_on_error = raise_on_error
+ super().__init__()
+
+ def handle_start_custom(self) -> None:
+ pass
+
+ def handle_error_custom(self, task: Task, e: Exception) -> None:
+ pass
+
+ def handle_success_custom(self) -> None:
+ pass
+
+ def handle_start(self) -> None:
+ self.handle_start_custom()
+
+ def handle_error(self, task: Task, e: Exception) -> None:
+ self.handle_error_custom(task, e)
+ if self.raise_on_error:
+ raise e
+
+ def handle_success(self) -> None:
+ self.handle_success_custom()
+
+ def run(self) -> None:
+ self.handle_start()
+ for task in self.tasks:
+ if task.should_skip():
+ task.handle_skip()
+ continue
+ task.handle_start()
+ try:
+ task.run()
+ except Exception as e:
+ task.handle_error(e)
+ if not task.can_fail:
+ return self.handle_error(task, e)
+ else:
+ task.handle_success()
+ self.handle_success()
+
+
+class StartupMixin:
+ def handle_start_custom(self) -> None:
+ logger.info("Performing some Dangerzone startup tasks")
+
+ def handle_error_custom(self, task: Task, e: Exception) -> None:
+ logger.error(
+ f"Stopping startup tasks because task '{task.name}' failed with an error"
+ )
+
+ def handle_success_custom(self) -> None:
+ logger.info("Successfully finished all Dangerzone startup tasks")
+
+
+class StartupLogic(Runner, StartupMixin):
+ pass
diff --git a/dangerzone/updater/cli.py b/dangerzone/updater/cli.py
index 00c6301a5..a44753acd 100644
--- a/dangerzone/updater/cli.py
+++ b/dangerzone/updater/cli.py
@@ -2,12 +2,15 @@
import functools
import logging
+import platform
from pathlib import Path
+from typing import Any, Callable
import click
-from .. import container_utils
-from ..container_utils import Runtime, expected_image_name
+from .. import shutdown, startup
+from ..container_utils import expected_image_name
+from ..podman.machine import PodmanMachineManager
from ..util import get_architecture
from . import cosign, errors, log, registry, signatures
from .signatures import DEFAULT_PUBKEY_LOCATION
@@ -17,6 +20,22 @@
DEFAULT_IMAGE_NAME = expected_image_name()
+def requires_container_runtime(func: Callable) -> Callable:
+ """Decorator to start and stop Podman machines for commands that require it."""
+
+ @functools.wraps(func)
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
+ tasks = [startup.MachineInitTask(), startup.MachineStartTask()]
+ try:
+ startup.StartupLogic(tasks=tasks).run()
+ res = func(*args, **kwargs)
+ finally:
+ shutdown.ShutdownLogic(tasks=[shutdown.MachineStopTask()]).run()
+ return res
+
+ return wrapper
+
+
@click.group(context_settings={"show_default": True})
@click.option("--debug", is_flag=True)
def main(debug: bool) -> None:
@@ -29,6 +48,7 @@ def main(debug: bool) -> None:
@main.command()
+@requires_container_runtime
def upgrade() -> None:
"""Upgrade the sandbox to the latest version available.
@@ -38,10 +58,7 @@ def upgrade() -> None:
manifest_digest = registry.get_manifest_digest(DEFAULT_IMAGE_NAME)
try:
- callback = functools.partial(click.echo, nl=False)
- signatures.upgrade_container_image(
- manifest_digest, DEFAULT_IMAGE_NAME, callback=callback
- )
+ signatures.upgrade_container_image(manifest_digest, DEFAULT_IMAGE_NAME)
click.echo(f"✅ The local image {DEFAULT_IMAGE_NAME} has been upgraded")
click.echo(f"✅ The image has been signed with {DEFAULT_PUBKEY_LOCATION}")
click.echo(f"✅ Signatures have been verified and stored locally")
@@ -76,6 +93,7 @@ def store_signatures(image: str) -> None:
is_flag=True,
help="Force the installation, bypassing logindex verification checks",
)
+@requires_container_runtime
def load_archive(archive_filename: Path, force: bool) -> None:
"""Use ARCHIVE_FILENAME as the dangerzone sandbox image"""
try:
@@ -131,6 +149,7 @@ def prepare_archive(image: str, output: str, arch: str) -> None:
default=DEFAULT_IMAGE_NAME,
help="The name of the image to check signatures for",
)
+@requires_container_runtime
def verify_local(image: str) -> None:
"""
Ensures local image signature(s) match the embedded public key.
diff --git a/dangerzone/updater/cosign.py b/dangerzone/updater/cosign.py
index c2d7eaeea..6d6defe8c 100644
--- a/dangerzone/updater/cosign.py
+++ b/dangerzone/updater/cosign.py
@@ -35,6 +35,7 @@ def verify_blob(pubkey: Path, bundle: str, payload: str) -> None:
cmd = [
_COSIGN_BINARY,
"verify-blob",
+ "--offline",
"--key",
str(pubkey.absolute()),
"--bundle",
diff --git a/dangerzone/updater/installer.py b/dangerzone/updater/installer.py
index b34053020..06cdec57b 100644
--- a/dangerzone/updater/installer.py
+++ b/dangerzone/updater/installer.py
@@ -20,31 +20,13 @@
log = logging.getLogger(__name__)
-class CallbackHandler(Handler):
- """
- A Logging handler that copies INFO log records
- to a specified callback.
-
- The main use-case being to display the progress
- to the user.
- """
-
- def __init__(self, callback: Optional[Callable]) -> None:
- super().__init__()
- self.callback = callback
-
- def emit(self, record: LogRecord) -> None:
- if record.levelname == "INFO" and self.callback:
- self.callback(f"{record.getMessage()}\n")
-
-
class Strategy(Enum):
DO_NOTHING = 1
INSTALL_LOCAL_CONTAINER = 2
INSTALL_REMOTE_CONTAINER = 3
-def install(callback: Optional[Callable] = None) -> None:
+def install() -> None:
"""
Determine the installation strategy and apply it.
@@ -52,12 +34,10 @@ def install(callback: Optional[Callable] = None) -> None:
to act upon the to-be-applied installation strategy.
"""
strategy = get_installation_strategy()
- apply_installation_strategy(strategy, callback)
+ apply_installation_strategy(strategy)
-def apply_installation_strategy(
- strategy: Strategy, callback: Optional[Callable] = None
-) -> None:
+def apply_installation_strategy(strategy: Strategy) -> None:
"""
Install or upgrade a container registry, based on previous computations.
"""
@@ -70,13 +50,10 @@ def apply_installation_strategy(
log.debug("Download and install a remote container image")
container_name = runtime.expected_image_name()
- # Also copy the logs INFO to the user interface
- updater_log.addHandler(CallbackHandler(callback))
-
remote_digest, remote_log_index, signatures = get_remote_digest_and_logindex(
container_name
)
- upgrade_container_image(remote_digest, callback=callback, signatures=signatures)
+ upgrade_container_image(remote_digest, signatures=signatures)
runtime.clear_old_images(digest_to_keep=remote_digest)
diff --git a/dangerzone/updater/registry.py b/dangerzone/updater/registry.py
index 3f7cd4e53..675ef33cb 100644
--- a/dangerzone/updater/registry.py
+++ b/dangerzone/updater/registry.py
@@ -6,7 +6,6 @@
import requests
-from .. import container_utils as runtime
from .. import errors as dzerrors
from . import errors, log
diff --git a/dangerzone/updater/signatures.py b/dangerzone/updater/signatures.py
index 59dfc73b4..7a935e023 100644
--- a/dangerzone/updater/signatures.py
+++ b/dangerzone/updater/signatures.py
@@ -616,7 +616,6 @@ def upgrade_container_image(
image_str: Optional[str] = None,
pubkey: Path = DEFAULT_PUBKEY_LOCATION,
bypass_logindex_check: bool = False,
- callback: Optional[Callable] = None,
signatures: Optional[List[Dict]] = None,
) -> None:
"""Verify and upgrade the image to the latest, if signed."""
@@ -639,7 +638,7 @@ def upgrade_container_image(
if remote_log_index == local_log_index and runtime.list_image_digests():
raise errors.ImageAlreadyUpToDate()
- runtime.container_pull(image_str, remote_digest, callback=callback)
+ runtime.container_pull(image_str, remote_digest)
# Now that they are verified, store the signatures
store_signatures(signatures, remote_digest, pubkey)
diff --git a/debian/control b/debian/control
index a4329128b..0c112fe0d 100644
--- a/debian/control
+++ b/debian/control
@@ -9,7 +9,7 @@ Rules-Requires-Root: no
Package: dangerzone
Architecture: any
-Depends: ${misc:Depends}, podman, python3, python3-pyside6.qtcore | python3-pyside2.qtcore, python3-pyside6.qtgui | python3-pyside2.qtgui, python3-pyside6.qtwidgets | python3-pyside2.qtwidgets, python3-pyside6.qtsvg | python3-pyside2.qtsvg, python3-platformdirs | python3-appdirs, python3-click, python3-xdg, python3-colorama, python3-requests, python3-markdown, python3-packaging, tesseract-ocr-all
+Depends: ${misc:Depends}, podman, python3, python3-pyside6.qtcore | python3-pyside2.qtcore, python3-pyside6.qtgui | python3-pyside2.qtgui, python3-pyside6.qtwidgets | python3-pyside2.qtwidgets, python3-pyside6.qtsvgwidgets | python3-pyside2.qtsvg, python3-platformdirs | python3-appdirs, python3-click, python3-xdg, python3-colorama, python3-requests, python3-markdown, python3-packaging, tesseract-ocr-all
Description: Take potentially dangerous PDFs, office documents, or images
Dangerzone is an open source desktop application that takes potentially dangerous PDFs, office documents, or images and converts them to safe PDFs. It uses disposable VMs on Qubes OS, or container technology in other OSes, to convert the documents within a secure sandbox.
.
diff --git a/dev_scripts/dangerzone b/dev_scripts/dangerzone
index 09fe82f5e..d7a4d3a60 100755
--- a/dev_scripts/dangerzone
+++ b/dev_scripts/dangerzone
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-import os
import sys
+from pathlib import Path
-# Load dangerzone module and resources from the source code tree
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+PROJECT_DIR = Path(__file__).parents[1]
+sys.path.insert(0, str(PROJECT_DIR))
sys.dangerzone_dev = True
import dangerzone
diff --git a/dev_scripts/dangerzone-image b/dev_scripts/dangerzone-image
index 546720766..04daf6956 100755
--- a/dev_scripts/dangerzone-image
+++ b/dev_scripts/dangerzone-image
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-import os
import sys
+from pathlib import Path
-# Load dangerzone module and resources from the source code tree
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+PROJECT_DIR = Path(__file__).parents[1]
+sys.path.insert(0, str(PROJECT_DIR))
sys.dangerzone_dev = True
from dangerzone.updater import cli
diff --git a/dev_scripts/dangerzone-machine b/dev_scripts/dangerzone-machine
new file mode 100755
index 000000000..154ddf8f1
--- /dev/null
+++ b/dev_scripts/dangerzone-machine
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import sys
+from pathlib import Path
+
+PROJECT_DIR = Path(__file__).parents[1]
+sys.path.insert(0, str(PROJECT_DIR))
+sys.dangerzone_dev = True
+
+from dangerzone.podman import cli
+
+cli.main()
diff --git a/dev_scripts/dangerzone-machine.bat b/dev_scripts/dangerzone-machine.bat
new file mode 100644
index 000000000..b4ac6828e
--- /dev/null
+++ b/dev_scripts/dangerzone-machine.bat
@@ -0,0 +1 @@
+poetry run python .\dev_scripts\dangerzone-machine %*
diff --git a/install/linux/dangerzone.spec b/install/linux/dangerzone.spec
index b5c394813..ff9338036 100644
--- a/install/linux/dangerzone.spec
+++ b/install/linux/dangerzone.spec
@@ -267,6 +267,7 @@ fi
/usr/bin/dangerzone
/usr/bin/dangerzone-cli
/usr/bin/dangerzone-image
+/usr/bin/dangerzone-machine
/usr/share/
%license LICENSE
%doc README.md
diff --git a/install/windows/build-app.bat b/install/windows/build-app.bat
index bac696f73..88e26ac0f 100644
--- a/install/windows/build-app.bat
+++ b/install/windows/build-app.bat
@@ -5,17 +5,19 @@ rmdir /s /q build
REM build the gui and cli exe
python .\setup-windows.py build
-REM code sign dangerzone.exe
-signtool.exe sign /v /d "Dangerzone" /a /n "Freedom of the Press Foundation" /fd sha256 /t http://time.certum.pl/ build\exe.win-amd64-3.13\dangerzone.exe
-
-REM verify the signature of dangerzone.exe
-signtool.exe verify /pa build\exe.win-amd64-3.13\dangerzone.exe
-
-REM code sign dangerzone-cli.exe
-signtool.exe sign /v /d "Dangerzone" /a /n "Freedom of the Press Foundation" /fd sha256 /t http://time.certum.pl/ build\exe.win-amd64-3.13\dangerzone-cli.exe
-
-REM verify the signature of dangerzone-cli.exe
-signtool.exe verify /pa build\exe.win-amd64-3.13\dangerzone-cli.exe
+REM code sign executables
+signtool.exe sign /v /d "Dangerzone" /a /n "Freedom of the Press Foundation" /fd sha256 /t http://time.certum.pl/ ^
+ build\exe.win-amd64-3.13\dangerzone.exe ^
+ build\exe.win-amd64-3.13\dangerzone-cli.exe ^
+ build\exe.win-amd64-3.13\dangerzone-image.exe ^
+ build\exe.win-amd64-3.13\dangerzone-machine.exe
+
+REM verify the signatures of the executables
+signtool.exe verify /pa ^
+ build\exe.win-amd64-3.13\dangerzone.exe ^
+ build\exe.win-amd64-3.13\dangerzone-cli.exe ^
+ build\exe.win-amd64-3.13\dangerzone-image.exe ^
+ build\exe.win-amd64-3.13\dangerzone-machine.exe
REM build the wxs file
python install\windows\build-wxs.py
diff --git a/install/windows/dangerzone-image.py b/install/windows/dangerzone-image.py
new file mode 100644
index 000000000..fcfe688b0
--- /dev/null
+++ b/install/windows/dangerzone-image.py
@@ -0,0 +1,3 @@
+from dangerzone.updater import cli
+
+cli.main()
diff --git a/install/windows/dangerzone-machine.py b/install/windows/dangerzone-machine.py
new file mode 100644
index 000000000..1efae9e74
--- /dev/null
+++ b/install/windows/dangerzone-machine.py
@@ -0,0 +1,3 @@
+from dangerzone.podman import cli
+
+cli.main()
diff --git a/mazette.lock b/mazette.lock
index b86219465..a44d00027 100644
--- a/mazette.lock
+++ b/mazette.lock
@@ -139,6 +139,124 @@
}
}
},
+ "podman": {
+ "windows/amd64": {
+ "repo": "containers/podman",
+ "download_url": "https://github.com/containers/podman/releases/download/v5.5.2/podman-remote-release-windows_amd64.zip",
+ "version": "5.5.2",
+ "checksum": "2e63aa82cf0b57305e3b8074cf46c09eab85d04bfa81330697dc07d127b2197a",
+ "executable": true,
+ "destination": "share/vendor/podman",
+ "extract": {
+ "globs": [
+ "**/bin/*"
+ ],
+ "flatten": true,
+ "filetype": "zip"
+ }
+ },
+ "darwin/amd64": {
+ "repo": "containers/podman",
+ "download_url": "https://github.com/containers/podman/releases/download/v5.5.2/podman-remote-release-darwin_amd64.zip",
+ "version": "5.5.2",
+ "checksum": "77ffabd8a48eef601694c24c7050fd82515651cf681f90538824b0c9e54ca65e",
+ "executable": true,
+ "destination": "share/vendor/podman",
+ "extract": {
+ "globs": [
+ "**/bin/*"
+ ],
+ "flatten": true,
+ "filetype": "zip"
+ }
+ },
+ "darwin/arm64": {
+ "repo": "containers/podman",
+ "download_url": "https://github.com/containers/podman/releases/download/v5.5.2/podman-remote-release-darwin_arm64.zip",
+ "version": "5.5.2",
+ "checksum": "0f18957c62896ddbd4b8adfd25e3918eb93f652df1f93349bf732a567696635b",
+ "executable": true,
+ "destination": "share/vendor/podman",
+ "extract": {
+ "globs": [
+ "**/bin/*"
+ ],
+ "flatten": true,
+ "filetype": "zip"
+ }
+ }
+ },
+ "gvproxy": {
+ "darwin/amd64": {
+ "repo": "containers/gvisor-tap-vsock",
+ "download_url": "https://github.com/containers/gvisor-tap-vsock/releases/download/v0.8.6/gvproxy-darwin",
+ "version": "0.8.6",
+ "checksum": "e18161fd2dbc67046f4b2a138807fdd1e7f8545b37850328e5a647684e9a809e",
+ "executable": true,
+ "destination": "share/vendor/podman/gvproxy",
+ "extract": false
+ },
+ "darwin/arm64": {
+ "repo": "containers/gvisor-tap-vsock",
+ "download_url": "https://github.com/containers/gvisor-tap-vsock/releases/download/v0.8.6/gvproxy-darwin",
+ "version": "0.8.6",
+ "checksum": "e18161fd2dbc67046f4b2a138807fdd1e7f8545b37850328e5a647684e9a809e",
+ "executable": true,
+ "destination": "share/vendor/podman/gvproxy",
+ "extract": false
+ }
+ },
+ "vfkit": {
+ "darwin/amd64": {
+ "repo": "crc-org/vfkit",
+ "download_url": "https://github.com/crc-org/vfkit/releases/download/v0.6.1/vfkit",
+ "version": "0.6.1",
+ "checksum": "751fb934d8234c0fa0e6558d86010eb36f156a92547f791c5573767af38a2936",
+ "executable": true,
+ "destination": "share/vendor/podman/vfkit",
+ "extract": false
+ },
+ "darwin/arm64": {
+ "repo": "crc-org/vfkit",
+ "download_url": "https://github.com/crc-org/vfkit/releases/download/v0.6.1/vfkit",
+ "version": "0.6.1",
+ "checksum": "751fb934d8234c0fa0e6558d86010eb36f156a92547f791c5573767af38a2936",
+ "executable": true,
+ "destination": "share/vendor/podman/vfkit",
+ "extract": false
+ }
+ },
+ "machine-os": {
+ "darwin/amd64": {
+ "repo": "containers/podman-machine-os",
+ "download_url": "https://github.com/containers/podman-machine-os/releases/download/v5.5.2/podman-machine.x86_64.applehv.raw.zst",
+ "version": "5.5.2",
+ "checksum": "9710cb739ec8b6473863607ecdd7ee27cdc8802b1133ad2e121f98206399a7cc",
+ "executable": false,
+ "destination": "share/machine.tar",
+ "extract": false
+ },
+ "darwin/arm64": {
+ "repo": "containers/podman-machine-os",
+ "download_url": "https://github.com/containers/podman-machine-os/releases/download/v5.5.2/podman-machine.aarch64.applehv.raw.zst",
+ "version": "5.5.2",
+ "checksum": "1f5c0ec861031a3e51ae30af3a34009ca344437609915ecf7833897e2292b448",
+ "executable": false,
+ "destination": "share/machine.tar",
+ "extract": false
+ }
+ },
+ "machine-os-wsl": {
+ "windows/amd64": {
+ "repo": "apyrgio/podman-machine-os",
+ "download_url": "https://github.com/apyrgio/podman-machine-os/releases/download/5.5.0/podman-machine.x86_64.wsl.tar.zst",
+ "version": "5.5.0",
+ "checksum": "ffada992b8a1efea5204e51b89c6bb011253735c6ddb98fd83a806668c377f9e",
+ "executable": false,
+ "destination": "share/machine.tar",
+ "extract": false
+ }
+ },
"cosign": {
"darwin/amd64": {
"repo": "sigstore/cosign",
@@ -178,5 +296,5 @@
}
}
},
- "config_checksum": "2a66bc2317b33aa43d8c2dce52da7b5f4a69ccd05fb913e51908ff2f1a08d58a"
+ "config_checksum": "305236a69bcb44719c3b6ab10caa8a2fbcbfdec0e0c70dbe00a82ea1a060abce"
}
\ No newline at end of file
diff --git a/poetry.lock b/poetry.lock
index 0bf12089f..cf6fa7ed2 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -803,14 +803,14 @@ files = [
[[package]]
name = "mazette"
-version = "0.2.0"
+version = "0.2.1"
description = "GitHub asset management with lock files and extensive configuration"
optional = false
python-versions = ">=3.9"
groups = ["package"]
files = [
- {file = "mazette-0.2.0-py3-none-any.whl", hash = "sha256:5b79d250dd48710f4791009fe4e032c1b39fc3d12e5cdb4e63380b520d9a9aad"},
- {file = "mazette-0.2.0.tar.gz", hash = "sha256:8113f0f06f7939ebecfcd4970b33bc2ab0902fcbe7faa8215c188600c7e6314c"},
+ {file = "mazette-0.2.1-py3-none-any.whl", hash = "sha256:b87f166cad936075ea3eec87f031b95a8ef71cafc7764cfb5937e914b86f503a"},
+ {file = "mazette-0.2.1.tar.gz", hash = "sha256:2e710e007f72413cefb9847dc16eadbc81130261b60f21e7e4c89567cd2f1c0f"},
]
[package.dependencies]
@@ -1681,4 +1681,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.9,<3.14"
-content-hash = "cccae5292f746e904a403beef4ec13cacb16c910d157974cbe82e937e7a87bbc"
+content-hash = "cf39d702905634457302062bcfd24529d4ef290a32bcd0178c888761ee4cb83f"
diff --git a/pyproject.toml b/pyproject.toml
index 3aa295c07..451d5a64f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,6 +32,7 @@ packaging = "*"
dangerzone = 'dangerzone:main'
dangerzone-cli = 'dangerzone:main'
dangerzone-image = "dangerzone.updater.cli:main"
+dangerzone-machine = "dangerzone.podman.cli:main"
# Dependencies required for packaging the code on various platforms.
[tool.poetry.group.package.dependencies]
@@ -41,7 +42,7 @@ pywin32 = { version = "*", platform = "win32" }
pyinstaller = { version = "*", platform = "darwin" }
doit = "^0.36.0"
jinja2-cli = "^0.8.2"
-mazette = "^0.2.0"
+mazette = "^0.2.1"
# Dependencies required for linting the code.
[tool.poetry.group.lint.dependencies]
@@ -77,6 +78,20 @@ httpx = "^0.27.2"
[tool.doit]
verbosity = 3
+[tool.mypy]
+ignore_missing_imports = true
+disallow_incomplete_defs = true
+disallow_untyped_defs = true
+show_error_codes = true
+warn_unreachable = true
+warn_unused_ignores = true
+exclude = [
+ "dangerzone/podman/command/",
+ "dangerzone/podman/errors/",
+ "dangerzone/podman/errors/",
+ "tests/test_docs_large/",
+]
+
[tool.ruff.lint]
select = [
# isort
@@ -87,6 +102,14 @@ select = [
requires = ["poetry-core>=1.2.0"]
build-backend = "poetry.core.masonry.api"
+[tool.pytest.ini_options]
+filterwarnings = [
+ "ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning",
+ "ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning",
+ "ignore:builtin type swigvarlink has no __module__ attribute:DeprecationWarning",
+ "ignore:NotImplemented should not be used in a boolean context",
+]
+
[tool.mazette.asset.tessdata]
repo = "tesseract-ocr/tessdata_fast"
version = ">=4.1.0"
@@ -221,8 +244,54 @@ extract.globs = [
]
extract.flatten = true
-[tool.mazette.asset.cosign]
+[tool.mazette.asset.podman]
+repo = "containers/podman"
+version = ">=5.4.2"
+platform."windows/amd64" = "podman-remote-release-windows_amd64.zip"
+platform."darwin/amd64" = "podman-remote-release-darwin_amd64.zip"
+platform."darwin/arm64" = "podman-remote-release-darwin_arm64.zip"
+destination = "share/vendor/podman"
+executable = true
+extract.globs = ["**/bin/*"]
+extract.flatten = true
+[tool.mazette.asset.gvproxy]
+repo = "containers/gvisor-tap-vsock"
+version = ">=0.8.5"
+platform."darwin/amd64" = "gvproxy-darwin"
+platform."darwin/arm64" = "gvproxy-darwin"
+executable = true
+destination = "share/vendor/podman/gvproxy"
+
+[tool.mazette.asset.vfkit]
+repo = "crc-org/vfkit"
+version = ">=0.6.1"
+platform."darwin/amd64" = "vfkit"
+platform."darwin/arm64" = "vfkit"
+executable = true
+destination = "share/vendor/podman/vfkit"
+
+[tool.mazette.asset.machine-os]
+repo = "containers/podman-machine-os"
+version = ">=5.5.0"
+platform."darwin/amd64" = "podman-machine.x86_64.applehv.raw.zst"
+platform."darwin/arm64" = "podman-machine.aarch64.applehv.raw.zst"
+destination = "share/machine.tar"
+
+# FIXME: Merge this section with the previous one, once Podman 5.6 is out, and
+# they provide WSL2 machine images from their release page
+# (see https://github.com/containers/podman-machine-os/issues/154).
+#
+# In the meantime, I've created a repo where I've packaged the Podman machine
+# image for WSL2, following the instructions in
+# https://github.com/freedomofpress/dangerzone/issues/1145#issuecomment-2935272159
+[tool.mazette.asset.machine-os-wsl]
+repo = "apyrgio/podman-machine-os"
+version = ">=5.5.0"
+platform."windows/amd64" = "podman-machine.x86_64.wsl.tar.zst"
+destination = "share/machine.tar"
+
+[tool.mazette.asset.cosign]
repo = "sigstore/cosign"
version = ">=2.5.0"
platform."darwin/amd64" = "cosign-darwin-amd64"
diff --git a/setup-windows.py b/setup-windows.py
index ac5331a3e..a2f417daf 100644
--- a/setup-windows.py
+++ b/setup-windows.py
@@ -28,5 +28,15 @@
Executable(
"install/windows/dangerzone-cli.py", base=None, icon="share/dangerzone.ico"
),
+ Executable(
+ "install/windows/dangerzone-image.py",
+ base=None,
+ icon="share/dangerzone.ico",
+ ),
+ Executable(
+ "install/windows/dangerzone-machine.py",
+ base=None,
+ icon="share/dangerzone.ico",
+ ),
],
)
diff --git a/setup.py b/setup.py
index 352123f46..b76beb26d 100644
--- a/setup.py
+++ b/setup.py
@@ -50,6 +50,9 @@ def data_files_list():
"dangerzone.conversion",
"dangerzone.gui",
"dangerzone.isolation_provider",
+ "dangerzone.podman",
+ "dangerzone.podman.command",
+ "dangerzone.podman.errors",
"dangerzone.updater",
],
data_files=data_files_list(),
@@ -63,6 +66,7 @@ def data_files_list():
"dangerzone = dangerzone:main",
"dangerzone-cli = dangerzone:main",
"dangerzone-image = dangerzone.updater.cli:main",
+ "dangerzone-machine= dangerzone.podman.cli:main",
]
},
)
diff --git a/share/dangerzone.css b/share/dangerzone.css
index 4436a255f..2ad1ef8c0 100644
--- a/share/dangerzone.css
+++ b/share/dangerzone.css
@@ -72,3 +72,39 @@ MainWindow[OSColorMode="dark"] QLabel[style="warning"] {
color: #FFD970;
border-color: #665A00;
}
+
+/*
+ * Light mode assumes background-color: #ECECEC
+ */
+MainWindow[OSColorMode="light"] QLabel[style="status-error"] {
+ color: #9D0000;
+ font-weight: bold;
+}
+
+MainWindow[OSColorMode="light"] QLabel[style="status-attention"] {
+ color: #7A3800;
+ font-weight: bold;
+}
+
+MainWindow[OSColorMode="light"] QLabel[style="status-success"] {
+ color: #005000;
+ font-weight: bold;
+}
+
+/*
+ * Dark mode assumes background-color: #323232
+ */
+MainWindow[OSColorMode="dark"] QLabel[style="status-error"] {
+ color: #FF9494;
+ font-weight: bold;
+}
+
+MainWindow[OSColorMode="dark"] QLabel[style="status-attention"] {
+ color: #FFD18F;
+ font-weight: bold;
+}
+
+MainWindow[OSColorMode="dark"] QLabel[style="status-success"] {
+ color: #86EE72;
+ font-weight: bold;
+}
diff --git a/share/info-circle-dark.svg b/share/info-circle-dark.svg
new file mode 100644
index 000000000..dbff0ef5e
--- /dev/null
+++ b/share/info-circle-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/info-circle.svg b/share/info-circle.svg
new file mode 100644
index 000000000..a09fa5f13
--- /dev/null
+++ b/share/info-circle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/spinner-dark.svg b/share/spinner-dark.svg
new file mode 100644
index 000000000..bd434dbcc
--- /dev/null
+++ b/share/spinner-dark.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/share/spinner.svg b/share/spinner.svg
new file mode 100644
index 000000000..91a2ee52d
--- /dev/null
+++ b/share/spinner.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
index aa1a4479d..81a4447b7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -4,12 +4,16 @@
import zipfile
from pathlib import Path
from typing import Any, Callable, Generator, List
+from unittest.mock import MagicMock
import pytest
+from pytest_mock import MockerFixture
+from dangerzone import container_utils, startup
from dangerzone.document import SAFE_EXTENSION
from dangerzone.gui import Application
from dangerzone.isolation_provider import container
+from dangerzone.podman.machine import PodmanMachineManager
from dangerzone.settings import Settings
sys.dangerzone_dev = True # type: ignore[attr-defined]
@@ -22,10 +26,17 @@
TAMPERED_SIGNATURES_PATH = ASSETS_PATH / "signatures" / "tampered"
+@pytest.fixture(autouse=True)
+def isolated_settings(mocker: MockerFixture, tmp_path: Path) -> Settings:
+ mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
+ return Settings()
+
+
@pytest.fixture(autouse=True)
def setup_function() -> Generator[None, None, None]:
# Reset the settings singleton between each test.
Settings._singleton = None
+ container_utils.init_podman_command.cache_clear()
yield
@@ -36,6 +47,13 @@ def qapp_cls() -> typing.Type[Application]:
return Application
+# Use this fixture to make `pytest-qt` invoke our custom QApplication.
+# See https://pytest-qt.readthedocs.io/en/latest/qapplication.html#testing-custom-qapplications
+@pytest.fixture(autouse=True)
+def machine_stop(mocker: MockerFixture) -> MagicMock:
+ return mocker.patch("dangerzone.podman.command.machine_manager.MachineManager.stop")
+
+
@pytest.fixture
def unreadable_pdf(tmp_path: Path) -> str:
file_path = tmp_path / "document.pdf"
diff --git a/tests/gui/conftest.py b/tests/gui/conftest.py
index 28df27474..747c855e6 100644
--- a/tests/gui/conftest.py
+++ b/tests/gui/conftest.py
@@ -4,42 +4,19 @@
import pytest
from pytest import MonkeyPatch
from pytest_mock import MockerFixture
+from pytestqt.qtbot import QtBot
from dangerzone import util
from dangerzone.gui import Application
from dangerzone.gui.logic import DangerzoneGui
-from dangerzone.gui.updater import UpdaterThread
+from dangerzone.gui.main_window import MainWindow
from dangerzone.isolation_provider.dummy import Dummy
-def get_qt_app() -> Application:
- if Application.instance() is None: # type: ignore [call-arg]
- return Application()
- else:
- return Application.instance() # type: ignore [call-arg]
-
-
-def generate_isolated_updater(
- tmp_path: Path,
- mocker: MockerFixture,
- mock_app: bool = False,
-) -> UpdaterThread:
- """Generate an Updater class with its own settings."""
- app = mocker.MagicMock() if mock_app else get_qt_app()
-
- dummy = Dummy()
- mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
-
- dangerzone = DangerzoneGui(app, isolation_provider=dummy)
- updater = UpdaterThread(dangerzone)
- return updater
-
-
-@pytest.fixture
-def updater(tmp_path: Path, mocker: MockerFixture) -> UpdaterThread:
- return generate_isolated_updater(tmp_path, mocker, mock_app=True)
-
-
@pytest.fixture
-def qt_updater(tmp_path: Path, mocker: MockerFixture) -> UpdaterThread:
- return generate_isolated_updater(tmp_path, mocker, mock_app=False)
+def dangerzone_gui(
+ qtbot: QtBot, mocker: MockerFixture, tmp_path: Path
+) -> DangerzoneGui:
+ mock_app = mocker.MagicMock()
+ dummy = mocker.MagicMock(spec=Dummy)
+ return DangerzoneGui(mock_app, dummy)
diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py
index 02ae9f1d5..c5ee7b647 100644
--- a/tests/gui/test_main_window.py
+++ b/tests/gui/test_main_window.py
@@ -3,14 +3,24 @@
import platform
import shutil
import time
-from typing import List
+import typing
+from typing import Any, Generator, List
from unittest.mock import MagicMock
from pytest import MonkeyPatch, fixture
from pytest_mock import MockerFixture
from pytestqt.qtbot import QtBot
-from dangerzone import errors
+# FIXME: See https://github.com/freedomofpress/dangerzone/issues/320 for more details.
+if typing.TYPE_CHECKING:
+ from PySide2 import QtCore, QtGui, QtWidgets
+else:
+ try:
+ from PySide6 import QtCore, QtGui, QtWidgets
+ except ImportError:
+ from PySide2 import QtCore, QtGui, QtWidgets
+
+from dangerzone import container_utils, errors, settings, startup
from dangerzone.document import Document
from dangerzone.gui import MainWindow
from dangerzone.gui import main_window as main_window_module
@@ -19,13 +29,11 @@
# import Pyside related objects from here to avoid duplicating import logic.
from dangerzone.gui.main_window import (
- ContentWidget,
- InstallContainerThread,
+ ConversionWidget,
QtCore,
QtGui,
- WaitingWidgetContainer,
+ # WaitingWidgetContainer,
)
-from dangerzone.gui.updater import UpdaterThread
from dangerzone.isolation_provider.container import Container
from dangerzone.isolation_provider.dummy import Dummy
from dangerzone.updater import (
@@ -47,12 +55,12 @@ def dummy(mocker: MockerFixture) -> None:
@fixture
-def content_widget(qtbot: QtBot, mocker: MockerFixture) -> ContentWidget:
+def conversion_widget(qtbot: QtBot, mocker: MockerFixture) -> ConversionWidget:
# Setup
mock_app = mocker.MagicMock()
dummy = mocker.MagicMock()
dz = DangerzoneGui(mock_app, dummy)
- w = ContentWidget(dz)
+ w = ConversionWidget(dz)
qtbot.addWidget(w)
return w
@@ -102,51 +110,99 @@ def drag_text_event(mocker: MockerFixture) -> QtGui.QDropEvent:
return ev
+def create_main_window(
+ qtbot: QtBot, mocker: MockerFixture, tmp_path: pathlib.Path
+) -> MainWindow:
+ mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
+ mock_app = mocker.MagicMock()
+ dummy = mocker.MagicMock(spec=Dummy)
+ dz = DangerzoneGui(mock_app, dummy)
+
+ window = MainWindow(dz)
+ qtbot.addWidget(window)
+ return window
+
+
+@fixture
+def window(
+ qtbot: QtBot, mocker: MockerFixture, tmp_path: pathlib.Path
+) -> Generator[MainWindow, Any, Any]:
+ window = create_main_window(qtbot, mocker, tmp_path)
+ yield window
+
+ # FIXME: There's something in pytest that:
+ #
+ # 1. calls the `.closeEvent()` method of the `MainWindow` fixture, and
+ # 2. does not respect the `.ignore()` method of the event, and closes immediately
+ #
+ # Because we spawn a thread to perform the shutdown tasks, and no one waits this
+ # thread, this chain of events leads to the following behavior:
+ #
+ # PASSED
+ #
+ # =============== 1 passed in 3.18s ==================
+ # QThread: Destroyed while thread '' is still running
+ # Aborted (core dumped)
+ #
+ # It looks like `QtBot` is the culprit, but I haven't found a better way to affect
+ # it's behavior. In order to circumvent it, we can eagerly wait for the shutdown
+ # thread to finish, which is not pretty nor stable, but works for now.
+ if hasattr(window, "shutdown_thread"):
+ window.shutdown_thread.wait()
+
+
def test_default_menu(
- qtbot: QtBot,
- updater: UpdaterThread,
+ qtbot: QtBot, mocker: MockerFixture, tmp_path: pathlib.Path
) -> None:
"""Check that the default menu entries are in order."""
- updater.dangerzone.settings.set("updater_check_all", True)
+ mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
+ settings.Settings().set("updater_check_all", True)
+ window = create_main_window(qtbot, mocker, tmp_path)
- window = MainWindow(updater.dangerzone)
menu_actions = window.hamburger_button.menu().actions()
- assert len(menu_actions) == 3
+ assert len(menu_actions) == 4
toggle_updates_action = menu_actions[0]
assert toggle_updates_action.text() == "Check for updates"
assert toggle_updates_action.isChecked()
- separator = menu_actions[1]
+ view_logs_action = menu_actions[1]
+ assert view_logs_action.text() == "View logs"
+
+ separator = menu_actions[2]
assert separator.isSeparator()
- exit_action = menu_actions[2]
+ exit_action = menu_actions[3]
assert exit_action.text() == "Exit"
# Let's pretend we planned to have a update already
- updater.dangerzone.settings.set("updater_remote_log_index", 1000, autosave=True)
+ window.dangerzone.settings.set("updater_remote_log_index", 1000, autosave=True)
toggle_updates_action.trigger()
assert not toggle_updates_action.isChecked()
- assert updater.dangerzone.settings.get("updater_check_all") is False
+ assert window.dangerzone.settings.get("updater_check_all") is False
# We keep the remote log index in case updates are activated back
# It doesn't mean they will be applied.
- assert updater.dangerzone.settings.get("updater_remote_log_index") is 1000
+ assert window.dangerzone.settings.get("updater_remote_log_index") is 1000
def test_no_new_release(
qtbot: QtBot,
- updater: UpdaterThread,
monkeypatch: MonkeyPatch,
mocker: MockerFixture,
+ window: MainWindow,
) -> None:
"""Test that when no new release has been detected, the user is not alerted."""
+ for task in window.startup_thread.tasks:
+ should_skip = not isinstance(task, startup.UpdateCheckTask)
+ mocker.patch.object(task, "should_skip", return_value=should_skip)
+
# Check that when no update is detected, e.g., due to update cooldown, an empty
# report is received that does not affect the menu entries.
curtime = int(time.time())
- updater.dangerzone.settings.set("updater_check_all", True)
- updater.dangerzone.settings.set("updater_errors", 9)
- updater.dangerzone.settings.set("updater_last_check", curtime)
- updater.dangerzone.settings.set("updater_remote_log_index", 0)
+ window.dangerzone.settings.set("updater_check_all", True)
+ window.dangerzone.settings.set("updater_errors", 9)
+ window.dangerzone.settings.set("updater_last_check", curtime)
+ window.dangerzone.settings.set("updater_remote_log_index", 0)
expected_settings = default_updater_settings()
expected_settings["updater_check_all"] = True
@@ -154,39 +210,40 @@ def test_no_new_release(
expected_settings["updater_last_check"] = curtime
expected_settings["updater_remote_log_index"] = 0
- window = MainWindow(updater.dangerzone)
- window.register_update_handler(updater.finished)
- handle_updates_spy = mocker.spy(window, "handle_updates")
-
menu_actions_before = window.hamburger_button.menu().actions()
+ check_for_updates_spy = mocker.spy(releases, "check_for_updates")
+ window.startup_thread.start()
+ window.startup_thread.wait()
- with qtbot.waitSignal(updater.finished):
- updater.start()
+ def assertions() -> None:
+ # Check that the callback function gets an empty report.
+ assert_report_equal(check_for_updates_spy.spy_return, EmptyReport())
- # Check that the callback function gets an empty report.
- handle_updates_spy.assert_called_once()
- assert_report_equal(handle_updates_spy.call_args.args[0], EmptyReport())
+ # Check that the menu entries remain exactly the same.
+ menu_actions_after = window.hamburger_button.menu().actions()
+ assert menu_actions_before == menu_actions_after
- # Check that the menu entries remain exactly the same.
- menu_actions_after = window.hamburger_button.menu().actions()
- assert menu_actions_before == menu_actions_after
+ # Check that any previous update errors are cleared.
+ assert window.dangerzone.settings.get_updater_settings() == expected_settings
- # Check that any previous update errors are cleared.
- assert updater.dangerzone.settings.get_updater_settings() == expected_settings
+ qtbot.waitUntil(assertions)
def test_new_release_is_detected(
qtbot: QtBot,
- qt_updater: UpdaterThread,
monkeypatch: MonkeyPatch,
mocker: MockerFixture,
+ window: MainWindow,
) -> None:
"""Test that a newly detected version leads to a notification to the user."""
+ for task in window.startup_thread.tasks:
+ should_skip = not isinstance(task, startup.UpdateCheckTask)
+ mocker.patch.object(task, "should_skip", return_value=should_skip)
- qt_updater.dangerzone.settings.set("updater_check_all", True)
- qt_updater.dangerzone.settings.set("updater_last_check", 0)
- qt_updater.dangerzone.settings.set("updater_errors", 9)
- qt_updater.dangerzone.settings.set("updater_remote_log_index", 0)
+ window.dangerzone.settings.set("updater_check_all", True)
+ window.dangerzone.settings.set("updater_last_check", 0)
+ window.dangerzone.settings.set("updater_errors", 9)
+ window.dangerzone.settings.set("updater_remote_log_index", 0)
# Make requests.get().json() return the following dictionary.
mock_upstream_info = {"tag_name": "99.9.9", "body": "changelog"}
@@ -195,10 +252,8 @@ def test_new_release_is_detected(
requests_mock().status_code = 200 # type: ignore [call-arg]
requests_mock().json.return_value = mock_upstream_info # type: ignore [attr-defined, call-arg]
- window = MainWindow(qt_updater.dangerzone)
- window.register_update_handler(qt_updater.finished)
- handle_updates_spy = mocker.spy(window, "handle_updates")
load_svg_spy = mocker.spy(main_window_module, "load_svg_image")
+ handle_app_update_available_spy = mocker.spy(window, "handle_app_update_available")
# Let's pretend we have a new container image out by bumping the remote logindex
mocker.patch(
@@ -206,30 +261,32 @@ def test_new_release_is_detected(
return_value=[None, 1000000000000000000, None],
)
menu_actions_before = window.hamburger_button.menu().actions()
+ check_for_updates_spy = mocker.spy(releases, "check_for_updates")
- with qtbot.waitSignal(qt_updater.finished):
- qt_updater.start()
+ window.startup_thread.start()
+ window.startup_thread.wait()
+
+ qtbot.waitUntil(handle_app_update_available_spy.assert_called_once)
menu_actions_after = window.hamburger_button.menu().actions()
# Check that the callback function gets an update report.
- handle_updates_spy.assert_called_once()
assert_report_equal(
- handle_updates_spy.call_args.args[0],
+ check_for_updates_spy.spy_return,
ReleaseReport("99.9.9", "
changelog
", container_image_bump=True),
)
# Check that the settings have been updated properly.
expected_settings = default_updater_settings()
expected_settings["updater_check_all"] = True
- expected_settings["updater_last_check"] = qt_updater.dangerzone.settings.get(
+ expected_settings["updater_last_check"] = window.dangerzone.settings.get(
"updater_last_check"
)
expected_settings["updater_latest_version"] = "99.9.9"
expected_settings["updater_latest_changelog"] = "
changelog
"
expected_settings["updater_errors"] = 0
expected_settings["updater_remote_log_index"] = 1000000000000000000
- assert qt_updater.dangerzone.settings.get_updater_settings() == expected_settings
+ assert window.dangerzone.settings.get_updater_settings() == expected_settings
# Check that the hamburger icon has changed with the expected SVG image.
assert load_svg_spy.call_count == 2
@@ -241,7 +298,7 @@ def test_new_release_is_detected(
# Check that new menu entries have been added.
menu_actions_after = window.hamburger_button.menu().actions()
- assert len(menu_actions_after) == 5
+ assert len(menu_actions_after) == 6
assert menu_actions_after[2:] == menu_actions_before
success_action = menu_actions_after[0]
@@ -254,7 +311,7 @@ def test_new_release_is_detected(
update_dialog_spy = mocker.spy(main_window_module, "UpdateDialog")
def check_dialog() -> None:
- dialog = qt_updater.dangerzone.app.activeWindow()
+ dialog = QtWidgets.QApplication.activeWindow()
update_dialog_spy.assert_called_once()
kwargs = update_dialog_spy.call_args.kwargs
@@ -276,9 +333,8 @@ def check_dialog() -> None:
with qtbot.waitSignal(collapsible_box.toggle_animation.finished):
collapsible_box.toggle_button.click()
- # FIXME:
- # assert dialog.sizeHint().height() > height_initial
- # assert dialog.sizeHint().width() == width_initial
+ assert dialog.sizeHint().height() > height_initial
+ assert dialog.sizeHint().width() == width_initial
# Collapse the "What's New" section, and ensure that the dialog's height gets
# back to the original value.
@@ -298,45 +354,49 @@ def check_dialog() -> None:
def test_update_error(
qtbot: QtBot,
- qt_updater: UpdaterThread,
monkeypatch: MonkeyPatch,
mocker: MockerFixture,
+ window: MainWindow,
) -> None:
"""Test that an error during an update check leads to a notification to the user."""
+ for task in window.startup_thread.tasks:
+ should_skip = not isinstance(task, startup.UpdateCheckTask)
+ mocker.patch.object(task, "should_skip", return_value=should_skip)
+
# Test 1 - Check that the first error does not notify the user.
- qt_updater.dangerzone.settings.set("updater_check_all", True)
- qt_updater.dangerzone.settings.set("updater_last_check", 0)
- qt_updater.dangerzone.settings.set("updater_errors", 0)
+ window.dangerzone.settings.set("updater_check_all", True)
+ window.dangerzone.settings.set("updater_last_check", 0)
+ window.dangerzone.settings.set("updater_errors", 0)
# Make requests.get() return an error
mocker.patch("dangerzone.updater.releases.requests.get")
requests_mock = releases.requests.get
requests_mock.side_effect = Exception("failed") # type: ignore [attr-defined]
- window = MainWindow(qt_updater.dangerzone)
- window.register_update_handler(qt_updater.finished)
- handle_updates_spy = mocker.spy(window, "handle_updates")
+ handle_update_check_failed_spy = mocker.spy(window, "handle_update_check_failed")
load_svg_spy = mocker.spy(main_window_module, "load_svg_image")
menu_actions_before = window.hamburger_button.menu().actions()
+ check_for_updates_spy = mocker.spy(releases, "check_for_updates")
+ window.startup_thread.start()
+ window.startup_thread.wait()
- with qtbot.waitSignal(qt_updater.finished):
- qt_updater.start()
+ qtbot.waitUntil(handle_update_check_failed_spy.assert_called_once)
menu_actions_after = window.hamburger_button.menu().actions()
# Check that the callback function gets an update report.
- handle_updates_spy.assert_called_once()
- assert "failed" in handle_updates_spy.call_args.args[0].error
+ handle_update_check_failed_spy.assert_called_once()
+ assert "failed" in handle_update_check_failed_spy.call_args.args[0]
# Check that the settings have been updated properly.
expected_settings = default_updater_settings()
expected_settings["updater_check_all"] = True
- expected_settings["updater_last_check"] = qt_updater.dangerzone.settings.get(
+ expected_settings["updater_last_check"] = window.dangerzone.settings.get(
"updater_last_check"
)
expected_settings["updater_errors"] += 1
- assert qt_updater.dangerzone.settings.get_updater_settings() == expected_settings
+ assert window.dangerzone.settings.get_updater_settings() == expected_settings
# Check that the hamburger icon has not changed.
assert load_svg_spy.call_count == 0
@@ -345,29 +405,34 @@ def test_update_error(
assert menu_actions_before == menu_actions_after
# Test 2 - Check that the second error does not notify the user either.
- qt_updater.dangerzone.settings.set("updater_last_check", 0)
- with qtbot.waitSignal(qt_updater.finished):
- qt_updater.start()
+ handle_update_check_failed_spy.reset_mock()
+ window.dangerzone.settings.set("updater_last_check", 0)
+ window.startup_thread.start()
+ window.startup_thread.wait()
+
+ qtbot.waitUntil(handle_update_check_failed_spy.assert_called_once)
assert load_svg_spy.call_count == 0
# Check that the settings have been updated properly.
expected_settings["updater_errors"] += 1
- expected_settings["updater_last_check"] = qt_updater.dangerzone.settings.get(
+ expected_settings["updater_last_check"] = window.dangerzone.settings.get(
"updater_last_check"
)
- assert qt_updater.dangerzone.settings.get_updater_settings() == expected_settings
+ assert window.dangerzone.settings.get_updater_settings() == expected_settings
# Check that no menu entries have been added.
assert menu_actions_before == menu_actions_after
# Test 3 - Check that a third error shows a new menu entry.
- qt_updater.dangerzone.settings.set("updater_last_check", 0)
- with qtbot.waitSignal(qt_updater.finished):
- qt_updater.start()
+ handle_update_check_failed_spy.reset_mock()
+ window.dangerzone.settings.set("updater_last_check", 0)
+ window.startup_thread.start()
+ window.startup_thread.wait()
+ qtbot.waitUntil(handle_update_check_failed_spy.assert_called_once)
menu_actions_after = window.hamburger_button.menu().actions()
- assert len(menu_actions_after) == 5
+ assert len(menu_actions_after) == 6
assert menu_actions_after[2:] == menu_actions_before
# Check that the hamburger icon has changed with the expected SVG image.
@@ -387,7 +452,7 @@ def test_update_error(
update_dialog_spy = mocker.spy(main_window_module, "UpdateDialog")
def check_dialog() -> None:
- dialog = qt_updater.dangerzone.app.activeWindow()
+ dialog = QtWidgets.QApplication.activeWindow()
update_dialog_spy.assert_called_once()
kwargs = update_dialog_spy.call_args.kwargs
@@ -415,7 +480,7 @@ def check_dialog() -> None:
def test_change_document_button(
- content_widget: ContentWidget,
+ conversion_widget: ConversionWidget,
qtbot: QtBot,
mocker: MockerFixture,
sample_pdf: str,
@@ -425,12 +490,12 @@ def test_change_document_button(
# Setup first doc selection
file_dialog_mock = mocker.MagicMock()
file_dialog_mock.selectedFiles.return_value = (sample_pdf,)
- content_widget.doc_selection_widget.file_dialog = file_dialog_mock
+ conversion_widget.doc_selection_widget.file_dialog = file_dialog_mock
# Select first file
- with qtbot.waitSignal(content_widget.documents_added):
+ with qtbot.waitSignal(conversion_widget.documents_added):
qtbot.mouseClick(
- content_widget.doc_selection_widget.dangerous_doc_button,
+ conversion_widget.doc_selection_widget.dangerous_doc_button,
QtCore.Qt.MouseButton.LeftButton,
)
file_dialog_mock.accept()
@@ -441,9 +506,9 @@ def test_change_document_button(
file_dialog_mock.selectedFiles.return_value = (tmp_sample_doc,)
# When clicking on "select docs" button
- with qtbot.waitSignal(content_widget.documents_added):
+ with qtbot.waitSignal(conversion_widget.documents_added):
qtbot.mouseClick(
- content_widget.settings_widget.change_selection_button,
+ conversion_widget.settings_widget.change_selection_button,
QtCore.Qt.MouseButton.LeftButton,
)
file_dialog_mock.accept()
@@ -455,201 +520,326 @@ def test_change_document_button(
# Then the final document should be only the second one
docs = [
doc.input_filename
- for doc in content_widget.dangerzone.get_unconverted_documents()
+ for doc in conversion_widget.dangerzone.get_unconverted_documents()
]
assert len(docs) == 1
assert docs[0] == str(tmp_sample_doc)
def test_drop_valid_documents(
- content_widget: ContentWidget,
+ conversion_widget: ConversionWidget,
drag_valid_files_event: QtGui.QDropEvent,
qtbot: QtBot,
) -> None:
with qtbot.waitSignal(
- content_widget.doc_selection_wrapper.documents_selected,
+ conversion_widget.doc_selection_wrapper.documents_selected,
check_params_cb=lambda x: len(x) == 2 and isinstance(x[0], Document),
):
- content_widget.doc_selection_wrapper.dropEvent(drag_valid_files_event)
+ conversion_widget.doc_selection_wrapper.dropEvent(drag_valid_files_event)
def test_drop_text(
- content_widget: ContentWidget,
+ conversion_widget: ConversionWidget,
drag_text_event: QtGui.QDropEvent,
qtbot: QtBot,
) -> None:
with qtbot.assertNotEmitted(
- content_widget.doc_selection_wrapper.documents_selected
+ conversion_widget.doc_selection_wrapper.documents_selected
):
- content_widget.doc_selection_wrapper.dropEvent(drag_text_event)
+ conversion_widget.doc_selection_wrapper.dropEvent(drag_text_event)
def test_drop_1_invalid_doc(
- content_widget: ContentWidget,
+ conversion_widget: ConversionWidget,
drag_1_invalid_file_event: QtGui.QDropEvent,
qtbot: QtBot,
) -> None:
with qtbot.assertNotEmitted(
- content_widget.doc_selection_wrapper.documents_selected
+ conversion_widget.doc_selection_wrapper.documents_selected
):
- content_widget.doc_selection_wrapper.dropEvent(drag_1_invalid_file_event)
+ conversion_widget.doc_selection_wrapper.dropEvent(drag_1_invalid_file_event)
def test_drop_1_invalid_2_valid_documents(
- content_widget: ContentWidget,
+ conversion_widget: ConversionWidget,
drag_1_invalid_and_2_valid_files_event: QtGui.QDropEvent,
qtbot: QtBot,
monkeypatch: MonkeyPatch,
) -> None:
# If we accept to continue
monkeypatch.setattr(
- content_widget.doc_selection_wrapper, "prompt_continue_without", lambda x: True
+ conversion_widget.doc_selection_wrapper,
+ "prompt_continue_without",
+ lambda x: True,
)
# Then the 2 valid docs will be selected
with qtbot.waitSignal(
- content_widget.doc_selection_wrapper.documents_selected,
+ conversion_widget.doc_selection_wrapper.documents_selected,
check_params_cb=lambda x: len(x) == 2 and isinstance(x[0], Document),
):
- content_widget.doc_selection_wrapper.dropEvent(
+ conversion_widget.doc_selection_wrapper.dropEvent(
drag_1_invalid_and_2_valid_files_event
)
# If we refuse to continue
monkeypatch.setattr(
- content_widget.doc_selection_wrapper, "prompt_continue_without", lambda x: False
+ conversion_widget.doc_selection_wrapper,
+ "prompt_continue_without",
+ lambda x: False,
)
# Then no docs will be selected
with qtbot.assertNotEmitted(
- content_widget.doc_selection_wrapper.documents_selected,
+ conversion_widget.doc_selection_wrapper.documents_selected,
):
- content_widget.doc_selection_wrapper.dropEvent(
+ conversion_widget.doc_selection_wrapper.dropEvent(
drag_1_invalid_and_2_valid_files_event
)
-def test_not_available_container_tech_exception(
- qtbot: QtBot, mocker: MockerFixture
+def test_installation_failure_exception(
+ qtbot: QtBot,
+ mocker: MockerFixture,
+ window: MainWindow,
) -> None:
- # Setup
- mock_app = mocker.MagicMock()
- dummy = Dummy()
- fn = mocker.patch.object(dummy, "is_available")
- fn.side_effect = errors.NotAvailableContainerTechException(
- "podman", "podman image ls logs"
+ """Ensures that if an exception is raised during image installation,
+ it is shown in the GUI.
+ """
+ installer = mocker.patch(
+ "dangerzone.updater.installer.install",
+ side_effect=RuntimeError("Error during install"),
)
+ for task in window.startup_thread.tasks:
+ should_skip = not isinstance(task, startup.ContainerInstallTask)
+ mocker.patch.object(task, "should_skip", return_value=should_skip)
- dz = DangerzoneGui(mock_app, dummy)
- widget = WaitingWidgetContainer(dz)
- qtbot.addWidget(widget)
+ handle_container_install_failed_spy = mocker.spy(
+ window.log_window, "handle_task_container_install_failed"
+ )
+ window.startup_thread.start()
+ window.startup_thread.wait()
+ qtbot.waitUntil(handle_container_install_failed_spy.assert_called_once)
- # Assert that the error is displayed in the GUI
- if platform.system() in ["Darwin", "Windows"]:
- assert "Dangerzone requires Docker Desktop" in widget.label.text()
- else:
- assert "Podman is installed but cannot run properly" in widget.label.text()
+ assert installer.call_count == 1
+ assert window.status_bar.message.property("style") == "status-error"
+ assert window.status_bar.message.text() == "Startup failed"
+ assert window.log_window.label.text() == "Installing the Dangerzone sandbox… failed"
+ assert "Error during install" in window.log_window.traceback_widget.toPlainText()
- assert "podman image ls logs" in widget.traceback.toPlainText()
+def test_close_event(
+ qtbot: QtBot,
+ mocker: MockerFixture,
+ dummy: MagicMock,
+ window: MainWindow,
+) -> None:
+ """Test that Dangerzone shuts down normally on closeEvent."""
+ container_name = f"{container_utils.CONTAINER_PREFIX}test"
+ # Mock Podman machine manager
+ mock_podman_machine_manager = mocker.patch(
+ "dangerzone.shutdown.PodmanMachineManager"
+ )
+ # Mock the functions necessary to kill a container.
+ mock_list_containers = mocker.patch(
+ "dangerzone.container_utils.list_containers",
+ return_value=[container_name],
+ )
+ mock_kill_container = mocker.patch(
+ "dangerzone.container_utils.kill_container",
+ )
+ # Mock status bar updates
+ handle_shutdown_begin_spy = mocker.spy(window.status_bar, "handle_shutdown_begin")
+ handle_task_container_stop_spy = mocker.spy(
+ window.status_bar, "handle_task_container_stop"
+ )
+ handle_task_machine_stop_spy = mocker.spy(
+ window.status_bar, "handle_task_machine_stop"
+ )
+ # Mock the alert so that we can close the window.
+ mock_alert = mocker.patch("dangerzone.gui.main_window.Alert")
+ # Mock the exit method of the main window.
+ mock_exit_spy = mocker.spy(window.dangerzone.app, "exit")
+
+ window.close()
+ qtbot.waitUntil(mock_exit_spy.assert_called_once)
+ window.shutdown_thread.wait()
+
+ mock_alert.assert_called_once()
+ if platform.system() != "Linux":
+ mock_podman_machine_manager().stop.assert_called_once()
+ handle_task_machine_stop_spy.assert_called_once()
+ else:
+ mock_podman_machine_manager().stop.assert_not_called()
+ handle_task_machine_stop_spy.assert_not_called()
+ mock_list_containers.assert_called_once()
+ mock_kill_container.assert_called_once_with(container_name)
+ handle_shutdown_begin_spy.assert_called_once()
+ handle_task_container_stop_spy.assert_called_once()
+
+
+def test_user_prompts(qtbot: QtBot, window: MainWindow, mocker: MockerFixture) -> None:
+ """Test prompting users to ask them if they want to enable update checks."""
+ # First run
+ #
+ # When Dangerzone runs for the first time, users should not be asked to enable
+ # updates.
+ for task in window.startup_thread.tasks:
+ if not isinstance(task, startup.UpdateCheckTask):
+ mocker.patch.object(task, "should_skip", return_value=True)
-def test_no_container_tech_exception(qtbot: QtBot, mocker: MockerFixture) -> None:
- # Setup
- mock_app = mocker.MagicMock()
- dummy = mocker.MagicMock()
+ expected_settings = default_updater_settings()
+ expected_settings["updater_check_all"] = None
+ expected_settings["updater_last_check"] = 0
- # Raise
- dummy.is_available.side_effect = errors.NoContainerTechException("podman")
+ window.startup_thread.start()
+ window.startup_thread.wait()
- dz = DangerzoneGui(mock_app, dummy)
- widget = WaitingWidgetContainer(dz)
- qtbot.addWidget(widget)
+ assert window.dangerzone.settings.get_updater_settings() == expected_settings
- # Assert that the error is displayed in the GUI
- if platform.system() in ["Darwin", "Windows"]:
- assert "Dangerzone requires Docker Desktop" in widget.label.text()
- else:
- assert "Dangerzone requires Podman" in widget.label.text()
+ # Second run
+ #
+ # When Dangerzone runs for a second time, users can be prompted to enable update
+ # checks. Depending on their answer, we should either enable or disable them.
+ prompt_mock = mocker.patch("dangerzone.gui.updater.UpdateCheckPrompt")
+ prompt_mock().x_pressed = False
+ # Check disabling update checks.
+ prompt_mock().launch.return_value = False
+ expected_settings["updater_check_all"] = False
+ handle_needs_user_input_spy = mocker.spy(window, "handle_needs_user_input")
-def test_installation_failure_exception(qtbot: QtBot, mocker: MockerFixture) -> None:
- """Ensures that if an exception is raised during image installation,
- it is shown in the GUI.
- """
- # Setup install to raise an exception
- mock_app = mocker.MagicMock()
+ window.startup_thread.start()
+ window.startup_thread.wait()
- mocker.patch(
- "dangerzone.gui.main_window.get_installation_strategy",
- return_value=InstallationStrategy.INSTALL_LOCAL_CONTAINER,
- )
- dummy = mocker.MagicMock(spec=Container)
- installer = mocker.patch(
- "dangerzone.gui.main_window.apply_installation_strategy",
- side_effect=RuntimeError("Error during install"),
- )
+ qtbot.waitUntil(handle_needs_user_input_spy.assert_called_once)
+ assert window.dangerzone.settings.get_updater_settings() == expected_settings
- dz = DangerzoneGui(mock_app, dummy)
+ # Reset the "updater_check_all" field and check enabling update checks.
+ window.dangerzone.settings.set("updater_check_all", None)
+ prompt_mock().launch.return_value = True
+ expected_settings["updater_check_all"] = True
- # Mock the InstallContainerThread to call the original run method instead of
- # starting a new thread
- mocker.patch.object(InstallContainerThread, "start", InstallContainerThread.run)
- widget = WaitingWidgetContainer(dz)
- qtbot.addWidget(widget)
+ handle_needs_user_input_spy.reset_mock()
+ window.startup_thread.start()
+ window.startup_thread.wait()
- assert installer.call_count == 1
+ qtbot.waitUntil(handle_needs_user_input_spy.assert_called_once)
+ assert window.dangerzone.settings.get_updater_settings() == expected_settings
- assert "Error during install" in widget.traceback.toPlainText()
- assert "RuntimeError" in widget.traceback.toPlainText()
+ # Third run
+ #
+ # From the third run onwards, users should never be prompted for enabling update
+ # checks.
+ prompt_mock().side_effect = RuntimeError("Should not be called")
+ for check in [True, False]:
+ window.dangerzone.settings.set("updater_check_all", check)
+ assert releases.should_check_for_updates(window.dangerzone.settings) == check
-def test_up_to_date_docker_desktop_does_nothing(
- qtbot: QtBot, mocker: MockerFixture, dummy: MagicMock
+def test_machine_stop_others_user_input(
+ qtbot: QtBot,
+ mocker: MockerFixture,
+ window: MainWindow,
) -> None:
- dummy.check_docker_desktop_version.return_value = (True, "1.0.0")
+ mocker.patch("platform.system", return_value="Darwin")
+ mock_podman_machine_manager = mocker.patch(
+ "dangerzone.startup.PodmanMachineManager"
+ )
+ mock_podman_machine_manager.return_value.list_other_running_machines.return_value = [
+ "other_machine"
+ ]
+ mocker.patch("dangerzone.shutdown.PodmanMachineManager")
+ mock_stop = mocker.patch("dangerzone.startup.MachineStopOthersTask.run")
+ mock_fail = mocker.patch("dangerzone.startup.MachineStopOthersTask.fail")
- mock_app = mocker.MagicMock()
- dz = DangerzoneGui(mock_app, dummy)
+ # Mock the Alert dialog
+ mock_alert = mocker.patch("dangerzone.gui.main_window.Alert")
- window = MainWindow(dz)
- qtbot.addWidget(window)
+ # Simulate user choosing to stop the machine and remember choice
+ mock_alert.return_value.launch.return_value = main_window_module.Alert.Accepted
+ mock_alert.return_value.checkbox.isChecked.return_value = False
+ mock_fail.return_value = False
- menu_actions = window.hamburger_button.menu().actions()
- assert "Docker Desktop should be upgraded" not in [
- a.toolTip() for a in menu_actions
- ]
+ # Ensure only MachineStopOthersTask runs
+ for task in window.startup_thread.tasks:
+ if not isinstance(task, startup.MachineStopOthersTask):
+ mocker.patch.object(task, "should_skip", return_value=True)
+ handle_needs_user_input_stop_others_spy = mocker.spy(
+ window, "handle_needs_user_input_stop_others"
+ )
+ assert window.dangerzone.settings.get("stop_other_podman_machines") == "ask"
-def test_outdated_docker_desktop_displays_warning(
- qtbot: QtBot, mocker: MockerFixture, dummy: MagicMock
-) -> None:
- # Setup install to return False
- mock_app = mocker.MagicMock()
- dummy.check_docker_desktop_version.return_value = (False, "1.0.0")
+ window.startup_thread.start()
+ qtbot.waitUntil(handle_needs_user_input_stop_others_spy.assert_called_once)
+ window.startup_thread.wait()
- dz = DangerzoneGui(mock_app, dummy)
+ mock_alert.assert_called_once()
+ mock_stop.assert_called_once()
- load_svg_spy = mocker.spy(main_window_module, "load_svg_image")
+ # Simulate user choosing to quit and remember choice
+ mock_alert.reset_mock()
+ mock_alert.return_value.launch.return_value = main_window_module.Alert.Rejected
+ mock_alert.return_value.checkbox.isChecked.return_value = True
+ mock_fail.assert_not_called()
- window = MainWindow(dz)
- qtbot.addWidget(window)
+ mock_stop.reset_mock()
- menu_actions = window.hamburger_button.menu().actions()
- assert menu_actions[0].toolTip() == "Docker Desktop should be upgraded"
+ handle_needs_user_input_stop_others_spy.reset_mock()
+ exit_spy = mocker.spy(window.dangerzone.app, "exit")
- # Check that the hamburger icon has changed with the expected SVG image.
- assert load_svg_spy.call_count == 4
- assert (
- load_svg_spy.call_args_list[2].args[0] == "hamburger_menu_update_dot_error.svg"
- )
+ window.startup_thread.start()
+ qtbot.waitUntil(exit_spy.assert_called_once)
+ window.startup_thread.wait()
- alert_spy = mocker.spy(window.alert, "launch")
+ handle_needs_user_input_stop_others_spy.assert_called_once()
+ mock_alert.assert_called_once()
+ mock_stop.assert_not_called()
+ assert window.dangerzone.settings.get("stop_other_podman_machines") == "never"
+ exit_spy.assert_called_once_with(2)
+ mock_fail.assert_called_once()
- # Clicking the menu item should open a warning message
- def _check_alert_displayed() -> None:
- alert_spy.assert_any_call()
- if window.alert:
- window.alert.close()
- QtCore.QTimer.singleShot(0, _check_alert_displayed)
- menu_actions[0].trigger()
+class TestShutdown:
+ def test_begin_shutdown_no_install_required(
+ self, qtbot: QtBot, mocker: MockerFixture, window: MainWindow
+ ) -> None:
+ """Test that shutdown is immediate if no container runtime is used."""
+ mocker.patch.object(
+ window.dangerzone.isolation_provider, "requires_install", return_value=False
+ )
+ mock_exit = mocker.spy(window, "exit")
+ mock_shutdown_thread = mocker.patch("dangerzone.gui.shutdown.ShutdownThread")
+
+ window.begin_shutdown(0)
+
+ mock_exit.assert_called_once_with(0)
+ mock_shutdown_thread.assert_not_called()
+
+ def test_shutdown_with_active_conversion(
+ self, qtbot: QtBot, mocker: MockerFixture, window: MainWindow
+ ) -> None:
+ """Test that the user is prompted before quitting during a conversion."""
+ # Mock that there is a conversion in progress
+ mocker.patch.object(
+ window.dangerzone,
+ "get_converting_documents",
+ return_value=[mocker.MagicMock()],
+ )
+ mock_alert = mocker.patch("dangerzone.gui.main_window.Alert")
+ mock_begin_shutdown = mocker.patch.object(window, "begin_shutdown")
+
+ # Simulate user rejecting the exit
+ mock_alert.return_value.launch.return_value = False
+ event = QtGui.QCloseEvent()
+ window.closeEvent(event)
+ assert event.isAccepted() is False
+ mock_begin_shutdown.assert_not_called()
+
+ # Simulate user accepting the exit
+ mock_alert.return_value.launch.return_value = True
+ event = QtGui.QCloseEvent()
+ window.closeEvent(event)
+ assert event.isAccepted() is False # Ignored because shutdown thread takes over
+ mock_begin_shutdown.assert_called_once_with(2)
diff --git a/tests/gui/test_startup.py b/tests/gui/test_startup.py
new file mode 100644
index 000000000..a2d618351
--- /dev/null
+++ b/tests/gui/test_startup.py
@@ -0,0 +1,316 @@
+import typing
+
+from pytest_mock import MockerFixture
+from pytestqt.qtbot import QtBot
+
+if typing.TYPE_CHECKING:
+ from PySide2 import QtCore
+
+from dangerzone.gui import startup
+from dangerzone.startup import MachineInitTask, MachineStartTask, Task
+from dangerzone.updater import ErrorReport, InstallationStrategy, ReleaseReport
+from dangerzone.updater import errors as update_errors
+
+
+class StartupThreadMocker(startup.StartupThread):
+ def __init__(self, qtbot: QtBot, mocker: MockerFixture) -> None:
+ self.qtbot = qtbot
+ self.mocker = mocker
+ self.task_machine_init = startup.MachineInitTask()
+ self.task_machine_start = startup.MachineStartTask()
+ self.task_update_check = startup.UpdateCheckTask()
+ self.task_container_install = startup.ContainerInstallTask()
+ self.tasks = [
+ self.task_machine_init,
+ self.task_machine_start,
+ self.task_update_check,
+ self.task_container_install,
+ ]
+ self.startup_thread = startup.StartupThread(self.tasks, raise_on_error=False)
+ self.expected_signals: list[tuple[QtCore.SignalInstance, str]] = []
+ self.not_expected_funcs: list[typing.Callable] = []
+
+ def make_machine_task_succeed(self) -> None:
+ self.mocker.patch("platform.system", return_value="Windows")
+ self.mocker.patch("dangerzone.startup.PodmanMachineManager")
+
+ def make_machine_task_skip(self) -> None:
+ self.mocker.patch("platform.system", return_value="Linux")
+
+ def make_machine_task_fail(self) -> None:
+ self.mocker.patch("platform.system", return_value="Windows")
+ self.mocker.patch(
+ "dangerzone.startup.PodmanMachineManager",
+ side_effect=Exception("Forcing task to fail"),
+ )
+
+ def make_update_task_succeed(self) -> None:
+ self.mocker.patch(
+ "dangerzone.updater.releases.should_check_for_updates", return_value=True
+ )
+ self.mocker.patch("dangerzone.updater.releases.check_for_updates")
+
+ def make_update_task_skip(self) -> None:
+ self.mocker.patch(
+ "dangerzone.updater.releases.should_check_for_updates", return_value=False
+ )
+
+ def make_update_task_fail(self) -> None:
+ self.mocker.patch(
+ "dangerzone.updater.releases.should_check_for_updates", return_value=True
+ )
+ self.mocker.patch(
+ "dangerzone.updater.releases.check_for_updates",
+ side_effect=Exception("Forcing task to fail"),
+ )
+
+ def make_install_task_succeed(self) -> None:
+ self.mocker.patch(
+ "dangerzone.updater.installer.get_installation_strategy",
+ return_value=InstallationStrategy.INSTALL_LOCAL_CONTAINER,
+ )
+ self.mocker.patch("dangerzone.updater.installer.install")
+
+ def make_install_task_skip(self) -> None:
+ self.mocker.patch(
+ "dangerzone.updater.installer.get_installation_strategy",
+ return_value=InstallationStrategy.DO_NOTHING,
+ )
+
+ def make_install_task_fail(self) -> None:
+ self.mocker.patch(
+ "dangerzone.updater.installer.get_installation_strategy",
+ return_value=InstallationStrategy.INSTALL_LOCAL_CONTAINER,
+ )
+ self.mocker.patch(
+ "dangerzone.updater.installer.install",
+ side_effect=Exception("Forcing task to fail"),
+ )
+
+ def expect_tasks_succeed(self, tasks: list[Task]) -> None:
+ for task in tasks:
+ if isinstance(task, (MachineInitTask, MachineStartTask)):
+ self.make_machine_task_succeed()
+ elif isinstance(task, startup.UpdateCheckTask):
+ self.make_update_task_succeed()
+ elif isinstance(task, startup.ContainerInstallTask):
+ self.make_install_task_succeed()
+ else:
+ raise RuntimeError(f"Unexpected task: {task}")
+
+ names = ["starting", "succeeded", "completed"]
+ for name in names:
+ self.expected_signals.append(
+ (getattr(task, name), f"{task.__class__.__name__}.{name}")
+ )
+ task.handle_skip = self.mocker.MagicMock() # type: ignore [method-assign]
+ self.not_expected_funcs.append(task.handle_skip)
+ task.handle_error = self.mocker.MagicMock() # type: ignore [method-assign]
+ self.not_expected_funcs.append(task.handle_error)
+
+ def expect_tasks_skip(self, tasks: list[Task]) -> None:
+ for task in tasks:
+ if isinstance(task, (MachineInitTask, MachineStartTask)):
+ self.make_machine_task_skip()
+ elif isinstance(task, startup.UpdateCheckTask):
+ self.make_update_task_skip()
+ elif isinstance(task, startup.ContainerInstallTask):
+ self.make_install_task_skip()
+ else:
+ raise RuntimeError(f"Unexpected task: {task}")
+
+ names = ["skipped", "completed"]
+ for name in names:
+ self.expected_signals.append(
+ (getattr(task, name), f"{task.__class__.__name__}.{name}")
+ )
+ task.handle_start = self.mocker.MagicMock() # type: ignore [method-assign]
+ self.not_expected_funcs.append(task.handle_start)
+ task.handle_error = self.mocker.MagicMock() # type: ignore [method-assign]
+ self.not_expected_funcs.append(task.handle_error)
+
+ def expect_tasks_fail(self, tasks: list[Task]) -> None:
+ for task in tasks:
+ if isinstance(task, (MachineInitTask, MachineStartTask)):
+ self.make_machine_task_fail()
+ elif isinstance(task, startup.UpdateCheckTask):
+ self.make_update_task_fail()
+ elif isinstance(task, startup.ContainerInstallTask):
+ self.make_install_task_fail()
+ else:
+ raise RuntimeError(f"Unexpected task: {task}")
+
+ names = ["starting", "failed"]
+ for name in names:
+ self.expected_signals.append(
+ (getattr(task, name), f"{task.__class__.__name__}.{name}")
+ )
+ task.handle_skip = self.mocker.MagicMock() # type: ignore [method-assign]
+ self.not_expected_funcs.append(task.handle_skip)
+
+ def expect_startup_succeed(self) -> None:
+ self.expected_signals += [
+ (self.startup_thread.starting, "StartupThread.starting"),
+ (self.startup_thread.succeeded, "StartupThread.succeeded"),
+ ]
+ self.startup_thread.handle_error = self.mocker.MagicMock() # type: ignore [method-assign]
+ self.not_expected_funcs.append(self.startup_thread.handle_error)
+
+ def expect_startup_fail(self) -> None:
+ self.expected_signals += [
+ (self.startup_thread.starting, "StartupThread.starting"),
+ (self.startup_thread.failed, "StartupThread.failed"),
+ ]
+ self.startup_thread.handle_success = self.mocker.MagicMock() # type: ignore [method-assign]
+ self.not_expected_funcs.append(self.startup_thread.handle_success)
+
+ def check_run(self) -> None:
+ with self.qtbot.waitSignals(self.expected_signals):
+ self.startup_thread.start()
+ self.startup_thread.wait()
+
+ for func in self.not_expected_funcs:
+ func.assert_not_called() # type: ignore [attr-defined]
+
+
+def test_startup_all_success(qtbot: QtBot, mocker: MockerFixture) -> None:
+ startup_thread = StartupThreadMocker(qtbot, mocker)
+ startup_thread.expect_tasks_succeed(startup_thread.tasks)
+ startup_thread.expect_startup_succeed()
+ startup_thread.check_run()
+
+
+def test_startup_all_skip(qtbot: QtBot, mocker: MockerFixture) -> None:
+ startup_thread = StartupThreadMocker(qtbot, mocker)
+ startup_thread.expect_tasks_skip(startup_thread.tasks)
+ startup_thread.expect_startup_succeed()
+ startup_thread.check_run()
+
+
+def test_startup_machine_init_fail(qtbot: QtBot, mocker: MockerFixture) -> None:
+ startup_thread = StartupThreadMocker(qtbot, mocker)
+ startup_thread.expect_tasks_fail([startup_thread.task_machine_init])
+ startup_thread.expect_startup_fail()
+ startup_thread.check_run()
+
+
+def test_startup_machine_start_fail(qtbot: QtBot, mocker: MockerFixture) -> None:
+ startup_thread = StartupThreadMocker(qtbot, mocker)
+ # NOTE: Make machine_init allowed to fail, so that we can proceed to the
+ # machine_start task.
+ startup_thread.task_machine_init.can_fail = True
+ startup_thread.expect_tasks_fail(
+ [startup_thread.task_machine_init, startup_thread.task_machine_start]
+ )
+ startup_thread.expect_startup_fail()
+ startup_thread.check_run()
+
+
+def test_startup_update_check_fail(qtbot: QtBot, mocker: MockerFixture) -> None:
+ startup_thread = StartupThreadMocker(qtbot, mocker)
+ startup_thread.expect_tasks_succeed(
+ [startup_thread.task_machine_init, startup_thread.task_machine_start]
+ )
+ startup_thread.expect_tasks_fail([startup_thread.task_update_check])
+ startup_thread.expect_tasks_skip([startup_thread.task_container_install])
+ # NOTE: The update check task is a special case, where a failure does not mean that
+ # startup will fail as a whole.
+ startup_thread.expect_startup_succeed()
+ startup_thread.check_run()
+
+
+def test_startup_update_check_needs_user_input(
+ qtbot: QtBot, mocker: MockerFixture
+) -> None:
+ startup_thread = StartupThreadMocker(qtbot, mocker)
+ startup_thread.expect_tasks_succeed(
+ [
+ startup_thread.task_machine_init,
+ startup_thread.task_machine_start,
+ ]
+ )
+ startup_thread.expect_tasks_skip(
+ [
+ startup_thread.task_update_check,
+ startup_thread.task_container_install,
+ ]
+ )
+ startup_thread.expect_startup_succeed()
+
+ mocker.patch(
+ "dangerzone.updater.releases.should_check_for_updates",
+ side_effect=update_errors.NeedUserInput(),
+ )
+ startup_thread.expected_signals.append(
+ (
+ startup_thread.task_update_check.needs_user_input,
+ "UpdateCheckTask.needs_user_input",
+ )
+ )
+ startup_thread.check_run()
+
+
+def test_startup_update_check_app_update(qtbot: QtBot, mocker: MockerFixture) -> None:
+ startup_thread = StartupThreadMocker(qtbot, mocker)
+ startup_thread.expect_tasks_succeed(
+ [
+ startup_thread.task_machine_init,
+ startup_thread.task_machine_start,
+ startup_thread.task_update_check,
+ ]
+ )
+ startup_thread.expect_tasks_skip([startup_thread.task_container_install])
+ startup_thread.expect_startup_succeed()
+
+ mocker.patch(
+ "dangerzone.updater.releases.check_for_updates",
+ return_value=ReleaseReport(version="0.9.9"),
+ )
+ startup_thread.expected_signals.append(
+ (
+ startup_thread.task_update_check.app_update_available,
+ "UpdateCheckTask.app_update_available",
+ )
+ )
+ startup_thread.check_run()
+
+
+def test_startup_update_check_container_update(
+ qtbot: QtBot, mocker: MockerFixture
+) -> None:
+ startup_thread = StartupThreadMocker(qtbot, mocker)
+ startup_thread.expect_tasks_succeed(
+ [
+ startup_thread.task_machine_init,
+ startup_thread.task_machine_start,
+ startup_thread.task_update_check,
+ ]
+ )
+ startup_thread.expect_tasks_skip([startup_thread.task_container_install])
+ startup_thread.expect_startup_succeed()
+
+ mocker.patch(
+ "dangerzone.updater.releases.check_for_updates",
+ return_value=ReleaseReport(container_image_bump=True),
+ )
+ startup_thread.expected_signals.append(
+ (
+ startup_thread.task_update_check.container_update_available,
+ "UpdateCheckTask.container_update_available",
+ )
+ )
+ startup_thread.check_run()
+
+
+def test_startup_container_install_fail(qtbot: QtBot, mocker: MockerFixture) -> None:
+ startup_thread = StartupThreadMocker(qtbot, mocker)
+ startup_thread.expect_tasks_succeed(
+ [
+ startup_thread.task_machine_init,
+ startup_thread.task_machine_start,
+ startup_thread.task_update_check,
+ ]
+ )
+ startup_thread.expect_tasks_fail([startup_thread.task_container_install])
+ startup_thread.expect_startup_fail()
+ startup_thread.check_run()
diff --git a/tests/gui/test_status_bar.py b/tests/gui/test_status_bar.py
new file mode 100644
index 000000000..51d1f73cc
--- /dev/null
+++ b/tests/gui/test_status_bar.py
@@ -0,0 +1,89 @@
+import pytest
+from PySide6.QtGui import QPixmap
+from PySide6.QtSvgWidgets import QSvgWidget
+from pytest_mock import MockerFixture
+from pytestqt.qtbot import QtBot
+
+from dangerzone.gui.logic import DangerzoneGui
+from dangerzone.gui.main_window import StatusBar
+
+
+@pytest.fixture
+def status_bar(qtbot: QtBot, mocker: MockerFixture) -> StatusBar:
+ animate_svg_mock = mocker.patch("dangerzone.gui.main_window.animate_svg_image")
+ animate_svg_mock.return_value = QSvgWidget()
+ load_svg_mock = mocker.patch("dangerzone.gui.main_window.load_svg_image")
+ load_svg_mock.return_value = QPixmap(15, 15)
+ mock_app = mocker.MagicMock()
+ mock_app.os_color_mode.value = "light"
+ dummy = mocker.MagicMock()
+ dangerzone_gui = DangerzoneGui(mock_app, dummy)
+ widget = StatusBar(dangerzone_gui)
+ qtbot.addWidget(widget)
+ return widget
+
+
+def test_status_bar_initial_state(status_bar: StatusBar) -> None:
+ assert status_bar.message.text() == ""
+ assert not status_bar.spinner.isHidden()
+ assert not status_bar.info_icon.isHidden()
+
+
+def test_set_status_ok(status_bar: StatusBar) -> None:
+ status_bar.set_status_ok("All good")
+ assert status_bar.message.text() == "All good"
+ assert status_bar.spinner.isHidden()
+ assert status_bar.info_icon.isHidden()
+ assert status_bar.message.property("style") == "status-success"
+
+
+def test_set_status_working(status_bar: StatusBar) -> None:
+ status_bar.set_status_working("Something is happening")
+ assert status_bar.message.text() == "Something is happening"
+ assert not status_bar.spinner.isHidden()
+ assert not status_bar.info_icon.isHidden()
+ assert status_bar.message.property("style") == "status-attention"
+
+
+def test_set_status_error(status_bar: StatusBar) -> None:
+ status_bar.set_status_error("An error occurred")
+ assert status_bar.message.text() == "An error occurred"
+ assert status_bar.spinner.isHidden()
+ assert not status_bar.info_icon.isHidden()
+ assert status_bar.message.property("style") == "status-error"
+
+
+def test_status_bar_dark_mode_svgs(qtbot: QtBot, mocker: MockerFixture) -> None:
+ animate_svg_image_mock = mocker.patch(
+ "dangerzone.gui.main_window.animate_svg_image"
+ )
+ animate_svg_image_mock.return_value = QSvgWidget()
+ load_svg_image_mock = mocker.patch("dangerzone.gui.main_window.load_svg_image")
+ load_svg_image_mock.return_value = QPixmap(15, 15)
+ mock_app = mocker.MagicMock()
+ mock_app.os_color_mode.value = "dark"
+ dummy = mocker.MagicMock()
+ dangerzone_gui = DangerzoneGui(mock_app, dummy)
+ widget = StatusBar(dangerzone_gui)
+ qtbot.addWidget(widget)
+
+ animate_svg_image_mock.assert_called_with("spinner-dark.svg", width=15, height=15)
+ load_svg_image_mock.assert_called_with("info-circle-dark.svg", width=15, height=15)
+
+
+def test_status_bar_light_mode_svgs(qtbot: QtBot, mocker: MockerFixture) -> None:
+ animate_svg_image_mock = mocker.patch(
+ "dangerzone.gui.main_window.animate_svg_image"
+ )
+ animate_svg_image_mock.return_value = QSvgWidget()
+ load_svg_image_mock = mocker.patch("dangerzone.gui.main_window.load_svg_image")
+ load_svg_image_mock.return_value = QPixmap(15, 15)
+ mock_app = mocker.MagicMock()
+ mock_app.os_color_mode.value = "light"
+ dummy = mocker.MagicMock()
+ dangerzone_gui = DangerzoneGui(mock_app, dummy)
+ widget = StatusBar(dangerzone_gui)
+ qtbot.addWidget(widget)
+
+ animate_svg_image_mock.assert_called_with("spinner.svg", width=15, height=15)
+ load_svg_image_mock.assert_called_with("info-circle.svg", width=15, height=15)
diff --git a/tests/gui/test_updater.py b/tests/gui/test_updater.py
index 0f2639d2b..547e802d5 100644
--- a/tests/gui/test_updater.py
+++ b/tests/gui/test_updater.py
@@ -2,18 +2,26 @@
import platform
import sys
import time
+import typing
from pathlib import Path
from typing import Any, Dict, Union
import pytest
-from PySide6 import QtCore
-from pytest import MonkeyPatch
+from pytest import MonkeyPatch, fixture
from pytest_mock import MockerFixture
from pytestqt.qtbot import QtBot
+if typing.TYPE_CHECKING:
+ from PySide2 import QtCore, QtGui, QtWidgets
+else:
+ try:
+ from PySide6 import QtCore, QtGui, QtWidgets
+ except ImportError:
+ from PySide2 import QtCore, QtGui, QtWidgets
+
from dangerzone import settings
-from dangerzone.gui import updater as updater_module
-from dangerzone.gui.updater import UpdaterThread
+from dangerzone.gui.logic import Alert, DangerzoneGui
+from dangerzone.gui.updater import CANCEL_TEXT, OK_TEXT, prompt_for_checks
from dangerzone.updater import releases
from dangerzone.updater.releases import (
EmptyReport,
@@ -23,7 +31,6 @@
from dangerzone.util import get_version
from ..test_settings import default_settings_0_4_1, save_settings
-from .conftest import generate_isolated_updater
def default_updater_settings() -> Dict[str, Any]:
@@ -43,37 +50,36 @@ def assert_report_equal(
report1: Union[ReleaseReport, EmptyReport, ErrorReport],
report2: Union[ReleaseReport, EmptyReport, ErrorReport],
) -> None:
+ assert isinstance(report1, (ReleaseReport, EmptyReport, ErrorReport))
+ assert isinstance(report2, (ReleaseReport, EmptyReport, ErrorReport))
assert type(report1) == type(report2)
# Python dataclasses give us the __eq__ comparison for free
assert report1.__eq__(report2)
-def test_default_updater_settings(updater: UpdaterThread) -> None:
+def test_default_updater_settings(isolated_settings: settings.Settings) -> None:
"""Check that new 0.4.2 installations have the expected updater settings.
This test is mostly a sanity check.
"""
- assert (
- updater.dangerzone.settings.get_updater_settings() == default_updater_settings()
- )
+ assert isolated_settings.get_updater_settings() == default_updater_settings()
-def test_pre_0_4_2_settings(tmp_path: Path, mocker: MockerFixture) -> None:
+def test_pre_0_4_2_settings(isolated_settings: settings.Settings) -> None:
"""Check settings of installations prior to 0.4.2.
Check that installations that have been upgraded from a version < 0.4.2 to >= 0.4.2
will automatically get the default updater settings, even though they never existed
in their settings.json file.
"""
+ tmp_path = isolated_settings.settings_filename.parent
save_settings(tmp_path, default_settings_0_4_1())
- updater = generate_isolated_updater(tmp_path, mocker, mock_app=True)
- assert (
- updater.dangerzone.settings.get_updater_settings() == default_updater_settings()
- )
+ assert isolated_settings.get_updater_settings() == default_updater_settings()
def test_post_0_4_2_settings(
- tmp_path: Path, monkeypatch: MonkeyPatch, mocker: MockerFixture
+ isolated_settings: settings.Settings,
+ monkeypatch: MonkeyPatch,
) -> None:
"""Check settings of installations post-0.4.2.
@@ -83,8 +89,10 @@ def test_post_0_4_2_settings(
erroneously prompted to a version they already have.
"""
# Store the settings of Dangerzone 0.4.2 to the filesystem.
+ tmp_path = isolated_settings.settings_filename.parent
old_settings = settings.Settings.generate_default_settings()
old_settings["updater_latest_version"] = "0.4.2"
+ # isolated_settings.set("updater_last_check", 0)
save_settings(tmp_path, old_settings)
# Mimic an upgrade to version 0.4.3, by making Dangerzone report that the current
@@ -94,24 +102,26 @@ def test_post_0_4_2_settings(
monkeypatch.setattr(settings, "get_version", lambda: "0.4.3")
# Ensure that the Settings class will correct the latest version field to 0.4.3.
- updater = generate_isolated_updater(tmp_path, mocker, mock_app=True)
- assert updater.dangerzone.settings.get_updater_settings() == expected_settings
+ isolated_settings.load()
+ assert isolated_settings.get_updater_settings() == expected_settings
# Simulate an updater check that found a newer Dangerzone version (e.g., 0.4.4).
expected_settings["updater_latest_version"] = "0.4.4"
- updater.dangerzone.settings.set(
+ isolated_settings.set(
"updater_latest_version", expected_settings["updater_latest_version"]
)
- updater.dangerzone.settings.save()
+ isolated_settings.save()
# Ensure that the Settings class will leave the "updater_latest_version" field
# intact the next time we reload the settings.
- updater.dangerzone.settings.load()
- assert updater.dangerzone.settings.get_updater_settings() == expected_settings
+ isolated_settings.load()
+ assert isolated_settings.get_updater_settings() == expected_settings
@pytest.mark.skipif(platform.system() != "Linux", reason="Linux-only test")
-def test_linux_no_check(updater: UpdaterThread, monkeypatch: MonkeyPatch) -> None:
+def test_linux_no_check(
+ isolated_settings: settings.Settings, monkeypatch: MonkeyPatch
+) -> None:
"""Ensure that Dangerzone on Linux does not make any update check."""
expected_settings = default_updater_settings()
expected_settings["updater_check_all"] = False
@@ -120,67 +130,24 @@ def test_linux_no_check(updater: UpdaterThread, monkeypatch: MonkeyPatch) -> Non
# XXX: Simulate Dangerzone installed via package manager.
monkeypatch.delattr(sys, "dangerzone_dev")
- assert updater.should_check_for_updates() is False
- assert updater.dangerzone.settings.get_updater_settings() == expected_settings
-
-
-def test_user_prompts(updater: UpdaterThread, mocker: MockerFixture) -> None:
- """Test prompting users to ask them if they want to enable update checks."""
- settings = updater.dangerzone.settings
- # First run
- #
- # When Dangerzone runs for the first time, users should not be asked to enable
- # updates.
- expected_settings = default_updater_settings()
- expected_settings["updater_check_all"] = None
- expected_settings["updater_last_check"] = 0
- assert updater.should_check_for_updates() is False
- assert settings.get_updater_settings() == expected_settings
-
- # Second run
- #
- # When Dangerzone runs for a second time, users can be prompted to enable update
- # checks. Depending on their answer, we should either enable or disable them.
- mocker.patch("dangerzone.gui.updater.UpdateCheckPrompt")
- prompt_mock = updater_module.UpdateCheckPrompt
- prompt_mock().x_pressed = False
-
- # Check disabling update checks.
- prompt_mock().launch.return_value = False # type: ignore [attr-defined]
- expected_settings["updater_check_all"] = False
- assert updater.should_check_for_updates() is False
- assert settings.get_updater_settings() == expected_settings
-
- # Reset the "updater_check_all" field and check enabling update checks.
- settings.set("updater_check_all", None)
- prompt_mock().launch.return_value = True # type: ignore [attr-defined]
- expected_settings["updater_check_all"] = True
- assert updater.should_check_for_updates() is True
- assert settings.get_updater_settings() == expected_settings
-
- # Third run
- #
- # From the third run onwards, users should never be prompted for enabling update
- # checks.
- prompt_mock().side_effect = RuntimeError("Should not be called") # type: ignore [attr-defined]
- for check in [True, False]:
- settings.set("updater_check_all", check)
- assert updater.should_check_for_updates() == check
+ assert releases.should_check_for_updates(isolated_settings) is False
+ assert isolated_settings.get_updater_settings() == expected_settings
def test_update_checks(
- updater: UpdaterThread, monkeypatch: MonkeyPatch, mocker: MockerFixture
+ isolated_settings: settings.Settings,
+ monkeypatch: MonkeyPatch,
+ mocker: MockerFixture,
) -> None:
"""Test version update checks."""
- settings = updater.dangerzone.settings
+ settings = isolated_settings
# This dictionary will simulate GitHub's response.
mock_upstream_info = {"tag_name": f"v{get_version()}", "body": "changelog"}
# Make requests.get().json() return the above dictionary.
- mocker.patch("dangerzone.updater.releases.requests.get")
- requests_mock = updater_module.releases.requests.get
- requests_mock().status_code = 200 # type: ignore [call-arg]
- requests_mock().json.return_value = mock_upstream_info # type: ignore [attr-defined, call-arg]
+ requests_mock = mocker.patch("dangerzone.updater.releases.requests.get")
+ requests_mock().status_code = 200
+ requests_mock().json.return_value = mock_upstream_info
mocker.patch(
"dangerzone.updater.releases.get_remote_digest_and_logindex",
@@ -205,9 +172,11 @@ def test_update_checks(
)
# Test 3 - Check that HTTP errors are converted to error reports.
- requests_mock.side_effect = Exception("failed") # type: ignore [attr-defined]
+ requests_mock.side_effect = Exception("failed")
report = releases.check_for_updates(settings)
- error_msg = f"Encountered an exception while checking {updater_module.releases.GH_RELEASE_URL}: failed"
+ error_msg = (
+ f"Encountered an exception while checking {releases.GH_RELEASE_URL}: failed"
+ )
assert_report_equal(report, ErrorReport(error=error_msg))
# Test 4 - Check that cached version/changelog info do not trigger an update check.
@@ -220,18 +189,21 @@ def test_update_checks(
)
-def test_update_checks_cooldown(updater: UpdaterThread, mocker: MockerFixture) -> None:
+def test_update_checks_cooldown(
+ isolated_settings: settings.Settings,
+ monkeypatch: MonkeyPatch,
+ mocker: MockerFixture,
+) -> None:
"""Make sure Dangerzone only checks for updates every X hours"""
- settings = updater.dangerzone.settings
+ settings = isolated_settings
settings.set("updater_check_all", True)
settings.set("updater_last_check", 0)
# Mock some functions before the tests start
- cooldown_spy = mocker.spy(updater_module.releases, "_should_postpone_update_check")
- timestamp_mock = mocker.patch.object(updater_module.releases, "_get_now_timestamp")
- mocker.patch("dangerzone.updater.releases.requests.get")
- requests_mock = updater_module.releases.requests.get
+ cooldown_spy = mocker.spy(releases, "_should_postpone_update_check")
+ timestamp_mock = mocker.patch.object(releases, "_get_now_timestamp")
+ requests_mock = mocker.patch("dangerzone.updater.releases.requests.get")
# Mock the response of the container updater check
mocker.patch(
@@ -241,8 +213,8 @@ def test_update_checks_cooldown(updater: UpdaterThread, mocker: MockerFixture) -
# # Make requests.get().json() return the version info that we want.
mock_upstream_info = {"tag_name": "99.9.9", "body": "changelog"}
- requests_mock().status_code = 200 # type: ignore [call-arg]
- requests_mock().json.return_value = mock_upstream_info # type: ignore [attr-defined, call-arg]
+ requests_mock().status_code = 200
+ requests_mock().json.return_value = mock_upstream_info
# Test 1: The first time Dangerzone checks for updates, the cooldown period should
# not stop it. Once we learn about an update, the last check setting should be
@@ -260,7 +232,7 @@ def test_update_checks_cooldown(updater: UpdaterThread, mocker: MockerFixture) -
# previous one.
curtime += 1
timestamp_mock.return_value = curtime
- requests_mock.side_effect = Exception("failed") # type: ignore [attr-defined]
+ requests_mock.side_effect = Exception("failed")
settings.set("updater_latest_version", get_version())
settings.set("updater_latest_changelog", None)
@@ -271,7 +243,7 @@ def test_update_checks_cooldown(updater: UpdaterThread, mocker: MockerFixture) -
# Test 3: Advance the current time by seconds. Ensure that
# Dangerzone checks for updates again, and the last check timestamp gets bumped.
- curtime += updater_module.releases.UPDATE_CHECK_COOLDOWN_SECS
+ curtime += releases.UPDATE_CHECK_COOLDOWN_SECS
timestamp_mock.return_value = curtime
requests_mock.side_effect = None
@@ -286,22 +258,26 @@ def test_update_checks_cooldown(updater: UpdaterThread, mocker: MockerFixture) -
settings.set("updater_latest_version", get_version())
settings.set("updater_latest_changelog", None)
- curtime += updater_module.releases.UPDATE_CHECK_COOLDOWN_SECS
+ curtime += releases.UPDATE_CHECK_COOLDOWN_SECS
timestamp_mock.return_value = curtime
requests_mock.side_effect = Exception("failed")
report = releases.check_for_updates(settings)
assert cooldown_spy.spy_return is False
assert settings.get("updater_last_check") == curtime
- error_msg = f"Encountered an exception while checking {updater_module.releases.GH_RELEASE_URL}: failed"
+ error_msg = (
+ f"Encountered an exception while checking {releases.GH_RELEASE_URL}: failed"
+ )
assert_report_equal(report, ErrorReport(error=error_msg))
def test_update_errors(
- updater: UpdaterThread, monkeypatch: MonkeyPatch, mocker: MockerFixture
+ isolated_settings: settings.Settings,
+ monkeypatch: MonkeyPatch,
+ mocker: MockerFixture,
) -> None:
"""Test update check errors."""
- settings = updater.dangerzone.settings
+ settings = isolated_settings
# Always assume that we can perform multiple update checks in a row.
monkeypatch.setattr(releases, "_should_postpone_update_check", lambda _: False)
mocker.patch(
@@ -310,11 +286,10 @@ def test_update_errors(
)
# Mock requests.get().
- mocker.patch("dangerzone.updater.releases.requests.get")
- requests_mock = releases.requests.get
+ requests_mock = mocker.patch("dangerzone.updater.releases.requests.get")
# Test 1 - Check that request exceptions are being detected as errors.
- requests_mock.side_effect = Exception("bad url") # type: ignore [attr-defined]
+ requests_mock.side_effect = Exception("bad url")
report = releases.check_for_updates(settings)
assert type(report) == ErrorReport
assert report.error is not None
@@ -325,8 +300,8 @@ def test_update_errors(
class MockResponse500:
status_code = 500
- requests_mock.return_value = MockResponse500() # type: ignore [attr-defined]
- requests_mock.side_effect = None # type: ignore [attr-defined]
+ requests_mock.return_value = MockResponse500()
+ requests_mock.side_effect = None
report = releases.check_for_updates(settings)
assert type(report) == ErrorReport
assert report.error is not None
@@ -339,7 +314,7 @@ class MockResponseBadJSON:
def json(self) -> dict:
return json.loads("bad json")
- requests_mock.return_value = MockResponseBadJSON() # type: ignore [attr-defined]
+ requests_mock.return_value = MockResponseBadJSON()
report = releases.check_for_updates(settings)
assert type(report) == ErrorReport
assert report.error is not None
@@ -352,7 +327,7 @@ class MockResponseEmpty:
def json(self) -> dict:
return {}
- requests_mock.return_value = MockResponseEmpty() # type: ignore [attr-defined]
+ requests_mock.return_value = MockResponseEmpty()
report = releases.check_for_updates(settings)
assert type(report) == ErrorReport
assert report.error is not None
@@ -365,7 +340,7 @@ class MockResponseBadVersion:
def json(self) -> dict:
return {"tag_name": "vbad_version", "body": "changelog"}
- requests_mock.return_value = MockResponseBadVersion() # type: ignore [attr-defined]
+ requests_mock.return_value = MockResponseBadVersion()
report = releases.check_for_updates(settings)
assert type(report) == ErrorReport
assert report.error is not None
@@ -378,7 +353,7 @@ class MockResponseBadMarkdown:
def json(self) -> dict:
return {"tag_name": "v99.9.9", "body": ["bad", "markdown"]}
- requests_mock.return_value = MockResponseBadMarkdown() # type: ignore [attr-defined]
+ requests_mock.return_value = MockResponseBadMarkdown()
report = releases.check_for_updates(settings)
assert type(report) == ErrorReport
assert report.error is not None
@@ -390,65 +365,41 @@ class MockResponseValid:
def json(self) -> dict:
return {"tag_name": "v99.9.9", "body": "changelog"}
- requests_mock.return_value = MockResponseValid() # type: ignore [attr-defined]
+ requests_mock.return_value = MockResponseValid()
report = releases.check_for_updates(settings)
assert_report_equal(report, ReleaseReport("99.9.9", "
changelog
"))
def test_update_check_prompt(
- qtbot: QtBot, qt_updater: UpdaterThread, mocker: MockerFixture
+ dangerzone_gui: DangerzoneGui,
) -> None:
"""Test that the prompt to enable update checks works properly."""
- # Force Dangerzone to check immediately for updates
- settings = qt_updater.dangerzone.settings
- settings.set("updater_last_check", 0)
- # Test 1 - Check that on the second run of Dangerzone, the user is prompted to
- # choose if they want to enable update checks.
+ # Force Dangerzone to check immediately for updates
+ # Test 1 - The user is prompted to choose if they want to enable update checks, and
+ # they agree.
def check_button_labels() -> None:
- dialog = qt_updater.dangerzone.app.activeWindow()
- assert dialog.ok_button.text() == updater_module.OK_TEXT # type: ignore [attr-defined]
- assert dialog.cancel_button.text() == updater_module.CANCEL_TEXT # type: ignore [attr-defined]
+ dialog = QtWidgets.QApplication.activeWindow()
+ assert dialog.ok_button.text() == OK_TEXT # type: ignore [attr-defined]
+ assert dialog.cancel_button.text() == CANCEL_TEXT # type: ignore [attr-defined]
dialog.ok_button.click() # type: ignore [attr-defined]
QtCore.QTimer.singleShot(500, check_button_labels)
- mocker.patch(
- "dangerzone.updater.releases._should_postpone_update_check", return_value=False
- )
- assert qt_updater.should_check_for_updates()
-
- # Test 2 - Check that when the user chooses to enable update checks, we
- # store that decision in the settings.
- settings.set("updater_check_all", None, autosave=True)
-
- def click_ok() -> None:
- dialog = qt_updater.dangerzone.app.activeWindow()
- dialog.ok_button.click() # type: ignore [attr-defined]
-
- QtCore.QTimer.singleShot(500, click_ok)
- assert qt_updater.should_check_for_updates()
- assert settings.get("updater_check_all") is True
-
- # Test 3 - Same as the previous test, but check that clicking on cancel stores the
- # opposite decision.
- settings.set("updater_check_all", None)
+ assert prompt_for_checks(dangerzone_gui)
+ # Test 2 - Same as the previous test, but the user disagrees.
def click_cancel() -> None:
- dialog = qt_updater.dangerzone.app.activeWindow()
+ dialog = QtWidgets.QApplication.activeWindow()
dialog.cancel_button.click() # type: ignore
QtCore.QTimer.singleShot(500, click_cancel)
- assert not qt_updater.should_check_for_updates()
- assert settings.get("updater_check_all") is False
+ assert not prompt_for_checks(dangerzone_gui)
- # Test 4 - Same as the previous test, but check that clicking on "X" does not store
+ # Test 3 - Same as the previous test, but check that clicking on "X" does not store
# any decision.
- settings.set("updater_check_all", None, autosave=True)
-
def click_x() -> None:
- dialog = qt_updater.dangerzone.app.activeWindow()
+ dialog = QtWidgets.QApplication.activeWindow()
dialog.close()
QtCore.QTimer.singleShot(500, click_x)
- assert not qt_updater.should_check_for_updates()
- assert settings.get("updater_check_all") is None
+ assert prompt_for_checks(dangerzone_gui) is None
diff --git a/tests/isolation_provider/test_container.py b/tests/isolation_provider/test_container.py
index 442e3d6e1..f5a316470 100644
--- a/tests/isolation_provider/test_container.py
+++ b/tests/isolation_provider/test_container.py
@@ -6,9 +6,10 @@
from pytest_subprocess import FakeProcess
from dangerzone import errors
-from dangerzone.container_utils import Runtime, expected_image_name
+from dangerzone.container_utils import expected_image_name, init_podman_command
from dangerzone.isolation_provider.container import Container
from dangerzone.isolation_provider.qubes import is_qubes_native_conversion
+from dangerzone.podman import machine
from dangerzone.updater import SignatureError, UpdaterError
from dangerzone.util import get_resource_path
@@ -28,122 +29,34 @@ def provider(skip_image_verification: None) -> Container:
@pytest.fixture
def runtime_path() -> str:
- return str(Runtime().path)
+ return str(init_podman_command().runner.podman_path)
class TestContainer(IsolationProviderTest):
- def test_is_available_raises(
- self, provider: Container, fp: FakeProcess, runtime_path: str
- ) -> None:
- """
- NotAvailableContainerTechException should be raised when
- the "podman image ls" command fails.
- """
- fp.register_subprocess(
- [runtime_path, "image", "ls"],
- returncode=-1,
- stderr="podman image ls logs",
- )
- with pytest.raises(errors.NotAvailableContainerTechException):
- provider.is_available()
+ @classmethod
+ def setup_class(cls) -> None:
+ if platform.system() != "Linux":
+ m = machine.PodmanMachineManager()
+ m.init()
+ m.start()
- def test_is_available_works(
- self, provider: Container, fp: FakeProcess, runtime_path: str
- ) -> None:
- """
- No exception should be raised when the "podman image ls" can return properly.
- """
- fp.register_subprocess(
- [runtime_path, "image", "ls"],
- )
- provider.is_available()
-
- @pytest.mark.skipif(
- platform.system() not in ("Windows", "Darwin"),
- reason="macOS and Windows specific",
- )
- def test_old_docker_desktop_version_is_detected(
- self, mocker: MockerFixture, provider: Container, fp: FakeProcess
- ) -> None:
- fp.register_subprocess(
- [
- "docker",
- "version",
- "--format",
- "{{.Server.Platform.Name}}",
- ],
- stdout="Docker Desktop 1.0.0 (173100)",
- )
-
- mocker.patch(
- "dangerzone.isolation_provider.container.MINIMUM_DOCKER_DESKTOP",
- {"Darwin": "1.0.1", "Windows": "1.0.1"},
- )
- assert (False, "1.0.0") == provider.check_docker_desktop_version()
-
- @pytest.mark.skipif(
- platform.system() not in ("Windows", "Darwin"),
- reason="macOS and Windows specific",
- )
- def test_up_to_date_docker_desktop_version_is_detected(
- self, mocker: MockerFixture, provider: Container, fp: FakeProcess
- ) -> None:
- fp.register_subprocess(
- [
- "docker",
- "version",
- "--format",
- "{{.Server.Platform.Name}}",
- ],
- stdout="Docker Desktop 1.0.1 (173100)",
- )
-
- # Require version 1.0.1
- mocker.patch(
- "dangerzone.isolation_provider.container.MINIMUM_DOCKER_DESKTOP",
- {"Darwin": "1.0.1", "Windows": "1.0.1"},
- )
- assert (True, "1.0.1") == provider.check_docker_desktop_version()
-
- fp.register_subprocess(
- [
- "docker",
- "version",
- "--format",
- "{{.Server.Platform.Name}}",
- ],
- stdout="Docker Desktop 2.0.0 (173100)",
- )
- assert (True, "2.0.0") == provider.check_docker_desktop_version()
-
- @pytest.mark.skipif(
- platform.system() not in ("Windows", "Darwin"),
- reason="macOS and Windows specific",
- )
- def test_docker_desktop_version_failure_returns_true(
- self, mocker: MockerFixture, provider: Container, fp: FakeProcess
- ) -> None:
- fp.register_subprocess(
- [
- "docker",
- "version",
- "--format",
- "{{.Server.Platform.Name}}",
- ],
- stderr="Oopsie",
- returncode=1,
- )
- assert provider.check_docker_desktop_version() == (True, "")
-
- @pytest.mark.skipif(
- platform.system() != "Linux",
- reason="Linux specific",
- )
- def test_linux_skips_desktop_version_check_returns_true(
- self, provider: Container
- ) -> None:
- assert (True, "") == provider.check_docker_desktop_version()
+ @classmethod
+ def teardownn_class(cls) -> None:
+ if platform.system() != "Linux":
+ m = machine.PodmanMachineManager()
+ m.stop()
class TestContainerTermination(IsolationProviderTermination):
- pass
+ @classmethod
+ def setup_class(cls) -> None:
+ if platform.system() != "Linux":
+ m = machine.PodmanMachineManager()
+ m.init()
+ m.start()
+
+ @classmethod
+ def teardownn_class(cls) -> None:
+ if platform.system() != "Linux":
+ m = machine.PodmanMachineManager()
+ m.stop()
diff --git a/tests/podman/test_machine.py b/tests/podman/test_machine.py
new file mode 100644
index 000000000..37637473a
--- /dev/null
+++ b/tests/podman/test_machine.py
@@ -0,0 +1,294 @@
+import json
+import platform
+from pathlib import Path
+from typing import Callable
+from unittest.mock import MagicMock
+
+import pytest
+from pytest_mock import MockerFixture
+from pytest_subprocess import FakeProcess
+
+from dangerzone import container_utils
+from dangerzone import errors as dz_errors
+from dangerzone.podman import errors
+from dangerzone.podman.machine import PodmanMachineManager
+from dangerzone.util import get_version
+
+
+@pytest.fixture
+def machine_manager(mocker: MockerFixture) -> PodmanMachineManager:
+ return PodmanMachineManager()
+
+
+@pytest.fixture
+def podman_register(fp: FakeProcess, machine_manager: PodmanMachineManager) -> Callable:
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ podman_path = str(machine_manager.podman.runner.podman_path)
+
+ base_cmd = [podman_path]
+ if platform.system() != "Linux":
+ base_cmd += [
+ "--connection",
+ machine_name,
+ "--storage-opt",
+ "overlay.mount_program=/usr/bin/fuse-overlayfs",
+ ]
+
+ def fp_register(cmd: list[str], **kwargs): # type: ignore [no-untyped-def]
+ return fp.register(base_cmd + cmd, **kwargs)
+
+ return fp_register
+
+
+def test_initialize_machine_no_existing(
+ machine_manager: PodmanMachineManager, podman_register: Callable
+) -> None:
+ """Test that the initialize_machine method runs the correct commands when no machine exists."""
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ image_path = str(machine_manager._get_machine_image_path())
+ rec_list = podman_register(
+ ["machine", "list", "--format", "json"], stdout=json.dumps([])
+ )
+ rec_init = podman_register(
+ [
+ "machine",
+ "init",
+ machine_name,
+ "--image",
+ image_path,
+ "--timezone",
+ "Etc/UTC",
+ ]
+ )
+ machine_manager.init()
+ assert rec_list.call_count() == 1
+ assert rec_init.call_count() == 1
+
+
+def test_initialize_machine_stale_exists(
+ machine_manager: PodmanMachineManager, podman_register: Callable
+) -> None:
+ """Test that stale machines are removed during initialization."""
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ stale_machine_name = "dz-internal-stale"
+ image_path = str(machine_manager._get_machine_image_path())
+
+ rec_list = podman_register(
+ ["machine", "list", "--format", "json"],
+ stdout=json.dumps([{"Name": stale_machine_name}]),
+ )
+ rec_rm = podman_register(["machine", "rm", stale_machine_name, "--force"])
+ rec_init = podman_register(
+ [
+ "machine",
+ "init",
+ machine_name,
+ "--image",
+ image_path,
+ "--timezone",
+ "Etc/UTC",
+ ]
+ )
+ machine_manager.init()
+ assert rec_list.call_count() == 1
+ assert rec_rm.call_count() == 1
+ assert rec_init.call_count() == 1
+
+
+def test_start_machine_success(
+ machine_manager: PodmanMachineManager, podman_register: Callable
+) -> None:
+ """Test that the machine starts normally if nothing else is running"""
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ if platform.system() == "Darwin":
+ rec_list_other = podman_register(
+ ["machine", "list", "--format", "json"], stdout=json.dumps([])
+ )
+ rec_start = podman_register(["machine", "start", machine_name])
+ machine_manager.start()
+ if platform.system() == "Darwin":
+ assert rec_list_other.call_count() == 1
+ assert rec_start.call_count() == 1
+
+
+def test_start_machine_already_running(
+ machine_manager: PodmanMachineManager,
+ podman_register: Callable,
+ mocker: MockerFixture,
+) -> None:
+ """Test that start() does not fail if machine is already running."""
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ rec_start = podman_register(["machine", "start", machine_name], returncode=1)
+ if platform.system() == "Darwin":
+ rec_list_other = podman_register(
+ ["machine", "list", "--format", "json"],
+ stdout=json.dumps([{"Name": machine_name, "Running": True}]),
+ )
+ rec_list = podman_register(
+ ["machine", "list", "--format", "json"],
+ stdout=json.dumps([{"Name": machine_name, "Running": True}]),
+ )
+ machine_manager.start()
+ if platform.system() == "Darwin":
+ assert rec_list_other.call_count() == 1
+ assert rec_start.call_count() == 1
+ assert rec_list.call_count() == 1
+
+
+@pytest.mark.skipif(platform.system() != "Darwin", reason="MacOS-specific")
+def test_start_machine_already_running_other_fail(
+ machine_manager: PodmanMachineManager,
+ podman_register: Callable,
+) -> None:
+ """Test that start() fails if another machine is already running on macOS."""
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ if platform.system() == "Darwin":
+ rec_list_other = podman_register(
+ ["machine", "list", "--format", "json"],
+ stdout=json.dumps([{"Name": "other_machine", "Running": True}]),
+ )
+ rec_start = podman_register(["machine", "start", machine_name])
+ with pytest.raises(dz_errors.OtherMachineRunningError):
+ machine_manager.start()
+ if platform.system() == "Darwin":
+ assert rec_list_other.call_count() == 1
+ assert rec_start.call_count() == 0
+
+
+def test_start_machine_stopped_other_success(
+ machine_manager: PodmanMachineManager,
+ podman_register: Callable,
+) -> None:
+ """Test that start() works if another stopped machine exists."""
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ if platform.system() == "Darwin":
+ rec_list_other = podman_register(
+ ["machine", "list", "--format", "json"],
+ stdout=json.dumps([{"Name": "other_machine", "Running": False}]),
+ )
+ rec_start = podman_register(["machine", "start", machine_name])
+ machine_manager.start()
+ if platform.system() == "Darwin":
+ assert rec_list_other.call_count() == 1
+ assert rec_start.call_count() == 1
+
+
+def test_start_machine_fail(
+ machine_manager: PodmanMachineManager, podman_register: Callable
+) -> None:
+ """Test that start() fails if `podman machine start` fails and the machine is not
+ running."""
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ rec_start = podman_register(["machine", "start", machine_name], returncode=1)
+ if platform.system() == "Darwin":
+ rec_list_other = podman_register(
+ ["machine", "list", "--format", "json"], stdout=json.dumps([])
+ )
+ rec_list = podman_register(
+ ["machine", "list", "--format", "json"],
+ stdout=json.dumps([{"Name": machine_name, "Running": False}]),
+ )
+ with pytest.raises(errors.CommandError):
+ machine_manager.start()
+ if platform.system() == "Darwin":
+ assert rec_list_other.call_count() == 1
+ assert rec_start.call_count() == 1
+ assert rec_list.call_count() == 1
+
+
+def test_stop_machine(
+ machine_manager: PodmanMachineManager,
+ podman_register: Callable,
+ machine_stop: MagicMock,
+ mocker: MockerFixture,
+) -> None:
+ """Test that the stop_machine method runs the correct commands."""
+ # Undo the global mock for this specific test, in order to trigger the underlying
+ # subprocess command.
+ mocker.stop(machine_stop)
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ rec_stop = podman_register(["machine", "stop", machine_name])
+ machine_manager.stop()
+ assert rec_stop.call_count() == 1
+
+
+def test_remove_machine(
+ machine_manager: PodmanMachineManager, podman_register: Callable
+) -> None:
+ """Test that the remove_machine method runs the correct commands."""
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ rec_rm = podman_register(["machine", "rm", machine_name, "--force"])
+ machine_manager.remove()
+ assert rec_rm.call_count() == 1
+
+
+def test_reset_machines(
+ machine_manager: PodmanMachineManager, podman_register: Callable
+) -> None:
+ """Test that the reset_machines method runs the correct commands."""
+ rec_reset = podman_register(["machine", "reset", "--force"])
+ machine_manager.reset()
+ assert rec_reset.call_count() == 1
+
+
+def test_list_dangerzone_machines(
+ machine_manager: PodmanMachineManager, podman_register: Callable
+) -> None:
+ """Test that list_dangerzone_machines filters correctly."""
+ rec_list = podman_register(
+ ["machine", "list", "--format", "json"],
+ stdout=json.dumps(
+ [{"Name": "dz-internal-1"}, {"Name": "other"}, {"Name": "dz-internal-2"}]
+ ),
+ )
+ machines = machine_manager.list()
+ assert len(machines) == 2
+ assert machines[0]["Name"] == "dz-internal-1"
+ assert machines[1]["Name"] == "dz-internal-2"
+ assert rec_list.call_count() == 1
+
+
+def test_run_raw_podman_command(
+ machine_manager: PodmanMachineManager, podman_register: Callable
+) -> None:
+ """Test that run_raw_podman_command executes the command."""
+ raw_command = ["info"]
+ recorder = podman_register(raw_command)
+ machine_manager.run_raw_podman_command(raw_command)
+ assert recorder.call_count() == 1
+
+
+def test_initialize_machine_with_timezone(
+ machine_manager: PodmanMachineManager, podman_register: Callable
+) -> None:
+ """Test that the initialize_machine method runs the correct commands when no machine exists."""
+ version = get_version()
+ machine_name = f"dz-internal-{version}"
+ image_path = str(machine_manager._get_machine_image_path())
+ rec_list = podman_register(
+ ["machine", "list", "--format", "json"], stdout=json.dumps([])
+ )
+ rec_init = podman_register(
+ [
+ "machine",
+ "init",
+ machine_name,
+ "--image",
+ image_path,
+ "--timezone",
+ "America/New_York",
+ ]
+ )
+ machine_manager.init(timezone="America/New_York")
+ assert rec_list.call_count() == 1
+ assert rec_init.call_count() == 1
diff --git a/tests/test_cli.py b/tests/test_cli.py
index ffb602216..78602e17c 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -19,6 +19,7 @@
from pytest_mock import MockerFixture
from strip_ansi import strip_ansi
+from dangerzone import errors
from dangerzone.cli import cli_main, display_banner
from dangerzone.document import ARCHIVE_SUBDIR, SAFE_EXTENSION
from dangerzone.isolation_provider.qubes import is_qubes_native_conversion
@@ -117,7 +118,10 @@ def __str__(self) -> str:
class TestCli:
def run_cli(
- self, args: Sequence[str] | str = (), tmp_path: Optional[Path] = None
+ self,
+ args: Sequence[str] | str = (),
+ tmp_path: Optional[Path] = None,
+ linger: bool = True,
) -> CLIResult:
"""Run the CLI with the provided arguments.
@@ -133,6 +137,10 @@ def run_cli(
# to tokenize it.
args = (args,)
+ # Make Windows/macOS tests faster by not stopping the Podman machine all the
+ # time.
+ if linger:
+ args = ("--linger", *args)
if os.environ.get("DUMMY_CONVERSION", False):
args = ("--unsafe-dummy-conversion", *args)
@@ -187,6 +195,21 @@ def test_version(self) -> None:
version = f.read().strip()
assert version in result.stdout
+ @pytest.mark.skipif(
+ os.environ.get("DUMMY_CONVERSION", False)
+ or os.environ.get("QUBES_CONVERSION", False),
+ reason="Test requires a container-based isolation provider",
+ )
+ def test_other_machine_running_error(
+ self, mocker: MockerFixture, sample_pdf: str
+ ) -> None:
+ mocker.patch(
+ "dangerzone.startup.MachineStopOthersTask.should_skip",
+ side_effect=errors.OtherMachineRunningError("Test error"),
+ )
+ result = self.run_cli([sample_pdf])
+ result.assert_failure(exit_code=1)
+
class TestCliConversion(TestCliBasic):
def test_invalid_lang(self, sample_pdf: str) -> None:
@@ -342,7 +365,9 @@ def test_filenames(self, filename: str, tmp_path: Path, sample_pdf: str) -> None
shutil.copyfile(sample_pdf, doc_path)
result = self.run_cli(doc_path)
result.assert_success()
- assert len(os.listdir(tmp_path)) == 2
+ # NOTE: The temp path should contain the two documents and the settings JSON
+ # file.
+ assert len(os.listdir(tmp_path)) == 3
def test_bulk(self, tmp_path: Path, sample_pdf: str) -> None:
filenames = ["1.pdf", "2.pdf", "3.pdf"]
@@ -354,7 +379,10 @@ def test_bulk(self, tmp_path: Path, sample_pdf: str) -> None:
result = self.run_cli(file_paths)
result.assert_success()
- assert len(os.listdir(tmp_path)) == 2 * len(filenames)
+
+ # NOTE: The temp path should contain the six documents and the settings JSON
+ # file.
+ assert len(os.listdir(tmp_path)) == 2 * len(filenames) + 1
def test_bulk_fail_on_output_filename(
self, tmp_path: Path, sample_pdf: str
@@ -429,3 +457,66 @@ def test_suspicious_double_dash_and_equals_file(self, tmp_path: Path) -> None:
# TODO: Check that this applies for single dash arguments, and concatenated
# single dash arguments, once Dangerzone supports them.
+
+
+class TestCliShutdown(TestCli):
+ def test_cli_shutdown_normal(
+ self, mocker: MockerFixture, sample_pdf: str, tmp_path: Path
+ ) -> None:
+ """Test that the CLI runs the shutdown sequence on success and error."""
+ mock_container_stop = mocker.patch("dangerzone.shutdown.ContainerStopTask.run")
+ mock_machine_stop = mocker.patch("dangerzone.shutdown.MachineStopTask.run")
+ mock_convert_documents = mocker.patch(
+ "dangerzone.logic.DangerzoneCore.convert_documents",
+ )
+
+ def assert_mocks() -> None:
+ if (
+ os.environ.get("DUMMY_CONVERSION", False)
+ or is_qubes_native_conversion()
+ ):
+ mock_container_stop.assert_not_called()
+ mock_machine_stop.assert_not_called()
+ return
+
+ mock_container_stop.assert_called_once()
+ if platform.system() != "Linux":
+ mock_machine_stop.assert_called_once()
+ else:
+ mock_machine_stop.assert_not_called()
+ mock_container_stop.reset_mock()
+ mock_machine_stop.reset_mock()
+
+ # The CLI should not linger, so that it can call the shutdown tasks.
+ result = self.run_cli(sample_pdf, tmp_path=tmp_path, linger=False)
+ result.assert_success()
+ assert_mocks()
+
+ mock_convert_documents.side_effect = (Exception("Conversion failed"),)
+ result = self.run_cli(sample_pdf, tmp_path=tmp_path, linger=False)
+ result.assert_failure()
+ assert_mocks()
+
+ def test_cli_shutdown_with_linger(
+ self, mocker: MockerFixture, sample_pdf: str, tmp_path: Path
+ ) -> None:
+ """Test that the CLI with --linger does not run the shutdown sequence on
+ success and error."""
+ mock_container_stop = mocker.patch("dangerzone.shutdown.ContainerStopTask.run")
+ mock_machine_stop = mocker.patch("dangerzone.shutdown.MachineStopTask.run")
+ mock_convert_documents = mocker.patch(
+ "dangerzone.logic.DangerzoneCore.convert_documents",
+ )
+
+ result = self.run_cli([sample_pdf], tmp_path=tmp_path)
+ result.assert_success()
+ mock_container_stop.assert_not_called()
+ mock_machine_stop.assert_not_called()
+ mock_container_stop.reset_mock()
+ mock_machine_stop.reset_mock()
+
+ mock_convert_documents.side_effect = (Exception("Conversion failed"),)
+ result = self.run_cli(sample_pdf, tmp_path=tmp_path)
+ result.assert_failure()
+ mock_container_stop.assert_not_called()
+ mock_machine_stop.assert_not_called()
diff --git a/tests/test_container_utils.py b/tests/test_container_utils.py
index db47b5abd..c41179320 100644
--- a/tests/test_container_utils.py
+++ b/tests/test_container_utils.py
@@ -1,60 +1,170 @@
-from pathlib import Path
+import pathlib
+import subprocess
+from typing import Any
+from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
-from dangerzone import errors
-from dangerzone.container_utils import Runtime
-from dangerzone.settings import Settings
+from dangerzone import container_utils, settings
-def test_get_runtime_name_from_settings(mocker: MockerFixture, tmp_path: Path) -> None:
- mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
- mocker.patch("dangerzone.container_utils.Path.exists", return_value=True)
+def test_get_podman_path(mocker: MockerFixture) -> None:
+ """Test that we get the correct Podman path, depending on the distro.
- settings = Settings()
- settings.set("container_runtime", "/opt/somewhere/docker", autosave=True)
+ We should be getting the default Podman installation (None) on Linux, and the
+ vendored path on Windows/macOS. On Windows specifically, it should end with .exe.
+ """
+ mocker.patch("platform.system", return_value="Linux")
+ assert container_utils.get_podman_path() is None
+
+ mocker.patch("platform.system", return_value="Windows")
+ path = container_utils.get_podman_path()
+ assert str(path).endswith("podman.exe")
+ assert "vendor" in str(path)
+
+ mocker.patch("platform.system", return_value="Darwin")
+ path = container_utils.get_podman_path()
+ assert str(path).endswith("podman")
+ assert "vendor" in str(path)
+
+
+def test_create_containers_conf(mocker: MockerFixture, tmp_path: pathlib.Path) -> None:
+ """Test that we don't fail when writing the containers conf file.
+
+ Test that we can write and overwrite the config file for Podman containers, and that
+ the intermediate dirs will be created.
+ """
+ seccomp_path = tmp_path / "seccomp.json"
+ mocker.patch("dangerzone.container_utils.SECCOMP_PATH", seccomp_path)
+ mocker.patch("os.cpu_count", return_value=4)
+
+ path = tmp_path / "path" / "to" / "containers.conf"
+ mocker.patch("platform.system", return_value="Windows")
+ mocker.patch("dangerzone.container_utils.CONTAINERS_CONF_PATH", path)
+ container_utils.create_containers_conf()
+ conf = path.read_text()
+ assert "helper_binaries_dir" in conf
+ assert "cpus=4" in conf
+ assert f'volumes=["{tmp_path}:{tmp_path}:ro"]'.replace("\\", "\\\\") in conf
+
+ container_utils.create_containers_conf()
+ assert conf == path.read_text()
- assert Runtime().name == "docker"
+def test_init_podman_command(mocker: MockerFixture) -> None:
+ cmd = mocker.patch("dangerzone.container_utils.PodmanCommand")
-def test_get_runtime_name_linux(mocker: MockerFixture, tmp_path: Path) -> None:
- mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
mocker.patch("platform.system", return_value="Linux")
- mocker.patch(
- "dangerzone.container_utils.shutil.which", return_value="/usr/bin/podman"
+ container_utils.init_podman_command.cache_clear()
+ container_utils.init_podman_command()
+ cmd.assert_called_once_with(path=None, env=None, options=None)
+
+ for distro in ["Windows", "Darwin"]:
+ mocker.patch("platform.system", return_value=distro)
+ cmd.reset_mock()
+ container_utils.init_podman_command.cache_clear()
+ container_utils.init_podman_command()
+ kwargs = cmd.call_args.kwargs
+ assert "vendor" in str(kwargs["path"])
+ assert kwargs["env"]["CONTAINERS_CONF"] is not None
+ assert kwargs["options"] is not None
+
+
+def test_init_podman_command_custom_runtime(mocker: MockerFixture) -> None:
+ # Test custom runtime
+ # Test Windows/macOS Podman command (env, connection)
+ # Test Linux Podman
+ mocker.patch("pathlib.Path.is_file", return_value=True)
+ mocker.patch("pathlib.Path.exists", return_value=True)
+ runtime = "/some/path/to/podman"
+ settings.Settings().set_custom_runtime(runtime)
+ cmd = mocker.patch("dangerzone.container_utils.PodmanCommand")
+
+ for distro in ["Linux", "Windows", "Darwin"]:
+ cmd.reset_mock()
+ mocker.patch("platform.system", return_value=distro)
+ container_utils.init_podman_command.cache_clear()
+ container_utils.init_podman_command()
+ cmd.assert_called_once_with(path=pathlib.Path(runtime), env=None, options=None)
+
+ # Second attempt, should be cached
+ cmd.reset_mock()
+ container_utils.init_podman_command()
+ cmd.assert_not_called()
+
+
+def test_list_containers(mocker: MockerFixture) -> None:
+ """Test that list_containers returns the correct containers."""
+ # Mock the podman command
+ mock_podman = mocker.patch("dangerzone.container_utils.init_podman_command")
+ mock_podman.return_value.run.return_value = (
+ "dangerzone-container1\ndangerzone-container2\nother-container"
)
- mocker.patch("dangerzone.container_utils.os.path.exists", return_value=True)
- runtime = Runtime()
- assert runtime.name == "podman"
- assert runtime.path == Path("/usr/bin/podman")
+ # Call the function
+ containers = container_utils.list_containers()
-def test_get_runtime_name_non_linux(mocker: MockerFixture, tmp_path: Path) -> None:
- mocker.patch("platform.system", return_value="Windows")
- mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
- mocker.patch(
- "dangerzone.container_utils.shutil.which", return_value="/usr/bin/docker"
+ # Check the result
+ assert containers == ["dangerzone-container1", "dangerzone-container2"]
+ mock_podman.return_value.run.assert_called_once_with(
+ ["ps", "-a", "--format", "{{ .Names }}"]
)
- mocker.patch("dangerzone.container_utils.os.path.exists", return_value=True)
- runtime = Runtime()
- assert runtime.name == "docker"
- assert runtime.path == Path("/usr/bin/docker")
- mocker.patch("platform.system", return_value="Something else")
- runtime = Runtime()
- assert runtime.name == "docker"
- assert runtime.path == Path("/usr/bin/docker")
- assert Runtime().name == "docker"
+def test_list_containers_empty(mocker: MockerFixture) -> None:
+ """Test that list_containers returns an empty list if there are no containers."""
+ # Mock the podman command
+ mock_podman = mocker.patch("dangerzone.container_utils.init_podman_command")
+ mock_podman.return_value.run.return_value = ""
+
+ # Call the function
+ containers = container_utils.list_containers()
+
+ # Check the result
+ assert containers == []
-def test_get_unsupported_runtime_name(mocker: MockerFixture, tmp_path: Path) -> None:
- mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
- settings = Settings()
- settings.set(
- "container_runtime", "/opt/somewhere/new-kid-on-the-block", autosave=True
+def test_kill_container(mocker: MockerFixture) -> None:
+ """Test that kill_container calls the correct podman command."""
+ # Mock the podman command
+ mock_podman = mocker.patch("dangerzone.container_utils.init_podman_command")
+
+ # Call the function
+ container_utils.kill_container("test-container")
+
+ # Check the result
+ mock_podman.return_value.run.assert_called_once_with(
+ ["kill", "test-container"], check=False, timeout=container_utils.TIMEOUT_KILL
)
- with pytest.raises(errors.UnsupportedContainerRuntime):
- assert Runtime().name == "new-kid-on-the-block"
+
+def test_kill_container_timeout(mocker: MockerFixture, caplog: Any) -> None:
+ """Test that kill_container logs a warning on timeout."""
+ # Mock the podman command
+ mock_podman = mocker.patch("dangerzone.container_utils.init_podman_command")
+ mock_podman.return_value.run.side_effect = subprocess.TimeoutExpired(
+ "kill", container_utils.TIMEOUT_KILL
+ )
+
+ # Call the function
+ container_utils.kill_container("test-container")
+
+ # Check the log
+ assert "Could not kill container 'test-container'" in caplog.text
+
+
+def test_kill_container_exception(mocker: MockerFixture, caplog: Any) -> None:
+ """Test that kill_container logs an error on exception."""
+ # Mock the podman command
+ mock_podman = mocker.patch("dangerzone.container_utils.init_podman_command")
+ mock_podman.return_value.run.side_effect = Exception("test error")
+
+ # Call the function
+ container_utils.kill_container("test-container")
+
+ # Check the log
+ assert (
+ "Unexpected error occurred while killing container 'test-container'"
+ in caplog.text
+ )
diff --git a/tests/test_startup.py b/tests/test_startup.py
new file mode 100644
index 000000000..59cbe205b
--- /dev/null
+++ b/tests/test_startup.py
@@ -0,0 +1,162 @@
+from typing import Optional
+
+import pytest
+from pytest_mock import MockerFixture
+
+from dangerzone import startup
+
+
+class StartupSpy:
+ def __init__(self, mocker: MockerFixture):
+ self.mock_task = mocker.MagicMock()
+ self.mock_task.should_skip.return_value = False
+ self.runner = startup.StartupLogic(tasks=[self.mock_task])
+ self.handle_start = mocker.spy(self.runner, "handle_start")
+ self.handle_error = mocker.spy(self.runner, "handle_error")
+ self.handle_success = mocker.spy(self.runner, "handle_success")
+
+
+@pytest.fixture
+def mock_startup_spy(mocker: MockerFixture) -> StartupSpy:
+ return StartupSpy(mocker)
+
+
+def test_startup_success(mock_startup_spy: StartupSpy) -> None:
+ """A task runs to completion"""
+ mock_startup_spy.runner.run()
+
+ mock_startup_spy.handle_start.assert_called_once()
+ mock_startup_spy.handle_success.assert_called_once()
+ mock_startup_spy.handle_error.assert_not_called()
+ mock_startup_spy.mock_task.handle_skip.assert_not_called()
+ mock_startup_spy.mock_task.handle_start.assert_called_once()
+ mock_startup_spy.mock_task.handle_error.assert_not_called()
+ mock_startup_spy.mock_task.handle_success.assert_called_once()
+ mock_startup_spy.mock_task.run.assert_called_once()
+
+
+def test_startup_skip(mock_startup_spy: StartupSpy) -> None:
+ """A task is skipped"""
+ mock_startup_spy.mock_task.should_skip.return_value = True
+ mock_startup_spy.runner.run()
+
+ mock_startup_spy.handle_start.assert_called_once()
+ mock_startup_spy.handle_success.assert_called_once()
+ mock_startup_spy.handle_error.assert_not_called()
+ mock_startup_spy.mock_task.handle_skip.assert_called_once()
+ mock_startup_spy.mock_task.handle_start.assert_not_called()
+ mock_startup_spy.mock_task.handle_error.assert_not_called()
+ mock_startup_spy.mock_task.handle_success.assert_not_called()
+ mock_startup_spy.mock_task.run.assert_not_called()
+
+
+def test_startup_fail_allowed(mock_startup_spy: StartupSpy) -> None:
+ """A task fails, and this is fine."""
+ exc = Exception("failed")
+ mock_startup_spy.mock_task.run.side_effect = exc
+ mock_startup_spy.runner.run()
+
+ mock_startup_spy.handle_start.assert_called_once()
+ mock_startup_spy.handle_success.assert_called_once()
+ mock_startup_spy.handle_error.assert_not_called()
+ mock_startup_spy.mock_task.handle_skip.assert_not_called()
+ mock_startup_spy.mock_task.handle_start.assert_called_once()
+ mock_startup_spy.mock_task.handle_error.assert_called_with(exc)
+ mock_startup_spy.mock_task.handle_success.assert_not_called()
+ mock_startup_spy.mock_task.run.assert_called_once()
+
+
+def test_startup_fail_not_allowed(mock_startup_spy: StartupSpy) -> None:
+ """A task fails, and this is fatal."""
+ exc = Exception("failed")
+ mock_startup_spy.mock_task.run.side_effect = exc
+ mock_startup_spy.mock_task.can_fail = False
+ with pytest.raises(Exception):
+ mock_startup_spy.runner.run()
+
+ mock_startup_spy.handle_start.assert_called_once()
+ mock_startup_spy.handle_success.assert_not_called()
+ mock_startup_spy.handle_error.assert_called_once_with(
+ mock_startup_spy.mock_task, exc
+ )
+ mock_startup_spy.mock_task.handle_skip.assert_not_called()
+ mock_startup_spy.mock_task.handle_start.assert_called_once()
+ mock_startup_spy.mock_task.handle_error.assert_called_with(exc)
+ mock_startup_spy.mock_task.handle_success.assert_not_called()
+ mock_startup_spy.mock_task.run.assert_called_once()
+
+
+@pytest.mark.parametrize(
+ "os_name, other_machines, setting, should_skip, raises_error, calls_prompt, calls_run",
+ [
+ ("Linux", False, "ask", True, False, False, False),
+ ("Windows", False, "ask", True, False, False, False),
+ ("Darwin", False, "ask", True, False, False, False),
+ ("Darwin", True, "always", False, False, False, True),
+ ("Darwin", True, "never", False, True, False, False),
+ ("Darwin", True, "ask", False, False, True, True),
+ ("Darwin", True, "ask", True, True, True, False),
+ ],
+)
+def test_machine_stop_others_task(
+ mocker: MockerFixture,
+ os_name: str,
+ other_machines: bool,
+ setting: bool,
+ should_skip: bool,
+ raises_error: bool,
+ calls_prompt: bool,
+ calls_run: bool,
+) -> None:
+ mocker.patch("platform.system", return_value=os_name)
+ mocker.patch("dangerzone.settings.Settings.get", return_value=setting)
+ mock_podman_machine_manager = mocker.patch(
+ "dangerzone.startup.PodmanMachineManager"
+ ).return_value
+ mock_prompt_user = mocker.patch(
+ "dangerzone.startup.MachineStopOthersTask.prompt_user",
+ return_value=not should_skip,
+ )
+
+ if other_machines:
+ mock_podman_machine_manager.list_other_running_machines.side_effect = [
+ ["other_machine"],
+ ["other_machine"],
+ [],
+ ]
+ else:
+ mock_podman_machine_manager.list_other_running_machines.return_value = []
+
+ task = startup.MachineStopOthersTask()
+ run_spy = mocker.spy(task, "run")
+
+ if raises_error:
+ with pytest.raises(startup.errors.OtherMachineRunningError):
+ task.should_skip()
+
+ if calls_prompt:
+ mock_prompt_user.assert_called_once()
+ else:
+ mock_prompt_user.assert_not_called()
+
+ assert not calls_run
+ run_spy.assert_not_called()
+ else:
+ assert task.should_skip() == should_skip
+ if calls_prompt:
+ mock_prompt_user.assert_called_once()
+ else:
+ mock_prompt_user.assert_not_called()
+
+ if not should_skip:
+ task.run()
+ if calls_run:
+ run_spy.assert_called_once()
+ mock_podman_machine_manager.stop.assert_called_once_with(
+ name="other_machine"
+ )
+ else:
+ run_spy.assert_not_called()
+ else:
+ run_spy.assert_not_called()
+ mock_podman_machine_manager.stop.assert_not_called()