Delay opening the key confirmation box
[zeroinstall.git] / zeroinstall / injector / handler.py
blob96bbfc7db0895dca589d44c7be27ce830d69b3e4
1 """
2 Integrates download callbacks with an external mainloop.
3 While things are being downloaded, Zero Install returns control to your program.
4 Your mainloop is responsible for monitoring the state of the downloads and notifying
5 Zero Install when they are complete.
7 To do this, you supply a L{Handler} to the L{policy}.
8 """
10 # Copyright (C) 2009, Thomas Leonard
11 # See the README file for details, or visit http://0install.net.
13 from zeroinstall import _
14 import sys
15 from logging import debug, warn, info
17 from zeroinstall import NeedDownload, SafeException
18 from zeroinstall.support import tasks
19 from zeroinstall.injector import download
21 KEY_INFO_TIMEOUT = 10 # Maximum time to wait for response from key-info-server
23 class NoTrustedKeys(SafeException):
24 """Thrown by L{Handler.confirm_trust_keys} on failure."""
25 pass
27 class Handler(object):
28 """
29 This implementation uses the GLib mainloop. Note that QT4 can use the GLib mainloop too.
31 @ivar monitored_downloads: dict of downloads in progress
32 @type monitored_downloads: {URL: L{download.Download}}
33 @ivar n_completed_downloads: number of downloads which have finished for GUIs, etc (can be reset as desired).
34 @type n_completed_downloads: int
35 @ivar total_bytes_downloaded: informational counter for GUIs, etc (can be reset as desired). Updated when download finishes.
36 @type total_bytes_downloaded: int
37 @ivar dry_run: instead of starting a download, just report what we would have downloaded
38 @type dry_run: bool
39 """
41 __slots__ = ['monitored_downloads', '_loop', 'dry_run', 'total_bytes_downloaded', 'n_completed_downloads', '_current_confirm']
43 def __init__(self, mainloop = None, dry_run = False):
44 self.monitored_downloads = {}
45 self._loop = None
46 self.dry_run = dry_run
47 self.n_completed_downloads = 0
48 self.total_bytes_downloaded = 0
49 self._current_confirm = None
51 def monitor_download(self, dl):
52 """Called when a new L{download} is started.
53 This is mainly used by the GUI to display the progress bar."""
54 dl.start()
55 self.monitored_downloads[dl.url] = dl
56 self.downloads_changed()
58 @tasks.async
59 def download_done_stats():
60 yield dl.downloaded
61 # NB: we don't check for exceptions here; someone else should be doing that
62 try:
63 self.n_completed_downloads += 1
64 self.total_bytes_downloaded += dl.get_bytes_downloaded_so_far()
65 del self.monitored_downloads[dl.url]
66 self.downloads_changed()
67 except Exception, ex:
68 self.report_error(ex)
69 download_done_stats()
71 def impl_added_to_store(self, impl):
72 """Called by the L{fetch.Fetcher} when adding an implementation.
73 The GUI uses this to update its display.
74 @param impl: the implementation which has been added
75 @type impl: L{model.Implementation}
76 """
77 pass
79 def downloads_changed(self):
80 """This is just for the GUI to override to update its display."""
81 pass
83 def wait_for_blocker(self, blocker):
84 """Run a recursive mainloop until blocker is triggered.
85 @param blocker: event to wait on
86 @type blocker: L{tasks.Blocker}"""
87 if not blocker.happened:
88 import gobject
90 def quitter():
91 yield blocker
92 self._loop.quit()
93 tasks.Task(quitter(), "quitter")
95 assert self._loop is None # Avoid recursion
96 self._loop = gobject.MainLoop(gobject.main_context_default())
97 try:
98 debug(_("Entering mainloop, waiting for %s"), blocker)
99 self._loop.run()
100 finally:
101 self._loop = None
103 assert blocker.happened, "Someone quit the main loop!"
105 tasks.check(blocker)
107 def get_download(self, url, force = False, hint = None, factory = None):
108 """Return the Download object currently downloading 'url'.
109 If no download for this URL has been started, start one now (and
110 start monitoring it).
111 If the download failed and force is False, return it anyway.
112 If force is True, abort any current or failed download and start
113 a new one.
114 @rtype: L{download.Download}
116 if self.dry_run:
117 raise NeedDownload(url)
119 try:
120 dl = self.monitored_downloads[url]
121 if dl and force:
122 dl.abort()
123 raise KeyError
124 except KeyError:
125 if factory is None:
126 dl = download.Download(url, hint)
127 else:
128 dl = factory(url, hint)
129 self.monitor_download(dl)
130 return dl
132 def confirm_keys(self, pending, fetch_key_info):
133 """We don't trust any of the signatures yet. Ask the user.
134 When done update the L{trust} database, and then call L{trust.TrustDB.notify}.
135 This method just calls L{confirm_import_feed} if the handler (self) is
136 new-style, or L{confirm_trust_keys} for older classes. A class
137 is considered old-style if it overrides confirm_trust_keys and
138 not confirm_import_feed.
139 @since: 0.42
140 @arg pending: an object holding details of the updated feed
141 @type pending: L{PendingFeed}
142 @arg fetch_key_info: a function which can be used to fetch information about a key fingerprint
143 @type fetch_key_info: str -> L{Blocker}
144 @return: A blocker that triggers when the user has chosen, or None if already done.
145 @rtype: None | L{Blocker}"""
147 assert pending.sigs
149 if hasattr(self.confirm_trust_keys, 'original') or not hasattr(self.confirm_import_feed, 'original'):
150 # new-style class
151 from zeroinstall.injector import gpg
152 valid_sigs = [s for s in pending.sigs if isinstance(s, gpg.ValidSig)]
153 if not valid_sigs:
154 def format_sig(sig):
155 msg = str(sig)
156 if sig.messages:
157 msg += "\nMessages from GPG:\n" + sig.messages
158 return msg
159 raise SafeException(_('No valid signatures found on "%(url)s". Signatures:%(signatures)s') %
160 {'url': pending.url, 'signatures': ''.join(['\n- ' + format_sig(s) for s in pending.sigs])})
162 # Start downloading information about the keys...
163 kfs = {}
164 for sig in valid_sigs:
165 kfs[sig] = fetch_key_info(sig.fingerprint)
167 return self._queue_confirm_import_feed(pending, kfs)
168 else:
169 # old-style class
170 from zeroinstall.injector import iface_cache
171 import warnings
172 warnings.warn("Should override confirm_import_feed(); using old confirm_trust_keys() for now", DeprecationWarning, stacklevel = 2)
174 iface = iface_cache.iface_cache.get_interface(pending.url)
175 return self.confirm_trust_keys(iface, pending.sigs, pending.new_xml)
177 @tasks.async
178 def _queue_confirm_import_feed(self, pending, valid_sigs):
179 # Wait up to KEY_INFO_TIMEOUT seconds for key information to arrive. Avoids having the dialog
180 # box update while the user is looking at it, and may allow it to be skipped completely in some
181 # cases.
182 timeout = tasks.TimeoutBlocker(KEY_INFO_TIMEOUT, "key info timeout")
183 while True:
184 key_info_blockers = [sig_info.blocker for sig_info in valid_sigs.values() if sig_info.blocker is not None]
185 if not key_info_blockers:
186 break
187 info("Waiting for response from key-info server: %s", key_info_blockers)
188 yield [timeout] + key_info_blockers
189 if timeout.happened:
190 info("Timeout waiting for key info response")
191 break
193 # If we're already confirming something else, wait for that to finish...
194 while self._current_confirm is not None:
195 info("Waiting for previous key confirmations to finish")
196 yield self._current_confirm
198 # Check whether we still need to confirm. The user may have
199 # already approved one of the keys while dealing with another
200 # feed.
201 from zeroinstall.injector import trust
202 domain = trust.domain_from_url(pending.url)
203 for sig in valid_sigs:
204 is_trusted = trust.trust_db.is_trusted(sig.fingerprint, domain)
205 if is_trusted:
206 return
208 # Take the lock and confirm this feed
209 self._current_confirm = lock = tasks.Blocker('confirm key lock')
210 try:
211 done = self.confirm_import_feed(pending, valid_sigs)
212 if done is not None:
213 yield done
214 tasks.check(done)
215 finally:
216 self._current_confirm = None
217 lock.trigger()
219 @tasks.async
220 def confirm_import_feed(self, pending, valid_sigs):
221 """Sub-classes should override this method to interact with the user about new feeds.
222 If multiple feeds need confirmation, L{confirm_keys} will only invoke one instance of this
223 method at a time.
224 @param pending: the new feed to be imported
225 @type pending: L{PendingFeed}
226 @param valid_sigs: maps signatures to a list of fetchers collecting information about the key
227 @type valid_sigs: {L{gpg.ValidSig} : L{fetch.KeyInfoFetcher}}
228 @since: 0.42
229 @see: L{confirm_keys}"""
230 from zeroinstall.injector import trust
232 assert valid_sigs
234 domain = trust.domain_from_url(pending.url)
236 # Ask on stderr, because we may be writing XML to stdout
237 print >>sys.stderr, _("Feed: %s") % pending.url
238 print >>sys.stderr, _("The feed is correctly signed with the following keys:")
239 for x in valid_sigs:
240 print >>sys.stderr, "-", x
242 def text(parent):
243 text = ""
244 for node in parent.childNodes:
245 if node.nodeType == node.TEXT_NODE:
246 text = text + node.data
247 return text
249 shown = set()
250 key_info_fetchers = valid_sigs.values()
251 while key_info_fetchers:
252 old_kfs = key_info_fetchers
253 key_info_fetchers = []
254 for kf in old_kfs:
255 infos = set(kf.info) - shown
256 if infos:
257 if len(valid_sigs) > 1:
258 print "%s: " % kf.fingerprint
259 for key_info in infos:
260 print >>sys.stderr, "-", text(key_info)
261 shown.add(key_info)
262 if kf.blocker:
263 key_info_fetchers.append(kf)
264 if key_info_fetchers:
265 for kf in key_info_fetchers: print >>sys.stderr, kf.status
266 stdin = tasks.InputBlocker(0, 'console')
267 blockers = [kf.blocker for kf in key_info_fetchers] + [stdin]
268 yield blockers
269 for b in blockers:
270 try:
271 tasks.check(b)
272 except Exception, ex:
273 warn(_("Failed to get key info: %s"), ex)
274 if stdin.happened:
275 print >>sys.stderr, _("Skipping remaining key lookups due to input from user")
276 break
278 if len(valid_sigs) == 1:
279 print >>sys.stderr, _("Do you want to trust this key to sign feeds from '%s'?") % domain
280 else:
281 print >>sys.stderr, _("Do you want to trust all of these keys to sign feeds from '%s'?") % domain
282 while True:
283 print >>sys.stderr, _("Trust [Y/N] "),
284 i = raw_input()
285 if not i: continue
286 if i in 'Nn':
287 raise NoTrustedKeys(_('Not signed with a trusted key'))
288 if i in 'Yy':
289 break
290 for key in valid_sigs:
291 print >>sys.stderr, _("Trusting %(key_fingerprint)s for %(domain)s") % {'key_fingerprint': key.fingerprint, 'domain': domain}
292 trust.trust_db.trust_key(key.fingerprint, domain)
294 confirm_import_feed.original = True
296 def confirm_trust_keys(self, interface, sigs, iface_xml):
297 """We don't trust any of the signatures yet. Ask the user.
298 When done update the L{trust} database, and then call L{trust.TrustDB.notify}.
299 @deprecated: see L{confirm_keys}
300 @arg interface: the interface being updated
301 @arg sigs: a list of signatures (from L{gpg.check_stream})
302 @arg iface_xml: the downloaded data (not yet trusted)
303 @return: a blocker, if confirmation will happen asynchronously, or None
304 @rtype: L{tasks.Blocker}"""
305 import warnings
306 warnings.warn("Use confirm_keys, not confirm_trust_keys", DeprecationWarning, stacklevel = 2)
307 from zeroinstall.injector import trust, gpg
308 assert sigs
309 valid_sigs = [s for s in sigs if isinstance(s, gpg.ValidSig)]
310 if not valid_sigs:
311 raise SafeException('No valid signatures found on "%s". Signatures:%s' %
312 (interface.uri, ''.join(['\n- ' + str(s) for s in sigs])))
314 domain = trust.domain_from_url(interface.uri)
316 # Ask on stderr, because we may be writing XML to stdout
317 print >>sys.stderr, "\nInterface:", interface.uri
318 print >>sys.stderr, _("The feed is correctly signed with the following keys:")
319 for x in valid_sigs:
320 print >>sys.stderr, "-", x
322 if len(valid_sigs) == 1:
323 print >>sys.stderr, _("Do you want to trust this key to sign feeds from '%s'?") % domain
324 else:
325 print >>sys.stderr, _("Do you want to trust all of these keys to sign feeds from '%s'?") % domain
326 while True:
327 print >>sys.stderr, _("Trust [Y/N] "),
328 i = raw_input()
329 if not i: continue
330 if i in 'Nn':
331 raise NoTrustedKeys(_('Not signed with a trusted key'))
332 if i in 'Yy':
333 break
334 for key in valid_sigs:
335 print >>sys.stderr, _("Trusting %s for %s") % (key.fingerprint, domain)
336 trust.trust_db.trust_key(key.fingerprint, domain)
338 trust.trust_db.notify()
340 confirm_trust_keys.original = True # Detect if someone overrides it
342 @tasks.async
343 def confirm_install(self, msg):
344 """We need to check something with the user before continuing with the install.
345 @raise download.DownloadAborted: if the user cancels"""
346 yield
347 print >>sys.stderr, msg
348 while True:
349 sys.stderr.write(_("Install [Y/N] "))
350 i = raw_input()
351 if not i: continue
352 if i in 'Nn':
353 raise download.DownloadAborted()
354 if i in 'Yy':
355 break
357 def report_error(self, exception, tb = None):
358 """Report an exception to the user.
359 @param exception: the exception to report
360 @type exception: L{SafeException}
361 @param tb: optional traceback
362 @since: 0.25"""
363 warn("%s", str(exception) or type(exception))
364 #import traceback
365 #traceback.print_exception(exception, None, tb)
367 class ConsoleHandler(Handler):
368 """A Handler that displays progress on stdout (a tty).
369 @since: 0.44"""
370 last_msg_len = None
371 update = None
372 disable_progress = 0
373 screen_width = None
375 def downloads_changed(self):
376 import gobject
377 if self.monitored_downloads and self.update is None:
378 if self.screen_width is None:
379 try:
380 import curses
381 curses.setupterm()
382 self.screen_width = curses.tigetnum('cols') or 80
383 except Exception, ex:
384 info("Failed to initialise curses library: %s", ex)
385 self.screen_width = 80
386 self.show_progress()
387 self.update = gobject.timeout_add(200, self.show_progress)
388 elif len(self.monitored_downloads) == 0:
389 if self.update:
390 gobject.source_remove(self.update)
391 self.update = None
392 print
393 self.last_msg_len = None
395 def show_progress(self):
396 urls = self.monitored_downloads.keys()
397 if not urls: return True
399 if self.disable_progress: return True
401 screen_width = self.screen_width - 2
402 item_width = max(16, screen_width / len(self.monitored_downloads))
403 url_width = item_width - 7
405 msg = ""
406 for url in sorted(urls):
407 dl = self.monitored_downloads[url]
408 so_far = dl.get_bytes_downloaded_so_far()
409 leaf = url.rsplit('/', 1)[-1]
410 if len(leaf) >= url_width:
411 display = leaf[:url_width]
412 else:
413 display = url[-url_width:]
414 if dl.expected_size:
415 msg += "[%s %d%%] " % (display, int(so_far * 100 / dl.expected_size))
416 else:
417 msg += "[%s] " % (display)
418 msg = msg[:screen_width]
420 if self.last_msg_len is None:
421 sys.stdout.write(msg)
422 else:
423 sys.stdout.write(chr(13) + msg)
424 if len(msg) < self.last_msg_len:
425 sys.stdout.write(" " * (self.last_msg_len - len(msg)))
427 self.last_msg_len = len(msg)
428 sys.stdout.flush()
430 return True
432 def clear_display(self):
433 if self.last_msg_len != None:
434 sys.stdout.write(chr(13) + " " * self.last_msg_len + chr(13))
435 sys.stdout.flush()
436 self.last_msg_len = None
438 def report_error(self, exception, tb = None):
439 self.clear_display()
440 Handler.report_error(self, exception, tb)
442 def confirm_import_feed(self, pending, valid_sigs):
443 self.clear_display()
444 self.disable_progress += 1
445 blocker = Handler.confirm_import_feed(self, pending, valid_sigs)
446 @tasks.async
447 def enable():
448 yield blocker
449 self.disable_progress -= 1
450 self.show_progress()
451 enable()
452 return blocker