Skip to content

Commit

Permalink
ui, notifications: allow to use system notifications
Browse files Browse the repository at this point in the history
WIP.

Until now we used Qt's systray notifications. They couldn't be disabled
and didn't integrate well with non-Qt based Desktop Environments. Also
we depended on the system tray availability, which is not always
available (i3, phosh, ...).

Now the user can choose to use Qt's notifications, the system
notification service or disabled them completely.

Pros:
 - The notification style is defined by the Desktop Environment.
 - Can be configured globally from the system settings.
 - In many DEs, the notifications are grouped into a single view. So if
   you miss any event, you can go there and check out what happened.
 - Now we can display notifications on DEs where we couldn't before.
 - It's a standard supported by major DEs.

Cons:
 - Sometimes we can't connect to the D-Bus mainloop instance. We need to
   investigate it.

TODO:
 - Deny/Allow new outgoing connections from the notifications,
   replacing the current pop-ups.

Requested here: #468 , #476 and #477 .
  • Loading branch information
gustavo-iniguez-goya committed Jan 7, 2022
1 parent 2d2a970 commit 843412d
Show file tree
Hide file tree
Showing 5 changed files with 402 additions and 82 deletions.
9 changes: 7 additions & 2 deletions ui/opensnitch/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ class Config:
DEFAULT_DB_MAX_DAYS = "database/max_days"
DEFAULT_DB_PURGE_INTERVAL = "database/purge_interval"

NOTIFICATIONS_ENABLED = "notifications/enabled"
NOTIFICATIONS_TYPE = "notifications/type"
NOTIFICATION_TYPE_SYSTEM = 0
NOTIFICATION_TYPE_QT = 1


STATS_GEOMETRY = "statsDialog/geometry"
STATS_LAST_TAB = "statsDialog/last_tab"
Expand Down Expand Up @@ -123,8 +128,8 @@ def setSettings(self, path, value):
def getSettings(self, path):
return self.settings.value(path)

def getBool(self, path):
return self.settings.value(path, False, type=bool)
def getBool(self, path, default_value=False):
return self.settings.value(path, type=bool, defaultValue=default_value)

def getInt(self, path, default_value=0):
try:
Expand Down
35 changes: 30 additions & 5 deletions ui/opensnitch/dialogs/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from opensnitch.nodes import Nodes
from opensnitch.database import Database
from opensnitch.utils import Message, QuickHelp
from opensnitch.notifications import DesktopNotifications

from opensnitch import ui_pb2

Expand Down Expand Up @@ -57,14 +58,16 @@ def __init__(self, parent=None):
self.cmdDBMaxDaysDown.clicked.connect(lambda: self._cb_cmd_spin_clicked(self.spinDBMaxDays, self.REST))
self.cmdDBPurgesUp.clicked.connect(lambda: self._cb_cmd_spin_clicked(self.spinDBPurgeInterval, self.SUM))
self.cmdDBPurgesDown.clicked.connect(lambda: self._cb_cmd_spin_clicked(self.spinDBPurgeInterval, self.REST))
self.helpButton.setToolTipDuration(10 * 1000)
self.cmdTestNotifs.clicked.connect(self._cb_test_notifs_clicked)
self.helpButton.setToolTipDuration(30 * 1000)

if QtGui.QIcon.hasThemeIcon("emblem-default") == False:
self.applyButton.setIcon(self.style().standardIcon(getattr(QtWidgets.QStyle, "SP_DialogApplyButton")))
self.cancelButton.setIcon(self.style().standardIcon(getattr(QtWidgets.QStyle, "SP_DialogCloseButton")))
self.acceptButton.setIcon(self.style().standardIcon(getattr(QtWidgets.QStyle, "SP_DialogSaveButton")))
self.dbFileButton.setIcon(self.style().standardIcon(getattr(QtWidgets.QStyle, "SP_DirOpenIcon")))
if QtGui.QIcon.hasThemeIcon("zoom-in") == False:

if QtGui.QIcon.hasThemeIcon("list-add") == False:
self.cmdTimeoutUp.setIcon(self.style().standardIcon(getattr(QtWidgets.QStyle, "SP_ArrowUp")))
self.cmdTimeoutDown.setIcon(self.style().standardIcon(getattr(QtWidgets.QStyle, "SP_ArrowDown")))
self.cmdDBMaxDaysUp.setIcon(self.style().standardIcon(getattr(QtWidgets.QStyle, "SP_ArrowUp")))
Expand All @@ -76,6 +79,7 @@ def showEvent(self, event):
super(PreferencesDialog, self).showEvent(event)

