Add backup from SystemTray.

This commit is contained in:
Manu 2018-10-28 17:35:25 +08:00
parent 6b00d73dff
commit af453d8a6c
11 changed files with 369 additions and 120 deletions

View File

@ -35,7 +35,7 @@
<x>0</x>
<y>0</y>
<width>801</width>
<height>541</height>
<height>561</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
@ -154,6 +154,24 @@
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="cancelButton">
<property name="font">
<font>
<pointsize>11</pointsize>
</font>
</property>
<property name="autoFillBackground">
<bool>false</bool>
</property>
<property name="text">
<string>Cancel</string>
</property>
<property name="flat">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>

View File

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>600</width>
<height>250</height>
<height>287</height>
</rect>
</property>
<property name="windowTitle">
@ -17,7 +17,7 @@
<bool>true</bool>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<item row="1" column="0">
<layout class="QFormLayout" name="formLayout">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::ExpandingFieldsGrow</enum>
@ -63,31 +63,8 @@
<verstretch>0</verstretch>
</sizepolicy>
</property>
<item>
<property name="text">
<string>Select Encryption Mode</string>
</property>
</item>
</widget>
</item>
<item row="4" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="saveButton">
<property name="text">
<string>Add</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="closeButton">
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
@ -112,9 +89,20 @@
</item>
<item row="5" column="1">
<widget class="QLabel" name="errorText">
<property name="font">
<font>
<pointsize>11</pointsize>
</font>
</property>
<property name="text">
<string/>
</property>
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0">
@ -134,8 +122,39 @@
</property>
</widget>
</item>
<item row="6" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="saveButton">
<property name="text">
<string>Add</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="closeButton">
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item row="0" column="0" alignment="Qt::AlignHCenter">
<widget class="QLabel" name="title">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Initialize New Backup Repository</string>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>

View File

