diff --git a/extensions/cloudlink.js b/extensions/cloudlink.js index 8496737d54..64f05bb998 100644 --- a/extensions/cloudlink.js +++ b/extensions/cloudlink.js @@ -1,1838 +1,2349 @@ // Name: Cloudlink // ID: cloudlink -// Description: Powerful WebSocket extension for Scratch 3. +// Description: A powerful WebSocket extension for Scratch. // By: MikeDEV -// Copy of S4-0_nosuite.js as of 10/31/2022 /* eslint-disable */ - +// prettier-ignore (function (Scratch) { - var servers = {}; // Server list - let mWS = null; - // Get the server URL list - try { - Scratch.fetch( - "https://raw.githubusercontent.com/MikeDev101/cloudlink/master/serverlist.json" - ) - .then((response) => { - return response.text(); - }) - .then((data) => { - servers = JSON.parse(data); - }) - .catch((err) => { - console.log(err); - servers = {}; - }); - } catch (err) { - console.log(err); - servers = {}; + /* + CloudLink Extension for TurboWarp v0.1.1. + + This extension should be fully compatible with projects developed using + extensions S4.1, S4.0, and B3.0. + + Server versions supported via backward compatibility: + - CL3 0.1.5 (was called S2.2) + - CL3 0.1.7 + - CL4 0.1.8.x + - CL4 0.1.9.x + - CL4 0.2.0 (latest) + + MIT License + Copyright 2023 Mike J. Renaker / "MikeDEV". + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE + FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + */ + + // Require extension to be unsandboxed. + 'use strict'; + if (!Scratch.extensions.unsandboxed) { + throw new Error('The CloudLink extension must run unsandboxed.'); + } + + // Declare icons as static SVG URIs + const cl_icon = + "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIyMjUuMzU0OCIgaGVpZ2h0PSIyMjUuMzU0OCIgdmlld0JveD0iMCwwLDIyNS4zNTQ4LDIyNS4zNTQ4Ij48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTI3LjMyMjYsLTY3LjMyMjYpIj48ZyBkYXRhLXBhcGVyLWRhdGE9InsmcXVvdDtpc1BhaW50aW5nTGF5ZXImcXVvdDs6dHJ1ZX0iIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9ImJ1dHQiIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgc3Ryb2tlLWRhc2hhcnJheT0iIiBzdHJva2UtZGFzaG9mZnNldD0iMCIgc3R5bGU9Im1peC1ibGVuZC1tb2RlOiBub3JtYWwiPjxwYXRoIGQ9Ik0xMjcuMzIyNiwxODBjMCwtNjIuMjMwMDEgNTAuNDQ3MzksLTExMi42Nzc0IDExMi42Nzc0LC0xMTIuNjc3NGM2Mi4yMzAwMSwwIDExMi42Nzc0LDUwLjQ0NzM5IDExMi42Nzc0LDExMi42Nzc0YzAsNjIuMjMwMDEgLTUwLjQ0NzM5LDExMi42Nzc0IC0xMTIuNjc3NCwxMTIuNjc3NGMtNjIuMjMwMDEsMCAtMTEyLjY3NzQsLTUwLjQ0NzM5IC0xMTIuNjc3NCwtMTEyLjY3NzR6IiBmaWxsPSIjMDBjMjhjIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHN0cm9rZS13aWR0aD0iMCIvPjxnIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlLXdpZHRoPSIxIj48cGF0aCBkPSJNMjg2LjEyMDM3LDE1MC41NTc5NWMyMy4yNDA4NiwwIDQyLjA3ODksMTguODM5NDYgNDIuMDc4OSw0Mi4wNzg5YzAsMjMuMjM5NDQgLTE4LjgzODAzLDQyLjA3ODkgLTQyLjA3ODksNDIuMDc4OWgtOTIuMjQwNzRjLTIzLjI0MDg2LDAgLTQyLjA3ODksLTE4LjgzOTQ2IC00Mi4wNzg5LC00Mi4wNzg5YzAsLTIzLjIzOTQ0IDE4LjgzODAzLC00Mi4wNzg5IDQyLjA3ODksLTQyLjA3ODloNC4xODg4N2MxLjgxMTUzLC0yMS41NzA1NSAxOS44OTM1NywtMzguNTEyODkgNDEuOTMxNSwtMzguNTEyODljMjIuMDM3OTMsMCA0MC4xMTk5NywxNi45NDIzNCA0MS45MzE1LDM4LjUxMjg5eiIgZmlsbD0iI2ZmZmZmZiIvPjxwYXRoIGQ9Ik0yODkuMDg2NTUsMjEwLjM0MTE0djkuMDQ2NjdoLTI2LjkxNjYzaC05LjA0NjY3di05LjA0NjY3di01NC41MDMzOWg5LjA0NjY3djU0LjUwMzM5eiIgZmlsbD0iIzAwYzI4YyIvPjxwYXRoIGQ9Ik0yMjIuNDA5MjUsMjE5LjM4NzgxYy04LjM1MzIsMCAtMTYuMzY0MzEsLTMuMzE4MzQgLTIyLjI3MDksLTkuMjI0OTJjLTUuOTA2NjEsLTUuOTA2NTggLTkuMjI0OTEsLTEzLjkxNzY4IC05LjIyNDkxLC0yMi4yNzA4OWMwLC04LjM1MzIgMy4zMTgyOSwtMTYuMzY0MzEgOS4yMjQ5MSwtMjIuMjcwOWM1LjkwNjU5LC01LjkwNjYxIDEzLjkxNzcsLTkuMjI0OTEgMjIuMjcwOSwtOS4yMjQ5MWgyMS4xMDg5djguOTM0OThoLTIxLjEwODl2MC4xMDI1N2MtNS45NTYyOCwwIC0xMS42Njg2NCwyLjM2NjE2IC0xNS44ODAzNyw2LjU3Nzg5Yy00LjIxMTczLDQuMjExNzMgLTYuNTc3ODksOS45MjQwOCAtNi41Nzc4OSwxNS44ODAzN2MwLDUuOTU2MjggMi4zNjYxNiwxMS42Njg2NCA2LjU3Nzg5LDE1Ljg4MDM3YzQuMjExNzMsNC4yMTE3MyA5LjkyNDA4LDYuNTc3OTMgMTUuODgwMzcsNi41Nzc5M3YwLjEwMjUzaDIxLjEwODl2OC45MzQ5OHoiIGZpbGw9IiMwMGMyOGMiLz48L2c+PC9nPjwvZz48L3N2Zz48IS0tcm90YXRpb25DZW50ZXI6MTEyLjY3NzQwNDA4NDA4MzkyOjExMi42Nzc0MDQwODQwODQwMy0tPg=="; + const cl_block = + "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIxNzYuMzk4NTQiIGhlaWdodD0iMTIyLjY3MDY5IiB2aWV3Qm94PSIwLDAsMTc2LjM5ODU0LDEyMi42NzA2OSI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE1MS44MDA3MywtMTE4LjY2NDY2KSI+PGcgZGF0YS1wYXBlci1kYXRhPSJ7JnF1b3Q7aXNQYWludGluZ0xheWVyJnF1b3Q7OnRydWV9IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtZGFzaGFycmF5PSIiIHN0cm9rZS1kYXNob2Zmc2V0PSIwIiBzdHlsZT0ibWl4LWJsZW5kLW1vZGU6IG5vcm1hbCI+PGc+PHBhdGggZD0iTTI4Ni4xMjAzNywxNTcuMTc3NTVjMjMuMjQwODYsMCA0Mi4wNzg5LDE4LjgzOTQ2IDQyLjA3ODksNDIuMDc4OWMwLDIzLjIzOTQ0IC0xOC44MzgwMyw0Mi4wNzg5IC00Mi4wNzg5LDQyLjA3ODloLTkyLjI0MDc0Yy0yMy4yNDA4NiwwIC00Mi4wNzg5LC0xOC44Mzk0NiAtNDIuMDc4OSwtNDIuMDc4OWMwLC0yMy4yMzk0NCAxOC44MzgwMywtNDIuMDc4OSA0Mi4wNzg5LC00Mi4wNzg5aDQuMTg4ODdjMS44MTE1MywtMjEuNTcwNTUgMTkuODkzNTcsLTM4LjUxMjg5IDQxLjkzMTUsLTM4LjUxMjg5YzIyLjAzNzkzLDAgNDAuMTE5OTcsMTYuOTQyMzQgNDEuOTMxNSwzOC41MTI4OXoiIGZpbGw9IiNmZmZmZmYiLz48cGF0aCBkPSJNMjg5LjA4NjU1LDIxNi45NjA3NHY5LjA0NjY3aC0yNi45MTY2M2gtOS4wNDY2N3YtOS4wNDY2N3YtNTQuNTAzMzloOS4wNDY2N3Y1NC41MDMzOXoiIGZpbGw9IiMwMGMyOGMiLz48cGF0aCBkPSJNMjIyLjQwOTI1LDIyNi4wMDc0MWMtOC4zNTMyLDAgLTE2LjM2NDMxLC0zLjMxODM0IC0yMi4yNzA5LC05LjIyNDkyYy01LjkwNjYxLC01LjkwNjU4IC05LjIyNDkxLC0xMy45MTc2OCAtOS4yMjQ5MSwtMjIuMjcwODljMCwtOC4zNTMyIDMuMzE4MjksLTE2LjM2NDMxIDkuMjI0OTEsLTIyLjI3MDljNS45MDY1OSwtNS45MDY2MSAxMy45MTc3LC05LjIyNDkxIDIyLjI3MDksLTkuMjI0OTFoMjEuMTA4OXY4LjkzNDk4aC0yMS4xMDg5djAuMTAyNTdjLTUuOTU2MjgsMCAtMTEuNjY4NjQsMi4zNjYxNiAtMTUuODgwMzcsNi41Nzc4OWMtNC4yMTE3Myw0LjIxMTczIC02LjU3Nzg5LDkuOTI0MDggLTYuNTc3ODksMTUuODgwMzdjMCw1Ljk1NjI4IDIuMzY2MTYsMTEuNjY4NjQgNi41Nzc4OSwxNS44ODAzN2M0LjIxMTczLDQuMjExNzMgOS45MjQwOCw2LjU3NzkzIDE1Ljg4MDM3LDYuNTc3OTN2MC4xMDI1M2gyMS4xMDg5djguOTM0OTh6IiBmaWxsPSIjMDBjMjhjIi8+PC9nPjwvZz48L2c+PC9zdmc+PCEtLXJvdGF0aW9uQ2VudGVyOjg4LjE5OTI2OTk5OTk5OTk4OjYxLjMzNTM0NDk5OTk5OTk5LS0+"; + + // Declare VM + const vm = Scratch.vm; + const runtime = vm.runtime; + + /* + This versioning system is intended for future use with CloudLink. + + When the client sends the handshake request, it will provide the server with the following details: + { + "cmd": "handshake", + "val": { + "language": "Scratch", + "version": { + "editorType": String, + "fullString": String + } + } + } + + version.editorType - Provides info regarding the Scratch IDE this Extension variant natively supports. Intended for server-side version identification. + version.versionNumber - Numerical version info. Increment by 1 every Semantic Versioning Patch. Intended for server-side version identification. + version.versionString - Semantic Versioning string. Intended for source-code versioning only. + + The extension will auto-generate a version string by using generateVersionString(). + */ + const version = { + editorType: "TurboWarp", + versionNumber: 1, + versionString: "0.1.1", + }; + + // Store extension state + var clVars = { + + // Editor-specific variable for hiding old, legacy-support blocks. + hideCLDeprecatedBlocks: true, + + // WebSocket object. + socket: null, + + // Disable nags about old servers. + currentServerUrl: "", + lastServerUrl: "", + + // gmsg.queue - An array of all currently queued gmsg values. + // gmsg.varState - The value of the most recently received gmsg message. + // gmsg.hasNew - Returns true if a new gmsg value has been received. + gmsg: { + queue: [], + varState: "", + hasNew: false, + eventHatTick: false, + }, + + // pmsg.queue - An array of all currently queued pmsg values. + // pmsg.varState - The value of the most recently received pmsg message. + // pmsg.hasNew - Returns true if a new pmsg value has been received. + pmsg: { + queue: [], + varState: "", + hasNew: false, + eventHatTick: false, + }, + + // gvar.queue - An array of all currently queued gvar values. + // gvar.varStates - A dictionary storing each gvar variable. + gvar: { + queue: [], + varStates: {}, + eventHatTick: false, + }, + + // pvar.queue - An array of all currently queued pvar values. + // pvar.varStates - A dictionary storing each pvar variable. + pvar: { + queue: [], + varStates: {}, + eventHatTick: false, + }, + + // direct.queue - An array of all currently queued direct values. + // direct.varState - The value of the most recently received direct message. + // direct.hasNew - Returns true if a new direct value has been received. + direct: { + queue: [], + varState: "", + hasNew: false, + eventHatTick: false, + }, + + // statuscode.queue - An array of all currently queued statuscode values. + // statuscode.varState - The value of the most recently received statuscode message. + // statuscode.hasNew - Returns true if a new statuscode value has been received. + statuscode: { + queue: [], + varState: "", + hasNew: false, + eventHatTick: false, + }, + + // ulist stores all currently connected client objects in the server/all subscribed room(s). + ulist: [], + + // Message-Of-The-Day + motd: "", + + // Client IP address + client_ip: "", + + // Server version string + server_version: "", + + // listeners.enablerState - Set to true when "createListener" is used. + // listeners.enablerValue - Set to a new listener ID when "createListener" is used. + // listeners.current - Keeps track of all current listener IDs being awaited. + // listeners.varStates - Storage for all successfully awaited messages from specific listener IDs. + listeners: { + enablerState: false, + enablerValue: "", + current: [], + varStates: {}, + }, + + // rooms.enablerState - Set to true when "selectRoomsInNextPacket" is used. + // rooms.enablerValue - Set to a new list of rooms when "selectRoomsInNextPacket" is used. + // rooms.current - Keeps track of all current rooms being used. + // rooms.varStates - Storage for all per-room messages. + // rooms.isLinked - Set to true when a room link request is successful. False when unlinked. + // rooms.isAttemptingLink - Set to true when running "linkToRooms()". + // rooms.isAttemptingUnlink - Set to true when running "unlinkFromRooms()". + rooms: { + enablerState: false, + enablerValue: "", + isLinked: false, + isAttemptingLink: false, + isAttemptingUnlink: false, + current: [], + varStates: {}, + }, + + // Username state + username: { + attempted: false, + accepted: false, + temp: "", + value: "", + }, + + // Store user_obj messages. + myUserObject: {}, + + /* + linkState.status - Current state of the connection. + 0 - Ready + 1 - Connecting + 2 - Connected + 3 - Disconnected, gracefully (OK) + 4 - Disconnected, abruptly (Connection failed / dropped) + + linkState.isAttemptingGracefulDisconnect - Boolean used to ignore websocket codes when disconnecting. + + linkstate.disconnectType - Type of disconnect that has occurred. + 0 - Safely disconnected (connected OK and gracefully disconnected) + 1 - Connection dropped (connected OK but lost connection afterwards) + 2 - Connection failed (attempted connection but did not succeed) + + linkstate.identifiedProtocol - Enables backwards compatibility for CL servers. + 0 - CL3 0.1.5 "S2.2" - Doesn't support listeners, MOTD, or statuscodes. + 1 - CL3 0.1.7 - Doesn't support listeners, has early MOTD support, and early statuscode support. + 2 - CL4 0.1.8.x - First version to support listeners, and modern server_version support. First version to implement rooms support. + 3 - CL4 0.1.9.x - First version to implement the handshake command and better ulist events. + 4 - CL4 0.2.0 - Latest version. First version to implement client_obj and enhanced ulists. + */ + linkState: { + status: 0, + isAttemptingGracefulDisconnect: false, + disconnectType: 0, + identifiedProtocol: 0, + }, + + // Timeout of 500ms upon connection to try and handshake. Automatically aborted if server_version is received within that timespan. + handshakeTimeout: null, + + // Prevent accidentally sending the handshake command more than once per connection. + handshakeAttempted: false, + + // Storage for the publically available CloudLink instances. + serverList: {}, + } + + function generateVersionString() { + return `${version.editorType} ${version.versionString}`; } - function find_id(ID, ulist) { - // Thanks StackOverflow! - if (jsonCheck(ID) && !intCheck(ID)) { - return ulist.some( - (o) => - o.username === JSON.parse(ID).username && o.id == JSON.parse(ID).id - ); + // Makes values safe for Scratch to represent. + async function makeValueScratchSafe(data) { + if (typeof data == "object") { + try { + return JSON.stringify(data); + } catch (SyntaxError) { + return String(data); + } } else { - return ulist.some((o) => o.username === String(ID) || o.id == ID); + return String(data); } } - function jsonCheck(JSON_STRING) { + // Clears out and resets the various values of clVars upon disconnect. + function resetOnClose() { + window.clearTimeout(clVars.handshakeTimeout); + clVars.handshakeAttempted = false; + clVars.socket = null; + clVars.motd = ""; + clVars.client_ip = ""; + clVars.server_version = ""; + clVars.linkState.identifiedProtocol = 0; + clVars.linkState.isAttemptingGracefulDisconnect = false; + clVars.myUserObject = {}; + clVars.gmsg = { + queue: [], + varState: "", + hasNew: false, + eventHatTick: false, + }; + clVars.pmsg = { + queue: [], + varState: "", + hasNew: false, + eventHatTick: false, + }; + clVars.gvar = { + queue: [], + varStates: {}, + eventHatTick: false, + }; + clVars.pvar = { + queue: [], + varStates: {}, + eventHatTick: false, + }; + clVars.direct = { + queue: [], + varState: "", + hasNew: false, + eventHatTick: false, + }; + clVars.statuscode = { + queue: [], + varState: "", + hasNew: false, + eventHatTick: false, + }; + clVars.ulist = []; + clVars.listeners = { + enablerState: false, + enablerValue: "", + current: [], + varStates: {}, + }; + clVars.rooms = { + enablerState: false, + enablerValue: "", + isLinked: false, + isAttemptingLink: false, + isAttemptingUnlink: false, + current: [], + varStates: {}, + }; + clVars.username = { + attempted: false, + accepted: false, + temp: "", + value: "", + }; + } + + // CL-specific netcode needed for sending messages + async function sendMessage(message) { + // Prevent running this while disconnected + if (clVars.socket == null) { + console.warn("[CloudLink] Ignoring attempt to send a packet while disconnected."); + return; + } + + // See if the outgoing val argument can be converted into JSON + if (message.hasOwnProperty("val")) { + try { + message.val = JSON.parse(message.val); + } catch {} + } + + // Attach listeners + if (clVars.listeners.enablerState) { + + // 0.1.8.x was the first server version to support listeners. + if (clVars.linkState.identifiedProtocol >= 2) { + message.listener = clVars.listeners.enablerValue; + + // Create listener + clVars.listeners.varStates[String(args.ID)] = { + hasNew: false, + varState: {}, + eventHatTick: false, + }; + + } else { + console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support listeners."); + } + clVars.listeners.enablerState = false; + } + + // Check if server supports rooms + if (((message.cmd == "link") || (message.cmd == "unlink")) && (clVars.linkState.identifiedProtocol < 2)) { + // 0.1.8.x was the first server version to support rooms. + console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support room linking/unlinking."); + return; + } + + // Convert the outgoing message to JSON + let outgoing = ""; try { - JSON.parse(JSON_STRING); - return true; - } catch (err) { - return false; + outgoing = JSON.stringify(message); + } catch (SyntaxError) { + console.warn("[CloudLink] Failed to send a packet, invalid syntax:", message); + return; } + + // Send the message + console.log("[CloudLink] TX:", message); + clVars.socket.send(outgoing); } - function intCheck(value) { - return !isNaN(value); + // Only sends the handshake command. + function sendHandshake() { + if (clVars.handshakeAttempted) return; + console.log("[CloudLink] Sending handshake..."); + sendMessage({ + cmd: "handshake", + val: { + language: "Scratch", + version: { + editorType: version.editorType, + versionNumber: version.versionNumber, + }, + }, + listener: "handshake_cfg" + }); + clVars.handshakeAttempted = true; } - function autoConvert(value) { - // Check if the value is JSON / Dict first - try { - JSON.parse(value); - return JSON.parse(value); - } catch (err) {} + // Compare the version string of the server to known compatible variants to configure clVars.linkState.identifiedProtocol. + async function setServerVersion(version) { + console.log(`[CloudLink] Server version: ${String(version)}`); + clVars.server_version = version; + + // Auto-detect versions + const versions = { + "0.2.": 4, + "0.1.9": 3, + "0.1.8": 2, + "0.1.7": 1, + "0.1.5": 0, + "S2.2": 0, // 0.1.5 + "0.1.": 0, // 0.1.5 or legacy + "S2.": 0, // Legacy + "S1.": -1 // Obsolete + }; + + for (const [key, value] of Object.entries(versions)) { + if (version.includes(key)) { + if (clVars.linkState.identifiedProtocol < value) { + + // Disconnect if protcol is too old + if (value == -1) { + console.warn(`[CloudLink] Server is too old to enable leagacy support. Disconnecting.`); + return clVars.socket.close(1000, ""); + } + + // Set the identified protocol variant + clVars.linkState.identifiedProtocol = value; + } + } + }; + + // Log configured spec version + console.log(`[CloudLink] Configured protocol spec to v${clVars.linkState.identifiedProtocol}.`); + + // Don't nag user if they already trusted this server + if (clVars.currentServerUrl === clVars.lastServerUrl) return; + + // Ask user if they wish to stay connected if the server is unsupported + if ((clVars.linkState.identifiedProtocol < 4) && (!confirm( + `You have connected to an old CloudLink server, running version ${clVars.server_version}.\n\nFor your security and privacy, we recommend you disconnect from this server and connect to an up-to-date server.\n\nClick/tap \"OK\" to stay connected.` + ))) { + // Close the connection if they choose "Cancel" + clVars.linkState.isAttemptingGracefulDisconnect = true; + clVars.socket.close(1000, "Client going away (legacy server rejected by end user)"); + return; + } + + // Don't nag user the next time they connect to this server + clVars.lastServerUrl = clVars.currentServerUrl; + } - // Check if the value is an array + // CL-specific netcode needed to make the extension work + async function handleMessage(data) { + // Parse the message JSON + let packet = {}; try { - tmp = value; - tmp = tmp.replace(/'/g, '"'); - JSON.parse(tmp); - return JSON.parse(tmp); - } catch (err) {} + packet = JSON.parse(data) + } catch (SyntaxError) { + console.error("[CloudLink] Incoming message parse failure! Is this really a CloudLink server?", data); + return; + }; + + // Handle packet commands + if (!packet.hasOwnProperty("cmd")) { + console.error("[CloudLink] Incoming message read failure! This message doesn't contain the required \"cmd\" key. Is this really a CloudLink server?", packet); + return; + } + console.log("[CloudLink] RX:", packet); + switch (packet.cmd) { + case "gmsg": + clVars.gmsg.varState = packet.val; + clVars.gmsg.hasNew = true; + clVars.gmsg.queue.push(packet); + clVars.gmsg.eventHatTick = true; + break; + + case "pmsg": + clVars.pmsg.varState = packet.val; + clVars.pmsg.hasNew = true; + clVars.pmsg.queue.push(packet); + clVars.pmsg.eventHatTick = true; + break; + + case "gvar": + clVars.gvar.varStates[String(packet.name)] = { + hasNew: true, + varState: packet.val, + eventHatTick: true, + }; + clVars.gvar.queue.push(packet); + clVars.gvar.eventHatTick = true; + break; + + case "pvar": + clVars.pvar.varStates[String(packet.name)] = { + hasNew: true, + varState: packet.val, + eventHatTick: true, + }; + clVars.pvar.queue.push(packet); + clVars.pvar.eventHatTick = true; + break; + + case "direct": + // Handle events from older server versions + if (packet.val.hasOwnProperty("cmd")) { + switch (packet.val.cmd) { + // Server 0.1.5 (at least) + case "vers": + window.clearTimeout(clVars.handshakeTimeout); + setServerVersion(packet.val.val); + return; + + // Server 0.1.7 (at least) + case "motd": + console.log(`[CloudLink] Message of the day: \"${packet.val.val}\"`); + clVars.motd = packet.val.val; + return; + } + } + + // Store direct value + clVars.direct.varState = packet.val; + clVars.direct.hasNew = true; + clVars.direct.queue.push(packet); + clVars.direct.eventHatTick = true; + break; + + case "client_obj": + console.log("[CloudLink] Client object for this session:", packet.val); + clVars.myUserObject = packet.val; + break; + + case "statuscode": + // Store direct value + // Protocol v0 (0.1.5 and legacy) don't implement status codes. + if (clVars.linkState.identifiedProtocol == 0) { + console.warn("[CloudLink] Received a statuscode message while using protocol v0. This event shouldn't happen. It's likely that this server is modified (did MikeDEV overlook some unexpected behavior?)."); + return; + } + + // Protocol v1 (0.1.7) uses "val" to represent the code. + else if (clVars.linkState.identifiedProtocol == 1) { + clVars.statuscode.varState = packet.val; + } + + // Protocol v2 (0.1.8.x) uses "code" instead. + // Protocol v3-v4 (0.1.9.x - latest, 0.2.0) adds "code_id" to the payload. Ignored by Scratch clients. + else { + + // Handle setup listeners + if (packet.hasOwnProperty("listener")) { + switch (packet.listener) { + case "username_cfg": + + // Username accepted + if (packet.code.includes("I:100")) { + clVars.myUserObject = packet.val; + clVars.username.value = packet.val.username; + clVars.username.accepted = true; + console.log(`[CloudLink] Username has been set to \"${clVars.username.value}\" successfully!`); + + // Username rejected / error + } else { + console.log(`[CloudLink] Username rejected by the server! Error code ${packet.code}.}`); + } + return; + + case "handshake_cfg": + // Prevent handshake responses being stored in the statuscode variables + console.log("[CloudLink] Server responded to our handshake!"); + return; + + case "link": + // Room link accepted + if (!clVars.rooms.isAttemptingLink) return; + if (packet.code.includes("I:100")) { + clVars.rooms.isAttemptingLink = false; + clVars.rooms.isLinked = true; + console.log("[CloudLink] Room linked successfully!"); + + // Room link rejected / error + } else { + console.log(`[CloudLink] Room link rejected! Error code ${packet.code}.}`); + } + return; + + case "unlink": + // Room unlink accepted + if (!clVars.rooms.isAttemptingUnlink) return; + if (packet.code.includes("I:100")) { + clVars.rooms.isAttemptingUnlink = false; + clVars.rooms.isLinked = false; + console.log("[CloudLink] Room unlinked successfully!"); + + // Room link rejected / error + } else { + console.log(`[CloudLink] Room unlink rejected! Error code ${packet.code}.}`); + } + return; + } + } + + // Update state + clVars.statuscode.varState = packet.code; + } + + // Update state + clVars.statuscode.hasNew = true; + clVars.statuscode.queue.push(packet); + clVars.statuscode.eventHatTick = true; + break; + + case "ulist": + // Protocol v0-v1 (0.1.5 and legacy - 0.1.7) use a semicolon (;) separated string for the userlist. + if ( + (clVars.linkState.identifiedProtocol == 0) + || + (clVars.linkState.identifiedProtocol == 1) + ) { + // Split the username list string + clVars.ulist = String(packet.val).split(';'); + + // Get rid of blank entry at the end of the list + clVars.ulist.pop(clVars.ulist.length); + + // Check if username has been set (since older servers don't implement statuscodes or listeners) + if ((clVars.username.attempted) && (clVars.ulist.includes(clVars.username.temp))) { + clVars.username.value = clVars.username.temp; + clVars.username.accepted = true; + console.log(`[CloudLink] Username has been set to \"${clVars.username.value}\" successfully!`); + } + } + + // Protocol v2 (0.1.8.x) uses a list of objects w/ "username" and "id" instead. + else if (clVars.linkState.identifiedProtocol == 2) { + clVars.ulist = packet.val; + } + + // Protocol v3-v4 (0.1.9.x - latest, 0.2.0) uses "mode" to add/set/remove entries to the userlist. + else { + // Check for "mode" key + if (!packet.hasOwnProperty("mode")) { + console.warn("[CloudLink] Userlist message did not specify \"mode\" while running in protocol mode 3 or 4."); + return; + }; + // Handle methods + switch (packet.mode) { + case 'set': + clVars.ulist = packet.val; + break; + case 'add': + clVars.ulist.push(packet.val); + break; + case 'remove': + clVars.ulist.slice(clVars.ulist.indexOf(packet.val), 1); + break; + default: + console.warn(`[CloudLink] Unrecognised userlist mode: \"${packet.mode}\".`); + break; + } + } + + console.log("[CloudLink] Updating userlist:", clVars.ulist); + break; + + case "server_version": + window.clearTimeout(clVars.handshakeTimeout); + setServerVersion(packet.val); + break; + + case "client_ip": + console.log(`[CloudLink] Client IP address: ${packet.val}`); + console.warn("[CloudLink] This server has relayed your identified IP address to you. Under normal circumstances, this will be erased server-side when you disconnect, but you should still be careful. Unless you trust this server, it is not recommended to send login credentials or personal info."); + clVars.client_ip = packet.val; + break; - // Check if an int/float - if (!isNaN(value)) { - return Number(value); + case "motd": + console.log(`[CloudLink] Message of the day: \"${packet.val}\"`); + clVars.motd = packet.val; + break; + + default: + console.warn(`[CloudLink] Unrecognised command: \"${packet.cmd}\".`); + return; } - // Leave as the original value if none of the above work - return value; + // Handle listeners + if (packet.hasOwnProperty("listener")) { + if (clVars.listeners.current.includes(String(packet.listener))) { + + // Remove the listener from the currently listening list + clVars.listeners.current.splice( + clVars.listeners.current.indexOf(String(packet.listener)), + 1 + ); + + // Update listener states + clVars.listeners.varStates[String(packet.listener)] = { + hasNew: true, + varState: packet, + eventHatTick: true, + }; + } + } } - class CloudLink { - constructor(runtime, extensionId) { - // Extension stuff - this.runtime = runtime; - this.cl_icon = - "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIyMjUuMzU0OCIgaGVpZ2h0PSIyMjUuMzU0OCIgdmlld0JveD0iMCwwLDIyNS4zNTQ4LDIyNS4zNTQ4Ij48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTI3LjMyMjYsLTY3LjMyMjYpIj48ZyBkYXRhLXBhcGVyLWRhdGE9InsmcXVvdDtpc1BhaW50aW5nTGF5ZXImcXVvdDs6dHJ1ZX0iIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9ImJ1dHQiIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgc3Ryb2tlLWRhc2hhcnJheT0iIiBzdHJva2UtZGFzaG9mZnNldD0iMCIgc3R5bGU9Im1peC1ibGVuZC1tb2RlOiBub3JtYWwiPjxwYXRoIGQ9Ik0xMjcuMzIyNiwxODBjMCwtNjIuMjMwMDEgNTAuNDQ3MzksLTExMi42Nzc0IDExMi42Nzc0LC0xMTIuNjc3NGM2Mi4yMzAwMSwwIDExMi42Nzc0LDUwLjQ0NzM5IDExMi42Nzc0LDExMi42Nzc0YzAsNjIuMjMwMDEgLTUwLjQ0NzM5LDExMi42Nzc0IC0xMTIuNjc3NCwxMTIuNjc3NGMtNjIuMjMwMDEsMCAtMTEyLjY3NzQsLTUwLjQ0NzM5IC0xMTIuNjc3NCwtMTEyLjY3NzR6IiBmaWxsPSIjMDBjMjhjIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHN0cm9rZS13aWR0aD0iMCIvPjxnIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlLXdpZHRoPSIxIj48cGF0aCBkPSJNMjg2LjEyMDM3LDE1MC41NTc5NWMyMy4yNDA4NiwwIDQyLjA3ODksMTguODM5NDYgNDIuMDc4OSw0Mi4wNzg5YzAsMjMuMjM5NDQgLTE4LjgzODAzLDQyLjA3ODkgLTQyLjA3ODksNDIuMDc4OWgtOTIuMjQwNzRjLTIzLjI0MDg2LDAgLTQyLjA3ODksLTE4LjgzOTQ2IC00Mi4wNzg5LC00Mi4wNzg5YzAsLTIzLjIzOTQ0IDE4LjgzODAzLC00Mi4wNzg5IDQyLjA3ODksLTQyLjA3ODloNC4xODg4N2MxLjgxMTUzLC0yMS41NzA1NSAxOS44OTM1NywtMzguNTEyODkgNDEuOTMxNSwtMzguNTEyODljMjIuMDM3OTMsMCA0MC4xMTk5NywxNi45NDIzNCA0MS45MzE1LDM4LjUxMjg5eiIgZmlsbD0iI2ZmZmZmZiIvPjxwYXRoIGQ9Ik0yODkuMDg2NTUsMjEwLjM0MTE0djkuMDQ2NjdoLTI2LjkxNjYzaC05LjA0NjY3di05LjA0NjY3di01NC41MDMzOWg5LjA0NjY3djU0LjUwMzM5eiIgZmlsbD0iIzAwYzI4YyIvPjxwYXRoIGQ9Ik0yMjIuNDA5MjUsMjE5LjM4NzgxYy04LjM1MzIsMCAtMTYuMzY0MzEsLTMuMzE4MzQgLTIyLjI3MDksLTkuMjI0OTJjLTUuOTA2NjEsLTUuOTA2NTggLTkuMjI0OTEsLTEzLjkxNzY4IC05LjIyNDkxLC0yMi4yNzA4OWMwLC04LjM1MzIgMy4zMTgyOSwtMTYuMzY0MzEgOS4yMjQ5MSwtMjIuMjcwOWM1LjkwNjU5LC01LjkwNjYxIDEzLjkxNzcsLTkuMjI0OTEgMjIuMjcwOSwtOS4yMjQ5MWgyMS4xMDg5djguOTM0OThoLTIxLjEwODl2MC4xMDI1N2MtNS45NTYyOCwwIC0xMS42Njg2NCwyLjM2NjE2IC0xNS44ODAzNyw2LjU3Nzg5Yy00LjIxMTczLDQuMjExNzMgLTYuNTc3ODksOS45MjQwOCAtNi41Nzc4OSwxNS44ODAzN2MwLDUuOTU2MjggMi4zNjYxNiwxMS42Njg2NCA2LjU3Nzg5LDE1Ljg4MDM3YzQuMjExNzMsNC4yMTE3MyA5LjkyNDA4LDYuNTc3OTMgMTUuODgwMzcsNi41Nzc5M3YwLjEwMjUzaDIxLjEwODl2OC45MzQ5OHoiIGZpbGw9IiMwMGMyOGMiLz48L2c+PC9nPjwvZz48L3N2Zz48IS0tcm90YXRpb25DZW50ZXI6MTEyLjY3NzQwNDA4NDA4MzkyOjExMi42Nzc0MDQwODQwODQwMy0tPg=="; - this.cl_block = - "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIxNzYuMzk4NTQiIGhlaWdodD0iMTIyLjY3MDY5IiB2aWV3Qm94PSIwLDAsMTc2LjM5ODU0LDEyMi42NzA2OSI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE1MS44MDA3MywtMTE4LjY2NDY2KSI+PGcgZGF0YS1wYXBlci1kYXRhPSJ7JnF1b3Q7aXNQYWludGluZ0xheWVyJnF1b3Q7OnRydWV9IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtZGFzaGFycmF5PSIiIHN0cm9rZS1kYXNob2Zmc2V0PSIwIiBzdHlsZT0ibWl4LWJsZW5kLW1vZGU6IG5vcm1hbCI+PGc+PHBhdGggZD0iTTI4Ni4xMjAzNywxNTcuMTc3NTVjMjMuMjQwODYsMCA0Mi4wNzg5LDE4LjgzOTQ2IDQyLjA3ODksNDIuMDc4OWMwLDIzLjIzOTQ0IC0xOC44MzgwMyw0Mi4wNzg5IC00Mi4wNzg5LDQyLjA3ODloLTkyLjI0MDc0Yy0yMy4yNDA4NiwwIC00Mi4wNzg5LC0xOC44Mzk0NiAtNDIuMDc4OSwtNDIuMDc4OWMwLC0yMy4yMzk0NCAxOC44MzgwMywtNDIuMDc4OSA0Mi4wNzg5LC00Mi4wNzg5aDQuMTg4ODdjMS44MTE1MywtMjEuNTcwNTUgMTkuODkzNTcsLTM4LjUxMjg5IDQxLjkzMTUsLTM4LjUxMjg5YzIyLjAzNzkzLDAgNDAuMTE5OTcsMTYuOTQyMzQgNDEuOTMxNSwzOC41MTI4OXoiIGZpbGw9IiNmZmZmZmYiLz48cGF0aCBkPSJNMjg5LjA4NjU1LDIxNi45NjA3NHY5LjA0NjY3aC0yNi45MTY2M2gtOS4wNDY2N3YtOS4wNDY2N3YtNTQuNTAzMzloOS4wNDY2N3Y1NC41MDMzOXoiIGZpbGw9IiMwMGMyOGMiLz48cGF0aCBkPSJNMjIyLjQwOTI1LDIyNi4wMDc0MWMtOC4zNTMyLDAgLTE2LjM2NDMxLC0zLjMxODM0IC0yMi4yNzA5LC05LjIyNDkyYy01LjkwNjYxLC01LjkwNjU4IC05LjIyNDkxLC0xMy45MTc2OCAtOS4yMjQ5MSwtMjIuMjcwODljMCwtOC4zNTMyIDMuMzE4MjksLTE2LjM2NDMxIDkuMjI0OTEsLTIyLjI3MDljNS45MDY1OSwtNS45MDY2MSAxMy45MTc3LC05LjIyNDkxIDIyLjI3MDksLTkuMjI0OTFoMjEuMTA4OXY4LjkzNDk4aC0yMS4xMDg5djAuMTAyNTdjLTUuOTU2MjgsMCAtMTEuNjY4NjQsMi4zNjYxNiAtMTUuODgwMzcsNi41Nzc4OWMtNC4yMTE3Myw0LjIxMTczIC02LjU3Nzg5LDkuOTI0MDggLTYuNTc3ODksMTUuODgwMzdjMCw1Ljk1NjI4IDIuMzY2MTYsMTEuNjY4NjQgNi41Nzc4OSwxNS44ODAzN2M0LjIxMTczLDQuMjExNzMgOS45MjQwOCw2LjU3NzkzIDE1Ljg4MDM3LDYuNTc3OTN2MC4xMDI1M2gyMS4xMDg5djguOTM0OTh6IiBmaWxsPSIjMDBjMjhjIi8+PC9nPjwvZz48L2c+PC9zdmc+PCEtLXJvdGF0aW9uQ2VudGVyOjg4LjE5OTI2OTk5OTk5OTk4OjYxLjMzNTM0NDk5OTk5OTk5LS0+"; - - // Socket data - this.socketData = { - gmsg: [], - pmsg: [], - direct: [], - statuscode: [], - gvar: [], - pvar: [], - motd: "", - client_ip: "", - ulist: [], - server_version: "", - }; - this.varData = { - gvar: {}, - pvar: {}, - }; + // Basic netcode needed to make the extension work + async function newClient(url) { + if (!(await Scratch.canFetch(url))) { + console.warn("[CloudLink] Did not get permission to connect, aborting..."); + return; + } - this.queueableCmds = [ - "gmsg", - "pmsg", - "gvar", - "pvar", - "direct", - "statuscode", - ]; - this.varCmds = ["gvar", "pvar"]; - - // Listeners - this.socketListeners = {}; - this.socketListenersData = {}; - this.newSocketData = { - gmsg: false, - pmsg: false, - direct: false, - statuscode: false, - gvar: false, - pvar: false, - }; + // Set the link state to connecting + clVars.linkState.status = 1; + clVars.linkState.disconnectType = 0; - // Edge-triggered hat blocks - this.connect_hat = 0; - this.packet_hat = 0; - this.close_hat = 0; - - // Status stuff - this.isRunning = false; - this.isLinked = false; - this.version = "S4.0"; - this.link_status = 0; - this.username = ""; - this.tmp_username = ""; - this.isUsernameSyncing = false; - this.isUsernameSet = false; - this.disconnectWasClean = false; - this.wasConnectionDropped = false; - this.didConnectionFail = false; - this.protocolOk = false; - - // Listeners stuff - this.enableListener = false; - this.setListener = ""; - - // Rooms stuff - this.enableRoom = false; - this.isRoomSetting = false; - this.selectRoom = ""; - - // Remapping stuff - this.menuRemap = { - "Global data": "gmsg", - "Private data": "pmsg", - "Global variables": "gvar", - "Private variables": "pvar", - "Direct data": "direct", - "Status code": "statuscode", - "All data": "all", - }; + // Establish a connection to the server + console.log("[CloudLink] Connecting to server:", url); + try { + clVars.socket = new WebSocket(url); + } catch (e) { + console.warn("[CloudLink] An exception has occurred:", e); + return; + } + + // Bind connection established event + clVars.socket.onopen = function (event) { + clVars.currentServerUrl = url; + + // Set the link state to connected. + console.log("[CloudLink] Connected."); + + clVars.linkState.status = 2; + + // If a server_version message hasn't been received in over half a second, try to broadcast a handshake + clVars.handshakeTimeout = window.setTimeout(function() { + console.log("[CloudLink] Hmm... This server hasn't sent us it's server info. Going to attempt a handshake."); + sendHandshake(); + }, 500); + + // Fire event hats (only one not broken) + runtime.startHats('cloudlink_onConnect'); + + // Return promise (during setup) + return; + }; + + // Bind message handler event + clVars.socket.onmessage = function (event) { + handleMessage(event.data); + }; + + // Bind connection closed event + clVars.socket.onclose = function (event) { + switch (clVars.linkState.status) { + case 1: // Was connecting + // Set the link state to ungraceful disconnect. + console.log(`[CloudLink] Connection failed (${event.code}).`); + clVars.linkState.status = 4; + clVars.linkState.disconnectType = 1; + break; + + case 2: // Was already connected + if (event.wasClean || clVars.linkState.isAttemptingGracefulDisconnect) { + // Set the link state to graceful disconnect. + console.log(`[CloudLink] Disconnected (${event.code} ${event.reason}).`); + clVars.linkState.status = 3; + clVars.linkState.disconnectType = 0; + } else { + // Set the link state to ungraceful disconnect. + console.log(`[CloudLink] Lost connection (${event.code} ${event.reason}).`); + clVars.linkState.status = 4; + clVars.linkState.disconnectType = 2; + } + break; + } + + // Reset clVars values + resetOnClose(); + + // Run all onClose event blocks + runtime.startHats('cloudlink_onClose'); + // Return promise (during setup) + return; } + } + + // GET the serverList + try { + Scratch.fetch( + "https://mikedev101.github.io/cloudlink/serverlist.json" + ) + .then((response) => { + return response.text(); + }) + .then((data) => { + clVars.serverList = JSON.parse(data); + }) + .catch((err) => { + console.log("[CloudLink] An error has occurred while parsing the public server list:", err); + clVars.serverList = {}; + }); + } catch (err) { + console.log("[CloudLink] An error has occurred while fetching the public server list:", err); + clVars.serverList = {}; + } + // Declare the CloudLink library. + class CloudLink { getInfo() { return { - id: "cloudlink", - name: "CloudLink", - blockIconURI: this.cl_block, - menuIconURI: this.cl_icon, - docsURI: "https://hackmd.io/@MikeDEV/HJiNYwOfo", + id: 'cloudlink', + name: 'CloudLink', + blockIconURI: cl_block, + menuIconURI: cl_icon, + docsURI: "https://github.com/MikeDev101/cloudlink/wiki/Scratch-Client", blocks: [ + { opcode: "returnGlobalData", - blockType: "reporter", - text: "Global data", + blockType: Scratch.BlockType.REPORTER, + text: "Global data" }, + { opcode: "returnPrivateData", - blockType: "reporter", - text: "Private data", + blockType: Scratch.BlockType.REPORTER, + text: "Private data" }, + { opcode: "returnDirectData", - blockType: "reporter", - text: "Direct Data", + blockType: Scratch.BlockType.REPORTER, + text: "Direct data" }, + + "---", + { opcode: "returnLinkData", - blockType: "reporter", - text: "Link status", + blockType: Scratch.BlockType.REPORTER, + text: "Link status" }, + { opcode: "returnStatusCode", - blockType: "reporter", - text: "Status code", + blockType: Scratch.BlockType.REPORTER, + text: "Status code" }, + + "---", + { opcode: "returnUserListData", - blockType: "reporter", - text: "Usernames", + blockType: Scratch.BlockType.REPORTER, + text: "Usernames" }, + { opcode: "returnUsernameData", - blockType: "reporter", - text: "My username", + blockType: Scratch.BlockType.REPORTER, + text: "My username" }, + + "---", + { opcode: "returnVersionData", - blockType: "reporter", - text: "Extension version", + blockType: Scratch.BlockType.REPORTER, + text: "Extension version" }, + { opcode: "returnServerVersion", - blockType: "reporter", - text: "Server version", + blockType: Scratch.BlockType.REPORTER, + text: "Server version" }, + { opcode: "returnServerList", - blockType: "reporter", - text: "Server list", + blockType: Scratch.BlockType.REPORTER, + text: "Server list" }, + { opcode: "returnMOTD", - blockType: "reporter", - text: "Server MOTD", + blockType: Scratch.BlockType.REPORTER, + text: "Server MOTD" }, + + "---", + { opcode: "returnClientIP", - blockType: "reporter", - text: "My IP address", + blockType: Scratch.BlockType.REPORTER, + text: "My IP address" + }, + + { + opcode: "returnUserObject", + blockType: Scratch.BlockType.REPORTER, + text: "My user object" }, + + "---", + { opcode: "returnListenerData", - blockType: "reporter", + blockType: Scratch.BlockType.REPORTER, text: "Response for listener [ID]", arguments: { ID: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "example-listener", }, }, }, + { opcode: "readQueueSize", - blockType: "reporter", + blockType: Scratch.BlockType.REPORTER, + hideFromPalette: clVars.hideCLDeprecatedBlocks, text: "Size of queue for [TYPE]", arguments: { TYPE: { - type: "string", + type: Scratch.ArgumentType.STRING, menu: "allmenu", defaultValue: "All data", }, }, }, + { opcode: "readQueueData", - blockType: "reporter", + blockType: Scratch.BlockType.REPORTER, + hideFromPalette: clVars.hideCLDeprecatedBlocks, text: "Packet queue for [TYPE]", arguments: { TYPE: { - type: "string", + type: Scratch.ArgumentType.STRING, menu: "allmenu", defaultValue: "All data", }, }, }, + + "---", + { opcode: "returnVarData", - blockType: "reporter", + blockType: Scratch.BlockType.REPORTER, text: "[TYPE] [VAR] data", arguments: { VAR: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Apple", }, TYPE: { - type: "string", + type: Scratch.ArgumentType.STRING, menu: "varmenu", defaultValue: "Global variables", }, }, }, + + "---", + { opcode: "parseJSON", - blockType: "reporter", + blockType: Scratch.BlockType.REPORTER, + hideFromPalette: clVars.hideCLDeprecatedBlocks, text: "[PATH] of [JSON_STRING]", arguments: { PATH: { - type: "string", - defaultValue: "fruit/apples", + type: Scratch.ArgumentType.STRING, + defaultValue: 'fruit/apples', }, JSON_STRING: { - type: "string", - defaultValue: - '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', + type: Scratch.ArgumentType.STRING, + defaultValue: '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', }, }, }, + { opcode: "getFromJSONArray", - blockType: "reporter", - text: "Get [NUM] from JSON array [ARRAY]", + blockType: Scratch.BlockType.REPORTER, + hideFromPalette: clVars.hideCLDeprecatedBlocks, + text: 'Get [NUM] from JSON array [ARRAY]', arguments: { NUM: { - type: "number", + type: Scratch.ArgumentType.NUMBER, defaultValue: 0, }, ARRAY: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: '["foo","bar"]', + } + } + }, + + { + opcode: "makeJSON", + blockType: Scratch.BlockType.REPORTER, + hideFromPalette: clVars.hideCLDeprecatedBlocks, + text: "Convert [toBeJSONified] to JSON", + arguments: { + toBeJSONified: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"test": true}', + }, + }, + }, + + { + opcode: "isValidJSON", + blockType: Scratch.BlockType.BOOLEAN, + hideFromPalette: clVars.hideCLDeprecatedBlocks, + text: "Is [JSON_STRING] valid JSON?", + arguments: { + JSON_STRING: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', }, }, }, + + + "---", + { opcode: "fetchURL", - blockType: "reporter", - blockAllThreads: "true", + blockType: Scratch.BlockType.REPORTER, + hideFromPalette: clVars.hideCLDeprecatedBlocks, text: "Fetch data from URL [url]", arguments: { url: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "https://extensions.turbowarp.org/hello.txt", }, }, }, + { opcode: "requestURL", - blockType: "reporter", - blockAllThreads: "true", + blockType: Scratch.BlockType.REPORTER, + hideFromPalette: clVars.hideCLDeprecatedBlocks, text: "Send request with method [method] for URL [url] with data [data] and headers [headers]", arguments: { method: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "GET", }, url: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "https://extensions.turbowarp.org/hello.txt", }, data: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "{}", }, headers: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "{}", }, }, }, - { - opcode: "makeJSON", - blockType: "reporter", - text: "Convert [toBeJSONified] to JSON", - arguments: { - toBeJSONified: { - type: "string", - defaultValue: '{"test": true}', - }, - }, - }, + + "---", + { opcode: "onConnect", - blockType: "hat", + blockType: Scratch.BlockType.EVENT, text: "When connected", - blockAllThreads: "true", + isEdgeActivated: false, // Gets called by runtime.startHats }, + { opcode: "onClose", - blockType: "hat", + blockType: Scratch.BlockType.EVENT, text: "When disconnected", - blockAllThreads: "true", + isEdgeActivated: false, // Gets called by runtime.startHats }, + + "---", + { opcode: "onListener", - blockType: "hat", - text: "When I receive new packet with listener [ID]", - blockAllThreads: "true", + blockType: Scratch.BlockType.HAT, + text: "When I receive new message with listener [ID]", + isEdgeActivated: true, arguments: { ID: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "example-listener", }, }, }, + { opcode: "onNewPacket", - blockType: "hat", - text: "When I receive new [TYPE] packet", - blockAllThreads: "true", + blockType: Scratch.BlockType.HAT, + text: "When I receive new [TYPE] message", + isEdgeActivated: true, arguments: { TYPE: { - type: "string", + type: Scratch.ArgumentType.STRING, menu: "almostallmenu", defaultValue: "Global data", }, }, }, + { opcode: "onNewVar", - blockType: "hat", + blockType: Scratch.BlockType.HAT, text: "When I receive new [TYPE] data for [VAR]", - blockAllThreads: "true", + isEdgeActivated: true, arguments: { TYPE: { - type: "string", + type: Scratch.ArgumentType.STRING, menu: "varmenu", defaultValue: "Global variables", }, VAR: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Apple", }, }, }, + + "---", + { opcode: "getComState", - blockType: "Boolean", + blockType: Scratch.BlockType.BOOLEAN, text: "Connected?", }, + { opcode: "getRoomState", - blockType: "Boolean", + blockType: Scratch.BlockType.BOOLEAN, text: "Linked to rooms?", }, + { opcode: "getComLostConnectionState", - blockType: "Boolean", + blockType: Scratch.BlockType.BOOLEAN, text: "Lost connection?", }, + { opcode: "getComFailedConnectionState", - blockType: "Boolean", + blockType: Scratch.BlockType.BOOLEAN, text: "Failed to connnect?", }, + { opcode: "getUsernameState", - blockType: "Boolean", + blockType: Scratch.BlockType.BOOLEAN, text: "Username synced?", }, + { opcode: "returnIsNewData", - blockType: "Boolean", + blockType: Scratch.BlockType.BOOLEAN, text: "Got New [TYPE]?", arguments: { TYPE: { - type: "string", + type: Scratch.ArgumentType.STRING, menu: "datamenu", defaultValue: "Global data", }, }, }, + { opcode: "returnIsNewVarData", - blockType: "Boolean", + blockType: Scratch.BlockType.BOOLEAN, text: "Got New [TYPE] data for variable [VAR]?", arguments: { TYPE: { - type: "string", + type: Scratch.ArgumentType.STRING, menu: "varmenu", - defaultValue: "Global variables", + defaultValue: 'Global variables', }, VAR: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Apple", }, }, }, + { opcode: "returnIsNewListener", - blockType: "Boolean", + blockType: Scratch.BlockType.BOOLEAN, text: "Got new packet with listener [ID]?", - blockAllThreads: "true", arguments: { ID: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "example-listener", }, }, }, + { opcode: "checkForID", - blockType: "Boolean", + blockType: Scratch.BlockType.BOOLEAN, text: "ID [ID] connected?", arguments: { ID: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Another name", }, }, }, - { - opcode: "isValidJSON", - blockType: "Boolean", - text: "Is [JSON_STRING] valid JSON?", - arguments: { - JSON_STRING: { - type: "string", - defaultValue: - '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', - }, - }, - }, + + "---", + { opcode: "openSocket", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Connect to [IP]", - blockAllThreads: "true", arguments: { IP: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "ws://127.0.0.1:3000/", - }, - }, + } + } }, + { opcode: "openSocketPublicServers", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Connect to server [ID]", - blockAllThreads: "true", arguments: { ID: { - type: "number", - defaultValue: "", - }, - }, + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1, + } + } }, + { opcode: "closeSocket", - blockType: "command", - blockAllThreads: "true", - text: "Disconnect", + blockType: Scratch.BlockType.COMMAND, + text: "Disconnect" }, + + "---", + { opcode: "setMyName", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Set [NAME] as username", - blockAllThreads: "true", arguments: { NAME: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "A name", }, }, }, + + "---", + { opcode: "createListener", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Attach listener [ID] to next packet", - blockAllThreads: "true", arguments: { ID: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "example-listener", }, }, + }, + + "---", + { - opcode: "linkToRooms", - blockType: "command", + opcode: 'linkToRooms', + blockType: Scratch.BlockType.COMMAND, text: "Link to room(s) [ROOMS]", - blockAllThreads: "true", arguments: { ROOMS: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: '["test"]', }, - }, + } }, + { opcode: "selectRoomsInNextPacket", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Select room(s) [ROOMS] for next packet", - blockAllThreads: "true", arguments: { ROOMS: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: '["test"]', }, }, }, + { opcode: "unlinkFromRooms", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Unlink from all rooms", - blockAllThreads: "true", }, + + "---", + { opcode: "sendGData", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Send [DATA]", - blockAllThreads: "true", arguments: { DATA: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Apple", }, }, }, + { opcode: "sendPData", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Send [DATA] to [ID]", - blockAllThreads: "true", arguments: { DATA: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Apple", }, ID: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Another name", }, }, }, + { opcode: "sendGDataAsVar", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Send variable [VAR] with data [DATA]", - blockAllThreads: "true", arguments: { DATA: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Banana", }, VAR: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Apple", }, }, }, + { opcode: "sendPDataAsVar", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Send variable [VAR] to [ID] with data [DATA]", - blockAllThreads: "true", arguments: { DATA: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Banana", }, ID: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Another name", }, VAR: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Apple", }, }, }, + + "---", + { opcode: "runCMDnoID", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, + hideFromPalette: clVars.hideCLDeprecatedBlocks, text: "Send command without ID [CMD] [DATA]", - blockAllThreads: "true", arguments: { CMD: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "direct", }, DATA: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "val", }, }, }, + { opcode: "runCMD", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, + hideFromPalette: clVars.hideCLDeprecatedBlocks, text: "Send command [CMD] [ID] [DATA]", - blockAllThreads: "true", arguments: { CMD: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "direct", }, ID: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "id", }, DATA: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "val", }, }, }, + + "---", + { opcode: "resetNewData", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Reset got new [TYPE] status", - blockAllThreads: "true", arguments: { TYPE: { - type: "string", + type: Scratch.ArgumentType.STRING, menu: "datamenu", defaultValue: "Global data", }, }, }, + { opcode: "resetNewVarData", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Reset got new [TYPE] [VAR] status", - blockAllThreads: "true", arguments: { TYPE: { - type: "string", + type: Scratch.ArgumentType.STRING, menu: "varmenu", defaultValue: "Global variables", }, VAR: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "Apple", }, }, }, + + "---", + { opcode: "resetNewListener", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Reset got new [ID] listener status", - blockAllThreads: "true", arguments: { ID: { - type: "string", + type: Scratch.ArgumentType.STRING, defaultValue: "example-listener", }, }, }, + + "---", + { opcode: "clearAllPackets", - blockType: "command", + blockType: Scratch.BlockType.COMMAND, text: "Clear all packets for [TYPE]", arguments: { TYPE: { - type: "string", + type: Scratch.ArgumentType.STRING, menu: "allmenu", defaultValue: "All data", }, - }, + } }, - ], - menus: { - coms: { - items: ["Connected", "Username synced"], + + "---", + + { + func: "showOldBlocks", + blockType: Scratch.BlockType.BUTTON, + text: "Show old blocks", + hideFromPalette: !clVars.hideCLDeprecatedBlocks, }, + + { + func: "hideOldBlocks", + blockType: Scratch.BlockType.BUTTON, + text: "Hide old blocks", + hideFromPalette: clVars.hideCLDeprecatedBlocks, + }, + + "---", + + ], + menus: { datamenu: { - items: [ - "Global data", - "Private data", - "Direct data", - "Status code", - ], + items: ['Global data', 'Private data', 'Direct data', 'Status code'] }, varmenu: { - items: ["Global variables", "Private variables"], + items: ['Global variables', 'Private variables'] }, allmenu: { - items: [ - "Global data", - "Private data", - "Direct data", - "Status code", - "Global variables", - "Private variables", - "All data", - ], + items: ['Global data', 'Private data', 'Direct data', 'Status code', "Global variables", "Private variables", "All data"] }, almostallmenu: { - items: [ - "Global data", - "Private data", - "Direct data", - "Status code", - "Global variables", - "Private variables", - ], + items: ['Global data', 'Private data', 'Direct data', 'Status code', "Global variables", "Private variables"] }, - }, + } }; } - // Code for blocks go here - - returnGlobalData() { - if (this.socketData.gmsg.length != 0) { - let data = this.socketData.gmsg[this.socketData.gmsg.length - 1].val; - - if (typeof data == "object") { - data = JSON.stringify(data); // Make the JSON safe for Scratch - } - - return data; - } else { - return ""; + // Credit to LilyMakesThings' "Lily's toolbox" for this feature. + showOldBlocks() { + if ( + confirm( + "Do you want to display old blocks?\n\nThese blocks are not recommended for use in newer CloudLink projects as they are deprecated or have better implementation in other extensions." + ) + ) { + clVars.hideCLDeprecatedBlocks = false; + vm.extensionManager.refreshBlocks(); } } - returnPrivateData() { - if (this.socketData.pmsg.length != 0) { - let data = this.socketData.pmsg[this.socketData.pmsg.length - 1].val; + // Credit to LilyMakesThings' "Lily's toolbox" for this feature. + hideOldBlocks() { + clVars.hideCLDeprecatedBlocks = true; + vm.extensionManager.refreshBlocks(); + } - if (typeof data == "object") { - data = JSON.stringify(data); // Make the JSON safe for Scratch - } + // Reporter - Returns gmsg values. + returnGlobalData() { + return makeValueScratchSafe(clVars.gmsg.varState); + } - return data; - } else { - return ""; - } + // Reporter - Returns pmsg values. + returnPrivateData() { + return makeValueScratchSafe(clVars.pmsg.varState); } + // Reporter - Returns direct values. returnDirectData() { - if (this.socketData.direct.length != 0) { - let data = - this.socketData.direct[this.socketData.direct.length - 1].val; - - if (typeof data == "object") { - data = JSON.stringify(data); // Make the JSON safe for Scratch - } - - return data; - } else { - return ""; - } + return makeValueScratchSafe(clVars.direct.varState); } + // Reporter - Returns current link state. returnLinkData() { - return String(this.link_status); + return makeValueScratchSafe(clVars.linkState.status); } + // Reporer - Returns status code values. returnStatusCode() { - if (this.socketData.statuscode.length != 0) { - let data = - this.socketData.statuscode[this.socketData.statuscode.length - 1] - .code; - - if (typeof data == "object") { - data = JSON.stringify(data); // Make the JSON safe for Scratch - } - - return data; - } else { - return ""; - } + return makeValueScratchSafe(clVars.statuscode.varState); } + // Reporter - Returns ulist value. returnUserListData() { - return JSON.stringify(this.socketData.ulist); + return makeValueScratchSafe(clVars.ulist); } + // Reporter - Returns currently set username. returnUsernameData() { - let data = this.username; - - if (typeof data == "object") { - data = JSON.stringify(data); // Make the JSON safe for Scratch - } - - return data; + return makeValueScratchSafe(clVars.username.value); } + // Reporter - Returns current client version. returnVersionData() { - return String(this.version); + return generateVersionString(); } + // Reporter - Returns reported server version. returnServerVersion() { - return String(this.socketData.server_version); + return makeValueScratchSafe(clVars.server_version); } + // Reporter - Returns the serverlist value. returnServerList() { - return JSON.stringify(servers); + return makeValueScratchSafe(clVars.serverList); } + // Reporter - Returns the reported Message-Of-The-Day. returnMOTD() { - return String(this.socketData.motd); + return makeValueScratchSafe(clVars.motd); } + // Reporter - Returns the reported IP address of the client. returnClientIP() { - return String(this.socketData.client_ip); + return makeValueScratchSafe(clVars.client_ip); } - returnListenerData({ ID }) { - const self = this; - if (this.isRunning && this.socketListeners.hasOwnProperty(String(ID))) { - return JSON.stringify(this.socketListenersData[ID]); - } else { - return "{}"; - } + // Reporter - Returns the reported user object of the client (Snowflake ID, UUID, Username) + returnUserObject() { + return makeValueScratchSafe(clVars.myUserObject); } - readQueueSize({ TYPE }) { - if (this.menuRemap[String(TYPE)] == "all") { - let tmp_size = 0; - tmp_size = tmp_size + this.socketData.gmsg.length; - tmp_size = tmp_size + this.socketData.pmsg.length; - tmp_size = tmp_size + this.socketData.direct.length; - tmp_size = tmp_size + this.socketData.statuscode.length; - tmp_size = tmp_size + this.socketData.gvar.length; - tmp_size = tmp_size + this.socketData.pvar.length; - return tmp_size; - } else { - return this.socketData[this.menuRemap[String(TYPE)]].length; + // Reporter - Returns data for a specific listener ID. + // ID - String (listener ID) + returnListenerData(args) { + if (!clVars.listeners.varStates.hasOwnProperty(String(args.ID))) { + console.warn(`[CloudLink] Listener ID ${args.ID} does not exist!`); + return ""; } + return clVars.listeners.varStates[String(args.ID)].varState; } - readQueueData({ TYPE }) { - if (this.menuRemap[String(TYPE)] == "all") { - let tmp_socketData = JSON.parse(JSON.stringify(this.socketData)); // Deep copy - - delete tmp_socketData.motd; - delete tmp_socketData.client_ip; - delete tmp_socketData.ulist; - delete tmp_socketData.server_version; + // Reporter - Returns the size of the message queue. + // TYPE - String (menu allmenu) + readQueueSize(args) { + switch (args.TYPE) { + case 'Global data': + return clVars.gmsg.queue.length; + case 'Private data': + return clVars.pmsg.queue.length; + case 'Direct data': + return clVars.direct.queue.length; + case 'Status code': + return clVars.statuscode.queue.length; + case 'Global variables': + return clVars.gvar.queue.length; + case 'Private variables': + return clVars.pvar.queue.length; + case 'All data': + return ( + clVars.gmsg.queue.length + + clVars.pmsg.queue.length + + clVars.direct.queue.length + + clVars.statuscode.queue.length + + clVars.gvar.queue.length + + clVars.pvar.queue.length + ); + } + } - return JSON.stringify(tmp_socketData); - } else { - return JSON.stringify(this.socketData[this.menuRemap[String(TYPE)]]); + // Reporter - Returns all values of the message queue. + // TYPE - String (menu allmenu) + readQueueData(args) { + switch (args.TYPE) { + case 'Global data': + return makeValueScratchSafe(clVars.gmsg.queue); + case 'Private data': + return makeValueScratchSafe(clVars.pmsg.queue); + case 'Direct data': + return makeValueScratchSafe(clVars.direct.queue); + case 'Status code': + return makeValueScratchSafe(clVars.statuscode.queue); + case 'Global variables': + return makeValueScratchSafe(clVars.gvar.queue); + case 'Private variables': + return makeValueScratchSafe(clVars.pvar.queue); + case 'All data': + return makeValueScratchSafe({ + gmsg: clVars.gmsg.queue, + pmsg: clVars.pmsg.queue, + direct: clVars.direct.queue, + statuscode: clVars.statuscode.queue, + gvar: clVars.gvar.queue, + pvar: clVars.pvar.queue + }); } } - returnVarData({ TYPE, VAR }) { - if (this.isRunning) { - if (this.varData.hasOwnProperty(this.menuRemap[TYPE])) { - if (this.varData[this.menuRemap[TYPE]].hasOwnProperty(VAR)) { - return this.varData[this.menuRemap[TYPE]][VAR].value; - } else { - return ""; - } - } else { + // Reporter - Returns a gvar/pvar value. + // TYPE - String (menu varmenu), VAR - String (variable name) + returnVarData(args) { + switch (args.TYPE) { + case 'Global variables': + if (!clVars.gvar.varStates.hasOwnProperty(String(args.VAR))) { + console.warn(`[CloudLink] Global variable ${args.VAR} does not exist!`); return ""; } - } else { - return ""; + return clVars.gvar.varStates[String(args.VAR)].varState; + case 'Private variables': + if (!clVars.pvar.varStates.hasOwnProperty(String(args.VAR))) { + console.warn(`[CloudLink] Private variable ${args.VAR} does not exist!`); + return ""; + } + return clVars.pvar.varStates[String(args.VAR)].varState; } } - parseJSON({ PATH, JSON_STRING }) { + // Reporter - Gets a JSON key value from a JSON string. + // PATH - String, JSON_STRING - String + parseJSON(args) { try { - const path = PATH.toString() - .split("/") - .map((prop) => decodeURIComponent(prop)); - if (path[0] === "") path.splice(0, 1); - if (path[path.length - 1] === "") path.splice(-1, 1); + const path = args.PATH.toString().split('/').map(prop => decodeURIComponent(prop)); + if (path[0] === '') path.splice(0, 1); + if (path[path.length - 1] === '') path.splice(-1, 1); let json; try { - json = JSON.parse(" " + JSON_STRING); + json = JSON.parse(' ' + args.JSON_STRING); } catch (e) { return e.message; - } - path.forEach((prop) => (json = json[prop])); - if (json === null) return "null"; - else if (json === undefined) return ""; - else if (typeof json === "object") return JSON.stringify(json); + }; + path.forEach(prop => json = json[prop]); + if (json === null) return 'null'; + else if (json === undefined) return ''; + else if (typeof json === 'object') return JSON.stringify(json); else return json.toString(); } catch (err) { - return ""; - } + return ''; + }; } - getFromJSONArray({ NUM, ARRAY }) { - var json_array = JSON.parse(ARRAY); - if (json_array[NUM] == "undefined") { + // Reporter - Returns an entry from a JSON array (0-based). + // NUM - Number, ARRAY - String (JSON Array) + getFromJSONArray(args) { + var json_array = JSON.parse(args.ARRAY); + if (json_array[args.NUM] == "undefined") { return ""; } else { - let data = json_array[NUM]; - - if (typeof data == "object") { + let data = json_array[args.NUM]; + + if (typeof (data) == "object") { data = JSON.stringify(data); // Make the JSON safe for Scratch } - + return data; } } + // Reporter - Returns a RESTful GET promise. + // url - String fetchURL(args) { - return Scratch.fetch(args.url, { - method: "GET", - }).then((response) => response.text()); + return Scratch.fetch(args.url, {method: "GET"}) + .then(response => response.text()) + .catch(error => { + console.warn(`[CloudLink] Fetch error: ${error}`); + }); } + // Reporter - Returns a RESTful request promise. + // url - String, method - String, data - String, headers - String requestURL(args) { if (args.method == "GET" || args.method == "HEAD") { return Scratch.fetch(args.url, { method: args.method, - headers: JSON.parse(args.headers), - }).then((response) => response.text()); + headers: JSON.parse(args.headers) + }) + .then(response => response.text()) + .catch(error => { + console.warn(`[CloudLink] Request error: ${error}`); + }); } else { return Scratch.fetch(args.url, { method: args.method, headers: JSON.parse(args.headers), - body: JSON.parse(args.data), - }).then((response) => response.text()); - } - } - - isValidJSON({ JSON_STRING }) { - return jsonCheck(JSON_STRING); - } - - makeJSON({ toBeJSONified }) { - if (typeof toBeJSONified == "string") { - try { - JSON.parse(toBeJSONified); - return String(toBeJSONified); - } catch (err) { - return "Not JSON!"; - } - } else if (typeof toBeJSONified == "object") { - return JSON.stringify(toBeJSONified); - } else { - return "Not JSON!"; + body: JSON.parse(args.data) + }) + .then(response => response.text()) + .catch(error => { + console.warn(`[CloudLink] Request error: ${error}`); + }); } } - - onConnect() { - const self = this; - if (self.connect_hat == 0 && self.isRunning && self.protocolOk) { - self.connect_hat = 1; + + // Event + // ID - String (listener) + onListener(args) { + // Must be connected + if (clVars.socket == null) return false; + if (clVars.linkState.status != 2) return false; + + // Listener must exist + if (!clVars.listeners.varStates.hasOwnProperty(args.ID)) return false; + + // Run event + if (clVars.listeners.varStates[args.ID].eventHatTick) { + clVars.listeners.varStates[args.ID].eventHatTick = false; return true; - } else { - return false; } + return false; } - onClose() { - const self = this; - if (self.close_hat == 0 && !self.isRunning) { - self.close_hat = 1; - return true; - } else { - return false; - } - } + // Event + // TYPE - String (menu almostallmenu) + onNewPacket(args) { + // Must be connected + if (clVars.socket == null) return false; + if (clVars.linkState.status != 2) return false; + + // Run event + switch (args.TYPE) { + case 'Global data': + if (clVars.gmsg.eventHatTick) { + clVars.gmsg.eventHatTick = false; + return true; + } + break; - onListener({ ID }) { - const self = this; - if (this.isRunning && this.socketListeners.hasOwnProperty(String(ID))) { - if (self.socketListeners[String(ID)]) { - self.socketListeners[String(ID)] = false; - return true; - } else { - return false; - } - } else { - return false; + case 'Private data': + if (clVars.pmsg.eventHatTick) { + clVars.pmsg.eventHatTick = false; + return true; + } + break; + + case 'Direct data': + if (clVars.direct.eventHatTick) { + clVars.direct.eventHatTick = false; + return true; + } + break; + + case 'Status code': + if (clVars.statuscode.eventHatTick) { + clVars.statuscode.eventHatTick = false; + return true; + } + break; + + case 'Global variables': + if (clVars.gvar.eventHatTick) { + clVars.gvar.eventHatTick = false; + return true; + } + break; + + case 'Private variables': + if (clVars.pvar.eventHatTick) { + clVars.pvar.eventHatTick = false; + return true; + } + break; } + return false; } - onNewPacket({ TYPE }) { - const self = this; - if (this.isRunning && this.newSocketData[this.menuRemap[String(TYPE)]]) { - self.newSocketData[this.menuRemap[String(TYPE)]] = false; - return true; - } else { - return false; + // Event + // TYPE - String (varmenu), VAR - String (variable name) + onNewVar(args) { + // Must be connected + if (clVars.socket == null) return false; + if (clVars.linkState.status != 2) return false; + + // Run event + switch (args.TYPE) { + case 'Global variables': + + // Variable must exist + if (!clVars.gvar.varStates.hasOwnProperty(String(args.VAR))) break; + if (clVars.gvar.varStates[String(args.VAR)].eventHatTick) { + clVars.gvar.varStates[String(args.VAR)].eventHatTick = false; + return true; + } + + break; + + case 'Private variables': + + // Variable must exist + if (!clVars.pvar.varStates.hasOwnProperty(String(args.VAR))) break; + if (clVars.pvar.varStates[String(args.VAR)].eventHatTick) { + clVars.pvar.varStates[String(args.VAR)].eventHatTick = false; + return true; + } + + break; } + return false; } - onNewVar({ TYPE, VAR }) { - const self = this; - if (this.isRunning) { - if (this.varData.hasOwnProperty(this.menuRemap[TYPE])) { - if (this.varData[this.menuRemap[TYPE]].hasOwnProperty(VAR)) { - if (this.varData[this.menuRemap[TYPE]][VAR].isNew) { - self.varData[this.menuRemap[TYPE]][VAR].isNew = false; - return true; - } else { - return false; - } - } else { - return false; - } - } else { - return false; + // Reporter - Returns a JSON-ified value. + // toBeJSONified - String + makeJSON(args) { + if (typeof(args.toBeJSONified) == "string") { + try { + JSON.parse(args.toBeJSONified); + return String(args.toBeJSONified); + } catch(err) { + return "Not JSON!"; } + } else if (typeof(args.toBeJSONified) == "object") { + return JSON.stringify(args.toBeJSONified); } else { - return false; - } + return "Not JSON!"; + }; } + // Boolean - Returns true if connected. getComState() { - return String(this.link_status == 2 || this.protocolOk); + return ((clVars.linkState.status == 2) && (clVars.socket != null)); } + // Boolean - Returns true if linked to rooms (other than "default") getRoomState() { - return this.isLinked; + return ((clVars.socket != null) && (clVars.rooms.isLinked)); } + // Boolean - Returns true if the connection was dropped. getComLostConnectionState() { - return this.wasConnectionDropped; + return ((clVars.linkState.status == 4) && (clVars.linkState.disconnectType == 2)); } + // Boolean - Returns true if the client failed to establish a connection. getComFailedConnectionState() { - return this.didConnectionFail; + return ((clVars.linkState.status == 4) && (clVars.linkState.disconnectType == 1)); } + // Boolean - Returns true if the username was set successfully. getUsernameState() { - return this.isUsernameSet; + return ((clVars.socket != null) && (clVars.username.accepted)); } - returnIsNewData({ TYPE }) { - if (this.isRunning) { - return this.newSocketData[this.menuRemap[String(TYPE)]]; - } else { - return false; + // Boolean - Returns true if there is new gmsg/pmsg/direct/statuscode data. + // TYPE - String (menu datamenu) + returnIsNewData(args) { + + // Must be connected + if (clVars.socket == null) return false; + + // Run event + switch (args.TYPE) { + case 'Global data': + return clVars.gmsg.hasNew; + case 'Private data': + return clVars.pmsg.hasNew; + case 'Direct data': + return clVars.direct.hasNew; + case 'Status code': + return clVars.statuscode.hasNew; } } - returnIsNewVarData({ TYPE, VAR }) { - if (this.isRunning) { - if (this.varData.hasOwnProperty(this.menuRemap[TYPE])) { - if (this.varData[this.menuRemap[TYPE]].hasOwnProperty(VAR)) { - return this.varData[this.menuRemap[TYPE]][VAR].isNew; - } else { + // Boolean - Returns true if there is new gvar/pvar data. + // TYPE - String (menu varmenu), VAR - String (variable name) + returnIsNewVarData(args) { + switch (args.TYPE) { + case 'Global variables': + if (!clVars.gvar.varStates.hasOwnProperty(String(args.VAR))) { + console.warn(`[CloudLink] Global variable ${args.VAR} does not exist!`); return false; } - } else { - return false; - } - } else { + return clVars.gvar.varStates[String(args.ID)].hasNew; + case 'Private variables': + if (!clVars.pvar.varStates.hasOwnProperty(String(args.VAR))) { + console.warn(`[CloudLink] Private variable ${args.VAR} does not exist!`); + return false; + } + return clVars.pvar.varStates[String(args.ID)].hasNew; + } + } + + // Boolean - Returns true if a listener has a new value. + // ID - String (listener ID) + returnIsNewListener(args) { + if (!clVars.listeners.varStates.hasOwnProperty(String(args.ID))) { + console.warn(`[CloudLink] Listener ID ${args.ID} does not exist!`); return false; } + return clVars.listeners.varStates[String(args.ID)].hasNew; } - returnIsNewListener({ ID }) { - if (this.isRunning) { - if (this.socketListeners.hasOwnProperty(String(ID))) { - return this.socketListeners[ID]; + // Boolean - Returns true if a username/ID/UUID/object exists in the userlist. + // ID - String (username or user object) + checkForID(args) { + + // Legacy ulist handling + if (clVars.ulist.includes(args.ID)) return true; + + // New ulist handling + if (clVars.linkState.identifiedProtocol > 2) { + if (this.isValidJSON(args.ID)) { + return clVars.ulist.some(o => ( + (o.username === JSON.parse(args.ID).username) + && + (o.id == JSON.parse(args.ID).id) + )); } else { - return false; + return clVars.ulist.some(o => ( + (o.username === String(args.ID)) + || + (o.id == args.ID) + )); } - } else { + } else return false; + } + + // Boolean - Returns true if the input JSON is valid. + // JSON_STRING - String + isValidJSON(args) { + try { + JSON.parse(args.JSON_STRING); + return true; + } catch { return false; - } + }; } - checkForID({ ID }) { - return find_id(ID, this.socketData.ulist); + // Command - Establishes a connection to a server. + // IP - String (websocket URL) + openSocket(args) { + if (clVars.socket != null) { + console.warn("[CloudLink] Already connected to a server."); + return; + }; + return newClient(args.IP); } - async openSocket({ IP }) { - const self = this; - if (!self.isRunning) { - if (!(await Scratch.canFetch(IP))) { - return; - } + // Command - Establishes a connection to a selected server. + // ID - Number (server entry #) + openSocketPublicServers(args) { + if (clVars.socket != null) { + console.warn("[CloudLink] Already connected to a server."); + return; + }; + if (!clVars.serverList.hasOwnProperty(String(args.ID))) { + console.warn("[CloudLink] Not a valid server ID!"); + return; + }; + return newClient(clVars.serverList[String(args.ID)]["url"]); + } - console.log("Starting socket."); - self.link_status = 1; + // Command - Closes the connection. + closeSocket() { + if (clVars.socket == null) { + console.warn("[CloudLink] Already disconnected."); + return; + }; + console.log("[CloudLink] Disconnecting..."); + clVars.linkState.isAttemptingGracefulDisconnect = true; + clVars.socket.close(1000, "Client going away"); + } - self.disconnectWasClean = false; - self.wasConnectionDropped = false; - self.didConnectionFail = false; + // Command - Sets the username of the client on the server. + // NAME - String + setMyName(args) { + // Must be connected to set a username. + if (clVars.socket == null) return; - mWS = new WebSocket(String(IP)); + // Prevent running if an attempt is currently processing. + if (clVars.username.attempted) { + console.warn("[CloudLink] Already attempting to set username!"); + return; + }; - mWS.onerror = function () { - self.isRunning = false; - }; + // Prevent running if the username is already set. + if (clVars.username.accepted) { + console.warn("[CloudLink] Already set username!"); + return; + }; - mWS.onopen = function () { - self.isRunning = true; - self.packet_queue = {}; - self.link_status = 2; + // Update state + clVars.username.attempted = true; + clVars.username.temp = args.NAME; - // Send the handshake request to get server to detect client protocol - mWS.send( - JSON.stringify({ cmd: "handshake", listener: "setprotocol" }) - ); + // Send the command + return sendMessage({ cmd: "setid", val: args.NAME, listener: "username_cfg" }); + } - console.log("Successfully opened socket."); - }; + // Command - Prepares the next transmitted message to have a listener ID attached to it. + // ID - String (listener ID) + createListener(args) { + + // Must be connected to set a username. + if (clVars.socket == null) return; + + // Require server support + if (clVars.linkState.identifiedProtocol < 2) { + console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support listeners."); + return; + } - mWS.onmessage = function (event) { - let tmp_socketData = JSON.parse(event.data); - console.log("RX:", tmp_socketData); + // Prevent running if the username hasn't been set. + if (!clVars.username.accepted) { + console.warn("[CloudLink] Username must be set before creating a listener!"); + return; + }; - if (self.queueableCmds.includes(tmp_socketData.cmd)) { - self.socketData[tmp_socketData.cmd].push(tmp_socketData); - } else { - if (tmp_socketData.cmd == "ulist") { - // ulist functionality has been changed in server 0.1.9 - if (tmp_socketData.hasOwnProperty("mode")) { - if (tmp_socketData.mode == "set") { - self.socketData["ulist"] = tmp_socketData.val; - } else if (tmp_socketData.mode == "add") { - if ( - !self.socketData.ulist.some( - (o) => - o.username === tmp_socketData.val.username && - o.id == tmp_socketData.val.id - ) - ) { - self.socketData["ulist"].push(tmp_socketData.val); - } else { - console.log( - "Could not perform ulist method add, client", - tmp_socketData.val, - "already exists" - ); - } - } else if (tmp_socketData.mode == "remove") { - if ( - self.socketData.ulist.some( - (o) => - o.username === tmp_socketData.val.username && - o.id == tmp_socketData.val.id - ) - ) { - // This is by far the fugliest thing I have ever written in JS, or in any programming language... thanks I hate it - self.socketData["ulist"] = self.socketData["ulist"].filter( - (user) => - !(user.username === tmp_socketData.val.username) && - !(user.id == tmp_socketData.val.id) - ); - } else { - console.log( - "Could not perform ulist method remove, client", - tmp_socketData.val, - "was not found" - ); - } - } else { - console.log( - "Could not understand ulist method:", - tmp_socketData.mode - ); - } - } else { - // Retain compatibility wtih existing servers - self.socketData["ulist"] = tmp_socketData.val; - } - } else { - self.socketData[tmp_socketData.cmd] = tmp_socketData.val; - } - } + // Must be used once per packet + if (clVars.listeners.enablerState) { + console.warn("[CloudLink] Cannot create multiple listeners at a time!"); + return; + } + + // Update state + clVars.listeners.enablerState = true; + clVars.listeners.enablerValue = args.ID; + } - if (self.newSocketData.hasOwnProperty(tmp_socketData.cmd)) { - self.newSocketData[tmp_socketData.cmd] = true; - } + // Command - Subscribes to various rooms on a server. + // ROOMS - String (JSON Array or single string) + linkToRooms(args) { - if (self.varCmds.includes(tmp_socketData.cmd)) { - self.varData[tmp_socketData.cmd][tmp_socketData.name] = { - value: tmp_socketData.val, - isNew: true, - }; - } - if (tmp_socketData.hasOwnProperty("listener")) { - if (tmp_socketData.listener == "setusername") { - self.socketListeners["setusername"] = true; - if (tmp_socketData.code == "I:100 | OK") { - self.username = tmp_socketData.val; - self.isUsernameSyncing = false; - self.isUsernameSet = true; - console.log( - "Username was accepted by the server, and has been set to:", - self.username - ); - } else { - console.warn( - "Username was rejected by the server. Error code:", - String(tmp_socketData.code) - ); - self.isUsernameSyncing = false; - } - } else if (tmp_socketData.listener == "roomLink") { - self.isRoomSetting = false; - self.socketListeners["roomLink"] = true; - if (tmp_socketData.code == "I:100 | OK") { - console.log("Linking to room(s) was accepted by the server!"); - self.isLinked = true; - } else { - console.warn( - "Linking to room(s) was rejected by the server. Error code:", - String(tmp_socketData.code) - ); - self.enableRoom = false; - self.isLinked = false; - self.selectRoom = ""; - } - } else if ( - tmp_socketData.listener == "setprotocol" && - !this.protocolOk - ) { - console.log( - "Server successfully set client protocol to cloudlink!" - ); - self.socketData.statuscode = []; - self.protocolOk = true; - self.socketListeners["setprotocol"] = true; - } else { - if ( - self.socketListeners.hasOwnProperty(tmp_socketData.listener) - ) { - self.socketListeners[tmp_socketData.listener] = true; - } - } - self.socketListenersData[tmp_socketData.listener] = tmp_socketData; - } - self.packet_hat = 0; - }; + // Must be connected to set a username. + if (clVars.socket == null) return; - mWS.onclose = function () { - self.isRunning = false; - self.connect_hat = 0; - self.packet_hat = 0; - self.protocolOk = false; - if (self.close_hat == 1) { - self.close_hat = 0; - } - self.socketData = { - gmsg: [], - pmsg: [], - direct: [], - statuscode: [], - gvar: [], - pvar: [], - motd: "", - client_ip: "", - ulist: [], - server_version: "", - }; - self.newSocketData = { - gmsg: false, - pmsg: false, - direct: false, - statuscode: false, - gvar: false, - pvar: false, - }; - self.socketListeners = {}; - self.username = ""; - self.tmp_username = ""; - self.isUsernameSyncing = false; - self.isUsernameSet = false; - self.enableListener = false; - self.setListener = ""; - self.enableRoom = false; - self.selectRoom = ""; - self.isLinked = false; - self.isRoomSetting = false; - - if (self.link_status != 1) { - if (self.disconnectWasClean) { - self.link_status = 3; - console.log("Socket closed."); - self.wasConnectionDropped = false; - self.didConnectionFail = false; - } else { - self.link_status = 4; - console.error("Lost connection to the server."); - self.wasConnectionDropped = true; - self.didConnectionFail = false; - } - } else { - self.link_status = 4; - console.error("Failed to connect to server."); - self.wasConnectionDropped = false; - self.didConnectionFail = true; - } - }; - } else { - console.warn("Socket is already open."); + // Require server support + if (clVars.linkState.identifiedProtocol < 2) { + console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support rooms."); + return; } - } - openSocketPublicServers({ ID }) { - if (servers.hasOwnProperty(ID)) { - console.log("Connecting to:", servers[ID].url); - this.openSocket({ IP: servers[ID].url }); - } - } + // Prevent running if the username hasn't been set. + if (!clVars.username.accepted) { + console.warn("[CloudLink] Username must be set before linking to rooms!"); + return; + }; - closeSocket() { - const self = this; - if (this.isRunning) { - console.log("Closing socket..."); - mWS.close(1000, "script closure"); - self.disconnectWasClean = true; - } else { - console.warn("Socket is not open."); - } - } + // Prevent running if already linked. + if (clVars.rooms.isLinked) { + console.warn("[CloudLink] Already linked to rooms!"); + return; + }; - setMyName({ NAME }) { - const self = this; - if (this.isRunning) { - if (!this.isUsernameSyncing) { - if (!this.isUsernameSet) { - if (String(NAME) != "") { - if (!(String(NAME).length > 20)) { - if ( - !( - String(NAME) == "%CA%" || - String(NAME) == "%CC%" || - String(NAME) == "%CD%" || - String(NAME) == "%MS%" - ) - ) { - let tmp_msg = { - cmd: "setid", - val: String(NAME), - listener: "setusername", - }; - - console.log("TX:", tmp_msg); - mWS.send(JSON.stringify(tmp_msg)); - - self.tmp_username = String(NAME); - self.isUsernameSyncing = true; - } else { - console.log("Blocking attempt to use reserved usernames"); - } - } else { - console.log( - "Blocking attempt to use username larger than 20 characters, username is " + - String(NAME).length + - " characters long" - ); - } - } else { - console.log("Blocking attempt to use blank username"); - } - } else { - console.warn("Username already has been set!"); - } - } else { - console.warn("Username is still syncing!"); - } - } - } + // Prevent running if a room link is in progress. + if (clVars.rooms.isAttemptingLink) { + console.warn("[CloudLink] Currently linking to rooms! Please wait!"); + return; + }; - createListener({ ID }) { - self = this; - if (this.isRunning) { - if (!this.enableListener) { - self.enableListener = true; - self.setListener = String(ID); - } else { - console.warn("Listeners were already created!"); - } - } else { - console.log("Cannot assign a listener to a packet while disconnected"); - } + clVars.rooms.isAttemptingLink = true; + return sendMessage({ cmd: "link", val: args.ROOMS, listener: "link" }); } - linkToRooms({ ROOMS }) { - const self = this; - - if (this.isRunning) { - if (!this.isRoomSetting) { - if (!(String(ROOMS).length > 1000)) { - let tmp_msg = { - cmd: "link", - val: autoConvert(ROOMS), - listener: "roomLink", - }; + // Command - Specifies specific subscribed rooms to transmit messages to. + // ROOMS - String (JSON Array or single string) + selectRoomsInNextPacket(args) { - console.log("TX:", tmp_msg); - mWS.send(JSON.stringify(tmp_msg)); + // Must be connected to user rooms. + if (clVars.socket == null) return; - self.isRoomSetting = true; - } else { - console.warn( - "Blocking attempt to send a room ID / room list larger than 1000 bytes (1 KB), room ID / room list is " + - String(ROOMS).length + - " bytes" - ); - } - } else { - console.warn("Still linking to rooms!"); - } - } else { - console.warn("Socket is not open."); + // Require server support + if (clVars.linkState.identifiedProtocol < 2) { + console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support rooms."); + return; } - } - selectRoomsInNextPacket({ ROOMS }) { - const self = this; - if (this.isRunning) { - if (this.isLinked) { - if (!this.enableRoom) { - if (!(String(ROOMS).length > 1000)) { - self.enableRoom = true; - self.selectRoom = ROOMS; - } else { - console.warn( - "Blocking attempt to select a room ID / room list larger than 1000 bytes (1 KB), room ID / room list is " + - String(ROOMS).length + - " bytes" - ); - } - } else { - console.warn("Rooms were already selected!"); - } - } else { - console.warn("Not linked to any room(s)!"); - } - } else { - console.warn("Socket is not open."); + // Prevent running if the username hasn't been set. + if (!clVars.username.accepted) { + console.warn("[CloudLink] Username must be set before selecting rooms!"); + return; + }; + + // Require once per packet + if (clVars.rooms.enablerState) { + console.warn("[CloudLink] Cannot use the room selector more than once at a time!"); + return; } - } - unlinkFromRooms() { - const self = this; - if (this.isRunning) { - if (this.isLinked) { - let tmp_msg = { - cmd: "unlink", - val: "", - }; + // Prevent running if not linked. + if (!clVars.rooms.isLinked) { + console.warn("[CloudLink] Cannot use room selector while not linked to rooms!"); + return; + }; - if (this.enableListener) { - tmp_msg["listener"] = autoConvert(this.setListener); - } + clVars.rooms.enablerState = true; + clVars.rooms.enablerValue = args.ROOMS; + } - console.log("TX:", tmp_msg); - mWS.send(JSON.stringify(tmp_msg)); + // Command - Unsubscribes from all rooms and re-subscribes to the the "default" room on the server. + unlinkFromRooms() { - if (this.enableListener) { - if (!self.socketListeners.hasOwnProperty(this.setListener)) { - self.socketListeners[this.setListener] = false; - } - self.enableListener = false; - } + // Must be connected to user rooms. + if (clVars.socket == null) return; - self.isLinked = false; - } else { - console.warn("Not linked to any rooms!"); - } - } else { - console.warn("Socket is not open."); + // Require server support + if (clVars.linkState.identifiedProtocol < 2) { + console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support rooms."); + return; } - } - sendGData({ DATA }) { - const self = this; - if (this.isRunning) { - if (!(String(DATA).length > 1000)) { - let tmp_msg = { - cmd: "gmsg", - val: autoConvert(DATA), - }; + // Prevent running if the username hasn't been set. + if (!clVars.username.accepted) { + console.warn("[CloudLink] Username must be set before unjoining rooms!"); + return; + }; - if (this.enableListener) { - tmp_msg["listener"] = String(this.setListener); - } + // Prevent running if already unlinked. + if (!clVars.rooms.isLinked) { + console.warn("[CloudLink] Already unlinked from rooms!"); + return; + }; - if (this.enableRoom) { - tmp_msg["rooms"] = autoConvert(this.selectRoom); - } + // Prevent running if a room unlink is in progress. + if (clVars.rooms.isAttemptingUnlink) { + console.warn("[CloudLink] Currently unlinking from rooms! Please wait!"); + return; + }; - console.log("TX:", tmp_msg); - mWS.send(JSON.stringify(tmp_msg)); + clVars.rooms.isAttemptingUnlink = true; + return sendMessage({ cmd: "unlink", val: "", listener: "unlink" }); + } - if (this.enableListener) { - if (!self.socketListeners.hasOwnProperty(this.setListener)) { - self.socketListeners[this.setListener] = false; - } - self.enableListener = false; - } - if (this.enableRoom) { - self.enableRoom = false; - self.selectRoom = ""; - } - } else { - console.warn( - "Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + - String(DATA).length + - " bytes" - ); - } - } else { - console.warn("Socket is not open."); - } + // Command - Sends a gmsg value. + // DATA - String + sendGData(args) { + + // Must be connected. + if (clVars.socket == null) return; + + return sendMessage({ cmd: "gmsg", val: args.DATA }); } - sendPData({ DATA, ID }) { - const self = this; - if (this.isRunning) { - if (!(String(DATA).length > 1000)) { - let tmp_msg = { - cmd: "pmsg", - val: autoConvert(DATA), - id: autoConvert(ID), - }; + // Command - Sends a pmsg value. + // DATA - String, ID - String (recipient ID) + sendPData(args) { - if (this.enableListener) { - tmp_msg["listener"] = String(this.setListener); - } - if (this.enableRoom) { - tmp_msg["rooms"] = autoConvert(this.selectRoom); - } + // Must be connected. + if (clVars.socket == null) return; - console.log("TX:", tmp_msg); - mWS.send(JSON.stringify(tmp_msg)); + // Prevent running if the username hasn't been set. + if (!clVars.username.accepted) { + console.warn("[CloudLink] Username must be set before sending private messages!"); + return; + }; - if (this.enableListener) { - if (!self.socketListeners.hasOwnProperty(this.setListener)) { - self.socketListeners[this.setListener] = false; - } - self.enableListener = false; - } - if (this.enableRoom) { - self.enableRoom = false; - self.selectRoom = ""; - } - } else { - console.warn( - "Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + - String(DATA).length + - " bytes" - ); - } - } else { - console.warn("Socket is not open."); - } + return sendMessage({ cmd: "pmsg", val: args.DATA, id: args.ID }); } - sendGDataAsVar({ VAR, DATA }) { - const self = this; - if (this.isRunning) { - if (!(String(DATA).length > 1000)) { - let tmp_msg = { - cmd: "gvar", - name: VAR, - val: autoConvert(DATA), - }; - - if (this.enableListener) { - tmp_msg["listener"] = String(this.setListener); - } - if (this.enableRoom) { - tmp_msg["rooms"] = autoConvert(this.selectRoom); - } + // Command - Sends a gvar value. + // DATA - String, VAR - String (variable name) + sendGDataAsVar(args) { - console.log("TX:", tmp_msg); - mWS.send(JSON.stringify(tmp_msg)); + // Must be connected. + if (clVars.socket == null) return; - if (this.enableListener) { - if (!self.socketListeners.hasOwnProperty(this.setListener)) { - self.socketListeners[this.setListener] = false; - } - self.enableListener = false; - } - if (this.enableRoom) { - self.enableRoom = false; - self.selectRoom = ""; - } - } else { - console.warn( - "Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + - String(DATA).length + - " bytes" - ); - } - } else { - console.warn("Socket is not open."); - } + return sendMessage({ cmd: "gvar", val: args.DATA, name: args.VAR }); } - sendPDataAsVar({ VAR, ID, DATA }) { - const self = this; - if (this.isRunning) { - if (!(String(DATA).length > 1000)) { - let tmp_msg = { - cmd: "pvar", - name: VAR, - val: autoConvert(DATA), - id: autoConvert(ID), - }; + // Command - Sends a pvar value. + // DATA - String, VAR - String (variable name), ID - String (recipient ID) + sendPDataAsVar(args) { - if (this.enableListener) { - tmp_msg["listener"] = String(this.setListener); - } - if (this.enableRoom) { - tmp_msg["rooms"] = autoConvert(this.selectRoom); - } + // Must be connected. + if (clVars.socket == null) return; - console.log("TX:", tmp_msg); - mWS.send(JSON.stringify(tmp_msg)); + // Prevent running if the username hasn't been set. + if (!clVars.username.accepted) { + console.warn("[CloudLink] Username must be set before sending private variables!"); + return; + }; - if (this.enableListener) { - if (!self.socketListeners.hasOwnProperty(this.setListener)) { - self.socketListeners[this.setListener] = false; - } - self.enableListener = false; - } - if (this.enableRoom) { - self.enableRoom = false; - self.selectRoom = ""; - } - } else { - console.warn( - "Blocking attempt to send packet larger than 1000 bytes (1 KB), packet is " + - String(DATA).length + - " bytes" - ); - } - } else { - console.warn("Socket is not open."); - } + return sendMessage({ cmd: "pvar", val: args.DATA, name: args.VAR, id: args.ID }); } - runCMDnoID({ CMD, DATA }) { - const self = this; - if (this.isRunning) { - if (!(String(CMD).length > 100) || !(String(DATA).length > 1000)) { - let tmp_msg = { - cmd: String(CMD), - val: autoConvert(DATA), - }; - - if (this.enableListener) { - tmp_msg["listener"] = String(this.setListener); - } - if (this.enableRoom) { - tmp_msg["rooms"] = String(this.selectRoom); - } + // Command - Sends a raw-format command without specifying an ID. + // CMD - String (command), DATA - String + runCMDnoID(args) { - console.log("TX:", tmp_msg); - mWS.send(JSON.stringify(tmp_msg)); + // Must be connected. + if (clVars.socket == null) return; - if (this.enableListener) { - if (!self.socketListeners.hasOwnProperty(this.setListener)) { - self.socketListeners[this.setListener] = false; - } - self.enableListener = false; - } - if (this.enableRoom) { - self.enableRoom = false; - self.selectRoom = ""; - } - } else { - console.warn( - "Blocking attempt to send packet with questionably long arguments" - ); - } - } else { - console.warn("Socket is not open."); - } + return sendMessage({ cmd: args.CMD, val: args.DATA }); } - runCMD({ CMD, ID, DATA }) { - const self = this; - if (this.isRunning) { - if ( - !(String(CMD).length > 100) || - !(String(ID).length > 20) || - !(String(DATA).length > 1000) - ) { - let tmp_msg = { - cmd: String(CMD), - id: autoConvert(ID), - val: autoConvert(DATA), - }; + // Command - Sends a raw-format command with an ID. + // CMD - String (command), DATA - String, ID - String (recipient ID) + runCMD(args) { - if (this.enableListener) { - tmp_msg["listener"] = String(this.setListener); - } - if (this.enableRoom) { - tmp_msg["rooms"] = String(this.selectRoom); - } + // Must be connected. + if (clVars.socket == null) return; - console.log("TX:", tmp_msg); - mWS.send(JSON.stringify(tmp_msg)); + // Prevent running if the username hasn't been set. + if (!clVars.username.accepted) { + console.warn("[CloudLink] Username must be set before using this command!"); + return; + }; - if (this.enableListener) { - if (!self.socketListeners.hasOwnProperty(this.setListener)) { - self.socketListeners[this.setListener] = false; - } - self.enableListener = false; - } - if (this.enableRoom) { - self.enableRoom = false; - self.selectRoom = ""; - } - } else { - console.warn( - "Blocking attempt to send packet with questionably long arguments" - ); - } - } else { - console.warn("Socket is not open."); - } + return sendMessage({ cmd: args.CMD, val: args.DATA, id: args.ID }); } - resetNewData({ TYPE }) { - const self = this; - if (this.isRunning) { - self.newSocketData[this.menuRemap[String(TYPE)]] = false; + // Command - Resets the "returnIsNewData" boolean state. + // TYPE - String (menu datamenu) + resetNewData(args) { + switch (args.TYPE) { + case 'Global data': + clVars.gmsg.hasNew = false; + break; + case 'Private data': + clVars.pmsg.hasNew = false; + break; + case 'Direct data': + clVars.direct.hasNew = false; + break; + case 'Status code': + clVars.statuscode.hasNew = false; + break; } } - resetNewVarData({ TYPE, VAR }) { - const self = this; - if (this.isRunning) { - if (this.varData.hasOwnProperty(this.menuRemap[TYPE])) { - if (this.varData[this.menuRemap[TYPE]].hasOwnProperty(VAR)) { - self.varData[this.menuRemap[TYPE]][VAR].isNew = false; + // Command - Resets the "returnIsNewVarData" boolean state. + // TYPE - String (menu varmenu), VAR - String (variable name) + resetNewVarData(args) { + switch (args.TYPE) { + case 'Global variables': + if (!clVars.gvar.varStates.hasOwnProperty(String(args.VAR))) { + console.warn(`[CloudLink] Global variable ${args.VAR} does not exist!`); + return; } - } + clVars.gvar.varStates[String(args.ID)].hasNew = false; + case 'Private variables': + if (!clVars.pvar.varStates.hasOwnProperty(String(args.VAR))) { + console.warn(`[CloudLink] Private variable ${args.VAR} does not exist!`); + return false; + } + clVars.pvar.varStates[String(args.ID)].hasNew = false; } } - resetNewListener({ ID }) { - const self = this; - if (this.isRunning) { - if (this.socketListeners.hasOwnProperty(String(ID))) { - self.socketListeners[String(ID)] = false; - } + // Command - Resets the "returnIsNewListener" boolean state. + // ID - Listener ID + resetNewListener(args) { + if (!clVars.listeners.varStates.hasOwnProperty(String(args.ID))) { + console.warn(`[CloudLink] Listener ID ${args.ID} does not exist!`); + return; } + clVars.listeners.varStates[String(args.ID)].hasNew = false; } - clearAllPackets({ TYPE }) { - const self = this; - if (this.menuRemap[String(TYPE)] == "all") { - self.socketData.gmsg = []; - self.socketData.pmsg = []; - self.socketData.direct = []; - self.socketData.statuscode = []; - self.socketData.gvar = []; - self.socketData.pvar = []; - } else { - self.socketData[this.menuRemap[String(TYPE)]] = []; + // Command - Clears all packet queues. + // TYPE - String (menu allmenu) + clearAllPackets(args) { + switch (args.TYPE) { + case 'Global data': + clVars.gmsg.queue = []; + break; + case 'Private data': + clVars.pmsg.queue = []; + break; + case 'Direct data': + clVars.direct.queue = []; + break; + case 'Status code': + clVars.statuscode.queue = []; + break; + case 'Global variables': + clVars.gvar.queue = []; + break; + case 'Private variables': + clVars.pvar.queue = []; + break; + case 'All data': + clVars.gmsg.queue = []; + clVars.pmsg.queue = []; + clVars.direct.queue = []; + clVars.statuscode.queue = []; + clVars.gvar.queue = []; + clVars.pvar.queue = []; + break; } } } - - console.log("CloudLink 4.0 loaded. Detecting unsandboxed mode."); - Scratch.extensions.register(new CloudLink(Scratch.vm.runtime)); + Scratch.extensions.register(new CloudLink()); })(Scratch);