diff --git a/extensions/Cheddarphanie/websockets-plus.js b/extensions/Cheddarphanie/websockets-plus.js new file mode 100644 index 0000000000..8a90f5712b --- /dev/null +++ b/extensions/Cheddarphanie/websockets-plus.js @@ -0,0 +1,621 @@ +// Name: WebSocket V2 +// ID: lemonWebSocketsPlus +// Description: Connect to more than one WebSockets. +// By: Cheddarphanie +// License: Apache-2.0 + +// eslint-ignore + +(function (Scratch) { + "use strict"; + + if (!Scratch.extensions.unsandboxed) { + throw new Error("The WebSocket V2 Extension must run unsandboxed."); + } + + const vm = Scratch.vm; + const extManager = vm.extensionManager; + const runtime = vm.runtime; + const Cast = Scratch.Cast; + + const regenReporters = ["lemonWebSocketsPlus_socketMessage"]; + + if (Scratch.gui) + Scratch.gui.getBlockly().then((SB) => { + const ogCheck = SB.scratchBlocksUtils.isShadowArgumentReporter; + SB.scratchBlocksUtils.isShadowArgumentReporter = function (block) { + const result = ogCheck(block); + if (result) return true; + return block.isShadow() && regenReporters.includes(block.type); + }; + }); + + const createLabel = (txt) => { + return { + blockType: Scratch.BlockType.LABEL, + text: txt, + }; + }; + + class WebsocketV2Ext { + constructor() { + this.debugging = false; + + this.sockets = {}; + + this.lastMessages = {}; + + this.socketStatuses = {}; + + this.socketCloseCodes = {}; + + this.socketCloseReasons = {}; + + this.fetchables = {}; + + this.WebSocketStates = { + 0: "CONNECTING", + 1: "OPEN", + 2: "CLOSING", + 3: "CLOSED", + }; + /** + * + * @param {string} socket + * @returns {Function} + */ + this.listener = function (socket = "") { + return function ({ data }) { + runtime + .startHats("lemonWebSocketsPlus_socketMessageReceived", { + SOCKET: socket, + }) + .forEach((thread) => { + thread.socketMessage = data; + }); + }; + }; + } + getInfo() { + return { + id: "lemonWebSocketsPlus", + name: Scratch.translate("WebSocket V2"), + color1: "#307eff", + color2: "#2c5eb0", + blocks: [ + { + func: "toggleDebugging", + blockType: Scratch.BlockType.BUTTON, + text: Scratch.translate("Toggle Debugging"), + }, + + createLabel("Variables"), + + { + opcode: "websockets", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("websockets"), + }, + { + opcode: "socketState", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("state of socket [SOCKET]"), + disableMonitor: true, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + defaultValue: "socket", + }, + }, + }, + { + opcode: "socketLastMessage", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate( + "last message received from socket [SOCKET]" + ), + disableMonitor: true, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + defaultValue: "socket", + }, + }, + }, + { + opcode: "socketCloseReason", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("reason of socket [SOCKET] closing"), + disableMonitor: true, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + defaultValue: "socket", + }, + }, + }, + { + opcode: "socketCloseCode", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("code of socket [SOCKET] closing"), + disableMonitor: true, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + defaultValue: "socket", + }, + }, + }, + + "---", + + createLabel(Scratch.translate("Blocks")), + + { + opcode: "connect", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("connect to [URL] with id [ID]"), + arguments: { + URL: { + type: Scratch.ArgumentType.STRING, + defaultValue: "wss://echo.websocket.org/", + }, + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: "socket", + }, + }, + }, + { + opcode: "disconnect", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate( + "close connection with socket [ID] with code [C] and reason [R]" + ), + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: "socket", + }, + C: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1000, + }, + R: { + type: Scratch.ArgumentType.STRING, + defaultValue: "fulfilled", + }, + }, + }, + { + opcode: "sendMessage", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate( + "send message [MESSAGE] to socket [SOCKET]" + ), + arguments: { + MESSAGE: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("Hello :)"), + }, + SOCKET: { + type: Scratch.ArgumentType.STRING, + defaultValue: "socket", + }, + }, + }, + + "---", + + createLabel(Scratch.translate("Booleans")), + + { + opcode: "socketExists", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("socket [SOCKET] exists?"), + disableMonitor: true, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + defaultValue: "socket", + }, + }, + }, + { + opcode: "socketConnected", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("connected to socket [SOCKET]?"), + disableMonitor: true, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + defaultValue: "socket", + }, + }, + }, + { + opcode: "socketClosed", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("closed connection with [SOCKET]?"), + disableMonitor: true, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + defaultValue: "socket", + }, + }, + }, + + "---", + + createLabel(Scratch.translate("Events")), + + { + opcode: "socketMessageReceived", + blockType: Scratch.BlockType.EVENT, + text: Scratch.translate( + "when i receive a message from [SOCKET] [MESSAGE]" + ), + isEdgeActivated: false, + hideFromPalette: true, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + menu: "socketMenu", + }, + MESSAGE: {}, + }, + }, + { + opcode: "socketMessage", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("message"), + hideFromPalette: true, + disableMonitor: true, + }, + { + opcode: "socketOpensConnection", + blockType: Scratch.BlockType.EVENT, + text: Scratch.translate("when connection with [SOCKET] opens"), + isEdgeActivated: false, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + menu: "socketMenu", + }, + }, + }, + { + blockType: Scratch.BlockType.XML, + xml: ` + + + + `, + }, + { + opcode: "socketErrored", + blockType: Scratch.BlockType.EVENT, + text: Scratch.translate("when socket [SOCKET] errors"), + isEdgeActivated: false, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + menu: "socketMenu", + }, + }, + }, + { + opcode: "socketClosedConnection", + blockType: Scratch.BlockType.EVENT, + text: Scratch.translate("when connection with [SOCKET] closes"), + isEdgeActivated: false, + arguments: { + SOCKET: { + type: Scratch.ArgumentType.STRING, + menu: "socketMenu", + }, + }, + }, + ], + menus: { + socketMenu: { + items: "getSockets", + }, + }, + }; + } + + toggleDebugging() { + this.debugging = !this.debugging; + window.alert(Scratch.translate("Toggled Debugging! :)")); + } + + async connect({ ID, URL }) { + const id = Cast.toString(ID); + const url = Cast.toString(URL); + + if (this.sockets[id] instanceof WebSocket) { + try { + this.sockets[id].removeEventListener("message", this.listener(id)); + this.sockets[id].removeEventListener("error", () => { + runtime.startHats("lemonWebSocketsPlus_socketErrored", { + SOCKET: id, + }); + }); + this.sockets[id].removeEventListener("message", ({ data }) => { + this.lastMessages[id] = data; + }); + this.sockets[id].removeEventListener("close", ({ reason, code }) => { + runtime.startHats("lemonWebSocketsPlus_socketClosedConnection", { + SOCKET: id, + }); + this.socketCloseReasons[id] = reason; + this.socketCloseCodes[id] = code; + }); + this.sockets[id].removeEventListener("open", () => { + runtime.startHats("lemonWebSocketsPlus_socketOpensConnection", { + SOCKET: id, + }); + }); + this.sockets[id].close(); + } catch (err) { + console.error(err); + } + } + + if (this.debugging) console.groupCollapsed("WebSocket V2 Connecting"); + + if (this.debugging) + console.log(`[WebSockets V2] Attempting to connect to '${url}'..`); + + if (!this.fetchables[url]) + this.fetchables[url] = await Scratch.canFetch(url); + + if (!this.fetchables[url]) { + this.socketStatuses[id] = "failed to connect"; + if (this.debugging) { + console.log(`[Websocket V2] Connection to '${url}' denied!`); + console.groupEnd(); + } + return; + } + + try { + this.sockets[id] = new WebSocket(url); + this.socketStatuses[id] = "connected"; + this.socketCloseCodes[id] = 0; + this.socketCloseReasons[id] = ""; + + /** + * @type {WebSocket} + */ + const socket = this.sockets[id]; + + socket.addEventListener("message", this.listener(id)); + socket.addEventListener("error", () => { + runtime.startHats("lemonWebSocketsPlus_socketErrored", { + SOCKET: id, + }); + }); + socket.addEventListener("message", ({ data }) => { + this.lastMessages[id] = data; + }); + socket.addEventListener("close", ({ reason, code }) => { + runtime.startHats("lemonWebSocketsPlus_socketClosedConnection", { + SOCKET: id, + }); + this.socketCloseReasons[id] = reason; + this.socketCloseCodes[id] = code; + }); + socket.addEventListener("open", () => { + runtime.startHats("lemonWebSocketsPlus_socketOpensConnection", { + SOCKET: id, + }); + }); + + extManager.refreshBlocks("lemonWebSocketsPlus"); + vm.refreshWorkspace(); + + if (this.debugging) + console.log(`[WebSocket V2] Successfully connected to '${url}'.`); + } catch (err) { + console.error(err); + this.socketStatuses[id] = "failed to connect"; + } + + if (this.debugging) console.groupEnd(); + } + + disconnect({ ID, C, R }) { + const id = Cast.toString(ID); + const Code = Cast.toNumber(C); + const Reason = Cast.toString(R); + + if (this.debugging) + console.groupCollapsed("WebSocket V2 Closing Connection"); + + if (this.debugging) + console.log( + `[WebSocket V2] Attemping to close connection with '${id}'..` + ); + + const socket = this.sockets[id]; + + if (socket instanceof WebSocket) { + socket.removeEventListener("message", this.listener(id)); + socket.removeEventListener("error", () => { + runtime.startHats("lemonWebSocketsPlus_socketErrored", { + SOCKET: id, + }); + }); + socket.removeEventListener("message", ({ data }) => { + this.lastMessages[id] = data; + }); + socket.removeEventListener("close", ({ reason, code }) => { + runtime.startHats("lemonWebSocketsPlus_socketClosedConnection", { + SOCKET: id, + }); + this.socketCloseReasons[id] = reason; + this.socketCloseCodes[id] = code; + }); + socket.removeEventListener("open", () => { + runtime.startHats("lemonWebSocketsPlus_socketOpensConnection", { + SOCKET: id, + }); + }); + + socket.close(Code, Reason); + + delete this.sockets[id]; + this.socketCloseCodes[id] = Code; + this.socketCloseReasons[id] = Reason; + this.socketStatuses[id] = "closed"; + + extManager.refreshBlocks("lemonWebSocketsPlus"); + vm.refreshWorkspace(); + + if (this.debugging) + console.log( + `[WebSocket V2] Successfully closed connection with '${id}'!` + ); + } else { + if (this.debugging) + console.warn(`[WebSocket V2] WebSocket '${id}' is not a WebSocket!`); + } + + if (this.debugging) console.groupEnd(); + + return; + } + + sendMessage({ MESSAGE, SOCKET }) { + if (this.debugging) { + console.groupCollapsed("WebSocket V2 Message Sending"); + + console.log( + `[WebSocket V2] Attempting to send a message to '${SOCKET}'..` + ); + } + SOCKET = Cast.toString(SOCKET); + const socket = this.sockets[SOCKET]; + + if (!socket) { + if (this.debugging) { + console.warn(`[WebSockets+] '${SOCKET}' doesn't exist!`); + console.groupEnd(); + } + return; + } + + if (socket instanceof WebSocket) { + try { + socket.send(MESSAGE); + if (this.debugging) + console.log( + `[WebSocket V2] Successfully sent a message to '${SOCKET}'!` + ); + } catch (err) { + console.error(err); + if (this.debugging) console.groupEnd(); + } + } else { + if (this.debugging) + console.warn(`[WebSocket V2] '${SOCKET}' isn't a WebSocket!`); + } + if (this.debugging) console.groupEnd(); + } + + socketExists({ SOCKET }) { + return Cast.toBoolean(this.sockets[Cast.toString(SOCKET)]); + } + + socketConnected({ SOCKET }) { + const socket = this.sockets[Cast.toString(SOCKET)]; + + if (socket instanceof WebSocket) { + return socket.readyState === WebSocket.OPEN; + } + + return false; + } + + socketClosed({ SOCKET }) { + const socket = this.sockets[Cast.toString(SOCKET)]; + + if (socket instanceof WebSocket) { + return socket.readyState === WebSocket.CLOSED; + } + + return true; + } + + websockets() { + return JSON.stringify(Object.keys(this.sockets)); + } + + socketState({ SOCKET }) { + const socket = this.sockets[Cast.toString(SOCKET)]; + if (socket instanceof WebSocket) { + return this.WebSocketStates[socket.readyState] ?? "UNKNOWN"; + } + return "UNKNOWN"; + } + + socketLastMessage({ SOCKET }) { + const socket = this.lastMessages[Cast.toString(SOCKET)]; + return socket ?? ""; + } + + socketCloseReason({ SOCKET }) { + return this.socketCloseReasons[Cast.toString(SOCKET)] ?? ""; + } + + socketCloseCode({ SOCKET }) { + return this.socketCloseCodes[Cast.toString(SOCKET)] ?? 0; + } + + getSockets() { + const Sockets = Object.keys(this.sockets); + return Sockets.length > 0 + ? Sockets.map((socket) => { + return { + value: socket, + text: socket, + }; + }) + : [ + { + value: Scratch.translate("None yet :("), + text: Scratch.translate("None yet :("), + }, + ]; + } + + /** + * @param {{}} args + * @param {VM.BlockUtility} util + */ + socketMessage(args, util) { + return util.thread.socketMessage ?? ""; + } + + socketOpensConnection() { + return; + } + + socketMessageReceived() { + return; + } + + socketErrored() { + return; + } + + socketClosedConnection() { + return; + } + } + + Scratch.extensions.register(new WebsocketV2Ext()); +})(Scratch); diff --git a/extensions/extensions.json b/extensions/extensions.json index d8ed71e33d..2a79f01fc7 100644 --- a/extensions/extensions.json +++ b/extensions/extensions.json @@ -89,6 +89,7 @@ "vercte/dictionaries", "godslayerakp/http", "godslayerakp/ws", + "Cheddarphanie/websockets-plus", "Lily/CommentBlocks", "veggiecan/LongmanDictionary", "CubesterYT/TurboHook",