# (c) Copyright 2009-2013, 2015-2018. CodeWeavers, Inc.
import cookielib
import os
import os.path
import stat
import tempfile
import urllib2
import urlparse
import cxproduct
import cxlog
import cxurlget
import cxutils
import dircache
import globtree
import cddetector
import cxaiebase
import cxhtmlutils
import shutil
# for localization
from cxutils import cxgettext as _
#####
#
# Insert / Release Media
#
#####
def needs_installer_file(iprofile):
"""Returns True if the specified installer profile needs an installer
file. False means the profile is either virtual, or just copies files
around.
"""
if 'virtual' in iprofile.parent.app_profile.flags:
return False
if iprofile.files_to_copy and \
len(iprofile.installer_file_globs) >= 1 and \
iprofile.installer_file_globs[0] == ':stop:':
# This profile only copies files so there is no need for an installer
# file.
return False
return True
def find_file(path, globs):
"""Looks for a file matching the one of the globs in the list."""
globber = globtree.FileGlobTree()
for i in range(len(globs)):
globber.add_glob(globs[i], i)
best_matches = []
best_rank = len(globs)
for match, rank in globber.matches(path):
if rank < best_rank:
best_matches = [match]
best_rank = rank
elif rank == best_rank:
best_matches.append(match)
return best_matches, best_rank
def locate_installer(iprofile, install_source):
"""Tries to find an installer file for the specified profile."""
stop = False
globs = []
for glob in iprofile.installer_file_globs:
if glob == ':stop:':
stop = True
break
globs.append(glob)
if not stop:
globs.extend(("autorun.inf",
"autorun.exe",
"setup.exe",
"install.exe",
"setup*.exe",
"install*.exe",
"setup.bat",
"install.bat",
"setup*.bat",
"install*.bat",
"*.exe"))
matches, index = find_file(install_source, globs)
if not matches:
cxlog.log("no match for " + cxlog.debug_str(globs))
return None
# Reject ties
if len(matches) > 1:
cxlog.log("got a tie for %s: %s" % (globs[index], cxlog.debug_str(matches)))
return None
return matches[0]
NO_MEDIA = 1
WRONG_CD = 2
NO_INSTALLER = 3
class AIEInsertMedia(cxaiebase.AIETask):
"""Asks the user to insert or specify the path to the installation media
needed for the associated AIECore (either a CD or a file installer).
This also marks the media as being in use until it is released by the
corresponding AIEReleaseMedia task.
"""
#####
#
# Limit concurrency
#
#####
def can_run(self):
"""Returns True if this task is runnable and another media is not
already in use.
"""
if 'AIEMedia' in self.scheduler.userdata:
return False
return cxaiebase.AIETask.can_run(self)
def _getniceness(self):
"""Run whatever installers are available before asking for new media.
"""
return self.aiecore.niceness + 1000
niceness = property(_getniceness)
def schedule(self):
cxaiebase.AIETask.schedule(self)
self.scheduler.userdata['AIEMedia'] = self
#####
#
# Main
#
#####
def __init__(self, aiecore):
cxaiebase.AIETask.__init__(self, aiecore.scheduler, "")
self.aiecore = aiecore
aiecore.add_dependency(self)
self.errcode = 0
# install_source should be set by the GUI before main() is run
self.install_source = None
# If True, then AIEInsertMedia should check that the media matches
# the CD profile. Otherwise it blindly assumes that it's the right CD.
self.detect = True
def __unicode__(self):
return "%s(%s)" % (self.__class__.__name__, self.aiecore.name)
def main(self):
# FIXME: We should have access to the list of potential CDs. Then we
# would be able to autodetect the source media.
if not self.install_source:
self.errcode = NO_MEDIA
self.error = _("The source media is not set.")
return False
if self.install_source.startswith("steam:"):
self.aiecore.state['install_source'] = self.install_source
return True
if os.path.isfile(self.install_source):
self.aiecore.state['install_source'] = self.install_source
return True
if not os.path.isdir(self.install_source):
self.errcode = NO_MEDIA
self.error = _("'%s' should either be a file or a directory") % self.install_source
return False
profile = self.aiecore.installer.parent
if self.detect and profile.cd_profile and not cddetector.find_profile(self.aiecore.installer.parent, (profile,)):
self.errcode = WRONG_CD
self.error = _("The inserted CD does not match %s") % profile.name
return False
if self.aiecore.installer.udf_remount_check != "":
if not os.path.exists(os.path.join(self.install_source, self.aiecore.installer.udf_remount_check)):
source_device = None
if self.install_source in self.scheduler.installtask.source_medias:
source_device = self.scheduler.installtask.source_medias[self.install_source].device
if not source_device:
source_device = '/dev/cdrom'
self.error = _("This disk contains two sections, one for Mac and one for Windows. "
"In order for CrossOver to use this disk it must be mounted so that "
"the Windows portion is visible.\n\n"
"To make this change, remount as root with this command:\n"
"mount -t udf -o users,ro,unhide,uid=%(uid)d,remount %(device)s "
"\"%(mountpoint)s\"") % {'uid': os.getuid(),
'device': source_device,
'mountpoint': self.install_source}
return False
if not needs_installer_file(self.aiecore.installer):
return True
# Make sure we can find an installer now so that we ask the user to
# pick some other install source if we cannot.
installer_file = locate_installer(self.aiecore.installer, self.install_source)
if installer_file:
self.aiecore.state['install_source'] = self.install_source
self.aiecore.state['installer_file'] = installer_file
return True
# Trigger an error and force a new lookup
self.errcode = NO_INSTALLER
self.error = _("Unable to find an installer in '%s'") % self.install_source
return False
class AIEReleaseMedia(cxaiebase.AIENop):
"""Releases the media that was inserted by the AIEInsertMedia task.
This signals other AIEInsertMedia tasks that they can run.
"""
def __init__(self, aiecore):
cxaiebase.AIENop.__init__(self, aiecore.scheduler, "Release " + aiecore.name)
self.aiecore = aiecore
self.add_dependency(aiecore)
def __unicode__(self):
return "%s(%s)" % (self.__class__.__name__, self.aiecore.name)
def done(self):
try:
insert_media = self.scheduler.userdata['AIEMedia']
if self.aiecore.insert_media != insert_media:
cxlog.err("mismatched Insert/Release (%s / %s)" % (cxlog.to_str(insert_media), cxlog.to_str(self)))
del self.scheduler.userdata['AIEMedia']
except KeyError:
cxlog.err(cxlog.to_str(self) + ": AIEMedia should have been set")
cxaiebase.AIETask.done(self)
#####
#
# Download tasks
#
#####
def get_builtin_installer(appid):
"""If the application has a builtin installer, returns the path to that
installer. Otherwise, returns None.
"""
dirpath = os.path.join(cxutils.CX_ROOT, "lib", "installers")
appid += "."
cxlog.log_("aie", "looking for " + cxlog.to_str(appid))
if os.path.exists(dirpath):
for dentry in dircache.listdir(dirpath):
if dentry.startswith(appid):
path = os.path.join(dirpath, dentry)
cxlog.log_("aie", " -> " + cxlog.to_str(path))
return path
return None
def get_cached_filename(url, filename):
"""Use the hashed URL so we can cache each localized installer separately,
and so we automatically update the installer if the URL changed.
"""
md5hasher = cxutils.md5_hasher()
# The anchor is irrelevant to the server so strip it
md5hasher.update(url.split('#', 1)[0])
return md5hasher.hexdigest() + "." + filename
def validate_installer(filename):
"""Checks whether the specified file is an html file, and if it is,
whether it redirects us elsewhere.
"""
return cxhtmlutils.is_html(filename, 4096)
def get_cached_installer(url):
"""Checks whether we have an installer for the specified url in our
downloaded installers cache.
"""
basename = get_cached_filename(url, "")
cxlog.log_("aie", "looking for " + cxlog.to_str(basename))
for dirpath in (cxproduct.get_user_dir(), cxproduct.get_managed_dir()):
try:
for dentry in dircache.listdir(os.path.join(dirpath, "installers")):
if dentry.startswith(basename):
path = os.path.join(dirpath, "installers", dentry)
if not os.path.isfile(path):
continue
cxlog.log_("aie", " -> " + cxlog.to_str(path))
is_html, _redirect = validate_installer(path)
if not is_html:
return path
# A bad file got cached somehow (maybe by an old
# version)
cxlog.log_("aie", " -> deleted (html file)")
os.unlink(path)
except OSError:
# The directory does not exist or is not readable.
# Just skip to the next
pass
return None
def _is_cache_filename(name):
if name.startswith('local.tmp.'):
return True
if len(name) > 33 and name[32] == '.' and \
all(ch in '0123456789abcdef' for ch in name[0:32]):
return True
return False
def size_cached_installers():
path = os.path.join(cxproduct.get_user_dir(), "installers")
if not os.path.exists(path):
return 0
result = 0
for name in os.listdir(path):
if not _is_cache_filename(name):
continue
filename = os.path.join(path, name)
st = os.lstat(filename)
if not stat.S_ISREG(st.st_mode):
continue
result += st.st_size
return result
def delete_cached_installers():
path = os.path.join(cxproduct.get_user_dir(), "installers")
if not os.path.exists(path):
return
for name in os.listdir(path):
if not _is_cache_filename(name):
continue
filename = os.path.join(path, name)
st = os.lstat(filename)
if not stat.S_ISREG(st.st_mode):
continue
os.unlink(filename)
class AIEDownload(cxaiebase.AIETask):
#####
#
# Initialization
#
#####
def __init__(self, aiecore):
"""Initializes the download task for the specified AIECore object."""
cxaiebase.AIETask.__init__(self, aiecore.scheduler,
_("Downloading %s") % aiecore.name)
self.aiecore = aiecore
aiecore.add_dependency(self)
self._getter = None
self.canceled = False
def __unicode__(self):
return "%s(%s)" % (self.__class__.__name__, self.aiecore.name)
def can_cancel(self):
return True
def cancel(self):
cxaiebase.AIETask.cancel(self)
self.canceled = True
def _getniceness(self):
"""Start downloads as soon as possible."""
return self.aiecore.niceness - 1000
niceness = property(_getniceness)
#####
#
# Caching helpers
#
#####
def _geturl(self):
if (self.aiecore.installer.parent.app_profile.download_urls_64bit and
self.scheduler.installtask.is64bit()):
download_urls = self.aiecore.installer.parent.app_profile.download_urls_64bit
else:
download_urls = self.aiecore.installer.parent.app_profile.download_urls
_lang, url = cxutils.get_language_value(
download_urls,
self.scheduler.installtask.realInstallerLocale)
return url
url = property(_geturl)
def _needs_download(self):
"""Returns True if the installer needs to be downloaded, and False
otherwise.
"""
try:
installer_file = self.aiecore.state['install_source']
if os.path.exists(installer_file):
# Presumably the installer was found on a CD so we don't need
# to download it.
return False
del self.aiecore.state['install_source']
except KeyError:
pass
installer_file = get_builtin_installer(self.aiecore.installer.parent.appid)
if not installer_file and not self.aiecore.installer.always_redownload:
installer_file = get_cached_installer(self.url)
if installer_file:
self.aiecore.state['install_source'] = installer_file
return False
return True
needs_download = property(_needs_download)
def set_installer_file(self, installer_file):
"""Sets the installer file to use."""
cxlog.log_("aie", "%s: install_source = %s" % (cxlog.to_str(self), cxlog.debug_str(installer_file)))
self.aiecore.state['install_source'] = installer_file
if self.aiecore.installer.always_redownload:
self.aiecore.state['temp_install_source'] = installer_file
setInstallerFile_ = set_installer_file
@staticmethod
def get_installers_dir():
dirpath = os.path.join(cxproduct.get_user_dir(), "installers")
cxutils.mkdirs(dirpath)
return dirpath
installers_dir = property(get_installers_dir)
#####
#
# Functions handling the actual download
#
#####
def _getsize(self):
"""This is -1 if the download has not started yet.
This is 0 if the download has started but the size is unknown.
Otherwise it is the size in bytes.
"""
if self._getter:
return self._getter.bytes_total
return -1
size = property(_getsize)
def _getdownloaded(self):
"""This is the number of bytes that have been downloaded."""
if self._getter:
return self._getter.bytes_downloaded
return 0
downloaded = property(_getdownloaded)
def _getprogress(self):
"""This is a number between 0 and 1 representing the percentage that
has been downloaded. If the download size is 0, then progress is -1.
"""
if self._getter and self._getter.bytes_total:
return 1.0 * self._getter.bytes_downloaded / self._getter.bytes_total
return 0
progress = property(_getprogress)
def urlgetter_failed(self, urlgetter, exception):
if isinstance(exception, urllib2.HTTPError):
if exception.code == 403 and urlgetter.user_agent is None:
# In fact we expected an installer but got an HTML file
# so we tried again with Python's default user-agent
# (look for user_agent below) but this backfired.
self.error = _("'%s' returned an HTML file instead of the installer") % cxlog.to_str(urlgetter.url)
elif exception.code == 404:
self.error = _("HTTP error 404: The file '%s' could not be found.") % cxlog.to_str(urlgetter.url)
else:
self.error = _("The HTTP server returned failure code %s.") % exception.code
else:
self.error = cxlog.debug_str(exception)
def urlgetter_progress(self, _urlgetter):
if self.canceled:
raise cxurlget.StopDownload()
def main(self):
if not self.needs_download or self.canceled:
return True
# Some sites return a cookie and redirect us to another URL which
# expects to receive that cookie. So create a temporary cookie jar.
cookiejar = cookielib.CookieJar()
installers_dir = self.get_installers_dir()
url = self.url
user_agent = cxurlget.USER_AGENT
for _count in range(5):
tmpfileno, tmppath = tempfile.mkstemp(prefix='local.tmp.', dir=installers_dir)
# fdopen() acquires the fd as its own
tmpfile = os.fdopen(tmpfileno, "w")
opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookiejar))
self._getter = cxurlget.UrlGetter(url, tmpfile, user_agent=user_agent, notify_progress=self.urlgetter_progress, notify_failed=self.urlgetter_failed, opener=opener)
self._getter.fetch() # closes tmpfile for us
if self.canceled or self.error:
os.unlink(tmppath)
return False
is_html, redirect = validate_installer(tmppath)
if not is_html:
break
# We may have gotten an HTML file instead of the installer:
# - We may be behind a WiFi hotspot that redirects all URLs to its
# login page.
# - Or the installer may be behind a web page with a <meta> refresh
# tag.
if redirect is None or redirect is False:
# Some sites (microsoft) return an HTTP 403 error when the
# user-agent does not look like a real browser, probably to
# ward off spiders.
# But when given a browser-like user-agent other sites put
# up a tracking-cookie notification page (sourceforge)
# which requires clicking on a button (which we cannot do)
# to get at the installer.
# So when trying to get an installer, try first with the
# browser-like user-agent and, if that fails, try again
# with the default user-agent.
if user_agent is not None:
# Switch to the default user-agent and try again
user_agent = None
continue
break
if redirect is True:
# Just reload the url, hoping not to get a redirect again
continue
# Follow the web page's http-equiv redirect to make sure the
# final destination is valid
url = urlparse.urljoin(self._getter.url, redirect)
if is_html:
os.unlink(tmppath)
self.error = _("Got an HTML file instead of an installer for %s") % self.url
return False
installer_file = os.path.join(installers_dir, get_cached_filename(self.url, self._getter.basename))
try:
shutil.move(tmppath, installer_file)
os.chmod(installer_file, 0777 & ~cxutils.UMASK)
except OSError, ose:
self.error = ose.strerror
return False
self.set_installer_file(installer_file)
return True
def getSuggestedFilename_(self, string):
# This is a convenience method used by the Mac code.
basename = cxurlget.url_to_sanitized_basename(string)
return get_cached_filename(self.url, basename)
#####
#
# Scan Media
#
#####
class AIEScanMedia(cxaiebase.AIETask):
"""Scans the media looking for extra installers for dependencies.
This helps us avoid downloads.
"""
def __init__(self, aiecore):
cxaiebase.AIETask.__init__(self, aiecore.scheduler, "")
self.aiecore = aiecore
def __unicode__(self):
return "%s(%s)" % (self.__class__.__name__, self.aiecore.name)
def main(self):
install_source = self.aiecore.insert_media.install_source
if not os.path.isdir(install_source):
# Not something we can scan
return True
best_rank = {}
globber = globtree.FileGlobTree()
for download in self.parents:
if not isinstance(download, AIEDownload):
continue
if not download.needs_download:
continue
installer = download.aiecore.installer
size = len(installer.local_installer_file_globs)
best_rank[download] = size
for i in range(size):
globber.add_glob(installer.local_installer_file_globs[i],
(download, i))
best_matches = {}
for match, data in globber.matches(install_source):
download, rank = data
if rank < best_rank[download]:
best_matches[download] = [match]
elif rank == best_rank[download]:
best_matches[download].append(match)
for download, matches in best_matches.iteritems():
# Reject ties
if len(matches) > 1:
cxlog.log("got a tie for %s: %s" % (cxlog.to_str(download.aiecore.installer.local_installer_file_globs[best_rank[download]]), cxlog.debug_str(matches)))
continue
# Make sure the media will be present when the installer is run
download.aiecore.add_dependency(self.aiecore.insert_media)
self.aiecore.release_media.add_dependency(download.aiecore)
download.aiecore.state['install_source'] = matches[0]
download.aiecore.state['silentinstall'] = True
return True
def done(self):
to_delete = []
self.scheduler.lock()
try:
for download in self.parents:
if not isinstance(download, AIEDownload):
continue
if self.aiecore in download.aiecore.parent_media and self.aiecore.insert_media.status == cxaiebase.TASK_DONE:
# We must remove the download's dependencies on all
# AIEScanMedia objects to avoid so it does not prevent its
# AIECore from installing, thus causing a deadlock.
# Perform the deletion in two steps so we don't change the
# self.parents set while iterating it.
cxlog.log_("aie", " clearing the %s dependencies" % cxlog.to_str(download))
to_delete.append(download)
finally:
self.scheduler.unlock()
for download in to_delete:
download.clear_dependencies()
cxaiebase.AIETask.done(self)