feature/app-split-tunneling (#702)

App Split Tunneling for Windows and Android
This commit is contained in:
Nethius 2024-04-01 18:45:00 +07:00 committed by GitHub
parent e7bd24f065
commit adab30fc81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1225 additions and 98 deletions

View file

@ -0,0 +1,50 @@
#include "appSplitTunnelingController.h"
#include <QFileInfo>
#include "core/defs.h"
AppSplitTunnelingController::AppSplitTunnelingController(const std::shared_ptr<Settings> &settings,
const QSharedPointer<AppSplitTunnelingModel> &appSplitTunnelingModel, QObject *parent)
: QObject(parent), m_settings(settings), m_appSplitTunnelingModel(appSplitTunnelingModel)
{
}
void AppSplitTunnelingController::addApp(const QString &appPath)
{
InstalledAppInfo appInfo { "", "", appPath };
if (!appPath.isEmpty()) {
QFileInfo fileInfo(appPath);
appInfo.appName = fileInfo.fileName();
}
if (m_appSplitTunnelingModel->addApp(appInfo)) {
emit finished(tr("Application added: %1").arg(appInfo.appName));
} else {
emit errorOccurred(tr("The application has already been added"));
}
}
void AppSplitTunnelingController::addApps(QVector<QPair<QString, QString>> apps)
{
qDebug() << apps;
for (const auto &app : apps) {
InstalledAppInfo appInfo { app.first, app.second, "" };
m_appSplitTunnelingModel->addApp(appInfo);
}
emit finished(tr("The selected applications have been added"));
}
void AppSplitTunnelingController::removeApp(const int index)
{
auto modelIndex = m_appSplitTunnelingModel->index(index);
auto appPath = m_appSplitTunnelingModel->data(modelIndex, AppSplitTunnelingModel::Roles::AppPathRole).toString();
m_appSplitTunnelingModel->removeApp(modelIndex);
QFileInfo fileInfo(appPath);
emit finished(tr("Application removed: %1").arg(fileInfo.fileName()));
}

View file

@ -0,0 +1,31 @@
#ifndef APPSPLITTUNNELINGCONTROLLER_H
#define APPSPLITTUNNELINGCONTROLLER_H
#include <QObject>
#include "settings.h"
#include "ui/models/appSplitTunnelingModel.h"
class AppSplitTunnelingController : public QObject
{
Q_OBJECT
public:
explicit AppSplitTunnelingController(const std::shared_ptr<Settings> &settings,
const QSharedPointer<AppSplitTunnelingModel> &sitesModel, QObject *parent = nullptr);
public slots:
void addApp(const QString &appPath);
void addApps(QVector<QPair<QString, QString>> apps);
void removeApp(const int index);
signals:
void errorOccurred(const QString &errorMessage);
void finished(const QString &message);
private:
std::shared_ptr<Settings> m_settings;
QSharedPointer<AppSplitTunnelingModel> m_appSplitTunnelingModel;
};
#endif // APPSPLITTUNNELINGCONTROLLER_H

View file

@ -29,6 +29,7 @@ namespace PageLoader
PageSettingsAbout,
PageSettingsLogging,
PageSettingsSplitTunneling,
PageSettingsAppSplitTunneling,
PageServiceSftpSettings,
PageServiceTorWebsiteSettings,

View file

@ -0,0 +1,101 @@
#include "appSplitTunnelingModel.h"
#include <QFileInfo>
AppSplitTunnelingModel::AppSplitTunnelingModel(std::shared_ptr<Settings> settings, QObject *parent)
: QAbstractListModel(parent), m_settings(settings)
{
auto routeMode = m_settings->getAppsRouteMode();
if (routeMode == Settings::AppsRouteMode::VpnAllApps) {
m_isSplitTunnelingEnabled = false;
m_currentRouteMode = Settings::AppsRouteMode::VpnAllExceptApps;
} else {
m_isSplitTunnelingEnabled = true;
m_currentRouteMode = routeMode;
}
m_apps = m_settings->getVpnApps(m_currentRouteMode);
}
int AppSplitTunnelingModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return m_apps.size();
}
QVariant AppSplitTunnelingModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= static_cast<int>(rowCount()))
return QVariant();
switch (role) {
case AppPathRole: {
return m_apps.at(index.row()).appName;
}
default: {
return true;
}
}
return QVariant();
}
bool AppSplitTunnelingModel::addApp(const InstalledAppInfo &appInfo)
{
if (m_apps.contains(appInfo)) {
return false;
}
beginInsertRows(QModelIndex(), rowCount(), rowCount());
m_apps.append(appInfo);
m_settings->setVpnApps(m_currentRouteMode, m_apps);
endInsertRows();
qDebug() << "app added " << appInfo.appName;
return true;
}
void AppSplitTunnelingModel::removeApp(QModelIndex index)
{
beginRemoveRows(QModelIndex(), index.row(), index.row());
m_apps.removeAt(index.row());
m_settings->setVpnApps(m_currentRouteMode, m_apps);
endRemoveRows();
}
int AppSplitTunnelingModel::getRouteMode()
{
return m_currentRouteMode;
}
void AppSplitTunnelingModel::setRouteMode(int routeMode)
{
beginResetModel();
m_settings->setAppsRouteMode(static_cast<Settings::AppsRouteMode>(routeMode));
m_currentRouteMode = m_settings->getAppsRouteMode();
m_apps = m_settings->getVpnApps(m_currentRouteMode);
endResetModel();
emit routeModeChanged();
}
bool AppSplitTunnelingModel::isSplitTunnelingEnabled()
{
return m_isSplitTunnelingEnabled;
}
void AppSplitTunnelingModel::toggleSplitTunneling(bool enabled)
{
if (enabled) {
setRouteMode(m_currentRouteMode);
} else {
m_settings->setAppsRouteMode(Settings::AppsRouteMode::VpnAllApps);
}
m_isSplitTunnelingEnabled = enabled;
emit splitTunnelingToggled();
}
QHash<int, QByteArray> AppSplitTunnelingModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[AppPathRole] = "appPath";
return roles;
}

