I just discovered Desura’s Linux client and, given the opportunity to have a DRM-free package repository system for my Humble Bundle games, I jumped at the chance.
Of course, I still wanted to keep all my games together in one launcher menu, so I went looking for a way to give the manually installed things like Super Meat Boy proper icons. Luckily, Desura turned out to have a pretty simple approach to specifying icons. Just a couple of columns in an ordinary SQLite database.
Here’s a little Python script I wrote which, in theory, should let you set/change the icon on ANY game in your Linux Desura library. I’ve only tested it on local ones though.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Simple script to set icons on locally-installed games in Desura.
If the game's title in the Desura Play list is case-insensitive identical to
the title in your Linux launcher menu and the game appears in the Games
category of the system launcher menu, you can provide only the title and the
icon will be looked up.
Otherwise, you must provide both the title in Desura and the title, icon name,
or icon path as used by the system.
NOTE: Exit Desura before running this script. Otherwise, your changes will be
overwritten.
--snip--
Requirements:
- Python 2.x (At least 2.5, maybe newer)
- PyGTK 2.x (python-gtk2 in Ubuntu)
- PyXDG (python-xdg in Ubuntu)
- wrestool (optional, icoutils in Ubuntu)
Examples:
./set_icon.py EDGE
./set_icon.py DOSBox dosbox
./set_icon.py 'Frogatto & Friends' Frogatto
./set_icon.py "Super Meat Boy" /usr/share/icons/supermeatboy/32.png
./set_icon.py Psychonauts ~/.wine/drive_c/Program\ Files/GOG.com/Psychonauts/Psychonauts.exe
This program will try the following methods, in order, to find Desura:
1. Check if the current working directory is the root of a Desura install.
2. Check if this script is installed in the root of a Desura install.
3. Use PyXDG to find Desura via the .desktop file it uses to integrate into
the system launcher menu.
ChangeLog:
0.4:
- Added a helper script for generating Wine wrappers in a manner which this
script can now cooperate with to resolve the location of the original EXE.
- Fixed database lookup for newer versions of Desurium
0.3.1:
- Prefix generated icon names to avoid a potential collision when the source
icon names are generic things like 32x32.png (GooTools, for example).
0.3:
- Experimental support for extracting icons from DLL/EXE/ICL files using
wrestool from the icoutils package.
0.2.1:
- Fix the resolution order for find_desura() so it's useful for people with
multiple copies present. (eg. stable, GitHub, Wine)
- Support looking up icons by the title in the system launcher menu.
- Warn if nothing in the Desura DB matched.
0.2:
- Can now also find Desura by its .desktop file or $PWD.
- Support for non-PNG/GIF icon formats.
- Support for looking up icon names from titles via PyXDG.
- Switch icon-resolution to PyGTK since we depend on it for SVG/XPM anyway.
0.1: Basic functionality using PyXDG
TODO:
- Update the single-argument mode to support looking up a .EXE file
automatically if the user is running Wine via binfmt_misc.
- Amend the Desura database lookup to also accept the path or filename to the
installcheck binary and, if feasible, to do case-insensitive lookup if case-
sensitive lookup fails.
- Break this script's reliance on xterm to run terminal-based games by
bundling my polished-up copy of xdg-terminal.
- Support no-argument operation by using this SQL:
SELECT installcheck FROM iteminfo WHERE icon = '';
- Gain a better understanding of iteminfo_c.sqlite and write code to look up
all .desktop files in the Games category and sync them into Desura. (minus
a small blacklist of things like Desura itself)
- Look into supporting Python 3.x and GTK 3.x
(http://readthedocs.org/docs/python-gtk-3-tutorial/en/latest/index.html)
Notes for eventual --sync functionality:
- Adding valid rows to iteminfo, exe, and newItems is apparently not enough
to get a valid entry in the Play list. I'm missing something.
- iteminfo.publisher doesn't automatically get shown if present. There's
another check somewhere.
- The following fields are populated in iteminfo_c.sqlite:iteminfo for a
manually-added entry:
- internalid (INTEGER PRIMARY KEY. Set by SQLite)
- statusflags (A bitfield with an integer representation of 2129946):
- The item is installed (1<<1),
- The item is local to the user's computer (1<<3),
- The download/install status is "ready to go" (1<<4),
- The item cannot be downloaded from Desura (1<<15)
- The item is a link and not managed by Desura (1<<21)
- name (The string displayed in the Play list)
- icon (The path of the cached icon, relative to the "desura" root folder)
- iconurl (The path the cached icon was retrieved from?)
- installpath (Probably useless with STATUS_LINK.
Set to /usr/games for /usr/games/stepmania)
- installcheck (If this file doesn't exist, the entry is hidden.)
- imod = 0
- ibranch = 0
- ibuild = 0
- lastbuild = 0
- lastbranch = 0
- The following fields are populated as follows in iteminfo_c.sqlite:exe for a
manually-added entry:
- itemid (Half of the primary key. The internalid from iteminfo)
- name (Other half of the primary key. Context menu entry text.
"Link" by default. Hidden if COUNT(itemid) = 1.
Other half of the primary key.)
- exe (Path of the file to execute)
- exeargs = '' (Fixed arguments the user can't edit)
- userargs = '' (Arguments the user can customize)
- rank = -1 (Sorting key for display in the context menu)
- The following fields are populated in iteminfo_c.sqlite:newItems for a
fresh entry:
- internalid (The internalid from iteminfo)
- userid (Not sure where this comes from. Just took it from existing rows.)
- time (Addition timestamp. Format: "2012-01-29 23:21:00")
"""
from __future__ import with_statement # For Python 2.5
__appname__ = "Desura Game Icon Setter"
__author__ = "Stephan Sokolow (deitarion/SSokolow)"
__version__ = "0.4"
__license__ = "MIT"
from urllib import pathname2url
import logging, os, re, shlex, sqlite3, subprocess, sys
import xdg.Menu
import gtk, glib, gobject
# These paths must be relative
ICON_SIZE = 32
#TODO: Include my patched xdg-terminal or some other fallback mechanism
TERMINAL_CMD = 'xterm -e %s'
log = logging.getLogger(__name__)
script_src_re = re.compile("\n# EXE_FILE: (.+)\n")
def _process_menu(menu):
"""Recursive handler for getting games from menus.
Written based on this public-domain code:
http://mmoeller.fedorapeople.org/icewm/icewm-xdg-menu/icewm-xdg-menu
"""
entries = []
for entry in menu.getEntries():
if isinstance(entry, xdg.Menu.Menu):
entries.extend(_process_menu(entry))
elif isinstance(entry, xdg.Menu.MenuEntry):
dentry = entry.DesktopEntry
name = dentry.getName() or entry.DesktopFileID
ico_name = dentry.getIcon()
cmd = re.sub('%%', '%', re.sub('%[a-zA-Z]', '', dentry.getExec()))
if dentry.getTerminal():
cmd = TERMINAL_CMD % cmd
entries.append((name.strip(), ico_name.strip(), cmd.strip()))
else:
print "S: %s" % entry
return entries
def get_system_games(root_folder='Games'):
"""Retrieve a list of games from the XDG system menus.
Written based on this public-domain code:
http://mmoeller.fedorapeople.org/icewm/icewm-xdg-menu/icewm-xdg-menu
@bug: On *buntu, this code doesn't find games unless you explicitly pass in
the appropriate C{root_folder} value.
"""
menu = xdg.Menu.parse()
if root_folder:
menu = menu.getMenu(root_folder)
return _process_menu(menu)
def find_desura(xdg_games=None):
"""Find the Desura root folder.
This will look for Desura in the following locations:
- In the current working directory.
- In the directory where this file resides.
- In the C{xdg_games} list, if provided.
@param xdg_games: Output from C{get_system_games} to be searched.
@return: The path to the Desura root folder or None on failure.
"""
xdg_games = xdg_games or []
desura_path = ''
for name, _, cmdline in get_system_games():
if name.lower() in ['desura', 'desurium']:
desura_path = os.path.dirname(shlex.split(cmdline)[0])
break
else:
raise Exception("Could not find Desura/Desurium")
for root in (os.environ['HOME'], os.getcwd(), os.path.dirname(__file__),
desura_path):
for settings_dir in ('.settings', '.desura'):
settings_path = os.path.join(root, settings_dir)
for db_name in ('iteminfo_c.sqlite', 'iteminfo_d.sqlite'):
db_path = os.path.join(settings_path, db_name)
cache_path = os.path.join(settings_path, 'cache', 'images')
if os.path.isfile(db_path) and os.path.isdir(cache_path):
return db_path, cache_path
raise Exception("Could not find Desura database")
def prepare_icon(name, target_root, target_size, prefix=''):
"""Look up the named icon in the current theme and save a copy as a PNG in
in C{target_root} scaled to C{target_size}.
@type target_size: C{int}
@return: The path to the newly-created file or C{None} on failure.
"""
icon_theme = gtk.icon_theme_get_default()
icon_abs = os.path.abspath(name)
icon_base, icon_ext = os.path.splitext(os.path.basename(name))
out_path = os.path.join(target_root, '%s%s.png' % (prefix, icon_base))
try:
if os.path.isfile(icon_abs):
# Resolve wine_mkwrapper.py-generated scripts
with open(icon_abs, 'rb') as fh:
if fh.read(2) == '#!':
fh.seek(0)
sources = script_src_re.findall(fh.read())
if sources and os.path.exists(sources[0]):
icon_abs = sources[0]
icon_base, icon_ext = os.path.splitext(icon_abs)
if icon_ext.lower() in ['.dll', '.exe', '.icl']:
#TODO: Use check_output() when Py27 is oldest supported.
data = subprocess.Popen(
["wrestool", '-x', "--type=14", icon_abs],
stdout=subprocess.PIPE).communicate()[0]
# FIXME: "ERROR: Failed to load named icon: Compressed icons are not supported"
# (Testable using Adobe Flash .EXE exports like Windosill)
#
# FIXME: "Failed to load named icon: Invalid header in icon"
# (Testable using NightSkyHD.exe)
#
# FIXME: "mismatch of size in icon resource `-1' and group (1024 vs 744)"
# (Testable using bricklyr.exe from BrickLayer v1.6 Shareware for Windows 3.1)
loader = gtk.gdk.PixbufLoader('ico')
loader.write(data)
icon_pixbuf = loader.get_pixbuf()
loader.close()
del data, loader
#TODO: Handle target_size
else:
icon_pixbuf = gtk.gdk.pixbuf_new_from_file_at_size(
icon_abs,
target_size, target_size)
else:
icon_pixbuf = icon_theme.load_icon(name, target_size, 0)
except gobject.GError, err:
#TODO: Verify that this error covers both branches.
log.error("Failed to load named icon: %s", err)
icon_pixbuf = None
if not icon_pixbuf:
return None
try:
icon_pixbuf.save(out_path, 'png')
return out_path
except glib.GError, err:
log.error("Failed to save converted icon: %s", err)
return None
if __name__ == '__main__':
from optparse import OptionParser
parser = OptionParser(version="%%prog v%s" % __version__,
usage="%prog [options] <game title> [icon name or path]",
description=__doc__.replace('\r\n', '\n').split('\n--snip--\n')[0])
parser.add_option('-v', '--verbose', action="count", dest="verbose",
default=2, help="Increase the verbosity. Can be used twice.")
parser.add_option('-q', '--quiet', action="count", dest="quiet",
default=0, help="Decrease the verbosity. Can be used twice.")
# Allow pre-formatted descriptions
parser.formatter.format_description = lambda description: description
opts, args = parser.parse_args()
# Set up clean logging to stderr
log_levels = [logging.CRITICAL, logging.ERROR, logging.WARNING,
logging.INFO, logging.DEBUG]
opts.verbose = min(opts.verbose - opts.quiet, len(log_levels) - 1)
opts.verbose = max(opts.verbose, 0)
logging.basicConfig(level=log_levels[opts.verbose],
format='%(levelname)s: %(message)s')
# Used to find Desura and resolve game names
games = get_system_games()
db_path, icon_cache = find_desura()
# Check for required arguments
if len(args) == 1:
title = icon_name = args[0]
elif len(args) == 2:
title, icon_name = args
else:
parser.print_help()
sys.exit(1)
# Resolve icon_name if given a title
for de_title, de_icon_name, cmd in get_system_games():
if de_title.lower() == icon_name.lower():
icon_name = de_icon_name
break
#Verify availability of target icon folder
if os.path.exists(icon_cache):
if not os.path.isdir(icon_cache):
log.critical("Desired icon cache exists as a file: %s", icon_cache)
sys.exit(3)
else:
log.warning("Expected location for Desura icon cache not found. "
"Creating: %s", icon_cache)
os.makedirs(icon_cache)
conn = sqlite3.connect(db_path)
ids = list(conn.execute("SELECT internalid FROM iteminfo WHERE name=?",
[title]))
if ids:
prefix = 'local-%d-' % ids[0][0]
else:
log.error("No Desura games matched '%s'" % title)
sys.exit(2)
# Prepare icon
icon_path = prepare_icon(icon_name, icon_cache, ICON_SIZE, prefix=prefix)
if icon_path:
log.info("Found path for %s: %s", icon_name, icon_path)
else:
log.error("Failed to prepare icon: %s", icon_name)
sys.exit(2)
# Update database to reference the icon
conn.execute("UPDATE iteminfo SET iconurl=?, icon=? WHERE name=?", [
'file://' + pathname2url(icon_path),
os.path.abspath(icon_path),
title])
conn.commit()
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Little script to make ~/bin/$2 wrappers for $1.exe"""
__appname__ = "Simple Wine Wrapper Maker"
__author__ = "Stephan Sokolow (deitarion/SSokolow)"
__version__ = "0.1"
__license__ = "MIT"
import os, string, sys
TEMPLATE = """#!/bin/sh
# EXE_FILE: %(cmd_path)s
cd %(parent)s
wine %(cmd)s
"""
def sh_quote(file):
"""Reliably quote a string as a single argument for /bin/sh
Borrowed from the pipes module in Python 2.6.2 stdlib and fixed to quote
empty strings properly and pass completely safechars strings through.
"""
# Safe unquoted
_safechars = string.ascii_letters + string.digits + '!@%_-+=:,./'
# Unsafe inside "double quotes"
_funnychars = '"`$\\'
if not file:
return "''"
for c in file:
if c not in _safechars:
break
else:
return file
if not [x for x in file if x not in _safechars]:
return file
elif '\'' not in file:
return '\'' + file + '\''
res = ''
for c in file:
if c in _funnychars:
c = '\\' + c
res = res + c
return '"' + res + '"'
if __name__ == '__main__':
from optparse import OptionParser
parser = OptionParser(version="%%prog v%s" % __version__,
usage="%prog <path/to/command.exe> [wrapper_name]",
description=__doc__.replace('\r\n', '\n').split('\n--snip--\n')[0])
# Allow pre-formatted descriptions
parser.formatter.format_description = lambda description: description
opts, args = parser.parse_args()
if len(args) < 1:
parser.print_help()
sys.exit(1)
cmd_path = os.path.abspath(args[0])
for suffix in ['', '.exe', '.com', '.bat', '.pif']:
if os.path.exists(cmd_path + suffix):
cmd_path = cmd_path + suffix
break
else:
print "ERROR: Could not find specified command"
sys.exit(2)
if len(args) < 2:
wrapper_cmd = os.path.splitext(os.path.split(cmd_path)[1])[0]
wrapper_cmd = wrapper_cmd.lower().replace(' ', '-')
args.append(wrapper_cmd)
wrapper_path = os.path.join(os.path.expanduser('~'), 'bin', args[1])
parent, cmd = os.path.split(cmd_path)
with open(wrapper_path, 'w') as fh:
fh.write(TEMPLATE % {
'cmd': sh_quote(cmd),
'parent': sh_quote(parent),
'cmd_path': sh_quote(cmd_path),
})
os.chmod(wrapper_path, 0755)
print "Wrote %s" % wrapper_path
Thank you for this script! Works like a charm!
Glad I could help.
It’s trickier (I already tried and failed) but, if I can ever find the time to poke through the Desurium source code to learn what I did wrong, I want to extend the script so it’ll even add the entries for you. (So you can just run it and have every entry in the Games section of your menu automatically added to Desura and any games you’ve uninstalled automatically removed)