@ -17,14 +17,27 @@
<item>
<widget class="QLabel" name="label_6">
<property name="text">
<string>Choose the folders to back up.</string>
<string>Choose folders to back up.</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<layout class="QHBoxLayout" name="horizontalLayout" stretch="0,0">
<item>
<widget class="QListWidget" name="sourceDirectoriesWidget"/>
<widget class="QListWidget" name="sourceDirectoriesWidget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
@ -46,6 +59,53 @@
</item>
</layout>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<property name="topMargin">
<number>20</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Exclude Patterns (&lt;a href=&quot;https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#0000ff;&quot;&gt;more&lt;/span&gt;&lt;/a&gt;)&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Exclude If Present (exclude folders with these files)</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPlainTextEdit" name="excludePatternsField">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="placeholderText">
<string>*/.DS_Store</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPlainTextEdit" name="excludeIfPresentField">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="placeholderText">
<string>.nobackup</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>

View File

@ -1,13 +1,10 @@
import sys
import os
from PyQt5.QtWidgets import QApplication
from vorta.main_window import MainWindow
from vorta.tray_menu import TrayMenu
app = QApplication(sys.argv)
app.thread = None
app.setQuitOnLastWindowClosed(False)
menu = TrayMenu(app)
ex = MainWindow()
ex.show()
sys.exit(app.exec_())

View File

@ -2,10 +2,16 @@ import json
import os
import sys
import shutil
import tempfile
import platform
from datetime import datetime as dt
from PyQt5 import QtCore
from PyQt5.QtWidgets import QApplication
import subprocess
from subprocess import Popen, PIPE
from .models import SourceDirModel, BackupProfileModel
class BorgThread(QtCore.QThread):
updated = QtCore.pyqtSignal(str)
@ -21,23 +27,31 @@ class BorgThread(QtCore.QThread):
cmd[0] = meipass_borg
self.cmd = cmd
print(cmd)
env = os.environ.copy()
if params.get('password'):
env['BORG_HOSTNAME_IS_UNIQUE'] = '1'
if params.get('password') and params['password']:
env['BORG_PASSPHRASE'] = params['password']
if params.get('ssh_key'):
if params.get('ssh_key') and params['ssh_key']:
env['BORG_RSH'] = f'ssh -i ~/.ssh/{params["ssh_key"]}'
self.env = env
self.params = params
def run(self):
with Popen(self.cmd, stdout=PIPE, stderr=PIPE, bufsize=1, universal_newlines=True, env=self.env) as p:
for line in p.stderr:
parsed = json.loads(line)
if parsed['type'] == 'log_message':
self.updated.emit(f'{parsed["levelname"]}: {parsed["message"]}')
elif parsed['type'] == 'file_status':
self.updated.emit(f'{parsed["path"]} ({parsed["status"]})')
print(line)
try:
parsed = json.loads(line)
if parsed['type'] == 'log_message':
self.updated.emit(f'{parsed["levelname"]}: {parsed["message"]}')
elif parsed['type'] == 'file_status':
self.updated.emit(f'{parsed["path"]} ({parsed["status"]})')
except json.decoder.JSONDecodeError:
self.updated.emit(line.strip())
p.wait()
stdout = p.stdout.read()
@ -51,3 +65,65 @@ class BorgThread(QtCore.QThread):
result['data'] = {}
self.result.emit(result)
@classmethod
def create_thread_factory(cls):
"""`borg create` is called from different places and need preparation.
Centralize it here and return a thread to the caller.
"""
ret = {
'ok': False,
}
profile = BackupProfileModel.get(id=1)
app = QApplication.instance()
n_backup_folders = SourceDirModel.select().count()
if app.thread and app.thread.isRunning():
ret['message'] = 'Backup is already in progress.'
return ret
if n_backup_folders == 0:
ret['message'] = 'Add some folders to back up first.'
return ret
if profile.repo is None:
ret['message'] = 'Add a remote backup repository first.'
return ret
cmd = ['borg', 'create', '--list', '--info', '--log-json', '--json', '-C', profile.compression]
# Add excludes
# Inspired by borgmatic/borgmatic/borg/create.py
exclude_dirs = []
for p in profile.exclude_patterns.split('\n'):
if p.strip():
expanded_directory = os.path.expanduser(p.strip())
exclude_dirs.append(expanded_directory)
if exclude_dirs:
pattern_file = tempfile.NamedTemporaryFile('w')
pattern_file.write('\n'.join(exclude_dirs))
pattern_file.flush()
cmd.extend(['--exclude-from', pattern_file.name])
for f in profile.exclude_if_present.split('\n'):
if f.strip():
cmd.extend(['--exclude-if-present', f.strip()])
# Add repo url and source dirs.
cmd.append(f'{profile.repo.url}::{platform.node()}-{dt.now().isoformat()}')
for f in SourceDirModel.select():
cmd.append(f.dir)
params = {'password': profile.repo.password,
'pattern_file': pattern_file}
app.thread = cls(app, cmd, params)
ret['message'] = 'Starting Backup.'
ret['ok'] = True
ret['thread'] = app.thread
return ret

View File

@ -1,11 +1,9 @@
import os
import platform
from datetime import datetime as dt
import sys
from dateutil import parser
from PyQt5.QtWidgets import QApplication
from PyQt5 import uic
from .config import APP_NAME, remove_config
from PyQt5.QtWidgets import QApplication, QShortcut
from PyQt5 import uic, QtCore
from PyQt5.QtGui import QKeySequence
from .config import APP_NAME
from .models import SnapshotModel, BackupProfileModel, SourceDirModel
from .borg_runner import BorgThread
from .repo_tab import RepoTab
@ -24,6 +22,7 @@ class MainWindow(MainWindowBase, MainWindowUI):
self.setupUi(self)
self.setWindowTitle(APP_NAME)
self.profile = BackupProfileModel.get(id=1)
self.app = QApplication.instance()
self.repoTab = RepoTab(self.repoTabSlot)
self.repoTab.repo_changed.connect(lambda: self.snapshotTab.populate())
@ -32,6 +31,21 @@ class MainWindow(MainWindowBase, MainWindowUI):
self.snapshotTab = SnapshotTab(self.snapshotTabSlot)
self.createStartBtn.clicked.connect(self.create_action)
self.cancelButton.clicked.connect(self.cancel_create_action)
QShortcut(QKeySequence("Ctrl+W"), self).activated.connect(self.on_close_window)
QShortcut(QKeySequence("Ctrl+Q"), self).activated.connect(self.on_close_window)
# Connect to existing thread.
if self.app.thread and self.app.thread.isRunning():
self.createStartBtn.setEnabled(False)
self.cancelButton.setEnabled(True)
self.set_status('Connected to existing backup process.', progress_max=0)
self.app.thread.updated.connect(self.create_update_log)
self.app.thread.result.connect(self.create_get_result)
def on_close_window(self):
self.close()
def set_status(self, text=None, progress_max=None):
if text:
@ -41,22 +55,12 @@ class MainWindow(MainWindowBase, MainWindowUI):
self.createProgressText.repaint()
def create_action(self):
n_backup_folders = SourceDirModel.select().count()
if n_backup_folders == 0:
self.set_status('Add some folders to back up first.')
return
self.set_status('Starting Backup.', progress_max=0)
self.createStartBtn.setEnabled(False)
self.createStartBtn.repaint()
repo = self.profile.repo
cmd = ['borg', 'create', '--list', '--info', '--log-json', '--json', '-C', self.profile.compression,
f'{repo.url}::{platform.node()}-{dt.now().isoformat()}'
]
for f in SourceDirModel.select():
cmd.append(f.dir)
thread = BorgThread(self, cmd, {})
thread_msg = BorgThread.create_thread_factory()
if thread_msg['ok']:
self.set_status(thread_msg['message'], progress_max=0)
self.createStartBtn.setEnabled(False)
self.createStartBtn.repaint()
thread = thread_msg['thread']
thread.updated.connect(self.create_update_log)
thread.result.connect(self.create_get_result)
thread.start()
@ -64,19 +68,31 @@ class MainWindow(MainWindowBase, MainWindowUI):
def create_update_log(self, text):
self.set_status(text)
def cancel_create_action(self):
try:
self.app.thread.terminate()
self.app.thread.wait()
self.createStartBtn.setEnabled(True)
self.createStartBtn.repaint()
self.set_status(progress_max=100)
except:
print('no thread')
def create_get_result(self, result):
self.createStartBtn.setEnabled(True)
self.createStartBtn.repaint()
self.set_status(progress_max=100)
if result['returncode'] == 0:
self.set_status(progress_max=100)
new_snapshot = SnapshotModel(
new_snapshot, created = SnapshotModel.get_or_create(
snapshot_id=result['data']['archive']['id'],
name=result['data']['archive']['name'],
time=parser.parse(result['data']['archive']['start']),
repo=self.profile.repo
defaults={
'name':result['data']['archive']['name'],
'time':parser.parse(result['data']['archive']['start']),
'repo':self.profile.repo
}
)
new_snapshot.save()
if 'cache' in result['data']:
if 'cache' in result['data'] and created:
stats = result['data']['cache']['stats']
repo = self.profile.repo
repo.total_size = stats['total_size']

View File

@ -28,6 +28,9 @@ class BackupProfileModel(peewee.Model):
repo = peewee.ForeignKeyField(RepoModel, default=None, null=True)
ssh_key = peewee.CharField(default=None, null=True)
compression = peewee.CharField(default='lz4')
exclude_patterns = peewee.TextField(null=True)
exclude_if_present = peewee.TextField(null=True)
class Meta:
database = db

View File

@ -1,26 +1,60 @@
import os
from PyQt5 import uic
from .utils import get_private_keys, get_relative_asset
from .borg_runner import BorgThread
uifile = get_relative_asset('UI/repoadd.ui')
AddRepoUI, AddRepoBase = uic.loadUiType(uifile)
class AddRepoWindow(AddRepoBase, AddRepoUI):
connection_message = 'Setting up new repo...'
cmd = ["borg", "init", "--log-json"]
def __init__(self):
super().__init__()
self.setupUi(self)
self.closeButton.clicked.connect(self.close)
self.saveButton.clicked.connect(self.validate)
self.saveButton.clicked.connect(self.run)
self.init_encryption()
self.init_ssh_key()
@property
def values(self):
out = dict(
ssh_key=self.sshComboBox.currentData(),
repo_url=self.repoURL.text(),
password=self.passwordLineEdit.text()
)
if self.__class__ == AddRepoWindow:
out['encryption'] = self.encryptionComboBox.currentData()
return out
def run(self):
if self.validate():
self.set_status(self.connection_message)
cmd = self.cmd + [self.values['repo_url']]
thread = BorgThread(self, cmd, self.values)
thread.updated.connect(self.set_status)
thread.result.connect(self.run_result)
thread.start()
def set_status(self, text):
self.errorText.setText(text)
self.errorText.repaint()
def run_result(self, result):
if result['returncode'] == 0:
self.result = result
self.accept()
def init_encryption(self):
self.encryptionComboBox.model().item(0).setEnabled(False)
self.encryptionComboBox.addItem('Repokey-Blake2 (Recommended)', 'repokey-blake2')
self.encryptionComboBox.addItem('Repokey-Blake2 (Recommended, key stored remotely)', 'repokey-blake2')
self.encryptionComboBox.addItem('Repokey', 'repokey')
self.encryptionComboBox.addItem('Keyfile-Blake2 (Key stored locally)', 'keyfile-blake2')
self.encryptionComboBox.addItem('Keyfile', 'keyfile')
self.encryptionComboBox.addItem('None (not recommended', 'none')
def init_ssh_key(self):
@ -29,34 +63,27 @@ class AddRepoWindow(AddRepoBase, AddRepoUI):
self.sshComboBox.addItem(f'{key["filename"]} ({key["format"]}:{key["fingerprint"]})', key['filename'])
def validate(self):
if len(self.repoURL.text()) < 5:
self.errorText.setText('Please enter a repo URL.')
return
if len(self.values['repo_url']) < 5 or ':' not in self.values['repo_url']:
self.set_status('Please enter a valid repo URL including hostname and path.')
return False
if self.encryptionComboBox.isVisible() and self.encryptionComboBox.currentData() is None:
self.errorText.setText('Please choose an encryption mode.')
return
if self.__class__ == AddRepoWindow:
if self.values['encryption'] != 'none' and len(self.values['password']) < 8:
self.set_status('Please use a longer password.')
return False
self.cmd = ["borg", "init", "--log-json", f"--encryption={params['encryption']}", params['repo_url']]
self.cmd.append(f"--encryption={self.values['encryption']}")
def get_values(self):
return {
'ssh_key': self.sshComboBox.currentData(),
'encryption': self.encryptionComboBox.currentData(),
'repo_url': self.repoURL.text(),
'password': self.passwordLineEdit.text()
}
return True
class ExistingRepoWindow(AddRepoWindow):
connection_message = 'Validating existing repo...'
cmd = ["borg", "list", "--json"]
def __init__(self):
super().__init__()
self.encryptionComboBox.hide()
self.encryptionLabel.hide()
self.title.setText('Connect to existing Repository')
def get_values(self):
return {
'ssh_key': self.sshComboBox.currentData(),
'repo_url': self.repoURL.text(),
'password': self.passwordLineEdit.text()
}

View File

@ -74,41 +74,26 @@ class RepoTab(RepoBase, RepoUI):
def repo_select_action(self, index):
if index <= 2:
if index == 1:
repo_add_window = AddRepoWindow()
window = AddRepoWindow()
else:
repo_add_window = ExistingRepoWindow()
window = ExistingRepoWindow()
repo_add_window.setParent(self, QtCore.Qt.Sheet)
repo_add_window.show()
if repo_add_window.exec_():
params = repo_add_window.get_values()
if index == 1:
cmd = ["borg", "init", "--log-json", f"--encryption={params['encryption']}", params['repo_url']]
else:
cmd = ["borg", "list", "--json", params['repo_url']]
self.set_status('Connecting to repo...', 0)
thread = BorgThread(self, cmd, params)
thread.updated.connect(self.repo_add_update_log)
thread.result.connect(self.repo_add_result)
thread.start()
window.setParent(self, QtCore.Qt.Sheet)
window.show()
if window.exec_():
self.process_new_repo(window.result)
else:
self.profile.repo = self.repoSelector.currentData()
self.profile.save()
self.init_repo_stats()
def repo_add_update_log(self, text):
self.set_status(text)
def repo_add_result(self, result):
def process_new_repo(self, result):
if result['returncode'] == 0:
self.set_status('Successfully connected to repo.', 100)
new_repo, _ = RepoModel.get_or_create(
url=result['params']['repo_url'],
defaults={
'password': result['params']['password'],
# 'encryption': result['params'].get('encryption', '')
'encryption': result['params'].get('encryption', 'none')
}
)
if 'cache' in result['data']:
@ -117,7 +102,9 @@ class RepoTab(RepoBase, RepoUI):
new_repo.unique_csize = stats['unique_csize']
new_repo.unique_size = stats['unique_size']
new_repo.total_unique_chunks = stats['total_unique_chunks']
if 'encryption' in result['data']:
new_repo.encryption = result['data']['encryption']['mode']
new_repo.save()
self.profile.repo = new_repo.id
self.profile.save()
@ -135,4 +122,4 @@ class RepoTab(RepoBase, RepoUI):
new_snapshot.save()
self.repoSelector.addItem(new_repo.url, new_repo.id)
self.repoSelector.setCurrentIndex(self.repoSelector.count()-1)
self.init_snapshots()
self.repo_changed.emit(self.profile.repo.id)

View File

@ -19,6 +19,12 @@ class SourceTab(SourceBase, SourceUI):
for source in SourceDirModel.select():
self.sourceDirectoriesWidget.addItem(source.dir)
self.excludePatternsField.appendPlainText(self.profile.exclude_patterns)
self.excludeIfPresentField.appendPlainText(self.profile.exclude_if_present)
self.excludePatternsField.textChanged.connect(self.save_exclude_patterns)
self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present)
def source_add(self):
options = QFileDialog.Options()
options |= QFileDialog.ShowDirsOnly
@ -35,3 +41,11 @@ class SourceTab(SourceBase, SourceUI):
db_item = SourceDirModel.get(dir=item.text())
db_item.delete_instance()
item = None
def save_exclude_patterns(self):
self.profile.exclude_patterns = self.excludePatternsField.toPlainText()
self.profile.save()
def save_exclude_if_present(self):
self.profile.exclude_if_present = self.excludeIfPresentField.toPlainText()
self.profile.save()

View File

@ -1,19 +1,26 @@
from PyQt5 import QtWidgets
from PyQt5.QtWidgets import QMenu, QAction, QApplication, QSystemTrayIcon
from vorta.main_window import MainWindow
from PyQt5.QtGui import QIcon
from .utils import get_relative_asset
from .config import remove_config
from .borg_runner import BorgThread
class TrayMenu(QSystemTrayIcon):
def __init__(self, parent=None):
icon = QIcon(get_relative_asset('UI/icons/hdd-o.png'))
QSystemTrayIcon.__init__(self, icon, parent)
self.app = parent
menu = QMenu()
self.status = menu.addAction("Sleeping")
self.status.setEnabled(False)
self.create_action = menu.addAction("Backup Now")
self.create_action.triggered.connect(self.on_create_backup)
settings_action = menu.addAction("Settings")
# settings_action.setEnabled(False)
# settings_action.setText('In Progress')
settings_action.setIcon(icon)
settings_action.triggered.connect(self.on_settings_action)
menu.addSeparator()
@ -24,6 +31,8 @@ class TrayMenu(QSystemTrayIcon):
exit_action = menu.addAction("Exit")
exit_action.triggered.connect(self.on_exit_action)
self.activated.connect(self.on_user_click)
self.setContextMenu(menu)
self.setVisible(True)
self.show()
@ -38,3 +47,26 @@ class TrayMenu(QSystemTrayIcon):
def on_reset(self):
remove_config()
QApplication.instance().quit()
def on_create_backup(self):
thread_msg = BorgThread.create_thread_factory()
if thread_msg['ok']:
thread_msg['thread'].start()
else:
error_dialog = QtWidgets.QErrorMessage()
error_dialog.showMessage(thread_msg['message'])
error_dialog.show()
def on_cancel_backup(self):
if self.app.thread and self.app.thread.isRunning():
self.app.thread.terminate()
def on_user_click(self):
if self.app.thread and self.app.thread.isRunning():
self.status.setText('Backup in Progress')
self.create_action.setText('Cancel Backup')
self.create_action.triggered.connect(self.on_cancel_backup)
else:
self.status.setText('Sleeping')
self.create_action.setText('Backup Now')
self.create_action.triggered.connect(self.on_create_backup)