From 596540c4eabf15f35daf37c365e9efb89fb1f908 Mon Sep 17 00:00:00 2001 From: Osama Al Banna Date: Fri, 17 Jul 2026 19:54:32 +0300 Subject: [PATCH] Add opt-in managed Python venv for FUSE mounting on macOS Vorta's bundled Borg binary cannot ship FUSE support on macOS due to notarization and hardened-runtime restrictions, so the Mount feature fails for many users, and the documented Homebrew fuse-tap workaround breaks when FUSE-T (rather than macFUSE) provides the FUSE headers. This adds an alternative, fully opt-in mount mode that builds Borg from source with llfuse inside a private virtual environment, linking against whichever FUSE implementation is already installed. - New setting "Use managed Python environment for mounting" (macOS only, off by default) in a new Mounting group on the Settings view (opened via the "Settings / About" button; MiscTab in code), with a Prepare/Rebuild Environment button and live status output. - New vorta.borg.fuse_venv module: builds the venv at /Vorta/borg-fuse-venv/ via a JobsManager background job, pre-checks for pkg-config and surfaces known build failures (missing pkg-config, missing Xcode CLT) as actionable messages, and only marks the environment ready after validating borg --version and the llfuse import. - BorgMountJob.prepare_bin() now prefers the venv's borg binary when the setting is enabled and the environment is validated; all other Borg commands (create, prune, check, umount, ...) are untouched. - Unit tests covering setting declaration, binary selection, fallback behavior, and that non-mount jobs remain unaffected. Users who never enable the setting see zero behavior change. --- src/vorta/borg/fuse_venv.py | 250 +++++++++++++++++++++++++++++++++++ src/vorta/borg/mount.py | 23 ++++ src/vorta/store/settings.py | 19 ++- src/vorta/views/misc_tab.py | 61 +++++++++ tests/unit/test_fuse_venv.py | 64 +++++++++ 5 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 src/vorta/borg/fuse_venv.py create mode 100644 tests/unit/test_fuse_venv.py diff --git a/src/vorta/borg/fuse_venv.py b/src/vorta/borg/fuse_venv.py new file mode 100644 index 000000000..cebdc90b5 --- /dev/null +++ b/src/vorta/borg/fuse_venv.py @@ -0,0 +1,250 @@ +""" +Managed Python environment used only for `borg mount` on macOS. + +Vorta's bundled Borg binary cannot ship FUSE support on macOS (notarization +restrictions prevent dynamically loading macFUSE/FUSE-T libraries). Building +Borg from source via pip inside a private virtualenv links against whatever +FUSE implementation is already installed and produces a mount-capable binary. + +This module owns the venv location, readiness checks and the background job +that builds it. Only `BorgMountJob` consults it; all other Borg commands keep +using the normal binary resolution in `BorgJob.prepare_bin()`. +""" + +import logging +import os +import shutil +import signal +import subprocess +import sys +from collections import deque +from pathlib import Path +from subprocess import PIPE, STDOUT, Popen, TimeoutExpired + +from PyQt6 import QtCore + +from vorta import config +from vorta.borg.jobs_manager import JobInterface + +logger = logging.getLogger(__name__) + +#: JobsManager site for setup jobs, so they never queue behind repo jobs. +SETUP_JOB_SITE = 'vorta-fuse-venv' + +#: Written only after the built venv passed validation. A partially built +#: venv (failed or cancelled pip install) never has this file. +MARKER_FILE = '.venv-ready' + + +def is_supported() -> bool: + """This workaround only makes sense on macOS.""" + return sys.platform == 'darwin' + + +def venv_path() -> Path: + """Fixed venv location inside Vorta's own application support directory.""" + return Path(config.SETTINGS_DIR) / 'borg-fuse-venv' + + +def venv_borg_bin() -> Path: + return venv_path() / 'bin' / 'borg' + + +def is_venv_ready() -> bool: + """ + Cheap check used at mount time and for UI state. + + The marker file is only written after a successful build was validated + (borg runs and llfuse imports), so checking marker + executable is enough + here without spawning subprocesses on every call. + """ + return (venv_path() / MARKER_FILE).is_file() and os.access(venv_borg_bin(), os.X_OK) + + +def find_system_python() -> str: + """ + Find a Python interpreter to create the venv with. + + `sys.executable` is the Vorta app binary when running from the bundled + `.app`, so it cannot be used here. + """ + candidates = [ + shutil.which('python3'), + '/opt/homebrew/bin/python3', + '/usr/local/bin/python3', + '/usr/bin/python3', + ] + for candidate in candidates: + if candidate and os.access(candidate, os.X_OK): + return candidate + return None + + +def find_pkg_config() -> str: + candidates = [ + shutil.which('pkg-config'), + '/opt/homebrew/bin/pkg-config', + '/usr/local/bin/pkg-config', + ] + for candidate in candidates: + if candidate and os.access(candidate, os.X_OK): + return candidate + return None + + +class FuseVenvSetupJob(JobInterface): + """ + Build (or rebuild) the managed mount venv in a background worker. + + Runs via `JobsManager` under its own site, streaming one-line progress + through `updated` and finishing with `result` = {'ok': bool, 'message': str, + 'borg_version': str}. Never installs anything outside the venv directory; + missing system tools (pkg-config, Xcode CLT) are reported with the exact + command for the user to run themselves. + """ + + updated = QtCore.pyqtSignal(str) + result = QtCore.pyqtSignal(dict) + + def __init__(self, rebuild=False): + super().__init__() + self.rebuild = rebuild + self.process = None + self._cancelled = False + self._output_tail = deque(maxlen=40) # last lines, for error diagnosis + + def repo_id(self): + return SETUP_JOB_SITE + + def cancel(self): + self._cancelled = True + if self.process is not None: + self.process.send_signal(signal.SIGINT) + try: + self.process.wait(timeout=3) + except TimeoutExpired: + try: + os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + except ProcessLookupError: + pass + + def run(self): + path = venv_path() + marker = path / MARKER_FILE + # A rebuild in progress must never count as ready. + if marker.exists(): + marker.unlink() + + try: + self._build() + except _SetupError as e: + logger.warning('Mount venv setup failed: %s', e) + self.result.emit({'ok': False, 'message': str(e), 'borg_version': ''}) + except Exception as e: + logger.exception('Unexpected error during mount venv setup') + self.result.emit({'ok': False, 'message': str(e), 'borg_version': ''}) + + def _build(self): + path = venv_path() + + python = find_system_python() + if python is None: + raise _SetupError( + self.tr( + 'No python3 interpreter found. Install the Xcode Command Line Tools ' + '(xcode-select --install) or Python via Homebrew, then try again.' + ) + ) + + # Detect the known pkg-config failure mode up front instead of letting + # pip fail later with an opaque compiler traceback. + if find_pkg_config() is None: + raise _SetupError( + self.tr( + 'pkg-config is required to build Borg but was not found. ' + 'Install it with "brew install pkg-config" and try again.' + ) + ) + + if self.rebuild: + self.updated.emit(self.tr('Rebuilding environment in {0}…').format(str(path))) + else: + self.updated.emit(self.tr('Creating environment in {0}…').format(str(path))) + # --clear empties a pre-existing venv, so repeated runs start clean. + self._run_step([python, '-m', 'venv', '--clear', str(path)]) + + pip = str(path / 'bin' / 'pip') + self.updated.emit(self.tr('Updating packaging tools…')) + self._run_step([pip, 'install', '--upgrade', 'pip', 'setuptools', 'wheel']) + + self.updated.emit(self.tr('Building Borg with FUSE support (this can take a few minutes)…')) + self._run_step([pip, 'install', 'borgbackup[llfuse]']) + + self.updated.emit(self.tr('Verifying environment…')) + borg_version = self._run_step([str(venv_borg_bin()), '--version'], capture=True).strip() + self._run_step([str(path / 'bin' / 'python'), '-c', 'import llfuse']) + + (path / MARKER_FILE).write_text(borg_version) + logger.info('Managed mount environment ready: %s', borg_version) + self.result.emit({'ok': True, 'message': '', 'borg_version': borg_version}) + + def _run_step(self, cmd, capture=False): + """Run one subprocess, streaming output. Raises _SetupError on failure.""" + if self._cancelled: + raise _SetupError(self.tr('Cancelled.')) + + logger.info('Running command: %s', ' '.join(cmd)) + env = os.environ.copy() + # Match BorgJob.prepare_bin(): the bundled app may miss Homebrew paths, + # which pip needs to find pkg-config during the build. + env['PATH'] = f"{env.get('PATH', '/usr/bin:/bin')}:/opt/homebrew/bin:/usr/local/bin" + + p = Popen( + cmd, + stdout=PIPE, + stderr=STDOUT, + bufsize=1, + universal_newlines=True, + env=env, + start_new_session=True, + ) + self.process = p + + output = [] + for line in p.stdout: + line = line.rstrip() + if line: + self._output_tail.append(line) + if capture: + output.append(line) + else: + self.updated.emit(line) + p.wait() + self.process = None + + if self._cancelled: + raise _SetupError(self.tr('Cancelled.')) + if p.returncode != 0: + raise _SetupError(self._diagnose_failure(cmd)) + return '\n'.join(output) + + def _diagnose_failure(self, cmd): + """Turn known build failures into actionable messages instead of a pip traceback.""" + tail = '\n'.join(self._output_tail) + lowered = tail.lower() + if 'pkg-config' in lowered: + return self.tr( + 'The build failed because pkg-config is missing. ' + 'Install it with "brew install pkg-config" and try again.' + ) + if 'xcrun' in lowered or 'command line tools' in lowered or 'xcode-select' in lowered: + return self.tr( + 'The build failed because the Xcode Command Line Tools are missing. ' + 'Install them with "xcode-select --install" and try again.' + ) + last_lines = '\n'.join(list(self._output_tail)[-5:]) + return self.tr('Setup failed running {0}:\n{1}').format(' '.join(cmd), last_lines) + + +class _SetupError(Exception): + """A setup step failed with a message meant for the user.""" diff --git a/src/vorta/borg/mount.py b/src/vorta/borg/mount.py index 64e99a4ab..9bb49962d 100644 --- a/src/vorta/borg/mount.py +++ b/src/vorta/borg/mount.py @@ -1,9 +1,12 @@ import logging import os +import peewee + from vorta.store.models import SettingsModel from vorta.utils import SHELL_PATTERN_ELEMENT, borg_compat +from . import fuse_venv from .borg_job import BorgJob logger = logging.getLogger(__name__) @@ -13,6 +16,26 @@ class BorgMountJob(BorgJob): def started_event(self): self.updated.emit(self.tr('Mounting archive into folder…')) + @classmethod + def prepare_bin(cls): + """ + Use the managed FUSE venv's borg for mounting, when the user opted in + and the environment passed validation. Only mount resolves the binary + this way; every other Borg command keeps the default resolution. + """ + if fuse_venv.is_supported(): + try: + use_venv = SettingsModel.get(key='use_managed_mount_venv').value + except (SettingsModel.DoesNotExist, peewee.PeeweeException): + use_venv = False + + if use_venv: + if fuse_venv.is_venv_ready(): + return str(fuse_venv.venv_borg_bin()) + logger.warning('Managed mount environment enabled but not ready. Using default Borg binary.') + + return super().prepare_bin() + @classmethod def prepare(cls, profile, archive: str = None): ret = super().prepare(profile) diff --git a/src/vorta/store/settings.py b/src/vorta/store/settings.py index 613c972c0..29b2f2d93 100644 --- a/src/vorta/store/settings.py +++ b/src/vorta/store/settings.py @@ -25,6 +25,7 @@ def get_misc_settings() -> list[dict[str, Any]]: information = trans_late('settings', 'Information') security = trans_late('settings', 'Security') updates = trans_late('settings', 'Updates') + mounting = trans_late('settings', 'Mounting') # Default settings for all platforms. settings = [ @@ -174,6 +175,22 @@ def get_misc_settings() -> list[dict[str, Any]]: ), 'tooltip': trans_late('settings', 'Alerts user when full disk access permission has not been provided'), }, + { + 'key': 'use_managed_mount_venv', + 'value': False, + 'type': 'checkbox', + 'group': mounting, + 'label': trans_late( + 'settings', + 'Use managed Python environment for mounting', + ), + 'tooltip': trans_late( + 'settings', + 'Recommended if Mount fails with FUSE errors. Builds its own Borg with FUSE ' + 'support in a private environment, used only for mounting. Prepare the ' + 'environment after enabling this. All other Borg operations are unaffected.', + ), + }, ] else: settings += [ @@ -183,7 +200,7 @@ def get_misc_settings() -> list[dict[str, Any]]: 'type': 'checkbox', 'label': trans_late( 'settings', - "If the system tray isn't available, " "ask whether to continue in the background " "on exit", + "If the system tray isn't available, ask whether to continue in the background on exit", ), }, { diff --git a/src/vorta/views/misc_tab.py b/src/vorta/views/misc_tab.py index 264f74cee..e52258ea5 100644 --- a/src/vorta/views/misc_tab.py +++ b/src/vorta/views/misc_tab.py @@ -5,10 +5,12 @@ QFormLayout, QHBoxLayout, QLabel, + QPushButton, QSizePolicy, QSpacerItem, ) +from vorta.borg import fuse_venv from vorta.i18n import translate from vorta.store.models import SettingsModel from vorta.store.settings import get_grouped_checkbox_settings @@ -36,6 +38,8 @@ def __init__(self, parent=None, profile_provider=None): self.checkboxLayout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint) self.checkboxLayout.setFormAlignment(Qt.AlignmentFlag.AlignHCenter) self.tooltip_buttons = [] + self._venv_setup_running = False + self._venv_setup_job = None self.populate() @@ -97,6 +101,9 @@ def populate(self): # increase i i += 1 + if setting.key == 'use_managed_mount_venv': + i = self.add_mount_venv_row(i, cb) + self.set_icons() def set_icons(self): @@ -108,3 +115,57 @@ def save_setting(self, key, new_value): setting = SettingsModel.get(key=key) setting.value = bool(new_value) setting.save() + + def add_mount_venv_row(self, i, checkbox): + """ + Add the Prepare/Rebuild Environment row below the managed-mount-venv + checkbox (macOS only — the setting doesn't exist elsewhere). + """ + self.mountVenvCheckbox = checkbox + self.bPrepareVenv = QPushButton() + self.bPrepareVenv.clicked.connect(self.prepare_mount_venv) + + self.mountVenvStatus = QLabel() + self.mountVenvStatus.setWordWrap(True) + if fuse_venv.is_venv_ready(): + self.mountVenvStatus.setText(self.tr('Environment ready.')) + + row = QHBoxLayout() + row.addWidget(self.bPrepareVenv) + row.addWidget(self.mountVenvStatus, 1) + self.checkboxLayout.setLayout(i, QFormLayout.ItemRole.FieldRole, row) + + checkbox.stateChanged.connect(lambda _: self.refresh_mount_venv_button()) + self.refresh_mount_venv_button() + return i + 1 + + def refresh_mount_venv_button(self): + """Update label and enabled state of the venv setup button.""" + if fuse_venv.is_venv_ready(): + self.bPrepareVenv.setText(self.tr('Rebuild Environment')) + else: + self.bPrepareVenv.setText(self.tr('Prepare Environment')) + self.bPrepareVenv.setEnabled(self.mountVenvCheckbox.isChecked() and not self._venv_setup_running) + + def prepare_mount_venv(self): + if self._venv_setup_running: + return + self._venv_setup_running = True + self.refresh_mount_venv_button() + + job = fuse_venv.FuseVenvSetupJob(rebuild=fuse_venv.venv_path().exists()) + job.updated.connect(self.mountVenvStatus.setText) + job.result.connect(self.mount_venv_result) + self._venv_setup_job = job # keep a reference while the worker runs + self.app.jobs_manager.add_job(job) + + def mount_venv_result(self, result): + self._venv_setup_running = False + self._venv_setup_job = None + if result['ok']: + self.mountVenvStatus.setText( + self.tr('Environment ready ({0}). Mount will now use it.').format(result['borg_version']) + ) + else: + self.mountVenvStatus.setText(result['message']) + self.refresh_mount_venv_button() diff --git a/tests/unit/test_fuse_venv.py b/tests/unit/test_fuse_venv.py new file mode 100644 index 000000000..07b1b285a --- /dev/null +++ b/tests/unit/test_fuse_venv.py @@ -0,0 +1,64 @@ +import sys + +from vorta.borg import fuse_venv +from vorta.borg.borg_job import BorgJob +from vorta.borg.mount import BorgMountJob +from vorta.store.models import SettingsModel +from vorta.store.settings import get_misc_settings + + +def set_use_venv(value): + setting, _ = SettingsModel.get_or_create( + key='use_managed_mount_venv', + defaults={ + 'label': 'Use managed Python environment for mounting', + 'type': 'checkbox', + 'value': False, + }, + ) + setting.value = value + setting.save() + + +def test_setting_only_declared_on_darwin(monkeypatch): + monkeypatch.setattr(sys, 'platform', 'darwin') + assert 'use_managed_mount_venv' in {s['key'] for s in get_misc_settings()} + + monkeypatch.setattr(sys, 'platform', 'linux') + assert 'use_managed_mount_venv' not in {s['key'] for s in get_misc_settings()} + + +def test_mount_uses_venv_borg_when_enabled_and_ready(monkeypatch): + set_use_venv(True) + monkeypatch.setattr(fuse_venv, 'is_supported', lambda: True) + monkeypatch.setattr(fuse_venv, 'is_venv_ready', lambda: True) + + assert BorgMountJob.prepare_bin() == str(fuse_venv.venv_borg_bin()) + + +def test_mount_falls_back_when_venv_not_ready(monkeypatch): + set_use_venv(True) + monkeypatch.setattr(fuse_venv, 'is_supported', lambda: True) + monkeypatch.setattr(fuse_venv, 'is_venv_ready', lambda: False) + + assert BorgMountJob.prepare_bin() == BorgJob.prepare_bin() + + +def test_mount_uses_default_borg_when_disabled(monkeypatch): + set_use_venv(False) + monkeypatch.setattr(fuse_venv, 'is_supported', lambda: True) + monkeypatch.setattr(fuse_venv, 'is_venv_ready', lambda: True) + + assert BorgMountJob.prepare_bin() == BorgJob.prepare_bin() + + +def test_other_jobs_unaffected(monkeypatch): + """Enabling the venv must never change binary resolution for non-mount jobs.""" + from vorta.borg.create import BorgCreateJob + + set_use_venv(True) + monkeypatch.setattr(fuse_venv, 'is_supported', lambda: True) + monkeypatch.setattr(fuse_venv, 'is_venv_ready', lambda: True) + + assert BorgCreateJob.prepare_bin.__func__ is BorgJob.prepare_bin.__func__ + assert BorgCreateJob.prepare_bin() != str(fuse_venv.venv_borg_bin())