This commit is contained in:
gallaert 2020-03-01 16:14:13 +00:00
commit 2d7ad25031
10 changed files with 302 additions and 113 deletions

View File

@ -250,10 +250,16 @@ namespace canvas
if( !texture )
{
// It shouldn't be necessary to allocate an image for the
// texture that is the target of dynamic rendering, but
// otherwise OSG won't construct all the mipmaps for the texture
// and dynamic mipmap generation doesn't work.
osg::Image* image = new osg::Image;
image->allocateImage(_size_x, _size_y, 1, GL_RGBA, GL_UNSIGNED_BYTE);
texture = new osg::Texture2D;
texture->setResizeNonPowerOfTwoHint(false);
texture->setTextureSize(_size_x, _size_y);
texture->setInternalFormat(GL_RGBA);
texture->setImage(image);
texture->setUnRefImageDataAfterApply(true);
}
updateSampling();

View File

@ -34,9 +34,11 @@
#include "simgear/debug/logstream.hxx"
#include "simgear/misc/strutils.hxx"
#include <simgear/misc/sg_dir.hxx>
#include <simgear/io/HTTPClient.hxx>
#include <simgear/io/sg_file.hxx>
#include <simgear/io/untar.hxx>
#include <simgear/io/iostreams/sgstream.hxx>
#include <simgear/structure/exception.hxx>
#include <simgear/timing/timestamp.hxx>
@ -176,7 +178,8 @@ class HTTPDirectory
enum Type
{
FileType,
DirectoryType
DirectoryType,
TarballType
};
ChildInfo(Type ty, const std::string & nameData, const std::string & hashData) :
@ -186,7 +189,7 @@ class HTTPDirectory
{
}
ChildInfo(const ChildInfo& other) = default;
ChildInfo(const ChildInfo& other) = default;
void setSize(const std::string & sizeData)
{
@ -311,59 +314,41 @@ public:
void updateChildrenBasedOnHash()
{
//SG_LOG(SG_TERRASYNC, SG_DEBUG, "updated children for:" << relativePath());
copyInstalledChildren();
copyInstalledChildren();
ChildInfoList toBeUpdated;
string_list toBeUpdated, orphans,
indexNames = indexChildren();
simgear::Dir d(absolutePath());
PathList fsChildren = d.children(0);
simgear::Dir d(absolutePath());
PathList fsChildren = d.children(0);
PathList orphans = d.children(0);
for (const auto& child : fsChildren) {
const auto& fileName = child.file();
if ((fileName == ".dirindex") || (fileName == ".hashes")) {
continue;
}
ChildInfoList::const_iterator it;
for (it=children.begin(); it != children.end(); ++it) {
// Check if the file exists
PathList::const_iterator p = std::find_if(fsChildren.begin(), fsChildren.end(), LocalFileMatcher(*it));
if (p == fsChildren.end()) {
// File or directory does not exist on local disk, so needs to be updated.
toBeUpdated.push_back(ChildInfo(*it));
} else if (hashForChild(*it) != it->hash) {
// File/directory exists, but hash doesn't match.
toBeUpdated.push_back(ChildInfo(*it));
orphans.erase(std::remove(orphans.begin(), orphans.end(), *p), orphans.end());
} else {
// File/Directory exists and hash is valid.
orphans.erase(std::remove(orphans.begin(), orphans.end(), *p), orphans.end());
ChildInfo info(child.isDir() ? ChildInfo::DirectoryType : ChildInfo::FileType,
fileName, "");
info.path = child;
std::string hash = hashForChild(info);
if (it->type == ChildInfo::DirectoryType) {
// If it's a directory,perform a recursive check.
HTTPDirectory* childDir = childDirectory(it->name);
childDir->updateChildrenBasedOnHash();
}
}
}
ChildInfoList::iterator c = findIndexChild(fileName);
if (c == children.end()) {
orphans.push_back(fileName);
} else if (c->hash != hash) {
#if 0
SG_LOG(SG_TERRASYNC, SG_DEBUG, "hash mismatch'" << fileName);
// file exists, but hash mismatch, schedule update
if (!hash.empty()) {
SG_LOG(SG_TERRASYNC, SG_DEBUG, "file exists but hash is wrong for:" << fileName);
SG_LOG(SG_TERRASYNC, SG_DEBUG, "on disk:" << hash << " vs in info:" << c->hash);
}
#endif
toBeUpdated.push_back(fileName);
} else {
// file exists and hash is valid. If it's a directory,
// perform a recursive check.
if (c->type == ChildInfo::DirectoryType) {
HTTPDirectory* childDir = childDirectory(fileName);
childDir->updateChildrenBasedOnHash();
}
}
// remove existing file system children from the index list,
// so we can detect new children
// https://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Erase-Remove
indexNames.erase(std::remove(indexNames.begin(), indexNames.end(), fileName), indexNames.end());
} // of real children iteration
// all remaining names in indexChilden are new children
toBeUpdated.insert(toBeUpdated.end(), indexNames.begin(), indexNames.end());
removeOrphans(orphans);
scheduleUpdates(toBeUpdated);
// We now have a list of entries that need to be updated, and a list
// of orphan files that should be removed.
removeOrphans(orphans);
scheduleUpdates(toBeUpdated);
}
HTTPDirectory* childDirectory(const std::string& name)
@ -372,10 +357,12 @@ public:
return _repository->getOrCreateDirectory(childPath);
}
void removeOrphans(const string_list& orphans)
void removeOrphans(const PathList orphans)
{
string_list::const_iterator it;
PathList::const_iterator it;
for (it = orphans.begin(); it != orphans.end(); ++it) {
if (it->file() == ".dirindex") continue;
if (it->file() == ".hash") continue;
removeChild(*it);
}
}
@ -391,21 +378,20 @@ public:
return r;
}
void scheduleUpdates(const string_list& names)
void scheduleUpdates(const ChildInfoList names)
{
string_list::const_iterator it;
ChildInfoList::const_iterator it;
for (it = names.begin(); it != names.end(); ++it) {
ChildInfoList::iterator cit = findIndexChild(*it);
if (cit == children.end()) {
SG_LOG(SG_TERRASYNC, SG_WARN, "scheduleUpdate, unknown child:" << *it);
continue;
}
if (cit->type == ChildInfo::FileType) {
_repository->updateFile(this, *it, cit->sizeInBytes);
if (it->type == ChildInfo::FileType) {
_repository->updateFile(this, it->name, it->sizeInBytes);
} else if (it->type == ChildInfo::DirectoryType){
HTTPDirectory* childDir = childDirectory(it->name);
_repository->updateDir(childDir, it->hash, it->sizeInBytes);
} else if (it->type == ChildInfo::TarballType) {
// Download a tarball just as a file.
_repository->updateFile(this, it->name, it->sizeInBytes);
} else {
HTTPDirectory* childDir = childDirectory(*it);
_repository->updateDir(childDir, cit->hash, cit->sizeInBytes);
SG_LOG(SG_TERRASYNC, SG_ALERT, "Coding error! Unknown Child type to schedule update");
}
}
}
@ -430,12 +416,58 @@ public:
SG_LOG(SG_TERRASYNC, SG_WARN, "updated file but not found in dir:" << _relativePath << " " << file);
} else {
if (it->hash != hash) {
// we don't erase the file on a hash mismatch, becuase if we're syncing during the
SG_LOG(SG_TERRASYNC, SG_INFO, "Checksum error for " << absolutePath() << "/" << file << " " << it->hash << " " << hash);
// we don't erase the file on a hash mismatch, because if we're syncing during the
// middle of a server-side update, the downloaded file may actually become valid.
_repository->failedToUpdateChild(_relativePath, HTTPRepository::REPO_ERROR_CHECKSUM);
} else {
_repository->updatedFileContents(it->path, hash);
_repository->totalDownloaded += sz;
SGPath p = SGPath(absolutePath(), file);
if ((p.extension() == "tgz") || (p.extension() == "zip")) {
// We require that any compressed files have the same filename as the file or directory
// they expand to, so we can remove the old file/directory before extracting the new
// data.
SGPath removePath = SGPath(p.base());
bool pathAvailable = true;
if (removePath.exists()) {
if (removePath.isDir()) {
simgear::Dir pd(removePath);
pathAvailable = pd.removeChildren();
} else {
pathAvailable = removePath.remove();
}
}
if (pathAvailable) {
// If this is a tarball, then extract it.
SGBinaryFile f(p);
if (! f.open(SG_IO_IN)) SG_LOG(SG_TERRASYNC, SG_ALERT, "Unable to open " << p << " to extract");
SG_LOG(SG_TERRASYNC, SG_INFO, "Extracting " << absolutePath() << "/" << file << " to " << p.dir());
SGPath extractDir = p.dir();
ArchiveExtractor ex(extractDir);
uint8_t* buf = (uint8_t*) alloca(128);
while (!f.eof()) {
size_t bufSize = f.read((char*) buf, 128);
ex.extractBytes(buf, bufSize);
}
ex.flush();
if (! ex.isAtEndOfArchive()) {
SG_LOG(SG_TERRASYNC, SG_ALERT, "Corrupt tarball " << p);
}
if (ex.hasError()) {
SG_LOG(SG_TERRASYNC, SG_ALERT, "Error extracting " << p);
}
} else {
SG_LOG(SG_TERRASYNC, SG_ALERT, "Unable to remove old file/directory " << removePath);
} // of pathAvailable
} // of handling tgz files
} // of hash matches
} // of found in child list
}
@ -458,6 +490,16 @@ private:
{ return info.name == name; }
};
struct LocalFileMatcher
{
LocalFileMatcher(const ChildInfo ci) : childInfo(ci) {}
ChildInfo childInfo;
bool operator()(const SGPath path) const {
return path.file() == childInfo.name;
}
};
ChildInfoList::iterator findIndexChild(const std::string& name)
{
return std::find_if(children.begin(), children.end(), ChildWithName(name));
@ -519,8 +561,8 @@ private:
continue;
}
if (typeData != "f" && typeData != "d" ) {
SG_LOG(SG_TERRASYNC, SG_WARN, "malformed .dirindex file: invalid type in line '" << line << "', expected 'd' or 'f', (ignoring line)"
if (typeData != "f" && typeData != "d" && typeData != "t" ) {
SG_LOG(SG_TERRASYNC, SG_WARN, "malformed .dirindex file: invalid type in line '" << line << "', expected 't', 'd' or 'f', (ignoring line)"
<< "\n\tparsing:" << p.utf8Str());
continue;
}
@ -533,8 +575,12 @@ private:
continue;
}
children.emplace_back(ChildInfo(typeData == "f" ? ChildInfo::FileType : ChildInfo::DirectoryType, tokens[1], tokens[2]));
children.back().path = absolutePath() / tokens[1];
ChildInfo ci = ChildInfo(ChildInfo::FileType, tokens[1], tokens[2]);
if (typeData == "d") ci.type = ChildInfo::DirectoryType;
if (typeData == "t") ci.type = ChildInfo::TarballType;
children.emplace_back(ci);
children.back().path = absolutePath() / tokens[1];
if (tokens.size() > 3) {
children.back().setSize(tokens[3]);
}
@ -543,40 +589,36 @@ private:
return true;
}
void removeChild(const std::string& name)
void removeChild(SGPath path)
{
SGPath p(absolutePath());
p.append(name);
bool ok;
SG_LOG(SG_TERRASYNC, SG_WARN, "Removing:" << path);
std::string fpath = _relativePath + "/" + name;
if (p.isDir()) {
ok = _repository->deleteDirectory(fpath, p);
std::string fpath = _relativePath + "/" + path.file();
if (path.isDir()) {
ok = _repository->deleteDirectory(fpath, path);
} else {
// remove the hash cache entry
_repository->updatedFileContents(p, std::string());
ok = p.remove();
_repository->updatedFileContents(path, std::string());
ok = path.remove();
}
if (!ok) {
SG_LOG(SG_TERRASYNC, SG_WARN, "removal failed for:" << p);
throw sg_io_exception("Failed to remove existing file/dir:", p);
SG_LOG(SG_TERRASYNC, SG_WARN, "removal failed for:" << path);
throw sg_io_exception("Failed to remove existing file/dir:", path);
}
}
std::string hashForChild(const ChildInfo& child) const
{
SGPath p(child.path);
if (child.type == ChildInfo::DirectoryType) {
p.append(".dirindex");
}
SGPath p(child.path);
if (child.type == ChildInfo::DirectoryType) p.append(".dirindex");
if (child.type == ChildInfo::TarballType) p.concat(".tgz"); // For tarballs the hash is against the tarball file itself
return _repository->hashForPath(p);
}
HTTPRepoPrivate* _repository;
std::string _relativePath; // in URL and file-system space
};
HTTPRepository::HTTPRepository(const SGPath& base, HTTP::Client *cl) :

