Added missing application/x-xz-compressed-tar menu option
[0publish-gui.git] / archive.py
blobd6f60285f8506c8b2322816aaea6a283147fba11
1 import re
3 import rox, os, urlparse, tempfile, time, urllib
4 from rox import g, tasks
5 import gtk.glade
7 from zeroinstall.support import ro_rmtree
8 from zeroinstall.injector import model
9 from zeroinstall.zerostore import unpack, manifest, NotStored
11 from logging import warn
12 import signing
13 from xmltools import *
14 import main
16 dotted_ints = '[0-9]+(\\.[0-9]+)*'
17 version_regexp = '[^a-zA-Z0-9](%s)(-(pre|rc|post|)%s)*' % (dotted_ints, dotted_ints)
19 def get_combo_value(combo):
20 i = combo.get_active()
21 m = combo.get_model()
22 return m[i][0]
24 watch = gtk.gdk.Cursor(gtk.gdk.WATCH)
26 def autopackage_get_details(package):
27 size = None
28 type = 'application/x-bzip-compressed-tar'
29 for line in file(package):
30 if line.startswith('export dataSize=') or line.startswith('export data_size='):
31 size = os.path.getsize(package) - int(line.split('"', 2)[1])
32 elif line.startswith('compression=') and 'lzma' in line:
33 type = 'application/x-lzma-compressed-tar'
34 if line.startswith('## END OF STUB'): break
35 if size is None:
36 raise Exception("Can't find payload in autopackage (missing 'dataSize')")
37 return size, type
39 def try_parse_version(version_str):
40 try:
41 return model.parse_version(version_str)
42 except model.SafeException, ex:
43 warn("Bad version number '%s'", ex)
44 return None
46 def sanity_check_tar_file(path, start_offset):
47 import subprocess
48 with open(path, 'rb') as stream:
49 stream.seek(start_offset)
50 child = subprocess.Popen(['tar', 'tf', path], stdout = subprocess.PIPE)
51 stdout, unused = child.communicate(None)
52 child.wait()
53 first = stdout.split('\n', 1)[0]
54 if first.startswith('./'):
55 rox.alert('WARNING: leading "./" in tar archive member names (e.g. "%s"); extracting a subdirectory may not work. Consider recreating the archive without the prefix.' % first)
57 class AddArchiveBox:
58 def __init__(self, feed_editor, local_archive = None):
59 self.feed_editor = feed_editor
60 self.tmpdir = None
61 self.mime_type = self.start_offset = None
63 widgets = gtk.glade.XML(main.gladefile, 'add_archive')
65 tree = widgets.get_widget('extract_list')
66 model = g.TreeStore(str)
67 tree.set_model(model)
68 selection = tree.get_selection()
69 selection.set_mode(g.SELECTION_BROWSE)
71 cell = g.CellRendererText()
72 col = g.TreeViewColumn('Extract', cell)
73 col.add_attribute(cell, 'text', 0)
74 tree.append_column(col)
76 dialog = widgets.get_widget('add_archive')
78 mime_type = widgets.get_widget('mime_type')
79 mime_type.set_active(0)
81 def local_archive_changed(chooser):
82 model.clear()
83 path = chooser.get_filename()
84 widgets.get_widget('subdirectory_frame').set_sensitive(False)
85 self.destroy_tmp()
86 if not path: return
88 if mime_type.get_active() == 0:
89 type = None
90 else:
91 type = mime_type.get_active_text()
93 archive_url = widgets.get_widget('archive_url')
94 url = archive_url.get_text()
95 if not url:
96 url = 'http://SITE/' + os.path.basename(path)
97 archive_url.set_text(url)
99 start_offset = 0
100 if not type:
101 if url.endswith('.package'):
102 type = 'Autopackage'
103 else:
104 type = unpack.type_from_url(url)
106 if type == 'Autopackage':
107 # Autopackage isn't a real type. Examine the .package file
108 # and find out what it really is.
109 start_offset, type = autopackage_get_details(path)
111 if type.endswith('-tar'):
112 sanity_check_tar_file(path, start_offset = start_offset)
114 self.tmpdir = tempfile.mkdtemp('-0publish-gui')
115 try:
116 # Must be readable to helper process running as 'zeroinst'...
117 old_umask = os.umask(0022)
118 try:
119 unpack_dir = os.path.join(self.tmpdir, 'unpacked')
120 os.mkdir(unpack_dir)
122 dialog.window.set_cursor(watch)
123 gtk.gdk.flush()
124 try:
125 unpack.unpack_archive(url, file(path), unpack_dir,
126 type = type, start_offset = start_offset)
127 manifest.fixup_permissions(unpack_dir)
128 finally:
129 dialog.window.set_cursor(None)
130 finally:
131 os.umask(old_umask)
132 except:
133 chooser.unselect_filename(path)
134 self.destroy_tmp()
135 raise
136 iter = model.append(None, ['Everything'])
137 items = os.listdir(unpack_dir)
138 for f in items:
139 model.append(iter, [f])
140 tree.expand_all()
141 # Choose a sensible default
142 iter = model.get_iter_root()
143 if len(items) == 1 and \
144 os.path.isdir(os.path.join(unpack_dir, items[0])) and \
145 items[0] not in ('usr', 'opt', 'bin', 'etc', 'sbin', 'doc', 'var'):
146 iter = model.iter_children(iter)
147 selection.select_iter(iter)
149 self.mime_type = type
150 self.start_offset = start_offset
151 widgets.get_widget('subdirectory_frame').set_sensitive(True)
153 local_archive_button = widgets.get_widget('local_archive')
154 local_archive_button.connect('selection-changed', local_archive_changed)
155 widgets.get_widget('subdirectory_frame').set_sensitive(False)
157 def download(button):
158 url = widgets.get_widget('archive_url').get_text()
159 if not url:
160 raise Exception("Enter a URL to download from!")
162 chooser = g.FileChooserDialog('Save archive as...', dialog, g.FILE_CHOOSER_ACTION_SAVE)
163 chooser.set_current_folder(os.getcwd()) # Needed with GTK 2.24 to avoid "Recently Used" thing
164 chooser.set_current_name(os.path.basename(url))
165 chooser.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
166 chooser.add_button(g.STOCK_SAVE, g.RESPONSE_OK)
167 chooser.set_default_response(g.RESPONSE_OK)
168 resp = chooser.run()
169 filename = chooser.get_filename()
170 chooser.destroy()
171 if resp != g.RESPONSE_OK:
172 return
174 DownloadBox(url, filename, local_archive_button, dialog)
176 widgets.get_widget('download').connect('clicked', download)
178 def resp(dialog, r):
179 if r == g.RESPONSE_OK:
180 if not self.tmpdir:
181 rox.alert("Archive not downloaded yet!")
182 return
183 unpack_dir = os.path.join(self.tmpdir, 'unpacked')
185 url = widgets.get_widget('archive_url').get_text()
186 if urlparse.urlparse(url)[1] == '':
187 raise Exception('Missing host name in URL "%s"' % url)
188 if urlparse.urlparse(url)[2] == '':
189 raise Exception('Missing resource part in URL "%s"' % url)
190 local_archive = widgets.get_widget('local_archive').get_filename()
191 if not local_archive:
192 raise Exception('Please select a local file')
193 if selection.iter_is_selected(model.get_iter_root()):
194 root = unpack_dir
195 extract = None
196 else:
197 _, iter = selection.get_selected()
198 extract = model[iter][0]
199 root = os.path.join(unpack_dir, extract)
201 size = os.path.getsize(local_archive)
202 if self.start_offset:
203 size -= self.start_offset
204 self.create_archive_element(url, self.mime_type, root, extract, size,
205 self.start_offset)
206 self.destroy_tmp()
207 dialog.destroy()
209 dialog.connect('response', resp)
211 if local_archive:
212 local_archive_button.set_filename(local_archive)
213 initial_url = 'http://SITE/' + os.path.basename(local_archive)
214 widgets.get_widget('archive_url').set_text(initial_url)
216 def destroy_tmp(self):
217 if self.tmpdir:
218 ro_rmtree(self.tmpdir)
219 self.tmpdir = None
221 def create_archive_element(self, url, mime_type, root, extract, size, start_offset):
222 alg = manifest.get_algorithm('sha1new')
223 digest = alg.new_digest()
224 for line in alg.generate_manifest(root):
225 digest.update(line + '\n')
226 id = alg.getID(digest)
228 # Add it to the cache if missing
229 # Helps with setting 'main' attribute later
230 try:
231 main.stores.lookup(id)
232 except NotStored:
233 main.stores.add_dir_to_cache(id, root)
235 # Do we already have an implementation with this digest?
236 impl_element = self.feed_editor.find_implementation(id)
238 if impl_element is None:
239 # No. Create a new implementation. Guess the details...
241 leaf = url.split('/')[-1]
242 version = None
243 for m in re.finditer(version_regexp, leaf):
244 match = m.group()[1:]
245 if version is None or len(version) < len(match):
246 version = match
248 existing_versions = self.feed_editor.list_versions()
249 older_versions = []
250 if existing_versions and version:
251 parsed_version = try_parse_version(version)
252 if parsed_version:
253 older_versions = [(v, elem) for v, elem in existing_versions if v < parsed_version]
255 impl_element = self.feed_editor.doc.createElementNS(XMLNS_INTERFACE, 'implementation')
257 if older_versions:
258 # Try to add it just after the previous version's element in the XML document
259 insert_after(impl_element, max(older_versions)[1])
260 elif existing_versions:
261 # Else add it before the first
262 insert_before(impl_element, min(existing_versions)[1])
263 else:
264 # Put it in the root
265 insert_element(impl_element, self.feed_editor.doc.documentElement)
267 impl_element.setAttribute('id', id)
268 impl_element.setAttribute('released', time.strftime('%Y-%m-%d'))
269 impl_element.setAttribute('version', version or '0.1')
270 created_impl = True
271 else:
272 created_impl = False
274 archive_element = create_element(impl_element, 'archive')
275 archive_element.setAttribute('size', str(size))
276 archive_element.setAttribute('href', url)
277 if extract: archive_element.setAttribute('extract', extract)
278 if mime_type: archive_element.setAttribute('type', mime_type)
279 if start_offset: archive_element.setAttribute('start-offset', str(start_offset))
281 self.feed_editor.update_version_model()
283 if created_impl:
284 self.feed_editor.edit_properties(element = impl_element)
286 class DownloadBox:
287 def __init__(self, url, path, archive_button, parent):
288 widgets = gtk.glade.XML(main.gladefile, 'download')
289 gtk.gdk.flush()
291 output = file(path, 'w')
293 dialog = widgets.get_widget('download')
294 dialog.set_transient_for(parent)
295 progress = widgets.get_widget('progress')
297 cancelled = tasks.Blocker()
298 def resp(box, resp):
299 cancelled.trigger()
300 dialog.connect('response', resp)
302 def download():
303 stream = None
305 def cleanup():
306 dialog.destroy()
307 if output:
308 output.close()
309 if stream:
310 stream.close()
312 try:
313 # (urllib2 is buggy; no fileno)
314 stream = urllib.urlopen(url)
315 size = float(stream.info().get('Content-Length', None))
316 got = 0
318 while True:
319 yield tasks.InputBlocker(stream), cancelled
320 if cancelled.happened:
321 raise Exception("Download cancelled at user's request")
322 data = os.read(stream.fileno(), 1024)
323 if not data: break
324 output.write(data)
325 got += len(data)
327 if size:
328 progress.set_fraction(got / size)
329 else:
330 progress.pulse()
331 except:
332 # No finally in python 2.4
333 cleanup()
334 raise
335 else:
336 cleanup()
337 archive_button.set_filename(path)
339 dialog.show()
340 tasks.Task(download())