try:
self._settingsSaved = False
self._reset_status_message()
self._hide_status_label()
self.comboNodes.clear()
Expand Down Expand Up @@ -143,6 +147,14 @@ def _load_settings(self):
self.dstPortCheck.setChecked(self._cfg.getBool(self._cfg.DEFAULT_POPUP_ADVANCED_DSTPORT))
self.uidCheck.setChecked(self._cfg.getBool(self._cfg.DEFAULT_POPUP_ADVANCED_UID))

self.groupNotifs.setChecked(self._cfg.getBool(Config.NOTIFICATIONS_ENABLED))
self.radioSysNotifs.setChecked(
True if self._cfg.getInt(Config.NOTIFICATIONS_TYPE) == Config.NOTIFICATION_TYPE_SYSTEM else False
)
self.radioQtNotifs.setChecked(
True if self._cfg.getInt(Config.NOTIFICATIONS_TYPE) == Config.NOTIFICATION_TYPE_QT else False
)

self.dbType = self._cfg.getInt(self._cfg.DEFAULT_DB_TYPE_KEY)
self.comboDBType.setCurrentIndex(self.dbType)
if self.comboDBType.currentIndex() != Database.DB_TYPE_MEMORY:
Expand Down Expand Up @@ -285,7 +297,7 @@ def _save_settings(self):
self._node_needs_update = False

self.saved.emit()

self._settingsSaved = True

def _save_db_config(self):
dbtype = self.comboDBType.currentIndex()
Expand Down Expand Up @@ -339,6 +351,11 @@ def _save_ui_config(self):
self._cfg.setSettings(self._cfg.DEFAULT_POPUP_ADVANCED_DSTIP, bool(self.dstIPCheck.isChecked()))
self._cfg.setSettings(self._cfg.DEFAULT_POPUP_ADVANCED_DSTPORT, bool(self.dstPortCheck.isChecked()))
self._cfg.setSettings(self._cfg.DEFAULT_POPUP_ADVANCED_UID, bool(self.uidCheck.isChecked()))

self._cfg.setSettings(self._cfg.NOTIFICATIONS_ENABLED, bool(self.groupNotifs.isChecked()))
self._cfg.setSettings(self._cfg.NOTIFICATIONS_TYPE,
int(Config.NOTIFICATION_TYPE_SYSTEM if self.radioSysNotifs.isChecked() else Config.NOTIFICATION_TYPE_QT))

# this is a workaround for not display pop-ups.
# see #79 for more information.
if self.popupsCheck.isChecked():
Expand Down Expand Up @@ -444,7 +461,8 @@ def _cb_db_type_changed(self):

def _cb_accept_button_clicked(self):
self.accept()
self._save_settings()
if not self._settingsSaved:
self._save_settings()

def _cb_apply_button_clicked(self):
self._save_settings()
Expand All @@ -456,7 +474,8 @@ def _cb_help_button_clicked(self):
QuickHelp.show(
QC.translate("preferences",
"Hover the mouse over the texts to display the help<br><br>Don't forget to visit the wiki: <a href=\"{0}\">{0}</a>"
).format(Config.HELP_URL))
).format(Config.HELP_URL)
)

def _cb_popups_check_toggled(self, checked):
self.spinUITimeout.setEnabled(not checked)
Expand All @@ -480,3 +499,9 @@ def _cb_cmd_spin_clicked(self, spinWidget, operation):
spinWidget.setValue(spinWidget.value() + 1)
else:
spinWidget.setValue(spinWidget.value() - 1)

def _cb_test_notifs_clicked(self):
if self.radioSysNotifs.isChecked():
DesktopNotifications().show("title", "body")
else:
pass
129 changes: 129 additions & 0 deletions ui/opensnitch/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/python

from PyQt5.QtCore import QCoreApplication as QC
import os
from utils import Utils
from opensnitch.config import Config

class DesktopNotifications():
"""DesktopNotifications display informative pop-ups using the system D-Bus.
The notifications are handled and configured by the system.
The notification daemon also decides where to show the notifications, as well
as how to group them.
The body of a notification supports markup (if the implementation supports it):
https://people.gnome.org/~mccann/docs/notification-spec/notification-spec-latest.html#markup
Basically: <a>, <u>, <b>, <i> and <img>. New lines can be added with the regular \n.
It also support actions (buttons).
https://notify2.readthedocs.io/en/latest/
"""

