File: notifications.py

package info (click to toggle)
syncthing-gtk 0.9.4.4%2Bds%2Bgit20201209%2Bc46fbd8-1
  • links: PTS, VCS
  • area: main
  • in suites: bullseye
  • size: 3,260 kB
  • sloc: python: 7,592; sh: 259; xml: 115; makefile: 2
file content (380 lines) | stat: -rw-r--r-- 11,880 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
#!/usr/bin/env python3

"""
Syncthing-GTK - Notifications

Listens to syncing events on daemon and displays desktop notifications.
"""

from syncthing_gtk.tools import IS_WINDOWS, IS_GNOME

DELAY = 5	# Display notification only after no file is downloaded for <DELAY> seconds
ICON_DEF = "syncthing-gtk"
ICON_ERR = "syncthing-gtk-error"

HAS_DESKTOP_NOTIFY = False
Notifications = None

try:
	if not IS_WINDOWS:
		import gi
		gi.require_version('Notify', '0.7')
		from gi.repository import Notify
		HAS_DESKTOP_NOTIFY = True
except ImportError:
	pass

if HAS_DESKTOP_NOTIFY:
	from syncthing_gtk.timermanager import TimerManager
	from syncthing_gtk.tools import _ # gettext function
	import os, logging
	log = logging.getLogger("Notifications")

	class STNotification():
		""" Basic class to track a notification and update its text """
		ACT_DEFAULT = "default"
		ACT_IGNORE = "IGNORE"
		ACT_ACCEPT = "ACCEPT"
		app = None
		n = None
		id = None
		label = None

		def __init__(self, app, id, label=None):
			self.app = app
			self.id = id
			self.set_label(label)

		def set_label(self, label):
			self.label = label

		def clean(self):
			pass

		def close_notification(self):
			try:
				self.n.close_notification()
			except Exception:
				# If I can't close the notification, I don't care
				pass

		def cb_notification_closed(self, notif):
			self.n = None
			self.clean()

		def supports(self, caps, supported=True, unsupported=False):
			if IS_GNOME:
				return unsupported
			else:
				return supported

		def addactions(self, n, actions=[], clear=True):
			if not self.supports("actions"): return
			if clear: n.clear_actions()
			for act, label, cb, data in actions:
				n.add_action(act, label, cb, data)

		def show(self, n):
			try:
				n.show()
			except Exception:
				# Ignore all errors here, there is no way I can handle
				# everything what can be broken with notifications...
				pass

		def push(self, summary, body=None, **kwargs):
			icon = kwargs.get('icon', ICON_DEF)
			urg = kwargs.get('urg')
			actions = kwargs.get('actions', [])

			if not self.n:
				self.n = Notify.Notification.new(summary, body, icon)
				self.n.connect("closed", self.cb_notification_closed),
			else:
				self.n.update(summary, body, icon)

			if urg:
				self.n.set_urgency(urg)

			self.addactions(self.n, actions)
			self.show(self.n)

	class STNotificationDevice(STNotification):
		"""Notification class to track a notification, which is related to a syncthing device"""

		def cb_accept(self, n, action, user_data):
			self.app.open_editor_device(self.id, self.label)

		def cb_ignore(self, n, action, user_data):
			self.app.add_ignored("ignoredDevices", self.id)

		def rejected(self):
			label_fb = self.label or self.id
			actions = [
				(self.ACT_DEFAULT, _('Accept device "%s"') % label_fb, self.cb_accept, None),
				(self.ACT_ACCEPT,  _('Accept device "%s"') % label_fb, self.cb_accept, None),
				(self.ACT_IGNORE,  _('Ignore device "%s"') % label_fb, self.cb_ignore, None),
			]
			summary = _("Unknown Device")
			body = _('Device "%s" is trying to connect to syncthing daemon.' % self.label)
			self.push(summary, body, actions=actions, urg=Notify.Urgency.CRITICAL)

	class STNotificationFolder(STNotification, TimerManager):
		"""Notification class to track a notification, which is related to a syncthing folder"""
		syncing = False

		updated = set([])
		deleted = set([])
		updating = set([])
		conflict = set([])

		timer_id = "display"
		timer_delay = DELAY

		def __init__(self, app, id, label=None):
			TimerManager.__init__(self)
			STNotification.__init__(self, app, id, label)

		def set_label(self, label):
			if label:
				self.label = label
			elif self.id in self.app.folders:
				self.label = self.app.folders[self.id]["label"]

		def clean(self):
			self.syncing = False
			self.cancel_timer(self.timer_id)
			self.updated.clear()
			self.deleted.clear()
			self.updating.clear()

		def cb_accept(self, n, action, user_data):
			self.app.open_editor_folder(self.id, self.label, user_data)

		def cb_ignore(self, n, action, user_data):
			self.app.add_ignored("ignoredFolders", self.id)

		def rejected(self, nid):
			device = self.app.devices[nid].get_title()
			label_fb = self.label or self.id
			actions = [
				(self.ACT_DEFAULT, _('Accept folder "%s"') % label_fb, self.cb_accept, nid),
				(self.ACT_ACCEPT,  _('Accept folder "%s"') % label_fb, self.cb_accept, nid),
				(self.ACT_IGNORE,  _('Ignore folder "%s"') % label_fb, self.cb_ignore, nid),
			]

			markup_dev = self.supports("body-markup",
				"<b>%s</b>" % device,
				device)
			markup_fol = self.supports("body-markup",
				"<b>%s</b>" % label_fb,
				label_fb)

			summary = _("Folder rejected")
			body = _('Unexpected folder "%(folder)s" sent from device "%(device)s".') % {
				'device' : markup_dev,
				'folder' : markup_fol
			}
			self.push(summary, body, actions=actions, urg=Notify.Urgency.CRITICAL)

		def add_path(self, path, itm_finished=True):
			path_full = os.path.join(self.app.folders[self.id]["norm_path"], path)

			if itm_finished:

				if ".sync-conflict" in path and os.path.exists(path_full):
					# Updated or new conflict
					self.sync_conflict(path)

				elif path in self.updating:
					if os.path.exists(path_full):
						self.updated.add(path)
					else:
						self.deleted.add(path)

					self.updating.remove(path)

					if not self.timer_active(self.timer_id):
						self.timer(self.timer_id, self.timer_delay, self.display)
			else:
				self.updating.add(path)

		def display(self, finished=False):
			summary = ""
			body = ""
			filename = ""
			if finished:
				summary = _('Completed synchronization in "%s"') % (self.label or self.id)
			else:
				summary = _('Updates in folder "%s"') % (self.label or self.id)

			if len(self.updated) == 1 and len(self.deleted) == 0:
				f_path = os.path.join(self.app.folders[self.id]["norm_path"], self.updated.pop())
				filename = os.path.split(f_path)[-1]

				self.supports("body-hyperlinks",
					"<a href='file://%s'>%s</a>" % (f_path.encode('unicode-escape'), filename),
					f_path)

				body = _("%(hostname)s: Downloaded '%(filename)s' to reflect remote changes.")
			elif len(self.updated) == 0 and len(self.deleted) == 1:
				f_path = os.path.join(self.app.folders[self.id]["norm_path"], self.deleted.pop())
				filename = os.path.split(f_path)[-1]
				body = _("%(hostname)s: Deleted '%(filename)s' to reflect remote changes.")
			elif len(self.deleted) == 0 and len(self.updated) > 0:
				body = _("%(hostname)s: Downloaded %(updated)s files to reflect remote changes.")
			elif len(self.updated) == 0 and len(self.deleted) > 0:
				body = _("%(hostname)s: Deleted %(deleted)s files to reflect remote changes.")
			elif len(self.deleted) > 0 and len(self.updated) > 0:
				body = _("%(hostname)s: downloaded %(updated)s files and deleted %(deleted)s files to reflect remote changes.")
			elif len(self.conflict) > 0:
				# If we reached this point, there are only sync-conflicts updated
				# we don't want to have another notification
				return

			body = body % {
				'hostname' : self.app.get_local_name(),
				'updated'  : len(self.updated),
				'deleted'  : len(self.deleted),
				'filename' : (filename),
			}

			self.push(summary, body)

		def set_progress(self, progress):
			if progress < 1.0:
				self.syncing = True

		def finished(self):
			if len(self.deleted) + len(self.updating) + len(self.updated) > 0 \
			   or self.syncing:
				self.display(True)

		def sync_conflict(self, path):
			path_full = os.path.join(self.app.folders[self.id]["norm_path"], path)

			summary = _('Conflicting file in "%s"') % (self.label or self.id)
			text = _('Conflict in path "%s" detected.') % path_full

			n = Notify.Notification.new(summary, text, ICON_ERR)
			n.set_urgency(Notify.Urgency.CRITICAL)
			n.add_action(self.ACT_DEFAULT, _("Open Conflicting file in filemanager"), self.cb_open_conflict, path_full)
			n.connect("closed", self.cb_sync_conflict_closed),
			self.conflict.add(n)

			self.show(n)

		def cb_sync_conflict_closed(self, notif):
			self.conflict.remove(notif)

		def cb_open_conflict(self, n, action, user_data):
			if user_data and os.path.exists(user_data):
				dirname = os.path.dirname(user_data)
				self.app.cb_browse_folder({'path': dirname})

	class NotificationsCls():
		""" Watches for filesystem changes and reports them to daemon """
		def __init__(self, app, daemon):
			Notify.init("Syncthing GTK")
			# Prepare stuff
			self.app = app
			self.daemon = daemon
			self.notify_folders = {}
			self.notify_devices = {}

			# Make deep connection with daemon
			self.signals = [
				self.daemon.connect("connected", self.cb_syncthing_connected)
			]
			if self.app.config["notification_for_error"]:
				self.signals += [
					self.daemon.connect("error", self.cb_syncthing_error),
					self.daemon.connect("folder-rejected", self.cb_syncthing_folder_rejected),
					self.daemon.connect("device-rejected", self.cb_syncthing_device_rejected)
				]
				log.verbose("Error notifications enabled")
			if self.app.config["notification_for_update"]:
				self.signals += [
					self.daemon.connect('item-started', self.cb_syncthing_item_started),
					self.daemon.connect('item-updated', self.cb_syncthing_item_updated),
				]
				log.verbose("File update notifications enabled")
			if self.app.config["notification_for_folder"]:
				self.signals += [
					self.daemon.connect('folder-sync-progress', self.cb_syncthing_folder_progress),
					self.daemon.connect('folder-sync-finished', self.cb_syncthing_folder_finished)
				]
				log.verbose("Folder notifications enabled")

		def get_folder(self, folder_id, label=None):
			if folder_id not in self.notify_folders:
				self.notify_folders[folder_id] = STNotificationFolder(self.app, folder_id, label)
			return self.notify_folders[folder_id]

		def get_device(self, device_id, label=None):
			if device_id not in self.notify_devices:
				self.notify_devices[device_id] = STNotificationDevice(self.app, device_id, label)
			return self.notify_devices[device_id]

		def clear_notifications(self):
			# Clear download list and close related notifications
			for dct in [self.notify_devices, self.notify_folders]:
				for obj in dct.values():
					obj.close_notification()
				dct = {}

		def kill(self, *a):
			""" Removes all event handlers and frees some stuff """
			for s in self.signals:
				self.daemon.handler_disconnect(s)
			self.clear_notifications()
			log.info("Notifications killed")

		def cb_syncthing_connected(self, *a):
			self.clear_notifications()

		def cb_syncthing_error(self, daemon, message):
			summary = _('An error occurred in Syncthing!')
			n = Notify.Notification.new(summary, None, ICON_ERR)
			n.set_urgency(Notify.Urgency.CRITICAL)

			try:
				n.show()
			except Exception:
				pass

		def cb_syncthing_folder_rejected(self, daemon, device_id, folder_id, label):
			if device_id not in self.app.devices:
				return

			n = self.get_folder(folder_id, label)
			n.rejected(device_id)

		def cb_syncthing_device_rejected(self, daemon, nid, name, address):
			n = self.get_device(nid, name)
			n.rejected()

		def cb_syncthing_item_started(self, daemon, folder_id, path, time):
			n = self.get_folder(folder_id)
			n.add_path(path, itm_finished=False)

		def cb_syncthing_item_updated(self, daemon, folder_id, path, *a):
			n = self.get_folder(folder_id)
			n.add_path(path)

		def cb_syncthing_folder_progress(self, daemon, folder_id, progress):
			n = self.get_folder(folder_id)
			n.set_progress(progress)

		def cb_syncthing_folder_finished(self, daemon, folder_id):
			n = self.get_folder(folder_id)
			n.finished()

	# Notifications is set to class only if libnotify is available
	Notifications = NotificationsCls

"""
Events emitted when file is changed on remote node:
	ItemStarted repo_name, path, time
	LocalIndexUpdated (item-updated) repo_name, path, time
"""