Add NDN Play integration to Mini-NDN code base

Integrate the minindn_play project by Varun Patil into
the base Mini-NDN codebase; this will allow for the
use of the NDN-Play browser UI with minimal additional
dependencies or setup.

Refs #5359

Change-Id: I4fedfa885b07d7fe946a18c6d9b5016d291b3582
diff --git a/minindn/minindn_play/socket.py b/minindn/minindn_play/socket.py
new file mode 100644
index 0000000..69e054a
--- /dev/null
+++ b/minindn/minindn_play/socket.py
@@ -0,0 +1,126 @@
+import os
+import asyncio
+import urllib
+import msgpack
+import secrets
+import time
+import webbrowser
+from threading import Thread
+
+import websockets
+
+from mininet.log import error
+from minindn.minindn_play.consts import Config, WSKeys
+
+class PlaySocket:
+    conn_list: dict = {}
+    executors: list = []
+    AUTH_TOKEN: str | None = None
+
+    def __init__(self):
+        """Initialize the PlaySocket.
+        This starts the background loop and creates the websocket server.
+        Calls to UI async functions are made from this class.
+        """
+        self._set_auth_token()
+        self.loop = asyncio.new_event_loop()
+        Thread(target=self.loop.run_forever, args=(), daemon=True).start()
+        self.loop.call_soon_threadsafe(self.loop.create_task, self._run())
+
+    def add_executor(self, executor):
+        self.executors.append(executor)
+
+    def send(self, websocket, msg):
+        """Send message to UI threadsafe"""
+        if websocket.state == websockets.protocol.State.OPEN:
+            self.loop.call_soon_threadsafe(self.loop.create_task, websocket.send(msg))
+
+    def send_all(self, msg):
+        """Send message to all UI threadsafe"""
+        for websocket in self.conn_list.copy():
+            try:
+                self.send(websocket, msg)
+            except Exception as err:
+                error(f'Failed to send to {websocket.remote_address} with error {err}\n')
+                del self.conn_list[websocket]
+
+    def _set_auth_token(self):
+        """Create auth token if it doesn't exist."""
+        # Perist auth token so you don't need to refresh every time
+        # Check if AUTH_FILE was modified less than a day ago
+        if os.path.exists(Config.AUTH_FILE) and time.time() - os.path.getmtime(Config.AUTH_FILE) < 24 * 60 * 60:
+            with open(Config.AUTH_FILE, 'r') as f:
+                self.AUTH_TOKEN = f.read().strip()
+
+        if not self.AUTH_TOKEN or len(self.AUTH_TOKEN) < 10:
+            self.AUTH_TOKEN = secrets.token_hex(16)
+            with open(Config.AUTH_FILE, 'w') as f:
+                f.write(self.AUTH_TOKEN)
+
+    async def _run(self) -> None:
+        """Runs in separate thread from main"""
+        # Show the URL to the user
+        ws_url = f"ws://{Config.SERVER_HOST_URL}:{Config.SERVER_PORT}"
+        ws_url_q = urllib.parse.quote(ws_url.encode('utf8'))
+        full_url = f"{Config.PLAY_URL}/?minindn={ws_url_q}&auth={self.AUTH_TOKEN}"
+        print(f"Opened NDN Play GUI at {full_url}")
+        webbrowser.open(full_url, 1)
+
+        # Start server
+        asyncio.set_event_loop(self.loop)
+
+        server = await websockets.serve(self._serve, Config.SERVER_HOST, Config.SERVER_PORT)
+        await server.serve_forever()
+
+    async def _serve(self, websocket):
+        """Handle websocket connection"""
+
+        try:
+            path = websocket.request.path
+            auth = urllib.parse.parse_qs(urllib.parse.urlparse(path).query)['auth'][0]
+            if auth != self.AUTH_TOKEN:
+                raise Exception("Invalid auth token")
+        except Exception:
+            print(f"Rejected connection from {websocket.remote_address}")
+            await websocket.close()
+            return
+
+        print(f"Accepted connection from {websocket.remote_address}")
+        self.conn_list[websocket] = 1
+        while True:
+            try:
+                fcall = msgpack.loads(await websocket.recv())
+                loop = asyncio.get_event_loop()
+                loop.create_task(self._call_fun(websocket, fcall))
+            except websockets.exceptions.ConnectionClosedOK:
+                print(f"Closed connection gracefully from {websocket.remote_address}")
+                break
+            except websockets.exceptions.ConnectionClosedError:
+                print(f"Closed connection with error from {websocket.remote_address}")
+                break
+
+        del self.conn_list[websocket]
+
+    async def _call_fun(self, websocket, fcall):
+        """Call function and return result to UI asynchronously"""
+
+        # Get function from any executor
+        fun = None
+        for executor in self.executors:
+            fun = getattr(executor, fcall[WSKeys.MSG_KEY_FUN], None)
+            if fun is not None:
+                break
+
+        # Function not found
+        if fun is None:
+            error(f"Function {fcall[WSKeys.MSG_KEY_FUN]} not found\n")
+            return # function not found
+
+        # Call function
+        res = await fun(*fcall[WSKeys.MSG_KEY_ARGS])
+        if res is not None:
+            pack = msgpack.dumps({
+                WSKeys.MSG_KEY_FUN: fcall[WSKeys.MSG_KEY_FUN],
+                WSKeys.MSG_KEY_RESULT: res,
+            })
+            await websocket.send(pack)