Showing the changes to interface/src/scripting/ScreenshareScriptingInterface.cpp (1 of 2 files). Show all
Unified · Split
| @@ -9,37 +9,27 @@ · 8 unmodified lines | |||
| 9 | // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html | 9 | // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html |
| 10 | // | 10 | // |
| 11 | 11 | ||
| 12 | #include <QCoreApplication> | ||
| 13 | #include <QDesktopServices> | ||
| 14 | #include <QJsonDocument> | 12 | #include <QJsonDocument> |
| 15 | #include <QJsonObject> | 13 | #include <QJsonObject> |
| 14 | #include <QNetworkAccessManager> | ||
| 16 | #include <QThread> | 15 | #include <QThread> |
| 17 | #include <QUrl> | 16 | #include <QUrl> |
| 18 | #include <QObject> | ||
| 19 | #include <QNetworkAccessManager> | ||
| 20 | #include <QNetworkReply> | ||
| 21 | #include <QNetworkRequest> | ||
| 22 | #include <QProcessEnvironment> | ||
| 23 | 17 | ||
| 24 | #include <AccountManager.h> | 18 | #include <AccountManager.h> |
| 25 | #include <AddressManager.h> | 19 | #include <AddressManager.h> |
| 26 | #include <EntityTreeRenderer.h> | 20 | #include <DependencyManager.h> |
| 27 | #include <EntityTree.h> | ||
| 28 | #include <UUID.h> | 21 | #include <UUID.h> |
| 29 | 22 | ||
| 30 | #include "EntityScriptingInterface.h" | 23 | #include "EntityScriptingInterface.h" |
| 31 | #include "ScreenshareScriptingInterface.h" | 24 | #include "ScreenshareScriptingInterface.h" |
| 32 | 25 | ||
| 33 | #include <RenderableEntityItem.h> | ||
| 34 | #include <RenderableTextEntityItem.h> | ||
| 35 | #include <RenderableWebEntityItem.h> | ||
| 36 | |||
| 37 | ScreenshareScriptingInterface::ScreenshareScriptingInterface() { | 26 | ScreenshareScriptingInterface::ScreenshareScriptingInterface() { |
| 38 | auto esi = DependencyManager::get<EntityScriptingInterface>(); | 27 | auto esi = DependencyManager::get<EntityScriptingInterface>(); |
| 39 | if (!esi) { | 28 | if (!esi) { |
| 40 | return; | 29 | return; |
| 41 | } | 30 | } |
| 42 | 31 | ||
| 32 | // This signal/slot connection is used when the screen share local web entity sends an event bridge message. | ||
| 43 | QObject::connect(esi.data(), &EntityScriptingInterface::webEventReceived, this, &ScreenshareScriptingInterface::onWebEventReceived); | 33 | QObject::connect(esi.data(), &EntityScriptingInterface::webEventReceived, this, &ScreenshareScriptingInterface::onWebEventReceived); |
| 44 | }; | 34 | }; |
| 45 | 35 | ||
| @@ -55,27 +45,35 @@ static const glm::vec3 LOCAL_SCREENSHARE_WEB_ENTITY_LOCAL_POSITION(0.0f, -0.0862 · 9 unmodified lines | |||
| 55 | static const glm::vec3 LOCAL_SCREENSHARE_WEB_ENTITY_DIMENSIONS(4.0419f, 2.2735f, 0.0100f); | 45 | static const glm::vec3 LOCAL_SCREENSHARE_WEB_ENTITY_DIMENSIONS(4.0419f, 2.2735f, 0.0100f); |
| 56 | static const QString LOCAL_SCREENSHARE_WEB_ENTITY_URL = | 46 | static const QString LOCAL_SCREENSHARE_WEB_ENTITY_URL = |
| 57 | "https://hifi-content.s3.amazonaws.com/Experiences/Releases/usefulUtilities/smartBoard/screenshareViewer/screenshareClient.html"; | 47 | "https://hifi-content.s3.amazonaws.com/Experiences/Releases/usefulUtilities/smartBoard/screenshareViewer/screenshareClient.html"; |
| 48 | static const QString LOCAL_SCREENSHARE_WEB_ENTITY_HOST_TYPE ="local"; | ||
| 58 | void ScreenshareScriptingInterface::startScreenshare(const QUuid& screenshareZoneID, | 49 | void ScreenshareScriptingInterface::startScreenshare(const QUuid& screenshareZoneID, |
| 59 | const QUuid& smartboardEntityID, | 50 | const QUuid& smartboardEntityID, |
| 60 | const bool& isPresenter) { | 51 | const bool& isPresenter) { |
| 52 | // We must start a new QProcess from the main thread. | ||
| 61 | if (QThread::currentThread() != thread()) { | 53 | if (QThread::currentThread() != thread()) { |
| 62 | // We must start a new QProcess from the main thread. | ||
| 63 | QMetaObject::invokeMethod(this, "startScreenshare", Q_ARG(const QUuid&, screenshareZoneID), | 54 | QMetaObject::invokeMethod(this, "startScreenshare", Q_ARG(const QUuid&, screenshareZoneID), |
| 64 | Q_ARG(const QUuid&, smartboardEntityID), Q_ARG(const bool&, isPresenter)); | 55 | Q_ARG(const QUuid&, smartboardEntityID), Q_ARG(const bool&, isPresenter)); |
| 65 | return; | 56 | return; |
| 66 | } | 57 | } |
| 67 | 58 | ||
| 59 | // These three private member variables are set now so that they may be used later during asynchronous | ||
| 60 | // callbacks. | ||
| 68 | _screenshareZoneID = screenshareZoneID; | 61 | _screenshareZoneID = screenshareZoneID; |
| 69 | _smartboardEntityID = smartboardEntityID; | 62 | _smartboardEntityID = smartboardEntityID; |
| 70 | _isPresenter = isPresenter; | 63 | _isPresenter = isPresenter; |
| 71 | 64 | ||
| 65 | // If we are presenting, and the screenshare process is already running, don't do anything else here. | ||
| 72 | if (_isPresenter && _screenshareProcess && _screenshareProcess->state() != QProcess::NotRunning) { | 66 | if (_isPresenter && _screenshareProcess && _screenshareProcess->state() != QProcess::NotRunning) { |
| 73 | return; | 67 | return; |
| 74 | } | 68 | } |
| 75 | 69 | ||
| 70 | // If we're presenting... | ||
| 76 | if (_isPresenter) { | 71 | if (_isPresenter) { |
| 72 | // ...make sure we first reset this `std::unique_ptr`. | ||
| 77 | _screenshareProcess.reset(new QProcess(this)); | 73 | _screenshareProcess.reset(new QProcess(this)); |
| 78 | 74 | ||
| 75 | // Ensure that the screenshare executable exists where we expect it to. | ||
| 76 | // Error out and reset the screen share state machine if the executable doesn't exist. | ||
| 79 | QFileInfo screenshareExecutable(SCREENSHARE_EXE_PATH); | 77 | QFileInfo screenshareExecutable(SCREENSHARE_EXE_PATH); |
| 80 | if (!screenshareExecutable.exists() || !screenshareExecutable.isFile()) { | 78 | if (!screenshareExecutable.exists() || !screenshareExecutable.isFile()) { |
| 81 | qDebug() << "Screenshare executable doesn't exist at" << SCREENSHARE_EXE_PATH; | 79 | qDebug() << "Screenshare executable doesn't exist at" << SCREENSHARE_EXE_PATH; |
| @@ -85,6 +83,7 @@ void ScreenshareScriptingInterface::startScreenshare(const QUuid& screenshareZon · 3 unmodified lines | |||
| 85 | } | 83 | } |
| 86 | } | 84 | } |
| 87 | 85 | ||
| 86 | // Don't continue with any more of this logic if we can't get the `AccountManager` or `AddressManager`. | ||
| 88 | auto accountManager = DependencyManager::get<AccountManager>(); | 87 | auto accountManager = DependencyManager::get<AccountManager>(); |
| 89 | if (!accountManager) { | 88 | if (!accountManager) { |
| 90 | return; | 89 | return; |
| @@ -94,9 +93,17 @@ void ScreenshareScriptingInterface::startScreenshare(const QUuid& screenshareZon · 3 unmodified lines | |||
| 94 | return; | 93 | return; |
| 95 | } | 94 | } |
| 96 | 95 | ||
| 96 | // Construct and send a request to the Metaverse to obtain the information | ||
| 97 | // necessary to start the screen sharing process. | ||
| 98 | // This request requires: | ||
| 99 | // 1. The domain ID of the domain in which the user's avatar is present | ||
| 100 | // 2. User authentication information that is automatically included when `sendRequest()` is passed | ||
| 101 | // with the `AccountManagerAuth::Required` argument. | ||
| 102 | // Note that this request will only return successfully if the Domain Server has already registered | ||
| 103 | // the user paired with the current domain with the Metaverse. | ||
| 104 | // See `DomainServer::screensharePresence()` for more info about that. | ||
| 97 | QString currentDomainID = uuidStringWithoutCurlyBraces(addressManager->getDomainID()); | 105 | QString currentDomainID = uuidStringWithoutCurlyBraces(addressManager->getDomainID()); |
| 98 | QString requestURLPath = "api/v1/domains/%1/screenshare"; | 106 | QString requestURLPath = "api/v1/domains/%1/screenshare"; |
| 99 | |||
| 100 | JSONCallbackParameters callbackParams; | 107 | JSONCallbackParameters callbackParams; |
| 101 | callbackParams.callbackReceiver = this; | 108 | callbackParams.callbackReceiver = this; |
| 102 | callbackParams.jsonCallbackMethod = "handleSuccessfulScreenshareInfoGet"; | 109 | callbackParams.jsonCallbackMethod = "handleSuccessfulScreenshareInfoGet"; |
| @@ -110,22 +117,29 @@ void ScreenshareScriptingInterface::startScreenshare(const QUuid& screenshareZon · 7 unmodified lines | |||
| 110 | } | 117 | } |
| 111 | 118 | ||
| 112 | void ScreenshareScriptingInterface::stopScreenshare() { | 119 | void ScreenshareScriptingInterface::stopScreenshare() { |
| 120 | // We can only deal with our Screen Share `QProcess` on the main thread. | ||
| 113 | if (QThread::currentThread() != thread()) { | 121 | if (QThread::currentThread() != thread()) { |
| 114 | QMetaObject::invokeMethod(this, "stopScreenshare"); | 122 | QMetaObject::invokeMethod(this, "stopScreenshare"); |
| 115 | return; | 123 | return; |
| 116 | } | 124 | } |
| 117 | 125 | ||
| 126 | // If the Screen Share process is running... | ||
| 118 | if (_screenshareProcess && _screenshareProcess->state() != QProcess::NotRunning) { | 127 | if (_screenshareProcess && _screenshareProcess->state() != QProcess::NotRunning) { |
| 128 | //...terminate it and make sure that scripts know we terminated it by emitting | ||
| 129 | // `screenshareProcessTerminated()`. | ||
| 119 | _screenshareProcess->terminate(); | 130 | _screenshareProcess->terminate(); |
| 120 | emit screenshareProcessTerminated(); | 131 | emit screenshareProcessTerminated(); |
| 121 | } | 132 | } |
| 122 | 133 | ||
| 134 | // Delete the local web entity if we know about it here. | ||
| 123 | if (!_screenshareViewerLocalWebEntityUUID.isNull()) { | 135 | if (!_screenshareViewerLocalWebEntityUUID.isNull()) { |
| 124 | auto esi = DependencyManager::get<EntityScriptingInterface>(); | 136 | auto esi = DependencyManager::get<EntityScriptingInterface>(); |
| 125 | if (esi) { | 137 | if (esi) { |
| 126 | esi->deleteEntity(_screenshareViewerLocalWebEntityUUID); | 138 | esi->deleteEntity(_screenshareViewerLocalWebEntityUUID); |
| 127 | } | 139 | } |
| 128 | } | 140 | } |
| 141 | |||
| 142 | // Reset all private member variables related to screen share here. | ||
| 129 | _screenshareViewerLocalWebEntityUUID = "{00000000-0000-0000-0000-000000000000}"; | 143 | _screenshareViewerLocalWebEntityUUID = "{00000000-0000-0000-0000-000000000000}"; |
| 130 | _token = ""; | 144 | _token = ""; |
| 131 | _projectAPIKey = ""; | 145 | _projectAPIKey = ""; |
| @@ -133,12 +147,14 @@ void ScreenshareScriptingInterface::stopScreenshare() { · 1 unmodified line | |||
| 133 | _isPresenter = false; | 147 | _isPresenter = false; |
| 134 | } | 148 | } |
| 135 | 149 | ||
| 150 | // Called when the Metaverse returns the information necessary to start/view a screen share. | ||
| 136 | void ScreenshareScriptingInterface::handleSuccessfulScreenshareInfoGet(QNetworkReply* reply) { | 151 | void ScreenshareScriptingInterface::handleSuccessfulScreenshareInfoGet(QNetworkReply* reply) { |
| 152 | // Read the reply and get it into a format we understand. | ||
| 137 | QString answer = reply->readAll(); | 153 | QString answer = reply->readAll(); |
| 138 | |||
| 139 | QByteArray answerByteArray = answer.toUtf8(); | 154 | QByteArray answerByteArray = answer.toUtf8(); |
| 140 | QJsonDocument answerJSONObject = QJsonDocument::fromJson(answerByteArray); | 155 | QJsonDocument answerJSONObject = QJsonDocument::fromJson(answerByteArray); |
| 141 | 156 | ||
| 157 | // This Metaverse endpoint will always return a status key/value pair of "success" if things went well. | ||
| 142 | QString status = answerJSONObject["status"].toString(); | 158 | QString status = answerJSONObject["status"].toString(); |
| 143 | if (status != "success") { | 159 | if (status != "success") { |
| 144 | qDebug() << "Error when retrieving screenshare info via HTTP. Error:" << reply->errorString(); | 160 | qDebug() << "Error when retrieving screenshare info via HTTP. Error:" << reply->errorString(); |
| @@ -147,10 +163,12 @@ void ScreenshareScriptingInterface::handleSuccessfulScreenshareInfoGet(QNetworkR · 2 unmodified lines | |||
| 147 | return; | 163 | return; |
| 148 | } | 164 | } |
| 149 | 165 | ||
| 166 | // Store the information necessary to start/view a screen share in these private member variables. | ||
| 150 | _token = answerJSONObject["token"].toString(); | 167 | _token = answerJSONObject["token"].toString(); |
| 151 | _projectAPIKey = answerJSONObject["projectApiKey"].toString(); | 168 | _projectAPIKey = answerJSONObject["projectApiKey"].toString(); |
| 152 | _sessionID = answerJSONObject["sessionID"].toString(); | 169 | _sessionID = answerJSONObject["sessionID"].toString(); |
| 153 | 170 | ||
| 171 | // Make sure we have all of the info that we need. | ||
| 154 | if (_token.isEmpty() || _projectAPIKey.isEmpty() || _sessionID.isEmpty()) { | 172 | if (_token.isEmpty() || _projectAPIKey.isEmpty() || _sessionID.isEmpty()) { |
| 155 | qDebug() << "Not all Screen Share information was retrieved from the backend. Stopping..."; | 173 | qDebug() << "Not all Screen Share information was retrieved from the backend. Stopping..."; |
| 156 | stopScreenshare(); | 174 | stopScreenshare(); |
| @@ -158,6 +176,10 @@ void ScreenshareScriptingInterface::handleSuccessfulScreenshareInfoGet(QNetworkR · 1 unmodified line | |||
| 158 | return; | 176 | return; |
| 159 | } | 177 | } |
| 160 | 178 | ||
| 179 | // If we're presenting: | ||
| 180 | // 1. Build a list of arguments that we're going to pass to the screen share Electron app. | ||
| 181 | // 2. Make sure we connect a signal/slot to know when the user quits the Electron app. | ||
| 182 | // 3. Start the screen share Electron app with the list of args from (1). | ||
| 161 | if (_isPresenter) { | 183 | if (_isPresenter) { |
| 162 | QStringList arguments; | 184 | QStringList arguments; |
| 163 | arguments << " "; | 185 | arguments << " "; |
| @@ -174,12 +196,7 @@ void ScreenshareScriptingInterface::handleSuccessfulScreenshareInfoGet(QNetworkR · 10 unmodified lines | |||
| 174 | _screenshareProcess->start(SCREENSHARE_EXE_PATH, arguments); | 196 | _screenshareProcess->start(SCREENSHARE_EXE_PATH, arguments); |
| 175 | } | 197 | } |
| 176 | 198 | ||
| 177 | if (!_screenshareViewerLocalWebEntityUUID.isNull()) { | 199 | // Make sure we can grab the entity scripting interface. Error out if we can't. |
| 178 | stopScreenshare(); | ||
| 179 | emit screenshareError(); | ||
| 180 | return; | ||
| 181 | } | ||
| 182 | |||
| 183 | auto esi = DependencyManager::get<EntityScriptingInterface>(); | 200 | auto esi = DependencyManager::get<EntityScriptingInterface>(); |
| 184 | if (!esi) { | 201 | if (!esi) { |
| 185 | stopScreenshare(); | 202 | stopScreenshare(); |
| @@ -187,6 +204,12 @@ void ScreenshareScriptingInterface::handleSuccessfulScreenshareInfoGet(QNetworkR · 1 unmodified line | |||
| 187 | return; | 204 | return; |
| 188 | } | 205 | } |
| 189 | 206 | ||
| 207 | // If, for some reason, we already have a record of a screen share local Web entity, delete it. | ||
| 208 | if (!_screenshareViewerLocalWebEntityUUID.isNull()) { | ||
| 209 | esi->deleteEntity(_screenshareViewerLocalWebEntityUUID); | ||
| 210 | } | ||
| 211 | |||
| 212 | // Set up the entity properties associated with the screen share local Web entity. | ||
| 190 | EntityItemProperties localScreenshareWebEntityProps; | 213 | EntityItemProperties localScreenshareWebEntityProps; |
| 191 | localScreenshareWebEntityProps.setType(LOCAL_SCREENSHARE_WEB_ENTITY_TYPE); | 214 | localScreenshareWebEntityProps.setType(LOCAL_SCREENSHARE_WEB_ENTITY_TYPE); |
| 192 | localScreenshareWebEntityProps.setMaxFPS(LOCAL_SCREENSHARE_WEB_ENTITY_FPS); | 215 | localScreenshareWebEntityProps.setMaxFPS(LOCAL_SCREENSHARE_WEB_ENTITY_FPS); |
| @@ -195,14 +218,16 @@ void ScreenshareScriptingInterface::handleSuccessfulScreenshareInfoGet(QNetworkR · 2 unmodified lines | |||
| 195 | localScreenshareWebEntityProps.setParentID(_smartboardEntityID); | 218 | localScreenshareWebEntityProps.setParentID(_smartboardEntityID); |
| 196 | localScreenshareWebEntityProps.setDimensions(LOCAL_SCREENSHARE_WEB_ENTITY_DIMENSIONS); | 219 | localScreenshareWebEntityProps.setDimensions(LOCAL_SCREENSHARE_WEB_ENTITY_DIMENSIONS); |
| 197 | 220 | ||
| 198 | // The lines below will be used when writing the feature to ensure that the smartboard can be of any arbitrary size. | 221 | // The lines below will be used when writing the feature to support scaling the Smartboard entity to any arbitrary size. |
| 199 | //EntityPropertyFlags desiredSmartboardProperties; | 222 | //EntityPropertyFlags desiredSmartboardProperties; |
| 200 | //desiredSmartboardProperties += PROP_POSITION; | 223 | //desiredSmartboardProperties += PROP_POSITION; |
| 201 | //desiredSmartboardProperties += PROP_DIMENSIONS; | 224 | //desiredSmartboardProperties += PROP_DIMENSIONS; |
| 202 | //EntityItemProperties smartboardProps = esi->getEntityProperties(smartboardEntityID, desiredSmartboardProperties); | 225 | //EntityItemProperties smartboardProps = esi->getEntityProperties(_smartboardEntityID, desiredSmartboardProperties); |
| 203 | 226 | ||
| 204 | QString hostType = "local"; | 227 | // Add the screen share local Web entity to Interface's entity tree. |
| 205 | _screenshareViewerLocalWebEntityUUID = esi->addEntity(localScreenshareWebEntityProps, hostType); | 228 | // When the Web entity loads the page specified by `LOCAL_SCREENSHARE_WEB_ENTITY_URL`, it will broadcast an Event Bridge |
| 229 | // message, which we will consume inside `ScreenshareScriptingInterface::onWebEventReceived()`. | ||
| 230 | _screenshareViewerLocalWebEntityUUID = esi->addEntity(localScreenshareWebEntityProps, LOCAL_SCREENSHARE_WEB_ENTITY_HOST_TYPE); | ||
| 206 | } | 231 | } |
| 207 | 232 | ||
| 208 | void ScreenshareScriptingInterface::handleFailedScreenshareInfoGet(QNetworkReply* reply) { | 233 | void ScreenshareScriptingInterface::handleFailedScreenshareInfoGet(QNetworkReply* reply) { |
| @@ -211,21 +236,31 @@ void ScreenshareScriptingInterface::handleFailedScreenshareInfoGet(QNetworkReply · 2 unmodified lines | |||
| 211 | emit screenshareError(); | 236 | emit screenshareError(); |
| 212 | } | 237 | } |
| 213 | 238 | ||
| 239 | // This function will handle _all_ web events received via `EntityScriptingInterface::webEventReceived()`, including | ||
| 240 | // those not related to screen sharing. | ||
| 214 | void ScreenshareScriptingInterface::onWebEventReceived(const QUuid& entityID, const QVariant& message) { | 241 | void ScreenshareScriptingInterface::onWebEventReceived(const QUuid& entityID, const QVariant& message) { |
| 242 | // Bail early if the entity that sent the Web event isn't the one we care about. | ||
| 215 | if (entityID == _screenshareViewerLocalWebEntityUUID) { | 243 | if (entityID == _screenshareViewerLocalWebEntityUUID) { |
| 244 | // Bail early if we can't grab the Entity Scripting Interface. | ||
| 216 | auto esi = DependencyManager::get<EntityScriptingInterface>(); | 245 | auto esi = DependencyManager::get<EntityScriptingInterface>(); |
| 217 | if (!esi) { | 246 | if (!esi) { |
| 218 | return; | 247 | return; |
| 219 | } | 248 | } |
| 220 | 249 | ||
| 250 | // Web events received from the screen share Web JS will always be in stringified JSON format. | ||
| 221 | QByteArray jsonByteArray = QVariant(message).toString().toUtf8(); | 251 | QByteArray jsonByteArray = QVariant(message).toString().toUtf8(); |
| 222 | QJsonDocument jsonObject = QJsonDocument::fromJson(jsonByteArray); | 252 | QJsonDocument jsonObject = QJsonDocument::fromJson(jsonByteArray); |
| 223 | 253 | ||
| 254 | // It should never happen where the screen share Web JS sends a message without the `app` key's value | ||
| 255 | // set to "screenshare". | ||
| 224 | if (jsonObject["app"] != "screenshare") { | 256 | if (jsonObject["app"] != "screenshare") { |
| 225 | return; | 257 | return; |
| 226 | } | 258 | } |
| 227 | 259 | ||
| 260 | // The screen share Web JS only sends a message with one method: "eventBridgeReady". Handle it here. | ||
| 228 | if (jsonObject["method"] == "eventBridgeReady") { | 261 | if (jsonObject["method"] == "eventBridgeReady") { |
| 262 | // Stuff a JSON object full of information necessary for the screen share local Web entity | ||
| 263 | // to connect to the screen share session associated with the room in which the user's avatar is standing. | ||
| 229 | QJsonObject responseObject; | 264 | QJsonObject responseObject; |
| 230 | responseObject.insert("app", "screenshare"); | 265 | responseObject.insert("app", "screenshare"); |
| 231 | responseObject.insert("method", "receiveConnectionInfo"); | 266 | responseObject.insert("method", "receiveConnectionInfo"); |
| @@ -235,7 +270,6 @@ void ScreenshareScriptingInterface::onWebEventReceived(const QUuid& entityID, co · 3 unmodified lines | |||
| 235 | responseObjectData.insert("sessionID", _sessionID); | 270 | responseObjectData.insert("sessionID", _sessionID); |
| 236 | responseObject.insert("data", responseObjectData); | 271 | responseObject.insert("data", responseObjectData); |
| 237 | 272 | ||
| 238 | auto esi = DependencyManager::get<EntityScriptingInterface>(); | ||
| 239 | esi->emitScriptEvent(_screenshareViewerLocalWebEntityUUID, responseObject.toVariantMap()); | 273 | esi->emitScriptEvent(_screenshareViewerLocalWebEntityUUID, responseObject.toVariantMap()); |
| 240 | } | 274 | } |
| 241 | } | 275 | } |