Don't use ELLIPSIZE_MIDDLE with pygtk < 2.8.0 as earlier versions may
[0publish-gui.git] / main.py
blobc179b086e5fc238dd582ad2eb28e8873ae6c786d
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 <?xml-stylesheet type='text/xsl' href='interface.xsl'?>
21 """
23 gladefile = os.path.join(rox.app_dir, '0publish-gui.glade')
25 # Zero Install implementation cache
26 stores = Stores()
28 stylesheet_src = os.path.join(os.path.dirname(__file__), 'interface.xsl')
30 def available_in_path(prog):
31 for d in os.environ['PATH'].split(':'):
32 path = os.path.join(d, prog)
33 if os.path.isfile(path):
34 return True
35 return False
37 def choose_feed():
38 tree = gtk.glade.XML(gladefile, 'no_file_specified')
39 box = tree.get_widget('no_file_specified')
40 tree.get_widget('new_button').grab_focus()
41 resp = box.run()
42 box.destroy()
43 if resp == 0:
44 chooser = g.FileChooserDialog('Choose a location for the new feed',
45 None, g.FILE_CHOOSER_ACTION_SAVE)
46 chooser.set_current_name('MyProg.xml')
47 chooser.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
48 chooser.add_button(g.STOCK_NEW, g.RESPONSE_OK)
49 elif resp == 1:
50 chooser = g.FileChooserDialog('Choose the feed to edit',
51 None, g.FILE_CHOOSER_ACTION_OPEN)
52 chooser.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
53 chooser.add_button(g.STOCK_OPEN, g.RESPONSE_OK)
54 else:
55 sys.exit(1)
56 chooser.set_default_response(g.RESPONSE_OK)
57 if chooser.run() != g.RESPONSE_OK:
58 sys.exit(1)
59 path = chooser.get_filename()
60 chooser.destroy()
61 return FeedEditor(path)
63 def combo_set_text(combo, text):
64 if combo.get_active_text() or text:
65 model = combo.get_model()
66 i = 0
67 for row in model:
68 if row[0] == text:
69 combo.set_active(i)
70 return
71 i += 1
72 combo.append_text(text)
73 combo.set_active(i)
74 else:
75 return
77 def list_attrs(element):
78 attrs = element.attributes
79 names = []
80 for x in range(attrs.length):
81 attr = attrs.item(x)
83 if attr.name in ['id', 'version-modifier']: continue
84 if element.localName == 'implementation' and attr.name == 'version': continue
86 if attr.name == 'stability':
87 names.append(attr.value)
88 else:
89 names.append(attr.name)
90 if names:
91 return ' (%s)' % ', '.join(names)
92 else:
93 return ''
95 emptyFeed = """<?xml version='1.0'?>
96 <interface xmlns="%s">
97 <name>Name</name>
98 </interface>
99 """ % (XMLNS_INTERFACE)
101 element_target = ('INTERNAL:FeedEditor/Element', gtk.TARGET_SAME_WIDGET, 0)
103 class FeedEditor(loading.XDSLoader):
104 def __init__(self, pathname):
105 loading.XDSLoader.__init__(self, None)
107 self.pathname = pathname
109 self.wTree = gtk.glade.XML(gladefile, 'main')
110 self.window = self.wTree.get_widget('main')
111 self.window.connect('destroy', rox.toplevel_unref)
112 self.xds_proxy_for(self.window)
114 help = gtk.glade.XML(gladefile, 'main_help')
115 help_box = help.get_widget('main_help')
116 help_box.set_default_size(g.gdk.screen_width() / 4,
117 g.gdk.screen_height() / 4)
118 help_box.connect('delete-event', lambda box, ev: True)
119 help_box.connect('response', lambda box, resp: box.hide())
121 def resp(box, resp):
122 if resp == g.RESPONSE_HELP:
123 help_box.present()
124 elif resp == RESPONSE_SAVE_AND_TEST:
125 self.save(self.test)
126 elif resp == RESPONSE_SAVE:
127 self.save()
128 else:
129 box.destroy()
130 self.window.connect('response', resp)
131 rox.toplevel_ref()
133 key_menu = self.wTree.get_widget('feed_key')
134 key_model = g.ListStore(str, str)
135 key_menu.set_model(key_model)
136 cell = g.CellRendererText()
138 if gtk.pygtk_version >= (2, 8, 0):
139 # Crashes with pygtk 2.6.1
140 cell.set_property('ellipsize', pango.ELLIPSIZE_MIDDLE)
142 key_menu.pack_start(cell)
143 key_menu.add_attribute(cell, 'text', 1)
145 self.update_key_model()
147 self.impl_model = g.TreeStore(str, object)
148 impl_tree = self.wTree.get_widget('impl_tree')
149 impl_tree.set_model(self.impl_model)
150 text = g.CellRendererText()
151 column = g.TreeViewColumn('', text)
152 column.add_attribute(text, 'text', 0)
153 impl_tree.append_column(column)
155 impl_tree.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, [element_target], gtk.gdk.ACTION_MOVE)
156 impl_tree.enable_model_drag_dest([element_target], gtk.gdk.ACTION_MOVE)
158 sel = impl_tree.get_selection()
159 sel.set_mode(g.SELECTION_BROWSE)
161 if os.path.exists(self.pathname):
162 data, _, self.key = signing.check_signature(self.pathname)
163 self.doc = minidom.parseString(data)
164 self.update_fields()
166 # Default to showing the versions tab
167 self.wTree.get_widget('notebook').next_page()
168 else:
169 default_name = os.path.basename(self.pathname)
170 if default_name.endswith('.xml'):
171 default_name = default_name[:-4]
172 self.wTree.get_widget('feed_name').set_text(default_name)
173 self.doc = minidom.parseString(emptyFeed)
174 self.key = None
175 key_menu.set_active(0)
177 root = self.impl_model.get_iter_root()
178 if root:
179 sel.select_iter(root)
181 self.wTree.get_widget('generate_key').connect('clicked', lambda b: self.generate_key())
183 self.wTree.get_widget('add_implementation').connect('clicked', lambda b: self.add_version())
184 self.wTree.get_widget('add_archive').connect('clicked', lambda b: self.add_archive())
185 self.wTree.get_widget('add_requires').connect('clicked', lambda b: self.add_requires())
186 self.wTree.get_widget('add_group').connect('clicked', lambda b: self.add_group())
187 self.wTree.get_widget('edit_properties').connect('clicked', lambda b: self.edit_properties())
188 self.wTree.get_widget('remove').connect('clicked', lambda b: self.remove_version())
189 impl_tree.connect('row-activated', lambda tv, path, col: self.edit_properties(path))
190 impl_tree.connect('drag-data-received', self.tree_drag_data_received)
192 def update_key_model(self):
193 key_menu = self.wTree.get_widget('feed_key')
194 key_model = key_menu.get_model()
195 keys = signing.get_secret_keys()
196 key_model.clear()
197 key_model.append((None, '(unsigned)'))
198 for k in keys:
199 key_model.append(k)
201 def generate_key(self):
202 for x in ['xterm', 'konsole']:
203 if available_in_path(x):
204 child = subprocess.Popen([x, '-e', 'gpg', '--gen-key'], stderr = subprocess.PIPE)
205 break
206 else:
207 child = subprocess.Popen(['gnome-terminal', '-e', 'gpg --gen-key'], stderr = subprocess.PIPE)
209 def get_keygen_out():
210 errors = ''
211 while True:
212 yield signing.InputBlocker(child.stderr)
213 data = os.read(child.stderr.fileno(), 100)
214 if not data:
215 break
216 errors += data
217 self.update_key_model()
218 if errors:
219 rox.alert('Errors from terminal: %s' % errors)
221 tasks.Task(get_keygen_out())
223 def tree_drag_data_received(self, treeview, context, x, y, selection, info, time):
224 if not selection: return
225 drop_info = treeview.get_dest_row_at_pos(x, y)
226 if drop_info:
227 model = treeview.get_model()
228 path, position = drop_info
230 src = self.get_selected()
231 dest = model[path][1]
233 def is_ancestor_or_self(a, b):
234 while b:
235 if b is a: return True
236 b = b.parentNode
237 return False
239 if is_ancestor_or_self(src, dest):
240 # Can't move an element into itself!
241 return
243 if position in (gtk.TREE_VIEW_DROP_BEFORE, gtk.TREE_VIEW_DROP_AFTER):
244 new_parent = dest.parentNode
245 else:
246 new_parent = dest
248 if src.namespaceURI != XMLNS_INTERFACE: return
249 if new_parent.namespaceURI != XMLNS_INTERFACE: return
251 if new_parent.localName == 'group':
252 if src.localName not in ('implementation', 'group', 'requires'):
253 return
254 elif new_parent.localName == 'interface':
255 if src.localName not in ('implementation', 'group'):
256 return
257 elif new_parent.localName == 'implementation':
258 if src.localName not in ['requires']:
259 return
260 else:
261 return
263 remove_element(src)
265 if position == gtk.TREE_VIEW_DROP_BEFORE:
266 insert_before(src, dest)
267 elif position == gtk.TREE_VIEW_DROP_AFTER:
268 next = dest.nextSibling
269 while next and not next.nodeType == Node.ELEMENT_NODE:
270 next = next.nextSibling
271 if next:
272 insert_before(src, next)
273 else:
274 insert_element(src, new_parent)
275 else:
276 for next in child_elements(new_parent):
277 insert_before(src, next)
278 break
279 else:
280 insert_element(src, new_parent)
281 self.update_version_model()
283 def add_version(self):
284 ImplementationProperties(self)
286 def add_group(self):
287 ImplementationProperties(self, is_group = True)
289 def add_requires(self):
290 elem = self.get_selected()
291 if elem.namespaceURI == XMLNS_INTERFACE:
292 if elem.localName not in ('group', 'implementation'):
293 elem = elem.parentNode
294 if elem.localName in ('group', 'implementation'):
295 Requires(self, parent = elem)
296 return
297 rox.alert('Select a group or implementation!')
299 def edit_properties(self, path = None, element = None):
300 assert not (path and element)
302 if element:
303 pass
304 elif path is None:
305 element = self.get_selected()
306 else:
307 element = self.impl_model[path][1]
309 if element.namespaceURI != XMLNS_INTERFACE:
310 rox.alert("Sorry, I don't known how to edit %s elements!" % element.namespaceURI)
312 if element.localName in ('group', 'implementation'):
313 ImplementationProperties(self, element)
314 elif element.localName == 'requires':
315 Requires(self, parent = element.parentNode, element = element)
316 else:
317 rox.alert("Sorry, I don't known how to edit %s elements!" % element.localName)
319 def update_fields(self):
320 root = self.doc.documentElement
322 def set(name):
323 value = singleton_text(root, name)
324 if value:
325 self.wTree.get_widget('feed_' + name).set_text(value)
326 set('name')
327 set('summary')
328 set('homepage')
330 uri = root.getAttribute('uri')
331 if uri:
332 self.wTree.get_widget('feed_url').set_text(uri)
334 for icon in children(root, 'icon'):
335 if icon.getAttribute('type') == 'image/png':
336 href = icon.getAttribute('href')
337 self.wTree.get_widget('feed_icon').set_text(href)
338 break
340 description = singleton_text(root, 'description') or ''
341 paragraphs = [format_para(p) for p in description.split('\n\n')]
342 buffer = self.wTree.get_widget('feed_description').get_buffer()
343 buffer.delete(buffer.get_start_iter(), buffer.get_end_iter())
344 buffer.insert_at_cursor('\n'.join(paragraphs))
346 key_menu = self.wTree.get_widget('feed_key')
347 model = key_menu.get_model()
348 if self.key:
349 i = 0
350 for line in model:
351 if line[0] == self.key:
352 break
353 i += 1
354 else:
355 model.append((self.key, 'Missing key (%s)' % self.key))
356 key_menu.set_active(i)
357 else:
358 key_menu.set_active(0)
360 self.update_version_model()
362 def add_archives(self, impl_element, iter):
363 for child in child_elements(impl_element):
364 if child.namespaceURI != XMLNS_INTERFACE: continue
365 if child.localName == 'archive':
366 self.impl_model.append(iter, ['Archive ' + child.getAttribute('href'), child])
367 elif child.localName == 'requires':
368 req_iface = child.getAttribute('interface')
369 self.impl_model.append(iter, ['Impl requires %s' % req_iface, child])
370 else:
371 self.impl_model.append(iter, ['<%s>' % child.localName, child])
373 def update_version_model(self):
374 impl_tree = self.wTree.get_widget('impl_tree')
376 # Remember which ones are open
377 expanded_elements = set()
378 impl_tree.map_expanded_rows(lambda tv, path: expanded_elements.add(self.impl_model[path][1]))
380 initial_build = not self.impl_model.get_iter_root()
382 self.impl_model.clear()
383 to_expand = []
385 def add_impls(elem, iter, attrs):
386 """Add all groups, implementations and requirements in elem"""
388 for x in child_elements(elem):
389 if x.namespaceURI != XMLNS_INTERFACE: continue
391 if x.localName == 'requires':
392 req_iface = x.getAttribute('interface')
393 new = self.impl_model.append(iter, ['Group requires %s' % req_iface, x])
395 if x.localName not in ('implementation', 'group'): continue
397 new_attrs = attrs.copy()
398 attributes = x.attributes
399 for i in range(attributes.length):
400 a = attributes.item(i)
401 new_attrs[str(a.name)] = a.value
403 if x.localName == 'implementation':
404 version = new_attrs.get('version', '(missing version number)') + \
405 (new_attrs.get('version-modifier') or '')
406 new = self.impl_model.append(iter, ['Version %s%s' % (version, list_attrs(x)), x])
407 self.add_archives(x, new)
408 elif x.localName == 'group':
409 new = self.impl_model.append(iter, ['Group%s' % list_attrs(x), x])
410 if initial_build:
411 expanded_elements.add(x)
412 add_impls(x, new, new_attrs)
414 add_impls(self.doc.documentElement, None, attrs = {})
416 def may_expand(model, path, iter):
417 if model[iter][1] in expanded_elements:
418 impl_tree.expand_row(path, False)
419 self.impl_model.foreach(may_expand)
421 def test(self, args = []):
422 child = os.fork()
423 if child == 0:
424 try:
425 try:
426 # We are the child
427 # Spawn a grandchild and exit
428 subprocess.Popen(['0launch', '--gui'] + args + [self.pathname])
429 os._exit(0)
430 except:
431 traceback.print_exc()
432 finally:
433 os._exit(1)
434 pid, status = os.waitpid(child, 0)
435 assert pid == child
436 if status:
437 raise Exception('Failed to run 0launch - status code %d' % status)
439 def test_compile(self, args = []):
440 child = os.fork()
441 if child == 0:
442 try:
443 try:
444 # We are the child
445 # Spawn a grandchild and exit
446 subprocess.Popen(['0launch',
447 'http://0install.net/2006/interfaces/0compile.xml', 'gui'] +
448 args + [self.pathname])
449 os._exit(0)
450 except:
451 traceback.print_exc()
452 finally:
453 os._exit(1)
454 pid, status = os.waitpid(child, 0)
455 assert pid == child
456 if status:
457 raise Exception('Failed to run 0compile - status code %d' % status)
459 def update_doc(self):
460 root = self.doc.documentElement
461 def update(name, required = False, attrs = {}, value_attr = None):
462 widget = self.wTree.get_widget('feed_' + name)
463 if isinstance(widget, g.TextView):
464 buffer = widget.get_buffer()
465 text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter())
466 paras = ['\n'.join(textwrap.wrap(para, 80)) for para in text.split('\n') if para.strip()]
467 value = '\n' + '\n\n'.join(paras)
468 else:
469 value = widget.get_text()
470 elems = list(children(root, name, attrs = attrs))
471 if value:
472 if elems:
473 elem = elems[0]
474 else:
475 elem = create_element(root, name,
476 before = ['group', 'implementation', 'requires'])
477 for x in attrs:
478 elem.setAttribute(x, attrs[x])
479 if value_attr:
480 elem.setAttribute(value_attr, value)
481 set_data(elem, None)
482 else:
483 set_data(elem, value)
484 else:
485 if required:
486 raise Exception('Missing required field "%s"' % name)
487 for e in elems:
488 remove_element(e)
490 update('name', True)
491 update('summary', True)
492 update('description', True)
493 update('homepage')
494 update('icon', attrs = {'type': 'image/png'}, value_attr = 'href')
496 uri = self.wTree.get_widget('feed_url').get_text()
497 if uri:
498 root.setAttribute('uri', uri)
499 elif root.hasAttribute('uri'):
500 root.removeAttribute('uri')
502 key_menu = self.wTree.get_widget('feed_key')
503 key_model = key_menu.get_model()
504 self.key = key_model[key_menu.get_active()][0]
506 def export_stylesheet_and_key(self):
507 dir = os.path.dirname(self.pathname)
508 stylesheet = os.path.join(dir, 'interface.xsl')
509 if not os.path.exists(stylesheet):
510 shutil.copyfile(stylesheet_src, stylesheet)
511 rox.info("I have saved a stylesheet as '%s'. You should upload "
512 "this to your web-server in the same directory as the feed file. "
513 "This allows browsers to display the feed nicely." % stylesheet)
515 exported = signing.export_key(dir, self.key)
516 if exported:
517 rox.info("I have exported your public key as '%s'. You should upload "
518 "this to your web-server in the same directory as the feed file. "
519 "This allows people to check the signature on your feed." % exported)
521 def save(self, callback = None):
522 self.update_doc()
523 if self.key:
524 sign = signing.sign_xml
525 self.export_stylesheet_and_key()
526 else:
527 sign = signing.sign_unsigned
528 data = xml_header + self.doc.documentElement.toxml() + '\n'
530 gen = sign(self.pathname, data, self.key, callback)
531 # May require interaction to get the pass-phrase, so run in the background...
532 if gen:
533 tasks.Task(gen)
535 def add_archive(self):
536 archive.AddArchiveBox(self)
538 def xds_load_from_file(self, path):
539 archive.AddArchiveBox(self, local_archive = path)
541 def remove_version(self, path = None):
542 elem = self.get_selected()
543 remove_element(elem)
544 self.update_version_model()
546 def get_selected(self):
547 tree = self.wTree.get_widget('impl_tree')
548 sel = tree.get_selection()
549 model, iter = sel.get_selected()
550 if not iter:
551 raise Exception('Select something first!')
552 return model[iter][1]
554 def find_implementation(self, id):
555 def find_impl(parent):
556 for x in child_elements(parent):
557 if x.namespaceURI != XMLNS_INTERFACE: continue
558 if x.localName == 'group':
559 sub = find_impl(x)
560 if sub: return sub
561 elif x.localName == 'implementation':
562 if x.getAttribute('id') == id:
563 return x
564 return find_impl(self.doc.documentElement)
566 def list_versions(self):
567 """Return a list of (version, element) pairs, one for each <implementation>."""
568 versions = []
570 def add_versions(parent, version):
571 for x in child_elements(parent):
572 if x.namespaceURI != XMLNS_INTERFACE: continue
573 if x.hasAttribute('version'): version = x.getAttribute('version')
574 if x.localName == 'group':
575 add_versions(x, version)
576 elif x.localName == 'implementation':
577 versions.append((model.parse_version(version), x))
579 add_versions(self.doc.documentElement, version = None)
581 return versions