View file

@ -0,0 +1,55 @@
#ifndef APPSPLITTUNNELINGMODEL_H
#define APPSPLITTUNNELINGMODEL_H
#include <QAbstractListModel>
#include "settings.h"
#include "core/defs.h"
class AppSplitTunnelingModel: public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
AppPathRole = Qt::UserRole + 1,
PackageAppNameRole,
PackageAppIconRole
};
explicit AppSplitTunnelingModel(std::shared_ptr<Settings> settings, QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
Q_PROPERTY(int routeMode READ getRouteMode WRITE setRouteMode NOTIFY routeModeChanged)
Q_PROPERTY(bool isTunnelingEnabled READ isSplitTunnelingEnabled NOTIFY splitTunnelingToggled)
public slots:
bool addApp(const InstalledAppInfo &appInfo);
void removeApp(QModelIndex index);
int getRouteMode();
void setRouteMode(int routeMode);
bool isSplitTunnelingEnabled();
void toggleSplitTunneling(bool enabled);
signals:
void routeModeChanged();
void splitTunnelingToggled();
protected:
QHash<int, QByteArray> roleNames() const override;
private:
std::shared_ptr<Settings> m_settings;
bool m_isSplitTunnelingEnabled;
Settings::AppsRouteMode m_currentRouteMode;
QVector<InstalledAppInfo> m_apps;
};
#endif // APPSPLITTUNNELINGMODEL_H

View file

