diff --git a/interface/resources/fonts/hifi-glyphs.ttf b/interface/resources/fonts/hifi-glyphs.ttf index 8db0377f88..7f7393da18 100644 Binary files a/interface/resources/fonts/hifi-glyphs.ttf and b/interface/resources/fonts/hifi-glyphs.ttf differ diff --git a/interface/resources/qml/hifi/commerce/checkout/Checkout.qml b/interface/resources/qml/hifi/commerce/checkout/Checkout.qml index 5627106e53..730396af08 100644 --- a/interface/resources/qml/hifi/commerce/checkout/Checkout.qml +++ b/interface/resources/qml/hifi/commerce/checkout/Checkout.qml @@ -48,6 +48,7 @@ Rectangle { property bool debugCheckoutSuccess: false; property bool canRezCertifiedItems: Entities.canRezCertified() || Entities.canRezTmpCertified(); property string referrer; + property bool isInstalled; // Style color: hifi.colors.white; Connections { @@ -130,6 +131,12 @@ Rectangle { root.refreshBuyUI(); } } + + onAppInstalled: { + if (appHref === root.itemHref) { + root.isInstalled = true; + } + } } onItemIdChanged: { @@ -154,7 +161,8 @@ Rectangle { } onItemTypeChanged: { - if (root.itemType === "entity" || root.itemType === "wearable" || root.itemType === "contentSet" || root.itemType === "avatar") { + if (root.itemType === "entity" || root.itemType === "wearable" || + root.itemType === "contentSet" || root.itemType === "avatar" || root.itemType === "app") { root.isCertified = true; } else { root.isCertified = false; @@ -319,7 +327,7 @@ Rectangle { z: 997; visible: !root.ownershipStatusReceived || !root.balanceReceived; anchors.fill: parent; - color: Qt.rgba(0.0, 0.0, 0.0, 0.7); + color: hifi.colors.white; // This object is always used in a popup. // This MouseArea is used to prevent a user from being @@ -331,8 +339,9 @@ Rectangle { } AnimatedImage { - source: "../common/images/loader.gif" - width: 96; + id: loadingImage; + source: "../common/images/loader-blue.gif" + width: 74; height: width; anchors.verticalCenter: parent.verticalCenter; anchors.horizontalCenter: parent.horizontalCenter; @@ -687,7 +696,7 @@ Rectangle { id: rezNowButton; enabled: (root.itemType === "entity" && root.canRezCertifiedItems) || (root.itemType === "contentSet" && Entities.canReplaceContent()) || - root.itemType === "wearable" || root.itemType === "avatar"; + root.itemType === "wearable" || root.itemType === "avatar" || root.itemType === "app"; buttonGlyph: (root.buttonGlyph)[itemTypesArray.indexOf(root.itemType)]; color: hifi.buttons.red; colorScheme: hifi.colorSchemes.light; @@ -696,7 +705,7 @@ Rectangle { height: 50; anchors.left: parent.left; anchors.right: parent.right; - text: (root.buttonTextNormal)[itemTypesArray.indexOf(root.itemType)]; + text: root.itemType === "app" && root.isInstalled ? "OPEN APP" : (root.buttonTextNormal)[itemTypesArray.indexOf(root.itemType)]; onClicked: { if (root.itemType === "contentSet") { lightboxPopup.titleText = "Replace Content"; @@ -720,6 +729,12 @@ Rectangle { lightboxPopup.button2text = "CONFIRM"; lightboxPopup.button2method = "MyAvatar.useFullAvatarURL('" + root.itemHref + "'); root.visible = false;"; lightboxPopup.visible = true; + } else if (root.itemType === "app") { + if (root.isInstalled) { + Commerce.openApp(root.itemHref); + } else { + Commerce.installApp(root.itemHref); + } } else { sendToScript({method: 'checkout_rezClicked', itemHref: root.itemHref, itemType: root.itemType}); rezzedNotifContainer.visible = true; diff --git a/interface/resources/qml/hifi/commerce/common/images/loader-blue.gif b/interface/resources/qml/hifi/commerce/common/images/loader-blue.gif new file mode 100644 index 0000000000..8b9e17053b Binary files /dev/null and b/interface/resources/qml/hifi/commerce/common/images/loader-blue.gif differ diff --git a/interface/resources/qml/hifi/commerce/purchases/PurchasedItem.qml b/interface/resources/qml/hifi/commerce/purchases/PurchasedItem.qml index cc2bcd69aa..fb8e509cde 100644 --- a/interface/resources/qml/hifi/commerce/purchases/PurchasedItem.qml +++ b/interface/resources/qml/hifi/commerce/purchases/PurchasedItem.qml @@ -47,6 +47,7 @@ Item { property bool showConfirmation: false; property bool hasPermissionToRezThis; property bool permissionExplanationCardVisible; + property bool isInstalled; property string originalStatusText; property string originalStatusColor; @@ -62,6 +63,18 @@ Item { showConfirmation = true; } } + + onAppInstalled: { + if (appHref === root.itemHref) { + root.isInstalled = true; + } + } + + onAppUninstalled: { + if (appHref === root.itemHref) { + root.isInstalled = false; + } + } } Connections { @@ -472,6 +485,43 @@ Item { } } + Rectangle { + id: appButtonContainer; + color: hifi.colors.white; + z: 994; + visible: root.isInstalled; + anchors.fill: buttonContainer; + + HifiControlsUit.Button { + id: openAppButton; + color: hifi.buttons.blue; + colorScheme: hifi.colorSchemes.light; + anchors.top: parent.top; + anchors.right: parent.right; + anchors.left: parent.left; + width: 92; + height: 44; + text: "OPEN" + onClicked: { + Commerce.openApp(root.itemHref); + } + } + + HifiControlsUit.Button { + id: uninstallAppButton; + color: hifi.buttons.noneBorderless; + colorScheme: hifi.colorSchemes.light; + anchors.bottom: parent.bottom; + anchors.right: parent.right; + anchors.left: parent.left; + height: 44; + text: "UNINSTALL" + onClicked: { + Commerce.uninstallApp(root.itemHref); + } + } + } + Button { id: buttonContainer; property int color: hifi.buttons.blue; @@ -506,6 +556,9 @@ Item { sendToPurchases({method: 'showReplaceContentLightbox', itemHref: root.itemHref}); } else if (root.itemType === "avatar") { sendToPurchases({method: 'showChangeAvatarLightbox', itemName: root.itemName, itemHref: root.itemHref}); + } else if (root.itemType === "app") { + // "Run" and "Uninstall" buttons are separate. + Commerce.installApp(root.itemHref); } else { sendToPurchases({method: 'purchases_rezClicked', itemHref: root.itemHref, itemType: root.itemType}); root.showConfirmation = true; diff --git a/interface/resources/qml/hifi/commerce/purchases/Purchases.qml b/interface/resources/qml/hifi/commerce/purchases/Purchases.qml index 1fe2b8eae7..97cddc453a 100644 --- a/interface/resources/qml/hifi/commerce/purchases/Purchases.qml +++ b/interface/resources/qml/hifi/commerce/purchases/Purchases.qml @@ -36,6 +36,7 @@ Rectangle { property bool isShowingMyItems: false; property bool isDebuggingFirstUseTutorial: false; property int pendingItemCount: 0; + property string installedApps; // Style color: hifi.colors.white; Connections { @@ -69,6 +70,7 @@ Rectangle { root.activeView = "firstUseTutorial"; } else if (!Settings.getValue("isFirstUseOfPurchases", true) && root.activeView === "initialize") { root.activeView = "purchasesMain"; + root.installedApps = Commerce.getInstalledApps(); Commerce.inventory(); } } else { @@ -277,6 +279,7 @@ Rectangle { case 'tutorial_finished': Settings.setValue("isFirstUseOfPurchases", false); root.activeView = "purchasesMain"; + root.installedApps = Commerce.getInstalledApps(); Commerce.inventory(); break; } @@ -402,6 +405,7 @@ Rectangle { limitedRun: model.limited_run; displayedItemCount: model.displayedItemCount; permissionExplanationCardVisible: model.permissionExplanationCardVisible; + isInstalled: model.isInstalled; itemType: { if (model.root_file_url.indexOf(".fst") > -1) { "avatar"; @@ -688,9 +692,13 @@ Rectangle { if (sameItemCount !== tempPurchasesModel.count || filterBar.text !== filterBar.previousText) { filteredPurchasesModel.clear(); + var currentId; for (var i = 0; i < tempPurchasesModel.count; i++) { + currentId = tempPurchasesModel.get(i).id; + filteredPurchasesModel.append(tempPurchasesModel.get(i)); filteredPurchasesModel.setProperty(i, 'permissionExplanationCardVisible', false); + filteredPurchasesModel.setProperty(i, 'isInstalled', ((root.installedApps).indexOf(currentId) > -1)); } populateDisplayedItemCounts(); diff --git a/interface/src/commerce/QmlCommerce.cpp b/interface/src/commerce/QmlCommerce.cpp index 6d4497999b..557193c074 100644 --- a/interface/src/commerce/QmlCommerce.cpp +++ b/interface/src/commerce/QmlCommerce.cpp @@ -10,6 +10,7 @@ // #include "QmlCommerce.h" +#include "CommerceLogging.h" #include "Application.h" #include "DependencyManager.h" #include "Ledger.h" @@ -17,6 +18,9 @@ #include #include #include +#include +#include +#include "scripting/HMDScriptingInterface.h" QmlCommerce::QmlCommerce() { auto ledger = DependencyManager::get(); @@ -40,6 +44,8 @@ QmlCommerce::QmlCommerce() { connect(accountManager.data(), &AccountManager::usernameChanged, this, [&]() { setPassphrase(""); }); + + _appsPath = PathUtils::getAppDataPath() + "Apps/"; } void QmlCommerce::getWalletStatus() { @@ -193,3 +199,148 @@ void QmlCommerce::alreadyOwned(const QString& marketplaceId) { auto ledger = DependencyManager::get(); ledger->alreadyOwned(marketplaceId); } + +QString QmlCommerce::getInstalledApps() { + QString installedAppsFromMarketplace; + QStringList runningScripts = DependencyManager::get()->getRunningScripts(); + + QDir directory(_appsPath); + QStringList apps = directory.entryList(QStringList("*.app.json")); + foreach(QString appFileName, apps) { + installedAppsFromMarketplace += appFileName; + installedAppsFromMarketplace += ","; + QFile appFile(_appsPath + appFileName); + if (appFile.open(QIODevice::ReadOnly)) { + QJsonDocument appFileJsonDocument = QJsonDocument::fromJson(appFile.readAll()); + + appFile.close(); + + QJsonObject appFileJsonObject = appFileJsonDocument.object(); + QString scriptURL = appFileJsonObject["scriptURL"].toString(); + + // If the script .app.json is on the user's local disk but the associated script isn't running + // for some reason, start that script again. + if (!runningScripts.contains(scriptURL)) { + if ((DependencyManager::get()->loadScript(scriptURL.trimmed())).isNull()) { + qCDebug(commerce) << "Couldn't start script while checking installed apps."; + } + } + } else { + qCDebug(commerce) << "Couldn't open local .app.json file for reading."; + } + } + + return installedAppsFromMarketplace; +} + +bool QmlCommerce::installApp(const QString& itemHref) { + if (!QDir(_appsPath).exists()) { + if (!QDir().mkdir(_appsPath)) { + qCDebug(commerce) << "Couldn't make _appsPath directory."; + return false; + } + } + + QUrl appHref(itemHref); + + auto request = DependencyManager::get()->createResourceRequest(this, appHref); + + if (!request) { + qCDebug(commerce) << "Couldn't create resource request for app."; + return false; + } + + connect(request, &ResourceRequest::finished, this, [=]() { + if (request->getResult() != ResourceRequest::Success) { + qCDebug(commerce) << "Failed to get .app.json file from remote."; + return false; + } + + // Copy the .app.json to the apps directory inside %AppData%/High Fidelity/Interface + auto requestData = request->getData(); + QFile appFile(_appsPath + "/" + appHref.fileName()); + if (!appFile.open(QIODevice::WriteOnly)) { + qCDebug(commerce) << "Couldn't open local .app.json file for creation."; + return false; + } + if (appFile.write(requestData) == -1) { + qCDebug(commerce) << "Couldn't write to local .app.json file."; + return false; + } + // Close the file + appFile.close(); + + // Read from the returned datastream to know what .js to add to Running Scripts + QJsonDocument appFileJsonDocument = QJsonDocument::fromJson(requestData); + QJsonObject appFileJsonObject = appFileJsonDocument.object(); + QString scriptUrl = appFileJsonObject["scriptURL"].toString(); + + if ((DependencyManager::get()->loadScript(scriptUrl.trimmed())).isNull()) { + qCDebug(commerce) << "Couldn't load script."; + return false; + } + + emit appInstalled(itemHref); + return true; + }); + request->send(); + return true; +} + +bool QmlCommerce::uninstallApp(const QString& itemHref) { + QUrl appHref(itemHref); + + // Read from the file to know what .js script to stop + QFile appFile(_appsPath + "/" + appHref.fileName()); + if (!appFile.open(QIODevice::ReadOnly)) { + qCDebug(commerce) << "Couldn't open local .app.json file for deletion."; + return false; + } + QJsonDocument appFileJsonDocument = QJsonDocument::fromJson(appFile.readAll()); + QJsonObject appFileJsonObject = appFileJsonDocument.object(); + QString scriptUrl = appFileJsonObject["scriptURL"].toString(); + + if (!DependencyManager::get()->stopScript(scriptUrl.trimmed(), false)) { + qCDebug(commerce) << "Couldn't stop script."; + return false; + } + + // Delete the .app.json from the filesystem + // remove() closes the file first. + if (!appFile.remove()) { + qCDebug(commerce) << "Couldn't delete local .app.json file."; + return false; + } + + emit appUninstalled(itemHref); + return true; +} + +bool QmlCommerce::openApp(const QString& itemHref) { + QUrl appHref(itemHref); + + // Read from the file to know what .html or .qml document to open + QFile appFile(_appsPath + "/" + appHref.fileName()); + if (!appFile.open(QIODevice::ReadOnly)) { + qCDebug(commerce) << "Couldn't open local .app.json file."; + return false; + } + QJsonDocument appFileJsonDocument = QJsonDocument::fromJson(appFile.readAll()); + QJsonObject appFileJsonObject = appFileJsonDocument.object(); + QString homeUrl = appFileJsonObject["homeURL"].toString(); + + auto tabletScriptingInterface = DependencyManager::get(); + auto tablet = dynamic_cast(tabletScriptingInterface->getTablet("com.highfidelity.interface.tablet.system")); + if (homeUrl.contains(".qml", Qt::CaseInsensitive)) { + tablet->loadQMLSource(homeUrl); + } else if (homeUrl.contains(".html", Qt::CaseInsensitive)) { + tablet->gotoWebScreen(homeUrl); + } else { + qCDebug(commerce) << "Attempted to open unknown type of homeURL!"; + return false; + } + + DependencyManager::get()->openTablet(); + + return true; +} diff --git a/interface/src/commerce/QmlCommerce.h b/interface/src/commerce/QmlCommerce.h index a2f5820b20..6a4eaa2be2 100644 --- a/interface/src/commerce/QmlCommerce.h +++ b/interface/src/commerce/QmlCommerce.h @@ -51,6 +51,9 @@ signals: void contentSetChanged(const QString& contentSetHref); + void appInstalled(const QString& appHref); + void appUninstalled(const QString& appHref); + protected: Q_INVOKABLE void getWalletStatus(); @@ -78,8 +81,15 @@ protected: Q_INVOKABLE void transferHfcToNode(const QString& nodeID, const int& amount, const QString& optionalMessage); Q_INVOKABLE void transferHfcToUsername(const QString& username, const int& amount, const QString& optionalMessage); - Q_INVOKABLE void replaceContentSet(const QString& itemHref); + + Q_INVOKABLE QString getInstalledApps(); + Q_INVOKABLE bool installApp(const QString& appHref); + Q_INVOKABLE bool uninstallApp(const QString& appHref); + Q_INVOKABLE bool openApp(const QString& appHref); + +private: + QString _appsPath; }; #endif // hifi_QmlCommerce_h diff --git a/scripts/system/marketplaces/marketplaces.js b/scripts/system/marketplaces/marketplaces.js index 631b5e97ac..8f51d88f2d 100644 --- a/scripts/system/marketplaces/marketplaces.js +++ b/scripts/system/marketplaces/marketplaces.js @@ -551,11 +551,7 @@ var selectionDisplay = null; // for gridTool.js to ignore break; case 'checkout_rezClicked': case 'purchases_rezClicked': - if (message.itemType === "app") { - console.log("How did you get here? You can't buy apps yet!"); - } else { - rezEntity(message.itemHref, message.itemType); - } + rezEntity(message.itemHref, message.itemType); break; case 'header_marketplaceImageClicked': case 'purchases_backClicked':