View File

@ -61,23 +61,23 @@ public:
std::map<string, string> headers;
protected:
virtual void onDone()
void onDone() override
{
complete = true;
}
virtual void onFail()
void onFail() override
{
failed = true;
}
virtual void gotBodyData(const char* s, int n)
void gotBodyData(const char* s, int n) override
{
//std::cout << "got body data:'" << string(s, n) << "'" <<std::endl;
// std::cout << "got body data:'" << string(s, n) << "'" <<std::endl;
bodyData += string(s, n);
}
virtual void responseHeader(const string& header, const string& value)
void responseHeader(const string& header, const string& value) override
{
Request::responseHeader(header, value);
headers[header] = value;
@ -783,8 +783,15 @@ cout << "testing proxy close" << endl;
SG_CHECK_EQUAL(tr3->bodyData, string(BODY1));
}
// disabling this test for now, since it seems to have changed depending
// on the libCurl version. (Or some other configuration which is currently
// not apparent).
// old behaviour: Curl sends the second request soon after makeRequest
// new behaviour: Curl waits for the first request to complete, before
// sending the second request (i.e acts as if HTTP pipelining is disabled)
#if 0
{
cout << "get-during-response-send" << endl;
cout << "get-during-response-send\n\n" << endl;
cl.clearAllConnections();
//test_get_during_send
@ -804,7 +811,10 @@ cout << "testing proxy close" << endl;
HTTP::Request_ptr own2(tr2);
cl.makeRequest(tr2);
waitForComplete(&cl, tr2);
SG_VERIFY(waitFor(&cl, [tr, tr2]() {
return tr->isComplete() && tr2->isComplete();
}));
SG_CHECK_EQUAL(tr->responseCode(), 200);
SG_CHECK_EQUAL(tr->bodyData, string(BODY3));
SG_CHECK_EQUAL(tr->responseBytesReceived(), strlen(BODY3));
@ -812,6 +822,7 @@ cout << "testing proxy close" << endl;
SG_CHECK_EQUAL(tr2->bodyData, string(BODY1));
SG_CHECK_EQUAL(tr2->responseBytesReceived(), strlen(BODY1));
}
#endif
{
cout << "redirect test" << endl;

View File

@ -198,9 +198,7 @@ private:
//////////////////////////////////////////////////////////////////////////////
Catalog::Catalog(Root *aRoot) :
m_root(aRoot),
m_status(Delegate::FAIL_UNKNOWN),
m_retrievedTime(0)
m_root(aRoot)
{
}
@ -221,7 +219,7 @@ CatalogRef Catalog::createFromPath(Root* aRoot, const SGPath& aPath)
SGPath xml = aPath;
xml.append("catalog.xml");
if (!xml.exists()) {
return NULL;
return nullptr;
}
SGPropertyNode_ptr props;
@ -229,7 +227,7 @@ CatalogRef Catalog::createFromPath(Root* aRoot, const SGPath& aPath)
props = new SGPropertyNode;
readProperties(xml, props);
} catch (sg_exception& ) {
return NULL;
return nullptr;
}
bool versionCheckOk = checkVersion(aRoot->applicationVersion(), props);
@ -240,19 +238,29 @@ CatalogRef Catalog::createFromPath(Root* aRoot, const SGPath& aPath)
} else {
SG_LOG(SG_GENERAL, SG_DEBUG, "creating catalog from:" << aPath);
}
// check for the marker file we write, to mark a catalog as disabled
const SGPath disableMarkerFile = aPath / "_disabled_";
CatalogRef c = new Catalog(aRoot);
c->m_installRoot = aPath;
if (disableMarkerFile.exists()) {
c->m_userEnabled = false;
}
c->parseProps(props);
c->parseTimestamp();
if (!c->validatePackages()) {
c->changeStatus(Delegate::FAIL_VALIDATION);
} else if (versionCheckOk) {
} else if (!versionCheckOk) {
c->changeStatus(Delegate::FAIL_VERSION);
} else if (!c->m_userEnabled) {
c->changeStatus(Delegate::USER_DISABLED);
} else {
// parsed XML ok, mark status as valid
c->changeStatus(Delegate::STATUS_SUCCESS);
} else {
c->changeStatus(Delegate::FAIL_VERSION);
}
return c;
@ -592,6 +600,9 @@ Delegate::StatusCode Catalog::status() const
bool Catalog::isEnabled() const
{
if (!m_userEnabled)
return false;
switch (m_status) {
case Delegate::STATUS_SUCCESS:
case Delegate::STATUS_REFRESHED:
@ -603,6 +614,36 @@ bool Catalog::isEnabled() const
return false;
}
}
bool Catalog::isUserEnabled() const
{
return m_userEnabled;
}
void Catalog::setUserEnabled(bool b)
{
if (m_userEnabled == b)
return;
m_userEnabled = b;
SGPath disableMarkerFile = installRoot() / "_disabled_";
if (m_userEnabled) {
sg_ofstream of(disableMarkerFile);
of << "1\n"; // touch the file
} else {
bool ok = disableMarkerFile.remove();
if (!ok) {
SG_LOG(SG_GENERAL, SG_ALERT, "Failed to remove catalog-disable marker file:" << disableMarkerFile);
}
}
Delegate::StatusCode effectiveStatus = m_status;
if ((m_status == Delegate::STATUS_SUCCESS) && !m_userEnabled) {
effectiveStatus = Delegate::USER_DISABLED;
}
m_root->catalogRefreshStatus(this, effectiveStatus);
}
void Catalog::processAlternate(SGPropertyNode_ptr alt)
{

View File

@ -150,6 +150,9 @@ public:
{
return addStatusCallback(boost::bind(mem_func, instance, _1));
}
bool isUserEnabled() const;
void setUserEnabled(bool b);
private:
Catalog(Root* aRoot);
@ -185,11 +188,12 @@ private:
SGPropertyNode_ptr m_props;
SGPath m_installRoot;
std::string m_url;
Delegate::StatusCode m_status;
Delegate::StatusCode m_status = Delegate::FAIL_UNKNOWN;
HTTP::Request_ptr m_refreshRequest;
bool m_userEnabled = true;
PackageList m_packages;
time_t m_retrievedTime;
time_t m_retrievedTime = 0;
typedef std::map<std::string, Package*> PackageWeakMap;
PackageWeakMap m_variantDict;

View File

@ -15,9 +15,7 @@
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
#ifdef HAVE_CONFIG_H
# include <simgear_config.h>
#endif
#include <simgear_config.h>
#include <simgear/misc/test_macros.hxx>
@ -98,6 +96,11 @@ public:
path = "/catalogTest1/movies-data.zip";
}
if (path == "/catalogTest1/b747.tar.gz") {
sendErrorResponse(403, false, "Bad URL");
return;
}
localPath.append(path);
// SG_LOG(SG_IO, SG_INFO, "local path is:" << localPath.str());
@ -158,7 +161,7 @@ int parseTest()
SG_CHECK_EQUAL(cat->description(), "First test catalog");
// check the packages too
SG_CHECK_EQUAL(cat->packages().size(), 5);
SG_CHECK_EQUAL(cat->packages().size(), 6);
pkg::PackageRef p1 = cat->packages().front();
SG_CHECK_EQUAL(p1->catalog(), cat.ptr());
@ -349,7 +352,7 @@ void testAddCatalog(HTTP::Client* cl)
p.append("org.flightgear.test.catalog1");
p.append("catalog.xml");
SG_VERIFY(p.exists());
SG_CHECK_EQUAL(root->allPackages().size(), 5);
SG_CHECK_EQUAL(root->allPackages().size(), 6);
SG_CHECK_EQUAL(root->catalogs().size(), 1);
pkg::PackageRef p1 = root->getPackageById("alpha");
@ -383,6 +386,7 @@ void testInstallPackage(HTTP::Client* cl)
waitForUpdateComplete(cl, root);
SG_VERIFY(p1->isInstalled());
SG_VERIFY(p1->existingInstall() == ins);
SG_CHECK_EQUAL(ins->status(), pkg::Delegate::STATUS_SUCCESS);
pkg::PackageRef commonDeps = root->getPackageById("common-sounds");
SG_VERIFY(commonDeps->existingInstall());
@ -428,6 +432,7 @@ void testUninstall(HTTP::Client* cl)
ins->uninstall();
SG_CHECK_EQUAL(ins->status(), pkg::Delegate::STATUS_SUCCESS);
SG_VERIFY(!ins->path().exists());
}
@ -1075,9 +1080,45 @@ void updateInvalidToInvalid(HTTP::Client* cl)
}
void testInstallBadPackage(HTTP::Client* cl)
{
global_catalogVersion = 0;
SGPath rootPath(simgear::Dir::current().path());
rootPath.append("pkg_install_bad_pkg");
simgear::Dir pd(rootPath);
pd.removeChildren();
pkg::RootRef root(new pkg::Root(rootPath, "8.1.2"));
// specify a test dir
root->setHTTPClient(cl);
pkg::CatalogRef c = pkg::Catalog::createFromUrl(root.ptr(), "http://localhost:2000/catalogTest1/catalog.xml");
waitForUpdateComplete(cl, root);
pkg::PackageRef p1 = root->getPackageById("org.flightgear.test.catalog1.b747-400");
pkg::InstallRef ins = p1->install();
bool didFail = false;
ins->fail([&didFail, &ins](pkg::Install* ourInstall) {
SG_CHECK_EQUAL(ins, ourInstall);
didFail = true;
});
SG_VERIFY(ins->isQueued());
waitForUpdateComplete(cl, root);
SG_VERIFY(!p1->isInstalled());
SG_VERIFY(didFail);
SG_VERIFY(p1->existingInstall() == ins);
SG_CHECK_EQUAL(ins->status(), pkg::Delegate::FAIL_DOWNLOAD);
SG_CHECK_EQUAL(ins->path(), rootPath / "org.flightgear.test.catalog1" / "Aircraft" / "b744");
}
int main(int argc, char* argv[])
{
sglog().setLogLevels( SG_ALL, SG_DEBUG );
// sglog().setLogLevels( SG_ALL, SG_DEBUG );
HTTP::Client cl;
cl.setMaxConnections(1);
@ -1116,6 +1157,8 @@ int main(int argc, char* argv[])
testVersionMigrateToId(&cl);
testInstallBadPackage(&cl);
SG_LOG(SG_GENERAL, SG_INFO, "Successfully passed all tests!");
return EXIT_SUCCESS;
}

View File

@ -56,7 +56,8 @@ public:
FAIL_HTTP_FORBIDDEN, ///< URL returned a 403. Marked specially to catch rate-limiting
FAIL_VALIDATION, ///< catalog or package failed to validate
STATUS_REFRESHED,
USER_CANCELLED
USER_CANCELLED,
USER_DISABLED
} StatusCode;

View File

@ -108,6 +108,8 @@ protected:
m_extractor.reset(new ArchiveExtractor(m_extractPath));
memset(&m_md5, 0, sizeof(SG_MD5_CTX));
SG_MD5Init(&m_md5);
m_owner->startDownload();
}
virtual void gotBodyData(const char* s, int n)
@ -395,6 +397,7 @@ Install* Install::progress(const ProgressCallback& cb)
//------------------------------------------------------------------------------
void Install::installResult(Delegate::StatusCode aReason)
{
m_status = aReason;
m_package->catalog()->root()->finishInstall(this, aReason);
if (aReason == Delegate::STATUS_SUCCESS) {
_cb_done(this);
@ -412,6 +415,15 @@ void Install::installProgress(unsigned int aBytes, unsigned int aTotal)
_cb_progress(this, aBytes, aTotal);
}
void Install::startDownload()
{
m_status = Delegate::STATUS_IN_PROGRESS;
}
Delegate::StatusCode Install::status() const
{
return m_status;
}
} // of namespace pkg