@ -0,0 +1,96 @@
#include "installedAppsModel.h"
#include <QEventLoop>
#include <QtConcurrent>
#ifdef Q_OS_ANDROID
#include "platforms/android/android_controller.h"
#endif
InstalledAppsModel::InstalledAppsModel(QObject *parent) : QAbstractListModel(parent)
{
}
int InstalledAppsModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return m_installedApps.size();
}
QVariant InstalledAppsModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= static_cast<int>(rowCount()))
return QVariant();
switch (role) {
case AppNameRole: {
auto appName = m_installedApps.at(index.row()).toObject().value("name").toString();
auto packageName = m_installedApps.at(index.row()).toObject().value("package").toString();
if (appName.isEmpty()) {
appName = packageName;
}
return appName;
}
case AppIconRole: {
return m_installedApps.at(index.row()).toObject().value("package").toString();
}
case PackageNameRole: {
return m_installedApps.at(index.row()).toObject().value("package");
}
}
return QVariant();
}
void InstalledAppsModel::selectedStateChanged(const int index, const bool selected)
{
if (selected) {
m_selectedAppIndexes.insert(index);
} else {
m_selectedAppIndexes.remove(index);
}
}
QVector<QPair<QString, QString>> InstalledAppsModel::getSelectedAppsInfo()
{
QVector<QPair<QString, QString>> appsInfo;
for (const auto i : m_selectedAppIndexes) {
QString packageName = data(index(i, 0), PackageNameRole).toString();
QString appName = data(index(i, 0), AppNameRole).toString();
if (appName.isEmpty()) {
appName = packageName;
}
appsInfo.push_back({ appName, packageName });
}
return appsInfo;
}
void InstalledAppsModel::updateModel()
{
QFuture<void> future = QtConcurrent::run([this]() {
beginResetModel();
#ifdef Q_OS_ANDROID
m_installedApps = AndroidController::instance()->getAppList();
#endif
endResetModel();
});
QFutureWatcher<void> watcher;
QEventLoop wait;
connect(&watcher, &QFutureWatcher<void>::finished, &wait, &QEventLoop::quit);
watcher.setFuture(future);
wait.exec();
return;
}
QHash<int, QByteArray> InstalledAppsModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[AppNameRole] = "appName";
roles[AppIconRole] = "appIcon";
roles[PackageNameRole] = "packageName";
return roles;
}

View file

@ -0,0 +1,38 @@
#ifndef INSTALLEDAPPSMODEL_H
#define INSTALLEDAPPSMODEL_H
#include <QJsonArray>
#include <QAbstractListModel>
class InstalledAppsModel: public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
AppNameRole= Qt::UserRole + 1,
PackageNameRole,
AppIconRole
};
explicit InstalledAppsModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
public slots:
void selectedStateChanged(const int index, const bool selected);
QVector<QPair<QString, QString>> getSelectedAppsInfo();
void updateModel();
protected:
QHash<int, QByteArray> roleNames() const override;
private:
QJsonArray m_installedApps;
QSet<int> m_selectedAppIndexes;
};
#endif // INSTALLEDAPPSMODEL_H

View file

@ -57,7 +57,7 @@ DrawerType2 {
Layout.fillWidth: true
Layout.topMargin: 16
enabled: ! ServersModel.isDefaultServerDefaultContainerHasSplitTunneling || !ServersModel.getDefaultServerData("isServerFromApi")
enabled: !ServersModel.isDefaultServerDefaultContainerHasSplitTunneling || !ServersModel.getDefaultServerData("isServerFromApi")
text: qsTr("Site-based split tunneling")
descriptionText: enabled && SitesModel.isTunnelingEnabled ? qsTr("Enabled") : qsTr("Disabled")
@ -80,7 +80,7 @@ DrawerType2 {
rightImageSource: "qrc:/images/controls/chevron-right.svg"
clickedFunction: function() {
// PageController.goToPage(PageEnum.PageSetupWizardConfigSource)
PageController.goToPage(PageEnum.PageSettingsAppSplitTunneling)
root.close()
}
}

View file

@ -0,0 +1,136 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "../Controls2"
import "../Controls2/TextTypes"
import InstalledAppsModel 1.0
DrawerType2 {
id: root
anchors.fill: parent
expandedHeight: parent.height * 0.9
onAboutToShow: {
PageController.showBusyIndicator(true)
installedAppsModel.updateModel()
PageController.showBusyIndicator(false)
}
InstalledAppsModel {
id: installedAppsModel
}
expandedContent: Item {
id: container
implicitHeight: expandedHeight
ColumnLayout {
id: backButton
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: addButton.top
anchors.topMargin: 16
BackButtonType {
backButtonImage: "qrc:/images/controls/arrow-left.svg"
backButtonFunction: function() {
root.close()
}
}
Header2Type {
id: header
Layout.fillWidth: true
Layout.topMargin: 16
Layout.rightMargin: 16
Layout.leftMargin: 16
headerText: qsTr("Choose application")
}
ListView {
id: listView
Layout.fillWidth: true
Layout.fillHeight: true
Layout.topMargin: 16
Layout.rightMargin: 16
Layout.leftMargin: 16
clip: true
interactive: true
model: installedAppsModel
ScrollBar.vertical: ScrollBar {
id: scrollBar
policy: ScrollBar.AlwaysOn
}
ButtonGroup {
id: buttonGroup
}
delegate: Item {
implicitWidth: root.width
implicitHeight: delegateContent.implicitHeight
ColumnLayout {
id: delegateContent
anchors.fill: parent
RowLayout {
CheckBoxType {
Layout.fillWidth: true
text: appName
onCheckedChanged: {
listView.model.selectedStateChanged(index, checked)
}
}
Image {
source: "image://installedAppImage/" + appIcon
sourceSize.width: 24
sourceSize.height: 24
Layout.rightMargin: 48
}
}
DividerType {}
}
}
}
}
BasicButtonType {
id: addButton
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.bottomMargin: 16
anchors.rightMargin: 16
anchors.leftMargin: 16
text: qsTr("Add selected")
clickedFunc: function() {
PageController.showBusyIndicator(true)
AppSplitTunnelingController.addApps(listView.model.getSelectedAppsInfo())
PageController.showBusyIndicator(false)
root.close()
}
}
}
}

View file

@ -90,12 +90,12 @@ CheckBox {
}
contentItem: Item {
implicitWidth: content.implicitWidth
implicitHeight: content.implicitHeight
anchors.fill: parent
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 8 + background.width
implicitHeight: content.implicitHeight
ColumnLayout {
id: content

View file

@ -126,32 +126,6 @@ Item {
}
}
}
BasicButtonType {
visible: (root.buttonText !== "") || (root.buttonImageSource !== "")
// defaultColor: "transparent"
// hoveredColor: Qt.rgba(1, 1, 1, 0.08)
// pressedColor: Qt.rgba(1, 1, 1, 0.12)
// disabledColor: "#878B91"
// textColor: "#D7D8DB"
// borderWidth: 0
focusPolicy: Qt.NoFocus
text: root.buttonText
imageSource: root.buttonImageSource
// Layout.rightMargin: 24
Layout.preferredHeight: content.implicitHeight
Layout.preferredWidth: content.implicitHeight
squareLeftSide: true
clickedFunc: function() {
if (root.clickedFunc && typeof root.clickedFunc === "function") {
root.clickedFunc()
}
}
}
}
}
@ -187,6 +161,28 @@ Item {
}
}
BasicButtonType {
visible: (root.buttonText !== "") || (root.buttonImageSource !== "")
focusPolicy: Qt.NoFocus
text: root.buttonText
imageSource: root.buttonImageSource
anchors.top: content.top
anchors.bottom: content.bottom
anchors.right: content.right
height: content.implicitHeight
width: content.implicitHeight
squareLeftSide: true
clickedFunc: function() {
if (root.clickedFunc && typeof root.clickedFunc === "function") {
root.clickedFunc()
}
}
}
function getBackgroundBorderColor(noneFocusedColor) {
return textField.focus ? root.borderFocusedColor : noneFocusedColor
}

View file

@ -0,0 +1,256 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Dialogs
import QtCore
import SortFilterProxyModel 0.2
import PageEnum 1.0
import ProtocolEnum 1.0
import ContainerProps 1.0
import "./"
import "../Controls2"
import "../Controls2/TextTypes"
import "../Config"
import "../Components"
PageType {
id: root
QtObject {
id: routeMode
property int allApps: 0
property int onlyForwardApps: 1
property int allExceptApps: 2
}
property list<QtObject> routeModesModel: [
onlyForwardApps,
allExceptApps
]
QtObject {
id: onlyForwardApps
property string name: qsTr("Only the Apps listed here will be accessed through the VPN")
property int type: routeMode.onlyForwardApps
}
QtObject {
id: allExceptApps
property string name: qsTr("Apps from the list should not be accessed via VPN")
property int type: routeMode.allExceptApps
}
function getRouteModesModelIndex() {
var currentRouteMode = AppSplitTunnelingModel.routeMode
if ((routeMode.onlyForwardApps === currentRouteMode) || (routeMode.allApps === currentRouteMode)) {
return 0
} else if (routeMode.allExceptApps === currentRouteMode) {
return 1
}
}
ColumnLayout {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 20
BackButtonType {
}
RowLayout {
HeaderType {
Layout.fillWidth: true
Layout.leftMargin: 16
headerText: qsTr("App split tunneling")
}
SwitcherType {
id: switcher
Layout.fillWidth: true
Layout.rightMargin: 16
checked: AppSplitTunnelingModel.isTunnelingEnabled
onToggled: {
AppSplitTunnelingModel.toggleSplitTunneling(checked)
selector.text = root.routeModesModel[getRouteModesModelIndex()].name
}
}
}
DropDownType {
id: selector
Layout.fillWidth: true
Layout.topMargin: 32
Layout.leftMargin: 16
Layout.rightMargin: 16
drawerHeight: 0.4375
drawerParent: root
headerText: qsTr("Mode")
enabled: Qt.platform.os === "android"
listView: ListViewWithRadioButtonType {
rootWidth: root.width
model: root.routeModesModel
currentIndex: getRouteModesModelIndex()
clickedFunction: function() {
selector.text = selectedText
selector.close()
if (AppSplitTunnelingModel.routeMode !== root.routeModesModel[currentIndex].type) {
AppSplitTunnelingModel.routeMode = root.routeModesModel[currentIndex].type
}
}
Component.onCompleted: {
if (root.routeModesModel[currentIndex].type === AppSplitTunnelingModel.routeMode) {
selector.text = selectedText
} else {
selector.text = root.routeModesModel[0].name
}
}
Connections {
target: AppSplitTunnelingModel
function onRouteModeChanged() {
currentIndex = getRouteModesModelIndex()
}
}
}
}
}
FlickableType {
anchors.top: header.bottom
anchors.topMargin: 16
contentHeight: col.implicitHeight + addAppButton.implicitHeight + addAppButton.anchors.bottomMargin + addAppButton.anchors.topMargin
Column {
id: col
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
ListView {
id: apps
width: parent.width
height: apps.contentItem.height
model: SortFilterProxyModel {
id: proxyAppSplitTunnelingModel
sourceModel: AppSplitTunnelingModel
filters: RegExpFilter {
roleName: "appPath"
pattern: ".*" + searchField.textField.text + ".*"
caseSensitivity: Qt.CaseInsensitive
}
}
clip: true
interactive: false
delegate: Item {
implicitWidth: apps.width
implicitHeight: delegateContent.implicitHeight
ColumnLayout {
id: delegateContent
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
LabelWithButtonType {
Layout.fillWidth: true
text: appPath
rightImageSource: "qrc:/images/controls/trash.svg"
rightImageColor: "#D7D8DB"
clickedFunction: function() {
var headerText = qsTr("Remove ") + appPath + "?"
var yesButtonText = qsTr("Continue")
var noButtonText = qsTr("Cancel")
var yesButtonFunction = function() {
AppSplitTunnelingController.removeApp(proxyAppSplitTunnelingModel.mapToSource(index))
}
var noButtonFunction = function() {
}
showQuestionDrawer(headerText, "", yesButtonText, noButtonText, yesButtonFunction, noButtonFunction)
}
}
DividerType {}
}
}
}
}
}
Rectangle {
anchors.fill: addAppButton
anchors.bottomMargin: -24
color: "#0E0E11"
opacity: 0.8
}
RowLayout {
id: addAppButton
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 24
anchors.rightMargin: 16
anchors.leftMargin: 16
anchors.bottomMargin: 24
TextFieldWithHeaderType {
id: searchField
Layout.fillWidth: true
textFieldPlaceholderText: qsTr("application name")
buttonImageSource: "qrc:/images/controls/plus.svg"
clickedFunc: function() {
searchField.focus = false
PageController.showBusyIndicator(true)
if (Qt.platform.os === "windows") {
var fileName = SystemController.getFileName(qsTr("Open executable file"),
qsTr("Executable file (*.*)"))
if (fileName !== "") {
AppSplitTunnelingController.addApp(fileName)
}
} else if (Qt.platform.os === "android"){
installedAppDrawer.open()
}
PageController.showBusyIndicator(false)
}
}
}
InstalledAppsDrawer {
id: installedAppDrawer
anchors.fill: parent
}
}

