Checkin of 0.2.98 upstream source
[time-slider.git] / usr / share / time-slider / lib / time_slider / applet.py
1 #!/usr/bin/python2.6
2 #
3 # CDDL HEADER START
4 #
5 # The contents of this file are subject to the terms of the
6 # Common Development and Distribution License (the "License").
7 # You may not use this file except in compliance with the License.
8 #
9 # You can obtain a copy of the license at usr/src/OPENSOLARIS.LICENSE
10 # or http://www.opensolaris.org/os/licensing.
11 # See the License for the specific language governing permissions
12 # and limitations under the License.
13 #
14 # When distributing Covered Code, include this CDDL HEADER in each
15 # file and include the License file at usr/src/OPENSOLARIS.LICENSE.
16 # If applicable, add the following below this CDDL HEADER, with the
17 # fields enclosed by brackets "[]" replaced with your own identifying
18 # information: Portions Copyright [yyyy] [name of copyright owner]
19 #
20 # CDDL HEADER END
21 #
22
23 import sys
24 import os
25 import subprocess
26 import threading
27 import gobject
28 import dbus
29 import dbus.decorators
30 import dbus.glib
31 import dbus.mainloop
32 import dbus.mainloop.glib
33 import gio
34 import gtk
35 import pygtk
36 import pynotify
37
38 from time_slider import util, rbac
39
40 from os.path import abspath, dirname, join, pardir
41 sys.path.insert(0, join(dirname(__file__), pardir, "plugin"))
42 import plugin
43 sys.path.insert(0, join(dirname(__file__), pardir, "plugin", "rsync"))
44 import backup, rsyncsmf
45
46 class Note:
47     _iconConnected = False
48
49     def __init__(self, icon, menu):
50         self._note = None
51         self._msgDialog = None
52         self._menu = menu
53         self._icon = icon
54         if Note._iconConnected == False:
55             self._icon.connect("popup-menu", self._popup_menu)
56             Note._iconConnected = True
57         self._icon.set_visible(True)
58
59     def _popup_menu(self, icon, button, time):
60         if button == 3:
61             # Don't popup an empty menu
62             if len(self._menu.get_children()) > 0:
63                 self._menu.popup(None, None,
64                                  gtk.status_icon_position_menu,
65                                  button, time, icon)
66
67     def _dialog_response(self, dialog, response):
68         dialog.destroy()
69
70     def _notification_closed(self, notifcation):
71         self._note = None
72         self._icon.set_blinking(False)
73
74     def _show_notification(self):
75         if self._icon.is_embedded() == True:
76             self._note.attach_to_status_icon(self._icon)
77         self._note.show()
78         return False
79
80     def _connect_to_object(self):
81         pass
82
83     def refresh(self):
84         pass
85
86     def _watch_handler(self, new_owner = None):
87         if new_owner == None or len(new_owner) == 0:
88             pass
89         else:
90             self._connect_to_object()
91
92     def _setup_icon_for_note(self, themed=None):
93         if themed:
94             iconList = themed.get_names()
95         else:
96             iconList = ['gnome-dev-harddisk']
97
98         iconTheme = gtk.icon_theme_get_default()
99         iconInfo = iconTheme.choose_icon(iconList, 48, 0)
100         pixbuf = iconInfo.load_icon()
101
102         self._note.set_category("device")
103         self._note.set_icon_from_pixbuf(pixbuf)
104
105
106 class RsyncNote(Note):
107
108     def __init__(self, icon, menu):
109         Note.__init__(self, icon, menu)
110         dbus.bus.NameOwnerWatch(bus,
111                                 "org.opensolaris.TimeSlider.plugin.rsync",
112                                 self._watch_handler)
113
114         self.smfInst = rsyncsmf.RsyncSMF("%s:rsync" \
115                                          % (plugin.PLUGINBASEFMRI))
116         self._lock = threading.Lock()
117         self._masterKey = None
118         sys,self._nodeName,rel,ver,arch = os.uname()
119         # References to gio.File and handler_id of a registered
120         # monitor callback on gio.File
121         self._fm = None
122         self._fmID = None
123         # References to gio.VolumeMonitor and handler_ids of
124         # registered mount-added and mount-removed callbacks.
125         self._vm = None
126         self._vmAdd = None
127         self._vmRem = None
128         # Every time the rsync backup script runs it will
129         # register with d-bus and trigger self._watch_handler().
130         # Use this variable to keep track of it's running status.
131         self._scriptRunning = False
132         self._targetDirAvail = False
133         self._syncNowItem = gtk.MenuItem(_("Update Backups Now"))
134         self._syncNowItem.set_sensitive(False)
135         self._syncNowItem.connect("activate",
136                                   self._sync_now)
137         self._menu.append(self._syncNowItem)
138
139         self.refresh()
140
141     def _validate_rsync_target(self, path):
142         """
143            Tests path to see if it is the pre-configured
144            rsync backup device path.
145            Returns True on success, otherwise False
146         """
147         if not os.path.exists(path):
148             return False
149         testDir = join(path,
150                        rsyncsmf.RSYNCDIRPREFIX,
151                        self._nodeName)
152         testKeyFile = join(path,
153                            rsyncsmf.RSYNCDIRPREFIX,
154                            rsyncsmf.RSYNCCONFIGFILE)
155         if os.path.exists(testDir) and \
156             os.path.exists(testKeyFile):
157             testKeyVal = None
158             f = open(testKeyFile, 'r')
159             for line in f.readlines():
160                 key, val = line.strip().split('=')
161                 if key.strip() == "target_key":
162                     targetKey = val.strip()
163                     break
164             f.close()
165             if targetKey == self._masterKey:
166                 return True
167         return False
168
169     def _setup_monitor(self):
170         # Disconnect any previously registered signal
171         # handlers
172         if self._fm:
173             self._fm.disconnect(self._fmID)
174             self._fm = None
175
176         useVolMonitor = False        
177
178         # We always compare against masterKey to validate
179         # an rsync backup device.
180         self._masterKey = self.smfInst.get_target_key()
181         self._baseTargetDir = None
182         online = False
183
184         self._masterTargetDir = self.smfInst.get_target_dir()
185
186         if self._validate_rsync_target(self._masterTargetDir) == True:
187             self._baseTargetDir = self._masterTargetDir
188             online = True
189
190         if self._vm == None:
191             self._vm = gio.volume_monitor_get()
192
193         # If located, see if it's also managed by the volume monitor.
194         # Or just try to find it otherwise.
195         mounts = self._vm.get_mounts()
196         for mount in mounts:
197             root = mount.get_root()
198             path = root.get_path()
199             if self._baseTargetDir != None and \
200                 path == self._baseTargetDir:
201                 # Means the directory we found is gio monitored,
202                 # so just monitor it using gio.VolumeMonitor.
203                 useVolMonitor = True
204                 break
205             elif self._validate_rsync_target(path) == True:
206                 # Found it but not where we expected it to be so override
207                 # the target path defined by SMF for now.
208                 useVolMonitor = True
209                 self._baseTargetDir = path
210                 online = True
211                 break
212
213         if self._baseTargetDir == None:
214             # Means we didn't find it, and we don't know where to expect
215             # it either - via a hotpluggable device or other nfs/zfs etc.
216             # We need to hedge our bets and monitor for both.
217             self._setup_file_monitor(self._masterTargetDir)
218             self._setup_volume_monitor()
219         else:
220             # Found it
221             if useVolMonitor == True:
222                 # Looks like a removable device. Use gio.VolumeMonitor
223                 # as the preferred monitoring mechanism.
224                 self._setup_volume_monitor()
225             else:
226                 # Found it on a static mount point like a zfs or nfs
227                 # mount point.
228                 # Can't use gio.VolumeMonitor so use a gio.File monitor
229                 # instead.
230                 self._setup_file_monitor(self._masterTargetDir)
231
232         # Finally, update the UI menu state
233         self._lock.acquire()
234         self._targetDirAvail = online
235         self._update_menu_state()
236         self._lock.release()
237             
238             
239     def _setup_file_monitor(self, expectedPath):
240         # Use gio.File monitor as a fallback in 
241         # case gio.VolumeMonitor can't track the device.
242         # This is the case for static/manual mount points
243         # such as NFS, ZFS and other non-hotpluggables.
244         gFile = gio.File(path=expectedPath)
245         self._fm = gFile.monitor_file(gio.FILE_MONITOR_WATCH_MOUNTS)
246         self._fmID = self._fm.connect("changed",
247                                       self._file_monitor_changed)
248
249     def _setup_volume_monitor(self):
250         # Check the handler_ids first to see if they have 
251         # already been connected. Avoids multiple callbacks
252         # for a single event
253         if self._vmAdd == None:
254             self._vmAdd = self._vm.connect("mount-added",
255                                            self._mount_added)
256         if self._vmRem == None:
257             self._vmRem = self._vm.connect("mount-removed",
258                                            self._mount_removed)
259             
260     def _mount_added(self, monitor, mount):
261         root = mount.get_root()
262         path = root.get_path()
263         if self._validate_rsync_target(path) == True:
264             # Since gio.VolumeMonitor found the rsync target, don't
265             # bother relying on gio.File to find it any more. Disconnect
266             # it's registered callbacks.
267             if self._fm:
268                 self._fm.disconnect(self._fmID)
269                 self._fm = None
270             self._lock.acquire()
271             self._baseTargetDir = path
272             self._targetDirAvail = True
273             self._update_menu_state()
274             self._lock.release()
275
276     def _mount_removed(self, monitor, mount):
277         root = mount.get_root()
278         path = root.get_path()
279         if path == self._baseTargetDir:
280             self._lock.acquire()
281             self._targetDirAvail = False
282             self._update_menu_state()
283             self._lock.release()
284
285     def _file_monitor_changed(self, filemonitor, file, other_file, event_type):
286         if file.get_path() == self._masterTargetDir:
287             self._lock.acquire()
288             if self._validate_rsync_target(self._masterTargetDir) == True:
289                 self._targetDirAvail = True
290             else:
291                 self._targetDirAvail = False
292             self._update_menu_state()
293             self._lock.release()            
294
295     def _update_menu_state(self):
296         if self._syncNowItem:
297             if self._targetDirAvail == True and \
298                 self._scriptRunning == False:
299                 self._syncNowItem.set_sensitive(True)
300             else:
301                 self._syncNowItem.set_sensitive(False)
302
303     def _watch_handler(self, new_owner = None):
304         self._lock.acquire()
305         if new_owner == None or len(new_owner) == 0:
306             # Script not running or exited
307             self._scriptRunning = False
308         else:
309             self._scriptRunning = True
310             self._connect_to_object()
311         self._update_menu_state()
312         self._lock.release()
313
314     def _rsync_started_handler(self, target, sender=None, interface=None, path=None):
315         urgency = pynotify.URGENCY_NORMAL
316         if (self._note != None):
317             self._note.close()
318         # Try to pretty things up a bit by displaying volume name
319         # and hinted icon instead of the raw device path,
320         # and standard harddisk icon if possible.
321         icon = None
322         volume = util.path_to_volume(target)
323         if volume == None:
324             volName = target
325         else:
326             volName = volume.get_name()
327             icon = volume.get_icon()
328                       
329         self._note = pynotify.Notification(_("Backup Started"),
330                                            _("Backing up snapshots to:\n<b>%s</b>\n" \
331                                            "Do not disconnect the backup device.") \
332                                             % (volName))
333         self._note.connect("closed", \
334                            self._notification_closed)
335         self._note.set_urgency(urgency)
336         self._setup_icon_for_note(icon)
337         gobject.idle_add(self._show_notification)
338
339     def _rsync_current_handler(self, snapshot, remaining, sender=None, interface=None, path=None):
340         self._icon.set_tooltip_markup(_("Backing up: <b>\'%s\'\n%d</b> snapshots remaining.\n" \
341                                       "Do not disconnect the backup device.") \
342                                       % (snapshot, remaining))
343
344     def _rsync_complete_handler(self, target, sender=None, interface=None, path=None):
345         urgency = pynotify.URGENCY_NORMAL
346         if (self._note != None):
347             self._note.close()
348         # Try to pretty things up a bit by displaying volume name
349         # and hinted icon instead of the raw device path,
350         # and standard harddisk icon if possible.
351         icon = None
352         volume = util.path_to_volume(target)
353         if volume == None:
354             volName = target
355         else:
356             volName = volume.get_name()
357             icon = volume.get_icon()
358
359         self._note = pynotify.Notification(_("Backup Complete"),
360                                            _("Your snapshots have been backed up to:\n<b>%s</b>") \
361                                            % (volName))
362         self._note.connect("closed", \
363                            self._notification_closed)
364         self._note.set_urgency(urgency)
365         self._setup_icon_for_note(icon)
366         self._icon.set_has_tooltip(False)
367         self.queueSize = 0
368         gobject.idle_add(self._show_notification)
369
370     def _rsync_synced_handler(self, sender=None, interface=None, path=None):
371         self._icon.set_tooltip_markup(_("Your backups are up to date."))
372         self.queueSize = 0
373
374     def _rsync_unsynced_handler(self, queueSize, sender=None, interface=None, path=None):
375         self._icon.set_tooltip_markup(_("%d snapshots are queued for backup.") \
376                                       % (queueSize))
377         self.queueSize = queueSize
378
379     def _connect_to_object(self):
380         try:
381             remote_object = bus.get_object("org.opensolaris.TimeSlider.plugin.rsync",
382                                            "/org/opensolaris/TimeSlider/plugin/rsync")
383         except dbus.DBusException:
384             sys.stderr.write("Failed to connect to remote D-Bus object: " + \
385                              "/org/opensolaris/TimeSlider/plugin/rsync")
386             return
387
388         # Create an Interface wrapper for the remote object
389         iface = dbus.Interface(remote_object, "org.opensolaris.TimeSlider.plugin.rsync")
390
391         iface.connect_to_signal("rsync_started", self._rsync_started_handler, sender_keyword='sender',
392                                 interface_keyword='interface', path_keyword='path')
393         iface.connect_to_signal("rsync_current", self._rsync_current_handler, sender_keyword='sender',
394                                 interface_keyword='interface', path_keyword='path')
395         iface.connect_to_signal("rsync_complete", self._rsync_complete_handler, sender_keyword='sender',
396                                 interface_keyword='interface', path_keyword='path')
397         iface.connect_to_signal("rsync_synced", self._rsync_synced_handler, sender_keyword='sender',
398                                 interface_keyword='interface', path_keyword='path')
399         iface.connect_to_signal("rsync_unsynced", self._rsync_unsynced_handler, sender_keyword='sender',
400                                 interface_keyword='interface', path_keyword='path')
401
402     def refresh(self):
403         # Hide/Unhide rsync menu item based on whether the plugin is online
404         if self._syncNowItem and \
405            self.smfInst.get_service_state() == "online":
406             #self._setup_file_monitor()
407             self._setup_monitor()
408             # Kick start things by initially obtaining the
409             # backlog size and triggering a callback.
410             # Signal handlers will keep tooltip status up
411             # to date afterwards when the backup cron job
412             # executes.
413             propName = "%s:rsync" % (backup.propbasename)
414             queue = backup.list_pending_snapshots(propName)
415             self.queueSize = len(queue)
416             if self.queueSize == 0:
417                 self._rsync_synced_handler()
418             else:
419                 self._rsync_unsynced_handler(self.queueSize)
420             self._syncNowItem.show()
421         else:
422             self._syncNowItem.hide()
423
424     def _sync_now(self, menuItem):
425         """Runs the rsync-backup script manually
426            Assumes that user is root since it is only
427            called from the menu item which is invisible to
428            not authorised users
429         """
430         cmdPath = os.path.join(os.path.dirname(sys.argv[0]), \
431                                "time-slider/plugins/rsync/rsync-backup")
432         if os.geteuid() == 0:
433           cmd = [cmdPath, \
434                  "%s:rsync" % (plugin.PLUGINBASEFMRI)]
435         else:
436           cmd = ['/usr/bin/gksu' ,cmdPath, \
437                  "%s:rsync" % (plugin.PLUGINBASEFMRI)]
438
439         subprocess.Popen(cmd, close_fds=True, cwd="/")
440
441
442 class CleanupNote(Note):
443
444     def __init__(self, icon, menu):
445         Note.__init__(self, icon, menu)
446         self._cleanupHead = None
447         self._cleanupBody = None
448         dbus.bus.NameOwnerWatch(bus,
449                                 "org.opensolaris.TimeSlider",
450                                 self._watch_handler)
451
452     def _show_cleanup_details(self, *args):
453         # We could keep a dialog around but this a rare
454         # enough event that's it not worth the effort.
455         dialog = gtk.MessageDialog(type=gtk.MESSAGE_WARNING,
456                                    buttons=gtk.BUTTONS_CLOSE)
457         dialog.set_title(_("Time Slider: Low Space Warning"))
458         dialog.set_markup("<b>%s</b>" % (self._cleanupHead))
459         dialog.format_secondary_markup(self._cleanupBody)
460         dialog.show()
461         dialog.present()
462         dialog.connect("response", self._dialog_response)
463
464     def _cleanup_handler(self, pool, severity, threshhold, sender=None, interface=None, path=None):
465         if severity == 4:
466             expiry = pynotify.EXPIRES_NEVER
467             urgency = pynotify.URGENCY_CRITICAL
468             self._cleanupHead = _("Emergency: \'%s\' is full!") % pool
469             notifyBody = _("The file system: \'%s\', is over %s%% full.") \
470                             % (pool, threshhold)
471             self._cleanupBody = _("The file system: \'%s\', is over %s%% full.\n"
472                      "As an emergency measure, Time Slider has "
473                      "destroyed all of its backups.\nTo fix this problem, "
474                      "delete any unnecessary files on \'%s\', or add "
475                      "disk space (see ZFS documentation).") \
476                       % (pool, threshhold, pool)
477         elif severity == 3:
478             expiry = pynotify.EXPIRES_NEVER
479             urgency = pynotify.URGENCY_CRITICAL
480             self._cleanupHead = _("Emergency: \'%s\' is almost full!") % pool
481             notifyBody = _("The file system: \'%s\', exceeded %s%% "
482                            "of its total capacity") \
483                             % (pool, threshhold)
484             self._cleanupBody = _("The file system: \'%s\', exceeded %s%% "
485                      "of its total capacity. As an emerency measure, "
486                      "Time Slider has has destroyed most or all of its "
487                      "backups to prevent the disk becoming full. "
488                      "To prevent this from happening again, delete "
489                      "any unnecessary files on \'%s\', or add disk "
490                      "space (see ZFS documentation).") \
491                       % (pool, threshhold, pool)
492         elif severity == 2:
493             expiry = pynotify.EXPIRES_NEVER
494             urgency = pynotify.URGENCY_CRITICAL
495             self._cleanupHead = _("Urgent: \'%s\' is almost full!") % pool
496             notifyBody = _("The file system: \'%s\', exceeded %s%% "
497                            "of its total capacity") \
498                             % (pool, threshhold)
499             self._cleanupBody = _("The file system: \'%s\', exceeded %s%% "
500                      "of its total capacity. As a remedial measure, "
501                      "Time Slider has destroyed some backups, and will "
502                      "destroy more, eventually all, as capacity continues "
503                      "to diminish.\nTo prevent this from happening again, "
504                      "delete any unnecessary files on \'%s\', or add disk "
505                      "space (see ZFS documentation).") \
506                      % (pool, threshhold, pool)
507         elif severity == 1:
508             expiry = 20000 # 20 seconds
509             urgency = pynotify.URGENCY_NORMAL
510             self._cleanupHead = _("Warning: \'%s\' is getting full") % pool
511             notifyBody = _("The file system: \'%s\', exceeded %s%% "
512                            "of its total capacity") \
513                             % (pool, threshhold)
514             self._cleanupBody = _("\'%s\' exceeded %s%% of its total "
515                      "capacity. To fix this, Time Slider has destroyed "
516                      "some recent backups, and will destroy more as "
517                      "capacity continues to diminish.\nTo prevent "
518                      "this from happening again, delete any "
519                      "unnecessary files on \'%s\', or add disk space "
520                      "(see ZFS documentation).\n") \
521                      % (pool, threshhold, pool)
522         else:
523             return # No other values currently supported
524
525         if (self._note != None):
526             self._note.close()
527         self._note = pynotify.Notification(self._cleanupHead,
528                                            notifyBody)
529         self._note.add_action("clicked",
530                               _("Details..."),
531                               self._show_cleanup_details)
532         self._note.connect("closed",
533                            self._notification_closed)
534         self._note.set_urgency(urgency)
535         self._note.set_timeout(expiry)
536         self._setup_icon_for_note()
537         self._icon.set_blinking(True)
538         gobject.idle_add(self._show_notification)
539
540     def _connect_to_object(self):
541         try:
542             remote_object = bus.get_object("org.opensolaris.TimeSlider",
543                                            "/org/opensolaris/TimeSlider/autosnap")
544         except dbus.DBusException:
545             sys.stderr.write("Failed to connect to remote D-Bus object: " + \
546                              "/org/opensolaris/TimeSlider/autosnap")
547
548         #Create an Interface wrapper for the remote object
549         iface = dbus.Interface(remote_object, "org.opensolaris.TimeSlider.autosnap")
550
551         iface.connect_to_signal("capacity_exceeded", self._cleanup_handler, sender_keyword='sender',
552                                 interface_keyword='interface', path_keyword='path')
553
554
555
556 class SetupNote(Note):
557
558     def __init__(self, icon, menu, manager):
559         Note.__init__(self, icon, menu)
560         # We are passed a reference to out parent so we can
561         # provide it notification which it can then circulate
562         # to other notification objects such as Rsync and
563         # Cleanup
564         self._manager = manager
565         self._icon = icon
566         self._menu = menu
567         self._configSvcItem = gtk.MenuItem(_("Configure Time Slider..."))
568         self._configSvcItem.connect("activate",
569                                     self._run_config_app)
570         self._configSvcItem.set_sensitive(True)
571         self._menu.append(self._configSvcItem)
572         self._configSvcItem.show()
573         dbus.bus.NameOwnerWatch(bus,
574                                 "org.opensolaris.TimeSlider.config",
575                                 self._watch_handler)
576
577     def _connect_to_object(self):
578         try:
579             remote_object = bus.get_object("org.opensolaris.TimeSlider.config",
580                                            "/org/opensolaris/TimeSlider/config")
581         except dbus.DBusException:
582             sys.stderr.write("Failed to connect to remote D-Bus object: " + \
583                              "/org/opensolaris/TimeSlider/config")
584
585         #Create an Interface wrapper for the remote object
586         iface = dbus.Interface(remote_object, "org.opensolaris.TimeSlider.config")
587
588         iface.connect_to_signal("config_changed", self._config_handler, sender_keyword='sender',
589                                 interface_keyword='interface', path_keyword='path')
590
591     def _config_handler(self, sender=None, interface=None, path=None):
592         # Notify the manager.
593         # This will eventually propogate through to an invocation
594         # of our own refresh() method.
595         self._manager.refresh()
596
597     def _run_config_app(self, menuItem):
598         cmdPath = os.path.join(os.path.dirname(sys.argv[0]),
599                            os.path.pardir,
600                            "bin",
601                            "time-slider-setup")
602         cmd = os.path.abspath(cmdPath)
603         # The setup GUI deals with it's own security and 
604         # authorisation, so no need to pfexec it. Any
605         # changes made to configuration will come back to
606         # us by way of D-Bus notification.
607         subprocess.Popen(cmd, close_fds=True)
608
609 class NoteManager():
610     def __init__(self):
611         # Notification objects need to share a common
612         # status icon and popup menu so these are created
613         # outside the object and passed to the constructor
614         self._menu = gtk.Menu()
615         self._icon = gtk.StatusIcon()
616         self._icon.set_from_icon_name("time-slider-setup")
617         self._setupNote = SetupNote(self._icon,
618                                     self._menu,
619                                     self)
620         self._cleanupNote = CleanupNote(self._icon,
621                                         self._menu)
622         self._rsyncNote = RsyncNote(self._icon,
623                                     self._menu)
624
625     def refresh(self):
626         self._rsyncNote.refresh()
627
628 bus = dbus.SystemBus()
629
630 def main(argv):
631     mainloop = gobject.MainLoop()
632     dbus.mainloop.glib.DBusGMainLoop(set_as_default = True)
633     gobject.threads_init()
634     pynotify.init(_("Time Slider"))
635
636     noteManager = NoteManager()
637
638     try:
639         mainloop.run()
640     except:
641         print "Exiting"
642
643 if __name__ == '__main__':
644     main()
645