Improved some defaults
[0publish-gui.git] / main.py
blob70139cc7de17168966926c0e52818f67631ee7cf
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 </interface>
107 """ % (XMLNS_INTERFACE)
109 element_target = ('INTERNAL:FeedEditor/Element', gtk.TARGET_SAME_WIDGET, 0)
111 class FeedEditor(loading.XDSLoader):
112 def __init__(self, pathname):
113 loading.XDSLoader.__init__(self, None)
115 self.pathname = pathname
117 self.wTree = gtk.glade.XML(gladefile, 'main')
118 self.window = self.wTree.get_widget('main')
119 self.window.connect('destroy', rox.toplevel_unref)
120 self.xds_proxy_for(self.window)
122 help = gtk.glade.XML(gladefile, 'main_help')
123 help_box = help.get_widget('main_help')
124 help_box.set_default_size(g.gdk.screen_width() / 4,
125 g.gdk.screen_height() / 4)
126 help_box.connect('delete-event', lambda box, ev: True)
127 help_box.connect('response', lambda box, resp: box.hide())
129 def resp(box, resp):
130 if resp == g.RESPONSE_HELP:
131 help_box.present()
132 elif resp == RESPONSE_SAVE_AND_TEST:
133 self.save(self.test)
134 elif resp == RESPONSE_SAVE:
135 self.save()
136 else:
137 box.destroy()
138 self.window.connect('response', resp)
139 rox.toplevel_ref()
141 key_menu = self.wTree.get_widget('feed_key')
142 key_model = g.ListStore(str, str)
143 key_menu.set_model(key_model)
144 cell = g.CellRendererText()
146 if gtk.pygtk_version >= (2, 8, 0):
147 # Crashes with pygtk 2.6.1
148 cell.set_property('ellipsize', pango.ELLIPSIZE_MIDDLE)
150 key_menu.pack_start(cell)
151 key_menu.add_attribute(cell, 'text', 1)
153 self.update_key_model()
155 self.impl_model = g.TreeStore(str, object)
156 impl_tree = self.wTree.get_widget('impl_tree')
157 impl_tree.set_model(self.impl_model)
158 text = g.CellRendererText()
159 column = g.TreeViewColumn('', text)
160 column.add_attribute(text, 'text', 0)
161 impl_tree.append_column(column)
163 impl_tree.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, [element_target], gtk.gdk.ACTION_MOVE)
164 impl_tree.enable_model_drag_dest([element_target], gtk.gdk.ACTION_MOVE)
166 sel = impl_tree.get_selection()
167 sel.set_mode(g.SELECTION_BROWSE)
169 if os.path.exists(self.pathname):
170 data, _, self.key = signing.check_signature(self.pathname)
171 self.doc = minidom.parseString(data)
172 self.update_fields()
174 # Default to showing the versions tab
175 self.wTree.get_widget('notebook').next_page()
176 else:
177 default_name = os.path.basename(self.pathname)
178 if default_name.endswith('.xml'):
179 default_name = default_name[:-4]
180 self.doc = minidom.parseString(emptyFeed)
181 self.key = None
182 key_menu.set_active(0)
184 self.update_fields()
185 self.wTree.get_widget('feed_name').set_text(default_name)
187 root = self.impl_model.get_iter_root()
188 if root:
189 sel.select_iter(root)
191 self.wTree.get_widget('generate_key').connect('clicked', lambda b: self.generate_key())
193 self.wTree.get_widget('add_implementation').connect('clicked', lambda b: self.add_version())
194 self.wTree.get_widget('add_archive').connect('clicked', lambda b: self.add_archive())
195 self.wTree.get_widget('add_requires').connect('clicked', lambda b: self.add_requires())
196 self.wTree.get_widget('add_group').connect('clicked', lambda b: self.add_group())
197 self.wTree.get_widget('edit_properties').connect('clicked', lambda b: self.edit_properties())
198 self.wTree.get_widget('remove').connect('clicked', lambda b: self.remove_version())
199 impl_tree.connect('row-activated', lambda tv, path, col: self.edit_properties(path))
200 impl_tree.connect('drag-data-received', self.tree_drag_data_received)
202 def update_key_model(self):
203 key_menu = self.wTree.get_widget('feed_key')
204 key_model = key_menu.get_model()
205 keys = signing.get_secret_keys()
206 key_model.clear()
207 key_model.append((None, '(unsigned)'))
208 for k in keys:
209 key_model.append(k)
211 def generate_key(self):
212 for x in ['xterm', 'konsole']:
213 if available_in_path(x):
214 child = subprocess.Popen([x, '-e', 'gpg', '--gen-key'], stderr = subprocess.PIPE)
215 break
216 else:
217 child = subprocess.Popen(['gnome-terminal', '-e', 'gpg --gen-key'], stderr = subprocess.PIPE)
219 def get_keygen_out():
220 errors = ''
221 while True:
222 yield signing.InputBlocker(child.stderr)
223 data = os.read(child.stderr.fileno(), 100)
224 if not data:
225 break
226 errors += data
227 self.update_key_model()
228 if errors:
229 rox.alert('Errors from terminal: %s' % errors)
231 tasks.Task(get_keygen_out())
233 def tree_drag_data_received(self, treeview, context, x, y, selection, info, time):
234 if not selection: return
235 drop_info = treeview.get_dest_row_at_pos(x, y)
236 if drop_info:
237 model = treeview.get_model()
238 path, position = drop_info
240 src = self.get_selected()
241 dest = model[path][1]
243 def is_ancestor_or_self(a, b):
244 while b:
245 if b is a: return True
246 b = b.parentNode
247 return False
249 if is_ancestor_or_self(src, dest):
250 # Can't move an element into itself!
251 return
253 if position in (gtk.TREE_VIEW_DROP_BEFORE, gtk.TREE_VIEW_DROP_AFTER):
254 new_parent = dest.parentNode
255 else:
256 new_parent = dest
258 if src.namespaceURI != XMLNS_INTERFACE: return
259 if new_parent.namespaceURI != XMLNS_INTERFACE: return
261 if new_parent.localName == 'group':
262 if src.localName not in ('implementation', 'group', 'requires'):
263 return
264 elif new_parent.localName == 'interface':
265 if src.localName not in ('implementation', 'group'):
266 return
267 elif new_parent.localName == 'implementation':
268 if src.localName not in ['requires']:
269 return
270 else:
271 return
273 remove_element(src)
275 if position == gtk.TREE_VIEW_DROP_BEFORE:
276 insert_before(src, dest)
277 elif position == gtk.TREE_VIEW_DROP_AFTER:
278 next = dest.nextSibling
279 while next and not next.nodeType == Node.ELEMENT_NODE:
280 next = next.nextSibling
281 if next:
282 insert_before(src, next)
283 else:
284 insert_element(src, new_parent)
285 else:
286 for next in child_elements(new_parent):
287 insert_before(src, next)
288 break
289 else:
290 insert_element(src, new_parent)
291 self.update_version_model()
293 def add_version(self):
294 ImplementationProperties(self)
296 def add_group(self):
297 ImplementationProperties(self, is_group = True)
299 def add_requires(self):
300 elem = self.get_selected()
301 if elem.namespaceURI == XMLNS_INTERFACE:
302 if elem.localName not in ('group', 'implementation'):
303 elem = elem.parentNode
304 if elem.localName in ('group', 'implementation'):
305 Requires(self, parent = elem)
306 return
307 rox.alert('Select a group or implementation!')
309 def edit_properties(self, path = None, element = None):
310 assert not (path and element)
312 if element:
313 pass
314 elif path is None:
315 element = self.get_selected()
316 else:
317 element = self.impl_model[path][1]
319 if element.namespaceURI != XMLNS_INTERFACE:
320 rox.alert("Sorry, I don't known how to edit %s elements!" % element.namespaceURI)
322 if element.localName in ('group', 'implementation'):
323 ImplementationProperties(self, element)
324 elif element.localName == 'requires':
325 Requires(self, parent = element.parentNode, element = element)
326 else:
327 rox.alert("Sorry, I don't known how to edit %s elements!" % element.localName)
329 def update_fields(self):
330 root = self.doc.documentElement
332 def set(name):
333 value = singleton_text(root, name)
334 if value:
335 self.wTree.get_widget('feed_' + name).set_text(value)
336 set('name')
337 set('summary')
338 set('homepage')
340 needs_terminal = len(list(children(root, 'needs-terminal'))) > 0
341 self.wTree.get_widget('feed_needs_terminal').set_active(needs_terminal)
343 category_widget = self.wTree.get_widget('feed_category')
344 category = singleton_text(root, 'category')
345 if category:
346 combo_set_text(category_widget, category)
347 else:
348 category_widget.set_active(0)
350 uri = root.getAttribute('uri')
351 if uri:
352 self.wTree.get_widget('feed_url').set_text(uri)
354 for feed_for in children(root, 'feed-for'):
355 self.wTree.get_widget('feed_feed_for').set_text(feed_for.getAttribute('interface'))
357 for icon in children(root, 'icon'):
358 if icon.getAttribute('type') == 'image/png':
359 href = icon.getAttribute('href')
360 self.wTree.get_widget('feed_icon').set_text(href)
361 break
363 description = singleton_text(root, 'description') or ''
364 paragraphs = [format_para(p) for p in description.split('\n\n')]
365 buffer = self.wTree.get_widget('feed_description').get_buffer()
366 buffer.delete(buffer.get_start_iter(), buffer.get_end_iter())
367 buffer.insert_at_cursor('\n'.join(paragraphs))
369 key_menu = self.wTree.get_widget('feed_key')
370 model = key_menu.get_model()
371 if self.key:
372 i = 0
373 for line in model:
374 if line[0] == self.key:
375 break
376 i += 1
377 else:
378 model.append((self.key, 'Missing key (%s)' % self.key))
379 key_menu.set_active(i)
380 else:
381 key_menu.set_active(0)
383 self.update_version_model()
385 def add_archives(self, impl_element, iter):
386 for child in child_elements(impl_element):
387 if child.namespaceURI != XMLNS_INTERFACE: continue
388 if child.localName == 'archive':
389 self.impl_model.append(iter, ['Archive ' + child.getAttribute('href'), child])
390 elif child.localName == 'requires':
391 req_iface = child.getAttribute('interface')
392 self.impl_model.append(iter, ['Impl requires %s' % req_iface, child])
393 else:
394 self.impl_model.append(iter, ['<%s>' % child.localName, child])
396 def update_version_model(self):
397 impl_tree = self.wTree.get_widget('impl_tree')
399 # Remember which ones are open
400 expanded_elements = set()
401 impl_tree.map_expanded_rows(lambda tv, path: expanded_elements.add(self.impl_model[path][1]))
403 initial_build = not self.impl_model.get_iter_root()
405 self.impl_model.clear()
406 to_expand = []
408 def add_impls(elem, iter, attrs):
409 """Add all groups, implementations and requirements in elem"""
411 for x in child_elements(elem):
412 if x.namespaceURI != XMLNS_INTERFACE: continue
414 if x.localName == 'requires':
415 req_iface = x.getAttribute('interface')
416 new = self.impl_model.append(iter, ['Group requires %s' % req_iface, x])
418 if x.localName not in ('implementation', 'group'): continue
420 new_attrs = attrs.copy()
421 attributes = x.attributes
422 for i in range(attributes.length):
423 a = attributes.item(i)
424 new_attrs[str(a.name)] = a.value
426 if x.localName == 'implementation':
427 version = new_attrs.get('version', '(missing version number)') + \
428 (new_attrs.get('version-modifier') or '')
429 new = self.impl_model.append(iter, ['Version %s%s' % (version, list_attrs(x)), x])
430 self.add_archives(x, new)
431 elif x.localName == 'group':
432 new = self.impl_model.append(iter, ['Group%s' % list_attrs(x), x])
433 if initial_build:
434 expanded_elements.add(x)
435 add_impls(x, new, new_attrs)
437 add_impls(self.doc.documentElement, None, attrs = {})
439 def may_expand(model, path, iter):
440 if model[iter][1] in expanded_elements:
441 impl_tree.expand_row(path, False)
442 self.impl_model.foreach(may_expand)
444 def test(self, args = []):
445 child = os.fork()
446 if child == 0:
447 try:
448 try:
449 # We are the child
450 # Spawn a grandchild and exit
451 command = ['0launch', '--gui'] + args + [self.pathname]
452 if self.wTree.get_widget('feed_needs_terminal').get_active():
453 command = [get_terminal_emulator(), '-e'] + command
454 subprocess.Popen(command)
455 os._exit(0)
456 except:
457 traceback.print_exc()
458 finally:
459 os._exit(1)
460 pid, status = os.waitpid(child, 0)
461 assert pid == child
462 if status:
463 raise Exception('Failed to run 0launch - status code %d' % status)
465 def test_compile(self, args = []):
466 child = os.fork()
467 if child == 0:
468 try:
469 try:
470 # We are the child
471 # Spawn a grandchild and exit
472 subprocess.Popen(['0launch',
473 'http://0install.net/2006/interfaces/0compile.xml', 'gui'] +
474 args + [self.pathname])
475 os._exit(0)
476 except:
477 traceback.print_exc()
478 finally:
479 os._exit(1)
480 pid, status = os.waitpid(child, 0)
481 assert pid == child
482 if status:
483 raise Exception('Failed to run 0compile - status code %d' % status)
485 def update_doc(self):
486 root = self.doc.documentElement
487 def update(name, required = False, attrs = {}, value_attr = None):
488 widget = self.wTree.get_widget('feed_' + name.replace('-', '_'))
489 if isinstance(widget, g.TextView):
490 buffer = widget.get_buffer()
491 text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter())
492 paras = ['\n'.join(textwrap.wrap(para, 80)) for para in text.split('\n') if para.strip()]
493 value = '\n' + '\n\n'.join(paras)
494 elif isinstance(widget, g.ComboBox):
495 if widget.get_active() == 0:
496 value = None
497 else:
498 value = widget.get_active_text()
499 elif isinstance(widget, g.ToggleButton):
500 value = widget.get_active()
501 else:
502 value = widget.get_text()
503 elems = list(children(root, name, attrs = attrs))
504 if value:
505 if elems:
506 elem = elems[0]
507 else:
508 elem = create_element(root, name,
509 before = ['group', 'implementation', 'requires'])
510 for x in attrs:
511 elem.setAttribute(x, attrs[x])
512 if value_attr:
513 # Set attribute
514 elem.setAttribute(value_attr, value)
515 set_data(elem, None)
516 elif isinstance(widget, g.ToggleButton):
517 pass
518 else:
519 # Set content
520 set_data(elem, value)
521 else:
522 if required:
523 raise Exception('Missing required field "%s"' % name)
524 for e in elems:
525 remove_element(e)
527 update('name', True)
528 update('summary', True)
529 update('description', True)
530 update('homepage')
531 update('category')
532 update('feed-for', value_attr = 'interface')
533 update('needs-terminal')
534 update('icon', attrs = {'type': 'image/png'}, value_attr = 'href')
536 uri = self.wTree.get_widget('feed_url').get_text()
537 if uri:
538 root.setAttribute('uri', uri)
539 elif root.hasAttribute('uri'):
540 root.removeAttribute('uri')
542 key_menu = self.wTree.get_widget('feed_key')
543 key_model = key_menu.get_model()
544 self.key = key_model[key_menu.get_active()][0]
546 def export_stylesheet_and_key(self):
547 dir = os.path.dirname(os.path.abspath(self.pathname))
548 stylesheet = os.path.join(dir, 'interface.xsl')
549 if not os.path.exists(stylesheet):
550 shutil.copyfile(stylesheet_src, stylesheet)
551 rox.info("I have saved a stylesheet as '%s'. You should upload "
552 "this to your web-server in the same directory as the feed file. "
553 "This allows browsers to display the feed nicely." % stylesheet)
555 if os.path.abspath(self.pathname).endswith('/feed.xml'):
556 # Probably the feed's URL is the directory, so we'll get the key from the parent.
557 dir = os.path.dirname(dir)
559 exported = signing.export_key(dir, self.key)
560 if exported:
561 rox.info("I have exported your public key as '%s'. You should upload "
562 "this to your web-server in the same directory as the feed file. "
563 "This allows people to check the signature on your feed." % exported)
565 def save(self, callback = None):
566 data = xml_header
567 self.update_doc()
568 if self.key:
569 sign = signing.sign_xml
570 self.export_stylesheet_and_key()
571 data += xml_stylesheet_header
572 else:
573 sign = signing.sign_unsigned
574 data += self.doc.documentElement.toxml() + '\n'
576 gen = sign(self.pathname, data, self.key, callback)
577 # May require interaction to get the pass-phrase, so run in the background...
578 if gen:
579 tasks.Task(gen)
581 def add_archive(self):
582 archive.AddArchiveBox(self)
584 def xds_load_from_file(self, path):
585 archive.AddArchiveBox(self, local_archive = path)
587 def remove_version(self, path = None):
588 elem = self.get_selected()
589 remove_element(elem)
590 self.update_version_model()
592 def get_selected(self):
593 tree = self.wTree.get_widget('impl_tree')
594 sel = tree.get_selection()
595 model, iter = sel.get_selected()
596 if not iter:
597 raise Exception('Select something first!')
598 return model[iter][1]
600 def find_implementation(self, id):
601 def find_impl(parent):
602 for x in child_elements(parent):
603 if x.namespaceURI != XMLNS_INTERFACE: continue
604 if x.localName == 'group':
605 sub = find_impl(x)
606 if sub: return sub
607 elif x.localName == 'implementation':
608 if x.getAttribute('id') == id:
609 return x
610 return find_impl(self.doc.documentElement)
612 def list_versions(self):
613 """Return a list of (version, element) pairs, one for each <implementation>."""
614 versions = []
616 def add_versions(parent, version):
617 for x in child_elements(parent):
618 if x.namespaceURI != XMLNS_INTERFACE: continue
619 if x.hasAttribute('version'): version = x.getAttribute('version')
620 if x.localName == 'group':
621 add_versions(x, version)
622 elif x.localName == 'implementation':
623 versions.append((model.parse_version(version), x))
625 add_versions(self.doc.documentElement, version = None)
627 return versions