Compare commits

...

20 Commits

Author SHA1 Message Date
shivansh02 f252a8ec6a hide password field if encryption is none 2024-05-31 16:58:12 +01:00
Parnassius 6b5f7a7aac
Keep the profile list sorted when adding profiles. By @Parnassius (#1986) 2024-05-25 14:00:51 +01:00
Manu 9cabbbd193 Input to change macOS version for building 2024-04-08 16:24:31 +01:00
Aryaman Sharma 3268bf1599
Notify after post_backup_tasks. By @TheLazron (#1940) 2024-04-07 18:05:04 +01:00
Manu 7642002573 Fix stalebot config 2024-04-07 08:40:45 +01:00
Sam 58137f004d
Add new exclusion presets (#1970) 2024-04-06 22:35:31 +01:00
Parnassius 9b8dbcecfb
Sort profiles in the Backup Now tray menu (#1899)
The profile list in the main window is already sorted by name (alphabetically, case-sensitive). However the profile list in the *Backup Now* action found in the tray menu wasn't. This commit constructs the sql query to return the profiles in order.

* src/vorta/tray_menu.py
2024-04-01 21:00:53 +02:00
Adwait bde55188e4
Disabled "Collapse" button in "Flat" view (#1855)
Our `DiffResultDialog` and `ExtractDialog` show a context menu for items of the list/tree view. The collapse action in this menu only makes sense for the tree mode of the view. This commit therefore enables the option only for this view mode.

* src/vorta/views/extract_dialog.py
* src/vorta/views/diff_result.py

* tests/unit/test_diff.py : Add tests for the new behaviour.
* tests/unit/test_extract.py
2024-04-01 16:37:58 +02:00
Shivansh Singh d721011c90
VSC and Android exclusion patterns. By @shivansh02 (#1967) 2024-03-15 11:51:42 +00:00
Shivansh Singh b2cf5b1fc9
Move log file link below logs table. By @shivansh02 (#1939) 2024-02-21 20:11:45 +00:00
Shivansh Singh 472c7c8996
Fix About dialog wording and year. By @shivansh02 (#1936)
* fix: about dialogue grammar and copyright year
* fix: made about dialogue copyright year dynamic
2024-02-14 11:35:33 +00:00
Hofer-Julian d8cce255eb
Add developer name to appdata (#1922)
* Add developer name to appdata

Flathub is getting more and more strict when it comes to metadata.
I've added "Vorta developers" no, I can also be more specific if people prefer that.

* Update com.borgbase.Vorta.appdata.xml
2024-02-08 11:29:14 +00:00
Jeff Ramnani 634f984e78
Improve metered connection detection for macOS. By @jramnani (#1902)
* Add dependency for pyobjc-CoreWLAN on darwin

* Rename existing implementation with Android

The current implementation was tested with Android, but does not work
with iOS.

Move the existing implementation and include android in the name to make
room for adding a new iOS metered connection detection strategy.

* get_current_wifi works with objc

Switch from using command line tools to using the Objective-C Cocoa API
to get the Wi-Fi status information.

Cocoa has an API to specifically check whether a Wi-Fi connection is
using a Personal Hotspot on iOS.

I'm using a private method to get the Wi-Fi interface object in Cocoa.
The reason for this is that cleaning up mocks on PyObjC/ObjC objects is
much harder than mocking out methods on objects in our control. Using
test doubles also let's me check for different states the Wi-Fi network
could be in.

* get_known_wifis works on darwin

Use the networksetup command on macOS to get the list of the user's
Wi-Fi networks.

  networksetup -listpreferredwirelessnetworks bsd_device

It looks like this command and option has existed on macOS since at
least 2013.

Also add some type annotations around the PyObjC return values to help
the reader know what they're dealing with at each step.

* Add test for get_current_wifi when wifi is off

The user might have Wi-Fi turned off. Account for that use case.

* Add iOS Personal Hotspot support to is_network_metered

The DarwinNetworkManager can now determine if the user is connected to
a Personal Hotspot Wi-Fi network from iOS.

Account for whether the user has Wi-Fi turned on and off.

* Refactor to avoid deprecated API in Cocoa

According to Apple's developer documentation, creating CWInterface
objects directly are discouraged. Instead, they prefer to use
CWInterface objects created by CWWiFiClient.

This also happens to be more compliant with Apple's application sandbox.
Creating CWInterface objects directly accesses raw BSD sockets which is
not allowed in the sandbox.

More details here:
https://developer.apple.com/documentation/corewlan/cwinterface

* Add test case for blank Wi-Fi network name

I have one of these in my list of networks in Vorta. And this also
covers a missing branch in get_known_wifis.

* Move private method below public methods

This is to provide a little more clarity. Especially since this class is
subclassing another one.

* Account for when there is no wifi interface

When a Mac does not have a Wi-Fi interface, CWWiFiClient.interface() can
return None.

Update the type annotation to mark it as Optional, and account for the
null condition in the other methods.

* Fix type annotation error

The CI tests failed on python 3.8.

I used the wrong type annotation to describe a list of SystemWifiInfo's.

The tests now pass for me when I run 'make test-unit' using a python 3.8
interpreter.

* Fix linter issue with imports
2024-02-02 12:05:47 +00:00
Hofer-Julian 0cc15e3d3d
Update appdata.xml (#1885)
The appdata.xml doesn't pass validation of flathub

1. The `launchable` tag is nowadays required
2. Flatpak doesn't like the beta releases. In the end, it only made sense to remove them from the xml
2024-01-25 11:06:56 +01:00
Manu 4665972076
Fix issue after Qt6 migration to save allowed Wifis (#1903) 2024-01-20 10:26:06 +00:00
Manu 9cc7a98838 Minor: color settings icon 2024-01-11 08:27:25 +00:00
Manu 1d85cb48dc Bump version to v0.9.1 2024-01-10 13:20:01 +00:00
Manu be6e08552a
Update screencast for v0.9 (#1881) 2024-01-10 13:11:38 +00:00
TW 675010e401
Random cleanups by @ThomasWaldmann (#1879)
* fix PEP8 E721

do not compare types, for exact checks use `is` / `is not`, for instance checks use `isinstance()`

* remove redundant parentheses

* fix SiteWorker.run for empty job queue

local variable job is not assigned if queue was empty
when calling .run(), but it is used in exception handler.

* remove unreachable code in parse_diff_lines

* bug fix for unreachable code in is_worker_running

the code intended to check if *any* worker is running for
any site was *unreachable*.

this caused false negative results for site=None.

* check_failed_response: remove outdated part of docstring

* pull request template: fix relative path to LICENSE.txt

* fix typos

* use logger.warning, .warn is deprecated
2024-01-09 08:06:48 +00:00
Manu 1f062359d8 Minor: include exclusion presets for macos package 2023-11-30 11:34:03 +00:00
39 changed files with 386 additions and 101 deletions

View File

@ -1,5 +1,5 @@
name: "Bug Report Form"
description: "Report a bug or a similiar issue."
description: "Report a bug or a similar issue."
body:
- type: markdown
attributes:

View File

@ -1,6 +1,6 @@
---
name: Bug Report
about: Report a bug or a similiar issue - the classic way
about: Report a bug or a similar issue - the classic way
title: ''
labels: ''
assignees: ''
@ -18,7 +18,7 @@ If you want to suggest a feature or have any other question, please use our
#### Description
<!-- Description
Please decribe your issue and its context in a clear and concise way.
Please describe your issue and its context in a clear and concise way.
Please try to reproduce the issue and provide the steps to reproduce it.
-->

View File

@ -36,7 +36,7 @@
- [ ] All new and existing tests passed.
*I provide my contribution under the terms of the [license](./../../LICENSE.txt) of this repository and I affirm the [Developer Certificate of Origin][dco].*
*I provide my contribution under the terms of the [license](./../LICENSE.txt) of this repository and I affirm the [Developer Certificate of Origin][dco].*
[dco]: https://developercertificate.org/

View File

@ -3,17 +3,21 @@ on:
workflow_dispatch:
inputs:
branch:
description: 'Branch to use for building macOS release'
description: 'Branch to use for building release'
required: true
default: 'master'
borg_version:
description: 'Borg version to package'
required: true
default: '1.2.1'
default: '1.2.8'
macos_version:
description: 'macOS version for building'
required: true
default: 'macos-11'
jobs:
build:
runs-on: macos-11
runs-on: ${{ github.event.inputs.macos_version }}
steps:
- name: Check out selected branch

View File

@ -6,10 +6,13 @@ on:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v8
with:
days-before-issue-stale: 60
days-before-issue-stale: 90
days-before-pr-stale: -1
days-before-issue-close: 7
# days-before-pr-close: 10

View File

@ -13,7 +13,7 @@
Vorta is a backup client for macOS and Linux desktops. It integrates the mighty [BorgBackup](https://borgbackup.readthedocs.io) with your desktop environment to protect your data from disk failure, ransomware and theft.
![](https://files.qmax.us/vorta/screencast-8-small.gif)
https://github.com/m3nu/vorta/assets/3916435/a622a148-5373-4ae0-87bc-4ca1d6f6202e
## Why is this great? 🤩

View File

@ -28,10 +28,10 @@ else:
@nox.parametrize("borgbackup", supported_borgbackup_versions)
def run_tests(session, borgbackup):
# install borgbackup
if (sys.platform == 'darwin'):
if sys.platform == 'darwin':
# in macOS there's currently no fuse package which works with borgbackup directly
session.install(f"borgbackup=={borgbackup}")
elif (borgbackup == "1.1.18"):
elif borgbackup == "1.1.18":
# borgbackup 1.1.18 doesn't support pyfuse3
session.install("llfuse")
session.install(f"borgbackup[llfuse]=={borgbackup}")

View File

@ -18,10 +18,10 @@ def create_symlink(folder: Path) -> None:
"""Create the appropriate symlink in the MacOS folder
pointing to the Resources folder.
"""
sibbling = Path(str(folder).replace("MacOS", ""))
sibling = Path(str(folder).replace("MacOS", ""))
# PyQt6/Qt/qml/QtQml/Models.2
root = str(sibbling).partition("Contents")[2].lstrip("/")
root = str(sibling).partition("Contents")[2].lstrip("/")
# ../../../../
backward = "../" * (root.count("/") + 1)
# ../../../../Resources/PyQt6/Qt/qml/QtQml/Models.2
@ -41,7 +41,7 @@ def fix_dll(dll: Path) -> None:
def match_func(pth: str) -> Optional[str]:
"""Callback function for MachO.rewriteLoadCommands() that is
called on every lookup path setted in the DLL headers.
called on every lookup path set in the DLL headers.
By returning None for system libraries, it changes nothing.
Else we return a relative path pointing to the good file
in the MacOS folder.
@ -73,7 +73,7 @@ def find_problematic_folders(folder: Path) -> Generator[Path, None, None]:
"""Recursively yields problematic folders (containing a dot in their name)."""
for path in folder.iterdir():
if not path.is_dir() or path.is_symlink():
# Skip simlinks as they are allowed (even with a dot)
# Skip symlinks as they are allowed (even with a dot)
continue
if "." in path.name:
yield path
@ -83,7 +83,7 @@ def find_problematic_folders(folder: Path) -> Generator[Path, None, None]:
def move_contents_to_resources(folder: Path) -> Generator[Path, None, None]:
"""Recursively move any non symlink file from a problematic folder
to the sibbling one in Resources.
to the sibling one in Resources.
"""
for path in folder.iterdir():
if path.is_symlink():
@ -91,10 +91,10 @@ def move_contents_to_resources(folder: Path) -> Generator[Path, None, None]:
if path.name == "qml":
yield from move_contents_to_resources(path)
else:
sibbling = Path(str(path).replace("MacOS", "Resources"))
sibbling.parent.mkdir(parents=True, exist_ok=True)
shutil.move(path, sibbling)
yield sibbling
sibling = Path(str(path).replace("MacOS", "Resources"))
sibling.parent.mkdir(parents=True, exist_ok=True)
shutil.move(path, sibling)
yield sibling
def main(args: List[str]) -> int:

View File

@ -23,6 +23,7 @@ a = Analysis([os.path.join(SRC_DIR, '__main__.py')],
datas=[
(os.path.join(SRC_DIR, 'assets/UI/*'), 'assets/UI'),
(os.path.join(SRC_DIR, 'assets/icons/*'), 'assets/icons'),
(os.path.join(SRC_DIR, 'assets/exclusion_presets/*'), 'assets/exclusion_presets'),
(os.path.join(SRC_DIR, 'i18n/qm/*'), 'vorta/i18n/qm'),
],
hiddenimports=[

View File

@ -47,6 +47,7 @@ install_requires =
pyobjc-core < 10; sys_platform == 'darwin'
pyobjc-framework-Cocoa < 10; sys_platform == 'darwin'
pyobjc-framework-LaunchServices < 10; sys_platform == 'darwin'
pyobjc-framework-CoreWLAN < 10; sys_platform == 'darwin'
tests_require =
pytest
pytest-qt

View File

@ -1 +1 @@
__version__ = '0.9.1-beta3'
__version__ = '0.9.1'

View File

@ -308,11 +308,6 @@ class VortaApp(QtSingleApplication):
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']
@ -344,7 +339,7 @@ class VortaApp(QtSingleApplication):
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)
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

View File

@ -213,7 +213,7 @@
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;a href=&quot;https://github.com/borgbase/vorta&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#0984e3;&quot;&gt;Click here&lt;/span&gt;&lt;/a&gt; for view Git repo.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;a href=&quot;https://github.com/borgbase/vorta&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#0984e3;&quot;&gt;Click here&lt;/span&gt;&lt;/a&gt; to view Git repo.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>
@ -241,7 +241,7 @@
<number>20</number>
</property>
<item>
<widget class="QLabel" name="label">
<widget class="QLabel" name="copyrightLabel">
<property name="text">
<string>
Vorta is a cross-platform, open-source client designed to simplify the management of Borg backups.

View File

@ -626,6 +626,19 @@
</column>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="logLink">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;a href=&quot;file:///&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#0984e3;&quot;&gt;View the logs&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="indent">
<number>0</number>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="page_3">

View File

@ -5,7 +5,8 @@
"patterns":
[
"fm:*/node_modules",
"fm:*/.npm"
"fm:*/.npm",
"fm:*/npm-global"
],
"tags": ["type:dev", "lang:javascript", "os:linux", "os:darwin"],
"author": "Divi"
@ -33,5 +34,66 @@
],
"tags": ["type:dev", "lang:rust", "os:linux", "os:darwin"],
"author": "Divi"
},
{
"name": "Visual Studio Code cache and config files",
"slug": "vscode-cache",
"patterns": [
"fm:*/.config/Code",
"fm:*/.vscode/extensions/*"
],
"tags": ["type:editor", "editor:vscode", "os:linux"],
"author": "shivansh02"
},
{
"name": "Android Studio Artefacts",
"slug": "android-studio",
"patterns": [
"fm:*/.android",
"fm:*/.gradle",
"fm:*/Android/Sdk",
"fm:*/.AndroidStudio"
],
"tags": ["type:dev", "editor:android-studio", "os:linux"],
"author": "shivansh02"
},
{
"name": "Jetbrains IDEs cache, config, path and logs",
"slug": "jetbrains",
"patterns": [
"fm:*/.config/JetBrains",
"fm:*/.cache/JetBrains",
"fm:*/.local/share/JetBrains"
],
"tags": ["type:dev", "editor:jetbrains", "os:linux"],
"author": "SAMAD101"
},
{
"name": "AWS artefacts",
"slug": "aws-artefacts",
"patterns": [
"fm:*/.aws"
],
"tags": ["type:dev", "cloud:aws", "os:linux", "os:darwin"],
"author": "SAMAD101"
},
{
"name": "Spotify cache and config files",
"slug": "spotify",
"patterns": [
"fm:*/.cache/spotify",
"fm:*/.config/spotify"
],
"tags": ["type:media", "media:spotify", "os:linux"],
"author": "SAMAD101"
},
{
"name": "Docker artefacts",
"slug": "docker-artefacts",
"patterns": [
"fm:*/.docker"
],
"tags": ["type:dev", "cloud:docker", "os:linux", "os:darwin"],
"author": "SAMAD101"
}
]

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path fill="#000000" d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/></svg>

Before

Width:  |  Height:  |  Size: 753 B

After

Width:  |  Height:  |  Size: 768 B

View File

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.borgbase.Vorta</id>
<launchable type="desktop-id">com.borgbase.Vorta.desktop</launchable>
<developer_name>Vorta contributors</developer_name>
<name>Vorta</name>
<project_license>GPL-3.0</project_license>
<metadata_license>CC0-1.0</metadata_license>
@ -40,18 +42,13 @@
</screenshot>
</screenshots>
<releases>
<release version="v0.9.1-beta3" date="2023-11-30" urgency="low">
<release version="v0.9.1" date="2024-01-10" urgency="low">
<description>
<ul>
<li>First production 0.9 release</li>
<li>Exclude GUI. By @diivi (#1846)</li>
<li>Backup settings.db before migrations. By @AdwaitSalankar (#1848)</li>
<li>Loosen platformdirs dependency (#1843)</li>
</ul>
</description>
</release>
<release version="v0.9.1-beta2" date="2023-10-27" urgency="low">
<description>
<ul>
<li>Unit test improvements and coverage increase. By @bigtedde (#1787)</li>
<li>Profile sidebar and new setting interface. By @bigtedde (#1809)</li>
<li>Update macOS notarization for use with notarytool (#1831)</li>

View File

@ -25,9 +25,9 @@ class JobInterface(QObject):
@abstractmethod
def cancel(self):
"""
Cancel can be called when the job is not started. It is the responsability of FuncJob to not cancel job if
Cancel can be called when the job is not started. It is the responsibility of FuncJob to not cancel job if
no job is running.
The cancel mehod of JobsManager calls the cancel method on the running jobs only. Other jobs are dequeued.
The cancel method of JobsManager calls the cancel method on the running jobs only. Other jobs are dequeued.
"""
pass
@ -50,6 +50,7 @@ class SiteWorker(threading.Thread):
self.current_job = None
def run(self):
job = None
while True:
try:
job = self.jobs.get(False)
@ -58,7 +59,8 @@ class SiteWorker(threading.Thread):
job.run()
logger.debug("Finish job for site: %s", job.repo_id())
except queue.Empty:
logger.debug("No more jobs for site: %s", job.repo_id())
if job is not None:
logger.debug("No more jobs for site: %s", job.repo_id())
return
@ -77,19 +79,20 @@ class JobsManager:
def is_worker_running(self, site=None):
"""
See if there are any active jobs. The user can't start a backup if a job is
running. The scheduler can.
"""
# Check status for specific site (repo)
if site in self.workers:
return self.workers[site].is_alive()
else:
return False
See if there are any active jobs.
The user can't start a backup if a job is running. The scheduler can.
# Check if *any* worker is active
for _, worker in self.workers.items():
if worker.is_alive():
return True
If site is None, check if there is any worker active for any site (repo).
If site is not None, only check if there is a worker active for the given site (repo).
"""
if site is not None:
if site in self.workers:
if self.workers[site].is_alive():
return True
else:
for _, worker in self.workers.items():
if worker.is_alive():
return True
return False
def add_job(self, job):

View File

@ -24,7 +24,7 @@ class NetworkStatusMonitor:
def is_network_status_available(self):
"""Is the network status really available, and not just a dummy implementation?"""
return type(self) != NetworkStatusMonitor
return type(self) is not NetworkStatusMonitor
def is_network_metered(self) -> bool:
"""Is the currently connected network a metered connection?"""

View File

@ -1,6 +1,8 @@
import subprocess
from datetime import datetime as dt
from typing import Iterator, Optional
from typing import Iterator, List, Optional
from CoreWLAN import CWInterface, CWNetwork, CWWiFiClient
from vorta.log import logger
from vorta.network_status.abc import NetworkStatusMonitor, SystemWifiInfo
@ -8,38 +10,65 @@ from vorta.network_status.abc import NetworkStatusMonitor, SystemWifiInfo
class DarwinNetworkStatus(NetworkStatusMonitor):
def is_network_metered(self) -> bool:
return any(is_network_metered(d) for d in get_network_devices())
interface: CWInterface = self._get_wifi_interface()
network: Optional[CWNetwork] = interface.lastNetworkJoined()
if network:
is_ios_hotspot = network.isPersonalHotspot()
else:
is_ios_hotspot = False
return is_ios_hotspot or any(is_network_metered_with_android(d) for d in get_network_devices())
def get_current_wifi(self) -> Optional[str]:
"""
Get current SSID or None if Wifi is off.
Get current SSID or None if Wi-Fi is off.
"""
interface: Optional[CWInterface] = self._get_wifi_interface()
if not interface:
return None
From https://gist.github.com/keithweaver/00edf356e8194b89ed8d3b7bbead000c
"""
cmd = [
'/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport',
'-I',
]
process = subprocess.Popen(cmd, stdout=subprocess.PIPE)
out, err = process.communicate()
process.wait()
for line in out.decode(errors='ignore').split('\n'):
split_line = line.strip().split(':')
if split_line[0] == 'SSID':
return split_line[1].strip()
# If the user has Wi-Fi turned off lastNetworkJoined will return None.
network: Optional[CWNetwork] = interface.lastNetworkJoined()
def get_known_wifis(self):
if network:
network_name = network.ssid()
return network_name
else:
return None
def get_known_wifis(self) -> List[SystemWifiInfo]:
"""
Listing all known Wifi networks isn't possible any more from macOS 11. Instead we
just return the current Wifi.
Use the program, "networksetup", to get the list of know Wi-Fi networks.
"""
wifis = []
current_wifi = self.get_current_wifi()
if current_wifi is not None:
wifis.append(SystemWifiInfo(ssid=current_wifi, last_connected=dt.now()))
interface: Optional[CWInterface] = self._get_wifi_interface()
if not interface:
return []
interface_name = interface.name()
output = call_networksetup_listpreferredwirelessnetworks(interface_name)
result = []
for line in output.strip().splitlines():
if line.strip().startswith("Preferred networks"):
continue
elif not line.strip():
continue
else:
result.append(line.strip())
for wifi_network_name in result:
wifis.append(SystemWifiInfo(ssid=wifi_network_name, last_connected=dt.now()))
return wifis
def _get_wifi_interface(self) -> Optional[CWInterface]:
wifi_client: CWWiFiClient = CWWiFiClient.sharedWiFiClient()
interface: Optional[CWInterface] = wifi_client.interface()
return interface
def get_network_devices() -> Iterator[str]:
for line in call_networksetup_listallhardwareports().splitlines():
@ -47,7 +76,7 @@ def get_network_devices() -> Iterator[str]:
yield line.split()[1].strip().decode('ascii')
def is_network_metered(bsd_device) -> bool:
def is_network_metered_with_android(bsd_device) -> bool:
return b'ANDROID_METERED' in call_ipconfig_getpacket(bsd_device)
@ -66,3 +95,11 @@ def call_networksetup_listallhardwareports():
return subprocess.check_output(cmd)
except subprocess.CalledProcessError:
logger.debug("Command %s failed", ' '.join(cmd))
def call_networksetup_listpreferredwirelessnetworks(interface) -> str:
command = ['/usr/sbin/networksetup', '-listpreferredwirelessnetworks', interface]
try:
return subprocess.check_output(command).decode(encoding='utf-8')
except subprocess.CalledProcessError:
logger.debug("Command %s failed", " ".join(command))

View File

@ -36,7 +36,7 @@ class ProfileExport:
def repo_url(self):
if (
'repo' in self._profile_dict
and type(self._profile_dict['repo']) == dict
and isinstance(self._profile_dict['repo'], dict)
and 'url' in self._profile_dict['repo']
):
return self._profile_dict['repo']['url']

View File

@ -70,7 +70,7 @@ class VortaScheduler(QtCore.QObject):
self.bus = bus
self.bus.connect(service, path, interface, name, "b", self.loginSuspendNotify)
else:
logger.warn('Failed to connect to DBUS interface to detect sleep/resume events')
logger.warning('Failed to connect to DBUS interface to detect sleep/resume events')
@QtCore.pyqtSlot(bool)
def loginSuspendNotify(self, suspend: bool):
@ -459,6 +459,7 @@ class VortaScheduler(QtCore.QObject):
Pruning and checking after successful backup.
"""
profile = BackupProfileModel.get(id=profile_id)
notifier = VortaNotifications.pick()
logger.info('Doing post-backup jobs for %s', profile.name)
if profile.prune_on:
msg = BorgPruneJob.prepare(profile)
@ -489,6 +490,11 @@ class VortaScheduler(QtCore.QObject):
self.app.jobs_manager.add_job(job)
logger.info('Finished background task for profile %s', profile.name)
notifier.deliver(
self.tr('Vorta Backup'),
self.tr('Post Backup Tasks successful for %s' % profile.name),
level='info',
)
def remove_job(self, profile_id):
if profile_id in self.timers:

View File

@ -62,7 +62,7 @@ class TrayMenu(QSystemTrayIcon):
profiles = BackupProfileModel.select()
if profiles.count() > 1:
profile_menu = menu.addMenu(self.tr('Backup Now'))
for profile in profiles:
for profile in sorted(profiles, key=lambda p: (p.name.casefold(), p.name)):
new_item = profile_menu.addAction(profile.name)
new_item.triggered.connect(lambda state, i=profile.id: self.app.create_backup_action(i))
else:

View File

@ -1,4 +1,5 @@
import logging
from datetime import datetime
from PyQt6 import QtCore, uic
@ -28,6 +29,9 @@ class AboutTab(AboutTabBase, AboutTabUI, BackupProfileMixin):
)
self.gpl_logo.setPixmap(get_colored_icon('gpl_logo', scaled_height=40, return_qpixmap=True))
self.python_logo.setPixmap(get_colored_icon('python_logo', scaled_height=40, return_qpixmap=True))
copyright_text = self.copyrightLabel.text()
copyright_text = copyright_text.replace('2020', str(datetime.now().year))
self.copyrightLabel.setText(copyright_text)
def set_borg_details(self, version, path):
self.borgVersion.setText(version)

View File

@ -875,7 +875,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
return msg.exec() == QMessageBox.StandardButton.Yes
def delete_action(self):
# Since this function modify the UI, we can't put the whole function in a JobQUeue.
# Since this function modify the UI, we can't put the whole function in a JobQueue.
# determine selected archives
archives = []

View File

@ -190,10 +190,13 @@ class DiffResultDialog(DiffResultBase, DiffResultUI):
"""
if selection == 0:
mode = FileTreeModel.DisplayMode.TREE
self.bCollapseAll.setEnabled(True)
elif selection == 1:
mode = FileTreeModel.DisplayMode.SIMPLIFIED_TREE
self.bCollapseAll.setEnabled(True)
elif selection == 2:
mode = FileTreeModel.DisplayMode.FLAT
self.bCollapseAll.setEnabled(False)
else:
raise Exception("Unknown item in comboBoxDisplayMode with index {}".format(selection))
@ -381,7 +384,6 @@ def parse_diff_lines(lines: List[str], model: 'DiffTree'):
if not parsed_line:
raise Exception("Couldn't parse diff output `{}`".format(line))
continue
path = PurePath(parsed_line['path'])
file_type = FileType.FILE

View File

@ -185,8 +185,10 @@ class ExtractDialog(ExtractDialogBase, ExtractDialogUI):
"""
if selection == 0:
mode = FileTreeModel.DisplayMode.TREE
self.bCollapseAll.setEnabled(True)
elif selection == 1:
mode = FileTreeModel.DisplayMode.SIMPLIFIED_TREE
self.bCollapseAll.setEnabled(True)
else:
raise Exception("Unknown item in comboBoxDisplayMode with index {}".format(selection))

View File

@ -65,7 +65,8 @@ class MainWindow(MainWindowBase, MainWindowUI):
prev_profile_id = SettingsModel.get(key='previous_profile_id')
self.current_profile = BackupProfileModel.get_or_none(id=prev_profile_id.str_value)
if self.current_profile is None:
self.current_profile = BackupProfileModel.select().order_by('name').first()
profiles = BackupProfileModel.select()
self.current_profile = min(profiles, key=lambda p: (p.name.casefold(), p.name))
# Load tab models
self.repoTab = RepoTab(self.repoTabSlot)
@ -161,7 +162,8 @@ class MainWindow(MainWindowBase, MainWindowUI):
current_item = None
# Add items to the QListWidget
for profile in BackupProfileModel.select().order_by(BackupProfileModel.name):
profiles = BackupProfileModel.select()
for profile in sorted(profiles, key=lambda p: (p.name.casefold(), p.name)):
item = QListWidgetItem(profile.name)
item.setData(Qt.ItemDataRole.UserRole, profile.id)
@ -284,13 +286,22 @@ class MainWindow(MainWindowBase, MainWindowUI):
def profile_add_edit_result(self, profile_name, profile_id):
# Profile is renamed
if self.profileSelector.currentItem().data(Qt.ItemDataRole.UserRole) == profile_id:
self.profileSelector.currentItem().setText(profile_name)
profile = self.profileSelector.takeItem(self.profileSelector.currentRow())
profile.setText(profile_name)
# Profile is added
else:
profile = QListWidgetItem(profile_name)
profile.setData(Qt.ItemDataRole.UserRole, profile_id)
self.profileSelector.addItem(profile)
self.profileSelector.setCurrentItem(profile)
# Insert the profile at the correct position
# Both the casefolded and the original name are used as the key to keep the order stable
profile_key = (profile.text().casefold(), profile.text())
row = 0
for i in range(self.profileSelector.count()):
item_name = self.profileSelector.item(i).text()
if (item_name.casefold(), item_name) < profile_key:
row += 1
self.profileSelector.insertItem(row, profile)
self.profileSelector.setCurrentItem(profile)
def toggle_misc_visibility(self):
if self.miscWidget.isVisible():

View File

@ -170,3 +170,9 @@ class PasswordInput(QObject):
self.add_form_to_layout(form_layout)
widget.setLayout(form_layout)
return widget
def set_visibility(self, visible: bool) -> None:
self._label_password.setVisible(visible)
self._label_confirm.setVisible(visible)
self.passwordLineEdit.setVisible(visible)
self.confirmLineEdit.setVisible(visible)

View File

@ -610,7 +610,7 @@ class FileTreeModel(QAbstractItemModel, Generic[T]):
if isinstance(path, PurePath):
path = path.parts
return self.root.get_path(path) # handels empty path
return self.root.get_path(path) # handles empty path
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
"""

View File

@ -27,7 +27,7 @@ class AddProfileWindow(AddProfileBase, AddProfileUI):
self.name_blank = trans_late('AddProfileWindow', 'Please enter a profile name.')
self.name_exists = trans_late('AddProfileWindow', 'A profile with this name already exists.')
# Call validate to set inital messages
# Call validate to set initial messages
self.buttonBox.button(QDialogButtonBox.StandardButton.Save).setEnabled(self.validate())
def _set_status(self, text):

View File

@ -199,8 +199,10 @@ class AddRepoWindow(RepoWindow):
'''Validates passwords only if its going to be used'''
if self.values['encryption'] == 'none':
self.passwordInput.set_validation_enabled(False)
self.passwordInput.set_visibility(False)
else:
self.passwordInput.set_validation_enabled(True)
self.passwordInput.set_visibility(True)
def display_backend_warning(self):
'''Display password backend message based off current keyring'''

View File

@ -40,7 +40,7 @@ class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
# compression or speed on a unified scale. this is not 1-dimensional and also depends
# on the input data. so we just tell what we know for sure.
# "auto" is used for some slower / older algorithms to avoid wasting a lot of time
# on uncompressible data.
# on incompressible data.
self.repoCompression.addItem(self.tr('LZ4 (modern, default)'), 'lz4')
self.repoCompression.addItem(self.tr('Zstandard Level 3 (modern)'), 'zstd,3')
self.repoCompression.addItem(self.tr('Zstandard Level 8 (modern)'), 'zstd,8')

View File

@ -1,5 +1,5 @@
from PyQt6 import QtCore, uic
from PyQt6.QtCore import QDateTime, QLocale
from PyQt6.QtCore import QDateTime, QLocale, Qt
from PyQt6.QtWidgets import (
QAbstractItemView,
QApplication,
@ -8,7 +8,7 @@ from PyQt6.QtWidgets import (
QTableWidgetItem,
)
from vorta import application
from vorta import application, config
from vorta.i18n import get_locale
from vorta.scheduler import ScheduleStatusType
from vorta.store.models import BackupProfileMixin, EventLogModel, WifiSettingModel
@ -43,6 +43,10 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
# Set up log table
self.logTableWidget.setAlternatingRowColors(True)
header = self.logTableWidget.horizontalHeader()
self.logLink.setText(
f'<a href="file://{config.LOG_DIR}"><span style="text-decoration:'
'underline; color:#0984e3;">Click here</span></a> for complete logs.'
)
header.setVisible(True)
[header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents) for i in range(5)]
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
@ -202,7 +206,7 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
def save_wifi_item(self, item):
db_item = WifiSettingModel.get(ssid=item.text(), profile=self.profile().id)
db_item.allowed = item.checkState() == 2
db_item.allowed = item.checkState() == Qt.CheckState.Checked
db_item.save()
def save_profile_attr(self, attr, new_value):

View File

@ -331,7 +331,7 @@ class SourceTab(SourceBase, SourceUI, BackupProfileMixin):
profile = self.profile()
# sort indexes, starting with lowest
indexes.sort()
# remove each selected row, starting with highest index (otherways, higher indexes become invalid)
# remove each selected row, starting with the highest index (otherwise, higher indexes become invalid)
for index in reversed(indexes):
db_item = SourceFileModel.get(
dir=self.sourceFilesWidget.item(index.row(), SourceColumn.Path).text(),

View File

@ -1,25 +1,118 @@
from unittest.mock import MagicMock
import pytest
from vorta.network_status import darwin
def test_get_current_wifi_when_wifi_is_on(mocker):
mock_interface = MagicMock()
mock_network = MagicMock()
mock_interface.lastNetworkJoined.return_value = mock_network
mock_network.ssid.return_value = "Coffee Shop Wifi"
instance = darwin.DarwinNetworkStatus()
mocker.patch.object(instance, "_get_wifi_interface", return_value=mock_interface)
result = instance.get_current_wifi()
assert result == "Coffee Shop Wifi"
def test_get_current_wifi_when_wifi_is_off(mocker):
mock_interface = MagicMock()
mock_interface.lastNetworkJoined.return_value = None
instance = darwin.DarwinNetworkStatus()
mocker.patch.object(instance, "_get_wifi_interface", return_value=mock_interface)
result = instance.get_current_wifi()
assert result is None
def test_get_current_wifi_when_no_wifi_interface(mocker):
instance = darwin.DarwinNetworkStatus()
mocker.patch.object(instance, "_get_wifi_interface", return_value=None)
result = instance.get_current_wifi()
assert result is None
@pytest.mark.parametrize("is_hotspot_enabled", [True, False])
def test_network_is_metered_with_ios(mocker, is_hotspot_enabled):
mock_interface = MagicMock()
mock_network = MagicMock()
mock_interface.lastNetworkJoined.return_value = mock_network
mock_network.isPersonalHotspot.return_value = is_hotspot_enabled
instance = darwin.DarwinNetworkStatus()
mocker.patch.object(instance, "_get_wifi_interface", return_value=mock_interface)
result = instance.is_network_metered()
assert result == is_hotspot_enabled
def test_network_is_metered_when_wifi_is_off(mocker):
mock_interface = MagicMock()
mock_interface.lastNetworkJoined.return_value = None
instance = darwin.DarwinNetworkStatus()
mocker.patch.object(instance, "_get_wifi_interface", return_value=mock_interface)
result = instance.is_network_metered()
assert result is False
@pytest.mark.parametrize(
'getpacket_output_name, expected',
[
('normal_router', False),
('phone', True),
('android_phone', True),
],
)
def test_is_network_metered(getpacket_output_name, expected, monkeypatch):
def test_is_network_metered_with_android(getpacket_output_name, expected, monkeypatch):
def mock_getpacket(device):
assert device == 'en0'
return GETPACKET_OUTPUTS[getpacket_output_name]
monkeypatch.setattr(darwin, 'call_ipconfig_getpacket', mock_getpacket)
result = darwin.is_network_metered('en0')
result = darwin.is_network_metered_with_android('en0')
assert result == expected
def test_get_known_wifi_networks_when_wifi_interface_exists(monkeypatch):
networksetup_output = """
Preferred networks on en0:
Home Network
Coffee Shop Wifi
iPhone
Office Wifi
"""
monkeypatch.setattr(
darwin, "call_networksetup_listpreferredwirelessnetworks", lambda interface_name: networksetup_output
)
network_status = darwin.DarwinNetworkStatus()
result = network_status.get_known_wifis()
assert len(result) == 4
assert result[0].ssid == "Home Network"
def test_get_known_wifi_networks_when_no_wifi_interface(mocker):
instance = darwin.DarwinNetworkStatus()
mocker.patch.object(instance, "_get_wifi_interface", return_value=None)
results = instance.get_known_wifis()
assert results == []
def test_get_network_devices(monkeypatch):
monkeypatch.setattr(darwin, 'call_networksetup_listallhardwareports', lambda: NETWORKSETUP_OUTPUT)
@ -55,7 +148,7 @@ interface_mtu (uint16): 0x5dc
server_identifier (ip): 172.16.12.1
end (none):
""",
'phone': b"""\
'android_phone': b"""\
op = BOOTREPLY
htype = 1
flags = 0

View File

@ -6,14 +6,17 @@ import vorta.utils
import vorta.views.archive_tab
from PyQt6.QtCore import QDateTime, QItemSelectionModel, Qt
from PyQt6.QtWidgets import QMenu
from vorta.store.models import ArchiveModel
from vorta.views.diff_result import (
ChangeType,
DiffData,
DiffResultDialog,
DiffTree,
FileType,
parse_diff_json,
parse_diff_lines,
)
from vorta.views.partials.treemodel import FileTreeModel
def setup_diff_result_window(qtbot, mocker, tab, borg_json_output, json_mock_file="diff_archives"):
@ -457,3 +460,19 @@ def test_archive_diff_json_parser(line, expected):
assert item.path == PurePath(expected[0]).parts
assert item.data == DiffData(*expected[1:])
@pytest.mark.parametrize(
"selection, expected_mode, expected_bCollapseAllEnabled",
[
(0, FileTreeModel.DisplayMode.TREE, True),
(1, FileTreeModel.DisplayMode.SIMPLIFIED_TREE, True),
(2, FileTreeModel.DisplayMode.FLAT, False),
],
)
def test_change_display_mode(selection: int, expected_mode, expected_bCollapseAllEnabled):
dialog = DiffResultDialog(ArchiveModel(), ArchiveModel(), DiffTree())
dialog.change_display_mode(selection)
assert dialog.model.mode == expected_mode
assert dialog.bCollapseAll.isEnabled() == expected_bCollapseAllEnabled

View File

@ -1,7 +1,15 @@
import pytest
import vorta.borg
from PyQt6.QtCore import QModelIndex, Qt
from vorta.views.extract_dialog import ExtractTree, FileData, FileType, parse_json_lines
from vorta.views.partials.treemodel import FileSystemItem
from vorta.store.models import ArchiveModel
from vorta.views.extract_dialog import (
ExtractDialog,
ExtractTree,
FileData,
FileType,
parse_json_lines,
)
from vorta.views.partials.treemodel import FileSystemItem, FileTreeModel
def prepare_borg(mocker, borg_json_output):
@ -177,3 +185,15 @@ def test_selection():
select(model, iab)
assert a.data.checkstate == Qt.CheckState(1)
@pytest.mark.parametrize(
"selection, expected_mode, expected_bCollapseAllEnabled",
[(0, FileTreeModel.DisplayMode.TREE, True), (1, FileTreeModel.DisplayMode.SIMPLIFIED_TREE, True)],
)
def test_change_display_mode(selection: int, expected_mode, expected_bCollapseAllEnabled):
dialog = ExtractDialog(ArchiveModel(), ExtractTree())
dialog.change_display_mode(selection)
assert dialog.model.mode == expected_mode
assert dialog.bCollapseAll.isEnabled() == expected_bCollapseAllEnabled

View File

@ -87,7 +87,7 @@ class TestFileSystemItem:
item.add(child2)
item.add(child3)
# test get inexistent subpath
# test get nonexistent subpath
assert item.get('unknown') is None
assert item.get('unknown', default='default') == 'default'