View file

@ -11,6 +11,8 @@ import "../Config"
PageType {
id: root
property bool isAppSplitTinnelingEnabled: Qt.platform.os === "windows" || Qt.platform.os === "android"
BackButtonType {
id: backButton
@ -73,8 +75,6 @@ PageType {
DividerType {}
LabelWithButtonType {
visible: true
Layout.fillWidth: true
text: qsTr("Site-based split tunneling")
@ -87,11 +87,11 @@ PageType {
}
DividerType {
visible: GC.isDesktop()
visible: root.isAppSplitTinnelingEnabled
}
LabelWithButtonType {
visible: false
visible: root.isAppSplitTinnelingEnabled
Layout.fillWidth: true
@ -100,11 +100,12 @@ PageType {
rightImageSource: "qrc:/images/controls/chevron-right.svg"
clickedFunction: function() {
PageController.goToPage(PageEnum.PageSettingsAppSplitTunneling)
}
}
DividerType {
visible: false
visible: root.isAppSplitTinnelingEnabled
}
}
}

View file

@ -22,7 +22,7 @@ PageType {
property var isServerFromApi: ServersModel.getDefaultServerData("isServerFromApi")
defaultActiveFocusItem: website_ip_field.textField
defaultActiveFocusItem: searchField.textField
property bool pageEnabled: {
return !ConnectionController.isConnected && !isServerFromApi
@ -188,7 +188,24 @@ PageType {
width: parent.width
height: sites.contentItem.height
model: SitesModel
model: SortFilterProxyModel {
id: proxySitesModel
sourceModel: SitesModel
filters: [
AnyOf {
RegExpFilter {
roleName: "url"
pattern: ".*" + searchField.textField.text + ".*"
caseSensitivity: Qt.CaseInsensitive
}
RegExpFilter {
roleName: "ip"
pattern: ".*" + searchField.textField.text + ".*"
caseSensitivity: Qt.CaseInsensitive
}
}
]
}
clip: true
interactive: false
@ -218,7 +235,7 @@ PageType {
var noButtonText = qsTr("Cancel")
var yesButtonFunction = function() {
SitesController.removeSite(index)
SitesController.removeSite(proxySitesModel.mapToSource(index))
}
var noButtonFunction = function() {
}
@ -255,7 +272,7 @@ PageType {
anchors.bottomMargin: 24
TextFieldWithHeaderType {
id: website_ip_field
id: searchField
Layout.fillWidth: true
@ -430,8 +447,4 @@ PageType {
}
}
}
QuestionDrawer {
id: questionDrawer
}
}