mirror of
https://github.com/Sneed-Group/Poodletooth-iLand
synced 2024-12-26 21:22:27 -06:00
1235 lines
48 KiB
Python
Executable file
1235 lines
48 KiB
Python
Executable file
from pandac.PandaModules import Filename, URLSpec, DocumentSpec, Ramfile, Multifile, Decompressor, EUOk, EUSuccess, VirtualFileSystem, Thread, getModelPath, ExecutionEnvironment, PStatCollector, TiXmlDocument, TiXmlDeclaration, TiXmlElement
|
|
from pandac import PandaModules
|
|
from libpandaexpress import ConfigVariableInt
|
|
from direct.p3d.FileSpec import FileSpec
|
|
from direct.p3d.ScanDirectoryNode import ScanDirectoryNode
|
|
from direct.showbase import VFSImporter
|
|
from direct.directnotify.DirectNotifyGlobal import directNotify
|
|
from direct.task.TaskManagerGlobal import taskMgr
|
|
import os
|
|
import sys
|
|
import random
|
|
import time
|
|
import copy
|
|
|
|
class PackageInfo:
|
|
|
|
""" This class represents a downloadable Panda3D package file that
|
|
can be (or has been) installed into the current runtime. It is
|
|
the Python equivalent of the P3DPackage class in the core API. """
|
|
|
|
notify = directNotify.newCategory("PackageInfo")
|
|
|
|
# Weight factors for computing download progress. This
|
|
# attempts to reflect the relative time-per-byte of each of
|
|
# these operations.
|
|
downloadFactor = 1
|
|
uncompressFactor = 0.01
|
|
unpackFactor = 0.01
|
|
patchFactor = 0.01
|
|
|
|
# These tokens are yielded (not returned) by __downloadFile() and
|
|
# other InstallStep functions.
|
|
stepComplete = 1
|
|
stepFailed = 2
|
|
restartDownload = 3
|
|
stepContinue = 4
|
|
|
|
UsageBasename = 'usage.xml'
|
|
|
|
class InstallStep:
|
|
""" This class is one step of the installPlan list; it
|
|
represents a single atomic piece of the installation step, and
|
|
the relative effort of that piece. When the plan is executed,
|
|
it will call the saved function pointer here. """
|
|
def __init__(self, func, bytes, factor, stepType):
|
|
self.__funcPtr = func
|
|
self.bytesNeeded = bytes
|
|
self.bytesDone = 0
|
|
self.bytesFactor = factor
|
|
self.stepType = stepType
|
|
self.pStatCol = PStatCollector(':App:PackageInstaller:%s' % (stepType))
|
|
|
|
def func(self):
|
|
""" self.__funcPtr(self) will return a generator of
|
|
tokens. This function defines a new generator that yields
|
|
each of those tokens, but wraps each call into the nested
|
|
generator within a pair of start/stop collector calls. """
|
|
|
|
self.pStatCol.start()
|
|
for token in self.__funcPtr(self):
|
|
self.pStatCol.stop()
|
|
yield token
|
|
self.pStatCol.start()
|
|
|
|
# Shouldn't ever get here.
|
|
self.pStatCol.stop()
|
|
raise StopIteration
|
|
|
|
def getEffort(self):
|
|
""" Returns the relative amount of effort of this step. """
|
|
return self.bytesNeeded * self.bytesFactor
|
|
|
|
def getProgress(self):
|
|
""" Returns the progress of this step, in the range
|
|
0..1. """
|
|
if self.bytesNeeded == 0:
|
|
return 1
|
|
return min(float(self.bytesDone) / float(self.bytesNeeded), 1)
|
|
|
|
def __init__(self, host, packageName, packageVersion, platform = None,
|
|
solo = False, asMirror = False, perPlatform = False):
|
|
self.host = host
|
|
self.packageName = packageName
|
|
self.packageVersion = packageVersion
|
|
self.platform = platform
|
|
self.solo = solo
|
|
self.asMirror = asMirror
|
|
self.perPlatform = perPlatform
|
|
|
|
# This will be active while we are in the middle of a download
|
|
# cycle.
|
|
self.http = None
|
|
|
|
# This will be filled in when the host's contents.xml file is
|
|
# read.
|
|
self.packageDir = None
|
|
|
|
# These will be filled in by HostInfo when the package is read
|
|
# from contents.xml.
|
|
self.descFile = None
|
|
self.importDescFile = None
|
|
|
|
# These are filled in when the desc file is successfully read.
|
|
self.hasDescFile = False
|
|
self.patchVersion = None
|
|
self.displayName = None
|
|
self.guiApp = False
|
|
self.uncompressedArchive = None
|
|
self.compressedArchive = None
|
|
self.extracts = []
|
|
self.requires = []
|
|
self.installPlans = None
|
|
|
|
# This is updated during downloadPackage(). It is in the
|
|
# range 0..1.
|
|
self.downloadProgress = 0
|
|
|
|
# This is set true when the package file has been fully
|
|
# downloaded and unpacked.
|
|
self.hasPackage = False
|
|
|
|
# This is set true when the package has been "installed",
|
|
# meaning it's been added to the paths and all.
|
|
self.installed = False
|
|
|
|
# This is set true when the package has been updated in this
|
|
# session, but not yet written to usage.xml.
|
|
self.updated = False
|
|
self.diskSpace = None
|
|
|
|
def getPackageDir(self):
|
|
""" Returns the directory in which this package is installed.
|
|
This may not be known until the host's contents.xml file has
|
|
been downloaded, which informs us of the host's own install
|
|
directory. """
|
|
|
|
if not self.packageDir:
|
|
if not self.host.hasContentsFile:
|
|
if not self.host.readContentsFile():
|
|
self.host.downloadContentsFile(self.http)
|
|
|
|
# Derive the packageDir from the hostDir.
|
|
self.packageDir = Filename(self.host.hostDir, self.packageName)
|
|
if self.packageVersion:
|
|
self.packageDir = Filename(self.packageDir, self.packageVersion)
|
|
|
|
if self.host.perPlatform:
|
|
# If we're running on a special host that wants us to
|
|
# include the platform, we include it.
|
|
includePlatform = True
|
|
elif self.perPlatform and self.host.appRunner.respectPerPlatform:
|
|
# Otherwise, if our package spec wants us to include
|
|
# the platform (and our plugin knows about this), then
|
|
# we also include it.
|
|
includePlatform = True
|
|
else:
|
|
# Otherwise, we must be running legacy code
|
|
# somewhere--either an old package or an old
|
|
# plugin--and we therefore shouldn't include the
|
|
# platform in the directory hierarchy.
|
|
includePlatform = False
|
|
|
|
if includePlatform and self.platform:
|
|
self.packageDir = Filename(self.packageDir, self.platform)
|
|
|
|
return self.packageDir
|
|
|
|
def getDownloadEffort(self):
|
|
""" Returns the relative amount of effort it will take to
|
|
download this package. The units are meaningless, except
|
|
relative to other packges."""
|
|
|
|
if not self.installPlans:
|
|
return 0
|
|
|
|
# Return the size of plan A, assuming it will work.
|
|
plan = self.installPlans[0]
|
|
size = sum([step.getEffort() for step in plan])
|
|
|
|
return size
|
|
|
|
def getPrevDownloadedEffort(self):
|
|
""" Returns a rough estimate of this package's total download
|
|
effort, even if it is already downloaded. """
|
|
|
|
effort = 0
|
|
if self.compressedArchive:
|
|
effort += self.compressedArchive.size * self.downloadFactor
|
|
if self.uncompressedArchive:
|
|
effort += self.uncompressedArchive.size * self.uncompressFactor
|
|
# Don't bother counting unpacking.
|
|
|
|
return effort
|
|
|
|
def getFormattedName(self):
|
|
""" Returns the name of this package, for output to the user.
|
|
This will be the "public" name of the package, as formatted
|
|
for user consumption; it will include capital letters and
|
|
spaces where appropriate. """
|
|
|
|
if self.displayName:
|
|
name = self.displayName
|
|
else:
|
|
name = self.packageName
|
|
if self.packageVersion:
|
|
name += ' %s' % (self.packageVersion)
|
|
|
|
if self.patchVersion:
|
|
name += ' rev %s' % (self.patchVersion)
|
|
|
|
return name
|
|
|
|
|
|
def setupFilenames(self):
|
|
""" This is called by the HostInfo when the package is read
|
|
from contents.xml, to set up the internal filenames and such
|
|
that rely on some of the information from contents.xml. """
|
|
|
|
dirname, basename = self.descFile.filename.rsplit('/', 1)
|
|
self.descFileDirname = dirname
|
|
self.descFileBasename = basename
|
|
|
|
def checkStatus(self):
|
|
""" Checks the current status of the desc file and the package
|
|
contents on disk. """
|
|
|
|
if self.hasPackage:
|
|
return True
|
|
|
|
if not self.hasDescFile:
|
|
filename = Filename(self.getPackageDir(), self.descFileBasename)
|
|
if self.descFile.quickVerify(self.getPackageDir(), pathname = filename, notify = self.notify):
|
|
if self.__readDescFile():
|
|
# Successfully read. We don't need to call
|
|
# checkArchiveStatus again, since readDescFile()
|
|
# has just done it.
|
|
return self.hasPackage
|
|
|
|
if self.hasDescFile:
|
|
if self.__checkArchiveStatus():
|
|
# It's all good.
|
|
self.hasPackage = True
|
|
|
|
return self.hasPackage
|
|
|
|
def hasCurrentDescFile(self):
|
|
""" Returns true if a desc file file has been successfully
|
|
read for this package and is still current, false
|
|
otherwise. """
|
|
|
|
if not self.host.hasCurrentContentsFile():
|
|
return False
|
|
|
|
return self.hasDescFile
|
|
|
|
def downloadDescFile(self, http):
|
|
""" Downloads the desc file for this particular package,
|
|
synchronously, and then reads it. Returns true on success,
|
|
false on failure. """
|
|
|
|
for token in self.downloadDescFileGenerator(http):
|
|
if token != self.stepContinue:
|
|
break
|
|
Thread.considerYield()
|
|
|
|
return (token == self.stepComplete)
|
|
|
|
def downloadDescFileGenerator(self, http):
|
|
""" A generator function that implements downloadDescFile()
|
|
one piece at a time. It yields one of stepComplete,
|
|
stepFailed, or stepContinue. """
|
|
|
|
assert self.descFile
|
|
|
|
if self.hasDescFile:
|
|
# We've already got one.
|
|
yield self.stepComplete; return
|
|
|
|
if self.host.appRunner and self.host.appRunner.verifyContents != self.host.appRunner.P3DVCNever:
|
|
# We're allowed to download it.
|
|
self.http = http
|
|
|
|
func = lambda step, self = self: self.__downloadFile(
|
|
None, self.descFile,
|
|
urlbase = self.descFile.filename,
|
|
filename = self.descFileBasename)
|
|
step = self.InstallStep(func, self.descFile.size, self.downloadFactor, 'downloadDesc')
|
|
|
|
for token in step.func():
|
|
if token == self.stepContinue:
|
|
yield token
|
|
else:
|
|
break
|
|
|
|
while token == self.restartDownload:
|
|
# Try again.
|
|
func = lambda step, self = self: self.__downloadFile(
|
|
None, self.descFile,
|
|
urlbase = self.descFile.filename,
|
|
filename = self.descFileBasename)
|
|
step = self.InstallStep(func, self.descFile.size, self.downloadFactor, 'downloadDesc')
|
|
for token in step.func():
|
|
if token == self.stepContinue:
|
|
yield token
|
|
else:
|
|
break
|
|
|
|
if token == self.stepFailed:
|
|
# Couldn't download the desc file.
|
|
yield self.stepFailed; return
|
|
|
|
assert token == self.stepComplete
|
|
|
|
filename = Filename(self.getPackageDir(), self.descFileBasename)
|
|
# Now that we've written the desc file, make it read-only.
|
|
os.chmod(filename.toOsSpecific(), 0o444)
|
|
|
|
if not self.__readDescFile():
|
|
# Weird, it passed the hash check, but we still can't read
|
|
# it.
|
|
filename = Filename(self.getPackageDir(), self.descFileBasename)
|
|
self.notify.warning("Failure reading %s" % (filename))
|
|
yield self.stepFailed; return
|
|
|
|
yield self.stepComplete; return
|
|
|
|
def __readDescFile(self):
|
|
""" Reads the desc xml file for this particular package,
|
|
assuming it's been already downloaded and verified. Returns
|
|
true on success, false on failure. """
|
|
|
|
if self.hasDescFile:
|
|
# No need to read it again.
|
|
return True
|
|
|
|
if self.solo:
|
|
# If this is a "solo" package, we don't actually "read"
|
|
# the desc file; that's the entire contents of the
|
|
# package.
|
|
self.hasDescFile = True
|
|
self.hasPackage = True
|
|
return True
|
|
|
|
filename = Filename(self.getPackageDir(), self.descFileBasename)
|
|
|
|
if not hasattr(PandaModules, 'TiXmlDocument'):
|
|
return False
|
|
doc = PandaModules.TiXmlDocument(filename.toOsSpecific())
|
|
if not doc.LoadFile():
|
|
return False
|
|
|
|
xpackage = doc.FirstChildElement('package')
|
|
if not xpackage:
|
|
return False
|
|
|
|
try:
|
|
self.patchVersion = int(xpackage.Attribute('patch_version') or '')
|
|
except ValueError:
|
|
self.patchVersion = None
|
|
|
|
try:
|
|
perPlatform = int(xpackage.Attribute('per_platform') or '')
|
|
except ValueError:
|
|
perPlatform = False
|
|
if perPlatform != self.perPlatform:
|
|
self.notify.warning("per_platform disagreement on package %s" % (self.packageName))
|
|
|
|
self.displayName = None
|
|
xconfig = xpackage.FirstChildElement('config')
|
|
if xconfig:
|
|
# The name for display to an English-speaking user.
|
|
self.displayName = xconfig.Attribute('display_name')
|
|
|
|
# True if any apps that use this package must be GUI apps.
|
|
guiApp = xconfig.Attribute('gui_app')
|
|
if guiApp:
|
|
self.guiApp = int(guiApp)
|
|
|
|
# The uncompressed archive, which will be mounted directly,
|
|
# and also used for patching.
|
|
xuncompressedArchive = xpackage.FirstChildElement('uncompressed_archive')
|
|
if xuncompressedArchive:
|
|
self.uncompressedArchive = FileSpec()
|
|
self.uncompressedArchive.loadXml(xuncompressedArchive)
|
|
|
|
# The compressed archive, which is what is downloaded.
|
|
xcompressedArchive = xpackage.FirstChildElement('compressed_archive')
|
|
if xcompressedArchive:
|
|
self.compressedArchive = FileSpec()
|
|
self.compressedArchive.loadXml(xcompressedArchive)
|
|
|
|
# The list of files that should be extracted to disk.
|
|
self.extracts = []
|
|
xextract = xpackage.FirstChildElement('extract')
|
|
while xextract:
|
|
file = FileSpec()
|
|
file.loadXml(xextract)
|
|
self.extracts.append(file)
|
|
xextract = xextract.NextSiblingElement('extract')
|
|
|
|
# The list of additional packages that must be installed for
|
|
# this package to function properly.
|
|
self.requires = []
|
|
xrequires = xpackage.FirstChildElement('requires')
|
|
while xrequires:
|
|
packageName = xrequires.Attribute('name')
|
|
version = xrequires.Attribute('version')
|
|
hostUrl = xrequires.Attribute('host')
|
|
if packageName and hostUrl:
|
|
host = self.host.appRunner.getHostWithAlt(hostUrl)
|
|
self.requires.append((packageName, version, host))
|
|
xrequires = xrequires.NextSiblingElement('requires')
|
|
|
|
self.hasDescFile = True
|
|
|
|
# Now that we've read the desc file, go ahead and use it to
|
|
# verify the download status.
|
|
if self.__checkArchiveStatus():
|
|
# It's all fully downloaded, unpacked, and ready.
|
|
self.hasPackage = True
|
|
return True
|
|
|
|
# Still have to download it.
|
|
self.__buildInstallPlans()
|
|
return True
|
|
|
|
def __buildInstallPlans(self):
|
|
""" Sets up self.installPlans, a list of one or more "plans"
|
|
to download and install the package. """
|
|
|
|
pc = PStatCollector(':App:PackageInstaller:buildInstallPlans')
|
|
pc.start()
|
|
|
|
self.hasPackage = False
|
|
|
|
if self.host.appRunner and self.host.appRunner.verifyContents == self.host.appRunner.P3DVCNever:
|
|
# We're not allowed to download anything.
|
|
self.installPlans = []
|
|
pc.stop()
|
|
return
|
|
|
|
if self.asMirror:
|
|
# If we're just downloading a mirror archive, we only need
|
|
# to get the compressed archive file.
|
|
|
|
# Build a one-item install plan to download the compressed
|
|
# archive.
|
|
downloadSize = self.compressedArchive.size
|
|
func = lambda step, fileSpec = self.compressedArchive: self.__downloadFile(step, fileSpec, allowPartial = True)
|
|
|
|
step = self.InstallStep(func, downloadSize, self.downloadFactor, 'download')
|
|
installPlan = [step]
|
|
self.installPlans = [installPlan]
|
|
pc.stop()
|
|
return
|
|
|
|
# The normal download process. Determine what we will need to
|
|
# download, and build a plan (or two) to download it all.
|
|
self.installPlans = None
|
|
|
|
# We know we will at least need to unpack the archive contents
|
|
# at the end.
|
|
unpackSize = 0
|
|
for file in self.extracts:
|
|
unpackSize += file.size
|
|
step = self.InstallStep(self.__unpackArchive, unpackSize, self.unpackFactor, 'unpack')
|
|
planA = [step]
|
|
|
|
# If the uncompressed archive file is good, that's all we'll
|
|
# need to do.
|
|
self.uncompressedArchive.actualFile = None
|
|
if self.uncompressedArchive.quickVerify(self.getPackageDir(), notify = self.notify):
|
|
self.installPlans = [planA]
|
|
pc.stop()
|
|
return
|
|
|
|
# Maybe the compressed archive file is good.
|
|
if self.compressedArchive.quickVerify(self.getPackageDir(), notify = self.notify):
|
|
uncompressSize = self.uncompressedArchive.size
|
|
step = self.InstallStep(self.__uncompressArchive, uncompressSize, self.uncompressFactor, 'uncompress')
|
|
planA = [step] + planA
|
|
self.installPlans = [planA]
|
|
pc.stop()
|
|
return
|
|
|
|
# Maybe we can download one or more patches. We'll come back
|
|
# to that in a minute as plan A. For now, construct plan B,
|
|
# which will be to download the whole archive.
|
|
planB = planA[:]
|
|
|
|
uncompressSize = self.uncompressedArchive.size
|
|
step = self.InstallStep(self.__uncompressArchive, uncompressSize, self.uncompressFactor, 'uncompress')
|
|
planB = [step] + planB
|
|
|
|
downloadSize = self.compressedArchive.size
|
|
func = lambda step, fileSpec = self.compressedArchive: self.__downloadFile(step, fileSpec, allowPartial = True)
|
|
|
|
step = self.InstallStep(func, downloadSize, self.downloadFactor, 'download')
|
|
planB = [step] + planB
|
|
|
|
# Now look for patches. Start with the md5 hash from the
|
|
# uncompressedArchive file we have on disk, and see if we can
|
|
# find a patch chain from this file to our target.
|
|
pathname = Filename(self.getPackageDir(), self.uncompressedArchive.filename)
|
|
fileSpec = self.uncompressedArchive.actualFile
|
|
if fileSpec is None and pathname.exists():
|
|
fileSpec = FileSpec()
|
|
fileSpec.fromFile(self.getPackageDir(), self.uncompressedArchive.filename)
|
|
plan = None
|
|
if fileSpec:
|
|
plan = self.__findPatchChain(fileSpec)
|
|
if plan:
|
|
# We can download patches. Great! That means this is
|
|
# plan A, and the full download is plan B (in case
|
|
# something goes wrong with the patching).
|
|
planA = plan + planA
|
|
self.installPlans = [planA, planB]
|
|
else:
|
|
# There are no patches to download, oh well. Stick with
|
|
# plan B as the only plan.
|
|
self.installPlans = [planB]
|
|
|
|
# In case of unexpected failures on the internet, we will retry
|
|
# the full download instead of just giving up.
|
|
for retry in range(ConfigVariableInt('package-full-dl-retries', 1)):
|
|
self.installPlans.append(planB[:])
|
|
|
|
pc.stop()
|
|
|
|
def __scanDirectoryRecursively(self, dirname):
|
|
""" Generates a list of Filename objects: all of the files
|
|
(not directories) within and below the indicated dirname. """
|
|
|
|
contents = []
|
|
for dirpath, dirnames, filenames in os.walk(dirname.toOsSpecific()):
|
|
dirpath = Filename.fromOsSpecific(dirpath)
|
|
if dirpath == dirname:
|
|
dirpath = Filename('')
|
|
else:
|
|
dirpath.makeRelativeTo(dirname)
|
|
for filename in filenames:
|
|
contents.append(Filename(dirpath, filename))
|
|
return contents
|
|
|
|
def __removeFileFromList(self, contents, filename):
|
|
""" Removes the indicated filename from the given list, if it is
|
|
present. """
|
|
try:
|
|
contents.remove(Filename(filename))
|
|
except ValueError:
|
|
pass
|
|
|
|
def __checkArchiveStatus(self):
|
|
""" Returns true if the archive and all extractable files are
|
|
already correct on disk, false otherwise. """
|
|
|
|
if self.host.appRunner and self.host.appRunner.verifyContents == self.host.appRunner.P3DVCNever:
|
|
# Assume that everything is just fine.
|
|
return True
|
|
|
|
# Get a list of all of the files in the directory, so we can
|
|
# remove files that don't belong.
|
|
contents = self.__scanDirectoryRecursively(self.getPackageDir())
|
|
self.__removeFileFromList(contents, self.descFileBasename)
|
|
self.__removeFileFromList(contents, self.compressedArchive.filename)
|
|
self.__removeFileFromList(contents, self.UsageBasename)
|
|
if not self.asMirror:
|
|
self.__removeFileFromList(contents, self.uncompressedArchive.filename)
|
|
for file in self.extracts:
|
|
self.__removeFileFromList(contents, file.filename)
|
|
|
|
# Now, any files that are still in the contents list don't
|
|
# belong. It's important to remove these files before we
|
|
# start verifying the files that we expect to find here, in
|
|
# case there is a problem with ambiguous filenames or
|
|
# something (e.g. case insensitivity).
|
|
for filename in contents:
|
|
self.notify.info("Removing %s" % (filename))
|
|
pathname = Filename(self.getPackageDir(), filename)
|
|
pathname.unlink()
|
|
self.updated = True
|
|
|
|
if self.asMirror:
|
|
return self.compressedArchive.quickVerify(self.getPackageDir(), notify = self.notify)
|
|
|
|
allExtractsOk = True
|
|
if not self.uncompressedArchive.quickVerify(self.getPackageDir(), notify = self.notify):
|
|
self.notify.debug("File is incorrect: %s" % (self.uncompressedArchive.filename))
|
|
allExtractsOk = False
|
|
|
|
if allExtractsOk:
|
|
# OK, the uncompressed archive is good; that means there
|
|
# shouldn't be a compressed archive file here.
|
|
pathname = Filename(self.getPackageDir(), self.compressedArchive.filename)
|
|
pathname.unlink()
|
|
|
|
for file in self.extracts:
|
|
if not file.quickVerify(self.getPackageDir(), notify = self.notify):
|
|
self.notify.debug("File is incorrect: %s" % (file.filename))
|
|
allExtractsOk = False
|
|
break
|
|
|
|
if allExtractsOk:
|
|
self.notify.debug("All %s extracts of %s seem good." % (
|
|
len(self.extracts), self.packageName))
|
|
|
|
return allExtractsOk
|
|
|
|
def __updateStepProgress(self, step):
|
|
""" This callback is made from within the several step
|
|
functions as the download step proceeds. It updates
|
|
self.downloadProgress with the current progress, so the caller
|
|
can asynchronously query this value. """
|
|
|
|
size = self.totalPlanCompleted + self.currentStepEffort * step.getProgress()
|
|
self.downloadProgress = min(float(size) / float(self.totalPlanSize), 1)
|
|
|
|
def downloadPackage(self, http):
|
|
""" Downloads the package file, synchronously, then
|
|
uncompresses and unpacks it. Returns true on success, false
|
|
on failure.
|
|
|
|
This assumes that self.installPlans has already been filled
|
|
in, which will have been done by self.__readDescFile().
|
|
"""
|
|
|
|
for token in self.downloadPackageGenerator(http):
|
|
if token != self.stepContinue:
|
|
break
|
|
Thread.considerYield()
|
|
|
|
return (token == self.stepComplete)
|
|
|
|
def downloadPackageGenerator(self, http):
|
|
""" A generator function that implements downloadPackage() one
|
|
piece at a time. It yields one of stepComplete, stepFailed,
|
|
or stepContinue. """
|
|
|
|
assert self.hasDescFile
|
|
|
|
if self.hasPackage:
|
|
# We've already got one.
|
|
yield self.stepComplete; return
|
|
|
|
if self.host.appRunner and self.host.appRunner.verifyContents == self.host.appRunner.P3DVCNever:
|
|
# We're not allowed to download anything. Assume it's already downloaded.
|
|
yield self.stepComplete; return
|
|
|
|
# We should have an install plan by the time we get here.
|
|
assert self.installPlans
|
|
|
|
self.http = http
|
|
for token in self.__followInstallPlans():
|
|
if token == self.stepContinue:
|
|
yield token
|
|
else:
|
|
break
|
|
|
|
while token == self.restartDownload:
|
|
# Try again.
|
|
for token in self.downloadDescFileGenerator(http):
|
|
if token == self.stepContinue:
|
|
yield token
|
|
else:
|
|
break
|
|
if token == self.stepComplete:
|
|
for token in self.__followInstallPlans():
|
|
if token == self.stepContinue:
|
|
yield token
|
|
else:
|
|
break
|
|
|
|
if token == self.stepFailed:
|
|
yield self.stepFailed; return
|
|
|
|
assert token == self.stepComplete
|
|
yield self.stepComplete; return
|
|
|
|
|
|
def __followInstallPlans(self):
|
|
""" Performs all of the steps in self.installPlans. Yields
|
|
one of stepComplete, stepFailed, restartDownload, or
|
|
stepContinue. """
|
|
|
|
if not self.installPlans:
|
|
self.__buildInstallPlans()
|
|
|
|
installPlans = self.installPlans
|
|
self.installPlans = None
|
|
for plan in installPlans:
|
|
self.totalPlanSize = sum([step.getEffort() for step in plan])
|
|
self.totalPlanCompleted = 0
|
|
self.downloadProgress = 0
|
|
|
|
planFailed = False
|
|
for step in plan:
|
|
self.currentStepEffort = step.getEffort()
|
|
|
|
for token in step.func():
|
|
if token == self.stepContinue:
|
|
yield token
|
|
else:
|
|
break
|
|
|
|
if token == self.restartDownload:
|
|
yield token
|
|
if token == self.stepFailed:
|
|
planFailed = True
|
|
break
|
|
assert token == self.stepComplete
|
|
|
|
self.totalPlanCompleted += self.currentStepEffort
|
|
|
|
if not planFailed:
|
|
# Successfully downloaded!
|
|
yield self.stepComplete; return
|
|
|
|
if taskMgr.destroyed:
|
|
yield self.stepFailed; return
|
|
|
|
# All plans failed.
|
|
yield self.stepFailed; return
|
|
|
|
def __findPatchChain(self, fileSpec):
|
|
""" Finds the chain of patches that leads from the indicated
|
|
patch version to the current patch version. If found,
|
|
constructs an installPlan that represents the steps of the
|
|
patch installation; otherwise, returns None. """
|
|
|
|
from direct.p3d.PatchMaker import PatchMaker
|
|
|
|
patchMaker = PatchMaker(self.getPackageDir())
|
|
patchChain = patchMaker.getPatchChainToCurrent(self.descFileBasename, fileSpec)
|
|
if patchChain is None:
|
|
# No path.
|
|
patchMaker.cleanup()
|
|
return None
|
|
|
|
plan = []
|
|
for patchfile in patchChain:
|
|
downloadSize = patchfile.file.size
|
|
func = lambda step, fileSpec = patchfile.file: self.__downloadFile(step, fileSpec, allowPartial = True)
|
|
step = self.InstallStep(func, downloadSize, self.downloadFactor, 'download')
|
|
plan.append(step)
|
|
|
|
patchSize = patchfile.targetFile.size
|
|
func = lambda step, patchfile = patchfile: self.__applyPatch(step, patchfile)
|
|
step = self.InstallStep(func, patchSize, self.patchFactor, 'patch')
|
|
plan.append(step)
|
|
|
|
patchMaker.cleanup()
|
|
return plan
|
|
|
|
def __downloadFile(self, step, fileSpec, urlbase = None, filename = None,
|
|
allowPartial = False):
|
|
""" Downloads the indicated file from the host into
|
|
packageDir. Yields one of stepComplete, stepFailed,
|
|
restartDownload, or stepContinue. """
|
|
|
|
if self.host.appRunner and self.host.appRunner.verifyContents == self.host.appRunner.P3DVCNever:
|
|
# We're not allowed to download anything.
|
|
yield self.stepFailed; return
|
|
|
|
self.updated = True
|
|
|
|
if not urlbase:
|
|
urlbase = self.descFileDirname + '/' + fileSpec.filename
|
|
|
|
# Build up a list of URL's to try downloading from. Unlike
|
|
# the C++ implementation in P3DPackage.cxx, here we build the
|
|
# URL's in forward order.
|
|
tryUrls = []
|
|
|
|
if self.host.appRunner and self.host.appRunner.superMirrorUrl:
|
|
# We start with the "super mirror", if it's defined.
|
|
url = self.host.appRunner.superMirrorUrl + urlbase
|
|
tryUrls.append((url, False))
|
|
|
|
if self.host.mirrors:
|
|
# Choose two mirrors at random.
|
|
mirrors = self.host.mirrors[:]
|
|
for i in range(2):
|
|
mirror = random.choice(mirrors)
|
|
mirrors.remove(mirror)
|
|
url = mirror + urlbase
|
|
tryUrls.append((url, False))
|
|
if not mirrors:
|
|
break
|
|
|
|
# After trying two mirrors and failing (or if there are no
|
|
# mirrors), go get it from the original host.
|
|
url = self.host.downloadUrlPrefix + urlbase
|
|
tryUrls.append((url, False))
|
|
|
|
# And finally, if the original host also fails, try again with
|
|
# a cache-buster.
|
|
tryUrls.append((url, True))
|
|
|
|
for url, cacheBust in tryUrls:
|
|
request = DocumentSpec(url)
|
|
|
|
if cacheBust:
|
|
# On the last attempt to download a particular file,
|
|
# we bust through the cache: append a query string to
|
|
# do this.
|
|
url += '?' + str(int(time.time()))
|
|
request = DocumentSpec(url)
|
|
request.setCacheControl(DocumentSpec.CCNoCache)
|
|
|
|
self.notify.info("%s downloading %s" % (self.packageName, url))
|
|
|
|
if not filename:
|
|
filename = fileSpec.filename
|
|
targetPathname = Filename(self.getPackageDir(), filename)
|
|
targetPathname.setBinary()
|
|
|
|
channel = self.http.makeChannel(False)
|
|
|
|
# If there's a previous partial download, attempt to resume it.
|
|
bytesStarted = 0
|
|
if allowPartial and not cacheBust and targetPathname.exists():
|
|
bytesStarted = targetPathname.getFileSize()
|
|
|
|
if bytesStarted < 1024*1024:
|
|
# Not enough bytes downloaded to be worth the risk of
|
|
# a partial download.
|
|
bytesStarted = 0
|
|
elif bytesStarted >= fileSpec.size:
|
|
# Couldn't possibly be our file.
|
|
bytesStarted = 0
|
|
|
|
if bytesStarted:
|
|
self.notify.info("Resuming %s after %s bytes already downloaded" % (url, bytesStarted))
|
|
# Make sure the file is writable.
|
|
os.chmod(targetPathname.toOsSpecific(), 0o644)
|
|
channel.beginGetSubdocument(request, bytesStarted, 0)
|
|
else:
|
|
# No partial download possible; get the whole file.
|
|
targetPathname.makeDir()
|
|
targetPathname.unlink()
|
|
channel.beginGetDocument(request)
|
|
|
|
channel.downloadToFile(targetPathname)
|
|
while channel.run():
|
|
if step:
|
|
step.bytesDone = channel.getBytesDownloaded() + channel.getFirstByteDelivered()
|
|
if step.bytesDone > step.bytesNeeded:
|
|
# Oops, too much data. Might as well abort;
|
|
# it's the wrong file.
|
|
self.notify.warning("Got more data than expected for download %s" % (url))
|
|
break
|
|
|
|
self.__updateStepProgress(step)
|
|
|
|
if taskMgr.destroyed:
|
|
# If the task manager has been destroyed, we must
|
|
# be shutting down. Get out of here.
|
|
self.notify.warning("Task Manager destroyed, aborting %s" % (url))
|
|
yield self.stepFailed; return
|
|
|
|
yield self.stepContinue
|
|
|
|
if step:
|
|
step.bytesDone = channel.getBytesDownloaded() + channel.getFirstByteDelivered()
|
|
self.__updateStepProgress(step)
|
|
|
|
if not channel.isValid():
|
|
self.notify.warning("Failed to download %s" % (url))
|
|
|
|
elif not fileSpec.fullVerify(self.getPackageDir(), pathname = targetPathname, notify = self.notify):
|
|
self.notify.warning("After downloading, %s incorrect" % (Filename(fileSpec.filename).getBasename()))
|
|
|
|
# This attempt failed. Maybe the original contents.xml
|
|
# file is stale. Try re-downloading it now, just to be
|
|
# sure.
|
|
if self.host.redownloadContentsFile(self.http):
|
|
# Yes! Go back and start over from the beginning.
|
|
yield self.restartDownload; return
|
|
|
|
else:
|
|
# Success!
|
|
yield self.stepComplete; return
|
|
|
|
# Maybe the mirror is bad. Go back and try the next
|
|
# mirror.
|
|
|
|
# All attempts failed. Maybe the original contents.xml file
|
|
# is stale. Try re-downloading it now, just to be sure.
|
|
if self.host.redownloadContentsFile(self.http):
|
|
# Yes! Go back and start over from the beginning.
|
|
yield self.restartDownload; return
|
|
|
|
# All mirrors failed; the server (or the internet connection)
|
|
# must be just fubar.
|
|
yield self.stepFailed; return
|
|
|
|
def __applyPatch(self, step, patchfile):
|
|
""" Applies the indicated patching in-place to the current
|
|
uncompressed archive. The patchfile is removed after the
|
|
operation. Yields one of stepComplete, stepFailed,
|
|
restartDownload, or stepContinue. """
|
|
|
|
self.updated = True
|
|
|
|
origPathname = Filename(self.getPackageDir(), self.uncompressedArchive.filename)
|
|
patchPathname = Filename(self.getPackageDir(), patchfile.file.filename)
|
|
result = Filename.temporary('', 'patch_')
|
|
self.notify.info("Patching %s with %s" % (origPathname, patchPathname))
|
|
|
|
p = PandaModules.Patchfile() # The C++ class
|
|
|
|
ret = p.initiate(patchPathname, origPathname, result)
|
|
if ret == EUSuccess:
|
|
ret = p.run()
|
|
while ret == EUOk:
|
|
step.bytesDone = step.bytesNeeded * p.getProgress()
|
|
self.__updateStepProgress(step)
|
|
if taskMgr.destroyed:
|
|
# If the task manager has been destroyed, we must
|
|
# be shutting down. Get out of here.
|
|
self.notify.warning("Task Manager destroyed, aborting patch %s" % (origPathname))
|
|
yield self.stepFailed; return
|
|
|
|
yield self.stepContinue
|
|
ret = p.run()
|
|
del p
|
|
patchPathname.unlink()
|
|
|
|
if ret < 0:
|
|
self.notify.warning("Patching of %s failed." % (origPathname))
|
|
result.unlink()
|
|
yield self.stepFailed; return
|
|
|
|
if not result.renameTo(origPathname):
|
|
self.notify.warning("Couldn't rename %s to %s" % (result, origPathname))
|
|
yield self.stepFailed; return
|
|
|
|
yield self.stepComplete; return
|
|
|
|
def __uncompressArchive(self, step):
|
|
""" Turns the compressed archive into the uncompressed
|
|
archive. Yields one of stepComplete, stepFailed,
|
|
restartDownload, or stepContinue. """
|
|
|
|
if self.host.appRunner and self.host.appRunner.verifyContents == self.host.appRunner.P3DVCNever:
|
|
# We're not allowed to!
|
|
yield self.stepFailed; return
|
|
|
|
self.updated = True
|
|
|
|
sourcePathname = Filename(self.getPackageDir(), self.compressedArchive.filename)
|
|
targetPathname = Filename(self.getPackageDir(), self.uncompressedArchive.filename)
|
|
targetPathname.unlink()
|
|
self.notify.info("Uncompressing %s to %s" % (sourcePathname, targetPathname))
|
|
decompressor = Decompressor()
|
|
decompressor.initiate(sourcePathname, targetPathname)
|
|
totalBytes = self.uncompressedArchive.size
|
|
result = decompressor.run()
|
|
while result == EUOk:
|
|
step.bytesDone = int(totalBytes * decompressor.getProgress())
|
|
self.__updateStepProgress(step)
|
|
result = decompressor.run()
|
|
if taskMgr.destroyed:
|
|
# If the task manager has been destroyed, we must
|
|
# be shutting down. Get out of here.
|
|
self.notify.warning("Task Manager destroyed, aborting decompresss %s" % (sourcePathname))
|
|
yield self.stepFailed; return
|
|
|
|
yield self.stepContinue
|
|
|
|
if result != EUSuccess:
|
|
yield self.stepFailed; return
|
|
|
|
step.bytesDone = totalBytes
|
|
self.__updateStepProgress(step)
|
|
|
|
if not self.uncompressedArchive.quickVerify(self.getPackageDir(), notify= self.notify):
|
|
self.notify.warning("after uncompressing, %s still incorrect" % (
|
|
self.uncompressedArchive.filename))
|
|
yield self.stepFailed; return
|
|
|
|
# Now that we've verified the archive, make it read-only.
|
|
os.chmod(targetPathname.toOsSpecific(), 0o444)
|
|
|
|
# Now we can safely remove the compressed archive.
|
|
sourcePathname.unlink()
|
|
yield self.stepComplete; return
|
|
|
|
def __unpackArchive(self, step):
|
|
""" Unpacks any files in the archive that want to be unpacked
|
|
to disk. Yields one of stepComplete, stepFailed,
|
|
restartDownload, or stepContinue. """
|
|
|
|
if not self.extracts:
|
|
# Nothing to extract.
|
|
self.hasPackage = True
|
|
yield self.stepComplete; return
|
|
|
|
if self.host.appRunner and self.host.appRunner.verifyContents == self.host.appRunner.P3DVCNever:
|
|
# We're not allowed to!
|
|
yield self.stepFailed; return
|
|
|
|
self.updated = True
|
|
|
|
mfPathname = Filename(self.getPackageDir(), self.uncompressedArchive.filename)
|
|
self.notify.info("Unpacking %s" % (mfPathname))
|
|
mf = Multifile()
|
|
if not mf.openRead(mfPathname):
|
|
self.notify.warning("Couldn't open %s" % (mfPathname))
|
|
yield self.stepFailed; return
|
|
|
|
allExtractsOk = True
|
|
step.bytesDone = 0
|
|
for file in self.extracts:
|
|
i = mf.findSubfile(file.filename)
|
|
if i == -1:
|
|
self.notify.warning("Not in Multifile: %s" % (file.filename))
|
|
allExtractsOk = False
|
|
continue
|
|
|
|
targetPathname = Filename(self.getPackageDir(), file.filename)
|
|
targetPathname.setBinary()
|
|
targetPathname.unlink()
|
|
if not mf.extractSubfile(i, targetPathname):
|
|
self.notify.warning("Couldn't extract: %s" % (file.filename))
|
|
allExtractsOk = False
|
|
continue
|
|
|
|
if not file.quickVerify(self.getPackageDir(), notify = self.notify):
|
|
self.notify.warning("After extracting, still incorrect: %s" % (file.filename))
|
|
allExtractsOk = False
|
|
continue
|
|
|
|
# Make sure it's executable, and not writable.
|
|
os.chmod(targetPathname.toOsSpecific(), 0o555)
|
|
|
|
step.bytesDone += file.size
|
|
self.__updateStepProgress(step)
|
|
if taskMgr.destroyed:
|
|
# If the task manager has been destroyed, we must
|
|
# be shutting down. Get out of here.
|
|
self.notify.warning("Task Manager destroyed, aborting unpacking %s" % (mfPathname))
|
|
yield self.stepFailed; return
|
|
|
|
yield self.stepContinue
|
|
|
|
if not allExtractsOk:
|
|
yield self.stepFailed; return
|
|
|
|
self.hasPackage = True
|
|
yield self.stepComplete; return
|
|
|
|
def installPackage(self, appRunner):
|
|
""" Mounts the package and sets up system paths so it becomes
|
|
available for use. Returns true on success, false on failure. """
|
|
|
|
assert self.hasPackage
|
|
if self.installed:
|
|
# Already installed.
|
|
return True
|
|
assert self not in appRunner.installedPackages
|
|
|
|
mfPathname = Filename(self.getPackageDir(), self.uncompressedArchive.filename)
|
|
mf = Multifile()
|
|
if not mf.openRead(mfPathname):
|
|
self.notify.warning("Couldn't open %s" % (mfPathname))
|
|
return False
|
|
|
|
# We mount it under its actual location on disk.
|
|
root = self.getPackageDir().cStr()
|
|
|
|
vfs = VirtualFileSystem.getGlobalPtr()
|
|
vfs.mount(mf, root, vfs.MFReadOnly)
|
|
|
|
# Add this to the Python search path, if it's not already
|
|
# there. We have to take a bit of care to check if it's
|
|
# already there, since there can be some ambiguity in
|
|
# os-specific path strings.
|
|
osRoot = self.getPackageDir().toOsSpecific()
|
|
foundOnPath = False
|
|
for p in sys.path:
|
|
if osRoot == p:
|
|
# Already here, exactly.
|
|
foundOnPath = True
|
|
break
|
|
elif osRoot == Filename.fromOsSpecific(p).toOsSpecific():
|
|
# Already here, with some futzing.
|
|
foundOnPath = True
|
|
break
|
|
|
|
if not foundOnPath:
|
|
# Not already here; add it.
|
|
sys.path.append(osRoot)
|
|
|
|
# Put it on the model-path, too. We do this indiscriminantly,
|
|
# because the Panda3D runtime won't be adding things to the
|
|
# model-path, so it shouldn't be already there.
|
|
getModelPath().appendDirectory(self.getPackageDir())
|
|
|
|
# Set the environment variable to reference the package root.
|
|
envvar = '%s_ROOT' % (self.packageName.upper())
|
|
ExecutionEnvironment.setEnvironmentVariable(envvar, osRoot)
|
|
|
|
# Add the package root to the system paths.
|
|
if sys.platform.startswith('win'):
|
|
path = os.environ.get('PATH', '')
|
|
os.environ['PATH'] = "%s;%s" % (osRoot, path)
|
|
else:
|
|
path = os.environ.get('PATH', '')
|
|
os.environ['PATH'] = "%s:%s" % (osRoot, path)
|
|
path = os.environ.get('LD_LIBRARY_PATH', '')
|
|
os.environ['LD_LIBRARY_PATH'] = "%s:%s" % (osRoot, path)
|
|
|
|
if sys.platform == "darwin":
|
|
path = os.environ.get('DYLD_LIBRARY_PATH', '')
|
|
os.environ['DYLD_LIBRARY_PATH'] = "%s:%s" % (osRoot, path)
|
|
|
|
# Now that the environment variable is set, read all of the
|
|
# prc files in the package.
|
|
appRunner.loadMultifilePrcFiles(mf, self.getPackageDir())
|
|
|
|
# Also, find any toplevel Python packages, and add these as
|
|
# shared packages. This will allow different packages
|
|
# installed in different directories to share Python files as
|
|
# if they were all in the same directory.
|
|
for filename in mf.getSubfileNames():
|
|
if filename.endswith('/__init__.pyc') or \
|
|
filename.endswith('/__init__.pyo') or \
|
|
filename.endswith('/__init__.py'):
|
|
components = filename.split('/')[:-1]
|
|
moduleName = '.'.join(components)
|
|
VFSImporter.sharedPackages[moduleName] = True
|
|
|
|
# Fix up any shared directories so we can load packages from
|
|
# disparate locations.
|
|
VFSImporter.reloadSharedPackages()
|
|
|
|
self.installed = True
|
|
appRunner.installedPackages.append(self)
|
|
|
|
self.markUsed()
|
|
|
|
return True
|
|
|
|
def __measureDiskSpace(self):
|
|
""" Returns the amount of space used by this package, in
|
|
bytes, as determined by examining the actual contents of the
|
|
package directory and its subdirectories. """
|
|
|
|
thisDir = ScanDirectoryNode(self.getPackageDir(), ignoreUsageXml = True)
|
|
diskSpace = thisDir.getTotalSize()
|
|
self.notify.info("Package %s uses %s MB" % (
|
|
self.packageName, (diskSpace + 524288) / 1048576))
|
|
return diskSpace
|
|
|
|
def markUsed(self):
|
|
""" Marks the package as having been used. This is normally
|
|
called automatically by installPackage(). """
|
|
|
|
if not hasattr(PandaModules, 'TiXmlDocument'):
|
|
return
|
|
|
|
if self.host.appRunner and self.host.appRunner.verifyContents == self.host.appRunner.P3DVCNever:
|
|
# Not allowed to write any files to the package directory.
|
|
return
|
|
|
|
if self.updated:
|
|
# If we've just installed a new version of the package,
|
|
# re-measure the actual disk space used.
|
|
self.diskSpace = self.__measureDiskSpace()
|
|
|
|
filename = Filename(self.getPackageDir(), self.UsageBasename)
|
|
doc = TiXmlDocument(filename.toOsSpecific())
|
|
if not doc.LoadFile():
|
|
decl = TiXmlDeclaration("1.0", "utf-8", "")
|
|
doc.InsertEndChild(decl)
|
|
|
|
xusage = doc.FirstChildElement('usage')
|
|
if not xusage:
|
|
doc.InsertEndChild(TiXmlElement('usage'))
|
|
xusage = doc.FirstChildElement('usage')
|
|
|
|
now = int(time.time())
|
|
|
|
count = xusage.Attribute('count_app')
|
|
try:
|
|
count = int(count or '')
|
|
except ValueError:
|
|
count = 0
|
|
xusage.SetAttribute('first_use', str(now))
|
|
count += 1
|
|
xusage.SetAttribute('count_app', str(count))
|
|
|
|
xusage.SetAttribute('last_use', str(now))
|
|
|
|
if self.updated:
|
|
xusage.SetAttribute('last_update', str(now))
|
|
self.updated = False
|
|
else:
|
|
# Since we haven't changed the disk space, we can just
|
|
# read it from the previous xml file.
|
|
diskSpace = xusage.Attribute('disk_space')
|
|
try:
|
|
diskSpace = int(diskSpace or '')
|
|
except ValueError:
|
|
# Unless it wasn't set already.
|
|
self.diskSpace = self.__measureDiskSpace()
|
|
|
|
xusage.SetAttribute('disk_space', str(self.diskSpace))
|
|
|
|
# Write the file to a temporary filename, then atomically move
|
|
# it to its actual filename, to avoid race conditions when
|
|
# updating this file.
|
|
tfile = Filename.temporary(self.getPackageDir().cStr(), '.xml')
|
|
if doc.SaveFile(tfile.toOsSpecific()):
|
|
tfile.renameTo(filename)
|
|
|
|
def getUsage(self):
|
|
""" Returns the xusage element that is read from the usage.xml
|
|
file, or None if there is no usage.xml file. """
|
|
|
|
if not hasattr(PandaModules, 'TiXmlDocument'):
|
|
return None
|
|
|
|
filename = Filename(self.getPackageDir(), self.UsageBasename)
|
|
doc = TiXmlDocument(filename.toOsSpecific())
|
|
if not doc.LoadFile():
|
|
return None
|
|
|
|
xusage = doc.FirstChildElement('usage')
|
|
if not xusage:
|
|
return None
|
|
|
|
return copy.copy(xusage)
|
|
|