import logging
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Tuple
from PyQt6 import QtCore
from PyQt6.QtWidgets import QMessageBox
from vorta.borg.break_lock import BorgBreakJob
from vorta.borg.create import BorgCreateJob
from vorta.borg.jobs_manager import JobsManager
from vorta.borg.version import BorgVersionJob
from vorta.config import LOG_DIR, PROFILE_BOOTSTRAP_FILE, TEMP_DIR
from vorta.i18n import init_translations, translate
from vorta.notifications import VortaNotifications
from vorta.profile_export import ProfileExport
from vorta.qt_single_application import QtSingleApplication
from vorta.scheduler import VortaScheduler
from vorta.store.connection import cleanup_db
from vorta.store.models import BackupProfileModel, SettingsModel
from vorta.tray_menu import TrayMenu
from vorta.utils import borg_compat, parse_args
from vorta.views.main_window import MainWindow
logger = logging.getLogger(__name__)
APP_ID = TEMP_DIR / "socket"
class VortaApp(QtSingleApplication):
"""
All windows and QWidgets are children of this app.
When running Borg-commands, the class `BorgJob` will emit events
via the `VortaApp` class to which other windows will subscribe to.
"""
backup_started_event = QtCore.pyqtSignal()
backup_finished_event = QtCore.pyqtSignal(dict)
backup_cancelled_event = QtCore.pyqtSignal()
backup_log_event = QtCore.pyqtSignal(str, dict)
backup_progress_event = QtCore.pyqtSignal(str)
check_failed_event = QtCore.pyqtSignal(dict)
def __init__(self, args_raw, single_app=False):
super().__init__(str(APP_ID), args_raw)
args = parse_args()
if self.isRunning():
if single_app:
self.sendMessage("open main window")
logger.info('An instance of Vorta is already running. Opening main window.')
sys.exit()
elif args.profile:
self.sendMessage(f"create {args.profile}")
logger.info('Creating backup using existing Vorta instance.')
sys.exit()
elif args.profile:
sys.exit('Vorta must already be running for --create to work')
init_translations(self)
self.setQuitOnLastWindowClosed(False)
self.jobs_manager = JobsManager()
self.scheduler = VortaScheduler()
self.setApplicationName("Vorta")
# Import profile from ~/.vorta-init.json or add empty "Default" profile.
self.bootstrap_profile()
# Prepare tray and main window
self.tray = TrayMenu(self)
self.main_window = MainWindow(self)
if getattr(args, 'daemonize', False):
pass
elif SettingsModel.get(key='foreground').value:
self.open_main_window_action()
self.backup_started_event.connect(self.backup_started_event_response)
self.backup_finished_event.connect(self.backup_finished_event_response)
self.backup_cancelled_event.connect(self.backup_cancelled_event_response)
self.message_received_event.connect(self.message_received_event_response)
self.check_failed_event.connect(self.check_failed_response)
self.backup_log_event.connect(self.react_to_log)
self.aboutToQuit.connect(self.quit_app_action)
self.set_borg_details_action()
if sys.platform == 'darwin':
self.check_darwin_permissions()
def create_backups_cmdline(self, profile_name):
profile = BackupProfileModel.get_or_none(name=profile_name)
if profile is not None:
if profile.repo is None:
logger.warning(f"Add a repository to {profile_name}")
self.create_backup_action(profile_id=profile.id)
else:
logger.warning(f"Invalid profile name {profile_name}")
def quit_app_action(self):
self.backup_cancelled_event.emit()
del self.main_window
self.tray.deleteLater()
del self.tray
cleanup_db()
def create_backup_action(self, profile_id=None):
if not profile_id:
profile_id = self.main_window.current_profile.id
profile = BackupProfileModel.get(id=profile_id)
msg = BorgCreateJob.prepare(profile)
if msg['ok']:
job = BorgCreateJob(msg['cmd'], msg, profile.repo.id)
self.jobs_manager.add_job(job)
else:
notifier = VortaNotifications.pick()
notifier.deliver(
self.tr('Vorta Backup'),
translate('messages', msg['message']),
level='error',
)
self.backup_progress_event.emit(f"[{profile.name}] {translate('messages', msg['message'])}")
return None
def open_main_window_action(self):
self.main_window.show()
self.main_window.raise_()
self.main_window.activateWindow()
def toggle_main_window_visibility(self):
if self.main_window.isVisible():
self.main_window.close()
else:
self.open_main_window_action()
def backup_started_event_response(self):
self.tray.set_tray_icon(active=True)
def backup_finished_event_response(self):
if not self.jobs_manager.is_worker_running():
self.tray.set_tray_icon()
def backup_cancelled_event_response(self):
self.jobs_manager.cancel_all_jobs()
self.tray.set_tray_icon()
def message_received_event_response(self, message):
if message == "open main window":
self.open_main_window_action()
elif message.startswith("create"):
message = message[7:] # Remove create
if self.jobs_manager.is_worker_running():
logger.warning("Cannot run while backups are already running")
else:
self.create_backups_cmdline(message)
# No need to add this function to JobsManager because it doesn't require to lock a repo.
def set_borg_details_action(self):
params = BorgVersionJob.prepare()
if not params['ok']:
self._alert_missing_borg()
return
job = BorgVersionJob(params['cmd'], params)
job.result.connect(self.set_borg_details_result)
self.jobs_manager.add_job(job)
def set_borg_details_result(self, result):
"""
Receive result from BorgVersionJob.
If no valid version was found, display an error.
"""
if 'version' in result['data']:
borg_compat.set_version(result['data']['version'], result['data']['path'])
self.main_window.miscTab.set_borg_details(borg_compat.version, borg_compat.path)
self.main_window.repoTab.toggle_available_compression()
self.scheduler.reload_all_timers() # Start timer after Borg version is set.
else:
self._alert_missing_borg()
def _alert_missing_borg(self):
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Critical)
msg.setText(self.tr("No Borg Binary Found"))
msg.setInformativeText(self.tr("Vorta was unable to locate a usable Borg Backup binary."))
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
msg.exec()
def check_darwin_permissions(self):
"""
macOS restricts access to certain folders by default. For some folders, the user
will get a prompt (e.g. Documents, Downloads), while others will cause file access
errors.
This function tries reading a file that is known to be restricted and warn the user about
incomplete backups.
"""
if not SettingsModel.get(key="check_full_disk_access").value:
return
test_path = Path('~/Library/Cookies').expanduser()
if test_path.exists() and not os.access(test_path, os.R_OK):
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Warning)
msg.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.LinksAccessibleByMouse)
msg.setText(self.tr("Vorta needs Full Disk Access for complete Backups"))
msg.setInformativeText(
self.tr(
"Without this, some files will not be accessible and you may end up with an incomplete "
"backup. Please set Full Disk Access permission for Vorta in "
""
"System Preferences > Security & Privacy."
)
)
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
msg.exec()
def react_to_log(self, mgs, context):
"""
Trigger Vorta actions based on Borg logs. E.g. repo lock.
"""
msgid = context.get('msgid')
if msgid == 'LockTimeout':
profile = BackupProfileModel.get(name=context['profile_name'])
repo_url = context.get('repo_url')
msg = QMessageBox()
msg.setWindowTitle(self.tr("Repository In Use"))
msg.setIcon(QMessageBox.Icon.Critical)
abortButton = msg.addButton(self.tr("Abort"), QMessageBox.ButtonRole.RejectRole)
msg.addButton(self.tr("Continue"), QMessageBox.ButtonRole.AcceptRole)
msg.setDefaultButton(abortButton)
msg.setText(self.tr(f"The repository at {repo_url} might be in use elsewhere."))
msg.setInformativeText(
self.tr(
"Only break the lock if you are certain no other Borg process "
"on any machine is accessing the repository. Abort or break the lock?"
)
)
msg.accepted.connect(lambda: self.break_lock(profile))
self._msg = msg
msg.show()
elif msgid == 'LockFailed':
repo_url = context.get('repo_url')
msg = QMessageBox()
msg.setText(
self.tr(
f"You do not have permission to access the repository at {repo_url}. Gain access and try again."
)
) # noqa: E501
msg.setWindowTitle(self.tr("No Repository Permissions"))
self._msg = msg
msg.show()
def break_lock(self, profile):
params = BorgBreakJob.prepare(profile)
if not params['ok']:
self.backup_progress_event.emit(f"[{profile.name}] {params['message']}")
return
job = BorgBreakJob(params['cmd'], params)
self.jobs_manager.add_job(job)
def bootstrap_profile(self, bootstrap_file=PROFILE_BOOTSTRAP_FILE):
"""
Make sure there is at least one profile when first starting Vorta.
Will either import a profile placed in ~/.vorta-init.json
or add an empty "Default" profile.
"""
if bootstrap_file.is_file():
try:
profile_export = ProfileExport.from_json(bootstrap_file)
profile = profile_export.to_db(overwrite_profile=True, overwrite_settings=True)
except Exception as exception:
double_newline = os.linesep + os.linesep
QMessageBox.critical(
None,
self.tr('Failed to import profile'),
"{}{}\"{}\"{}{}".format(
self.tr('Failed to import a profile from {}:').format(bootstrap_file),
double_newline,
str(exception),
double_newline,
self.tr('Consider removing or repairing this file to ' 'get rid of this message.'),
),
)
return
bootstrap_file.unlink()
notifier = VortaNotifications.pick()
notifier.deliver(
self.tr('Profile import successful!'),
self.tr('Profile {} imported.').format(profile.name),
level='info',
)
logger.info('Profile {} imported.'.format(profile.name))
if BackupProfileModel.select().count() == 0:
default_profile = BackupProfileModel(name='Default')
default_profile.save()
def check_failed_response(self, result: Dict[str, Any]):
"""
Process the signal that a repo consistency check failed.
Displays a `QMessageBox` with an error message depending on the
return code of the `BorgJob`.
Parameters
----------
repo_url : str
The url of the repo of concern
"""
# extract data from the params for the borg job
repo_url = result['params']['repo_url']
returncode = result['returncode']
errors: List[Tuple[int, str]] = result['errors']
error_message = errors[0][1] if errors else ''
# Switch over returncodes
if returncode == 0:
# No fail
logger.warning('VortaApp.check_failed_response was called with returncode 0')
elif returncode == 130:
# Keyboard interupt
pass
else: # Real error
# Create QMessageBox
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Critical) # changed for warning
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
msg.setWindowTitle(self.tr('Repo Check Failed'))
if returncode == 1:
# warning
msg.setIcon(QMessageBox.Icon.Warning)
text = translate(
'VortaApp', 'Borg exited with warning status (rc 1). See the logs for details.'
).format(LOG_DIR.as_uri())
infotext = error_message
elif returncode > 128:
# 128+N - killed by signal N (e.g. 137 == kill -9)
signal = returncode - 128
text = self.tr('Repository data check for repo was killed by signal %s.') % (signal)
infotext = self.tr('The process running the check job got a kill signal. Try again.')
else:
# Real error
text = self.tr('Repository data check for repo %s failed. Error code %s') % (
repo_url,
returncode,
)
infotext = error_message + '\n'
infotext += self.tr('Consider repairing or recreating the repository soon to avoid missing data.')
msg.setText(text)
msg.setInformativeText(infotext)
# Display messagebox
msg.exec()