f05f214f6c
Do not regenerate all the icons when the source is unchanged. Also add preliminary code to support the same improvement for .desktop files, but it requires some more work, especially because KDE does a lot of caching and we need to force update sometimes (for example when VM label color has changed).
350 lines
14 KiB
Python
Executable File
350 lines
14 KiB
Python
Executable File
#!/usr/bin/python
|
|
# -*- coding: utf-8 -*-
|
|
#
|
|
# The Qubes OS Project, http://www.qubes-os.org
|
|
#
|
|
# Copyright (C) 2011 Marek Marczykowski <marmarek@mimuw.edu.pl>
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
#
|
|
#
|
|
|
|
import subprocess
|
|
import re
|
|
import os
|
|
import sys
|
|
import shutil
|
|
import pipes
|
|
|
|
from optparse import OptionParser
|
|
from qubes.qubes import QubesVmCollection,QubesException,system_path
|
|
from qubes.qubes import QubesHVm
|
|
from qubes.qubes import vm_files
|
|
import qubes.imgconverter
|
|
|
|
# fields required to be present (and verified) in retrieved desktop file
|
|
required_fields = [ "Name", "Exec" ]
|
|
|
|
#limits
|
|
appmenus_line_size = 1024
|
|
appmenus_line_count = 100000
|
|
|
|
# regexps for sanitization of retrieved values
|
|
std_re = re.compile(r"^[/a-zA-Z0-9.,&() -]*$")
|
|
fields_regexp = {
|
|
"Name": std_re,
|
|
"GenericName": std_re,
|
|
"Comment": std_re,
|
|
"Categories": re.compile(r"^[a-zA-Z0-9/.;:'() -]*$"),
|
|
"Exec": re.compile(r"^[a-zA-Z0-9()%&>/{}\"'\\:.= -]*$"),
|
|
"Icon": re.compile(r"^[a-zA-Z0-9/_.-]*$"),
|
|
}
|
|
|
|
CATEGORIES_WHITELIST = set([
|
|
# Main Categories
|
|
# http://standards.freedesktop.org/menu-spec/1.1/apa.html 20140507
|
|
'AudioVideo', 'Audio', 'Video', 'Development', 'Education', 'Game',
|
|
'Graphics', 'Network', 'Office', 'Science', 'Settings', 'System',
|
|
'Utility',
|
|
|
|
# Additional Categories
|
|
# http://standards.freedesktop.org/menu-spec/1.1/apas02.html
|
|
'Building', 'Debugger', 'IDE', 'GUIDesigner', 'Profiling',
|
|
'RevisionControl', 'Translation', 'Calendar', 'ContactManagement',
|
|
'Database', 'Dictionary', 'Chart', 'Email', 'Finance', 'FlowChart', 'PDA',
|
|
'ProjectManagement', 'Presentation', 'Spreadsheet', 'WordProcessor',
|
|
'2DGraphics', 'VectorGraphics', 'RasterGraphics', '3DGraphics', 'Scanning',
|
|
'OCR', 'Photography', 'Publishing', 'Viewer', 'TextTools',
|
|
'DesktopSettings', 'HardwareSettings', 'Printing', 'PackageManager',
|
|
'Dialup', 'InstantMessaging', 'Chat', 'IRCClient', 'Feed', 'FileTransfer',
|
|
'HamRadio', 'News', 'P2P', 'RemoteAccess', 'Telephony', 'TelephonyTools',
|
|
'VideoConference', 'WebBrowser', 'WebDevelopment', 'Midi', 'Mixer',
|
|
'Sequencer', 'Tuner', 'TV', 'AudioVideoEditing', 'Player', 'Recorder',
|
|
'DiscBurning', 'ActionGame', 'AdventureGame', 'ArcadeGame', 'BoardGame',
|
|
'BlocksGame', 'CardGame', 'KidsGame', 'LogicGame', 'RolePlaying',
|
|
'Shooter', 'Simulation', 'SportsGame', 'StrategyGame', 'Art',
|
|
'Construction', 'Music', 'Languages', 'ArtificialIntelligence',
|
|
'Astronomy', 'Biology', 'Chemistry', 'ComputerScience',
|
|
'DataVisualization', 'Economy', 'Electricity', 'Geography', 'Geology',
|
|
'Geoscience', 'History', 'Humanities', 'ImageProcessing', 'Literature',
|
|
'Maps', 'Math', 'NumericalAnalysis', 'MedicalSoftware', 'Physics',
|
|
'Robotics', 'Spirituality', 'Sports', 'ParallelComputing', 'Amusement',
|
|
'Archiving', 'Compression', 'Electronics', 'Emulator', 'Engineering',
|
|
'FileTools', 'FileManager', 'TerminalEmulator', 'Filesystem', 'Monitor',
|
|
'Security', 'Accessibility', 'Calculator', 'Clock', 'TextEditor',
|
|
'Documentation', 'Adult', 'Core', 'KDE', 'GNOME', 'XFCE', 'GTK', 'Qt',
|
|
'Motif', 'Java', 'ConsoleOnly',
|
|
|
|
# Reserved Categories (not whitelisted)
|
|
# http://standards.freedesktop.org/menu-spec/1.1/apas03.html
|
|
# 'Screensaver', 'TrayIcon', 'Applet', 'Shell',
|
|
])
|
|
|
|
def sanitise_categories(untrusted_value):
|
|
untrusted_categories = (c.strip() for c in untrusted_value.split(';') if c)
|
|
categories = (c for c in untrusted_categories if c in CATEGORIES_WHITELIST)
|
|
|
|
return ';'.join(categories) + ';'
|
|
|
|
def fallback_hvm_appmenulist():
|
|
p = subprocess.Popen(["grep", "-rH", "=", "/usr/share/qubes-appmenus/hvm"],
|
|
stdout=subprocess.PIPE)
|
|
(stdout, stderr) = p.communicate()
|
|
return stdout.splitlines()
|
|
|
|
def get_appmenus(vm):
|
|
global appmenus_line_count
|
|
global appmenus_line_size
|
|
untrusted_appmenulist = []
|
|
if vm is None:
|
|
while appmenus_line_count > 0:
|
|
untrusted_line = sys.stdin.readline(appmenus_line_size)
|
|
if untrusted_line == "":
|
|
break
|
|
untrusted_appmenulist.append(untrusted_line.strip())
|
|
appmenus_line_count -= 1
|
|
if appmenus_line_count == 0:
|
|
raise QubesException("Line count limit exceeded")
|
|
else:
|
|
p = vm.run('QUBESRPC qubes.GetAppmenus dom0', passio_popen=True,
|
|
gui=False)
|
|
while appmenus_line_count > 0:
|
|
untrusted_line = p.stdout.readline(appmenus_line_size)
|
|
if untrusted_line == "":
|
|
break
|
|
untrusted_appmenulist.append(untrusted_line.strip())
|
|
appmenus_line_count -= 1
|
|
p.wait()
|
|
if p.returncode != 0:
|
|
if isinstance(vm, QubesHVm):
|
|
untrusted_appmenulist = fallback_hvm_appmenulist()
|
|
else:
|
|
raise QubesException("Error getting application list")
|
|
if appmenus_line_count == 0:
|
|
raise QubesException("Line count limit exceeded")
|
|
|
|
row_no = 0
|
|
appmenus = {}
|
|
line_rx = re.compile(r"([a-zA-Z0-9.()_-]+.desktop):([a-zA-Z0-9-]+(?:\[[a-zA-Z@_]+\])?)=(.*)")
|
|
ignore_rx = re.compile(r".*([a-zA-Z0-9._-]+.desktop):(#.*|\s+)$")
|
|
for untrusted_line in untrusted_appmenulist:
|
|
# Ignore blank lines and comments
|
|
if len(untrusted_line) == 0 or ignore_rx.match(untrusted_line):
|
|
continue
|
|
# use search instead of match to skip file path
|
|
untrusted_m = line_rx.search(untrusted_line)
|
|
if untrusted_m:
|
|
filename = untrusted_m.group(1)
|
|
assert '/' not in filename
|
|
assert '\0' not in filename
|
|
|
|
untrusted_key = untrusted_m.group(2)
|
|
assert '\0' not in untrusted_key
|
|
assert '\x1b' not in untrusted_key
|
|
assert '=' not in untrusted_key
|
|
|
|
untrusted_value = untrusted_m.group(3)
|
|
# TODO add key-dependent asserts
|
|
|
|
# Look only at predefined keys
|
|
if fields_regexp.has_key(untrusted_key):
|
|
if fields_regexp[untrusted_key].match(untrusted_value):
|
|
# now values are sanitized
|
|
key = untrusted_key
|
|
if key == 'Categories':
|
|
value = sanitise_categories(untrusted_value)
|
|
else:
|
|
value = untrusted_value
|
|
|
|
if not appmenus.has_key(filename):
|
|
appmenus[filename] = {}
|
|
|
|
appmenus[filename][key]=value
|
|
else:
|
|
print >>sys.stderr, "Warning: ignoring key %r of %s" % (untrusted_key, filename)
|
|
# else: ignore this key
|
|
|
|
return appmenus
|
|
|
|
|
|
def create_template(path, values):
|
|
# check if all required fields are present
|
|
for key in required_fields:
|
|
if not values.has_key(key):
|
|
print >>sys.stderr, "Warning: not creating/updating '%s' because of missing '%s' key" % (path, key)
|
|
return
|
|
|
|
desktop_entry = ""
|
|
desktop_entry += "[Desktop Entry]\n"
|
|
desktop_entry += "Version=1.0\n"
|
|
desktop_entry += "Type=Application\n"
|
|
desktop_entry += "Terminal=false\n"
|
|
desktop_entry += "X-Qubes-VmName=%VMNAME%\n"
|
|
|
|
if 'Icon' in values:
|
|
icon_file = os.path.splitext(os.path.split(path)[1])[0] + '.png'
|
|
desktop_entry += "Icon={0}\n".format(os.path.join(
|
|
'%VMDIR%', vm_files['appmenus_icons_subdir'], icon_file))
|
|
else:
|
|
desktop_entry += "Icon=%XDGICON%\n"
|
|
|
|
for key in ["Name", "GenericName" ]:
|
|
if values.has_key(key):
|
|
desktop_entry += "{0}=%VMNAME%: {1}\n".format(key, values[key])
|
|
|
|
for key in [ "Comment", "Categories" ]:
|
|
if values.has_key(key):
|
|
desktop_entry += "{0}={1}\n".format(key, values[key])
|
|
|
|
desktop_entry += "Exec=qvm-run -q --tray -a %VMNAME% -- {0}\n".format(
|
|
pipes.quote(values['Exec']))
|
|
if not os.path.exists(path) or desktop_entry != open(path, "r").read():
|
|
desktop_file = open(path, "w")
|
|
desktop_file.write(desktop_entry)
|
|
desktop_file.close()
|
|
|
|
def main():
|
|
env_vmname = os.environ.get("QREXEC_REMOTE_DOMAIN")
|
|
usage = "usage: %prog [options] <vm-name>\n"\
|
|
"Updates desktop file templates for given StandaloneVM or TemplateVM"
|
|
|
|
parser = OptionParser (usage)
|
|
parser.add_option ("-v", "--verbose", action="store_true", dest="verbose", default=False)
|
|
parser.add_option ("--force-root", action="store_true", dest="force_root", default=False,
|
|
help="Force to run, even with root privileges")
|
|
parser.add_option ("--force-rpc", action="store_true", dest="force_rpc", default=False,
|
|
help="Force to start a new RPC call, even if called from existing one")
|
|
|
|
(options, args) = parser.parse_args ()
|
|
if (len (args) != 1) and env_vmname is None:
|
|
parser.error ("You must specify at least the VM name!")
|
|
|
|
if env_vmname:
|
|
vmname=env_vmname
|
|
else:
|
|
vmname=args[0]
|
|
|
|
if os.geteuid() == 0:
|
|
if not options.force_root:
|
|
print >> sys.stderr, "*** Running this tool as root is strongly discouraged, this will lead you in permissions problems."
|
|
print >> sys.stderr, "Retry as unprivileged user."
|
|
print >> sys.stderr, "... or use --force-root to continue anyway."
|
|
exit(1)
|
|
|
|
qvm_collection = QubesVmCollection()
|
|
qvm_collection.lock_db_for_reading()
|
|
qvm_collection.load()
|
|
qvm_collection.unlock_db()
|
|
|
|
vm = qvm_collection.get_vm_by_name(vmname)
|
|
|
|
if vm is None:
|
|
print >>sys.stderr, "ERROR: A VM with the name '{0}' does not exist in the system.".format(vmname)
|
|
exit(1)
|
|
|
|
if vm.template is not None:
|
|
print >>sys.stderr, "ERROR: To sync appmenus for template based VM, do it on template instead"
|
|
exit(1)
|
|
|
|
if not vm.is_running():
|
|
print >>sys.stderr, "ERROR: Appmenus can be retrieved only from running VM - start it first"
|
|
exit(1)
|
|
|
|
new_appmenus = {}
|
|
if env_vmname is None or options.force_rpc:
|
|
new_appmenus = get_appmenus(vm)
|
|
else:
|
|
options.verbose = False
|
|
new_appmenus = get_appmenus(None)
|
|
|
|
if len(new_appmenus) == 0:
|
|
print >>sys.stderr, "ERROR: No appmenus received, terminating"
|
|
exit(1)
|
|
|
|
if not os.path.exists(vm.appmenus_templates_dir):
|
|
os.mkdir(vm.appmenus_templates_dir)
|
|
|
|
if not os.path.exists(vm.appmenus_template_icons_dir):
|
|
os.mkdir(vm.appmenus_template_icons_dir)
|
|
|
|
# Create new/update existing templates
|
|
if options.verbose:
|
|
print >> sys.stderr, "--> Got {0} appmenus, storing to disk".format(str(len(new_appmenus)))
|
|
for appmenu_file in new_appmenus.keys():
|
|
if options.verbose:
|
|
if os.path.exists(os.path.join(vm.appmenus_templates_dir, appmenu_file)):
|
|
print >> sys.stderr, "---> Updating {0}".format(appmenu_file)
|
|
else:
|
|
print >> sys.stderr, "---> Creating {0}".format(appmenu_file)
|
|
|
|
if 'Icon' in new_appmenus[appmenu_file]:
|
|
# the following line is used for time comparison
|
|
# del new_appmenus[appmenu_file]['Icon']
|
|
|
|
icondest = os.path.join(vm.appmenus_template_icons_dir,
|
|
os.path.splitext(appmenu_file)[0] + '.png')
|
|
|
|
try:
|
|
icon = qubes.imgconverter.Image.get_xdg_icon_from_vm(vm,
|
|
new_appmenus[appmenu_file]['Icon'])
|
|
if os.path.exists(icondest):
|
|
old_icon = qubes.imgconverter.Image.load_from_file(icondest)
|
|
else:
|
|
old_icon = None
|
|
if old_icon is None or icon != old_icon:
|
|
icon.save(icondest)
|
|
except Exception, e:
|
|
print >> sys.stderr, '----> Failed to get icon for {0}: {1!s}'.format(appmenu_file, e)
|
|
|
|
if os.path.exists(icondest):
|
|
print >> sys.stderr, '-----> Found old icon, using it instead'
|
|
else:
|
|
del new_appmenus[appmenu_file]['Icon']
|
|
|
|
create_template(os.path.join(vm.appmenus_templates_dir, appmenu_file),
|
|
new_appmenus[appmenu_file])
|
|
|
|
# Delete appmenus of removed applications
|
|
if options.verbose:
|
|
print >> sys.stderr, "--> Cleaning old files"
|
|
for appmenu_file in os.listdir(vm.appmenus_templates_dir):
|
|
if not appmenu_file.endswith('.desktop'):
|
|
continue
|
|
|
|
if not new_appmenus.has_key(appmenu_file):
|
|
if options.verbose:
|
|
print >> sys.stderr, "---> Removing {0}".format(appmenu_file)
|
|
os.unlink(os.path.join(vm.appmenus_templates_dir, appmenu_file))
|
|
|
|
if isinstance(vm, QubesHVm):
|
|
if not os.path.exists(os.path.join(vm.appmenus_templates_dir,
|
|
os.path.basename(system_path['appmenu_start_hvm_template']))):
|
|
shutil.copy(system_path['appmenu_start_hvm_template'], vm.appmenus_templates_dir)
|
|
|
|
vm.appmenus_recreate()
|
|
if hasattr(vm, 'appvms'):
|
|
os.putenv('SKIP_CACHE_REBUILD', '1')
|
|
for child_vm in vm.appvms.values():
|
|
try:
|
|
child_vm.appmenus_update()
|
|
except Exception, e:
|
|
print >> sys.stderr, "---> Failed to recreate appmenus for " \
|
|
"'{0}': {1}".format(child_vm.name, str(e))
|
|
if 'KDE_SESSION_UID' in os.environ:
|
|
subprocess.call(['kbuildsycoca4'])
|
|
os.unsetenv('SKIP_CACHE_REBUILD')
|
|
|
|
main()
|