View File

@ -86,6 +86,8 @@ public:
size_t downloadedBytes() const;
Delegate::StatusCode status() const;
/**
* full path to the primary -set.xml file for this install
*/
@ -169,6 +171,7 @@ private:
void installResult(Delegate::StatusCode aReason);
void installProgress(unsigned int aBytes, unsigned int aTotal);
void startDownload();
PackageRef m_package;
unsigned int m_revision; ///< revision on disk

View File

@ -194,6 +194,32 @@
<url>http://localhost:2000/catalogTest1/b737.tar.gz</url>
</package>
<package>
<id>b747-400</id>
<name>Boeing 747-400</name>
<dir>b744</dir>
<description>A popular four-engined wide-body jet</description>
<revision type="int">111</revision>
<file-size-bytes type="int">860</file-size-bytes>
<tag>boeing</tag>
<tag>jet</tag>
<tag>ifr</tag>
<rating>
<FDM type="int">5</FDM>
<systems type="int">5</systems>
<model type="int">4</model>
<cockpit type="int">4</cockpit>
</rating>
<md5>a94ca5704f305b90767f40617d194ed6</md5>
<!-- this URL will fail, on purpose -->
<url>http://localhost:2000/catalogTest1/b747.tar.gz</url>
</package>
<package>
<id>common-sounds</id>
<name>Common sound files for test catalog aircraft</name>