├── tests ├── __init__.py └── unit │ ├── __init__.py │ ├── services │ ├── __init__.py │ └── reconnector │ │ ├── __init__.py │ │ ├── test_session_monitor.py │ │ ├── test_vpn_monitor.py │ │ └── test_network_monitor.py │ ├── widgets │ ├── __init__.py │ ├── login │ │ ├── __init__.py │ │ ├── two_factor_auth │ │ │ ├── __init__.py │ │ │ ├── test_two_factor_auth_widget.py │ │ │ └── test_two_factor_auth_stack.py │ │ ├── test_login_stack.py │ │ └── test_login_widget.py │ ├── main │ │ ├── __init__.py │ │ ├── test_notification_bar.py │ │ ├── test_main_window.py │ │ └── test_notifications.py │ ├── vpn │ │ ├── __init__.py │ │ ├── serverlist │ │ │ └── __init__.py │ │ ├── test_quick_connect_widget.py │ │ ├── test_search_entry.py │ │ └── test_connection_status_widget.py │ └── headerbar │ │ ├── __init__.py │ │ └── menu │ │ ├── __init__.py │ │ ├── settings │ │ ├── __init__.py │ │ ├── split_tunneling │ │ │ ├── __init__.py │ │ │ ├── app │ │ │ │ ├── __init__.py │ │ │ │ ├── mock_app_data.py │ │ │ │ ├── test_data_structures.py │ │ │ │ ├── test_installed_apps.py │ │ │ │ ├── test_selected_app_list.py │ │ │ │ ├── test_app_select_window.py │ │ │ │ └── test_settings.py │ │ │ ├── test_mode.py │ │ │ └── test_split_tunneling.py │ │ ├── test_account_settings.py │ │ └── test_connection_settings.py │ │ └── test_release_notes_dialog.py │ ├── utils │ ├── __init__.py │ ├── test_search.py │ ├── test_glib.py │ └── test_executor.py │ ├── test_controller.py │ └── testing_utils.py ├── debian ├── compat ├── install ├── rules ├── .gitignore ├── copyright └── control ├── rpmbuild ├── BUILD │ └── .gitkeep ├── SPECS │ ├── .gitkeep │ └── package.spec.template ├── SRPMS │ └── .gitkeep ├── BUILDROOT │ └── .gitkeep └── SOURCES │ ├── .gitkeep │ ├── proton.vpn.app.gtk.desktop │ └── proton-vpn-logo.svg ├── proton └── vpn │ └── app │ └── gtk │ ├── widgets │ ├── __init__.py │ ├── main │ │ ├── __init__.py │ │ └── confirmation_dialog.py │ ├── headerbar │ │ ├── __init__.py │ │ ├── menu │ │ │ ├── __init__.py │ │ │ ├── settings │ │ │ │ ├── __init__.py │ │ │ │ ├── split_tunneling │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── app │ │ │ │ │ │ └── __init__.py │ │ │ │ └── account_settings.py │ │ │ └── about_dialog.py │ │ └── headerbar.py │ ├── login │ │ ├── __init__.py │ │ ├── two_factor_auth │ │ │ ├── __init__.py │ │ │ ├── authenticate_button.py │ │ │ └── two_factor_auth_widget.py │ │ ├── logo.py │ │ ├── password_entry.py │ │ ├── login_widget.py │ │ ├── disable_killswitch.py │ │ └── login_stack.py │ └── vpn │ │ ├── serverlist │ │ ├── __init__.py │ │ └── icons.py │ │ ├── __init__.py │ │ ├── search_entry.py │ │ └── quick_connect_widget.py │ ├── assets │ ├── icons │ │ ├── eye │ │ │ ├── __init__.py │ │ │ ├── show.svg │ │ │ └── hide.svg │ │ ├── servers │ │ │ ├── __init__.py │ │ │ ├── streaming.svg │ │ │ ├── p2p.svg │ │ │ ├── smart-routing.svg │ │ │ ├── tor.svg │ │ │ └── secure-core.svg │ │ ├── __init__.py │ │ ├── no-app-icon.svg │ │ ├── maintenance-icon.svg │ │ ├── state-disconnected.svg │ │ ├── icons.py │ │ ├── state-connected.svg │ │ ├── state-error.svg │ │ └── proton-vpn-sign.svg │ ├── style │ │ ├── __init__.py │ │ ├── dark_buttons.css │ │ ├── dark_colours.css │ │ └── main.css │ └── __init__.py │ ├── services │ ├── reconnector │ │ ├── __init__.py │ │ ├── vpn_monitor.py │ │ ├── session_monitor.py │ │ └── network_monitor.py │ └── __init__.py │ ├── utils │ ├── accessibility.py │ ├── __init__.py │ ├── search.py │ └── glib.py │ ├── util.py │ ├── __init__.py │ ├── __main__.py │ ├── settings_watchers.py │ ├── config.py │ └── conflicts.py ├── requirements.txt ├── MANIFEST.in ├── .gitmodules ├── .gitignore ├── .gitlab-ci.yml ├── setup.cfg ├── CODEOWNERS ├── CONTRIBUTING.md ├── COPYING.md ├── scripts └── create_changelogs.py ├── setup.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 11 2 | -------------------------------------------------------------------------------- /rpmbuild/BUILD/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rpmbuild/SPECS/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rpmbuild/SRPMS/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rpmbuild/BUILDROOT/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rpmbuild/SOURCES/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e ".[development]" -------------------------------------------------------------------------------- /tests/unit/widgets/login/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/widgets/main/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/widgets/vpn/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/main/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/services/reconnector/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/eye/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/headerbar/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/login/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/widgets/vpn/serverlist/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/servers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/headerbar/menu/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/vpn/serverlist/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/widgets/login/two_factor_auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/split_tunneling/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/services/reconnector/__init__.py: -------------------------------------------------------------------------------- 1 | """VPN reconnector""" 2 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/split_tunneling/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include proton/vpn/app/gtk/assets * 2 | include versions.yml 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "scripts/devtools"] 2 | path = scripts/devtools 3 | url = ../integration/devtools.git 4 | -------------------------------------------------------------------------------- /debian/install: -------------------------------------------------------------------------------- 1 | rpmbuild/SOURCES/proton.vpn.app.gtk.desktop usr/share/applications/ 2 | rpmbuild/SOURCES/proton-vpn-logo.svg usr/share/icons/hicolor/scalable/apps -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | #export DH_VERBOSE=1 4 | 5 | export PYBUILD_NAME=proton-vpn-gtk-app 6 | 7 | %: 8 | dh $@ --with python3 --buildsystem=pybuild 9 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/style/__init__.py: -------------------------------------------------------------------------------- 1 | """All the icons the app uses are available in this module.""" 2 | from pathlib import Path 3 | 4 | STYLE_PATH = Path(__file__).parent 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | venv 3 | .idea 4 | *.pyc 5 | *.egg-info/ 6 | .coverage 7 | .python-version 8 | *.code-workspace 9 | venv_testing 10 | .vscode 11 | dist/ 12 | package.spec -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | GIT_SUBMODULE_STRATEGY: recursive 3 | GIT_SUBMODULE_DEPTH: 1 4 | 5 | include: 6 | - project: 'ProtonVPN/Linux/integration/ci-libraries' 7 | ref: develop 8 | file: 'develop-pipeline.yml' 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | per-file-ignores = integration_tests/features/steps/*.py: F811 4 | 5 | [tool:pytest] 6 | addopts = --cov=proton --cov-report=html --cov-report=term 7 | testpaths = 8 | tests/unit 9 | -------------------------------------------------------------------------------- /rpmbuild/SOURCES/proton.vpn.app.gtk.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Proton VPN 3 | Exec=protonvpn-app 4 | Terminal=false 5 | Type=Application 6 | Icon=proton-vpn-logo 7 | StartupWMClass=protonvpn-app 8 | Comment=Proton VPN GUI client 9 | Categories=Network; -------------------------------------------------------------------------------- /debian/.gitignore: -------------------------------------------------------------------------------- 1 | .debhelper 2 | debhelper-build-stamp 3 | files 4 | python3-proton-vpn-cli.debhelper.log 5 | python3-proton-vpn-cli.postinst.debhelper 6 | python3-proton-vpn-cli.postrm.debhelper 7 | python3-proton-vpn-cli.prerm.debhelper 8 | python3-proton-vpn-cli.substvars 9 | python3-proton-vpn-cli 10 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # ownership: loose 2 | * @ProtonVPN/groups/linux-developers 3 | /debian/ @ProtonVPN/groups/linux-developers 4 | /proton/ @ProtonVPN/groups/linux-developers 5 | /rpmbuild/ @ProtonVPN/groups/linux-developers 6 | /scripts/ @ProtonVPN/groups/linux-developers 7 | /tests/ @ProtonVPN/groups/linux-developers 8 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Source: https://github.com/ProtonVPN/ 3 | Upstream-Name: proton-vpn-gtk-app 4 | 5 | Files: 6 | * 7 | Copyright: 2023 Proton AG 8 | License: GPL-3 9 | The full text of the GPL version 3 is distributed in 10 | /usr/share/common-licenses/GPL-3 on Debian systems. -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/split_tunneling/app/mock_app_data.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.unit.testing_utils import process_gtk_events 4 | 5 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.app.data_structures \ 6 | import AppData 7 | 8 | 9 | @pytest.fixture 10 | def mock_app_data() -> AppData: 11 | return AppData( 12 | name="test-app", 13 | executable="test/path", 14 | icon_name="test-icon" 15 | ) 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Policy 2 | 3 | By making a contribution to this project: 4 | 5 | 1. I assign any and all copyright related to the contribution to Proton AG; 6 | 2. I certify that the contribution was created in whole by me; 7 | 3. I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it) is maintained indefinitely and may be redistributed with this project or the open source license(s) involved. 8 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/utils/accessibility.py: -------------------------------------------------------------------------------- 1 | """Utils used to increase accessibility on the app.""" 2 | 3 | from typing import List, Tuple 4 | 5 | from gi.repository import Atk, Gtk 6 | 7 | 8 | def add_widget_relationships( 9 | target_widget, relationships: List[Tuple[Gtk.Widget, Atk.RelationType]]): 10 | """Screen readers use these relationships to add information to the server row button.""" 11 | for related_widget, relation_type in relationships: 12 | target_widget.get_accessible().add_relationship( 13 | relation_type, related_widget.get_accessible() 14 | ) 15 | -------------------------------------------------------------------------------- /COPYING.md: -------------------------------------------------------------------------------- 1 | ## Copying 2 | 3 | Copyright (c) 2023 Proton AG 4 | 5 | Proton VPN GTK app is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | Proton VPN GTK app is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with Proton VPN. If not, see [https://www.gnu.org/licenses](https://www.gnu.org/licenses/). 17 | -------------------------------------------------------------------------------- /tests/unit/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/servers/streaming.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Miscellaneous utilities. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/eye/show.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/__init__.py: -------------------------------------------------------------------------------- 1 | """All the assets the app uses are available in this module. 2 | 3 | 4 | Copyright (c) 2023 Proton AG 5 | 6 | This file is part of Proton VPN. 7 | 8 | Proton VPN is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | Proton VPN is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU General Public License for more details. 17 | 18 | You should have received a copy of the GNU General Public License 19 | along with ProtonVPN. If not, see . 20 | """ 21 | from pathlib import Path 22 | 23 | ASSETS_PATH = Path(__file__).parent 24 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/__init__.py: -------------------------------------------------------------------------------- 1 | """All the icons the app uses are available in this module. 2 | 3 | Copyright (c) 2023 Proton AG 4 | 5 | This file is part of Proton VPN. 6 | 7 | Proton VPN is free software: you can redistribute it and/or modify 8 | it under the terms of the GNU General Public License as published by 9 | the Free Software Foundation, either version 3 of the License, or 10 | (at your option) any later version. 11 | 12 | Proton VPN is distributed in the hope that it will be useful, 13 | but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | GNU General Public License for more details. 16 | 17 | You should have received a copy of the GNU General Public License 18 | along with ProtonVPN. If not, see . 19 | """ 20 | from proton.vpn.app.gtk.assets.icons.icons import get, ICONS_PATH 21 | 22 | 23 | __all__ = ["get", "ICONS_PATH"] 24 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/vpn/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module includes the main vpn widget for the application. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | from proton.vpn.app.gtk.widgets.vpn.vpn_widget import VPNWidget 23 | 24 | __all__ = ["VPNWidget"] 25 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/servers/p2p.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: proton-vpn-gtk-app 2 | Section: python 3 | Priority: optional 4 | Maintainer: Proton AG 5 | Build-Depends: debhelper (>= 9), dh-python, python3-all, librsvg2-common, python3-setuptools, 6 | python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, python3-proton-vpn-api-core (>= 4.13.0), 7 | python3-dbus, gir1.2-nm-1.0, gir1.2-notify-0.7, python3-packaging, python3-distro, 8 | python3-requests, python3-proton-core 9 | Standards-Version: 4.1.1 10 | X-Python3-Version: >= 3.9 11 | 12 | Package: proton-vpn-gtk-app 13 | Architecture: all 14 | Depends: ${python3:Depends}, ${misc:Depends}, librsvg2-common, python3-gi, python3-gi-cairo, 15 | gir1.2-gtk-3.0, python3-proton-vpn-api-core (>= 4.13.0), python3-dbus, 16 | gir1.2-nm-1.0, gir1.2-notify-0.7, python3-packaging, python3-distro, 17 | python3-requests, python3-proton-core 18 | Suggests: libayatana-appindicator3-1, gir1.2-ayatanaappindicator3-0.1 19 | Description: Proton VPN App 20 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/headerbar/menu/settings/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Entry module for settings. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | 23 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.settings_window import SettingsWindow 24 | 25 | __all__ = ["SettingsWindow"] 26 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/headerbar/menu/settings/split_tunneling/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2025 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.split_tunneling \ 20 | import SplitTunnelingToggle 21 | 22 | __all__ = ["SplitTunnelingToggle"] 23 | -------------------------------------------------------------------------------- /tests/unit/utils/test_search.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from proton.vpn.app.gtk.utils.search import normalize 20 | 21 | 22 | def test_normalize(): 23 | input_string = "CH-PT#1 " 24 | normalized_string = normalize(input_string) 25 | assert normalized_string == "ch-pt#1" 26 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/utils/search.py: -------------------------------------------------------------------------------- 1 | """Utilities used on the search features of the app. 2 | 3 | 4 | Copyright (c) 2023 Proton AG 5 | 6 | This file is part of Proton VPN. 7 | 8 | Proton VPN is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | Proton VPN is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU General Public License for more details. 17 | 18 | You should have received a copy of the GNU General Public License 19 | along with ProtonVPN. If not, see .""" 20 | 21 | 22 | def normalize(search_string: str): 23 | """Returns the normalized version of the input search string.""" 24 | return search_string.lower().replace(" ", "") 25 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/headerbar/menu/settings/split_tunneling/app/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2025 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.app.settings import \ 20 | AppBasedSplitTunnelingSettings 21 | 22 | __all__ = ["AppBasedSplitTunnelingSettings"] 23 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/login/two_factor_auth/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines the widget used to display the two factor authentication methods. 3 | 4 | 5 | Copyright (c) 2025 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | 23 | from proton.vpn.app.gtk.widgets.login.two_factor_auth.two_factor_auth_widget \ 24 | import TwoFactorAuthWidget 25 | 26 | __all__ = ["TwoFactorAuthWidget"] 27 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/test_account_settings.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, PropertyMock, patch 2 | from tests.unit.testing_utils import process_gtk_events 3 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.account_settings import AccountSettings, CustomButton 4 | 5 | 6 | @patch("proton.vpn.app.gtk.widgets.headerbar.menu.settings.account_settings.AccountSettings.pack_start") 7 | @patch("proton.vpn.app.gtk.widgets.headerbar.menu.settings.account_settings.Gtk.show_uri_on_window") 8 | def test_account_settings_ensure_url_is_opened_when_clicking_on_button(show_uri_on_window_mock, pack_start_mock): 9 | controller_mock = Mock() 10 | 11 | controller_mock.account_name = "test account name" 12 | controller_mock.account_data.plan_title = "Free" 13 | 14 | account_settings = AccountSettings(controller_mock) 15 | account_settings.build_ui() 16 | custom_button = pack_start_mock.call_args[0][0] 17 | custom_button.button.clicked() 18 | 19 | process_gtk_events() 20 | 21 | show_uri_on_window_mock.assert_called_once() 22 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/no-app-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/maintenance-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/eye/hide.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/servers/smart-routing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 15 | 16 | -------------------------------------------------------------------------------- /tests/unit/widgets/main/test_notification_bar.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | import time 20 | 21 | from proton.vpn.app.gtk.widgets.main.notification_bar import NotificationBar 22 | from tests.unit.testing_utils import process_gtk_events 23 | 24 | 25 | def test_notification_bar_shows_error_message_and_hides_it_automatically(): 26 | notification_bar = NotificationBar() 27 | notification_bar.show_error_message("My error message.", hide_after_ms=100) 28 | assert notification_bar.current_message == "My error message." 29 | time.sleep(0.2) 30 | process_gtk_events() 31 | assert notification_bar.current_message == "" 32 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains utility tools. 3 | 4 | Copyright (c) 2023 Proton AG 5 | 6 | This file is part of Proton VPN. 7 | 8 | Proton VPN is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | Proton VPN is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU General Public License for more details. 17 | 18 | You should have received a copy of the GNU General Public License 19 | along with ProtonVPN. If not, see . 20 | """ 21 | from typing import Callable 22 | from gi.repository import Gtk 23 | 24 | APPLICATION_ID = "proton.vpn.app.gtk" 25 | 26 | 27 | def connect_once(widget: Gtk.Widget, signal: str, callback: Callable, *args): 28 | """Subscribes to the signal once.""" 29 | context = {} 30 | 31 | def wrapper(*args, **kwargs): 32 | widget.disconnect(context["id"]) 33 | callback(*args, **kwargs) 34 | 35 | context["id"] = widget.connect(signal, wrapper, *args) 36 | return context["id"] 37 | 38 | 39 | __all__ = ["connect_once"] 40 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/style/dark_buttons.css: -------------------------------------------------------------------------------- 1 | @import "dark_colours.css"; 2 | 3 | button.primary:not(:disabled) { 4 | background-color: @interaction-norm; 5 | background-image: image(@interaction-norm); 6 | border-color: @interaction-norm; 7 | color: @text-norm; 8 | text-shadow: none; 9 | } 10 | 11 | button.secondary:hover, button.primary:hover { 12 | background-color: @interaction-norm-hover; 13 | background-image: image(@interaction-norm-hover); 14 | border-color: @interaction-norm-hover; 15 | color: @text-norm; 16 | text-shadow: none; 17 | } 18 | 19 | button.danger:hover { 20 | border-color: @signal-danger; 21 | background-color: @signal-danger; 22 | background-image: image(@signal-danger); 23 | color: @text-norm; 24 | text-shadow: none; 25 | } 26 | 27 | button.secondary:active, button.primary:active { 28 | background-image: image(@interaction-norm-active); 29 | border-color: @interaction-norm-active; 30 | color: @text-norm; 31 | text-shadow: none; 32 | } 33 | 34 | button.danger:active { 35 | border-color: @signal-danger-active; 36 | background-color: @signal-danger-active; 37 | background-image: image(@signal-danger-active); 38 | color: @text-norm; 39 | text-shadow: none; 40 | } 41 | 42 | button.spaced label { 43 | padding: 5px 20px; 44 | } 45 | 46 | button.link label { 47 | color: @interaction-norm; 48 | } 49 | 50 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module includes the Proton VPN GTK application for Linux. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | from importlib.metadata import version, PackageNotFoundError 23 | import gi 24 | 25 | try: 26 | __version__ = version("proton-vpn-gtk-app") 27 | except PackageNotFoundError: 28 | __version__ = "development" 29 | 30 | gi.require_version("Gtk", "3.0") 31 | gi.require_version("Notify", "0.7") 32 | 33 | from gi.repository import Gtk # pylint: disable=C0413 # noqa: E402 34 | 35 | from proton.vpn import logging # pylint: disable=C0413 # noqa: E402 36 | 37 | 38 | logging.config(filename="vpn-app") 39 | 40 | __all__ = [Gtk] 41 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | App entry point. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | 23 | import sys 24 | 25 | from proton.vpn.app.gtk.app import App 26 | from proton.vpn.app.gtk.controller import Controller 27 | from proton.vpn.app.gtk.utils.exception_handler import ExceptionHandler 28 | from proton.vpn.app.gtk.utils.executor import AsyncExecutor 29 | 30 | 31 | def main(): 32 | """Runs the app.""" 33 | 34 | with AsyncExecutor() as executor, ExceptionHandler() as exception_handler: 35 | controller = Controller.get(executor, exception_handler) 36 | sys.exit(App(controller).run(sys.argv)) 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/services/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Different services running in the background. 3 | 4 | Currently, these services are all running on the main (GLib) event loop. 5 | However, the goal is to extract some (like the reconnector), to a systemd service 6 | running on separate process. But, to be able to do that, first we need a VPN daemon 7 | process coordinating the creation/deletion of VPN connections requested by other 8 | processes like the app, the CLI or the reconnector, once extracted to a separate 9 | process. 10 | 11 | 12 | Copyright (c) 2023 Proton AG 13 | 14 | This file is part of Proton VPN. 15 | 16 | Proton VPN is free software: you can redistribute it and/or modify 17 | it under the terms of the GNU General Public License as published by 18 | the Free Software Foundation, either version 3 of the License, or 19 | (at your option) any later version. 20 | 21 | Proton VPN is distributed in the hope that it will be useful, 22 | but WITHOUT ANY WARRANTY; without even the implied warranty of 23 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 24 | GNU General Public License for more details. 25 | 26 | You should have received a copy of the GNU General Public License 27 | along with ProtonVPN. If not, see . 28 | """ 29 | from proton.vpn.app.gtk.services.reconnector.reconnector import VPNReconnector 30 | 31 | __all__ = ["VPNReconnector"] 32 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/split_tunneling/app/test_data_structures.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from tests.unit.testing_utils import process_gtk_events 4 | 5 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.app.data_structures \ 6 | import AppData, AppRowWithCheckbox, AppRowWithRemoveButton 7 | 8 | from .mock_app_data import mock_app_data 9 | 10 | 11 | def test_app_data_coverts_from_dict_correctly(mock_app_data): 12 | app_dict = mock_app_data.to_dict() 13 | assert AppData.from_dict(app_dict) == mock_app_data 14 | 15 | 16 | def test_build_with_remove_button_remove_signals_is_emitted_when_clicked_on_remove_button(mock_app_data): 17 | remove_app_callback = Mock() 18 | app_row = AppRowWithRemoveButton.build(mock_app_data) 19 | app_row.connect("remove-app", remove_app_callback) 20 | app_row._remove_button.clicked() 21 | 22 | process_gtk_events() 23 | 24 | remove_app_callback.assert_called_once() 25 | 26 | 27 | def test_build_with_checkbox_is_checked_when_passing_already_selected_app(mock_app_data): 28 | app_row = AppRowWithCheckbox.build( 29 | app_data=mock_app_data, checked=mock_app_data in [mock_app_data] 30 | ) 31 | assert app_row.checked 32 | 33 | 34 | def test_build_with_checkbox_is_checked_when_passing_newly_selected_app(mock_app_data): 35 | app_row = AppRowWithCheckbox.build( 36 | app_data=mock_app_data, checked=mock_app_data in [] 37 | ) 38 | assert not app_row.checked 39 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/state-disconnected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /scripts/create_changelogs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ''' 3 | This program generates a deb changelog file, and rpm spec file and a 4 | CHANGELOG.md file for this project. 5 | 6 | It reads versions.yml. 7 | ''' 8 | import os 9 | import yaml 10 | import devtools.versions as versions 11 | 12 | # The root of this repo 13 | ROOT = os.path.dirname( 14 | os.path.dirname(os.path.realpath(__file__)) 15 | ) 16 | 17 | NAME = "proton-vpn-gtk-app" # Name of this application. 18 | VERSIONS = os.path.join(ROOT, "versions.yml") # Name of this applications versions.yml 19 | RPM = os.path.join(ROOT, "rpmbuild", "SPECS", "package.spec") # Path of spec filefor rpm. 20 | RPM_TMPLT = os.path.join(ROOT, "rpmbuild", "SPECS", "package.spec.template") # Path of template spec file for rpm. 21 | DEB = os.path.join(ROOT, "debian", "changelog") # Path of debian changelog. 22 | MARKDOWN = os.path.join(ROOT, "CHANGELOG.md",) # Path of CHANGELOG.md. 23 | 24 | 25 | def build(): 26 | ''' 27 | This is what generates the rpm spec, deb changelog and 28 | markdown CHANGELOG.md file. 29 | ''' 30 | with open(VERSIONS, encoding="utf-8") as versions_file: 31 | 32 | # Load versions.yml 33 | versions_yml = list(yaml.safe_load_all(versions_file)) 34 | 35 | # Make our files 36 | versions.build_rpm(RPM, versions_yml, RPM_TMPLT) 37 | versions.build_deb(DEB, versions_yml, NAME) 38 | versions.build_mkd(MARKDOWN, versions_yml) 39 | 40 | 41 | if __name__ == "__main__": 42 | build() 43 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/servers/tor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 17 | 18 | -------------------------------------------------------------------------------- /rpmbuild/SOURCES/proton-vpn-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/unit/widgets/login/two_factor_auth/test_two_factor_auth_widget.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | 20 | import pytest 21 | from unittest.mock import Mock 22 | 23 | from proton.vpn.app.gtk.widgets.login.two_factor_auth.two_factor_auth_widget import TwoFactorAuthWidget 24 | from tests.unit.testing_utils import process_gtk_events 25 | 26 | 27 | def test_two_factor_auth_widget_forwards_two_factor_auth_successful_signal_when_received_from_two_factor_auth_stack(): 28 | controller_mock = Mock() 29 | controller_mock.fido2_available = True 30 | two_factor_auth_successful_callback = Mock() 31 | 32 | two_factor_auth_widget = TwoFactorAuthWidget(controller_mock, Mock(), Mock()) 33 | two_factor_auth_widget.connect("two-factor-auth-successful", two_factor_auth_successful_callback) 34 | two_factor_auth_widget.two_factor_auth_stack.emit("two-factor-auth-successful") 35 | 36 | process_gtk_events() 37 | 38 | two_factor_auth_successful_callback.assert_called_once() 39 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/login/two_factor_auth/authenticate_button.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines the widget used to display the authenticate button. 3 | 4 | 5 | Copyright (c) 2025 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | from gi.repository import Gtk 23 | 24 | AUTHENTICATE_BUTTON_LABEL = "Authenticate" 25 | 26 | 27 | class AuthenticateButton(Gtk.Button): 28 | """ 29 | Implements the UI for the authenticate button. 30 | """ 31 | 32 | def __init__(self, label: str = AUTHENTICATE_BUTTON_LABEL): 33 | super().__init__(label=label) 34 | self.get_style_context().add_class("primary") 35 | self.set_halign(Gtk.Align.FILL) 36 | self.set_hexpand(True) 37 | 38 | @property 39 | def enable(self) -> bool: 40 | """Returns if the authenticate button should be enabled or not.""" 41 | return self.get_property("sensitive") 42 | 43 | @enable.setter 44 | def enable(self, newvalue: bool): 45 | """Sets if the authenticate button should be enabled or not.""" 46 | self.set_property("sensitive", newvalue) 47 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/icons.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility module to load and cache icons. 3 | 4 | We should consider to switch to Gtk.IconTheme: 5 | https://docs.gtk.org/gtk3/class.IconTheme.html 6 | """ 7 | from pathlib import Path 8 | from typing import Optional 9 | 10 | from gi.repository import GdkPixbuf 11 | 12 | ICONS_PATH = Path(__file__).parent 13 | 14 | _cache = {} 15 | 16 | 17 | def get( 18 | relative_path: Path, 19 | width: Optional[int] = None, 20 | height: Optional[int] = None, 21 | preserve_aspect_ratio: bool = True 22 | ) -> GdkPixbuf.Pixbuf: 23 | """ 24 | Loads the image (if it wasn't cached), caches it and returns it. 25 | :param relative_path: Path relative to the icons directory root. 26 | :param width: Optional width of the image to be loaded. 27 | :param height: Optional height of the image to be loaded. 28 | :param preserve_aspect_ratio: Whether the aspect ratio should be preserved 29 | or not. The default is True. 30 | """ 31 | # Pixbuf API quirks. 32 | width = width if width is not None else -1 33 | height = height if height is not None else -1 34 | 35 | cache_key = (relative_path, width, height, preserve_aspect_ratio) 36 | cached_icon = _cache.get(cache_key) 37 | if cached_icon: 38 | return cached_icon 39 | 40 | full_path = ICONS_PATH / relative_path 41 | if not full_path.is_file(): 42 | raise ValueError(f"File not found: {full_path}") 43 | 44 | filename = str(ICONS_PATH / relative_path) 45 | pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( 46 | filename=filename, width=width, height=height, 47 | preserve_aspect_ratio=preserve_aspect_ratio 48 | ) 49 | _cache[cache_key] = pixbuf 50 | 51 | return pixbuf 52 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/vpn/search_entry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Server search entry module. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | from __future__ import annotations 23 | 24 | from gi.repository import GObject 25 | 26 | from proton.vpn import logging 27 | 28 | from proton.vpn.app.gtk import Gtk 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | class SearchEntry(Gtk.SearchEntry): 34 | """Widget used to filter server list based on user input.""" 35 | def __init__(self): 36 | super().__init__() 37 | self.set_placeholder_text("Press Ctrl+F to search") 38 | self.connect("request-focus", lambda _: self.grab_focus()) # pylint: disable=no-member, disable=line-too-long # noqa: E501 # nosemgrep: python.lang.correctness.return-in-init.return-in-init 39 | 40 | @GObject.Signal(name="request_focus", flags=GObject.SignalFlags.ACTION) 41 | def request_focus(self, _): 42 | """Emitting this signal requests input focus on the search text entry.""" 43 | 44 | def reset(self): 45 | """Resets the widget UI.""" 46 | self.set_text("") 47 | -------------------------------------------------------------------------------- /tests/unit/utils/test_glib.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from unittest.mock import Mock, call 20 | 21 | from proton.vpn.app.gtk.utils import glib 22 | from gi.repository import GLib 23 | from tests.unit.testing_utils import process_gtk_events, run_main_loop 24 | 25 | 26 | def test_run_once(): 27 | mock = Mock() 28 | mock.return_value = True 29 | 30 | glib.run_once(mock, "arg1", "arg2") 31 | 32 | process_gtk_events() 33 | 34 | mock.assert_called_once_with("arg1", "arg2") 35 | 36 | 37 | def test_run_periodically(): 38 | main_loop = GLib.MainLoop() 39 | mock = Mock() 40 | 41 | glib.run_periodically(mock, "arg1", arg2="arg2", interval_ms=10) 42 | 43 | expected_number_of_calls = 3 44 | 45 | def stop_after_n_calls(*args, **kwargs): 46 | if mock.call_count == expected_number_of_calls: 47 | GLib.idle_add(main_loop.quit) 48 | 49 | mock.side_effect = stop_after_n_calls 50 | 51 | run_main_loop(main_loop) 52 | 53 | assert mock.call_count == expected_number_of_calls 54 | assert mock.mock_calls == [call("arg1", arg2="arg2") for _ in range(expected_number_of_calls)] 55 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/split_tunneling/app/test_installed_apps.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from gi.repository import Gio 4 | 5 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.app.installed_apps import get_app_executable 6 | 7 | 8 | 9 | def test_get_app_executable_gets_executable_path_without_args_for_native_apps(): 10 | app = Mock(spec=Gio.AppInfo) 11 | executable = "/usr/bin/google-chrome-stable" 12 | app.get_commandline.return_value = "/usr/bin/google-chrome-stable %U" 13 | app.get_executable.return_value = "/usr/bin/google-chrome-stable" 14 | assert get_app_executable(app) == "/usr/bin/google-chrome-stable" 15 | 16 | 17 | def test_get_app_executable_gets_snap_app_path_from_snap_desktop_files(): 18 | app = Mock(spec=Gio.AppInfo) 19 | app.get_commandline.return_value = "/snap/bin/firefox %u" 20 | app.get_executable.return_value = "/snap/bin/firefox" 21 | assert get_app_executable(app) == "/snap/firefox/" 22 | 23 | 24 | def test_get_app_executable_trims_double_at_symbol_from_flatpak_desktop_files(): 25 | # It's not possible to instantiate a flatpak desktop file with Gio.DesktopAppInfo.new_from_filename(flatpak_desktop_file_path) 26 | # on Ubuntu (TypeError) but it's possible on Fedora. Flatpak desktop files are also returned with Gio.AppInfo.get_all(). 27 | # That's why a Mock is used instead of the loading it as in other tests. 28 | app = Mock(spec=Gio.AppInfo) 29 | app.get_commandline.return_value = "/usr/bin/flatpak run --branch=stable --arch=x86_64 --command=brave --file-forwarding com.brave.Browser @@u %U @@" 30 | app.get_executable.return_value = "/usr/bin/flatpak" 31 | 32 | assert get_app_executable(app) == "/usr/bin/flatpak run --branch=stable --arch=x86_64 --command=brave --file-forwarding com.brave.Browser" # @@ argument trimmed off 33 | -------------------------------------------------------------------------------- /tests/unit/widgets/login/test_login_stack.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from unittest.mock import Mock 20 | 21 | from proton.vpn.app.gtk.widgets.login.login_widget import LoginStack 22 | from tests.unit.testing_utils import process_gtk_events 23 | 24 | 25 | def test_login_stack_signals_user_logged_in_when_user_is_authenticated_and_2fa_is_not_required(): 26 | login_stack = LoginStack(controller=Mock(), notifications=Mock(), overlay_widget=Mock()) 27 | 28 | user_logged_in_callback = Mock() 29 | login_stack.connect("user-logged-in", user_logged_in_callback) 30 | 31 | two_factor_auth_required = False 32 | login_stack.login_form.emit("user-authenticated", two_factor_auth_required) 33 | 34 | user_logged_in_callback.assert_called_once() 35 | 36 | 37 | def test_login_stack_asks_for_2fa_when_required(): 38 | login_stack = LoginStack(controller=Mock(), notifications=Mock(), overlay_widget=Mock()) 39 | two_factor_auth_required = True 40 | login_stack.login_form.emit("user-authenticated", two_factor_auth_required) 41 | 42 | process_gtk_events() 43 | 44 | assert login_stack.active_widget == login_stack.two_factor_auth_widget 45 | -------------------------------------------------------------------------------- /tests/unit/utils/test_executor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | 20 | import asyncio 21 | import time 22 | 23 | from proton.vpn.app.gtk.utils.executor import AsyncExecutor 24 | 25 | 26 | def test_async_executor_submit_with_coroutine_func(): 27 | async def asyncio_func(blocking_time): 28 | await asyncio.sleep(blocking_time) 29 | return "done" 30 | 31 | with AsyncExecutor() as executor: 32 | future = executor.submit(asyncio_func, blocking_time=0) 33 | assert future.result() == "done" 34 | 35 | 36 | def test_async_executor_submit_regular_func(): 37 | def blocking_func(blocking_time): 38 | time.sleep(blocking_time) 39 | return "done" 40 | 41 | with AsyncExecutor() as executor: 42 | future = executor.submit(blocking_func, blocking_time=0) 43 | assert future.result() == "done" 44 | 45 | 46 | def test_async_executor_start_starts_running_the_executor(): 47 | executor = AsyncExecutor() 48 | executor.start() 49 | assert executor.is_running 50 | 51 | 52 | def test_async_executor_stop_stops_running_the_executor(): 53 | executor = AsyncExecutor() 54 | executor.start() 55 | executor.stop() 56 | assert not executor.is_running 57 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_namespace_packages 4 | import re 5 | 6 | VERSIONS = 'versions.yml' 7 | VERSION = re.search(r'version: (\S+)', open(VERSIONS, encoding='utf-8') 8 | .readline()).group(1) 9 | 10 | setup( 11 | name="proton-vpn-gtk-app", 12 | version=VERSION, 13 | description="Proton VPN GTK app", 14 | author="Proton AG", 15 | author_email="opensource@proton.me", 16 | url="https://github.com/ProtonVPN/proton-vpn-gtk-app", 17 | install_requires=[ 18 | "proton-vpn-api-core", 19 | "pygobject", 20 | "pycairo", 21 | "dbus-python", 22 | "packaging", 23 | "distro", 24 | "requests", 25 | "proton-core" 26 | ], 27 | extras_require={ 28 | "development": [ 29 | "proton-core-internal", 30 | "proton-keyring-linux", 31 | "proton-vpn-network-manager", 32 | "behave", 33 | "pyotp", 34 | "pytest", 35 | "pytest-cov", 36 | "pygobject-stubs", 37 | "flake8", 38 | "pylint", 39 | "mypy", 40 | "PyYAML" 41 | ] 42 | }, 43 | packages=find_namespace_packages(include=["proton.vpn.app.*"]), 44 | include_package_data=True, 45 | python_requires=">=3.9", 46 | license="GPLv3", 47 | platforms="Linux", 48 | classifiers=[ 49 | "Development Status :: 5 - Production/Stable", 50 | "Intended Audience :: End Users/Desktop", 51 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 52 | "Operating System :: POSIX :: Linux", 53 | "Programming Language :: Python :: 3", 54 | "Programming Language :: Python", 55 | "Topic :: Security", 56 | ], 57 | entry_points={ 58 | "console_scripts": [ 59 | ['protonvpn-app=proton.vpn.app.gtk.__main__:main'], 60 | ], 61 | } 62 | ) 63 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/settings_watchers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from __future__ import annotations 20 | from typing import Callable 21 | 22 | from proton.vpn import logging 23 | 24 | from proton.vpn.core.settings import Settings 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | class SettingsWatchers: 30 | """ 31 | A class to manage watchers for settings changes. 32 | It allows adding, removing, and notifying watchers when settings change. 33 | Each watcher is a callable that takes the updated settings as an argument. 34 | """ 35 | def __init__(self): 36 | self.watchers = [] 37 | 38 | def add(self, watcher: Callable[[Settings], None]): 39 | """Adds a new watcher to the list.""" 40 | self.watchers.append(watcher) 41 | 42 | def remove(self, watcher: Callable[[Settings], None]): 43 | """Removes a watcher from the list.""" 44 | if watcher in self.watchers: 45 | self.watchers.remove(watcher) 46 | 47 | def notify(self, settings: Settings): 48 | """Notifies all watchers with the new settings.""" 49 | for watcher in self.watchers: 50 | watcher(settings) 51 | 52 | def clear(self): 53 | """Clears all watchers.""" 54 | self.watchers.clear() 55 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/split_tunneling/test_mode.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | import pytest 3 | 4 | from tests.unit.testing_utils import process_gtk_events 5 | 6 | from proton.vpn.app.gtk.controller import Controller 7 | from proton.vpn.core.settings.split_tunneling import SplitTunnelingMode 8 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.mode import ( 9 | SplitTunnelingModeSetting, SETTINGS_PATH_NAME 10 | ) 11 | 12 | 13 | @pytest.fixture 14 | def mock_controller(): 15 | mock = Mock(name="controller", spec=Controller) 16 | mock.get_setting_attr.return_value = SplitTunnelingMode.EXCLUDE 17 | mock.user_tier = 1 18 | mock_controller.connection_disconnected = True 19 | 20 | return mock 21 | 22 | 23 | def test_split_tunneling_mode_setting_selects_exclude_radio_button_when_loading_with_exclude_mode(mock_controller): 24 | mode = SplitTunnelingModeSetting(controller=mock_controller) 25 | 26 | assert mode.get_exclude_radio_button().get_active() 27 | assert not mode.get_include_radio_button().get_active() 28 | 29 | 30 | def test_split_tunneling_mode_signal_is_emitted_when_include_button_is_selected( 31 | mock_controller 32 | ): 33 | mock_callback = Mock(name="mode-switched-callback") 34 | mode = SplitTunnelingModeSetting(controller=mock_controller) 35 | 36 | mode.connect("mode-switched", mock_callback) 37 | 38 | mode.get_include_radio_button().set_active(True) 39 | 40 | process_gtk_events() 41 | 42 | mock_callback.assert_called_once_with(mode, SplitTunnelingMode.INCLUDE) 43 | 44 | 45 | def test_split_tunneling_mode_setting_mode_is_saved_when_switching_from_exclude_to_include_mode( 46 | mock_controller 47 | ): 48 | mode = SplitTunnelingModeSetting(controller=mock_controller) 49 | 50 | mode.get_include_radio_button().set_active(True) 51 | 52 | process_gtk_events() 53 | 54 | mock_controller.save_setting_attr.assert_called_once_with(SETTINGS_PATH_NAME, SplitTunnelingMode.INCLUDE) 55 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/headerbar/menu/about_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for the about dialog. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | from pathlib import Path 23 | 24 | from proton.vpn.app.gtk import Gtk 25 | 26 | from proton.vpn.app.gtk.assets import icons 27 | from proton.vpn.app.gtk import __version__ 28 | 29 | 30 | class AboutDialog(Gtk.AboutDialog): 31 | """This widget will display general information about this application""" 32 | TITLE = "About" 33 | PROGRAM_NAME = "Proton VPN Linux Client" 34 | VERSION = __version__ 35 | COPYRIGHT = "Proton AG 2023" 36 | LICENSE = Gtk.License.GPL_3_0 37 | WEBSITE = "https://protonvpn.com" 38 | WEBSITE_LABEL = "Proton VPN" 39 | AUTHORS = ["Proton AG"] 40 | 41 | def __init__(self): 42 | super().__init__() 43 | self.set_title(self.TITLE) 44 | self.set_program_name(self.PROGRAM_NAME) 45 | self.set_version(self.VERSION) 46 | self.set_copyright(self.COPYRIGHT) 47 | self.set_license_type(self.LICENSE) 48 | self.set_website(self.WEBSITE) 49 | self.set_website_label(self.WEBSITE_LABEL) 50 | self.set_authors(self.AUTHORS) 51 | self._set_icon() 52 | 53 | def _set_icon(self): 54 | self.set_logo(icons.get(Path("proton-vpn-sign.svg"), width=80, height=80)) 55 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/login/logo.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from pathlib import Path 20 | 21 | from gi.repository import Gtk 22 | 23 | from proton.vpn.app.gtk.assets import icons 24 | 25 | 26 | class ProtonVPNLogo(Gtk.Image): 27 | """Proton VPN logo shown in the login widget.""" 28 | def __init__(self): 29 | super().__init__() 30 | pixbuf = icons.get( 31 | Path("proton-vpn-logo.svg"), 32 | width=300, 33 | preserve_aspect_ratio=True 34 | ) 35 | self.set_name("login-logo") 36 | self.set_from_pixbuf(pixbuf) 37 | 38 | 39 | class TwoFactorAuthProtonVPNLogo(Gtk.Image): 40 | """Proton VPN logo shown in the login widget.""" 41 | def __init__(self): 42 | super().__init__() 43 | pixbuf = icons.get( 44 | Path("proton-vpn-logo.svg"), 45 | width=200, 46 | preserve_aspect_ratio=True 47 | ) 48 | self.set_name("two-factor-auth-vpn-logo") 49 | self.set_from_pixbuf(pixbuf) 50 | 51 | 52 | class SecurityKeyLogo(Gtk.Image): 53 | """Proton VPN logo shown in the login widget.""" 54 | def __init__(self): 55 | super().__init__() 56 | pixbuf = icons.get( 57 | Path("security-key.svg"), 58 | width=300, 59 | preserve_aspect_ratio=True 60 | ) 61 | self.set_from_pixbuf(pixbuf) 62 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/servers/secure-core.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/headerbar/headerbar.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines the headerbar widget 3 | that is present at the top of the window. 4 | 5 | 6 | Copyright (c) 2023 Proton AG 7 | 8 | This file is part of Proton VPN. 9 | 10 | Proton VPN is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | 15 | Proton VPN is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU General Public License for more details. 19 | 20 | You should have received a copy of the GNU General Public License 21 | along with ProtonVPN. If not, see . 22 | """ 23 | from typing import TYPE_CHECKING 24 | 25 | from proton.vpn.app.gtk import Gtk 26 | from proton.vpn.app.gtk.controller import Controller 27 | from proton.vpn.app.gtk.widgets.headerbar.menu.menu import Menu 28 | from proton.vpn.app.gtk.widgets.main.loading_widget import OverlayWidget 29 | 30 | if TYPE_CHECKING: 31 | from proton.vpn.app.gtk.app import MainWindow 32 | 33 | 34 | class HeaderBar(Gtk.HeaderBar): 35 | """ 36 | Allows to customize the header bar (also known as the title bar), 37 | by adding custom buttons, icons and text. 38 | """ 39 | 40 | def __init__( 41 | self, 42 | controller: Controller, 43 | main_window: "MainWindow", 44 | overlay_widget: OverlayWidget 45 | ): 46 | super().__init__() 47 | 48 | self.set_decoration_layout("menu:minimize,close") 49 | self.set_title("Proton VPN") 50 | self.set_show_close_button(True) 51 | 52 | menu_button = Gtk.MenuButton() 53 | self.menu = Menu( 54 | controller=controller, 55 | main_window=main_window, 56 | overlay_widget=overlay_widget 57 | ) 58 | menu_button.set_menu_model(self.menu) 59 | self.pack_start(menu_button) 60 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/headerbar/menu/settings/account_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Account settings module. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | from gi.repository import Gtk, Gdk 23 | from proton.vpn.app.gtk.controller import Controller 24 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.common import ( 25 | BaseCategoryContainer, CustomButton 26 | ) 27 | 28 | 29 | class AccountSettings(BaseCategoryContainer): # pylint: disable=too-many-instance-attributes 30 | """Account settings are grouped under this class.""" 31 | CATEGORY_NAME = "Account" 32 | MANAGE_ACCOUNT_URL = "https://account.protonvpn.com/account" 33 | 34 | def __init__(self, controller: Controller): 35 | super().__init__(self.CATEGORY_NAME) 36 | self._controller = controller 37 | 38 | def build_ui(self): 39 | """Builds the UI, invoking all necessary methods that are 40 | under this category.""" 41 | self.pack_start(CustomButton( 42 | title=self._controller.account_name, 43 | description=f"VPN plan: {self._controller.account_data.plan_title or 'Free'}", 44 | button_label="Manage Account", 45 | on_click_callback=self._on_click_manage_account_button, 46 | bold_title=True 47 | ), False, False, 0) 48 | 49 | def _on_click_manage_account_button(self, *_): 50 | Gtk.show_uri_on_window( 51 | None, 52 | self.MANAGE_ACCOUNT_URL, 53 | Gdk.CURRENT_TIME 54 | ) 55 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/split_tunneling/app/test_selected_app_list.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | 4 | from tests.unit.testing_utils import process_gtk_events 5 | 6 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.app.selected_app_list \ 7 | import SelectedAppList 8 | 9 | from .mock_app_data import mock_app_data 10 | 11 | 12 | def test_remove_app_is_removed_and_signal_is_emitted_when_app_is_removed_from_list(mock_app_data): 13 | mock_app_removed_callback = Mock() 14 | sap = SelectedAppList(apps_to_add=[mock_app_data]) 15 | sap.connect("app-removed", mock_app_removed_callback) 16 | 17 | sap.main_container.get_children()[0]._click_on_remove_button() 18 | 19 | process_gtk_events() 20 | 21 | mock_app_removed_callback.assert_called_once() 22 | 23 | 24 | def test_refresh_apps_are_added_to_list_when_they_are_newly_selected(mock_app_data): 25 | mock_app_list_refreshed_callback = Mock() 26 | 27 | sap = SelectedAppList(apps_to_add=[]) 28 | sap.connect("app-list-refreshed", mock_app_list_refreshed_callback) 29 | 30 | sap.refresh(selected_apps=[mock_app_data]) 31 | 32 | process_gtk_events() 33 | 34 | mock_app_list_refreshed_callback.assert_called_once() 35 | assert mock_app_list_refreshed_callback.call_args_list[0][0][1] == [mock_app_data] 36 | 37 | 38 | def test_refresh_apps_are_removed_from_list_when_they_are_deselected(mock_app_data): 39 | mock_app_list_refreshed_callback = Mock() 40 | 41 | sap = SelectedAppList(apps_to_add=[mock_app_data]) 42 | sap.connect("app-list-refreshed", mock_app_list_refreshed_callback) 43 | 44 | sap.refresh(selected_apps=[]) 45 | 46 | process_gtk_events() 47 | 48 | mock_app_list_refreshed_callback.assert_called_once() 49 | assert mock_app_list_refreshed_callback.call_args_list[0][0][1] == [] 50 | 51 | 52 | def test_refresh_apps_list_is_not_updated_when_apps_are_untouched(mock_app_data): 53 | mock_app_list_refreshed_callback = Mock() 54 | 55 | sap = SelectedAppList(apps_to_add=[mock_app_data]) 56 | sap.connect("app-list-refreshed", mock_app_list_refreshed_callback) 57 | 58 | sap.refresh(selected_apps=[mock_app_data]) 59 | 60 | process_gtk_events() 61 | 62 | mock_app_list_refreshed_callback.assert_not_called() 63 | -------------------------------------------------------------------------------- /rpmbuild/SPECS/package.spec.template: -------------------------------------------------------------------------------- 1 | %define unmangled_name proton-vpn-gtk-app 2 | %define pep_625_name proton_vpn_gtk_app 3 | %define version {version} 4 | %define upstream_version {upstream_version} 5 | %define logo_filename proton-vpn-logo.svg 6 | %define desktop_entry_filename proton.vpn.app.gtk.desktop 7 | %define release 1 8 | 9 | Prefix: %{{_prefix}} 10 | Name: %{{unmangled_name}} 11 | Version: %{{version}} 12 | Release: %{{release}}%{{?dist}} 13 | Summary: %{{unmangled_name}} library 14 | 15 | Group: ProtonVPN 16 | License: GPLv3 17 | Vendor: Proton Technologies AG 18 | URL: https://github.com/ProtonVPN/%{{unmangled_name}} 19 | Source0: %{{pep_625_name}}-%{{upstream_version}}.tar.gz 20 | Source3: %{{desktop_entry_filename}} 21 | Source4: %{{logo_filename}} 22 | BuildArch: noarch 23 | BuildRoot: %{{_tmppath}}/%{{pep_625_name}}-%{{version}}-%{{release}}-buildroot 24 | 25 | BuildRequires: desktop-file-utils 26 | BuildRequires: python3-devel 27 | BuildRequires: python3-setuptools 28 | BuildRequires: gtk3 29 | BuildRequires: libnotify 30 | BuildRequires: python3-gobject 31 | BuildRequires: python3-dbus 32 | BuildRequires: python3-proton-vpn-api-core >= 4.13.0 33 | BuildRequires: librsvg2 34 | BuildRequires: python3-packaging 35 | 36 | Requires: gtk3 37 | Requires: libnotify 38 | Requires: python3-gobject 39 | Requires: python3-dbus 40 | Requires: python3-proton-vpn-api-core >= 4.13.0 41 | Requires: librsvg2 42 | Requires: python3-packaging 43 | 44 | Suggests: libappindicator-gtk3 45 | 46 | %{{?python_disable_dependency_generator}} 47 | 48 | %description 49 | Package %{{unmangled_name}}. 50 | 51 | %prep 52 | %setup -q -n %{{pep_625_name}}-%{{upstream_version}} 53 | 54 | %build 55 | %pyproject_wheel 56 | 57 | %install 58 | %pyproject_install 59 | %pyproject_save_files proton 60 | desktop-file-install --dir=%{{buildroot}}%{{_datadir}}/applications %{{SOURCE3}} 61 | desktop-file-validate %{{buildroot}}%{{_datadir}}/applications/%{{desktop_entry_filename}} 62 | mkdir -p %{{buildroot}}%{{_datadir}}/icons/hicolor/scalable/apps 63 | cp %{{SOURCE4}} %{{buildroot}}%{{_datadir}}/icons/hicolor/scalable/apps/%{{logo_filename}} 64 | 65 | %files -n %{{name}} -f %{{pyproject_files}} 66 | %{{_bindir}}/protonvpn-app 67 | %{{_datadir}}/applications/%{{desktop_entry_filename}} 68 | %{{_datadir}}/icons/hicolor/scalable/apps/%{{logo_filename}} 69 | 70 | %changelog 71 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/state-connected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | App configuration module. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | from __future__ import annotations 23 | from typing import Optional 24 | from dataclasses import dataclass, asdict 25 | import os 26 | 27 | from proton.utils.environment import VPNExecutionEnvironment 28 | 29 | DEFAULT_APP_CONFIG = { 30 | "tray_pinned_servers": [], 31 | "connect_at_app_startup": None, 32 | "start_app_minimized": False 33 | } 34 | 35 | APP_CONFIG = os.path.join( 36 | VPNExecutionEnvironment().path_config, 37 | "app-config.json" 38 | ) 39 | 40 | 41 | @dataclass 42 | class AppConfig: 43 | """Contains configurations that are app specific. 44 | """ 45 | tray_pinned_servers: list 46 | connect_at_app_startup: Optional[str] 47 | start_app_minimized: bool 48 | 49 | @staticmethod 50 | def from_dict(data: dict) -> AppConfig: 51 | """Creates and returns `AppConfig` from the provided dict.""" 52 | connect_at_app_startup = data.get("connect_at_app_startup") 53 | 54 | return AppConfig( 55 | tray_pinned_servers=data.get("tray_pinned_servers", []), 56 | connect_at_app_startup=( 57 | connect_at_app_startup.upper() 58 | if connect_at_app_startup 59 | else None 60 | ), 61 | start_app_minimized=data.get("start_app_minimized", False) 62 | ) 63 | 64 | def to_dict(self) -> dict: 65 | """Converts the class to dict.""" 66 | return asdict(self) 67 | 68 | @staticmethod 69 | def default() -> AppConfig: 70 | """Creates and returns `AppConfig` from default app configurations.""" 71 | return AppConfig( 72 | tray_pinned_servers=DEFAULT_APP_CONFIG["tray_pinned_servers"], 73 | connect_at_app_startup=DEFAULT_APP_CONFIG["connect_at_app_startup"], 74 | start_app_minimized=DEFAULT_APP_CONFIG["start_app_minimized"] 75 | ) 76 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/state-error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/style/dark_colours.css: -------------------------------------------------------------------------------- 1 | /* Primary colour 2 | 3 | Used for non-interactive elements. 4 | */ 5 | @define-color primary #8A6EFF; 6 | 7 | /* Interaction-norm 8 | 9 | Used for button-norm, links and other interactive elements. 10 | */ 11 | @define-color interaction-norm #6D4AFF; 12 | @define-color interaction-norm-hover #7C5CFF; 13 | @define-color interaction-norm-active #8A6EFF; 14 | 15 | /* Interaction-norm-accent */ 16 | @define-color interaction-norm-accent #8A6EFF; 17 | @define-color interaction-norm-accent-hover #A898ED; 18 | @define-color interaction-norm-accent-active #BBABFF; 19 | 20 | /* Text 21 | 22 | Text and icon colors. 23 | */ 24 | @define-color text-norm #FFFFFF; 25 | @define-color text-weak #A7A4B5; 26 | @define-color text-hint #6D697D; 27 | @define-color text-disabled #5B576B; 28 | @define-color text-invert #1C1B24; 29 | 30 | /* Field 31 | 32 | Used for input fields, selects and selection controls. 33 | */ 34 | @define-color field-norm #5B576B; 35 | @define-color field-hover #6D697D; 36 | @define-color field-disabled #3F3B4C; 37 | 38 | /* Border 39 | 40 | Used as separators in tables and lists, 41 | for section lines and for containers. 42 | */ 43 | @define-color border-norm #4A4658; 44 | @define-color border-weak #343140; 45 | 46 | /* Background 47 | 48 | Used for main layout areas and components. 49 | */ 50 | @define-color background-norm #1C1B24; 51 | @define-color background-weak #292733; 52 | @define-color background-strong #3F3B4C; 53 | 54 | /* Interaction-weak 55 | 56 | Used for button-weak. 57 | */ 58 | @define-color interaction-weak #4A4658; 59 | @define-color interaction-weak-hover #5B576B; 60 | @define-color interaction-weak-active #6D697D; 61 | 62 | /* Interaction-default 63 | 64 | Used for button-ghost and interactions that have 65 | distinct visible background. 66 | E.g. sidenavigation and toolbar. 67 | */ 68 | @define-color interaction-default transparent; 69 | @define-color interaction-default-hover rgba(91,87,107,0.2); 70 | @define-color interaction-default-active rgba(91,87,107,0.4); 71 | 72 | /* Signal 73 | 74 | Used for banners, form-validation, buttons and meter-bars. 75 | */ 76 | @define-color signal-danger #F5385A; 77 | @define-color signal-danger-hover #FF5473; 78 | @define-color signal-danger-active #DC3251; 79 | 80 | @define-color signal-warning #FF9900; 81 | @define-color signal-warning-hover #FFB800; 82 | @define-color signal-warning-active #FF8419; 83 | 84 | @define-color signal-success #1EA885; 85 | @define-color signal-success-hover #1EA885; 86 | @define-color signal-success-active #198F71; 87 | 88 | @define-color signal-info #239ECE; 89 | @define-color signal-info-hover #27B1E8; 90 | @define-color signal-info-active #1F83B5; 91 | 92 | /* Shadows */ 93 | @define-color shadow #1C1B24; 94 | 95 | -------------------------------------------------------------------------------- /tests/unit/widgets/main/test_main_window.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | import pytest 20 | from gi.repository import GLib 21 | 22 | from unittest.mock import Mock, patch 23 | 24 | from proton.vpn.app.gtk import Gtk 25 | from proton.vpn.app.gtk.widgets.main.main_window import MainWindow 26 | from tests.unit.testing_utils import process_gtk_events 27 | 28 | 29 | class HeaderBarMock(Gtk.HeaderBar): 30 | def __init__(self): 31 | super().__init__() 32 | self.menu = Mock() 33 | 34 | 35 | @pytest.fixture 36 | def main_window(): 37 | return MainWindow( 38 | application=None, 39 | controller=Mock(), 40 | notifications=Mock(), 41 | header_bar=HeaderBarMock(), 42 | main_widget=Gtk.Label(label="Main widget") 43 | ) 44 | 45 | 46 | @pytest.fixture 47 | def dummy_app(main_window): 48 | def show_window(app): 49 | app.add_window(main_window) 50 | main_window.show() 51 | process_gtk_events() 52 | 53 | app = Gtk.Application() 54 | app.connect("activate", show_window) 55 | return app 56 | 57 | 58 | def test_close_button_triggers_quit_menu_entry_when_tray_indicator_is_not_used(dummy_app, main_window): 59 | main_window.configure_close_button_behaviour(tray_indicator_enabled=False) 60 | 61 | main_window.connect("show", lambda _: main_window.close()) 62 | 63 | GLib.timeout_add(interval=50, function=dummy_app.quit) 64 | process_gtk_events() 65 | 66 | dummy_app.run() 67 | process_gtk_events() 68 | 69 | main_window.header_bar.menu.quit_button_click.assert_called_once() 70 | 71 | 72 | def test_close_button_hides_window_when_tray_indicator_is_used(dummy_app, main_window): 73 | main_window.configure_close_button_behaviour(tray_indicator_enabled=True) 74 | 75 | main_window.connect("show", lambda _: main_window.close()) 76 | GLib.timeout_add(interval=50, function=dummy_app.quit) 77 | process_gtk_events() 78 | 79 | with patch.object(main_window, "hide"): 80 | dummy_app.run() 81 | process_gtk_events() 82 | 83 | main_window.hide.assert_called_once() 84 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/services/reconnector/vpn_monitor.py: -------------------------------------------------------------------------------- 1 | """ 2 | VPN connection monitoring. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | from typing import Callable, Optional 23 | 24 | from gi.repository import GLib 25 | 26 | from proton.vpn.connection import states 27 | from proton.vpn.core.connection import VPNConnector 28 | 29 | 30 | class VPNMonitor: 31 | """ 32 | After being enabled, it calls the configured callbacks whenever certain 33 | VPN events happen. 34 | 35 | Attributes: 36 | vpn_drop_callback: callable to be called whenever the VPN connection dropped. 37 | vpn_up_callback: callable to be called whenever the VPN connection is up. 38 | """ 39 | 40 | def __init__(self, vpn_connector: VPNConnector): 41 | self._vpn_connector = vpn_connector 42 | self.vpn_drop_callback: Optional[Callable] = None 43 | self.vpn_up_callback: Optional[Callable] = None 44 | self.vpn_disconnected_callback: Optional[Callable] = None 45 | 46 | def enable(self): 47 | """Enables VPN connection monitoring.""" 48 | self._vpn_connector.register(self) 49 | 50 | def disable(self): 51 | """Disabled VPN connection monitoring.""" 52 | self._vpn_connector.unregister(self) 53 | 54 | def status_update(self, connection_status): 55 | """This method is called by the VPN connection state machine whenever 56 | the connection state changes.""" 57 | if isinstance(connection_status, states.Error) and self.vpn_drop_callback: 58 | event = connection_status.context.event 59 | GLib.idle_add(self.vpn_drop_callback, event) # pylint: disable=not-callable 60 | 61 | if isinstance(connection_status, states.Connected) and self.vpn_up_callback: 62 | GLib.idle_add(self.vpn_up_callback) # pylint: disable=not-callable 63 | 64 | if isinstance(connection_status, states.Disconnected) \ 65 | and not connection_status.context.reconnection \ 66 | and self.vpn_disconnected_callback: 67 | GLib.idle_add(self.vpn_disconnected_callback) # pylint: disable=not-callable 68 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/split_tunneling/app/test_app_select_window.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | from tests.unit.testing_utils import process_gtk_events 4 | 5 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.app.app_select_window \ 6 | import AppSelectionWindow 7 | 8 | from .mock_app_data import mock_app_data 9 | 10 | 11 | def test_click_on_done_returns_selected_app_when_is_already_selected_and_app_exists_on_system(mock_app_data): 12 | app_selection_completed_callback = Mock() 13 | 14 | mock_controller = Mock(name="mock_controller") 15 | app_selection_window = AppSelectionWindow( 16 | title="Test", 17 | controller=mock_controller, 18 | stored_apps=[mock_app_data.executable], 19 | installed_apps=[mock_app_data] 20 | ) 21 | app_selection_window.realize() 22 | app_selection_window.connect("app_selection_completed", app_selection_completed_callback) 23 | app_selection_window._click_on_done_button() 24 | 25 | process_gtk_events() 26 | 27 | app_selection_completed_callback.assert_called_once() 28 | assert app_selection_completed_callback.call_args_list[0][0][1] == [mock_app_data] 29 | 30 | 31 | def test_click_on_done_returns_selected_app_when_is_not_already_selected_and_app_exists_on_system(mock_app_data): 32 | app_selection_completed_callback = Mock() 33 | 34 | mock_controller = Mock(name="mock_controller") 35 | app_selection_window = AppSelectionWindow( 36 | title="Test", 37 | controller=mock_controller, 38 | stored_apps=[], 39 | installed_apps=[mock_app_data] 40 | ) 41 | app_selection_window.realize() 42 | app_selection_window.connect("app_selection_completed", app_selection_completed_callback) 43 | app_selection_window._get_first_app_()._set_check(True) 44 | app_selection_window._click_on_done_button() 45 | 46 | process_gtk_events() 47 | 48 | app_selection_completed_callback.assert_called_once() 49 | assert mock_app_data in app_selection_completed_callback.call_args_list[0][0][1] 50 | 51 | 52 | def test_click_on_done_returns_no_app_when_is_already_selected_and_app_is_missing_from_system(mock_app_data): 53 | app_selection_completed_callback = Mock() 54 | 55 | mock_controller = Mock(name="mock_controller") 56 | app_selection_window = AppSelectionWindow( 57 | title="Test", 58 | controller=mock_controller, 59 | stored_apps=[mock_app_data.executable], 60 | installed_apps=[] 61 | ) 62 | app_selection_window.realize() 63 | app_selection_window.connect("app_selection_completed", app_selection_completed_callback) 64 | app_selection_window._click_on_done_button() 65 | 66 | process_gtk_events() 67 | 68 | app_selection_completed_callback.assert_called_once() 69 | assert not app_selection_completed_callback.call_args_list[0][0][1] 70 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/assets/icons/proton-vpn-sign.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/utils/glib.py: -------------------------------------------------------------------------------- 1 | """ 2 | GLib utility functions. 3 | 4 | 5 | Copyright (c) 2023 Proton AG 6 | 7 | This file is part of Proton VPN. 8 | 9 | Proton VPN is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | Proton VPN is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with ProtonVPN. If not, see . 21 | """ 22 | from concurrent.futures import Future 23 | from typing import Callable 24 | 25 | from gi.repository import GLib 26 | 27 | 28 | def run_once(function: Callable, *args, priority=GLib.PRIORITY_DEFAULT, **kwargs) -> int: 29 | """ 30 | Python implementation of GLib.idle_add_once, as currently is not available 31 | in pygobject. 32 | https://docs.gtk.org/glib/func.idle_add_once.html. 33 | """ 34 | def wrapper_function(): 35 | function(*args, **kwargs) 36 | # Returning a falsy value is required so that GLib does not keep 37 | # running the function over and over again. 38 | return False 39 | 40 | return GLib.idle_add(wrapper_function, priority=priority) 41 | 42 | 43 | def run_periodically(function, *args, interval_ms: int, **kwargs) -> int: 44 | """ 45 | Runs a function periodically on the GLib main loop. 46 | 47 | :param function: function to be called periodically 48 | :param *args: arguments to be passed to the function. 49 | :param interval_ms: interval at which the function should be called. 50 | :param **kwargs: keyword arguments to be passed to the function. 51 | """ 52 | run_once(function, *args, **kwargs) 53 | 54 | def wrapper_function(): 55 | function(*args, **kwargs) 56 | # True is returned so that GLib keeps running the function. 57 | return True 58 | 59 | return GLib.timeout_add(interval_ms, wrapper_function) 60 | 61 | 62 | def bubble_up_errors(future: Future): 63 | """Makes sure that any error the future resolves to bubbles up to the GLib main loop.""" 64 | future.add_done_callback(lambda f: GLib.idle_add(f.result)) 65 | 66 | 67 | def add_done_callback(future: Future, callback: Callable[[Future], None]): 68 | """ 69 | Adds a callback to be executed once the future completes, but in a way that's GLib-compatible. 70 | 71 | We currently use Futures to bridge between the asyncio event loop and the GLib event loop. 72 | When using Future.add_done_callback(callback), the callback is run by the thread 73 | running the asyncio loop. However, if the callback does GTK-related work, it needs to 74 | run on the thread running the GLib loop. 75 | 76 | This function takes care of wrapping the callback in a GLib.idle_add call to make sure the 77 | callback runs on the GLib thread. 78 | """ 79 | future.add_done_callback(lambda f: GLib.idle_add(callback, f)) 80 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/login/password_entry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2025 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from pathlib import Path 20 | 21 | from proton.vpn.app.gtk import Gtk 22 | from proton.vpn.app.gtk.assets import icons 23 | 24 | 25 | class PasswordEntry(Gtk.Entry): 26 | """ 27 | Entry used to introduce the password in the login form. 28 | 29 | On top of the inherited functionality from Gtk.Entry, an icon is shown 30 | inside the text entry to show or hide the password. 31 | 32 | By default, the text (password) introduced in the entry is not show. 33 | Therefore, the icon to be able to show the text is displayed. Once this 34 | icon is pressed, the text is revealed and the icon to hide the password 35 | is shown instead. 36 | """ 37 | def __init__(self): 38 | super().__init__() 39 | self.set_input_purpose(Gtk.InputPurpose.PASSWORD) 40 | self.set_visibility(False) 41 | # Load icon to hide the password. 42 | eye_dirpath = Path("eye") 43 | hide_fp = eye_dirpath / "hide.svg" 44 | self._hide_pixbuff = icons.get( 45 | hide_fp, 46 | width=17, 47 | height=17, 48 | preserve_aspect_ratio=True 49 | ) 50 | # Load icon to show the password. 51 | show_fp = eye_dirpath / "show.svg" 52 | self._show_pixbuff = icons.get( 53 | show_fp, 54 | width=17, 55 | height=17, 56 | preserve_aspect_ratio=True 57 | ) 58 | # By default, the password is not shown. Therefore, the icon to 59 | # be able to show the password is shown. 60 | self.set_icon_from_pixbuf( 61 | Gtk.EntryIconPosition.SECONDARY, 62 | self._show_pixbuff 63 | ) 64 | self.set_icon_activatable( 65 | Gtk.EntryIconPosition.SECONDARY, 66 | True 67 | ) 68 | self.connect( 69 | "icon-press", self._on_change_password_visibility_icon_press 70 | ) 71 | 72 | def _on_change_password_visibility_icon_press( 73 | self, gtk_entry_object, 74 | gtk_icon_object, gtk_event # pylint: disable=unused-argument 75 | ): 76 | """Changes password visibility, updating accordingly the icon.""" 77 | is_text_visible = gtk_entry_object.get_visibility() 78 | gtk_entry_object.set_visibility(not is_text_visible) 79 | self.set_icon_from_pixbuf( 80 | Gtk.EntryIconPosition.SECONDARY, 81 | self._show_pixbuff 82 | if is_text_visible 83 | else self._hide_pixbuff 84 | ) 85 | -------------------------------------------------------------------------------- /tests/unit/widgets/main/test_notifications.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from unittest.mock import Mock, patch 20 | 21 | from proton.vpn.app.gtk.widgets.main.notifications import Notifications 22 | from tests.unit.testing_utils import process_gtk_events 23 | 24 | 25 | ERROR_MESSAGE = "Error message to be displayed" 26 | ERROR_TITLE = "Error title to be displayed" 27 | 28 | 29 | @patch("proton.vpn.app.gtk.widgets.main.notifications.Gtk") 30 | def test_show_error_dialog_shows_error_in_popup_window(gtk_mock): 31 | dialog_mock = Mock() 32 | gtk_mock.MessageDialog.return_value = dialog_mock 33 | notifications = Notifications(Mock(), Mock()) 34 | 35 | notifications.show_error_dialog(ERROR_MESSAGE, ERROR_TITLE) 36 | process_gtk_events() 37 | 38 | dialog_mock.format_secondary_markup.assert_called_once() 39 | dialog_mock.run.assert_called_once() 40 | dialog_mock.destroy.assert_called_once() 41 | 42 | 43 | @patch("proton.vpn.app.gtk.widgets.main.notifications.Gtk") 44 | def test_show_error_dialog_closes_previous_dialog_if_existing(gtk_mock): 45 | first_dialog_mock = Mock() 46 | second_dialog_mock = Mock() 47 | gtk_mock.MessageDialog.return_value = second_dialog_mock 48 | notifications = Notifications(Mock(), Mock()) 49 | 50 | # Simulate an existing dialog being shown. 51 | notifications.error_dialog = first_dialog_mock 52 | 53 | notifications.show_error_dialog(ERROR_MESSAGE, ERROR_TITLE) 54 | 55 | process_gtk_events() 56 | 57 | first_dialog_mock.destroy.assert_called_once() 58 | second_dialog_mock.destroy.assert_called_once() 59 | 60 | 61 | @patch("proton.vpn.app.gtk.widgets.main.notifications.GLib") 62 | def test_show_error_displays_error_message_in_app_notification_bar(glib_mock): 63 | notification_bar_mock = Mock() 64 | 65 | notifications = Notifications(Mock(), notification_bar_mock) 66 | 67 | notifications.show_error_message(ERROR_MESSAGE) 68 | process_gtk_events() 69 | 70 | glib_mock.idle_add.assert_called_once_with( 71 | notification_bar_mock.show_error_message, ERROR_MESSAGE 72 | ) 73 | 74 | 75 | @patch("proton.vpn.app.gtk.widgets.main.notifications.GLib") 76 | def test_show_success_message_displays_success_message_in_app_notification_bar(glib_mock): 77 | notification_bar_mock = Mock() 78 | 79 | notifications = Notifications(Mock(), notification_bar_mock) 80 | 81 | notifications.show_success_message("Success message") 82 | process_gtk_events() 83 | 84 | glib_mock.idle_add.assert_called_once_with( 85 | notification_bar_mock.show_success_message, "Success message" 86 | ) 87 | -------------------------------------------------------------------------------- /tests/unit/widgets/login/test_login_widget.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | import pytest 20 | from unittest.mock import Mock, patch, PropertyMock 21 | 22 | from proton.vpn.app.gtk.widgets.login.login_widget import LoginStack, KillSwitchSettingEnum, LoginWidget 23 | from tests.unit.testing_utils import process_gtk_events 24 | 25 | 26 | 27 | 28 | @patch("proton.vpn.app.gtk.widgets.login.login_widget.Gtk.Box.pack_start") 29 | @patch("proton.vpn.app.gtk.widgets.login.login_widget.Gtk.Box.pack_end") 30 | @pytest.mark.parametrize("killswitch_setting", [KillSwitchSettingEnum.OFF, KillSwitchSettingEnum.ON, KillSwitchSettingEnum.PERMANENT]) 31 | def test_login_widget_displays_disable_killswitch_revealer_if_permanent_kill_switch_is_enabled(pack_end_mock, pack_start_mock, killswitch_setting): 32 | controller_mock = Mock() 33 | disable_killswitch_widget_mock = Mock() 34 | login_stack_mock = Mock() 35 | controller_mock.get_settings.return_value.killswitch = killswitch_setting 36 | login_widget = LoginWidget( 37 | controller=controller_mock, notifications=Mock(), overlay_widget=Mock(), 38 | main_window=Mock(), login_stack=login_stack_mock, disable_killswitch_widget=disable_killswitch_widget_mock 39 | ) 40 | 41 | login_widget.reset() 42 | 43 | disable_killswitch_widget_mock.set_reveal_child.assert_called_once_with( 44 | killswitch_setting == KillSwitchSettingEnum.PERMANENT 45 | ) 46 | 47 | 48 | @patch("proton.vpn.app.gtk.widgets.login.login_widget.Gtk.Box.pack_start") 49 | @patch("proton.vpn.app.gtk.widgets.login.login_widget.Gtk.Box.pack_end") 50 | def test_login_widget_enables_login_form_and_updates_settings_when_killswitch_is_disabled(pack_end_mock, pack_start_mock): 51 | controller_mock = Mock() 52 | killswitch_property_mock = PropertyMock() 53 | type(controller_mock.get_settings.return_value).killswitch = killswitch_property_mock 54 | disable_killswitch_widget_mock = Mock() 55 | login_stack_mock = Mock() 56 | 57 | LoginWidget( 58 | controller_mock, notifications=Mock(), overlay_widget=Mock(), 59 | main_window=Mock(), login_stack=login_stack_mock, disable_killswitch_widget=disable_killswitch_widget_mock 60 | ) 61 | 62 | callback = disable_killswitch_widget_mock.connect.mock_calls[0].args[1] 63 | callback(None) 64 | 65 | killswitch_property_mock.assert_called_once_with(KillSwitchSettingEnum.OFF) 66 | controller_mock.save_settings.assert_called_once() 67 | disable_killswitch_widget_mock.set_reveal_child.assert_called_once_with(False) 68 | login_stack_mock.login_form.set_property.assert_called_once_with("sensitive", True) 69 | -------------------------------------------------------------------------------- /tests/unit/widgets/vpn/test_quick_connect_widget.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from unittest.mock import Mock 20 | 21 | import pytest 22 | from gi.repository import GLib 23 | 24 | from proton.vpn.app.gtk import Gtk 25 | from proton.vpn.app.gtk.widgets.vpn.quick_connect_widget import QuickConnectWidget 26 | from proton.vpn.connection.states import Disconnected, Connected, Connecting, Error 27 | from tests.unit.testing_utils import process_gtk_events, run_main_loop 28 | 29 | 30 | @pytest.mark.parametrize("connection_state, connect_button_visible, disconnect_button_visible, disconnect_button_label", [ 31 | (Disconnected(), True, False, None), 32 | (Connecting(), False, True, "Cancel Connection"), 33 | (Connected(), False, True, "Disconnect"), 34 | (Error(), False, True, "Cancel Connection") 35 | ]) 36 | def test_quick_connect_widget_changes_button_according_to_connection_state_changes( 37 | connection_state, connect_button_visible, disconnect_button_visible, disconnect_button_label 38 | ): 39 | quick_connect_widget = QuickConnectWidget(controller=Mock()) 40 | window = Gtk.Window() 41 | window.add(quick_connect_widget) 42 | main_loop = GLib.MainLoop() 43 | 44 | def run(): 45 | window.show_all() 46 | 47 | quick_connect_widget.connection_status_update(connection_state) 48 | 49 | try: 50 | assert quick_connect_widget.connection_state is connection_state 51 | assert quick_connect_widget.connect_button.get_visible() is connect_button_visible 52 | assert quick_connect_widget.disconnect_button.get_visible() is disconnect_button_visible 53 | if disconnect_button_label: 54 | assert quick_connect_widget.disconnect_button.get_label() == disconnect_button_label 55 | finally: 56 | main_loop.quit() 57 | 58 | GLib.idle_add(run) 59 | run_main_loop(main_loop) 60 | 61 | 62 | def test_quick_connect_widget_connects_to_fastest_server_when_connect_button_is_clicked(): 63 | controller_mock = Mock() 64 | quick_connect_widget = QuickConnectWidget(controller=controller_mock) 65 | 66 | quick_connect_widget.connect_button.clicked() 67 | process_gtk_events() 68 | 69 | controller_mock.connect_to_fastest_server.assert_called_once() 70 | 71 | 72 | def test_quick_connect_widget_disconnects_from_current_server_when_disconnect_is_clicked(): 73 | controller_mock = Mock() 74 | quick_connect_widget = QuickConnectWidget(controller=controller_mock) 75 | 76 | quick_connect_widget.disconnect_button.clicked() 77 | process_gtk_events() 78 | 79 | controller_mock.disconnect.assert_called_once() 80 | -------------------------------------------------------------------------------- /tests/unit/services/reconnector/test_session_monitor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from unittest.mock import Mock, patch 20 | import pytest 21 | 22 | from proton.vpn.app.gtk.services.reconnector.session_monitor import ( 23 | SessionMonitor, BUS_NAME, 24 | SESSION_INTERFACE, UNLOCK_SIGNAL 25 | ) 26 | 27 | 28 | PATH_NAME = "/some/random/bus/object/path" 29 | 30 | 31 | def test_enable_hooks_login1_unlock_signal(): 32 | bus_mock = Mock() 33 | callback_mock = Mock() 34 | session_monitor = SessionMonitor(bus_mock, PATH_NAME) 35 | session_monitor.session_unlocked_callback = callback_mock 36 | 37 | session_monitor.enable() 38 | 39 | bus_mock.add_signal_receiver.assert_called_once_with( 40 | handler_function=callback_mock, 41 | signal_name=UNLOCK_SIGNAL, 42 | dbus_interface=SESSION_INTERFACE, 43 | bus_name=BUS_NAME, 44 | path=PATH_NAME 45 | ) 46 | 47 | 48 | def test_enable_raises_runtime_error_if_callback_is_not_set(): 49 | bus_mock = Mock() 50 | session_monitor = SessionMonitor(bus_mock, PATH_NAME) 51 | 52 | with pytest.raises(RuntimeError): 53 | session_monitor.enable() 54 | 55 | 56 | @patch("proton.vpn.app.gtk.services.reconnector.session_monitor.dbus") 57 | def test_enable_raises_runtime_error_if_there_is_not_an_active_session(dbus_mock): 58 | bus_mock = Mock() 59 | callback_mock = Mock() 60 | properties_proxy_mock = Mock() 61 | session_monitor = SessionMonitor(bus_mock) 62 | session_monitor.session_unlocked_callback = callback_mock 63 | 64 | properties_proxy_mock.GetAll.return_value = {"ActiveSession": []} 65 | dbus_mock.Interface.return_value = properties_proxy_mock 66 | 67 | with pytest.raises(RuntimeError): 68 | session_monitor.enable() 69 | 70 | 71 | def test_disable_unhooks_login1_signal(): 72 | bus_mock = Mock() 73 | signal_receiver_mock = Mock() 74 | 75 | bus_mock.add_signal_receiver.return_value = signal_receiver_mock 76 | 77 | session_monitor = SessionMonitor(bus_mock, PATH_NAME) 78 | session_monitor.set_signal_receiver(signal_receiver_mock) 79 | 80 | session_monitor.disable() 81 | 82 | signal_receiver_mock.remove.assert_called_once() 83 | 84 | 85 | def test_disable_does_not_unhook_from_login1_signal_if_it_was_not_hooked_before(): 86 | bus_mock = Mock() 87 | signal_receiver_mock = Mock() 88 | signal_receiver_mock.return_value = None 89 | 90 | session_monitor = SessionMonitor(bus_mock, PATH_NAME) 91 | bus_mock.add_signal_receiver.return_value = signal_receiver_mock 92 | 93 | session_monitor.disable() 94 | assert not signal_receiver_mock.remove.call_count 95 | -------------------------------------------------------------------------------- /tests/unit/services/reconnector/test_vpn_monitor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from unittest.mock import Mock, patch 20 | 21 | import pytest 22 | 23 | from proton.vpn.connection import states 24 | from proton.vpn.core.connection import VPNConnector 25 | 26 | from proton.vpn.app.gtk.services.reconnector.vpn_monitor import VPNMonitor 27 | 28 | 29 | def test_enable_registers_monitor_to_connection_state_updated(): 30 | vpn_connector = Mock(VPNConnector) 31 | monitor = VPNMonitor(vpn_connector) 32 | 33 | monitor.enable() 34 | 35 | vpn_connector.register.assert_called_once() 36 | 37 | 38 | def test_disable_unregisters_monitor_from_connection_state_updates(): 39 | vpn_connector = Mock(VPNConnector) 40 | monitor = VPNMonitor(vpn_connector) 41 | 42 | monitor.disable() 43 | 44 | vpn_connector.unregister.assert_called_once() 45 | 46 | 47 | @pytest.mark.parametrize("state,vpn_drop_callback_called", [ 48 | (states.Disconnected(), False), 49 | (states.Connecting(), False), 50 | (states.Connected(), False), 51 | (states.Disconnected(), False), 52 | (states.Error(), True) 53 | ]) 54 | @patch("proton.vpn.app.gtk.services.reconnector.vpn_monitor.GLib") 55 | def test_status_update_only_triggers_vpn_drop_callback_on_error_connection_state( 56 | glib_mock, state, vpn_drop_callback_called 57 | ): 58 | vpn_connector = Mock(VPNConnector) 59 | monitor = VPNMonitor(vpn_connector) 60 | monitor.vpn_drop_callback = Mock() 61 | 62 | event = state.context.event 63 | monitor.status_update(state) 64 | 65 | if vpn_drop_callback_called: 66 | glib_mock.idle_add.assert_called_with(monitor.vpn_drop_callback, event) 67 | else: 68 | glib_mock.idle_add.assert_not_called() 69 | 70 | 71 | @pytest.mark.parametrize("state,vpn_up_callback_called", [ 72 | (states.Disconnected(), False), 73 | (states.Connecting(), False), 74 | (states.Connected(), True), 75 | (states.Disconnected(), False), 76 | (states.Error(), False) 77 | ]) 78 | @patch("proton.vpn.app.gtk.services.reconnector.vpn_monitor.GLib") 79 | def test_status_update_only_triggers_vpn_up_callback_on_connected_connection_state( 80 | glib_mock, state, vpn_up_callback_called 81 | ): 82 | vpn_connector = Mock(VPNConnector) 83 | monitor = VPNMonitor(vpn_connector) 84 | monitor.vpn_up_callback = Mock() 85 | 86 | monitor.status_update(state) 87 | 88 | if vpn_up_callback_called: 89 | glib_mock.idle_add.assert_called_with(monitor.vpn_up_callback) 90 | else: 91 | glib_mock.idle_add.assert_not_called() 92 | 93 | 94 | def test_status_update_does_not_fail_when_callbacks_were_not_set(): 95 | pass 96 | -------------------------------------------------------------------------------- /tests/unit/test_controller.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | import pytest 3 | 4 | from proton.vpn.app.gtk.controller import Controller 5 | 6 | 7 | MockOpenVPNTCP = Mock(name="MockOpenVPNTCP") 8 | MockOpenVPNTCP.cls.protocol = "openvpn-tcp" 9 | MockOpenVPNTCP.cls.ui_protocol = "OpenVPN (TCP)" 10 | MockOpenVPNUDP = Mock(name="MockOpenVPNUDP") 11 | MockOpenVPNUDP.cls.protocol = "openvpn-udp" 12 | MockOpenVPNUDP.cls.ui_protocol = "OpenVPN (UDP)" 13 | MockWireGuard = Mock(name="MockWireGuard") 14 | MockWireGuard.cls.protocol = "wireguard" 15 | MockWireGuard.cls.ui_protocol = "WireGuard (experimental)" 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "connect_at_app_startup_value, app_start_on_login_widget, method_name, call_arg", 20 | [ 21 | ("FASTEST", True, "connect_to_fastest_server", None), 22 | ("PT", False, "connect_to_country", None), 23 | ("PT#1", True, "connect_to_server", "PT#1"), 24 | ("PT", True, "connect_to_country", "PT"), 25 | ] 26 | ) 27 | def test_autoconnect_feature( 28 | connect_at_app_startup_value, app_start_on_login_widget, 29 | method_name, call_arg 30 | ): 31 | app_configuration_mock = Mock() 32 | app_configuration_mock.connect_at_app_startup = connect_at_app_startup_value 33 | 34 | controller = Controller( 35 | executor=Mock(), 36 | exception_handler=Mock(), 37 | api=Mock(), 38 | vpn_reconnector=Mock(), 39 | app_config=app_configuration_mock 40 | ) 41 | 42 | with patch.object(controller, method_name) as mock_method: 43 | controller.autoconnect() 44 | 45 | if call_arg: 46 | mock_method.assert_called_once_with(call_arg) 47 | else: 48 | mock_method.assert_called_once() 49 | 50 | 51 | @patch("proton.vpn.app.gtk.controller.Controller.get_settings") 52 | def test_get_available_protocols_returns_list_of_protocols_which_includes_wireguard_when_feature_flag_is_disabled_and_selected_protocol_is_wireguard(mock_get_settings): 53 | mock_connector = Mock() 54 | mock_api = Mock() 55 | controller = Controller( 56 | executor=Mock(), 57 | exception_handler=Mock(), 58 | api=Mock(), 59 | vpn_reconnector=Mock(), 60 | app_config=Mock(), 61 | vpn_connector=mock_connector 62 | ) 63 | mock_get_settings.return_value.protocol = MockWireGuard.cls.protocol 64 | mock_api.refresher.feature_flags.get.return_value = False 65 | mock_connector.get_available_protocols_for_backend.return_value = [MockOpenVPNUDP, MockOpenVPNTCP, MockWireGuard] 66 | protocols = controller.get_available_protocols() 67 | assert MockWireGuard in protocols 68 | 69 | 70 | @patch("proton.vpn.app.gtk.controller.Controller.get_settings") 71 | def test_get_available_protocols_returns_list_of_protocols_which_includes_wireguard_when_feature_flag_is_enabled_and_selected_protocol_is_openvpn(mock_get_settings): 72 | mock_connector = Mock() 73 | mock_api = Mock() 74 | controller = Controller( 75 | executor=Mock(), 76 | exception_handler=Mock(), 77 | api=mock_api, 78 | vpn_reconnector=Mock(), 79 | app_config=Mock(), 80 | vpn_connector=mock_connector 81 | ) 82 | mock_get_settings.return_value.protocol = MockOpenVPNTCP.cls.protocol 83 | mock_api.refresher.feature_flags.get.return_value = True 84 | mock_connector.get_available_protocols_for_backend.return_value = [MockOpenVPNUDP, MockOpenVPNTCP, MockWireGuard] 85 | protocols = controller.get_available_protocols() 86 | assert MockWireGuard in protocols 87 | -------------------------------------------------------------------------------- /proton/vpn/app/gtk/widgets/vpn/serverlist/icons.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from pathlib import Path 20 | 21 | from gi.repository import Gtk 22 | 23 | from proton.vpn.app.gtk.assets import icons 24 | 25 | 26 | class UnderMaintenanceIcon(Gtk.Image): 27 | """Icon displayed when a server/country is under maintenance.""" 28 | def __init__(self, widget_under_maintenance: str): 29 | super().__init__() 30 | pixbuf = icons.get(Path("maintenance-icon.svg")) 31 | self.set_from_pixbuf(pixbuf) 32 | self.set_tooltip_text( 33 | f"{widget_under_maintenance} is under maintenance" 34 | ) 35 | 36 | 37 | class SmartRoutingIcon(Gtk.Image): 38 | """Icon displayed when smart routing is used.""" 39 | def __init__(self): 40 | super().__init__() 41 | self.set_from_pixbuf(icons.get(Path("servers/smart-routing.svg"))) 42 | help_text = "Smart routing is used" 43 | self.set_tooltip_text(help_text) 44 | self.get_accessible().set_name(help_text) 45 | 46 | 47 | class StreamingIcon(Gtk.Image): 48 | """Icon displayed when a server supports streaming.""" 49 | def __init__(self): 50 | super().__init__() 51 | self.set_from_pixbuf(icons.get(Path("servers/streaming.svg"))) 52 | help_text = "Streaming supported" 53 | self.set_tooltip_text(help_text) 54 | self.get_accessible().set_name(help_text) 55 | 56 | 57 | class P2PIcon(Gtk.Image): 58 | """Icon displayed when a server supports P2P.""" 59 | def __init__(self): 60 | super().__init__() 61 | self.set_from_pixbuf(icons.get(Path("servers/p2p.svg"))) 62 | help_text = "P2P/BitTorrent supported" 63 | self.set_tooltip_text(help_text) 64 | self.get_accessible().set_name(help_text) 65 | 66 | 67 | class TORIcon(Gtk.Image): 68 | """Icon displayed when a server supports TOR.""" 69 | def __init__(self): 70 | super().__init__() 71 | self.set_from_pixbuf(icons.get(Path("servers/tor.svg"))) 72 | help_text = "TOR supported" 73 | self.set_tooltip_text(help_text) 74 | self.get_accessible().set_name(help_text) 75 | 76 | 77 | class SecureCoreIcon(Gtk.Image): 78 | """Icon displayed when a server supports Secure core. 79 | 80 | Since Secure core servers have a different exit country from the entry 81 | country, for accessibility purposes both entry and exit countries must be 82 | passed. 83 | """ 84 | def __init__(self, entry_country_name: str, exit_country_name: str): 85 | super().__init__() 86 | self.set_from_pixbuf(icons.get(Path("servers/secure-core.svg"))) 87 | help_text = "Secure core server that "\ 88 | f"connects to {exit_country_name} through {entry_country_name}." 89 | self.set_tooltip_text(help_text) 90 | self.get_accessible().set_name(help_text) 91 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/test_connection_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | 20 | from unittest.mock import Mock, patch 21 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.connection_settings import ConnectionSettings 22 | 23 | 24 | FREE_TIER = 0 25 | PLUS_TIER = 1 26 | 27 | 28 | @patch("proton.vpn.app.gtk.widgets.headerbar.menu.settings.connection_settings.ConnectionSettings.pack_start") 29 | @patch("proton.vpn.app.gtk.widgets.headerbar.menu.settings.connection_settings.ToggleWidget") 30 | def test_build_vpn_accelerator_save_new_value_when_callback_is_called(toggle_widget_mock, _): 31 | controller_mock = Mock() 32 | controller_mock.user_tier = FREE_TIER 33 | settings_window_mock = Mock() 34 | cs = ConnectionSettings(controller_mock, settings_window_mock) 35 | cs.build_vpn_accelerator() 36 | new_value = False 37 | 38 | toggle_widget = toggle_widget_mock.call_args[1] 39 | callback = toggle_widget["callback"] 40 | 41 | callback(None, new_value, toggle_widget_mock) 42 | 43 | toggle_widget_mock.save_setting.assert_called_once_with(new_value) 44 | settings_window_mock.notify_user_with_reconnect_message.assert_not_called() 45 | 46 | 47 | @patch("proton.vpn.app.gtk.widgets.headerbar.menu.settings.connection_settings.ConnectionSettings.pack_start") 48 | @patch("proton.vpn.app.gtk.widgets.headerbar.menu.settings.connection_settings.ToggleWidget") 49 | def test_build_moderate_nat_save_new_value_when_callback_is_called(toggle_widget_mock, _): 50 | controller_mock = Mock() 51 | controller_mock.user_tier = FREE_TIER 52 | settings_window_mock = Mock() 53 | cs = ConnectionSettings(controller_mock, settings_window_mock) 54 | cs.build_moderate_nat() 55 | new_value = False 56 | 57 | toggle_widget = toggle_widget_mock.call_args[1] 58 | callback = toggle_widget["callback"] 59 | 60 | callback(None, new_value, toggle_widget_mock) 61 | 62 | toggle_widget_mock.save_setting.assert_called_once_with(new_value) 63 | settings_window_mock.notify_user_with_reconnect_message.assert_not_called() 64 | 65 | 66 | @patch("proton.vpn.app.gtk.widgets.headerbar.menu.settings.connection_settings.ConnectionSettings.pack_start") 67 | @patch("proton.vpn.app.gtk.widgets.headerbar.menu.settings.connection_settings.ToggleWidget") 68 | def test_build_ipv6_save_new_value_when_callback_is_called(toggle_widget_mock, _): 69 | controller_mock = Mock() 70 | settings_window_mock = Mock() 71 | cs = ConnectionSettings(controller_mock, settings_window_mock) 72 | cs.build_ipv6() 73 | new_value = False 74 | 75 | toggle_widget = toggle_widget_mock.call_args[1] 76 | callback = toggle_widget["callback"] 77 | 78 | callback(None, new_value, toggle_widget_mock) 79 | 80 | toggle_widget_mock.save_setting.assert_called_once_with(new_value) 81 | settings_window_mock.notify_user_with_reconnect_message.assert_called_once() 82 | -------------------------------------------------------------------------------- /tests/unit/widgets/vpn/test_search_entry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023 Proton AG 3 | 4 | This file is part of Proton VPN. 5 | 6 | Proton VPN is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Proton VPN is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with ProtonVPN. If not, see . 18 | """ 19 | from time import time 20 | from unittest.mock import Mock 21 | 22 | from gi.repository import GLib 23 | import pytest 24 | 25 | from proton.vpn.session.servers import ServerList, LogicalServer 26 | 27 | from proton.vpn.app.gtk.widgets.vpn.search_entry import SearchEntry 28 | from proton.vpn.app.gtk.widgets.vpn.serverlist.serverlist import ServerListWidget 29 | from tests.unit.testing_utils import process_gtk_events, run_main_loop 30 | 31 | PLUS_TIER = 2 32 | FREE_TIER = 0 33 | 34 | 35 | @pytest.fixture 36 | def api_data(): 37 | return { 38 | "Code": 1000, 39 | "LogicalServers": [ 40 | { 41 | "ID": 2, 42 | "Name": "AR#10", 43 | "Status": 1, 44 | "Load": 50, 45 | "Servers": [{"Status": 1}], 46 | "ExitCountry": "AR", 47 | "Tier": PLUS_TIER, 48 | }, 49 | { 50 | "ID": 1, 51 | "Name": "JP-FREE#10", 52 | "Status": 1, 53 | "Load": 50, 54 | "Servers": [{"Status": 1}], 55 | "ExitCountry": "JP", 56 | "Tier": FREE_TIER, 57 | 58 | }, 59 | { 60 | "ID": 3, 61 | "Name": "AR#9", 62 | "Status": 1, 63 | "Load": 50, 64 | "Servers": [{"Status": 1}], 65 | "ExitCountry": "AR", 66 | "Tier": PLUS_TIER, 67 | }, 68 | { 69 | "ID": 5, 70 | "Name": "CH-JP#1", 71 | "Status": 1, 72 | "Load": 50, 73 | "Servers": [{"Status": 1}], 74 | "Features": 1, # Secure core feature 75 | "EntryCountry": "CH", 76 | "ExitCountry": "JP", 77 | "Tier": PLUS_TIER, 78 | }, 79 | { 80 | "ID": 4, 81 | "Name": "JP#9", 82 | "Status": 1, 83 | "Load": 50, 84 | "Servers": [{"Status": 1}], 85 | "ExitCountry": "JP", 86 | "Tier": PLUS_TIER, 87 | 88 | }, 89 | ] 90 | } 91 | 92 | 93 | @pytest.fixture 94 | def server_list(api_data): 95 | return ServerList( 96 | user_tier=PLUS_TIER, 97 | logicals=[LogicalServer(server) for server in api_data["LogicalServers"]] 98 | ) 99 | 100 | 101 | @pytest.fixture 102 | def server_list_widget(server_list): 103 | server_list_widget = ServerListWidget(controller=Mock()) 104 | server_list_widget.display(user_tier=PLUS_TIER, server_list=server_list) 105 | process_gtk_events() 106 | return server_list_widget 107 | 108 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/split_tunneling/test_split_tunneling.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | import pytest 3 | 4 | from tests.unit.testing_utils import process_gtk_events 5 | 6 | from proton.vpn.core.settings.split_tunneling import SplitTunnelingMode 7 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling import SplitTunnelingToggle 8 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.common import \ 9 | UpgradePlusTag 10 | from proton.vpn.app.gtk.controller import Controller 11 | 12 | 13 | @pytest.fixture 14 | def mock_controller(): 15 | mock = Mock(name="controller", spec=Controller) 16 | mock.get_setting_attr.side_effect = [SplitTunnelingMode.EXCLUDE, ["test-app-exec"]] 17 | mock.user_tier = 1 18 | mock_controller.connection_disconnected = True 19 | 20 | return mock 21 | 22 | 23 | def test_build_revealer_is_hides_app_list_when_setting_is_disabled(mock_controller): 24 | st = SplitTunnelingToggle( 25 | controller=mock_controller, 26 | setting_name="test.setting", 27 | enabled=False, 28 | ) 29 | 30 | st.build_revealer() 31 | assert not st.revealer.get_child() 32 | 33 | 34 | def test_build_revealer_shows_app_list_when_setting_is_enabled(mock_controller): 35 | st = SplitTunnelingToggle( 36 | controller=mock_controller, 37 | settings_container=None, 38 | setting_name="test.setting", 39 | enabled=True, 40 | ) 41 | 42 | st.build_revealer() 43 | assert st.revealer.get_child() 44 | 45 | 46 | def test_toggle_enabled_revealer_reveals_app_list(mock_controller): 47 | 48 | st = SplitTunnelingToggle( 49 | controller=mock_controller, 50 | settings_container=None, 51 | setting_name="test.setting", 52 | enabled=False, 53 | conflict_resolver=lambda setting_name, value: "" 54 | ) 55 | 56 | st.build_revealer() 57 | st.switch.set_state(True) 58 | 59 | process_gtk_events() 60 | 61 | assert st.revealer.get_reveal_child() 62 | 63 | 64 | def test_toggle_disable_revealer_hides_app_list(mock_controller): 65 | setting_name = "test.setting" 66 | 67 | st = SplitTunnelingToggle( 68 | controller=mock_controller, 69 | settings_container=None, 70 | setting_name=setting_name, 71 | enabled=True, 72 | conflict_resolver=lambda setting_name, value: "" 73 | ) 74 | 75 | st.build_revealer() 76 | 77 | mock_controller.reset_mock() 78 | 79 | st.switch.set_state(False) 80 | 81 | process_gtk_events() 82 | 83 | mock_controller.save_setting_attr.assert_called_once_with(setting_name, False) 84 | assert not st.revealer.get_reveal_child() 85 | 86 | 87 | def test_build_display_upgrade_tag_for_free_tier_user(mock_controller): 88 | mock_controller.user_tier = 0 89 | 90 | setting_name = "test.setting" 91 | 92 | st = SplitTunnelingToggle( 93 | controller=mock_controller, 94 | settings_container=None, 95 | setting_name=setting_name, 96 | enabled=True, 97 | ) 98 | 99 | st.build_revealer() 100 | 101 | assert st.overridden_by_upgrade_tag 102 | 103 | 104 | def test_settings_change_for_free_tier_user(mock_controller): 105 | mock_controller.user_tier = 0 106 | 107 | setting_name = "test.setting" 108 | 109 | st = SplitTunnelingToggle( 110 | controller=mock_controller, 111 | settings_container=None, 112 | setting_name=setting_name, 113 | enabled=True, 114 | ) 115 | 116 | st.on_settings_changed( 117 | Mock(features=Mock(split_tunneling=Mock(enabled=True)))) 118 | -------------------------------------------------------------------------------- /tests/unit/widgets/headerbar/menu/settings/split_tunneling/app/test_settings.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | 5 | from tests.unit.testing_utils import process_gtk_events 6 | 7 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.app.settings import \ 8 | AppBasedSplitTunnelingSettings, LABEL_CONVERSION 9 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.app.selected_app_list \ 10 | import SelectedAppList 11 | from proton.vpn.app.gtk.controller import Controller 12 | from proton.vpn.app.gtk.widgets.headerbar.menu.settings.split_tunneling.app.data_structures \ 13 | import AppData 14 | from proton.vpn.core.settings.split_tunneling import SplitTunnelingMode 15 | 16 | 17 | from .mock_app_data import mock_app_data 18 | 19 | 20 | @pytest.fixture 21 | def mock_controller(): 22 | return Mock(spec=Controller, name="controller") 23 | 24 | 25 | @pytest.fixture 26 | def mock_selected_app_list(): 27 | yield Mock(spec=SelectedAppList, name="selected_app_list") 28 | 29 | 30 | def test_app_based_split_tunneling_saves_modified_list_after_receiving_app_removed_signal( 31 | mock_controller, mock_app_data 32 | ): 33 | settings_path_name = "test.path" 34 | split_tunneling_mode = SplitTunnelingMode.EXCLUDE 35 | 36 | sp = AppBasedSplitTunnelingSettings( 37 | controller=mock_controller, 38 | setting_path_name_template=settings_path_name, 39 | mode=split_tunneling_mode, 40 | installed_apps=[mock_app_data], 41 | stored_apps=[mock_app_data.executable], 42 | ) 43 | 44 | sp.emit_signal_app_removed(mock_app_data) 45 | 46 | process_gtk_events() 47 | 48 | mock_controller.save_setting_attr.assert_called_once_with(settings_path_name, []) 49 | assert sp.get_app_count_label() == f"({sp.amount_of_selected_apps})" 50 | 51 | 52 | def test_app_based_split_tunneling_saves_modified_list_after_receiving_app_list_refreshed_signal( 53 | mock_controller, mock_app_data 54 | ): 55 | settings_path_name = "test.path" 56 | 57 | mock_app_data_list = [mock_app_data] 58 | 59 | sp = AppBasedSplitTunnelingSettings( 60 | controller=mock_controller, 61 | setting_path_name_template=settings_path_name, 62 | mode=SplitTunnelingMode.EXCLUDE, 63 | installed_apps=mock_app_data_list, 64 | stored_apps=[] 65 | ) 66 | 67 | sp.emit_signal_app_list_refreshed(mock_app_data_list) 68 | 69 | mock_controller.save_setting_attr.assert_called_once_with(settings_path_name, [mock_app_data.executable]) 70 | assert sp.get_app_count_label() == f"({sp.amount_of_selected_apps})" 71 | 72 | 73 | def test_app_based_split_tunneling_settings_restores_app_list_when_st_mode_is_changed( 74 | mock_controller 75 | ): 76 | include_app = AppData( 77 | name="test-app", 78 | executable="test/path/include", 79 | icon_name="test-icon" 80 | ) 81 | exclude_app = AppData( 82 | name="test-app", 83 | executable="test/path/exclude", 84 | icon_name="test-icon" 85 | ) 86 | 87 | settings_path_name = "test.path" 88 | mock_controller.get_setting_attr.side_effect = [["test/path/include"], ["test/path/exclude"]] 89 | 90 | mock_app_data_list = [include_app, exclude_app] 91 | 92 | sp = AppBasedSplitTunnelingSettings( 93 | controller=mock_controller, 94 | setting_path_name_template=settings_path_name, 95 | mode=SplitTunnelingMode.EXCLUDE, 96 | installed_apps=mock_app_data_list, 97 | stored_apps=[], 98 | ) 99 | sp.update_list_on_new_mode(mode=SplitTunnelingMode.INCLUDE) 100 | 101 | mock_controller.save_setting_attr.assert_called_once_with(settings_path_name, [include_app.executable]) 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Proton VPN GTK app 2 | 3 | Copyright (c) 2023 Proton AG 4 | 5 | This repository holds the Proton VPN GTK app. 6 | For licensing information see [COPYING](COPYING.md) and [LICENSE](LICENSE). 7 | For contribution policy see [CONTRIBUTING](CONTRIBUTING.md). 8 | 9 | ## Description 10 | 11 | The [Proton VPN](https://protonvpn.com) GTK app is intended for every Proton VPN service user, it provides full access to all functionalities available to authenticated users, with the user signup process handled on the website. 12 | 13 | ### Cloning 14 | 15 | Once you've cloned this repo, run: 16 | 17 | > git submodule update --init --recursive 18 | 19 | to clone the necessary submodule. 20 | 21 | ### Installation 22 | 23 | You can get the latest stable release from our [Proton VPN official website](https://protonvpn.com/download-linux). 24 | 25 | ### Dependencies 26 | 27 | For development purposes (within a virtual environment) see the required packages in the setup.py file, under `install_requires` and `extra_require`. As of now these packages will not be available on pypi. Also see [Virtual environment](#virtual-environment) below. 28 | 29 | ### Virtual environment 30 | 31 | If you didn't do it yet, to be able to pip install Proton VPN components you'll 32 | need to set up our internal Python package registry. You can do so running the 33 | command below, after replacing `{GITLAB_TOKEN`} with your 34 | [personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html) 35 | with the scope set to `api`. 36 | 37 | ```shell 38 | pip config set global.index-url https://__token__:{GITLAB_TOKEN}@{GITLAB_INSTANCE}/api/v4/groups/{GROUP_ID}/-/packages/pypi/simple 39 | ``` 40 | 41 | You can create the virtual environment and install the rest of dependencies as 42 | follows: 43 | 44 | ```shell 45 | python3 -m venv venv 46 | source venv/bin/activate 47 | pip install -r requirements.txt 48 | ``` 49 | 50 | #### GUI application 51 | 52 | App logs are stored under `~/.cache/Proton/VPN/logs/` directory. 53 | 54 | User settings are under `~/.config/Proton/VPN/` directory. 55 | 56 | ## Folder structure 57 | 58 | ### Folder "debian" 59 | 60 | Contains all debian related data, for easy package compilation. 61 | 62 | ### Folder "rpmbuild" 63 | 64 | Contains all rpm/fedora related data, for easy package compilation. 65 | 66 | ### Folder "proton/app/gtk" 67 | 68 | This folder contains the gtk app source code. 69 | 70 | ### Folder "tests" 71 | 72 | This folder contains unit/integrations test code. 73 | 74 | You can run the integration tests with: 75 | 76 | ```shell 77 | behave tests/integration/features 78 | ``` 79 | 80 | On headless systems, it's possible to run the integration tests using `Xvfb` 81 | (virtual framebuffer X server). On Debian-based distributions, you just have 82 | to install the `xvfb` package. After that, you can run the integration tests with: 83 | 84 | ```shell 85 | xvfb-run -a behave integration_tests/features 86 | ``` 87 | 88 | ## Versioning 89 | Version matches format: `[major][minor][patch]` 90 | 91 | We automate the versioning of the debian and rpm files. 92 | All versions of the application are recorded in versions.yml. 93 | To bump the version, add the following text to the top of versions.yml 94 | 95 | ``` 96 | version: 97 | time: