1 | #!/usr/bin/env python |
---|
2 | # |
---|
3 | |
---|
4 | from gi.repository import GObject |
---|
5 | from gi.repository import GLib |
---|
6 | from gi.repository import Gio |
---|
7 | from gi.repository import Gtk |
---|
8 | from gi.repository import Gdk |
---|
9 | from gi.repository import GdkPixbuf |
---|
10 | from gi.repository import LightDM |
---|
11 | |
---|
12 | import sys |
---|
13 | import platform |
---|
14 | import subprocess |
---|
15 | import pwd |
---|
16 | import time |
---|
17 | import os.path |
---|
18 | from optparse import OptionParser |
---|
19 | |
---|
20 | # TODO: ConfigParser |
---|
21 | MIN_UID=1 |
---|
22 | NOLOGIN_FILE="/var/run/athena-nologin" |
---|
23 | UI_FILE="/usr/share/debathena-lightdm-config/debathena-lightdm-greeter.ui" |
---|
24 | BG_IMG_FILE="/usr/share/debathena-lightdm-config/background.jpg" |
---|
25 | DEBATHENA_LOGO_FILES=["/usr/share/debathena-lightdm-config/debathena.png", |
---|
26 | "/usr/share/debathena-lightdm-config/debathena1.png", |
---|
27 | "/usr/share/debathena-lightdm-config/debathena2.png", |
---|
28 | "/usr/share/debathena-lightdm-config/debathena3.png", |
---|
29 | "/usr/share/debathena-lightdm-config/debathena4.png", |
---|
30 | "/usr/share/debathena-lightdm-config/debathena5.png", |
---|
31 | "/usr/share/debathena-lightdm-config/debathena6.png", |
---|
32 | "/usr/share/debathena-lightdm-config/debathena7.png", |
---|
33 | "/usr/share/debathena-lightdm-config/debathena8.png"] |
---|
34 | |
---|
35 | class DebathenaGreeter: |
---|
36 | animation_loop_frames = 300 |
---|
37 | |
---|
38 | |
---|
39 | def _debug(self, *args): |
---|
40 | if self.debugMode: |
---|
41 | if type(args[0]) is str and len(args) > 1: |
---|
42 | print >> sys.stderr, "D: " + args[0], args[1:] |
---|
43 | else: |
---|
44 | print >> sys.stderr, "D: ", args[0] |
---|
45 | |
---|
46 | def __init__(self, options): |
---|
47 | self.debugMode = options.debug |
---|
48 | self.timePedantry = True |
---|
49 | |
---|
50 | # Set up and connect to the greeter |
---|
51 | self.greeter = LightDM.Greeter() |
---|
52 | self.greeter.connect("authentication-complete", |
---|
53 | self.cbAuthenticationComplete) |
---|
54 | self.greeter.connect("show-message", self.cbShowMessage) |
---|
55 | self.greeter.connect("show-prompt", self.cbShowPrompt) |
---|
56 | self.greeter.connect_sync() |
---|
57 | |
---|
58 | # Gtk signal handlers |
---|
59 | handlers = { |
---|
60 | "login_cb": self.cbLogin, |
---|
61 | "cancel_cb": self.cancelLogin, |
---|
62 | "kpEvent": self.cbKeyPress, |
---|
63 | "power_cb": self.showPowerDialog, |
---|
64 | "access_cb": self.showAccessDialog, |
---|
65 | } |
---|
66 | |
---|
67 | # Save the screen size for various window operations |
---|
68 | defaultScreen = Gdk.Screen.get_default() |
---|
69 | self.screenSize = (defaultScreen.width(), defaultScreen.height()) |
---|
70 | |
---|
71 | self.get_workstation_information() |
---|
72 | |
---|
73 | # Load the UI and get objects we care about |
---|
74 | self.builder = Gtk.Builder() |
---|
75 | try: |
---|
76 | self.builder.add_from_file(options.ui_file) |
---|
77 | except GLib.GError, e: |
---|
78 | print >> sys.stderr, "FATAL: Unable to load UI: ", e |
---|
79 | sys.exit(-1) |
---|
80 | |
---|
81 | # The login window |
---|
82 | self.winLogin = self.builder.get_object("winLogin") |
---|
83 | # A box containing the prompt label, entry, and a spinner |
---|
84 | self.prompt_box = self.builder.get_object("boxPrompt") |
---|
85 | self.prompt_label = self.builder.get_object("lblPrompt") |
---|
86 | self.prompt_entry = self.builder.get_object("entryPrompt") |
---|
87 | self.loginSpinner = self.builder.get_object("loginSpinner") |
---|
88 | # A label where we display messages received from the greeter |
---|
89 | self.message_label = self.builder.get_object("lblMessage") |
---|
90 | # The owl |
---|
91 | self.imgDebathena = self.builder.get_object("imgDebathena") |
---|
92 | # The workstation's hostname |
---|
93 | lblHostname = self.builder.get_object("lblHostname") |
---|
94 | lblHostname.set_text(LightDM.get_hostname()) |
---|
95 | # The buttons |
---|
96 | self.btnCancel = self.builder.get_object("btnCancel") |
---|
97 | self.btnLogin = self.builder.get_object("btnLogin") |
---|
98 | # The session combo box |
---|
99 | self.cmbSession = self.builder.get_object("cmbSession") |
---|
100 | self.sessionBox = self.builder.get_object("sessionBox") |
---|
101 | for s in LightDM.get_sessions(): |
---|
102 | self.cmbSession.append(s.get_key(), s.get_name()) |
---|
103 | # Select the first session |
---|
104 | # TODO: Select the configured default session or the user's session |
---|
105 | self.cmbSession.set_active(0) |
---|
106 | |
---|
107 | self.powerDlg = self.builder.get_object("powerDialog") |
---|
108 | # LightDM checks with PolKit for the various "get_can_foo()" functions |
---|
109 | self.builder.get_object("rbShutdown").set_sensitive(LightDM.get_can_shutdown()) |
---|
110 | self.builder.get_object("rbReboot").set_sensitive(LightDM.get_can_restart()) |
---|
111 | # We don't allow suspend/hibernate on cluster |
---|
112 | self.builder.get_object("rbHibernate").set_sensitive(LightDM.get_can_hibernate() and self.metapackage != "debathena-cluster") |
---|
113 | self.builder.get_object("rbSuspend").set_sensitive(LightDM.get_can_suspend() and self.metapackage != "debathena-cluster") |
---|
114 | |
---|
115 | self.loginNotebook = self.builder.get_object("notebook1") |
---|
116 | |
---|
117 | # Scaling factor for smaller displays |
---|
118 | logoScale = 0.75 if self.screenSize[1] <= 768 else 1.0 |
---|
119 | self.animate = self.setup_owl(logoScale) |
---|
120 | |
---|
121 | self.winLogin.set_position(Gtk.WindowPosition.CENTER) |
---|
122 | self.winLogin.show() |
---|
123 | self.initBackgroundWindow() |
---|
124 | self.initPanelWindow() |
---|
125 | self.initBrandingWindow() |
---|
126 | # Connect Gtk+ signal handlers |
---|
127 | self.builder.connect_signals(handlers) |
---|
128 | # GNOME 3 turns off button images by default. Turn it on |
---|
129 | # for the "Panel" window |
---|
130 | s = Gtk.Settings.get_default() |
---|
131 | s.set_property('gtk-button-images', True) |
---|
132 | # Set a cursor for the root window, otherwise there isn't one |
---|
133 | rw = Gdk.get_default_root_window() |
---|
134 | rw.set_cursor(Gdk.Cursor(Gdk.CursorType.LEFT_PTR)) |
---|
135 | self.noLoginMonitor = Gio.File.new_for_path(NOLOGIN_FILE).monitor_file(Gio.FileMonitorFlags.NONE, None) |
---|
136 | self.noLoginMonitor.connect("changed", self._file_changed) |
---|
137 | # Setup the login window for first login |
---|
138 | self.resetLoginWindow() |
---|
139 | |
---|
140 | def initBackgroundWindow(self): |
---|
141 | # The background image |
---|
142 | self.winBg = self.builder.get_object("winBg") |
---|
143 | self.imgBg = self.builder.get_object("imgBg") |
---|
144 | bg_pixbuf = GdkPixbuf.Pixbuf.new_from_file(BG_IMG_FILE) |
---|
145 | bg_scaled = bg_pixbuf.scale_simple(self.screenSize[0], self.screenSize[1], GdkPixbuf.InterpType.BILINEAR) |
---|
146 | self.imgBg.set_from_pixbuf(bg_scaled) |
---|
147 | self.winBg.show_all() |
---|
148 | |
---|
149 | def initPanelWindow(self): |
---|
150 | # A window that looks like the GNOME "panel" at the top of the screen |
---|
151 | self.winPanel = self.builder.get_object("winPanel") |
---|
152 | self.lblTime = self.builder.get_object("lblTime") |
---|
153 | self.winPanel.set_gravity(Gdk.Gravity.NORTH_WEST) |
---|
154 | self.winPanel.move(0,0) |
---|
155 | self.winPanel.set_size_request(self.screenSize[0], 2) |
---|
156 | self.winPanel.show_all() |
---|
157 | |
---|
158 | def initBrandingWindow(self): |
---|
159 | # The "branding window", in the bottom right |
---|
160 | winBranding = self.builder.get_object("winBranding") |
---|
161 | lblBranding = self.builder.get_object("lblBranding") |
---|
162 | arch = platform.machine() |
---|
163 | if arch != "x86_64": |
---|
164 | arch = "<b>" + arch + "</b>" |
---|
165 | # Possibly no longer needed, workaround for a Glade bug in Gtk+ 2 |
---|
166 | lblBranding.set_property('can_focus', False) |
---|
167 | winBranding.set_property('can_focus', False) |
---|
168 | lblBranding.set_markup(self.metapackage + "\n" + self.baseos + "\n" + arch) |
---|
169 | winBranding.set_gravity(Gdk.Gravity.SOUTH_EAST) |
---|
170 | width, height = winBranding.get_size() |
---|
171 | winBranding.move(self.screenSize[0] - width, self.screenSize[1] - height) |
---|
172 | winBranding.show_all() |
---|
173 | |
---|
174 | def showPowerDialog(self, widget): |
---|
175 | if self.powerDlg.run() == Gtk.ResponseType.OK: |
---|
176 | # Just do the action. The relevant buttons should be |
---|
177 | # greyed out for things we can't do. LightDM will |
---|
178 | # check with ConsoleKit anyway |
---|
179 | try: |
---|
180 | if self.builder.get_object("rbShutdown").get_active(): |
---|
181 | LightDM.shutdown() |
---|
182 | elif self.builder.get_object("rbReboot").get_active(): |
---|
183 | LightDM.restart() |
---|
184 | elif self.builder.get_object("rbHiberate").get_active(): |
---|
185 | LightDM.hibernate() |
---|
186 | elif self.builder.get_object("rbSuspend").get_active(): |
---|
187 | LightDM.suspend() |
---|
188 | except Glib.Gerror, e: |
---|
189 | self.errDialog(str(e)) |
---|
190 | self.powerDlg.hide() |
---|
191 | |
---|
192 | def showAccessDialog(self, widget): |
---|
193 | pass |
---|
194 | |
---|
195 | def _file_changed(self, monitor, file1, file2, evt_type): |
---|
196 | if evt_type == Gio.FileMonitorEvent.CREATED: |
---|
197 | self.loginNotebook.set_current_page(1) |
---|
198 | self.builder.get_object("lblUpdTime").set_text("Update started at %s" % (time.strftime("%Y-%m-%d %H:%M"))) |
---|
199 | if evt_type == Gio.FileMonitorEvent.DELETED: |
---|
200 | self.loginNotebook.set_current_page(0) |
---|
201 | |
---|
202 | # Update the time in the "panel" |
---|
203 | def updateTime(self): |
---|
204 | timeFmt="%a, %b %e %Y %l:%M" + ":%S" if self.timePedantry else "" |
---|
205 | # every second counts |
---|
206 | timeFmt=timeFmt + " %p" |
---|
207 | self.lblTime.set_text(time.strftime(timeFmt, time.localtime(time.time()))) |
---|
208 | return True |
---|
209 | |
---|
210 | # Reset the UI and prepare for a new login |
---|
211 | def resetLoginWindow(self): |
---|
212 | self.spin(False) |
---|
213 | self.clearMessage() |
---|
214 | self.btnCancel.hide() |
---|
215 | self.sessionBox.hide() |
---|
216 | self.prompted=False |
---|
217 | self.prompt_label.set_text("") |
---|
218 | self.prompt_entry.set_text("") |
---|
219 | self.prompt_box.hide() |
---|
220 | self.btnLogin.grab_focus() |
---|
221 | # Because there's no WM, we need to focus the actual X window |
---|
222 | Gdk.Window.focus(self.winLogin.get_window(), Gdk.CURRENT_TIME) |
---|
223 | |
---|
224 | def getSelectedSession(self): |
---|
225 | i = self.cmbSession.get_active_iter() |
---|
226 | session_name = self.cmbSession.get_model().get_value(i, 1) |
---|
227 | self._debug("selected session is " + session_name) |
---|
228 | return session_name |
---|
229 | |
---|
230 | def startOver(self): |
---|
231 | self.greeter.cancel_authentication() |
---|
232 | self.greeter.authenticate(None) |
---|
233 | |
---|
234 | # LightDM Callbacks |
---|
235 | # The workflow is this: |
---|
236 | # - call .authenticate() with a username |
---|
237 | # - lightdm responds with a prompt for password |
---|
238 | # - call .respond with whatever the user provides |
---|
239 | # - lightdm responds with authentication-complete |
---|
240 | # N.B. complete != successful |
---|
241 | # - .cancel_authentication will cancel the authentication in progress |
---|
242 | # call .authenticate with a new username to restart it |
---|
243 | # |
---|
244 | # Calling .authenticate with None (or NULL in C) will cause lightdm |
---|
245 | # to first prompt for a username, then a password. This means two |
---|
246 | # show-prompt callbacks and thus two .respond calls |
---|
247 | |
---|
248 | # This callback is called when the authentication process is |
---|
249 | # complete. "complete" means a username and password have been |
---|
250 | # received, and PAM has done its thing. "complete" does not |
---|
251 | # mean "successful". |
---|
252 | def cbAuthenticationComplete(self, greeter): |
---|
253 | self.spin(False) |
---|
254 | self._debug("cbAuthenticationComplete: received authentication-complete message") |
---|
255 | if greeter.get_is_authenticated(): |
---|
256 | self.spin(True) |
---|
257 | self._debug("Authentication was successful.") |
---|
258 | session_name = self.getSelectedSession() |
---|
259 | #FIXME: Make sure it's a valid session |
---|
260 | self._debug("User has selected '%s' session" % (session_name)) |
---|
261 | if not greeter.start_session_sync(session_name): |
---|
262 | self._debug("Failed to start session") |
---|
263 | print >> sys.stderr, "Failed to start session" |
---|
264 | else: |
---|
265 | self._debug("Authentication failed.") |
---|
266 | self.displayMessage("Authentication failed, please try again") |
---|
267 | self.greeter.authenticate(None) |
---|
268 | |
---|
269 | # The show-prompt message is emitted when LightDM wants you to |
---|
270 | # show a prompt to the user, and respond with the user's response. |
---|
271 | # Currently, the prompts we care about are "login:" and |
---|
272 | # "Password: " (yes, with the trailing space), which ask for the |
---|
273 | # username and password respectively. promptType is one of |
---|
274 | # LightDM.PromptType.SECRET or LightDM.PromptType.QUESTION, which |
---|
275 | # mean that the text of the user's response should or should not be |
---|
276 | # masked/invisible, respectively. |
---|
277 | |
---|
278 | def cbShowPrompt(self, greeter, text, promptType): |
---|
279 | self._debug("cbShowPrompt: Received show-prompt message: ", |
---|
280 | text, promptType) |
---|
281 | self.prompted=True |
---|
282 | # Make things pretty |
---|
283 | if text == "login:": |
---|
284 | text = "Username: " |
---|
285 | # Sanity check the username |
---|
286 | currUser = self.greeter.get_authentication_user() |
---|
287 | if currUser: |
---|
288 | self._debug("Current user being authenticated is " + currUser) |
---|
289 | # See if the user exists |
---|
290 | try: |
---|
291 | passwd=pwd.getpwnam(currUser) |
---|
292 | except KeyError: |
---|
293 | # Why are we not using the message label here? |
---|
294 | # Because what will happen is that someone will quickly |
---|
295 | # typo their username, and then type their password without |
---|
296 | # looking at the screen, which would otherwise result in the |
---|
297 | # window resetting after the first error, and then they end |
---|
298 | # up typing their password into the username box. |
---|
299 | self.errDialog("The username '%s' is invalid.\n\n(Tip: Please ensure you're typing your username in lowercase letters.\nDo not add '@mit.edu' or any other suffix to your username.)" % (currUser)) |
---|
300 | self.startOver() |
---|
301 | return True |
---|
302 | # There's probably a better way |
---|
303 | if passwd.pw_uid < MIN_UID: |
---|
304 | self.errDialog("Logging in as '%s' disallowed by configuation" % (currUser)) |
---|
305 | self.startOver() |
---|
306 | return True |
---|
307 | |
---|
308 | # Set the label to the value of the prompt |
---|
309 | self.prompt_label.set_text(text) |
---|
310 | # clear the entry and get focus |
---|
311 | self.prompt_entry.set_text("") |
---|
312 | self.prompt_entry.set_sensitive(True) |
---|
313 | self.prompt_box.show() |
---|
314 | self.prompt_entry.grab_focus() |
---|
315 | # Mask the input if requested |
---|
316 | if promptType == LightDM.PromptType.SECRET: |
---|
317 | self.prompt_entry.set_visibility(False) |
---|
318 | else: |
---|
319 | self.prompt_entry.set_visibility(True) |
---|
320 | self.spin(False) |
---|
321 | |
---|
322 | # show-message is emitted when LightDM has something to say to the user |
---|
323 | # Typically, these are emitted by PAM modules (e.g. pam_echo) |
---|
324 | # Note that this is _not_ "authentication failed" (unless a PAM module |
---|
325 | # specifically says that). |
---|
326 | # |
---|
327 | # The docs which say to check .is_authenticated() in the |
---|
328 | # authentication-complete callback to determine login success or |
---|
329 | # failure. |
---|
330 | # |
---|
331 | # messageType is one of LightDM.MessageType.{ERROR,INFO} |
---|
332 | def cbShowMessage(self, greeter, text, messageType): |
---|
333 | self._debug("cbShowMessage: Received show-messsage message", |
---|
334 | text, messageType) |
---|
335 | # TODO: Wrap text |
---|
336 | self.displayMessage(text) |
---|
337 | self.spin(False) |
---|
338 | |
---|
339 | def cbKeyPress(self, widget, event): |
---|
340 | if event.keyval == Gdk.KEY_Escape: |
---|
341 | self.cancelLogin(widget) |
---|
342 | |
---|
343 | def cancelLogin(self, widget=None): |
---|
344 | self._debug("Cancelling authentication. User=", |
---|
345 | self.greeter.get_authentication_user()) |
---|
346 | self.greeter.cancel_authentication() |
---|
347 | self.resetLoginWindow() |
---|
348 | |
---|
349 | def displayMessage(self, msg): |
---|
350 | self.message_label.set_text(msg) |
---|
351 | self.message_label.show() |
---|
352 | |
---|
353 | def clearMessage(self): |
---|
354 | self.message_label.set_text("") |
---|
355 | self.message_label.hide() |
---|
356 | |
---|
357 | def errDialog(self, errText): |
---|
358 | dlg = Gtk.MessageDialog(self.winLogin, |
---|
359 | Gtk.DialogFlags.DESTROY_WITH_PARENT, |
---|
360 | Gtk.MessageType.ERROR, |
---|
361 | Gtk.ButtonsType.CLOSE, |
---|
362 | errText) |
---|
363 | dlg.run() |
---|
364 | dlg.destroy() |
---|
365 | |
---|
366 | |
---|
367 | def spin(self, start): |
---|
368 | if start: |
---|
369 | self.loginSpinner.show() |
---|
370 | self.loginSpinner.start() |
---|
371 | else: |
---|
372 | self.loginSpinner.stop() |
---|
373 | self.loginSpinner.hide() |
---|
374 | |
---|
375 | # Some greeter implementations check .get_is_authenticated() here |
---|
376 | # and then start the session. I think that's only relevant |
---|
377 | # dealing with a user-picker and passwordless users (that is, you |
---|
378 | # would call .authenticate(joeuser), and then click the button, |
---|
379 | # and you'd just be logged in. But we disable the user picker, so |
---|
380 | # that's not relevant. |
---|
381 | def cbLogin(self, widget): |
---|
382 | self.clearMessage() |
---|
383 | self._debug("In cbLogin") |
---|
384 | if self.prompted: |
---|
385 | response = self.prompt_entry.get_text() |
---|
386 | self._debug("Sending response to prompt", response if self.prompt_entry.get_visibility() else "[redacted]") |
---|
387 | self.spin(True) |
---|
388 | self.greeter.respond(response) |
---|
389 | self.prompted=False |
---|
390 | else: |
---|
391 | self._debug("No prompt. Beginning new authentication process.") |
---|
392 | # Show the "Cancel" button" |
---|
393 | self.sessionBox.show() |
---|
394 | self.btnCancel.show() |
---|
395 | self.greeter.authenticate(None) |
---|
396 | |
---|
397 | # Load the Debathena owl image and generate self.logo_pixbufs, the list of |
---|
398 | # animation frames. Returns True if successful, False otherwise. |
---|
399 | def setup_owl(self,logoScale): |
---|
400 | self.logo_pixbufs = [] |
---|
401 | num_pixbufs = 0 |
---|
402 | # Eyes go closed. |
---|
403 | for img in DEBATHENA_LOGO_FILES: |
---|
404 | try: |
---|
405 | pixbuf = GdkPixbuf.Pixbuf.new_from_file(img) |
---|
406 | self.logo_pixbufs.append(pixbuf.scale_simple(int(pixbuf.get_width() * logoScale), int(pixbuf.get_height() * logoScale), GdkPixbuf.InterpType.BILINEAR)) |
---|
407 | num_pixbufs += 1 |
---|
408 | except Glib.Gerror, e: |
---|
409 | print >> sys.stderr, "Glib Error:", e |
---|
410 | return False |
---|
411 | # Eyes come open. |
---|
412 | for pixbuf in self.logo_pixbufs[::-1]: |
---|
413 | self.logo_pixbufs.append(pixbuf) |
---|
414 | num_pixbufs += 1 |
---|
415 | # Eyes stay open. |
---|
416 | self.logo_pixbufs.extend([None] * (self.animation_loop_frames - num_pixbufs)) |
---|
417 | self.img_idx = -1 |
---|
418 | # Set it to the first image so that the window can size itself |
---|
419 | # accordingly |
---|
420 | self.imgDebathena.set_from_pixbuf(self.logo_pixbufs[0]) |
---|
421 | self._debug("Owl setup done") |
---|
422 | return True |
---|
423 | |
---|
424 | def update_owl(self): |
---|
425 | if not self.animate: |
---|
426 | self._debug("Owl loading failed, ending update_owl timer") |
---|
427 | return False |
---|
428 | |
---|
429 | self.img_idx = (self.img_idx + 1) % self.animation_loop_frames |
---|
430 | pixbuf = self.logo_pixbufs[self.img_idx] |
---|
431 | if pixbuf is not None: |
---|
432 | self.imgDebathena.set_from_pixbuf(pixbuf) |
---|
433 | return True |
---|
434 | |
---|
435 | |
---|
436 | def get_workstation_information(self): |
---|
437 | try: |
---|
438 | self.metapackage = subprocess.Popen(["machtype", "-L"], stdout=subprocess.PIPE).communicate()[0].rstrip() |
---|
439 | except OSError: |
---|
440 | self.metapackage = '(unknown metapackage)' |
---|
441 | try: |
---|
442 | self.baseos = subprocess.Popen(["machtype", "-E"], stdout=subprocess.PIPE).communicate()[0].rstrip() |
---|
443 | except OSError: |
---|
444 | self.baseos = '(unknown OS)' |
---|
445 | |
---|
446 | |
---|
447 | |
---|
448 | |
---|
449 | if __name__ == '__main__': |
---|
450 | parser = OptionParser() |
---|
451 | parser.set_defaults(debug=False) |
---|
452 | parser.add_option("--debug", action="store_true", dest="debug") |
---|
453 | parser.add_option("--ui", action="store", type="string", |
---|
454 | default=UI_FILE, dest="ui_file") |
---|
455 | (options, args) = parser.parse_args() |
---|
456 | Gtk.init(None); |
---|
457 | main_loop = GObject.MainLoop () |
---|
458 | dagreeter = DebathenaGreeter(options) |
---|
459 | # Add a timeout for the owl animation |
---|
460 | GObject.timeout_add(50, dagreeter.update_owl) |
---|
461 | # Add a timeout for the clock in the panel |
---|
462 | GObject.timeout_add(30, dagreeter.updateTime) |
---|
463 | |
---|
464 | main_loop.run () |
---|