Warn about tar archives with a leading "./" in the name.
[0publish-gui.git] / main.py
blob4c3161bc6cc6f3a45fe8d3d5bba8366af7aefc21
1 from xml.dom import Node, minidom
3 from StringIO import StringIO
5 import rox, os, pango, sys, textwrap, traceback, subprocess, shutil
6 from rox import g, tasks, loading
7 import gtk.glade
9 import signing
10 import archive
11 from implementation import ImplementationProperties
12 from requires import Requires
13 from xmltools import *
15 from zeroinstall.injector import model, qdom
16 from zeroinstall.zerostore import Stores
18 RESPONSE_SAVE = 0
19 RESPONSE_SAVE_AND_TEST = 1
21 xml_header = """<?xml version="1.0" ?>
22 """
23 xml_stylesheet_header = """<?xml-stylesheet type='text/xsl' href='interface.xsl'?>
24 """
26 gladefile = os.path.join(rox.app_dir, '0publish-gui.glade')
28 # Zero Install implementation cache
29 stores = Stores()
31 stylesheet_src = os.path.join(os.path.dirname(__file__), 'interface.xsl')
33 def available_in_path(prog):
34 for d in os.environ['PATH'].split(':'):
35 path = os.path.join(d, prog)
36 if os.path.isfile(path):
37 return True
38 return False
40 def get_terminal_emulator():
41 terminal_emulators = ['x-terminal-emulator', 'xterm', 'konsole']
42 for xterm in terminal_emulators:
43 if available_in_path(xterm):
44 return xterm
45 return 'xterm' # Hope
47 def choose_feed():
48 tree = gtk.glade.XML(gladefile, 'no_file_specified')
49 box = tree.get_widget('no_file_specified')
50 tree.get_widget('new_button').grab_focus()
51 resp = box.run()
52 box.destroy()
53 if resp == 0:
54 chooser = g.FileChooserDialog('Choose a location for the new feed',
55 None, g.FILE_CHOOSER_ACTION_SAVE)
56 chooser.set_current_name('MyProg.xml')
57 chooser.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
58 chooser.add_button(g.STOCK_NEW, g.RESPONSE_OK)
59 elif resp == 1:
60 chooser = g.FileChooserDialog('Choose the feed to edit',
61 None, g.FILE_CHOOSER_ACTION_OPEN)
62 chooser.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
63 chooser.add_button(g.STOCK_OPEN, g.RESPONSE_OK)
64 else:
65 sys.exit(1)
66 chooser.set_default_response(g.RESPONSE_OK)
67 if chooser.run() != g.RESPONSE_OK:
68 sys.exit(1)
69 path = chooser.get_filename()
70 chooser.destroy()
71 return FeedEditor(path)
73 def combo_set_text(combo, text):
74 if combo.get_active_text() or text:
75 model = combo.get_model()
76 i = 0
77 for row in model:
78 if row[0] == text:
79 combo.set_active(i)
80 return
81 i += 1
82 combo.append_text(text)
83 combo.set_active(i)
84 else:
85 return
87 def list_attrs(element):
88 attrs = element.attributes
89 names = []
90 for x in range(attrs.length):
91 attr = attrs.item(x)
93 if attr.name in ['id', 'version-modifier']: continue
94 if element.localName == 'implementation' and attr.name == 'version': continue
96 if attr.name in ('stability', 'arch'):
97 names.append(attr.value)
98 else:
99 names.append(attr.name)
100 if names:
101 return ' (%s)' % ', '.join(names)
102 else:
103 return ''
105 emptyFeed = """<?xml version='1.0'?>
106 <interface xmlns="%s">
107 <name>Name</name>
108 </interface>
109 """ % (XMLNS_INTERFACE)
111 element_target = ('INTERNAL:FeedEditor/Element', gtk.TARGET_SAME_WIDGET, 0)
113 class FeedEditor(loading.XDSLoader):
114 def __init__(self, pathname):
115 loading.XDSLoader.__init__(self, None)
117 self.pathname = pathname
119 self.wTree = gtk.glade.XML(gladefile, 'main')
120 self.window = self.wTree.get_widget('main')
121 self.window.connect('destroy', rox.toplevel_unref)
122 self.xds_proxy_for(self.window)
124 help = gtk.glade.XML(gladefile, 'main_help')
125 help_box = help.get_widget('main_help')
126 help_box.set_default_size(g.gdk.screen_width() / 4,
127 g.gdk.screen_height() / 4)
128 help_box.connect('delete-event', lambda box, ev: True)
129 help_box.connect('response', lambda box, resp: box.hide())
131 def resp(box, resp):
132 if resp == g.RESPONSE_HELP:
133 help_box.present()
134 elif resp == RESPONSE_SAVE_AND_TEST:
135 self.save(self.test)
136 elif resp == RESPONSE_SAVE:
137 self.save()
138 else:
139 box.destroy()
140 self.window.connect('response', resp)
141 rox.toplevel_ref()
143 key_menu = self.wTree.get_widget('feed_key')
144 key_model = g.ListStore(str, str)
145 key_menu.set_model(key_model)
146 cell = g.CellRendererText()
148 if gtk.pygtk_version >= (2, 8, 0):
149 # Crashes with pygtk 2.6.1
150 cell.set_property('ellipsize', pango.ELLIPSIZE_MIDDLE)
152 key_menu.pack_start(cell)
153 key_menu.add_attribute(cell, 'text', 1)
155 self.update_key_model()
157 self.impl_model = g.TreeStore(str, object)
158 impl_tree = self.wTree.get_widget('impl_tree')
159 impl_tree.set_model(self.impl_model)
160 text = g.CellRendererText()
161 column = g.TreeViewColumn('', text)
162 column.add_attribute(text, 'text', 0)
163 impl_tree.append_column(column)
165 impl_tree.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, [element_target], gtk.gdk.ACTION_MOVE)
166 impl_tree.enable_model_drag_dest([element_target], gtk.gdk.ACTION_MOVE)
168 sel = impl_tree.get_selection()
169 sel.set_mode(g.SELECTION_BROWSE)
171 if os.path.exists(self.pathname):
172 data, _, self.key = signing.check_signature(self.pathname)
173 self.doc = minidom.parseString(data)
174 self.update_fields()
176 # Default to showing the versions tab
177 self.wTree.get_widget('notebook').next_page()
178 else:
179 default_name = os.path.basename(self.pathname)
180 if default_name.endswith('.xml'):
181 default_name = default_name[:-4]
182 self.doc = minidom.parseString(emptyFeed)
183 self.key = None
184 key_menu.set_active(0)
186 self.update_fields()
187 self.wTree.get_widget('feed_name').set_text(default_name)
189 root = self.impl_model.get_iter_root()
190 if root:
191 sel.select_iter(root)
193 self.wTree.get_widget('generate_key').connect('clicked', lambda b: self.generate_key())
195 self.wTree.get_widget('add_implementation').connect('clicked', lambda b: self.add_version())
196 self.wTree.get_widget('add_archive').connect('clicked', lambda b: self.add_archive())
197 self.wTree.get_widget('add_requires').connect('clicked', lambda b: self.add_requires())
198 self.wTree.get_widget('add_group').connect('clicked', lambda b: self.add_group())
199 self.wTree.get_widget('edit_properties').connect('clicked', lambda b: self.edit_properties())
200 self.wTree.get_widget('remove').connect('clicked', lambda b: self.remove_version())
201 impl_tree.connect('row-activated', lambda tv, path, col: self.edit_properties(path))
202 impl_tree.connect('drag-data-received', self.tree_drag_data_received)
204 def update_key_model(self):
205 key_menu = self.wTree.get_widget('feed_key')
206 key_model = key_menu.get_model()
207 keys = signing.get_secret_keys()
208 key_model.clear()
209 key_model.append((None, '(unsigned)'))
210 for k in keys:
211 key_model.append(k)
213 def generate_key(self):
214 for x in ['xterm', 'konsole']:
215 if available_in_path(x):
216 child = subprocess.Popen([x, '-e', 'gpg', '--gen-key'], stderr = subprocess.PIPE)
217 break
218 else:
219 child = subprocess.Popen(['gnome-terminal', '-e', 'gpg --gen-key'], stderr = subprocess.PIPE)
221 def get_keygen_out():
222 errors = ''
223 while True:
224 yield tasks.InputBlocker(child.stderr)
225 data = os.read(child.stderr.fileno(), 100)
226 if not data:
227 break
228 errors += data
229 self.update_key_model()
230 if errors:
231 rox.alert('Errors from terminal: %s' % errors)
233 tasks.Task(get_keygen_out())
235 def tree_drag_data_received(self, treeview, context, x, y, selection, info, time):
236 if not selection: return
237 drop_info = treeview.get_dest_row_at_pos(x, y)
238 if drop_info:
239 model = treeview.get_model()
240 path, position = drop_info
242 src = self.get_selected()
243 dest = model[path][1]
245 def is_ancestor_or_self(a, b):
246 while b:
247 if b is a: return True
248 b = b.parentNode
249 return False
251 if is_ancestor_or_self(src, dest):
252 # Can't move an element into itself!
253 return
255 if position in (gtk.TREE_VIEW_DROP_BEFORE, gtk.TREE_VIEW_DROP_AFTER):
256 new_parent = dest.parentNode
257 else:
258 new_parent = dest
260 if src.namespaceURI != XMLNS_INTERFACE: return
261 if new_parent.namespaceURI != XMLNS_INTERFACE: return
263 if new_parent.localName == 'group':
264 if src.localName not in ('implementation', 'group', 'requires'):
265 return
266 elif new_parent.localName == 'interface':
267 if src.localName not in ('implementation', 'group'):
268 return
269 elif new_parent.localName == 'implementation':
270 if src.localName not in ['requires']:
271 return
272 else:
273 return
275 remove_element(src)
277 if position == gtk.TREE_VIEW_DROP_BEFORE:
278 insert_before(src, dest)
279 elif position == gtk.TREE_VIEW_DROP_AFTER:
280 next = dest.nextSibling
281 while next and not next.nodeType == Node.ELEMENT_NODE:
282 next = next.nextSibling
283 if next:
284 insert_before(src, next)
285 else:
286 insert_element(src, new_parent)
287 else:
288 for next in child_elements(new_parent):
289 insert_before(src, next)
290 break
291 else:
292 insert_element(src, new_parent)
293 self.update_version_model()
295 def add_version(self):
296 ImplementationProperties(self)
298 def add_group(self):
299 ImplementationProperties(self, is_group = True)
301 def add_requires(self):
302 elem = self.get_selected()
303 if elem.namespaceURI == XMLNS_INTERFACE:
304 if elem.localName not in ('group', 'implementation'):
305 elem = elem.parentNode
306 if elem.localName in ('group', 'implementation'):
307 Requires(self, parent = elem)
308 return
309 rox.alert('Select a group or implementation!')
311 def edit_properties(self, path = None, element = None):
312 assert not (path and element)
314 if element:
315 pass
316 elif path is None:
317 element = self.get_selected()
318 else:
319 element = self.impl_model[path][1]
321 if element.namespaceURI != XMLNS_INTERFACE:
322 rox.alert("Sorry, I don't known how to edit %s elements!" % element.namespaceURI)
324 if element.localName in ('group', 'implementation'):
325 ImplementationProperties(self, element)
326 elif element.localName == 'requires':
327 Requires(self, parent = element.parentNode, element = element)
328 else:
329 rox.alert("Sorry, I don't known how to edit %s elements!" % element.localName)
331 def update_fields(self):
332 root = self.doc.documentElement
334 def set(name):
335 value = singleton_text(root, name)
336 if value:
337 self.wTree.get_widget('feed_' + name).set_text(value)
338 set('name')
339 set('summary')
340 set('homepage')
342 needs_terminal = len(list(children(root, 'needs-terminal'))) > 0
343 self.wTree.get_widget('feed_needs_terminal').set_active(needs_terminal)
345 category_widget = self.wTree.get_widget('feed_category')
346 category = singleton_text(root, 'category')
347 if category:
348 combo_set_text(category_widget, category)
349 else:
350 category_widget.set_active(0)
352 uri = root.getAttribute('uri')
353 if uri:
354 self.wTree.get_widget('feed_url').set_text(uri)
356 for feed_for in children(root, 'feed-for'):
357 self.wTree.get_widget('feed_feed_for').set_text(feed_for.getAttribute('interface'))
359 for icon in children(root, 'icon'):
360 if icon.getAttribute('type') == 'image/png':
361 href = icon.getAttribute('href')
362 self.wTree.get_widget('feed_icon').set_text(href)
363 break
365 description = singleton_text(root, 'description') or ''
366 paragraphs = [format_para(p) for p in description.split('\n\n')]
367 buffer = self.wTree.get_widget('feed_description').get_buffer()
368 buffer.delete(buffer.get_start_iter(), buffer.get_end_iter())
369 buffer.insert_at_cursor('\n'.join(paragraphs))
371 key_menu = self.wTree.get_widget('feed_key')
372 model = key_menu.get_model()
373 if self.key:
374 i = 0
375 for line in model:
376 if line[0] == self.key:
377 break
378 i += 1
379 else:
380 model.append((self.key, 'Missing key (%s)' % self.key))
381 key_menu.set_active(i)
382 else:
383 key_menu.set_active(0)
385 self.update_version_model()
387 def add_archives(self, impl_element, iter):
388 for child in child_elements(impl_element):
389 if child.namespaceURI != XMLNS_INTERFACE: continue
390 if child.localName == 'archive':
391 self.impl_model.append(iter, ['Archive ' + child.getAttribute('href'), child])
392 elif child.localName == 'requires':
393 req_iface = child.getAttribute('interface')
394 self.impl_model.append(iter, ['Impl requires %s' % req_iface, child])
395 else:
396 self.impl_model.append(iter, ['<%s>' % child.localName, child])
398 def update_version_model(self):
399 impl_tree = self.wTree.get_widget('impl_tree')
401 # Remember which ones are open
402 expanded_elements = set()
403 impl_tree.map_expanded_rows(lambda tv, path: expanded_elements.add(self.impl_model[path][1]))
405 initial_build = not self.impl_model.get_iter_root()
407 self.impl_model.clear()
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(os.path.abspath(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 if os.path.abspath(self.pathname).endswith('/feed.xml'):
557 # Probably the feed's URL is the directory, so we'll get the key from the parent.
558 dir = os.path.dirname(dir)
560 exported = signing.export_key(dir, self.key)
561 if exported:
562 rox.info("I have exported your public key as '%s'. You should upload "
563 "this to your web-server in the same directory as the feed file. "
564 "This allows people to check the signature on your feed." % exported)
566 def save(self, callback = None):
567 data = xml_header
568 self.update_doc()
569 if self.key:
570 sign = signing.sign_xml
571 self.export_stylesheet_and_key()
572 data += xml_stylesheet_header
573 else:
574 sign = signing.sign_unsigned
575 data += self.doc.documentElement.toxml() + '\n'
577 gen = sign(self.pathname, data, self.key, callback)
578 # May require interaction to get the pass-phrase, so run in the background...
579 if gen:
580 tasks.Task(gen)
582 def add_archive(self):
583 archive.AddArchiveBox(self)
585 def xds_load_from_file(self, path):
586 archive.AddArchiveBox(self, local_archive = path)
588 def remove_version(self, path = None):
589 elem = self.get_selected()
590 remove_element(elem)
591 self.update_version_model()
593 def get_selected(self):
594 tree = self.wTree.get_widget('impl_tree')
595 sel = tree.get_selection()
596 model, iter = sel.get_selected()
597 if not iter:
598 raise Exception('Select something first!')
599 return model[iter][1]
601 def find_implementation(self, id):
602 def find_impl(parent):
603 for x in child_elements(parent):
604 if x.namespaceURI != XMLNS_INTERFACE: continue
605 if x.localName == 'group':
606 sub = find_impl(x)
607 if sub: return sub
608 elif x.localName == 'implementation':
609 if x.getAttribute('id') == id:
610 return x
611 return find_impl(self.doc.documentElement)
613 def list_versions(self):
614 """Return a list of (version, element) pairs, one for each <implementation>."""
615 versions = []
617 def add_versions(parent, version):
618 for x in child_elements(parent):
619 if x.namespaceURI != XMLNS_INTERFACE: continue
620 if x.hasAttribute('version'): version = x.getAttribute('version')
621 if x.localName == 'group':
622 add_versions(x, version)
623 elif x.localName == 'implementation':
624 versions.append((model.parse_version(version), x))
626 add_versions(self.doc.documentElement, version = None)
628 return versions
630 def get_as_feed(self):
631 self.update_doc()
632 xml = self.doc.documentElement.toxml(encoding = 'utf-8')
633 return model.ZeroInstallFeed(qdom.parse(StringIO(xml)), local_path = os.path.abspath(self.pathname))