1 """Display the contents of the implementation cache."""
2 # Copyright (C) 2009, Thomas Leonard
3 # See the README file for details, or visit http://0install.net.
5 from __future__
import print_function
7 from zeroinstall
import _
11 from zeroinstall
.injector
import namespaces
, model
12 from zeroinstall
.zerostore
import BadDigest
, manifest
13 from zeroinstall
import support
14 from zeroinstall
.support
import basedir
15 from zeroinstall
.gtkui
import help_box
, gtkutils
17 __all__
= ['CacheExplorer']
19 ROX_IFACE
= 'http://rox.sourceforge.net/2005/interfaces/ROX-Filer'
24 def __init__(self
, name
, column_type
, resizable
=False, props
={}, hide
=False, markup
=False):
25 self
.idx
= len(self
.columns
)
26 self
.columns
.append(self
)
28 self
.column_type
= column_type
30 self
.resizable
= resizable
35 def column_types(cls
):
36 return [col
.column_type
for col
in cls
.columns
]
39 def add_all(cls
, tree_view
):
40 [col
.add(tree_view
) for col
in cls
.columns
]
43 cell
= gtk
.CellRendererText()
44 self
.set_props(cell
, self
.props
)
47 def set_props(self
, obj
, props
):
48 for k
,v
in props
.items():
49 obj
.set_property(k
, v
)
53 kwargs
= {'markup': self
.idx
}
55 kwargs
= {'text': self
.idx
}
56 column
= gtk
.TreeViewColumn(self
.name
, self
.get_cell(), **kwargs
)
57 if 'xalign' in self
.props
:
58 self
.set_props(column
, {'alignment': self
.props
['xalign']})
61 def add(self
, tree_view
):
64 column
= self
.get_column()
65 if self
.resizable
: column
.set_resizable(True)
66 tree_view
.append_column(column
)
68 NAME
= Column(_('Name'), str, hide
=True)
69 URI
= Column(_('URI'), str, hide
=True)
70 TOOLTIP
= Column(_('Description'), str, hide
=True)
71 ITEM_VIEW
= Column(_('Item'), str, props
={'ypad': 6, 'yalign': 0}, resizable
=True, markup
=True)
72 SELF_SIZE
= Column(_('Self Size'), int, hide
=True)
73 TOTAL_SIZE
= Column(_('Total Size'), int, hide
=True)
74 PRETTY_SIZE
= Column(_('Size'), str, props
={'xalign':1.0})
75 ITEM_OBJECT
= Column(_('Object'), object, hide
=True)
77 ACTION_REMOVE
= object() # just make a unique value
79 class Section(object):
81 def __init__(self
, name
, tooltip
):
83 self
.tooltip
= tooltip
85 def append_to(self
, model
):
86 return model
.append(None, extract_columns(
92 SECTION_INTERFACES
= Section(
94 _("Feeds in the cache"))
95 SECTION_UNOWNED_IMPLEMENTATIONS
= Section(
96 _("Unowned implementations and temporary files"),
97 _("These probably aren't needed any longer. You can delete them."))
98 SECTION_INVALID_INTERFACES
= Section(
99 _("Invalid feeds (unreadable)"),
100 _("These feeds exist in the cache but cannot be read. You should probably delete them."))
103 def extract_columns(**d
):
104 vals
= list(map(lambda x
:None, Column
.columns
))
105 def setcol(column
, val
):
106 vals
[column
.idx
] = val
108 name
= d
.get('name', None)
109 desc
= d
.get('desc', None)
110 uri
= d
.get('uri', None)
115 setcol(ITEM_VIEW
, '<span font-size="larger" weight="bold">%s</span>\n'
116 '<span color="#666666">%s</span>' % tuple(map(cgi
.escape
, (name
, uri
))))
118 setcol(ITEM_VIEW
, cgi
.escape(name
or desc
))
120 size
= d
.get('size', 0)
121 setcol(SELF_SIZE
, size
)
122 setcol(TOTAL_SIZE
, 0) # must be set to prevent type error
123 setcol(TOOLTIP
, d
.get('tooltip', None))
124 setcol(ITEM_OBJECT
, d
.get('object', None))
128 def popup_menu(bev
, obj
, model
, path
, cache_explorer
):
130 for i
in obj
.menu_items
:
132 item
= gtk
.SeparatorMenuItem()
135 item
= gtk
.MenuItem()
137 def _cb(item
, cb
=cb
):
138 action_required
= cb(obj
, cache_explorer
)
139 if action_required
is ACTION_REMOVE
:
140 model
.remove(model
.get_iter(path
))
141 item
.connect('activate', _cb
)
144 menu
.popup(None, None, None, bev
.button
, bev
.time
)
146 def warn(message
, parent
=None):
147 "Present a blocking warning message with OK/Cancel buttons, and return True if OK was pressed"
148 dialog
= gtk
.MessageDialog(parent
=parent
, buttons
=gtk
.BUTTONS_OK_CANCEL
, type=gtk
.MESSAGE_WARNING
)
149 dialog
.set_property('text', message
)
151 def _response(dialog
, resp
):
152 if resp
== gtk
.RESPONSE_OK
:
153 response
.append(True)
154 dialog
.connect('response', _response
)
157 return bool(response
)
159 def size_if_exists(path
):
160 "Get the size for a file, or 0 if it doesn't exist."
161 if path
and os
.path
.isfile(path
):
162 return os
.path
.getsize(path
)
166 "Get the size for a directory tree. Get the size from the .manifest if possible."
167 man
= os
.path
.join(path
, '.manifest')
168 if os
.path
.exists(man
):
169 size
= os
.path
.getsize(man
)
170 for line
in open(man
, 'rb'):
172 size
+= int(line
.split(' ', 4)[3])
175 for root
, dirs
, files
in os
.walk(path
):
177 size
+= os
.path
.getsize(os
.path
.join(root
, name
))
182 return feed
.get_name() + ' - ' + feed
.summary
183 return feed
.get_name()
185 def get_selected_paths(tree_view
):
186 "GTK 2.0 doesn't have this built-in"
187 selection
= tree_view
.get_selection()
189 def add(model
, path
, iter):
191 selection
.selected_foreach(add
)
194 def all_children(model
, iter):
195 "make a python generator out of the children of `iter`"
196 iter = model
.iter_children(iter)
199 iter = model
.iter_next(iter)
203 SAFE_MODE
= False # really delete things
204 #SAFE_MODE = True # print deletes, instead of performing them
206 class CachedFeed(object):
207 def __init__(self
, uri
, size
):
212 if not os
.path
.isabs(self
.uri
):
213 cached_iface
= basedir
.load_first_cache(namespaces
.config_site
,
214 'interfaces', model
.escape(self
.uri
))
217 print("Delete", cached_iface
)
219 os
.unlink(cached_iface
)
220 user_overrides
= basedir
.load_first_config(namespaces
.config_site
,
221 namespaces
.config_prog
,
222 'interfaces', model
._pretty
_escape
(self
.uri
))
225 print("Delete", user_overrides
)
227 os
.unlink(user_overrides
)
229 def __cmp__(self
, other
):
230 return self
.uri
.__cmp
__(other
.uri
)
232 class ValidFeed(CachedFeed
):
233 def __init__(self
, feed
, size
):
234 CachedFeed
.__init
__(self
, feed
.url
, size
)
238 def delete_children(self
):
239 deletable
= self
.deletable_children()
240 undeletable
= list(filter(lambda child
: not child
.may_delete
, self
.in_cache
))
241 # the only undeletable items we expect to encounter are LocalImplementations
242 unexpected_undeletable
= list(filter(lambda child
: not isinstance(child
, LocalImplementation
), undeletable
))
243 assert not unexpected_undeletable
, "unexpected undeletable items!: %r" % (unexpected_undeletable
,)
244 [child
.delete() for child
in deletable
]
247 self
.delete_children()
248 super(ValidFeed
, self
).delete()
250 def append_to(self
, model
, iter):
251 iter2
= model
.append(iter, extract_columns(
252 name
=self
.feed
.get_name(),
254 tooltip
=self
.feed
.summary
,
256 for cached_impl
in self
.in_cache
:
257 cached_impl
.append_to(model
, iter2
)
259 def launch(self
, explorer
):
260 os
.spawnlp(os
.P_NOWAIT
, '0launch', '0launch', '--gui', self
.uri
)
262 def copy_uri(self
, explorer
):
263 clipboard
= gtk
.clipboard_get()
264 clipboard
.set_text(self
.uri
)
265 primary
= gtk
.clipboard_get('PRIMARY')
266 primary
.set_text(self
.uri
)
268 def deletable_children(self
):
269 return list(filter(lambda child
: child
.may_delete
, self
.in_cache
))
271 def prompt_delete(self
, cache_explorer
):
272 description
= "\"%s\"" % (self
.feed
.get_name(),)
273 num_children
= len(self
.deletable_children())
275 description
+= _(" (and %s %s)") % (num_children
, _("implementation") if num_children
== 1 else _("implementations"))
276 if warn(_("Really delete %s?") % (description
,), parent
=cache_explorer
.window
):
280 menu_items
= [(_('Launch with GUI'), launch
),
281 (_('Copy URI'), copy_uri
),
282 (_('Delete'), prompt_delete
)]
284 class RemoteFeed(ValidFeed
):
287 class LocalFeed(ValidFeed
):
290 class InvalidFeed(CachedFeed
):
293 def __init__(self
, uri
, ex
, size
):
294 CachedFeed
.__init
__(self
, uri
, size
)
297 def append_to(self
, model
, iter):
298 model
.append(iter, extract_columns(
299 name
=self
.uri
.rsplit('/', 1)[-1],
305 class LocalImplementation
:
308 def __init__(self
, impl
):
311 def append_to(self
, model
, iter):
312 model
.append(iter, extract_columns(
313 name
=self
.impl
.local_path
,
314 tooltip
=_('This is a local version, not held in the cache.'),
318 class CachedImplementation
:
321 def __init__(self
, cache_dir
, digest
):
322 self
.impl_path
= os
.path
.join(cache_dir
, digest
)
323 self
.size
= get_size(self
.impl_path
)
328 print("Delete", self
.impl_path
)
330 support
.ro_rmtree(self
.impl_path
)
332 def open_rox(self
, explorer
):
333 os
.spawnlp(os
.P_WAIT
, '0launch', '0launch', ROX_IFACE
, '-d', self
.impl_path
)
335 def verify(self
, explorer
):
337 manifest
.verify(self
.impl_path
)
338 except BadDigest
as ex
:
339 box
= gtk
.MessageDialog(None, 0,
340 gtk
.MESSAGE_WARNING
, gtk
.BUTTONS_OK
, str(ex
))
342 swin
= gtk
.ScrolledWindow()
343 buffer = gtk
.TextBuffer()
344 mono
= buffer.create_tag('mono', family
= 'Monospace')
345 buffer.insert_with_tags(buffer.get_start_iter(), ex
.detail
, mono
)
346 text
= gtk
.TextView(buffer)
347 text
.set_editable(False)
348 text
.set_cursor_visible(False)
350 swin
.set_shadow_type(gtk
.SHADOW_IN
)
351 swin
.set_border_width(4)
352 box
.vbox
.pack_start(swin
)
354 box
.set_resizable(True)
356 box
= gtk
.MessageDialog(None, 0,
357 gtk
.MESSAGE_INFO
, gtk
.BUTTONS_OK
,
358 _('Contents match digest; nothing has been changed.'))
362 def prompt_delete(self
, explorer
):
363 if warn(_("Really delete implementation?"), parent
=explorer
.window
):
367 menu_items
= [(_('Open in ROX-Filer'), open_rox
),
368 (_('Verify integrity'), verify
),
369 (_('Delete'), prompt_delete
)]
371 class UnusedImplementation(CachedImplementation
):
372 def append_to(self
, model
, iter):
373 model
.append(iter, extract_columns(
376 tooltip
=self
.impl_path
,
379 class KnownImplementation(CachedImplementation
):
380 def __init__(self
, cached_iface
, cache_dir
, impl
, impl_size
, digest
):
381 CachedImplementation
.__init
__(self
, cache_dir
, digest
)
382 self
.cached_iface
= cached_iface
384 self
.size
= impl_size
388 print("Delete", self
.impl
)
390 CachedImplementation
.delete(self
)
391 self
.cached_iface
.in_cache
.remove(self
)
393 def append_to(self
, model
, iter):
395 label
= _('Version %(implementation_version)s (%(arch)s)') % {
396 'implementation_version': impl
.get_version(),
397 'arch': impl
.arch
or 'any platform'}
399 model
.append(iter, extract_columns(
402 tooltip
=self
.impl_path
,
405 def __cmp__(self
, other
):
406 if hasattr(other
, 'impl'):
407 return self
.impl
.__cmp
__(other
.impl
)
411 """A graphical interface for viewing the cache and deleting old items."""
413 def __init__(self
, iface_cache
):
414 widgets
= gtkutils
.Template(os
.path
.join(os
.path
.dirname(__file__
), 'cache.ui'), 'cache')
415 self
.window
= window
= widgets
.get_widget('cache')
416 window
.set_default_size(gtk
.gdk
.screen_width() / 2, gtk
.gdk
.screen_height() / 2)
417 self
.iface_cache
= iface_cache
420 self
.raw_model
= gtk
.TreeStore(*Column
.column_types())
421 self
.view_model
= self
.raw_model
.filter_new()
422 self
.model
.set_sort_column_id(URI
.idx
, gtk
.SORT_ASCENDING
)
423 self
.tree_view
= widgets
.get_widget('treeview')
424 self
.tree_view
.set_model(self
.view_model
)
425 Column
.add_all(self
.tree_view
)
427 # Sort / Filter options:
429 def init_combo(combobox
, items
, on_select
):
430 liststore
= gtk
.ListStore(str)
431 combobox
.set_model(liststore
)
432 cell
= gtk
.CellRendererText()
433 combobox
.pack_start(cell
, True)
434 combobox
.add_attribute(cell
, 'text', 0)
436 combobox
.append_text(item
[0])
437 combobox
.set_active(0)
439 selected_item
= combobox
.get_active()
440 on_select(selected_item
)
441 combobox
.connect('changed', lambda *a
: on_select(items
[combobox
.get_active()]))
443 def set_sort_order(sort_order
):
444 #print "SORT: %r" % (sort_order,)
445 name
, column
, order
= sort_order
446 self
.model
.set_sort_column_id(column
.idx
, order
)
447 self
.sort_combo
= widgets
.get_widget('sort_combo')
448 init_combo(self
.sort_combo
, SORT_OPTIONS
, set_sort_order
)
451 #print "FILTER: %r" % (f,)
452 description
, filter_func
= f
453 self
.view_model
= self
.model
.filter_new()
454 self
.view_model
.set_visible_func(filter_func
)
455 self
.tree_view
.set_model(self
.view_model
)
456 self
.set_initial_expansion()
457 self
.filter_combo
= widgets
.get_widget('filter_combo')
458 init_combo(self
.filter_combo
, FILTER_OPTIONS
, set_filter
)
460 def button_press(tree_view
, bev
):
463 pos
= tree_view
.get_path_at_pos(int(bev
.x
), int(bev
.y
))
466 path
, col
, x
, y
= pos
467 obj
= self
.model
[path
][ITEM_OBJECT
.idx
]
468 if obj
and hasattr(obj
, 'menu_items'):
469 popup_menu(bev
, obj
, model
=self
.model
, path
=path
, cache_explorer
=self
)
470 self
.tree_view
.connect('button-press-event', button_press
)
473 window
.set_default_response(gtk
.RESPONSE_CLOSE
)
475 selection
= self
.tree_view
.get_selection()
476 def selection_changed(selection
):
478 for x
in get_selected_paths(self
.tree_view
):
479 obj
= self
.model
[x
][ITEM_OBJECT
.idx
]
480 if obj
is None or not obj
.may_delete
:
481 window
.set_response_sensitive(DELETE
, False)
484 window
.set_response_sensitive(DELETE
, any_selected
)
485 selection
.set_mode(gtk
.SELECTION_MULTIPLE
)
486 selection
.connect('changed', selection_changed
)
487 selection_changed(selection
)
489 def response(dialog
, resp
):
490 if resp
== gtk
.RESPONSE_CLOSE
:
492 elif resp
== gtk
.RESPONSE_HELP
:
496 window
.connect('response', response
)
500 return self
.view_model
.get_model()
506 paths
= get_selected_paths(self
.tree_view
)
509 item
= model
[path
][ITEM_OBJECT
.idx
]
513 except OSError as ex
:
514 errors
.append(str(ex
))
516 model
.remove(model
.get_iter(path
))
520 gtkutils
.show_message_box(self
.window
, _("Failed to delete:\n%s") % '\n'.join(errors
))
523 """Display the window and scan the caches to populate it."""
525 self
.window
.window
.set_cursor(gtkutils
.get_busy_pointer())
527 self
._populate
_model
()
528 self
.set_initial_expansion()
530 def set_initial_expansion(self
):
533 i
= model
.get_iter_root()
535 # expand only "Feeds"
536 if model
[i
][ITEM_OBJECT
.idx
] is SECTION_INTERFACES
:
537 self
.tree_view
.expand_row(model
.get_path(i
), False)
538 i
= model
.iter_next(i
)
540 self
.window
.window
.set_cursor(None)
542 def _populate_model(self
):
543 # Find cached implementations
545 unowned
= {} # Impl ID -> Store
546 duplicates
= [] # TODO
548 for s
in self
.iface_cache
.stores
.stores
:
549 if os
.path
.isdir(s
.dir):
550 for id in os
.listdir(s
.dir):
552 duplicates
.append(id)
558 # Look through cached feeds for implementation owners
559 all_interfaces
= self
.iface_cache
.list_all_interfaces()
561 for uri
in all_interfaces
:
563 iface
= self
.iface_cache
.get_interface(uri
)
564 except Exception as ex
:
565 error_feeds
.append((uri
, str(ex
), 0))
567 all_feeds
.update(self
.iface_cache
.get_feeds(iface
))
569 for url
, feed
in all_feeds
.items():
570 if not feed
: continue
574 # (e.g. for .new feeds)
575 raise Exception('Incorrect URL for feed (%s vs %s)' % (url
, feed
.url
))
577 if os
.path
.isabs(url
):
579 feed_type
= LocalFeed
581 feed_type
= RemoteFeed
582 cached_feed
= basedir
.load_first_cache(namespaces
.config_site
,
583 'interfaces', model
.escape(url
))
584 user_overrides
= basedir
.load_first_config(namespaces
.config_site
,
585 namespaces
.config_prog
,
586 'interfaces', model
._pretty
_escape
(url
))
588 feed_size
= size_if_exists(cached_feed
) + size_if_exists(user_overrides
)
589 except Exception as ex
:
590 error_feeds
.append((url
, str(ex
), feed_size
))
592 cached_feed
= feed_type(feed
, feed_size
)
593 for impl
in feed
.implementations
.values():
595 cached_feed
.in_cache
.append(LocalImplementation(impl
))
596 for digest
in impl
.digests
:
597 if digest
in unowned
:
598 cached_dir
= unowned
[digest
].dir
599 impl_path
= os
.path
.join(cached_dir
, digest
)
600 impl_size
= get_size(impl_path
)
601 cached_feed
.in_cache
.append(KnownImplementation(cached_feed
, cached_dir
, impl
, impl_size
, digest
))
603 cached_feed
.in_cache
.sort()
604 ok_feeds
.append(cached_feed
)
607 iter = SECTION_INVALID_INTERFACES
.append_to(self
.raw_model
)
608 for uri
, ex
, size
in error_feeds
:
609 item
= InvalidFeed(uri
, ex
, size
)
610 item
.append_to(self
.raw_model
, iter)
613 local_dir
= os
.path
.join(basedir
.xdg_cache_home
, '0install.net', 'implementations')
615 if unowned
[id].dir == local_dir
:
616 impl
= UnusedImplementation(local_dir
, id)
617 unowned_sizes
.append((impl
.size
, impl
))
619 iter = SECTION_UNOWNED_IMPLEMENTATIONS
.append_to(self
.raw_model
)
620 for size
, item
in unowned_sizes
:
621 item
.append_to(self
.raw_model
, iter)
624 iter = SECTION_INTERFACES
.append_to(self
.raw_model
)
625 for item
in ok_feeds
:
626 item
.append_to(self
.raw_model
, iter)
629 def _update_sizes(self
):
630 """Set TOTAL_SIZE and PRETTY_SIZE to the total size, including all children."""
633 total
= m
[itr
][SELF_SIZE
.idx
]
634 total
+= sum(map(update
, all_children(m
, itr
)))
635 m
[itr
][PRETTY_SIZE
.idx
] = support
.pretty_size(total
) if total
else '-'
636 m
[itr
][TOTAL_SIZE
.idx
] = total
638 itr
= m
.get_iter_root()
641 itr
= m
.iter_next(itr
)
645 ('URI', URI
, gtk
.SORT_ASCENDING
),
646 ('Name', NAME
, gtk
.SORT_ASCENDING
),
647 ('Size', TOTAL_SIZE
, gtk
.SORT_DESCENDING
),
651 def filter_only(filterable_types
, filter_func
):
652 def _filter(model
, iter):
653 obj
= model
.get_value(iter, ITEM_OBJECT
.idx
)
654 if any((isinstance(obj
, t
) for t
in filterable_types
)):
655 result
= filter_func(model
, iter)
661 return lambda *a
: not func(*a
)
663 def is_local_feed(model
, iter):
664 return isinstance(model
[iter][ITEM_OBJECT
.idx
], LocalFeed
)
666 def has_implementations(model
, iter):
667 return model
.iter_has_child(iter)
670 ('All', lambda *a
: True),
671 ('Feeds with implementations', filter_only([ValidFeed
], has_implementations
)),
672 ('Feeds without implementations', filter_only([ValidFeed
], not_(has_implementations
))),
673 ('Local Feeds', filter_only([ValidFeed
], is_local_feed
)),
674 ('Remote Feeds', filter_only([ValidFeed
], not_(is_local_feed
))),
676 FILTER_OPTIONS
= init_filters()
679 cache_help
= help_box
.HelpBox(_("Cache Explorer Help"),
680 (_('Overview'), '\n' +
681 _("""When you run a program using Zero Install, it downloads the program's 'feed' file, \
682 which gives information about which versions of the program are available. This feed \
683 file is stored in the cache to save downloading it next time you run the program.
685 When you have chosen which version (implementation) of the program you want to \
686 run, Zero Install downloads that version and stores it in the cache too. Zero Install lets \
687 you have many different versions of each program on your computer at once. This is useful, \
688 since it lets you use an old version if needed, and different programs may need to use \
689 different versions of libraries in some cases.
691 The cache viewer shows you all the feeds and implementations in your cache. \
692 This is useful to find versions you don't need anymore, so that you can delete them and \
693 free up some disk space.""")),
695 (_('Invalid feeds'), '\n' +
696 _("""The cache viewer gets a list of all feeds in your cache. However, some may not \
697 be valid; they are shown in the 'Invalid feeds' section. It should be fine to \
698 delete these. An invalid feed may be caused by a local feed that no longer \
699 exists or by a failed attempt to download a feed (the name ends in '.new').""")),
701 (_('Unowned implementations and temporary files'), '\n' +
702 _("""The cache viewer searches through all the feeds to find out which implementations \
703 they use. If no feed uses an implementation, it is shown in the 'Unowned implementations' \
706 Unowned implementations can result from old versions of a program no longer being listed \
707 in the feed file. Temporary files are created when unpacking an implementation after \
708 downloading it. If the archive is corrupted, the unpacked files may be left there. Unless \
709 you are currently unpacking new programs, it should be fine to delete everything in this \
713 _("""All remaining feeds are listed in this section. You may wish to delete old versions of \
714 certain programs. Deleting a program which you may later want to run will require it to be downloaded \
715 again. Deleting a version of a program which is currently running may cause it to crash, so be careful!""")))