# (c) Copyright 2009, 2012-2014. CodeWeavers, Inc.
"""Handles refreshing and reading the C4 profile information."""
import os
import threading
import time
import UserDict
import cxobjc
import cxlog
import cxutils
import cxproduct
import c4profiles
import fileupdate
import c4parser
import shutil
UNKNOWN = "com.codeweavers.unknown"
# This is the application id of the unknown application profile
class _C4PSource(object):
"""This is a helper class for C4ProfilesSet.
It keeps track of a couple of extra data about a C4Pfile object: its old
list of profiles and its last modification time.
The 'trusted' flag marks profiles that are official
CodeWeavers profiles. Untrusted profiles will produce a
user warning upon selection. Ideally this will be handled via
signing but at the moment it is based on where the files came from.
The 'source' member marks profiles which were added at runtime via
associations or drag and drop. It's used for ranking when
there are profile collisions. Possible values are
builtin, download, dropin
"""
def __init__(self, c4pfile, trusted=True, source=c4profiles.builtin):
self.c4pfile = c4pfile
self.profiles = {}
self.mtime = 0
self.trusted = trusted
self.source = source
class C4Profiles(cxobjc.Proxy):
pass
@cxobjc.method(C4Profiles, 'profilesSet')
def profilesSet():
return C4ProfilesSet.all_profiles()
@cxobjc.method(C4Profiles, 'dropinC4PFile_withSig_')
def dropin_C4PFile(c4pfilename, sigfilepath):
if sigfilepath:
sigfilepath = sigfilepath.encode('utf8')
return C4ProfilesSet.add_c4p_file(c4pfilename.encode('utf8'), sigfilepath, c4profiles.dropin)
@cxobjc.method(C4Profiles, 'tasteC4PFile_')
def taste_c4p(sourcetie):
(trusted, tmptie, tmpsig) = C4ProfilesSet.taste_new_c4p_file(sourcetie)
if trusted:
os.remove(tmptie)
os.remove(tmpsig)
return trusted
@cxobjc.method(C4Profiles, 'prepareC4PFile_')
def prepare_c4p(c4pfilename):
(tmpfile, tmpsig) = fileupdate.prepare(c4pfilename)
if tmpfile is None:
return (None, None)
return fileupdate.install(c4pfilename, tmpfile, tmpsig)
@cxobjc.method(C4Profiles, 'prepareSingleUseC4PFile_')
def prepare_temp_c4p(c4pfilename):
return fileupdate.prepare(c4pfilename)
class C4ProfilesSet(UserDict.IterableUserDict):
"""A mapping of application ids to the corresponding C4Profile objects."""
#####
#
# Instance methods
#
#####
def __init__(self):
"""Adds a data mapping attribute that third parties can use to store
or cache data related to this profiles set.
"""
UserDict.IterableUserDict.__init__(self)
self.userdata = {}
def installer_profiles(self, appid):
"""Returns the application's installer profiles if any, and the
unknown application installer profiles otherwise.
"""
installers = None
if appid in self:
profile = self[appid]
if profile.app_profile and profile.app_profile.installer_id:
try:
profile = self[profile.app_profile.installer_id]
except KeyError:
profile = None
if profile:
installers = profile.installer_profiles
if not installers:
installers = self[UNKNOWN].installer_profiles
return installers
installers_ = installer_profiles
def unknown_installer(self):
return self[UNKNOWN].installer_profiles[0]
def unknown_profile(self):
return self[UNKNOWN]
def contentsDict(self):
return self.data
#####
#
# Class methods and data
#
#####
_lock = threading.Lock()
# To avoid races while updating _sources, should not be held while blocking.
_update_lock = threading.Lock()
# Held while reading crossties.
_updating = False
# True if a thread is updating crossties, protected by _lock
_retry_update = False
# True if sources were added while updating crossties, protected by _lock
_sources = {}
# Keep track of the files the profiles come from. This is so we know when
# to refresh the profiles set, and also so we know to delete a given
# profile if the file goes away. This is a mapping so each file is present
# only once (and for now order does not matter).
# Protected by _lock
_last_checked = 0
# The timestamp of the last time the freshness of the _profiles data
# was checked
# Protected by _lock
_profiles = None
# The cached profiles set. This variable is updated atomically, so this
# set can be returned at any time without locking.
# Protected by _lock
_revoked_licenses = set()
# The set of revoked licenses. This is intentionally static (no need to
# track it separately for each set) and is updated whenever profiles are
# refreshed.
_mtimes = {}
# We keep track of the mtimes of each file in order to
# update our list when needed. The keys are source files.
# Protected by _lock
tiedir = os.path.join(cxproduct.get_user_dir(), "tie")
_tie_files_loaded = False
# True if _sources contains the standard builtin crosstie files.
# Protected by _lock
@classmethod
def add_c4p(cls, c4pfile):
"""Adds the specified CrossTie profile to the global database.
The CrossTie file is then watched for updates and re-read if it is
modified.
"""
with cls._lock:
cls._sources[c4pfile.filename] = _C4PSource(c4pfile, c4pfile.trusted, c4pfile.source)
cls._last_checked = 0
if cls._updating:
cls._retry_update = True
return c4pfile
@classmethod
def add_c4p_file(cls, c4pfilename, sigfile=None, source=c4profiles.builtin):
"""Reads the specified c4p file and add its profiles to the global
database.
The c4p file is then watched for updates and re-read if it is modified.
"""
trusted = cls.is_c4p_file_trusted(c4pfilename, sigfile)
if not trusted and source == c4profiles.download:
if os.path.exists(c4pfilename + ".new"):
# We were interrupted in fileupdate.install()
shutil.move(c4pfilename + ".new", c4pfilename)
trusted = cls.is_c4p_file_trusted(c4pfilename, sigfile)
if not trusted:
cxlog.err("Deleting '%s' because it lacks a valid signature." % c4pfilename)
os.remove(c4pfilename)
return None
c4pfile = c4parser.C4PFile(c4pfilename, trusted, source)
return cls.add_c4p(c4pfile)
@classmethod
def is_c4p_file_trusted(cls, filepath, sigfilepath=None):
"""Evaluate the trustworthiness of a c4p. Typically the
signature filename (if it exists) can be derived from
the filepath. When using temp files (e.g. via taste_new_c4p_file)
the sigfile may have been uniquely generated in which case
it needs to be specified here.
"""
if not filepath:
return False
# Files that originate from within the CrossOver package
# get a free pass.
if filepath.startswith(cxutils.CX_ROOT):
return True
if fileupdate.is_signed(filepath, sigfilepath):
return True
return False
@classmethod
def taste_new_c4p_file(cls, sourcetie):
"""For GUI purposes it is useful to be able to detect if a file will be
trusted or untrusted before we actually load it into any CrossOver
dirs. This function prepares and tests the signature of a CrossTie file
using disposable temp files.
Returns a (trusted, tmptie, tmpsig) triplet where trusted is True if
the file is trusted and False otherwise, and tmptie and tmpsig are the
temporary filenames if the file is trusted.
"""
(tmptie, tmpsig) = fileupdate.prepare(sourcetie)
if not tmptie:
return (False, None, None)
if cls.is_c4p_file_trusted(tmptie, tmpsig):
return (True, tmptie, tmpsig)
os.remove(tmptie)
if tmpsig:
os.remove(tmpsig)
return (False, None, None)
@classmethod
def _rebuild_profiles(cls, sources):
profiles = C4ProfilesSet()
profile_lists = []
result = None
for source in sources.itervalues():
try:
# Also refreshes that source's profile list
profile_list = source.c4pfile.profiles
if profile_list is None:
# If the file does not exist, then we get
# None with no exception
continue
except Exception, exception: # pylint: disable=W0703
cxlog.err("an error occurred while reading %s:\n%s" % (cxlog.debug_str(source), cxlog.debug_str(exception)))
continue
# First check all files for revoke profiles, so we know
# new revoke profiles are applied everywhere
if source.trusted and source.c4pfile.revokelist:
revokelist_file = c4profiles.get_revokelist_file()
revokelist_file.lock_file()
try:
for revoke_profile in source.c4pfile.revokelist:
revoke_string = '%s..%s' % (
revoke_profile.first and revoke_profile.first.encode('utf8') or '',
revoke_profile.last and revoke_profile.last.encode('utf8') or '')
section = revokelist_file[revoke_profile.appid.encode('utf8')]
if revoke_string not in section.values():
section['Range%s' % len(section)] = revoke_string
finally:
revokelist_file.save_and_unlock_file()
profile_lists.append(profile_list)
for profile_list in profile_lists:
for profile in profile_list:
if profile.is_revoked:
continue
appid = profile.appid
if appid not in profiles:
profiles[appid] = profile
elif profile.score < profiles[appid].score:
profiles[appid] = profile
# Atomically update cls._profiles
with cls._lock:
if not cls._retry_update:
cls._profiles = result = profiles
cls._last_checked = time.time()
cls._updating = False
return result
@classmethod
def all_profiles(cls):
"""Returns a mapping of application ids to the corresponding C4Profile
objects.
This automatically reads or re-reads the relevant c4p files if needed
but will always return a consistent view of the underlying c4p profile
database.
"""
if not cls._tie_files_loaded:
with cls._update_lock:
if not cls._tie_files_loaded:
cls.load_tie_files()
cls._tie_files_loaded = True
result = None
# The consistency guarantee means that even if we split the local C4
# database across many different c4p files, and these are in various
# stages of being updated, the returned profiles will not mix old data
# with new data.
while result is None:
other_thread_updating = False
needs_update = False
with cls._lock:
if cls._updating:
other_thread_updating = True
elif cls._profiles is None or cls._last_checked + 2 < time.time():
needs_update = True
cls._updating = True
cls._retry_update = False
sources = cls._sources.copy()
else:
result = cls._profiles
if other_thread_updating:
with cls._update_lock:
pass
elif needs_update:
with cls._update_lock:
# Check if one of the source files changed
changes = False
for source in sources.itervalues():
latestmtime = source.c4pfile.update()
if latestmtime and ((source not in cls._mtimes) or (latestmtime > cls._mtimes[source])):
cls._mtimes[source] = latestmtime
if source.trusted:
cls._revoked_licenses.update(source.c4pfile.disabled_licenses)
changes = True
if changes:
# If a profile has changed for a given appid in one of the
# source files, then we need to reevaluate whether it's the
# profile that best matches the current product for that
# appid, or whether a profile from another source file
# should be used instead.
# The simplest way to do so, and possibly fastest too, is
# to rebuild the list from scratch.
result = cls._rebuild_profiles(sources)
else:
with cls._lock:
if not cls._retry_update:
result = cls._profiles
cls._last_checked = time.time()
cls._updating = False
return result
@classmethod
def load_tie_files(cls):
for basename in ("crossover.tie", "unknown.tie"):
filename = os.path.join(cxutils.CX_ROOT, "share/crossover/data", basename)
cls.add_c4p_file(filename, None, c4profiles.builtin)
if os.path.exists(cls.tiedir):
for c4pfile in os.listdir(cls.tiedir):
if os.path.splitext(c4pfile)[1] == ".c4p" or os.path.splitext(c4pfile)[1] == ".tie":
if c4pfile == os.path.basename(online_file):
cls.add_c4p_file(os.path.join(cls.tiedir, c4pfile), None, c4profiles.download)
else:
cls.add_c4p_file(os.path.join(cls.tiedir, c4pfile), None, c4profiles.dropin)
@classmethod
def get_revoked_licenses(cls):
"""Returns a set of revoked license id's."""
cls.all_profiles() # reload ties as needed
return cls._revoked_licenses
def copy(self):
# We have to override this because IterableUserDict wrongly copies userdata.
result = C4ProfilesSet()
result.data.update(self.data)
return result
online_file = os.path.join(C4ProfilesSet.tiedir, "crossover.tie")
@cxobjc.method(C4Profiles, 'getOnlineFilePath')
def getOnlineFilePath():
# This is used by the Mac GUI to display the mtime of the file.
return online_file
# Return True if anything is updated.
@cxobjc.method(C4Profiles, 'updateOnlineProfilesFromURL_')
def update_online_profiles(url):
add_profiles = not os.path.exists(online_file)
if not fileupdate.update(online_file, url, True):
return False
if add_profiles:
c4pfile = c4parser.C4PFile(online_file, True, c4profiles.download)
C4ProfilesSet.add_c4p(c4pfile)
return True