Fixed a bug in the handling of version numbers we can't parse.
[0publish-gui.git] / main.py
blobc7e02b995b68c7b1325a34d8ea763c4aad98468a
1 from xml.dom import Node, minidom
3 import rox, os, pango, sys, textwrap, traceback, subprocess, time, urlparse, shutil
4 from rox import g, tasks, loading
5 import gtk.glade
7 import signing
8 import archive
9 from implementation import ImplementationProperties
10 from requires import Requires
11 from xmltools import *
13 from zeroinstall.injector import model
14 from zeroinstall.zerostore import unpack, Stores
16 RESPONSE_SAVE = 0
17 RESPONSE_SAVE_AND_TEST = 1
19 xml_header = """<?xml version="1.0" ?>
20 """
21 xml_stylesheet_header = """<?xml-stylesheet type='text/xsl' href='interface.xsl'?>
22 """
24 gladefile = os.path.join(rox.app_dir, '0publish-gui.glade')
26 # Zero Install implementation cache
27 stores = Stores()
29 stylesheet_src = os.path.join(os.path.dirname(__file__), 'interface.xsl')
31 def available_in_path(prog):
32 for d in os.environ['PATH'].split(':'):
33 path = os.path.join(d, prog)
34 if os.path.isfile(path):
35 return True
36 return False
38 def get_terminal_emulator():
39 terminal_emulators = ['x-terminal-emulator', 'xterm', 'konsole']
40 for xterm in terminal_emulators:
41 if available_in_path(xterm):
42 return xterm
43 return 'xterm' # Hope
45 def choose_feed():
46 tree = gtk.glade.XML(gladefile, 'no_file_specified')
47 box = tree.get_widget('no_file_specified')
48 tree.get_widget('new_button').grab_focus()
49 resp = box.run()
50 box.destroy()
51 if resp == 0:
52 chooser = g.FileChooserDialog('Choose a location for the new feed',
53 None, g.FILE_CHOOSER_ACTION_SAVE)
54 chooser.set_current_name('MyProg.xml')
55 chooser.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
56 chooser.add_button(g.STOCK_NEW, g.RESPONSE_OK)
57 elif resp == 1:
58 chooser = g.FileChooserDialog('Choose the feed to edit',
59 None, g.FILE_CHOOSER_ACTION_OPEN)
60 chooser.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
61 chooser.add_button(g.STOCK_OPEN, g.RESPONSE_OK)
62 else:
63 sys.exit(1)
64 chooser.set_default_response(g.RESPONSE_OK)
65 if chooser.run() != g.RESPONSE_OK:
66 sys.exit(1)
67 path = chooser.get_filename()
68 chooser.destroy()
69 return FeedEditor(path)
71 def combo_set_text(combo, text):
72 if combo.get_active_text() or text:
73 model = combo.get_model()
74 i = 0
75 for row in model:
76 if row[0] == text:
77 combo.set_active(i)
78 return
79 i += 1
80 combo.append_text(text)
81 combo.set_active(i)
82 else:
83 return
85 def list_attrs(element):
86 attrs = element.attributes
87 names = []
88 for x in range(attrs.length):
89 attr = attrs.item(x)
91 if attr.name in ['id', 'version-modifier']: continue
92 if element.localName == 'implementation' and attr.name == 'version': continue
94 if attr.name in ('stability', 'arch'):
95 names.append(attr.value)
96 else:
97 names.append(attr.name)
98 if names:
99 return ' (%s)' % ', '.join(names)
100 else:
101 return ''
103 emptyFeed = """<?xml version='1.0'?>
104 <interface xmlns="%s">
105 <name>Name</name>
106 <group/>
107 </interface>
108 """ % (XMLNS_INTERFACE)
110 element_target = ('INTERNAL:FeedEditor/Element', gtk.TARGET_SAME_WIDGET, 0)
112 class FeedEditor(loading.XDSLoader):
113 def __init__(self, pathname):
114 loading.XDSLoader.__init__(self, None)
116 self.pathname = pathname
118 self.wTree = gtk.glade.XML(gladefile, 'main')
119 self.window = self.wTree.get_widget('main')
120 self.window.connect('destroy', rox.toplevel_unref)
121 self.xds_proxy_for(self.window)
123 help = gtk.glade.XML(gladefile, 'main_help')
124 help_box = help.get_widget('main_help')
125 help_box.set_default_size(g.gdk.screen_width() / 4,
126 g.gdk.screen_height() / 4)
127 help_box.connect('delete-event', lambda box, ev: True)
128 help_box.connect('response', lambda box, resp: box.hide())
130 def resp(box, resp):
131 if resp == g.RESPONSE_HELP:
132 help_box.present()
133 elif resp == RESPONSE_SAVE_AND_TEST:
134 self.save(self.test)
135 elif resp == RESPONSE_SAVE:
136 self.save()
137 else:
138 box.destroy()
139 self.window.connect('response', resp)
140 rox.toplevel_ref()
142 key_menu = self.wTree.get_widget('feed_key')
143 key_model = g.ListStore(str, str)
144 key_menu.set_model(key_model)
145 cell = g.CellRendererText()
147 if gtk.pygtk_version >= (2, 8, 0):
148 # Crashes with pygtk 2.6.1
149 cell.set_property('ellipsize', pango.ELLIPSIZE_MIDDLE)
151 key_menu.pack_start(cell)
152 key_menu.add_attribute(cell, 'text', 1)
154 self.update_key_model()
156 self.impl_model = g.TreeStore(str, object)
157 impl_tree = self.wTree.get_widget('impl_tree')
158 impl_tree.set_model(self.impl_model)
159 text = g.CellRendererText()
160 column = g.TreeViewColumn('', text)
161 column.add_attribute(text, 'text', 0)
162 impl_tree.append_column(column)
164 impl_tree.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, [element_target], gtk.gdk.ACTION_MOVE)
165 impl_tree.enable_model_drag_dest([element_target], gtk.gdk.ACTION_MOVE)
167 sel = impl_tree.get_selection()
168 sel.set_mode(g.SELECTION_BROWSE)
170 if os.path.exists(self.pathname):
171 data, _, self.key = signing.check_signature(self.pathname)
172 self.doc = minidom.parseString(data)
173 self.update_fields()
175 # Default to showing the versions tab
176 self.wTree.get_widget('notebook').next_page()
177 else:
178 default_name = os.path.basename(self.pathname)
179 if default_name.endswith('.xml'):
180 default_name = default_name[:-4]
181 self.doc = minidom.parseString(emptyFeed)
182 self.key = None
183 key_menu.set_active(0)
185 self.update_fields()
186 self.wTree.get_widget('feed_name').set_text(default_name)
188 root = self.impl_model.get_iter_root()
189 if root:
190 sel.select_iter(root)
192 self.wTree.get_widget('generate_key').connect('clicked', lambda b: self.generate_key())
194 self.wTree.get_widget('add_implementation').connect('clicked', lambda b: self.add_version())
195 self.wTree.get_widget('add_archive').connect('clicked', lambda b: self.add_archive())
196 self.wTree.get_widget('add_requires').connect('clicked', lambda b: self.add_requires())
197 self.wTree.get_widget('add_group').connect('clicked', lambda b: self.add_group())
198 self.wTree.get_widget('edit_properties').connect('clicked', lambda b: self.edit_properties())
199 self.wTree.get_widget('remove').connect('clicked', lambda b: self.remove_version())
200 impl_tree.connect('row-activated', lambda tv, path, col: self.edit_properties(path))
201 impl_tree.connect('drag-data-received', self.tree_drag_data_received)
203 def update_key_model(self):
204 key_menu = self.wTree.get_widget('feed_key')
205 key_model = key_menu.get_model()
206 keys = signing.get_secret_keys()
207 key_model.clear()
208 key_model.append((None, '(unsigned)'))
209 for k in keys:
210 key_model.append(k)
212 def generate_key(self):
213 for x in ['xterm', 'konsole']:
214 if available_in_path(x):
215 child = subprocess.Popen([x, '-e', 'gpg', '--gen-key'], stderr = subprocess.PIPE)
216 break
217 else:
218 child = subprocess.Popen(['gnome-terminal', '-e', 'gpg --gen-key'], stderr = subprocess.PIPE)
220 def get_keygen_out():
221 errors = ''
222 while True:
223 yield signing.InputBlocker(child.stderr)
224 data = os.read(child.stderr.fileno(), 100)
225 if not data:
226 break
227 errors += data
228 self.update_key_model()
229 if errors:
230 rox.alert('Errors from terminal: %s' % errors)
232 tasks.Task(get_keygen_out())
234 def tree_drag_data_received(self, treeview, context, x, y, selection, info, time):
235 if not selection: return
236 drop_info = treeview.get_dest_row_at_pos(x, y)
237 if drop_info:
238 model = treeview.get_model()
239 path, position = drop_info
241 src = self.get_selected()
242 dest = model[path][1]
244 def is_ancestor_or_self(a, b):
245 while b:
246 if b is a: return True
247 b = b.parentNode
248 return False
250 if is_ancestor_or_self(src, dest):
251 # Can't move an element into itself!
252 return
254 if position in (gtk.TREE_VIEW_DROP_BEFORE, gtk.TREE_VIEW_DROP_AFTER):
255 new_parent = dest.parentNode
256 else:
257 new_parent = dest
259 if src.namespaceURI != XMLNS_INTERFACE: return
260 if new_parent.namespaceURI != XMLNS_INTERFACE: return
262 if new_parent.localName == 'group':
263 if src.localName not in ('implementation', 'group', 'requires'):
264 return
265 elif new_parent.localName == 'interface':
266 if src.localName not in ('implementation', 'group'):
267 return
268 elif new_parent.localName == 'implementation':
269 if src.localName not in ['requires']:
270 return
271 else:
272 return
274 remove_element(src)
276 if position == gtk.TREE_VIEW_DROP_BEFORE:
277 insert_before(src, dest)
278 elif position == gtk.TREE_VIEW_DROP_AFTER:
279 next = dest.nextSibling
280 while next and not next.nodeType == Node.ELEMENT_NODE:
281 next = next.nextSibling
282 if next:
283 insert_before(src, next)
284 else:
285 insert_element(src, new_parent)
286 else:
287 for next in child_elements(new_parent):
288 insert_before(src, next)
289 break
290 else:
291 insert_element(src, new_parent)
292 self.update_version_model()
294 def add_version(self):
295 ImplementationProperties(self)
297 def add_group(self):
298 ImplementationProperties(self, is_group = True)
300 def add_requires(self):
301 elem = self.get_selected()
302 if elem.namespaceURI == XMLNS_INTERFACE:
303 if elem.localName not in ('group', 'implementation'):
304 elem = elem.parentNode
305 if elem.localName in ('group', 'implementation'):
306 Requires(self, parent = elem)
307 return
308 rox.alert('Select a group or implementation!')
310 def edit_properties(self, path = None, element = None):
311 assert not (path and element)
313 if element:
314 pass
315 elif path is None:
316 element = self.get_selected()
317 else:
318 element = self.impl_model[path][1]
320 if element.namespaceURI != XMLNS_INTERFACE:
321 rox.alert("Sorry, I don't known how to edit %s elements!" % element.namespaceURI)
323 if element.localName in ('group', 'implementation'):
324 ImplementationProperties(self, element)
325 elif element.localName == 'requires':
326 Requires(self, parent = element.parentNode, element = element)
327 else:
328 rox.alert("Sorry, I don't known how to edit %s elements!" % element.localName)
330 def update_fields(self):
331 root = self.doc.documentElement
333 def set(name):
334 value = singleton_text(root, name)
335 if value:
336 self.wTree.get_widget('feed_' + name).set_text(value)
337 set('name')
338 set('summary')
339 set('homepage')
341 needs_terminal = len(list(children(root, 'needs-terminal'))) > 0
342 self.wTree.get_widget('feed_needs_terminal').set_active(needs_terminal)
344 category_widget = self.wTree.get_widget('feed_category')
345 category = singleton_text(root, 'category')
346 if category:
347 combo_set_text(category_widget, category)
348 else:
349 category_widget.set_active(0)
351 uri = root.getAttribute('uri')
352 if uri:
353 self.wTree.get_widget('feed_url').set_text(uri)
355 for feed_for in children(root, 'feed-for'):
356 self.wTree.get_widget('feed_feed_for').set_text(feed_for.getAttribute('interface'))
358 for icon in children(root, 'icon'):
359 if icon.getAttribute('type') == 'image/png':
360 href = icon.getAttribute('href')
361 self.wTree.get_widget('feed_icon').set_text(href)
362 break
364 description = singleton_text(root, 'description') or ''
365 paragraphs = [format_para(p) for p in description.split('\n\n')]
366 buffer = self.wTree.get_widget('feed_description').get_buffer()
367 buffer.delete(buffer.get_start_iter(), buffer.get_end_iter())
368 buffer.insert_at_cursor('\n'.join(paragraphs))
370 key_menu = self.wTree.get_widget('feed_key')
371 model = key_menu.get_model()
372 if self.key:
373 i = 0
374 for line in model:
375 if line[0] == self.key:
376 break
377 i += 1
378 else:
379 model.append((self.key, 'Missing key (%s)' % self.key))
380 key_menu.set_active(i)
381 else:
382 key_menu.set_active(0)
384 self.update_version_model()
386 def add_archives(self, impl_element, iter):
387 for child in child_elements(impl_element):
388 if child.namespaceURI != XMLNS_INTERFACE: continue
389 if child.localName == 'archive':
390 self.impl_model.append(iter, ['Archive ' + child.getAttribute('href'), child])
391 elif child.localName == 'requires':
392 req_iface = child.getAttribute('interface')
393 self.impl_model.append(iter, ['Impl requires %s' % req_iface, child])
394 else:
395 self.impl_model.append(iter, ['<%s>' % child.localName, child])
397 def update_version_model(self):
398 impl_tree = self.wTree.get_widget('impl_tree')
400 # Remember which ones are open
401 expanded_elements = set()
402 impl_tree.map_expanded_rows(lambda tv, path: expanded_elements.add(self.impl_model[path][1]))
404 initial_build = not self.impl_model.get_iter_root()
406 self.impl_model.clear()
407 to_expand = []
409 def add_impls(elem, iter, attrs):
410 """Add all groups, implementations and requirements in elem"""
412 for x in child_elements(elem):
413 if x.namespaceURI != XMLNS_INTERFACE: continue
415 if x.localName == 'requires':
416 req_iface = x.getAttribute('interface')
417 new = self.impl_model.append(iter, ['Group requires %s' % req_iface, x])
419 if x.localName not in ('implementation', 'group'): continue
421 new_attrs = attrs.copy()
422 attributes = x.attributes
423 for i in range(attributes.length):
424 a = attributes.item(i)
425 new_attrs[str(a.name)] = a.value
427 if x.localName == 'implementation':
428 version = new_attrs.get('version', '(missing version number)') + \
429 (new_attrs.get('version-modifier') or '')
430 new = self.impl_model.append(iter, ['Version %s%s' % (version, list_attrs(x)), x])
431 self.add_archives(x, new)
432 elif x.localName == 'group':
433 new = self.impl_model.append(iter, ['Group%s' % list_attrs(x), x])
434 if initial_build:
435 expanded_elements.add(x)
436 add_impls(x, new, new_attrs)
438 add_impls(self.doc.documentElement, None, attrs = {})
440 def may_expand(model, path, iter):
441 if model[iter][1] in expanded_elements:
442 impl_tree.expand_row(path, False)
443 self.impl_model.foreach(may_expand)
445 def test(self, args = []):
446 child = os.fork()
447 if child == 0:
448 try:
449 try:
450 # We are the child
451 # Spawn a grandchild and exit
452 command = ['0launch', '--gui'] + args + [self.pathname]
453 if self.wTree.get_widget('feed_needs_terminal').get_active():
454 command = [get_terminal_emulator(), '-e'] + command
455 subprocess.Popen(command)
456 os._exit(0)
457 except:
458 traceback.print_exc()
459 finally:
460 os._exit(1)
461 pid, status = os.waitpid(child, 0)
462 assert pid == child
463 if status:
464 raise Exception('Failed to run 0launch - status code %d' % status)
466 def test_compile(self, args = []):
467 child = os.fork()
468 if child == 0:
469 try:
470 try:
471 # We are the child
472 # Spawn a grandchild and exit
473 subprocess.Popen(['0launch',
474 'http://0install.net/2006/interfaces/0compile.xml', 'gui'] +
475 args + [self.pathname])
476 os._exit(0)
477 except:
478 traceback.print_exc()
479 finally:
480 os._exit(1)
481 pid, status = os.waitpid(child, 0)
482 assert pid == child
483 if status:
484 raise Exception('Failed to run 0compile - status code %d' % status)
486 def update_doc(self):
487 root = self.doc.documentElement
488 def update(name, required = False, attrs = {}, value_attr = None):
489 widget = self.wTree.get_widget('feed_' + name.replace('-', '_'))
490 if isinstance(widget, g.TextView):
491 buffer = widget.get_buffer()
492 text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter())
493 paras = ['\n'.join(textwrap.wrap(para, 80)) for para in text.split('\n') if para.strip()]
494 value = '\n' + '\n\n'.join(paras)
495 elif isinstance(widget, g.ComboBox):
496 if widget.get_active() == 0:
497 value = None
498 else:
499 value = widget.get_active_text()
500 elif isinstance(widget, g.ToggleButton):
501 value = widget.get_active()
502 else:
503 value = widget.get_text()
504 elems = list(children(root, name, attrs = attrs))
505 if value:
506 if elems:
507 elem = elems[0]
508 else:
509 elem = create_element(root, name,
510 before = ['group', 'implementation', 'requires'])
511 for x in attrs:
512 elem.setAttribute(x, attrs[x])
513 if value_attr:
514 # Set attribute
515 elem.setAttribute(value_attr, value)
516 set_data(elem, None)
517 elif isinstance(widget, g.ToggleButton):
518 pass
519 else:
520 # Set content
521 set_data(elem, value)
522 else:
523 if required:
524 raise Exception('Missing required field "%s"' % name)
525 for e in elems:
526 remove_element(e)
528 update('name', True)
529 update('summary', True)
530 update('description', True)
531 update('homepage')
532 update('category')
533 update('feed-for', value_attr = 'interface')
534 update('needs-terminal')
535 update('icon', attrs = {'type': 'image/png'}, value_attr = 'href')
537 uri = self.wTree.get_widget('feed_url').get_text()
538 if uri:
539 root.setAttribute('uri', uri)
540 elif root.hasAttribute('uri'):
541 root.removeAttribute('uri')
543 key_menu = self.wTree.get_widget('feed_key')
544 key_model = key_menu.get_model()
545 self.key = key_model[key_menu.get_active()][0]
547 def export_stylesheet_and_key(self):
548 dir = os.path.dirname(self.pathname)
549 stylesheet = os.path.join(dir, 'interface.xsl')
550 if not os.path.exists(stylesheet):
551 shutil.copyfile(stylesheet_src, stylesheet)
552 rox.info("I have saved a stylesheet as '%s'. You should upload "
553 "this to your web-server in the same directory as the feed file. "
554 "This allows browsers to display the feed nicely." % stylesheet)
556 exported = signing.export_key(dir, self.key)
557 if exported:
558 rox.info("I have exported your public key as '%s'. You should upload "
559 "this to your web-server in the same directory as the feed file. "
560 "This allows people to check the signature on your feed." % exported)
562 def save(self, callback = None):
563 data = xml_header
564 self.update_doc()
565 if self.key:
566 sign = signing.sign_xml
567 self.export_stylesheet_and_key()
568 data += xml_stylesheet_header
569 else:
570 sign = signing.sign_unsigned
571 data += self.doc.documentElement.toxml() + '\n'
573 gen = sign(self.pathname, data, self.key, callback)
574 # May require interaction to get the pass-phrase, so run in the background...
575 if gen:
576 tasks.Task(gen)
578 def add_archive(self):
579 archive.AddArchiveBox(self)
581 def xds_load_from_file(self, path):
582 archive.AddArchiveBox(self, local_archive = path)
584 def remove_version(self, path = None):
585 elem = self.get_selected()
586 remove_element(elem)
587 self.update_version_model()
589 def get_selected(self):
590 tree = self.wTree.get_widget('impl_tree')
591 sel = tree.get_selection()
592 model, iter = sel.get_selected()
593 if not iter:
594 raise Exception('Select something first!')
595 return model[iter][1]
597 def find_implementation(self, id):
598 def find_impl(parent):
599 for x in child_elements(parent):
600 if x.namespaceURI != XMLNS_INTERFACE: continue
601 if x.localName == 'group':
602 sub = find_impl(x)
603 if sub: return sub
604 elif x.localName == 'implementation':
605 if x.getAttribute('id') == id:
606 return x
607 return find_impl(self.doc.documentElement)
609 def list_versions(self):
610 """Return a list of (version, element) pairs, one for each <implementation>."""
611 versions = []
613 def add_versions(parent, version):
614 for x in child_elements(parent):
615 if x.namespaceURI != XMLNS_INTERFACE: continue
616 if x.hasAttribute('version'): version = x.getAttribute('version')
617 if x.localName == 'group':
618 add_versions(x, version)
619 elif x.localName == 'implementation':
620 versions.append((model.parse_version(version), x))
622 add_versions(self.doc.documentElement, version = None)
624 return versions