Added 'Save and Test' button to version dialog. Allows testing that version directly.
[0publish-gui.git] / main.py
blobadf413c24067da5b96ec83685eb60ff1b5f92a95
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()
137 cell.set_property('ellipsize', pango.ELLIPSIZE_MIDDLE)
138 key_menu.pack_start(cell)
139 key_menu.add_attribute(cell, 'text', 1)
141 self.update_key_model()
143 self.impl_model = g.TreeStore(str, object)
144 impl_tree = self.wTree.get_widget('impl_tree')
145 impl_tree.set_model(self.impl_model)
146 text = g.CellRendererText()
147 column = g.TreeViewColumn('', text)
148 column.add_attribute(text, 'text', 0)
149 impl_tree.append_column(column)
151 impl_tree.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, [element_target], gtk.gdk.ACTION_MOVE)
152 impl_tree.enable_model_drag_dest([element_target], gtk.gdk.ACTION_MOVE)
154 sel = impl_tree.get_selection()
155 sel.set_mode(g.SELECTION_BROWSE)
157 if os.path.exists(self.pathname):
158 data, _, self.key = signing.check_signature(self.pathname)
159 self.doc = minidom.parseString(data)
160 self.update_fields()
162 # Default to showing the versions tab
163 self.wTree.get_widget('notebook').next_page()
164 else:
165 default_name = os.path.basename(self.pathname)
166 if default_name.endswith('.xml'):
167 default_name = default_name[:-4]
168 self.wTree.get_widget('feed_name').set_text(default_name)
169 self.doc = minidom.parseString(emptyFeed)
170 self.key = None
171 key_menu.set_active(0)
173 root = self.impl_model.get_iter_root()
174 if root:
175 sel.select_iter(root)
177 self.wTree.get_widget('generate_key').connect('clicked', lambda b: self.generate_key())
179 self.wTree.get_widget('add_implementation').connect('clicked', lambda b: self.add_version())
180 self.wTree.get_widget('add_archive').connect('clicked', lambda b: self.add_archive())
181 self.wTree.get_widget('add_requires').connect('clicked', lambda b: self.add_requires())
182 self.wTree.get_widget('add_group').connect('clicked', lambda b: self.add_group())
183 self.wTree.get_widget('edit_properties').connect('clicked', lambda b: self.edit_properties())
184 self.wTree.get_widget('remove').connect('clicked', lambda b: self.remove_version())
185 impl_tree.connect('row-activated', lambda tv, path, col: self.edit_properties(path))
186 impl_tree.connect('drag-data-received', self.tree_drag_data_received)
188 def update_key_model(self):
189 key_menu = self.wTree.get_widget('feed_key')
190 key_model = key_menu.get_model()
191 keys = signing.get_secret_keys()
192 key_model.clear()
193 key_model.append((None, '(unsigned)'))
194 for k in keys:
195 key_model.append(k)
197 def generate_key(self):
198 for x in ['xterm', 'konsole']:
199 if available_in_path(x):
200 child = subprocess.Popen([x, '-e', 'gpg', '--gen-key'], stderr = subprocess.PIPE)
201 break
202 else:
203 child = subprocess.Popen(['gnome-terminal', '-e', 'gpg --gen-key'], stderr = subprocess.PIPE)
205 def get_keygen_out():
206 errors = ''
207 while True:
208 yield signing.InputBlocker(child.stderr)
209 data = os.read(child.stderr.fileno(), 100)
210 if not data:
211 break
212 errors += data
213 self.update_key_model()
214 if errors:
215 rox.alert('Errors from terminal: %s' % errors)
217 tasks.Task(get_keygen_out())
219 def tree_drag_data_received(self, treeview, context, x, y, selection, info, time):
220 if not selection: return
221 drop_info = treeview.get_dest_row_at_pos(x, y)
222 if drop_info:
223 model = treeview.get_model()
224 path, position = drop_info
226 src = self.get_selected()
227 dest = model[path][1]
229 def is_ancestor_or_self(a, b):
230 while b:
231 if b is a: return True
232 b = b.parentNode
233 return False
235 if is_ancestor_or_self(src, dest):
236 # Can't move an element into itself!
237 return
239 if position in (gtk.TREE_VIEW_DROP_BEFORE, gtk.TREE_VIEW_DROP_AFTER):
240 new_parent = dest.parentNode
241 else:
242 new_parent = dest
244 if src.namespaceURI != XMLNS_INTERFACE: return
245 if new_parent.namespaceURI != XMLNS_INTERFACE: return
247 if new_parent.localName == 'group':
248 if src.localName not in ('implementation', 'group', 'requires'):
249 return
250 elif new_parent.localName == 'interface':
251 if src.localName not in ('implementation', 'group'):
252 return
253 elif new_parent.localName == 'implementation':
254 if src.localName not in ['requires']:
255 return
256 else:
257 return
259 remove_element(src)
261 if position == gtk.TREE_VIEW_DROP_BEFORE:
262 insert_before(src, dest)
263 elif position == gtk.TREE_VIEW_DROP_AFTER:
264 next = dest.nextSibling
265 while next and not next.nodeType == Node.ELEMENT_NODE:
266 next = next.nextSibling
267 if next:
268 insert_before(src, next)
269 else:
270 insert_element(src, new_parent)
271 else:
272 for next in child_elements(new_parent):
273 insert_before(src, next)
274 break
275 else:
276 insert_element(src, new_parent)
277 self.update_version_model()
279 def add_version(self):
280 ImplementationProperties(self)
282 def add_group(self):
283 ImplementationProperties(self, is_group = True)
285 def add_requires(self):
286 elem = self.get_selected()
287 if elem.namespaceURI == XMLNS_INTERFACE:
288 if elem.localName not in ('group', 'implementation'):
289 elem = elem.parentNode
290 if elem.localName in ('group', 'implementation'):
291 Requires(self, parent = elem)
292 return
293 rox.alert('Select a group or implementation!')
295 def edit_properties(self, path = None, element = None):
296 assert not (path and element)
298 if element:
299 pass
300 elif path is None:
301 element = self.get_selected()
302 else:
303 element = self.impl_model[path][1]
305 if element.namespaceURI != XMLNS_INTERFACE:
306 rox.alert("Sorry, I don't known how to edit %s elements!" % element.namespaceURI)
308 if element.localName in ('group', 'implementation'):
309 ImplementationProperties(self, element)
310 elif element.localName == 'requires':
311 Requires(self, parent = element.parentNode, element = element)
312 else:
313 rox.alert("Sorry, I don't known how to edit %s elements!" % element.localName)
315 def update_fields(self):
316 root = self.doc.documentElement
318 def set(name):
319 value = singleton_text(root, name)
320 if value:
321 self.wTree.get_widget('feed_' + name).set_text(value)
322 set('name')
323 set('summary')
324 set('homepage')
326 uri = root.getAttribute('uri')
327 if uri:
328 self.wTree.get_widget('feed_url').set_text(uri)
330 for icon in children(root, 'icon'):
331 if icon.getAttribute('type') == 'image/png':
332 href = icon.getAttribute('href')
333 self.wTree.get_widget('feed_icon').set_text(href)
334 break
336 description = singleton_text(root, 'description') or ''
337 paragraphs = [format_para(p) for p in description.split('\n\n')]
338 buffer = self.wTree.get_widget('feed_description').get_buffer()
339 buffer.delete(buffer.get_start_iter(), buffer.get_end_iter())
340 buffer.insert_at_cursor('\n'.join(paragraphs))
342 key_menu = self.wTree.get_widget('feed_key')
343 model = key_menu.get_model()
344 if self.key:
345 i = 0
346 for line in model:
347 if line[0] == self.key:
348 break
349 i += 1
350 else:
351 model.append((self.key, 'Missing key (%s)' % self.key))
352 key_menu.set_active(i)
353 else:
354 key_menu.set_active(0)
356 self.update_version_model()
358 def add_archives(self, impl_element, iter):
359 for child in child_elements(impl_element):
360 if child.namespaceURI != XMLNS_INTERFACE: continue
361 if child.localName == 'archive':
362 self.impl_model.append(iter, ['Archive ' + child.getAttribute('href'), child])
363 elif child.localName == 'requires':
364 req_iface = child.getAttribute('interface')
365 self.impl_model.append(iter, ['Impl requires %s' % req_iface, child])
366 else:
367 self.impl_model.append(iter, ['<%s>' % child.localName, child])
369 def update_version_model(self):
370 impl_tree = self.wTree.get_widget('impl_tree')
372 # Remember which ones are open
373 expanded_elements = set()
374 impl_tree.map_expanded_rows(lambda tv, path: expanded_elements.add(self.impl_model[path][1]))
376 initial_build = not self.impl_model.get_iter_root()
378 self.impl_model.clear()
379 to_expand = []
381 def add_impls(elem, iter, attrs):
382 """Add all groups, implementations and requirements in elem"""
384 for x in child_elements(elem):
385 if x.namespaceURI != XMLNS_INTERFACE: continue
387 if x.localName == 'requires':
388 req_iface = x.getAttribute('interface')
389 new = self.impl_model.append(iter, ['Group requires %s' % req_iface, x])
391 if x.localName not in ('implementation', 'group'): continue
393 new_attrs = attrs.copy()
394 attributes = x.attributes
395 for i in range(attributes.length):
396 a = attributes.item(i)
397 new_attrs[str(a.name)] = a.value
399 if x.localName == 'implementation':
400 version = new_attrs.get('version', '(missing version number)') + \
401 (new_attrs.get('version-modifier') or '')
402 new = self.impl_model.append(iter, ['Version %s%s' % (version, list_attrs(x)), x])
403 self.add_archives(x, new)
404 elif x.localName == 'group':
405 new = self.impl_model.append(iter, ['Group%s' % list_attrs(x), x])
406 if initial_build:
407 expanded_elements.add(x)
408 add_impls(x, new, new_attrs)
410 add_impls(self.doc.documentElement, None, attrs = {})
412 def may_expand(model, path, iter):
413 if model[iter][1] in expanded_elements:
414 impl_tree.expand_row(path, False)
415 self.impl_model.foreach(may_expand)
417 def test(self, args = []):
418 child = os.fork()
419 if child == 0:
420 try:
421 try:
422 # We are the child
423 # Spawn a grandchild and exit
424 subprocess.Popen(['0launch', '--gui'] + args + [self.pathname])
425 os._exit(0)
426 except:
427 traceback.print_exc()
428 finally:
429 os._exit(1)
430 pid, status = os.waitpid(child, 0)
431 assert pid == child
432 if status:
433 raise Exception('Failed to run 0launch - status code %d' % status)
435 def update_doc(self):
436 root = self.doc.documentElement
437 def update(name, required = False, attrs = {}, value_attr = None):
438 widget = self.wTree.get_widget('feed_' + name)
439 if isinstance(widget, g.TextView):
440 buffer = widget.get_buffer()
441 text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter())
442 paras = ['\n'.join(textwrap.wrap(para, 80)) for para in text.split('\n') if para.strip()]
443 value = '\n' + '\n\n'.join(paras)
444 else:
445 value = widget.get_text()
446 elems = list(children(root, name, attrs = attrs))
447 if value:
448 if elems:
449 elem = elems[0]
450 else:
451 elem = create_element(root, name,
452 before = ['group', 'implementation', 'requires'])
453 for x in attrs:
454 elem.setAttribute(x, attrs[x])
455 if value_attr:
456 elem.setAttribute(value_attr, value)
457 set_data(elem, None)
458 else:
459 set_data(elem, value)
460 else:
461 if required:
462 raise Exception('Missing required field "%s"' % name)
463 for e in elems:
464 remove_element(e)
466 update('name', True)
467 update('summary', True)
468 update('description', True)
469 update('homepage')
470 update('icon', attrs = {'type': 'image/png'}, value_attr = 'href')
472 uri = self.wTree.get_widget('feed_url').get_text()
473 if uri:
474 root.setAttribute('uri', uri)
475 elif root.hasAttribute('uri'):
476 root.removeAttribute('uri')
478 key_menu = self.wTree.get_widget('feed_key')
479 key_model = key_menu.get_model()
480 self.key = key_model[key_menu.get_active()][0]
482 def export_stylesheet_and_key(self):
483 dir = os.path.dirname(self.pathname)
484 stylesheet = os.path.join(dir, 'interface.xsl')
485 if not os.path.exists(stylesheet):
486 shutil.copyfile(stylesheet_src, stylesheet)
487 rox.info("I have saved a stylesheet as '%s'. You should upload "
488 "this to your web-server in the same directory as the feed file. "
489 "This allows browsers to display the feed nicely." % stylesheet)
491 exported = signing.export_key(dir, self.key)
492 if exported:
493 rox.info("I have exported your public key as '%s'. You should upload "
494 "this to your web-server in the same directory as the feed file. "
495 "This allows people to check the signature on your feed." % exported)
497 def save(self, callback = None):
498 self.update_doc()
499 if self.key:
500 sign = signing.sign_xml
501 self.export_stylesheet_and_key()
502 else:
503 sign = signing.sign_unsigned
504 data = xml_header + self.doc.documentElement.toxml() + '\n'
506 gen = sign(self.pathname, data, self.key, callback)
507 # May require interaction to get the pass-phrase, so run in the background...
508 if gen:
509 tasks.Task(gen)
511 def add_archive(self):
512 archive.AddArchiveBox(self)
514 def xds_load_from_file(self, path):
515 archive.AddArchiveBox(self, local_archive = path)
517 def remove_version(self, path = None):
518 elem = self.get_selected()
519 remove_element(elem)
520 self.update_version_model()
522 def get_selected(self):
523 tree = self.wTree.get_widget('impl_tree')
524 sel = tree.get_selection()
525 model, iter = sel.get_selected()
526 if not iter:
527 raise Exception('Select something first!')
528 return model[iter][1]
530 def find_implementation(self, id):
531 def find_impl(parent):
532 for x in child_elements(parent):
533 if x.namespaceURI != XMLNS_INTERFACE: continue
534 if x.localName == 'group':
535 sub = find_impl(x)
536 if sub: return sub
537 elif x.localName == 'implementation':
538 if x.getAttribute('id') == id:
539 return x
540 return find_impl(self.doc.documentElement)
542 def list_versions(self):
543 """Return a list of (version, element) pairs, one for each <implementation>."""
544 versions = []
546 def add_versions(parent, version):
547 for x in child_elements(parent):
548 if x.namespaceURI != XMLNS_INTERFACE: continue
549 if x.hasAttribute('version'): version = x.getAttribute('version')
550 if x.localName == 'group':
551 add_versions(x, version)
552 elif x.localName == 'implementation':
553 versions.append((model.parse_version(version), x))
555 add_versions(self.doc.documentElement, version = None)
557 return versions