/** * @file llaisapi.cpp * @brief classes and functions for interfacing with the v3+ ais inventory service. * * $LicenseInfo:firstyear=2013&license=viewerlgpl$ * Second Life Viewer Source Code * Copyright (C) 2013, Linden Research, Inc. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; * version 2.1 of the License only. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA * * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA * $/LicenseInfo$ * */ #include "llviewerprecompiledheaders.h" #include "llaisapi.h" #include "llagent.h" #include "llcallbacklist.h" #include "llinventorymodel.h" #include "llsdutil.h" #include "llviewerregion.h" ///---------------------------------------------------------------------------- /// Classes for AISv3 support. ///---------------------------------------------------------------------------- // AISCommand - base class for retry-able HTTP requests using the AISv3 cap. AISCommand::AISCommand(LLPointer callback): mCommandFunc(NULL), mCallback(callback) { mRetryPolicy = new LLAdaptiveRetryPolicy(1.0, 32.0, 2.0, 10); } bool AISCommand::run_command() { if (NULL == mCommandFunc) { // This may happen if a command failed to initiate itself. LL_WARNS("Inventory") << "AIS command attempted with null command function" << LL_ENDL; return false; } else { mCommandFunc(); return true; } } void AISCommand::setCommandFunc(command_func_type command_func) { mCommandFunc = command_func; } // virtual bool AISCommand::getResponseUUID(const LLSD& content, LLUUID& id) { return false; } /* virtual */ void AISCommand::httpSuccess() { // Command func holds a reference to self, need to release it // after a success or final failure. setCommandFunc(no_op); const LLSD& content = getContent(); if (!content.isMap()) { failureResult(HTTP_INTERNAL_ERROR, "Malformed response contents", content); return; } mRetryPolicy->onSuccess(); gInventory.onAISUpdateReceived("AISCommand", content); if (mCallback) { LLUUID id; // will default to null if parse fails. getResponseUUID(content,id); mCallback->fire(id); } } /*virtual*/ void AISCommand::httpFailure() { LL_WARNS("Inventory") << dumpResponse() << LL_ENDL; S32 status = getStatus(); const LLSD& headers = getResponseHeaders(); mRetryPolicy->onFailure(status, headers); F32 seconds_to_wait; if (mRetryPolicy->shouldRetry(seconds_to_wait)) { doAfterInterval(boost::bind(&AISCommand::run_command,this),seconds_to_wait); } else { // Command func holds a reference to self, need to release it // after a success or final failure. // *TODO: Notify user? This seems bad. setCommandFunc(no_op); } } //static bool AISCommand::isAPIAvailable() { if (gAgent.getRegion()) { return gAgent.getRegion()->isCapabilityAvailable("InventoryAPIv3"); } return false; } //static bool AISCommand::getInvCap(std::string& cap) { if (gAgent.getRegion()) { cap = gAgent.getRegion()->getCapability("InventoryAPIv3"); } if (!cap.empty()) { return true; } return false; } //static bool AISCommand::getLibCap(std::string& cap) { if (gAgent.getRegion()) { cap = gAgent.getRegion()->getCapability("LibraryAPIv3"); } if (!cap.empty()) { return true; } return false; } //static void AISCommand::getCapabilityNames(LLSD& capabilityNames) { capabilityNames.append("InventoryAPIv3"); capabilityNames.append("LibraryAPIv3"); } RemoveItemCommand::RemoveItemCommand(const LLUUID& item_id, LLPointer callback): AISCommand(callback) { std::string cap; if (!getInvCap(cap)) { llwarns << "No cap found" << llendl; return; } std::string url = cap + std::string("/item/") + item_id.asString(); LL_DEBUGS("Inventory") << "url: " << url << LL_ENDL; LLHTTPClient::ResponderPtr responder = this; LLSD headers; F32 timeout = HTTP_REQUEST_EXPIRY_SECS; command_func_type cmd = boost::bind(&LLHTTPClient::del, url, responder, headers, timeout); setCommandFunc(cmd); } RemoveCategoryCommand::RemoveCategoryCommand(const LLUUID& item_id, LLPointer callback): AISCommand(callback) { std::string cap; if (!getInvCap(cap)) { llwarns << "No cap found" << llendl; return; } std::string url = cap + std::string("/category/") + item_id.asString(); LL_DEBUGS("Inventory") << "url: " << url << LL_ENDL; LLHTTPClient::ResponderPtr responder = this; LLSD headers; F32 timeout = HTTP_REQUEST_EXPIRY_SECS; command_func_type cmd = boost::bind(&LLHTTPClient::del, url, responder, headers, timeout); setCommandFunc(cmd); } PurgeDescendentsCommand::PurgeDescendentsCommand(const LLUUID& item_id, LLPointer callback): AISCommand(callback) { std::string cap; if (!getInvCap(cap)) { llwarns << "No cap found" << llendl; return; } std::string url = cap + std::string("/category/") + item_id.asString() + "/children"; LL_DEBUGS("Inventory") << "url: " << url << LL_ENDL; LLCurl::ResponderPtr responder = this; LLSD headers; F32 timeout = HTTP_REQUEST_EXPIRY_SECS; command_func_type cmd = boost::bind(&LLHTTPClient::del, url, responder, headers, timeout); setCommandFunc(cmd); } UpdateItemCommand::UpdateItemCommand(const LLUUID& item_id, const LLSD& updates, LLPointer callback): mUpdates(updates), AISCommand(callback) { std::string cap; if (!getInvCap(cap)) { llwarns << "No cap found" << llendl; return; } std::string url = cap + std::string("/item/") + item_id.asString(); LL_DEBUGS("Inventory") << "url: " << url << LL_ENDL; LL_DEBUGS("Inventory") << "request: " << ll_pretty_print_sd(mUpdates) << LL_ENDL; LLCurl::ResponderPtr responder = this; LLSD headers; headers["Content-Type"] = "application/llsd+xml"; F32 timeout = HTTP_REQUEST_EXPIRY_SECS; command_func_type cmd = boost::bind(&LLHTTPClient::patch, url, mUpdates, responder, headers, timeout); setCommandFunc(cmd); } UpdateCategoryCommand::UpdateCategoryCommand(const LLUUID& cat_id, const LLSD& updates, LLPointer callback): mUpdates(updates), AISCommand(callback) { std::string cap; if (!getInvCap(cap)) { llwarns << "No cap found" << llendl; return; } std::string url = cap + std::string("/category/") + cat_id.asString(); LL_DEBUGS("Inventory") << "url: " << url << LL_ENDL; LLCurl::ResponderPtr responder = this; LLSD headers; headers["Content-Type"] = "application/llsd+xml"; F32 timeout = HTTP_REQUEST_EXPIRY_SECS; command_func_type cmd = boost::bind(&LLHTTPClient::patch, url, mUpdates, responder, headers, timeout); setCommandFunc(cmd); } CreateInventoryCommand::CreateInventoryCommand(const LLUUID& parent_id, const LLSD& new_inventory, LLPointer callback): mNewInventory(new_inventory), AISCommand(callback) { std::string cap; if (!getInvCap(cap)) { llwarns << "No cap found" << llendl; return; } LLUUID tid; tid.generate(); std::string url = cap + std::string("/category/") + parent_id.asString() + "?tid=" + tid.asString(); LL_DEBUGS("Inventory") << "url: " << url << LL_ENDL; LLCurl::ResponderPtr responder = this; LLSD headers; headers["Content-Type"] = "application/llsd+xml"; F32 timeout = HTTP_REQUEST_EXPIRY_SECS; command_func_type cmd = boost::bind(&LLHTTPClient::post, url, mNewInventory, responder, headers, timeout); setCommandFunc(cmd); } SlamFolderCommand::SlamFolderCommand(const LLUUID& folder_id, const LLSD& contents, LLPointer callback): mContents(contents), AISCommand(callback) { std::string cap; if (!getInvCap(cap)) { llwarns << "No cap found" << llendl; return; } LLUUID tid; tid.generate(); std::string url = cap + std::string("/category/") + folder_id.asString() + "/links?tid=" + tid.asString(); llinfos << url << llendl; LLCurl::ResponderPtr responder = this; LLSD headers; headers["Content-Type"] = "application/llsd+xml"; F32 timeout = HTTP_REQUEST_EXPIRY_SECS; command_func_type cmd = boost::bind(&LLHTTPClient::put, url, mContents, responder, headers, timeout); setCommandFunc(cmd); } CopyLibraryCategoryCommand::CopyLibraryCategoryCommand(const LLUUID& source_id, const LLUUID& dest_id, LLPointer callback): AISCommand(callback) { std::string cap; if (!getLibCap(cap)) { llwarns << "No cap found" << llendl; return; } LL_DEBUGS("Inventory") << "Copying library category: " << source_id << " => " << dest_id << LL_ENDL; LLUUID tid; tid.generate(); std::string url = cap + std::string("/category/") + source_id.asString() + "?tid=" + tid.asString(); llinfos << url << llendl; LLCurl::ResponderPtr responder = this; LLSD headers; F32 timeout = HTTP_REQUEST_EXPIRY_SECS; command_func_type cmd = boost::bind(&LLHTTPClient::copy, url, dest_id.asString(), responder, headers, timeout); setCommandFunc(cmd); } bool CopyLibraryCategoryCommand::getResponseUUID(const LLSD& content, LLUUID& id) { if (content.has("category_id")) { id = content["category_id"]; return true; } return false; } AISUpdate::AISUpdate(const LLSD& update) { parseUpdate(update); } void AISUpdate::clearParseResults() { mCatDescendentDeltas.clear(); mCatDescendentsKnown.clear(); mCatVersionsUpdated.clear(); mItemsCreated.clear(); mItemsUpdated.clear(); mCategoriesCreated.clear(); mCategoriesUpdated.clear(); mObjectsDeletedIds.clear(); mItemIds.clear(); mCategoryIds.clear(); } void AISUpdate::parseUpdate(const LLSD& update) { clearParseResults(); parseMeta(update); parseContent(update); } void AISUpdate::parseMeta(const LLSD& update) { // parse _categories_removed -> mObjectsDeletedIds uuid_list_t cat_ids; parseUUIDArray(update,"_categories_removed",cat_ids); for (uuid_list_t::const_iterator it = cat_ids.begin(); it != cat_ids.end(); ++it) { LLViewerInventoryCategory *cat = gInventory.getCategory(*it); mCatDescendentDeltas[cat->getParentUUID()]--; mObjectsDeletedIds.insert(*it); } // parse _categories_items_removed -> mObjectsDeletedIds uuid_list_t item_ids; parseUUIDArray(update,"_category_items_removed",item_ids); parseUUIDArray(update,"_removed_items",item_ids); for (uuid_list_t::const_iterator it = item_ids.begin(); it != item_ids.end(); ++it) { LLViewerInventoryItem *item = gInventory.getItem(*it); mCatDescendentDeltas[item->getParentUUID()]--; mObjectsDeletedIds.insert(*it); } // parse _broken_links_removed -> mObjectsDeletedIds uuid_list_t broken_link_ids; parseUUIDArray(update,"_broken_links_removed",broken_link_ids); for (uuid_list_t::const_iterator it = broken_link_ids.begin(); it != broken_link_ids.end(); ++it) { LLViewerInventoryItem *item = gInventory.getItem(*it); mCatDescendentDeltas[item->getParentUUID()]--; mObjectsDeletedIds.insert(*it); } // parse _created_items parseUUIDArray(update,"_created_items",mItemIds); // parse _created_categories parseUUIDArray(update,"_created_categories",mCategoryIds); // Parse updated category versions. const std::string& ucv = "_updated_category_versions"; if (update.has(ucv)) { for(LLSD::map_const_iterator it = update[ucv].beginMap(), end = update[ucv].endMap(); it != end; ++it) { const LLUUID id((*it).first); S32 version = (*it).second.asInteger(); mCatVersionsUpdated[id] = version; } } } void AISUpdate::parseContent(const LLSD& update) { if (update.has("linked_id")) { parseLink(update); } else if (update.has("item_id")) { parseItem(update); } if (update.has("category_id")) { parseCategory(update); } else { if (update.has("_embedded")) { parseEmbedded(update["_embedded"]); } } } void AISUpdate::parseItem(const LLSD& item_map) { LLUUID item_id = item_map["item_id"].asUUID(); LLPointer new_item(new LLViewerInventoryItem); LLViewerInventoryItem *curr_item = gInventory.getItem(item_id); if (curr_item) { // Default to current values where not provided. new_item->copyViewerItem(curr_item); } BOOL rv = new_item->unpackMessage(item_map); if (rv) { if (curr_item) { mItemsUpdated[item_id] = new_item; // This statement is here to cause a new entry with 0 // delta to be created if it does not already exist; // otherwise has no effect. mCatDescendentDeltas[new_item->getParentUUID()]; } else { mItemsCreated[item_id] = new_item; mCatDescendentDeltas[new_item->getParentUUID()]++; } } else { // *TODO: Wow, harsh. Should we just complain and get out? llerrs << "unpack failed" << llendl; } } void AISUpdate::parseLink(const LLSD& link_map) { LLUUID item_id = link_map["item_id"].asUUID(); LLPointer new_link(new LLViewerInventoryItem); LLViewerInventoryItem *curr_link = gInventory.getItem(item_id); if (curr_link) { // Default to current values where not provided. new_link->copyViewerItem(curr_link); } BOOL rv = new_link->unpackMessage(link_map); if (rv) { const LLUUID& parent_id = new_link->getParentUUID(); if (curr_link) { mItemsUpdated[item_id] = new_link; // This statement is here to cause a new entry with 0 // delta to be created if it does not already exist; // otherwise has no effect. mCatDescendentDeltas[parent_id]; } else { LLPermissions default_perms; default_perms.init(gAgent.getID(),gAgent.getID(),LLUUID::null,LLUUID::null); default_perms.initMasks(PERM_NONE,PERM_NONE,PERM_NONE,PERM_NONE,PERM_NONE); new_link->setPermissions(default_perms); LLSaleInfo default_sale_info; new_link->setSaleInfo(default_sale_info); //LL_DEBUGS("Inventory") << "creating link from llsd: " << ll_pretty_print_sd(link_map) << LL_ENDL; mItemsCreated[item_id] = new_link; mCatDescendentDeltas[parent_id]++; } } else { // *TODO: Wow, harsh. Should we just complain and get out? llerrs << "unpack failed" << llendl; } } void AISUpdate::parseCategory(const LLSD& category_map) { LLUUID category_id = category_map["category_id"].asUUID(); // Check descendent count first, as it may be needed // to populate newly created categories if (category_map.has("_embedded")) { parseDescendentCount(category_id, category_map["_embedded"]); } LLPointer new_cat(new LLViewerInventoryCategory(category_id)); LLViewerInventoryCategory *curr_cat = gInventory.getCategory(category_id); if (curr_cat) { // Default to current values where not provided. new_cat->copyViewerCategory(curr_cat); } BOOL rv = new_cat->unpackMessage(category_map); // *NOTE: unpackMessage does not unpack version or descendent count. //if (category_map.has("version")) //{ // mCatVersionsUpdated[category_id] = category_map["version"].asInteger(); //} if (rv) { if (curr_cat) { mCategoriesUpdated[category_id] = new_cat; // This statement is here to cause a new entry with 0 // delta to be created if it does not already exist; // otherwise has no effect. mCatDescendentDeltas[new_cat->getParentUUID()]; // Capture update for the category itself as well. mCatDescendentDeltas[category_id]; } else { // Set version/descendents for newly created categories. if (category_map.has("version")) { S32 version = category_map["version"].asInteger(); LL_DEBUGS("Inventory") << "Setting version to " << version << " for new category " << category_id << LL_ENDL; new_cat->setVersion(version); } uuid_int_map_t::const_iterator lookup_it = mCatDescendentsKnown.find(category_id); if (mCatDescendentsKnown.end() != lookup_it) { S32 descendent_count = lookup_it->second; LL_DEBUGS("Inventory") << "Setting descendents count to " << descendent_count << " for new category " << category_id << LL_ENDL; new_cat->setDescendentCount(descendent_count); } mCategoriesCreated[category_id] = new_cat; mCatDescendentDeltas[new_cat->getParentUUID()]++; } } else { // *TODO: Wow, harsh. Should we just complain and get out? llerrs << "unpack failed" << llendl; } // Check for more embedded content. if (category_map.has("_embedded")) { parseEmbedded(category_map["_embedded"]); } } void AISUpdate::parseDescendentCount(const LLUUID& category_id, const LLSD& embedded) { // We can only determine true descendent count if this contains all descendent types. if (embedded.has("categories") && embedded.has("links") && embedded.has("items")) { mCatDescendentsKnown[category_id] = embedded["categories"].size(); mCatDescendentsKnown[category_id] += embedded["links"].size(); mCatDescendentsKnown[category_id] += embedded["items"].size(); } } void AISUpdate::parseEmbedded(const LLSD& embedded) { if (embedded.has("links")) // _embedded in a category { parseEmbeddedLinks(embedded["links"]); } if (embedded.has("items")) // _embedded in a category { parseEmbeddedItems(embedded["items"]); } if (embedded.has("item")) // _embedded in a link { parseEmbeddedItem(embedded["item"]); } if (embedded.has("categories")) // _embedded in a category { parseEmbeddedCategories(embedded["categories"]); } if (embedded.has("category")) // _embedded in a link { parseEmbeddedCategory(embedded["category"]); } } void AISUpdate::parseUUIDArray(const LLSD& content, const std::string& name, uuid_list_t& ids) { if (content.has(name)) { for(LLSD::array_const_iterator it = content[name].beginArray(), end = content[name].endArray(); it != end; ++it) { ids.insert((*it).asUUID()); } } } void AISUpdate::parseEmbeddedLinks(const LLSD& links) { for(LLSD::map_const_iterator linkit = links.beginMap(), linkend = links.endMap(); linkit != linkend; ++linkit) { const LLUUID link_id((*linkit).first); const LLSD& link_map = (*linkit).second; if (mItemIds.end() == mItemIds.find(link_id)) { LL_DEBUGS("Inventory") << "Ignoring link not in items list " << link_id << LL_ENDL; } else { parseLink(link_map); } } } void AISUpdate::parseEmbeddedItem(const LLSD& item) { // a single item (_embedded in a link) if (item.has("item_id")) { if (mItemIds.end() != mItemIds.find(item["item_id"].asUUID())) { parseItem(item); } } } void AISUpdate::parseEmbeddedItems(const LLSD& items) { // a map of items (_embedded in a category) for(LLSD::map_const_iterator itemit = items.beginMap(), itemend = items.endMap(); itemit != itemend; ++itemit) { const LLUUID item_id((*itemit).first); const LLSD& item_map = (*itemit).second; if (mItemIds.end() == mItemIds.find(item_id)) { LL_DEBUGS("Inventory") << "Ignoring item not in items list " << item_id << LL_ENDL; } else { parseItem(item_map); } } } void AISUpdate::parseEmbeddedCategory(const LLSD& category) { // a single category (_embedded in a link) if (category.has("category_id")) { if (mCategoryIds.end() != mCategoryIds.find(category["category_id"].asUUID())) { parseCategory(category); } } } void AISUpdate::parseEmbeddedCategories(const LLSD& categories) { // a map of categories (_embedded in a category) for(LLSD::map_const_iterator categoryit = categories.beginMap(), categoryend = categories.endMap(); categoryit != categoryend; ++categoryit) { const LLUUID category_id((*categoryit).first); const LLSD& category_map = (*categoryit).second; if (mCategoryIds.end() == mCategoryIds.find(category_id)) { LL_DEBUGS("Inventory") << "Ignoring category not in categories list " << category_id << LL_ENDL; } else { parseCategory(category_map); } } } void AISUpdate::doUpdate() { // Do version/descendent accounting. for (std::map::const_iterator catit = mCatDescendentDeltas.begin(); catit != mCatDescendentDeltas.end(); ++catit) { LL_DEBUGS("Inventory") << "descendent accounting for " << catit->first << llendl; const LLUUID cat_id(catit->first); // Don't account for update if we just created this category. if (mCategoriesCreated.find(cat_id) != mCategoriesCreated.end()) { LL_DEBUGS("Inventory") << "Skipping version increment for new category " << cat_id << LL_ENDL; continue; } // Don't account for update unless AIS told us it updated that category. if (mCatVersionsUpdated.find(cat_id) == mCatVersionsUpdated.end()) { LL_DEBUGS("Inventory") << "Skipping version increment for non-updated category " << cat_id << LL_ENDL; continue; } // If we have a known descendent count, set that now. LLViewerInventoryCategory* cat = gInventory.getCategory(cat_id); if (cat) { S32 descendent_delta = catit->second; S32 old_count = cat->getDescendentCount(); LL_DEBUGS("Inventory") << "Updating descendent count for " << cat->getName() << " " << cat_id << " with delta " << descendent_delta << " from " << old_count << " to " << (old_count+descendent_delta) << LL_ENDL; LLInventoryModel::LLCategoryUpdate up(cat_id, descendent_delta); gInventory.accountForUpdate(up); } else { LL_DEBUGS("Inventory") << "Skipping version accounting for unknown category " << cat_id << LL_ENDL; } } // CREATE CATEGORIES for (deferred_category_map_t::const_iterator create_it = mCategoriesCreated.begin(); create_it != mCategoriesCreated.end(); ++create_it) { LLUUID category_id(create_it->first); LLPointer new_category = create_it->second; gInventory.updateCategory(new_category); LL_DEBUGS("Inventory") << "created category " << category_id << LL_ENDL; } // UPDATE CATEGORIES for (deferred_category_map_t::const_iterator update_it = mCategoriesUpdated.begin(); update_it != mCategoriesUpdated.end(); ++update_it) { LLUUID category_id(update_it->first); LLPointer new_category = update_it->second; // Since this is a copy of the category *before* the accounting update, above, // we need to transfer back the updated version/descendent count. LLViewerInventoryCategory* curr_cat = gInventory.getCategory(new_category->getUUID()); if (NULL == curr_cat) { LL_WARNS("Inventory") << "Failed to update unknown category " << new_category->getUUID() << LL_ENDL; } else { new_category->setVersion(curr_cat->getVersion()); new_category->setDescendentCount(curr_cat->getDescendentCount()); gInventory.updateCategory(new_category); LL_DEBUGS("Inventory") << "updated category " << new_category->getName() << " " << category_id << LL_ENDL; } } // CREATE ITEMS for (deferred_item_map_t::const_iterator create_it = mItemsCreated.begin(); create_it != mItemsCreated.end(); ++create_it) { LLUUID item_id(create_it->first); LLPointer new_item = create_it->second; // FIXME risky function since it calls updateServer() in some // cases. Maybe break out the update/create cases, in which // case this is create. LL_DEBUGS("Inventory") << "created item " << item_id << LL_ENDL; gInventory.updateItem(new_item); } // UPDATE ITEMS for (deferred_item_map_t::const_iterator update_it = mItemsUpdated.begin(); update_it != mItemsUpdated.end(); ++update_it) { LLUUID item_id(update_it->first); LLPointer new_item = update_it->second; // FIXME risky function since it calls updateServer() in some // cases. Maybe break out the update/create cases, in which // case this is update. LL_DEBUGS("Inventory") << "updated item " << item_id << LL_ENDL; //LL_DEBUGS("Inventory") << ll_pretty_print_sd(new_item->asLLSD()) << LL_ENDL; gInventory.updateItem(new_item); } // DELETE OBJECTS for (uuid_list_t::const_iterator del_it = mObjectsDeletedIds.begin(); del_it != mObjectsDeletedIds.end(); ++del_it) { LL_DEBUGS("Inventory") << "deleted item " << *del_it << LL_ENDL; gInventory.onObjectDeletedFromServer(*del_it, false, false, false); } // TODO - how can we use this version info? Need to be sure all // changes are going through AIS first, or at least through // something with a reliable responder. for (uuid_int_map_t::iterator ucv_it = mCatVersionsUpdated.begin(); ucv_it != mCatVersionsUpdated.end(); ++ucv_it) { const LLUUID id = ucv_it->first; S32 version = ucv_it->second; LLViewerInventoryCategory *cat = gInventory.getCategory(id); LL_DEBUGS("Inventory") << "cat version update " << cat->getName() << " to version " << cat->getVersion() << llendl; if (cat->getVersion() != version) { llwarns << "Possible version mismatch for category " << cat->getName() << ", viewer version " << cat->getVersion() << " server version " << version << llendl; } } gInventory.notifyObservers(); }