Merge branch 'master' of github.com:highfidelity/hifi into ice-troubles

This commit is contained in:
Seth Alves 2016-09-22 10:34:25 -07:00
commit c43c83019d
15 changed files with 169 additions and 78 deletions

View file

@ -380,6 +380,14 @@
"default": "0",
"advanced": false
},
{
"name": "maximum_user_capacity_redirect_location",
"label": "Redirect to Location on Maximum Capacity",
"help": "Is there another domain, you'd like to redirect clients to when the maximum number of avatars are connected.",
"placeholder": "",
"default": "",
"advanced": false
},
{
"name": "standard_permissions",
"type": "table",

View file

@ -317,6 +317,7 @@ SharedNodePointer DomainGatekeeper::processAssignmentConnectRequest(const NodeCo
}
const QString MAXIMUM_USER_CAPACITY = "security.maximum_user_capacity";
const QString MAXIMUM_USER_CAPACITY_REDIRECT_LOCATION = "security.maximum_user_capacity_redirect_location";
SharedNodePointer DomainGatekeeper::processAgentConnectRequest(const NodeConnectionData& nodeConnection,
const QString& username,
@ -363,7 +364,7 @@ SharedNodePointer DomainGatekeeper::processAgentConnectRequest(const NodeConnect
if (!userPerms.can(NodePermissions::Permission::canConnectToDomain)) {
sendConnectionDeniedPacket("You lack the required permissions to connect to this domain.",
nodeConnection.senderSockAddr, DomainHandler::ConnectionRefusedReason::TooManyUsers);
nodeConnection.senderSockAddr, DomainHandler::ConnectionRefusedReason::NotAuthorized);
#ifdef WANT_DEBUG
qDebug() << "stalling login due to permissions:" << username;
#endif
@ -372,8 +373,16 @@ SharedNodePointer DomainGatekeeper::processAgentConnectRequest(const NodeConnect
if (!userPerms.can(NodePermissions::Permission::canConnectPastMaxCapacity) && !isWithinMaxCapacity()) {
// we can't allow this user to connect because we are at max capacity
QString redirectOnMaxCapacity;
const QVariant* redirectOnMaxCapacityVariant =
valueForKeyPath(_server->_settingsManager.getSettingsMap(), MAXIMUM_USER_CAPACITY_REDIRECT_LOCATION);
if (redirectOnMaxCapacityVariant && redirectOnMaxCapacityVariant->canConvert<QString>()) {
redirectOnMaxCapacity = redirectOnMaxCapacityVariant->toString();
qDebug() << "Redirection domain:" << redirectOnMaxCapacity;
}
sendConnectionDeniedPacket("Too many connected users.", nodeConnection.senderSockAddr,
DomainHandler::ConnectionRefusedReason::TooManyUsers);
DomainHandler::ConnectionRefusedReason::TooManyUsers, redirectOnMaxCapacity);
#ifdef WANT_DEBUG
qDebug() << "stalling login due to max capacity:" << username;
#endif
@ -623,22 +632,30 @@ void DomainGatekeeper::sendProtocolMismatchConnectionDenial(const HifiSockAddr&
}
void DomainGatekeeper::sendConnectionDeniedPacket(const QString& reason, const HifiSockAddr& senderSockAddr,
DomainHandler::ConnectionRefusedReason reasonCode) {
DomainHandler::ConnectionRefusedReason reasonCode,
QString extraInfo) {
// this is an agent and we've decided we won't let them connect - send them a packet to deny connection
QByteArray utfString = reason.toUtf8();
quint16 payloadSize = utfString.size();
QByteArray utfReasonString = reason.toUtf8();
quint16 reasonSize = utfReasonString.size();
QByteArray utfExtraInfo = extraInfo.toUtf8();
quint16 extraInfoSize = utfExtraInfo.size();
// setup the DomainConnectionDenied packet
auto connectionDeniedPacket = NLPacket::create(PacketType::DomainConnectionDenied,
payloadSize + sizeof(payloadSize) + sizeof(uint8_t));
sizeof(uint8_t) + // reasonCode
reasonSize + sizeof(reasonSize) +
extraInfoSize + sizeof(extraInfoSize));
// pack in the reason the connection was denied (the client displays this)
if (payloadSize > 0) {
uint8_t reasonCodeWire = (uint8_t)reasonCode;
connectionDeniedPacket->writePrimitive(reasonCodeWire);
connectionDeniedPacket->writePrimitive(payloadSize);
connectionDeniedPacket->write(utfString);
}
uint8_t reasonCodeWire = (uint8_t)reasonCode;
connectionDeniedPacket->writePrimitive(reasonCodeWire);
connectionDeniedPacket->writePrimitive(reasonSize);
connectionDeniedPacket->write(utfReasonString);
// write the extra info as well
connectionDeniedPacket->writePrimitive(extraInfoSize);
connectionDeniedPacket->write(utfExtraInfo);
// send the packet off
DependencyManager::get<LimitedNodeList>()->sendPacket(std::move(connectionDeniedPacket), senderSockAddr);

View file

@ -88,7 +88,8 @@ private:
void sendConnectionTokenPacket(const QString& username, const HifiSockAddr& senderSockAddr);
static void sendConnectionDeniedPacket(const QString& reason, const HifiSockAddr& senderSockAddr,
DomainHandler::ConnectionRefusedReason reasonCode = DomainHandler::ConnectionRefusedReason::Unknown);
DomainHandler::ConnectionRefusedReason reasonCode = DomainHandler::ConnectionRefusedReason::Unknown,
QString extraInfo = QString());
void pingPunchForConnectingPeer(const SharedNetworkPeer& peer);

View file

@ -1239,8 +1239,15 @@ Application::Application(int& argc, char** argv, QElapsedTimer& startupTimer) :
firstRun.set(false);
}
void Application::domainConnectionRefused(const QString& reasonMessage, int reasonCode) {
switch (static_cast<DomainHandler::ConnectionRefusedReason>(reasonCode)) {
void Application::domainConnectionRefused(const QString& reasonMessage, int reasonCodeInt, const QString& extraInfo) {
DomainHandler::ConnectionRefusedReason reasonCode = static_cast<DomainHandler::ConnectionRefusedReason>(reasonCodeInt);
if (reasonCode == DomainHandler::ConnectionRefusedReason::TooManyUsers && !extraInfo.isEmpty()) {
DependencyManager::get<AddressManager>()->handleLookupString(extraInfo);
return;
}
switch (reasonCode) {
case DomainHandler::ConnectionRefusedReason::ProtocolMismatch:
case DomainHandler::ConnectionRefusedReason::TooManyUsers:
case DomainHandler::ConnectionRefusedReason::Unknown: {

View file

@ -375,7 +375,7 @@ private slots:
void nodeKilled(SharedNodePointer node);
static void packetSent(quint64 length);
void updateDisplayMode();
void domainConnectionRefused(const QString& reasonMessage, int reason);
void domainConnectionRefused(const QString& reasonMessage, int reason, const QString& extraInfo);
private:
static void initDisplay();

View file

@ -58,7 +58,7 @@ public slots:
signals:
void domainChanged(const QString& domainHostname);
void svoImportRequested(const QString& url);
void domainConnectionRefused(const QString& reasonMessage, int reasonCode);
void domainConnectionRefused(const QString& reasonMessage, int reasonCode, const QString& extraInfo);
void snapshotTaken(const QString& path, bool notify);
void snapshotShared(const QString& error);

View file

@ -278,12 +278,18 @@ void setupPreferences() {
preferences->addPreference(preference);
}
#if DEV_BUILD || PR_BUILD
{
auto getter = []()->bool { return DependencyManager::get<AudioClient>()->isSimulatingJitter(); };
auto setter = [](bool value) { return DependencyManager::get<AudioClient>()->setIsSimulatingJitter(value); };
auto preference = new CheckPreference(AUDIO, "Packet jitter simulator", getter, setter);
preferences->addPreference(preference);
}
{
auto getter = []()->float { return DependencyManager::get<AudioClient>()->getGateThreshold(); };
auto setter = [](float value) { return DependencyManager::get<AudioClient>()->setGateThreshold(value); };
auto preference = new SpinnerPreference(AUDIO, "Debug gate threshold", getter, setter);
auto preference = new SpinnerPreference(AUDIO, "Packet throttle threshold", getter, setter);
preference->setMin(1);
preference->setMax((float)100);
preference->setMax(200);
preference->setStep(1);
preferences->addPreference(preference);
}

View file

@ -56,8 +56,6 @@ static const int RECEIVED_AUDIO_STREAM_CAPACITY_FRAMES = 100;
static const auto DEFAULT_POSITION_GETTER = []{ return Vectors::ZERO; };
static const auto DEFAULT_ORIENTATION_GETTER = [] { return Quaternions::IDENTITY; };
static const int DEFAULT_AUDIO_OUTPUT_GATE_THRESHOLD = 1;
Setting::Handle<bool> dynamicJitterBuffers("dynamicJitterBuffers", DEFAULT_DYNAMIC_JITTER_BUFFERS);
Setting::Handle<int> maxFramesOverDesired("maxFramesOverDesired", DEFAULT_MAX_FRAMES_OVER_DESIRED);
Setting::Handle<int> staticDesiredJitterBufferFrames("staticDesiredJitterBufferFrames",
@ -102,8 +100,7 @@ private:
AudioClient::AudioClient() :
AbstractAudioInterface(),
_gateThreshold("audioOutputGateThreshold", DEFAULT_AUDIO_OUTPUT_GATE_THRESHOLD),
_gate(this, _gateThreshold.get()),
_gate(this),
_audioInput(NULL),
_desiredInputFormat(),
_inputFormat(),
@ -551,31 +548,53 @@ void AudioClient::handleAudioDataPacket(QSharedPointer<ReceivedMessage> message)
}
}
AudioClient::Gate::Gate(AudioClient* audioClient, int threshold) :
_audioClient(audioClient),
_threshold(threshold) {}
AudioClient::Gate::Gate(AudioClient* audioClient) :
_audioClient(audioClient) {}
void AudioClient::Gate::setIsSimulatingJitter(bool enable) {
std::lock_guard<std::mutex> lock(_mutex);
flush();
_isSimulatingJitter = enable;
}
void AudioClient::Gate::setThreshold(int threshold) {
std::lock_guard<std::mutex> lock(_mutex);
flush();
_threshold = std::max(threshold, 1);
}
void AudioClient::Gate::insert(QSharedPointer<ReceivedMessage> message) {
std::lock_guard<std::mutex> lock(_mutex);
// Short-circuit for normal behavior
if (_threshold == 1) {
if (_threshold == 1 && !_isSimulatingJitter) {
_audioClient->_receivedAudioStream.parseData(*message);
return;
}
// Throttle the current packet until the next flush
_queue.push(message);
_index++;
if (_index % _threshold == 0) {
// When appropriate, flush all held packets to the received audio stream
if (_isSimulatingJitter) {
// The JITTER_FLUSH_CHANCE defines the discrete probability density function of jitter (ms),
// where f(t) = pow(1 - JITTER_FLUSH_CHANCE, (t / 10) * JITTER_FLUSH_CHANCE
// for t (ms) = 10, 20, ... (because typical packet timegap is 10ms),
// because there is a JITTER_FLUSH_CHANCE of any packet instigating a flush of all held packets.
static const float JITTER_FLUSH_CHANCE = 0.6f;
// It is set at 0.6 to give a low chance of spikes (>30ms, 2.56%) so that they are obvious,
// but settled within the measured 5s window in audio network stats.
if (randFloat() < JITTER_FLUSH_CHANCE) {
flush();
}
} else if (!(_index % _threshold)) {
flush();
}
}
void AudioClient::Gate::flush() {
// Send all held packets to the received audio stream to be (eventually) played
while (!_queue.empty()) {
_audioClient->_receivedAudioStream.parseData(*_queue.front());
_queue.pop();

View file

@ -132,6 +132,9 @@ public:
int getOutputStarveDetectionThreshold() { return _outputStarveDetectionThreshold.get(); }
void setOutputStarveDetectionThreshold(int threshold) { _outputStarveDetectionThreshold.set(threshold); }
bool isSimulatingJitter() { return _gate.isSimulatingJitter(); }
void setIsSimulatingJitter(bool enable) { _gate.setIsSimulatingJitter(enable); }
int getGateThreshold() { return _gate.getThreshold(); }
void setGateThreshold(int threshold) { _gate.setThreshold(threshold); }
@ -230,7 +233,10 @@ private:
class Gate {
public:
Gate(AudioClient* audioClient, int threshold);
Gate(AudioClient* audioClient);
bool isSimulatingJitter() { return _isSimulatingJitter; }
void setIsSimulatingJitter(bool enable);
int getThreshold() { return _threshold; }
void setThreshold(int threshold);
@ -242,11 +248,13 @@ private:
AudioClient* _audioClient;
std::queue<QSharedPointer<ReceivedMessage>> _queue;
std::mutex _mutex;
int _index{ 0 };
int _threshold;
int _threshold{ 1 };
bool _isSimulatingJitter{ false };
};
Setting::Handle<int> _gateThreshold;
Gate _gate;
Mutex _injectorsMutex;

View file

@ -402,13 +402,18 @@ void DomainHandler::processDomainServerConnectionDeniedPacket(QSharedPointer<Rec
auto reasonText = message->readWithoutCopy(reasonSize);
QString reasonMessage = QString::fromUtf8(reasonText);
quint16 extraInfoSize;
message->readPrimitive(&extraInfoSize);
auto extraInfoUtf8= message->readWithoutCopy(extraInfoSize);
QString extraInfo = QString::fromUtf8(extraInfoUtf8);
// output to the log so the user knows they got a denied connection request
// and check and signal for an access token so that we can make sure they are logged in
qCWarning(networking) << "The domain-server denied a connection request: " << reasonMessage;
qCWarning(networking) << "The domain-server denied a connection request: " << reasonMessage << " extraInfo:" << extraInfo;
if (!_domainConnectionRefusals.contains(reasonMessage)) {
_domainConnectionRefusals.insert(reasonMessage);
emit domainConnectionRefused(reasonMessage, (int)reasonCode);
emit domainConnectionRefused(reasonMessage, (int)reasonCode, extraInfo);
}
auto accountManager = DependencyManager::get<AccountManager>();

View file

@ -123,7 +123,7 @@ signals:
void settingsReceived(const QJsonObject& domainSettingsObject);
void settingsReceiveFail();
void domainConnectionRefused(QString reasonMessage, int reason);
void domainConnectionRefused(QString reasonMessage, int reason, const QString& extraInfo);
private:
bool reasonSuggestsLogin(ConnectionRefusedReason reasonCode);

View file

@ -64,7 +64,7 @@ PacketVersion versionForPacketType(PacketType packetType) {
return 18; // Introduction of node ignore request (which replaced an unused packet tpye)
case PacketType::DomainConnectionDenied:
return static_cast<PacketVersion>(DomainConnectionDeniedVersion::IncludesReasonCode);
return static_cast<PacketVersion>(DomainConnectionDeniedVersion::IncludesExtraInfo);
case PacketType::DomainConnectRequest:
return static_cast<PacketVersion>(DomainConnectRequestVersion::HasProtocolVersions);

View file

@ -206,7 +206,8 @@ enum class DomainConnectRequestVersion : PacketVersion {
enum class DomainConnectionDeniedVersion : PacketVersion {
ReasonMessageOnly = 17,
IncludesReasonCode
IncludesReasonCode,
IncludesExtraInfo
};
enum class DomainServerAddedNodeVersion : PacketVersion {

View file

@ -184,7 +184,7 @@ var toolBar = (function () {
properties.position = position;
entityID = Entities.addEntity(properties);
} else {
Window.alert("Can't create " + properties.type + ": " + properties.type + " would be out of bounds.");
Window.notifyEditError("Can't create " + properties.type + ": " + properties.type + " would be out of bounds.");
}
selectionManager.clearSelections();
@ -445,7 +445,7 @@ var toolBar = (function () {
return;
}
if (active && !Entities.canRez() && !Entities.canRezTmp()) {
Window.alert(INSUFFICIENT_PERMISSIONS_ERROR_MSG);
Window.notifyEditError(INSUFFICIENT_PERMISSIONS_ERROR_MSG);
return;
}
Messages.sendLocalMessage("edit-events", JSON.stringify({
@ -1082,13 +1082,13 @@ function handeMenuEvent(menuItem) {
deleteSelectedEntities();
} else if (menuItem === "Export Entities") {
if (!selectionManager.hasSelection()) {
Window.alert("No entities have been selected.");
Window.notifyEditError("No entities have been selected.");
} else {
var filename = Window.save("Select Where to Save", "", "*.json");
if (filename) {
var success = Clipboard.exportEntities(filename, selectionManager.selections);
if (!success) {
Window.alert("Export failed.");
Window.notifyEditError("Export failed.");
}
}
}
@ -1156,7 +1156,7 @@ function getPositionToImportEntity() {
}
function importSVO(importURL) {
if (!Entities.canAdjustLocks()) {
Window.alert(INSUFFICIENT_PERMISSIONS_IMPORT_ERROR_MSG);
Window.notifyEditError(INSUFFICIENT_PERMISSIONS_IMPORT_ERROR_MSG);
return;
}
@ -1188,10 +1188,10 @@ function importSVO(importURL) {
Window.raiseMainWindow();
} else {
Window.alert("Can't import objects: objects would be out of bounds.");
Window.notifyEditError("Can't import objects: objects would be out of bounds.");
}
} else {
Window.alert("There was an error importing the entity file.");
Window.notifyEditError("There was an error importing the entity file.");
}
Overlays.editOverlay(importingSVOTextOverlay, {
@ -1481,7 +1481,7 @@ var PropertiesTool = function (opts) {
// If any of the natural dimensions are not 0, resize
if (properties.type === "Model" && naturalDimensions.x === 0 && naturalDimensions.y === 0 &&
naturalDimensions.z === 0) {
Window.alert("Cannot reset entity to its natural dimensions: Model URL" +
Window.notifyEditError("Cannot reset entity to its natural dimensions: Model URL" +
" is invalid or the model has not yet been loaded.");
} else {
Entities.editEntity(selectionManager.selections[i], {

View file

@ -58,6 +58,8 @@
// }
// }
/* global Script, Controller, Overlays, SoundArray, Quat, Vec3, MyAvatar, Menu, HMD, AudioDevice, LODManager, Settings, Camera */
(function() { // BEGIN LOCAL_SCOPE
Script.include("./libraries/soundArray.js");
@ -76,11 +78,9 @@ var fontSize = 12.0;
var PERSIST_TIME_2D = 10.0; // Time in seconds before notification fades
var PERSIST_TIME_3D = 15.0;
var persistTime = PERSIST_TIME_2D;
var clickedText = false;
var frame = 0;
var ourWidth = Window.innerWidth;
var ourHeight = Window.innerHeight;
var text = "placeholder";
var ctrlIsPressed = false;
var ready = true;
var MENU_NAME = 'Tools > Notifications';
@ -97,12 +97,14 @@ var NotificationType = {
WINDOW_RESIZE: 3,
LOD_WARNING: 4,
CONNECTION_REFUSED: 5,
EDIT_ERROR: 6,
properties: [
{ text: "Mute Toggle" },
{ text: "Snapshot" },
{ text: "Window Resize" },
{ text: "Level of Detail" },
{ text: "Connection Refused" }
{ text: "Connection Refused" },
{ text: "Edit error" }
],
getTypeFromMenuItem: function(menuItemName) {
if (menuItemName.substr(menuItemName.length - NOTIFICATION_MENU_ITEM_POST.length) !== NOTIFICATION_MENU_ITEM_POST) {
@ -253,6 +255,9 @@ function notify(notice, button, height, imageProperties, image) {
positions = calculate3DOverlayPositions(noticeWidth, noticeHeight, notice.y);
notice.parentID = MyAvatar.sessionUUID;
notice.parentJointIndex = -2;
if (!image) {
notice.topMargin = 0.75 * notice.topMargin * NOTIFICATION_3D_SCALE;
notice.leftMargin = 2 * notice.leftMargin * NOTIFICATION_3D_SCALE;
@ -270,6 +275,8 @@ function notify(notice, button, height, imageProperties, image) {
button.url = button.imageURL;
button.scale = button.width * NOTIFICATION_3D_SCALE;
button.isFacingAvatar = false;
button.parentID = MyAvatar.sessionUUID;
button.parentJointIndex = -2;
buttons.push((Overlays.addOverlay("image3d", button)));
overlay3DDetails.push({
@ -279,6 +286,34 @@ function notify(notice, button, height, imageProperties, image) {
width: noticeWidth,
height: noticeHeight
});
var defaultEyePosition,
avatarOrientation,
notificationPosition,
notificationOrientation,
buttonPosition;
if (isOnHMD && notifications.length > 0) {
// Update 3D overlays to maintain positions relative to avatar
defaultEyePosition = MyAvatar.getDefaultEyePosition();
avatarOrientation = MyAvatar.orientation;
for (i = 0; i < notifications.length; i += 1) {
notificationPosition = Vec3.sum(defaultEyePosition,
Vec3.multiplyQbyV(avatarOrientation,
overlay3DDetails[i].notificationPosition));
notificationOrientation = Quat.multiply(avatarOrientation,
overlay3DDetails[i].notificationOrientation);
buttonPosition = Vec3.sum(defaultEyePosition,
Vec3.multiplyQbyV(avatarOrientation,
overlay3DDetails[i].buttonPosition));
Overlays.editOverlay(notifications[i], { position: notificationPosition,
rotation: notificationOrientation });
Overlays.editOverlay(buttons[i], { position: buttonPosition, rotation: notificationOrientation });
}
}
} else {
if (!image) {
notificationText = Overlays.addOverlay("text", notice);
@ -429,11 +464,6 @@ function update() {
noticeOut,
buttonOut,
arraysOut,
defaultEyePosition,
avatarOrientation,
notificationPosition,
notificationOrientation,
buttonPosition,
positions,
i,
j,
@ -457,7 +487,8 @@ function update() {
Overlays.editOverlay(notifications[i], { x: overlayLocationX, y: locationY });
Overlays.editOverlay(buttons[i], { x: buttonLocationX, y: locationY + 12.0 });
if (isOnHMD) {
positions = calculate3DOverlayPositions(overlay3DDetails[i].width, overlay3DDetails[i].height, locationY);
positions = calculate3DOverlayPositions(overlay3DDetails[i].width,
overlay3DDetails[i].height, locationY);
overlay3DDetails[i].notificationOrientation = positions.notificationOrientation;
overlay3DDetails[i].notificationPosition = positions.notificationPosition;
overlay3DDetails[i].buttonPosition = positions.buttonPosition;
@ -480,22 +511,6 @@ function update() {
}
}
}
if (isOnHMD && notifications.length > 0) {
// Update 3D overlays to maintain positions relative to avatar
defaultEyePosition = MyAvatar.getDefaultEyePosition();
avatarOrientation = MyAvatar.orientation;
for (i = 0; i < notifications.length; i += 1) {
notificationPosition = Vec3.sum(defaultEyePosition,
Vec3.multiplyQbyV(avatarOrientation, overlay3DDetails[i].notificationPosition));
notificationOrientation = Quat.multiply(avatarOrientation, overlay3DDetails[i].notificationOrientation);
buttonPosition = Vec3.sum(defaultEyePosition,
Vec3.multiplyQbyV(avatarOrientation, overlay3DDetails[i].buttonPosition));
Overlays.editOverlay(notifications[i], { position: notificationPosition, rotation: notificationOrientation });
Overlays.editOverlay(buttons[i], { position: buttonPosition, rotation: notificationOrientation });
}
}
}
var STARTUP_TIMEOUT = 500, // ms
@ -532,12 +547,17 @@ function onDomainConnectionRefused(reason) {
createNotification("Connection refused: " + reason, NotificationType.CONNECTION_REFUSED);
}
function onEditError(msg) {
createNotification(wordWrap(msg), NotificationType.EDIT_ERROR);
}
function onSnapshotTaken(path, notify) {
if (notify) {
var imageProperties = {
path: "file:///" + path,
aspectRatio: Window.innerWidth / Window.innerHeight
}
};
createNotification(wordWrap("Snapshot saved to " + path), NotificationType.SNAPSHOT, imageProperties);
}
}
@ -571,8 +591,6 @@ function keyReleaseEvent(key) {
// Triggers notification on specific key driven events
function keyPressEvent(key) {
var noteString;
if (key.key === 16777249) {
ctrlIsPressed = true;
}
@ -622,13 +640,13 @@ function menuItemEvent(menuItem) {
}
LODManager.LODDecreased.connect(function() {
var warningText = "\n"
+ "Due to the complexity of the content, the \n"
+ "level of detail has been decreased. "
+ "You can now see: \n"
+ LODManager.getLODFeedbackText();
var warningText = "\n" +
"Due to the complexity of the content, the \n" +
"level of detail has been decreased. " +
"You can now see: \n" +
LODManager.getLODFeedbackText();
if (lodTextID == false) {
if (lodTextID === false) {
lodTextID = createNotification(warningText, NotificationType.LOD_WARNING);
} else {
Overlays.editOverlay(lodTextID, { text: warningText });
@ -644,6 +662,7 @@ Script.scriptEnding.connect(scriptEnding);
Menu.menuItemEvent.connect(menuItemEvent);
Window.domainConnectionRefused.connect(onDomainConnectionRefused);
Window.snapshotTaken.connect(onSnapshotTaken);
Window.notifyEditError = onEditError;
setup();