_cfg = Config.init()

# list of hints:
# https://people.gnome.org/~mccann/docs/notification-spec/notification-spec-latest.html#hints
HINT_DESKTOP_ENTRY = "desktop-entry"
CATEGORY_NETWORK = "network"

EXPIRES_DEFAULT = 0
NEVER_EXPIRES = -1

def __init__(self):
self.ACTION_ALLOW = QC.translate("popups", "Allow")
self.ACTION_DENY = QC.translate("popups", "Deny")
self.IS_LIBNOTIFY_AVAILABLE = True
self.DOES_SUPPORT_ACTIONS = True

try:
import notify2
self.ntf2 = notify2
mloop = 'glib'

# First try to initialise the D-Bus connection with the given
# mainloop.
# If it fails, we'll try to initialise it without it.
try:
self.ntf2.init("opensnitch", mainloop=mloop)
except Exception:
self.DOES_SUPPORT_ACTIONS = False

# usually because dbus mainloop is not initiated, specially
# with 'qt'
# FIXME: figure out how to init it, or how to connect to an
# existing session.
print("DesktopNotifications(): system doesn't support actions. Available capabilities:")
print(self.ntf2.get_server_caps())

self.ntf2.init("opensnitch")

# Example: ['actions', 'action-icons', 'body', 'body-markup', 'icon-static', 'persistence', 'sound']
if ('actions' not in self.ntf2.get_server_caps()):
self.DOES_SUPPORT_ACTIONS = False

except Exception as e:
print("DesktopNotifications not available (install python3-notify2):", e)
self.IS_LIBNOTIFY_AVAILABLE = False

def is_available(self):
return self.IS_LIBNOTIFY_AVAILABLE and self._cfg.getBool(Config.NOTIFICATIONS_ENABLED)

def support_actions(self):
"""Returns true if the notifications daemon support actions(buttons).
This depends on 2 factors:
- If the notification server actually supports it (get_server_caps()).
- If there's a dbus instance running.
"""
return self.DOES_SUPPORT_ACTIONS

def show(self, title, body, icon="dialog-information"):
ntf = self.ntf2.Notification(title, body, icon)

# timeouts seems to be ignored (on Cinnamon at least)
timeout = self._cfg.getInt(Config.DEFAULT_TIMEOUT_KEY, 15)
ntf.set_timeout(timeout)
ntf.timeout = timeout

ntf.set_category(self.CATEGORY_NETWORK)
# used to display our app icon an name.
ntf.set_hint(self.HINT_DESKTOP_ENTRY, "opensnitch_ui")
ntf.show()

# TODO:
# - construct a rule with the default configured parameters.
# - create a common dialogs/prompt.py:_send_rule(), maybe in utils.py
def ask(self, connection, timeout, callback):
c = connection
title = QC.translate("popups", "New outgoing connection")
body = """
{0}
{1} {2}:{3} -> {4}:{5}
UID: {6} PID: {7}
""".format(c.process_path, c.protocol.upper(),
c.src_port, c.src_ip,
c.dst_host if c.dst_host != "" else c.dst_ip, c.dst_port,
c.user_id, c.process_id
)
ntf = self.ntf2.Notification(title, body, "dialog-warning")
timeout = self._cfg.getInt(Config.DEFAULT_TIMEOUT_KEY, 15)
ntf.set_timeout(timeout * 1000)
ntf.timeout = timeout * 1000
if self.DOES_SUPPORT_ACTIONS:
ntf.set_urgency(self.ntf2.URGENCY_CRITICAL)
ntf.add_action("allow", self.ACTION_ALLOW, callback, connection)
ntf.add_action("deny", self.ACTION_DENY, callback, connection)
#ntf.add_action("open-gui", QC.translate("popups", "View"), callback, connection)
ntf.set_category(self.CATEGORY_NETWORK)
ntf.set_hint(self.HINT_DESKTOP_ENTRY, "opensnitch_ui")
ntf.show()

@staticmethod
def areEnabled():
"""Return if notifications are enabled.
Default: True
"""
return DesktopNotifications._cfg.getBool(DesktopNotifications._cfg.NOTIFICATIONS_ENABLED, True)
Loading

0 comments on commit 843412d

Please sign in to comment.