diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 18c3502..d517940 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,7 +45,10 @@ jobs: artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe" build_mac: - runs-on: macos-13 + strategy: + matrix: + os: [ macos-15, macos-15-intel ] + runs-on: ${{ matrix.os }} permissions: contents: write steps: @@ -80,7 +83,7 @@ jobs: replacesArtifacts: true omitDraftDuringUpdate: true omitNameDuringUpdate: true - artifacts: "dist/*-mac.dmg" + artifacts: "dist/*-mac-*.dmg" build_linux: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 6c5e0e3..6f25ccc 100644 --- a/README.md +++ b/README.md @@ -283,20 +283,6 @@ I build the vite app everytime without hot reload, since MeshChat expects everyt ## TODO - [ ] button to forget announces -- [ ] support for managing Reticulum interfaces via the web ui - - [x] AutoInterface - - [x] RNodeInterface - - [x] TCPClientInterface - - [x] TCPServerInterface - - [x] UDPInterface - - [ ] I2PInterface - - [ ] SerialInterface - - [ ] PipeInterface - - [ ] KISSInterface - - [ ] AX25KISSInterface - - [ ] Other Options - - [ ] network_name - - [ ] passphrase # Notes diff --git a/database.py b/database.py index 11a7427..c9ad27c 100644 --- a/database.py +++ b/database.py @@ -95,6 +95,21 @@ class CustomDestinationDisplayName(BaseModel): table_name = "custom_destination_display_names" +class FavouriteDestination(BaseModel): + + id = BigAutoField() + destination_hash = CharField(unique=True) # unique destination hash + display_name = CharField() # custom display name for the destination hash + aspect = CharField() # e.g: nomadnetwork.node + + created_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) + updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) + + # define table name + class Meta: + table_name = "favourite_destinations" + + class LxmfMessage(BaseModel): id = BigAutoField() diff --git a/electron/main.js b/electron/main.js index d58ce1d..bdaa0cc 100644 --- a/electron/main.js +++ b/electron/main.js @@ -22,6 +22,27 @@ ipcMain.handle('alert', async(event, message) => { }); }); +// add support for showing a confirm window via ipc +ipcMain.handle('confirm', async(event, message) => { + + // show confirm dialog + const result = await dialog.showMessageBox(mainWindow, { + type: "question", + title: "Confirm", + message: message, + cancelId: 0, // esc key should press cancel button + defaultId: 1, // enter key should press ok button + buttons: [ + "Cancel", // 0 + "OK", // 1 + ], + }); + + // check if user clicked OK + return result.response === 1; + +}); + // add support for showing a prompt window via ipc ipcMain.handle('prompt', async(event, message) => { return await electronPrompt({ @@ -48,6 +69,9 @@ ipcMain.handle('showPathInFolder', (event, path) => { function log(message) { + // log to stdout of this process + console.log(message); + // make sure main window exists if(!mainWindow){ return; @@ -58,9 +82,6 @@ function log(message) { return; } - // log to electron console - console.log(message); - // log to web console mainWindow.webContents.send('log', message); @@ -98,50 +119,64 @@ function getDefaultReticulumConfigDir() { app.whenReady().then(async () => { - // create browser window - mainWindow = new BrowserWindow({ - width: 1500, - height: 800, - webPreferences: { - // used to inject logging over ipc - preload: path.join(__dirname, 'preload.js'), - }, - }); + // get arguments passed to application, and remove the provided application path + const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"]; + const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg)); + const shouldLaunchHeadless = userProvidedArguments.includes("--headless"); - // open external links in default web browser instead of electron - mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if(!shouldLaunchHeadless){ - var shouldShowInNewElectronWindow = false; + // create browser window + mainWindow = new BrowserWindow({ + width: 1500, + height: 800, + webPreferences: { + // used to inject logging over ipc + preload: path.join(__dirname, 'preload.js'), + }, + }); - // we want to open call.html in a new electron window - // but all other target="_blank" links should open in the system web browser - // we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices... - if(url.startsWith("http://localhost") && url.includes("/call.html")){ - shouldShowInNewElectronWindow = true; - } + // open external links in default web browser instead of electron + mainWindow.webContents.setWindowOpenHandler(({ url }) => { - // we want to open blob urls in a new electron window - else if(url.startsWith("blob:")) { - shouldShowInNewElectronWindow = true; - } + var shouldShowInNewElectronWindow = false; - // open in new electron window - if(shouldShowInNewElectronWindow){ + // we want to open call.html in a new electron window + // but all other target="_blank" links should open in the system web browser + // we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices... + if(url.startsWith("http://localhost") && url.includes("/call.html")){ + shouldShowInNewElectronWindow = true; + } + + // we want to open blob urls in a new electron window + else if(url.startsWith("blob:")) { + shouldShowInNewElectronWindow = true; + } + + // open in new electron window + if(shouldShowInNewElectronWindow){ + return { + action: "allow", + }; + } + + // fallback to opening any other url in external browser + shell.openExternal(url); return { - action: "allow", + action: "deny", }; + + }); + + // navigate to loading page + await mainWindow.loadFile(path.join(__dirname, 'loading.html')); + + // ask mac users for microphone access for audio calls to work + if(process.platform === "darwin"){ + await systemPreferences.askForMediaAccess('microphone'); } - // fallback to opening any other url in external browser - shell.openExternal(url); - return { - action: "deny", - }; - - }); - - // navigate to loading page - await mainWindow.loadFile(path.join(__dirname, 'loading.html')); + } // find path to python/cxfreeze reticulum meshchat executable const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat"; @@ -152,16 +187,8 @@ app.whenReady().then(async () => { exe = path.join(__dirname, '..', `build/exe/${exeName}`); } - // ask mac users for microphone access for audio calls to work - if(process.platform === "darwin"){ - await systemPreferences.askForMediaAccess('microphone'); - } - try { - // get arguments passed to application, and remove the provided application path - const userProvidedArguments = process.argv.slice(1); - // arguments we always want to pass in const requiredArguments = [ '--headless', // reticulum meshchat usually launches default web browser, we don't want this when using electron diff --git a/electron/preload.js b/electron/preload.js index 8abc460..5465420 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -15,6 +15,11 @@ contextBridge.exposeInMainWorld('electron', { return await ipcRenderer.invoke('alert', message); }, + // show a confirm dialog in electron browser window, this fixes a bug where confirm breaks input fields on windows + confirm: async function(message) { + return await ipcRenderer.invoke('confirm', message); + }, + // add support for using "prompt" in electron browser window prompt: async function(message) { return await ipcRenderer.invoke('prompt', message); diff --git a/meshchat.py b/meshchat.py index f43201a..448c7a3 100644 --- a/meshchat.py +++ b/meshchat.py @@ -1,8 +1,10 @@ #!/usr/bin/env python import argparse +import io import json import os +import platform import sys import threading import time @@ -26,6 +28,7 @@ from src.backend.announce_handler import AnnounceHandler from src.backend.async_utils import AsyncUtils from src.backend.colour_utils import ColourUtils from src.backend.interface_config_parser import InterfaceConfigParser +from src.backend.interface_editor import InterfaceEditor from src.backend.lxmf_message_fields import LxmfImageField, LxmfFileAttachmentsField, LxmfFileAttachment, LxmfAudioField from src.backend.audio_call_manager import AudioCall, AudioCallManager from src.backend.sideband_commands import SidebandCommands @@ -76,6 +79,7 @@ class ReticulumMeshChat: database.Config, database.Announce, database.CustomDestinationDisplayName, + database.FavouriteDestination, database.LxmfMessage, database.LxmfConversationReadState, database.LxmfUserIcon, @@ -269,7 +273,7 @@ class ReticulumMeshChat: # handle receiving a new audio call def on_incoming_audio_call(self, audio_call: AudioCall): print("on_incoming_audio_call: {}".format(audio_call.link.hash.hex())) - asyncio.run(self.websocket_broadcast(json.dumps({ + AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ "type": "incoming_audio_call", }))) @@ -329,8 +333,29 @@ class ReticulumMeshChat: if "interfaces" in self.reticulum.config: interfaces = self.reticulum.config["interfaces"] + processed_interfaces = {} + for interface_name, interface in interfaces.items(): + interface_data = interface.copy() + + # handle sub-interfaces for RNodeMultiInterface + if interface_data.get("type") == "RNodeMultiInterface": + sub_interfaces = [] + for sub_name, sub_config in interface_data.items(): + if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode", "configured_bitrate"}: + if isinstance(sub_config, dict): + sub_config["name"] = sub_name + sub_interfaces.append(sub_config) + + # add sub-interfaces to the main interface data + interface_data["sub_interfaces"] = sub_interfaces + + for sub in sub_interfaces: + del interface_data[sub["name"]] + + processed_interfaces[interface_name] = interface_data + return web.json_response({ - "interfaces": interfaces, + "interfaces": processed_interfaces, }) # enable reticulum interface @@ -443,94 +468,178 @@ class ReticulumMeshChat: if "enabled" not in interface_details and "interface_enabled" not in interface_details: interface_details["interface_enabled"] = "true" - # handle tcp client interface + # handle AutoInterface + if interface_type == "AutoInterface": + + # set optional AutoInterface options + InterfaceEditor.update_value(interface_details, data, "group_id") + InterfaceEditor.update_value(interface_details, data, "multicast_address_type") + InterfaceEditor.update_value(interface_details, data, "devices") + InterfaceEditor.update_value(interface_details, data, "ignored_devices") + InterfaceEditor.update_value(interface_details, data, "discovery_scope") + InterfaceEditor.update_value(interface_details, data, "discovery_port") + InterfaceEditor.update_value(interface_details, data, "data_port") + + # handle TCPClientInterface if interface_type == "TCPClientInterface": - interface_target_host = data.get('target_host') - interface_target_port = data.get('target_port') - # ensure target host provided + interface_target_host = data.get('target_host') if interface_target_host is None or interface_target_host == "": return web.json_response({ "message": "Target Host is required", }, status=422) # ensure target port provided + interface_target_port = data.get('target_port') if interface_target_port is None or interface_target_port == "": return web.json_response({ "message": "Target Port is required", }, status=422) - interface_details["target_host"] = data.get('target_host') - interface_details["target_port"] = data.get('target_port') + # set required TCPClientInterface options + interface_details["target_host"] = interface_target_host + interface_details["target_port"] = interface_target_port + + # set optional TCPClientInterface options + InterfaceEditor.update_value(interface_details, data, "kiss_framing") + InterfaceEditor.update_value(interface_details, data, "i2p_tunneled") + + # handle I2P interface + if interface_type == "I2PInterface": + interface_details['connectable'] = "True" + interface_details["peers"] = data.get('peers') # handle tcp server interface if interface_type == "TCPServerInterface": - interface_listen_ip = data.get('listen_ip') - interface_listen_port = data.get('listen_port') - # ensure listen ip provided + interface_listen_ip = data.get('listen_ip') if interface_listen_ip is None or interface_listen_ip == "": return web.json_response({ "message": "Listen IP is required", }, status=422) # ensure listen port provided + interface_listen_port = data.get('listen_port') if interface_listen_port is None or interface_listen_port == "": return web.json_response({ "message": "Listen Port is required", }, status=422) - interface_details["listen_ip"] = data.get('listen_ip') - interface_details["listen_port"] = data.get('listen_port') + # set required TCPServerInterface options + interface_details["listen_ip"] = interface_listen_ip + interface_details["listen_port"] = interface_listen_port + + # set optional TCPServerInterface options + InterfaceEditor.update_value(interface_details, data, "device") + InterfaceEditor.update_value(interface_details, data, "prefer_ipv6") # handle udp interface if interface_type == "UDPInterface": - interface_listen_ip = data.get('listen_ip') - interface_listen_port = data.get('listen_port') - interface_forward_ip = data.get('forward_ip') - interface_forward_port = data.get('forward_port') - # ensure listen ip provided + interface_listen_ip = data.get('listen_ip') if interface_listen_ip is None or interface_listen_ip == "": return web.json_response({ "message": "Listen IP is required", }, status=422) # ensure listen port provided + interface_listen_port = data.get('listen_port') if interface_listen_port is None or interface_listen_port == "": return web.json_response({ "message": "Listen Port is required", }, status=422) # ensure forward ip provided + interface_forward_ip = data.get('forward_ip') if interface_forward_ip is None or interface_forward_ip == "": return web.json_response({ "message": "Forward IP is required", }, status=422) # ensure forward port provided + interface_forward_port = data.get('forward_port') if interface_forward_port is None or interface_forward_port == "": return web.json_response({ "message": "Forward Port is required", }, status=422) - interface_details["listen_ip"] = data.get('listen_ip') - interface_details["listen_port"] = data.get('listen_port') - interface_details["forward_ip"] = data.get('forward_ip') - interface_details["forward_port"] = data.get('forward_port') + # set required UDPInterface options + interface_details["listen_ip"] = interface_listen_ip + interface_details["listen_port"] = interface_listen_port + interface_details["forward_ip"] = interface_forward_ip + interface_details["forward_port"] = interface_forward_port - # handle rnode interface + # set optional UDPInterface options + InterfaceEditor.update_value(interface_details, data, "device") + + # handle RNodeInterface if interface_type == "RNodeInterface": + # ensure port provided interface_port = data.get('port') + if interface_port is None or interface_port == "": + return web.json_response({ + "message": "Port is required", + }, status=422) + + # ensure frequency provided interface_frequency = data.get('frequency') + if interface_frequency is None or interface_frequency == "": + return web.json_response({ + "message": "Frequency is required", + }, status=422) + + # ensure bandwidth provided interface_bandwidth = data.get('bandwidth') + if interface_bandwidth is None or interface_bandwidth == "": + return web.json_response({ + "message": "Bandwidth is required", + }, status=422) + + # ensure txpower provided interface_txpower = data.get('txpower') + if interface_txpower is None or interface_txpower == "": + return web.json_response({ + "message": "TX power is required", + }, status=422) + + # ensure spreading factor provided interface_spreadingfactor = data.get('spreadingfactor') + if interface_spreadingfactor is None or interface_spreadingfactor == "": + return web.json_response({ + "message": "Spreading Factor is required", + }, status=422) + + # ensure coding rate provided interface_codingrate = data.get('codingrate') + if interface_codingrate is None or interface_codingrate == "": + return web.json_response({ + "message": "Coding Rate is required", + }, status=422) + + # set required RNodeInterface options + interface_details["port"] = interface_port + interface_details["frequency"] = interface_frequency + interface_details["bandwidth"] = interface_bandwidth + interface_details["txpower"] = interface_txpower + interface_details["spreadingfactor"] = interface_spreadingfactor + interface_details["codingrate"] = interface_codingrate + + # set optional RNodeInterface options + InterfaceEditor.update_value(interface_details, data, "callsign") + InterfaceEditor.update_value(interface_details, data, "id_interval") + InterfaceEditor.update_value(interface_details, data, "airtime_limit_long") + InterfaceEditor.update_value(interface_details, data, "airtime_limit_short") + + # handle RNodeMultiInterface + if interface_type == "RNodeMultiInterface": + + # required settings + interface_port = data.get("port") + sub_interfaces = data.get("sub_interfaces", []) # ensure port provided if interface_port is None or interface_port == "": @@ -538,42 +647,114 @@ class ReticulumMeshChat: "message": "Port is required", }, status=422) - # ensure frequency provided - if interface_frequency is None or interface_frequency == "": + # ensure sub interfaces provided + if not isinstance(sub_interfaces, list) or not sub_interfaces: return web.json_response({ - "message": "Frequency is required", - }, status=422) - - # ensure bandwidth provided - if interface_bandwidth is None or interface_bandwidth == "": - return web.json_response({ - "message": "Bandwidth is required", - }, status=422) - - # ensure txpower provided - if interface_txpower is None or interface_txpower == "": - return web.json_response({ - "message": "TX power is required", - }, status=422) - - # ensure spreading factor provided - if interface_spreadingfactor is None or interface_spreadingfactor == "": - return web.json_response({ - "message": "Spreading Factor is required", - }, status=422) - - # ensure coding rate provided - if interface_codingrate is None or interface_codingrate == "": - return web.json_response({ - "message": "Coding Rate is required", + "message": "At least one sub-interface is required", }, status=422) + # set required RNodeMultiInterface options interface_details["port"] = interface_port - interface_details["frequency"] = interface_frequency - interface_details["bandwidth"] = interface_bandwidth - interface_details["txpower"] = interface_txpower - interface_details["spreadingfactor"] = interface_spreadingfactor - interface_details["codingrate"] = interface_codingrate + + # remove any existing sub interfaces, which can be found by finding keys that contain a dict value + # this allows us to replace all sub interfaces with the ones we are about to add, while also ensuring + # that we do not remove any existing config values from the main interface config + for key in interface_details: + value = interface_details[key] + if isinstance(value, dict): + del interface_details[key] + + # process each provided sub interface + for idx, sub_interface in enumerate(sub_interfaces): + + # ensure required fields for sub-interface provided + missing_fields = [] + required_subinterface_fields = ["name", "frequency", "bandwidth", "txpower", "spreadingfactor", "codingrate", "vport"] + for field in required_subinterface_fields: + if field not in sub_interface or sub_interface.get(field) is None or sub_interface.get(field) == "": + missing_fields.append(field) + if missing_fields: + return web.json_response({ + "message": f"Sub-interface {idx + 1} is missing required field(s): {', '.join(missing_fields)}" + }, status=422) + + sub_interface_name = sub_interface.get("name") + interface_details[sub_interface_name] = { + "interface_enabled": "true", + "frequency": int(sub_interface["frequency"]), + "bandwidth": int(sub_interface["bandwidth"]), + "txpower": int(sub_interface["txpower"]), + "spreadingfactor": int(sub_interface["spreadingfactor"]), + "codingrate": int(sub_interface["codingrate"]), + "vport": int(sub_interface["vport"]), + } + + interfaces[interface_name] = interface_details + + # handle SerialInterface, KISSInterface, and AX25KISSInterface + if interface_type == "SerialInterface" or interface_type == "KISSInterface" or interface_type == "AX25KISSInterface": + + # ensure port provided + interface_port = data.get('port') + if interface_port is None or interface_port == "": + return web.json_response({ + "message": "Port is required", + }, status=422) + + # set required options + interface_details["port"] = interface_port + + # set optional options + InterfaceEditor.update_value(interface_details, data, "speed") + InterfaceEditor.update_value(interface_details, data, "databits") + InterfaceEditor.update_value(interface_details, data, "parity") + InterfaceEditor.update_value(interface_details, data, "stopbits") + + # Handle KISS and AX25KISS specific options + if interface_type == "KISSInterface" or interface_type == "AX25KISSInterface": + + # set optional options + InterfaceEditor.update_value(interface_details, data, "preamble") + InterfaceEditor.update_value(interface_details, data, "txtail") + InterfaceEditor.update_value(interface_details, data, "persistence") + InterfaceEditor.update_value(interface_details, data, "slottime") + InterfaceEditor.update_value(interface_details, data, "callsign") + InterfaceEditor.update_value(interface_details, data, "ssid") + + # FIXME: move to own sections + # RNode Airtime limits and station ID + InterfaceEditor.update_value(interface_details, data, "callsign") + InterfaceEditor.update_value(interface_details, data, "id_interval") + InterfaceEditor.update_value(interface_details, data, "airtime_limit_long") + InterfaceEditor.update_value(interface_details, data, "airtime_limit_short") + + # handle Pipe Interface + if interface_type == "PipeInterface": + + # ensure command provided + interface_command = data.get('command') + if interface_command is None or interface_command == "": + return web.json_response({ + "message": "Command is required", + }, status=422) + + # ensure command provided + interface_respawn_delay = data.get('respawn_delay') + if interface_respawn_delay is None or interface_respawn_delay == "": + return web.json_response({ + "message": "Respawn delay is required", + }, status=422) + + # set required options + interface_details["command"] = interface_command + interface_details["respawn_delay"] = interface_respawn_delay + + # set common interface options + InterfaceEditor.update_value(interface_details, data, "bitrate") + InterfaceEditor.update_value(interface_details, data, "mode") + InterfaceEditor.update_value(interface_details, data, "network_name") + InterfaceEditor.update_value(interface_details, data, "passphrase") + InterfaceEditor.update_value(interface_details, data, "ifac_size") # merge new interface into existing interfaces interfaces[interface_name] = interface_details @@ -617,9 +798,22 @@ class ReticulumMeshChat: # add interface to output output.append(f"[[{interface_name}]]") for key, value in interface.items(): - output.append(f" {key} = {value}") + if not isinstance(value, dict): + output.append(f" {key} = {value}") output.append("") - + + # Handle sub-interfaces for RNodeMultiInterface + if interface.get("type") == "RNodeMultiInterface": + for sub_name, sub_config in interface.items(): + if sub_name in {"type", "port", "interface_enabled"}: + continue + if isinstance(sub_config, dict): + output.append(f" [[[{sub_name}]]]") + for sub_key, sub_value in sub_config.items(): + output.append(f" {sub_key} = {sub_value}") + output.append("") + + return web.Response( text="\n".join(output), content_type="text/plain", @@ -751,6 +945,7 @@ class ReticulumMeshChat: "version": self.get_app_version(), "lxmf_version": LXMF.__version__, "rns_version": RNS.__version__, + "python_version": platform.python_version(), "storage_path": self.storage_path, "database_path": self.database_path, "database_file_size": os.path.getsize(self.database_path), @@ -785,6 +980,10 @@ class ReticulumMeshChat: @routes.post("/api/v1/reticulum/enable-transport") async def index(request): + # ensure reticulum section exists + if "reticulum" not in self.reticulum.config: + self.reticulum.config["reticulum"] = {} + # enable transport mode self.reticulum.config["reticulum"]["enable_transport"] = True self.reticulum.config.write() @@ -797,6 +996,10 @@ class ReticulumMeshChat: @routes.post("/api/v1/reticulum/disable-transport") async def index(request): + # ensure reticulum section exists + if "reticulum" not in self.reticulum.config: + self.reticulum.config["reticulum"] = {} + # disable transport mode self.reticulum.config["reticulum"]["enable_transport"] = False self.reticulum.config.write() @@ -924,7 +1127,7 @@ class ReticulumMeshChat: def on_audio_packet(data): if websocket_response.closed is False: try: - asyncio.run(websocket_response.send_bytes(data)) + AsyncUtils.run_async(websocket_response.send_bytes(data)) except: # ignore errors sending audio packets to websocket pass @@ -933,7 +1136,7 @@ class ReticulumMeshChat: def on_hangup(): if websocket_response.closed is False: try: - asyncio.run(websocket_response.close(code=WSCloseCode.GOING_AWAY)) + AsyncUtils.run_async(websocket_response.close(code=WSCloseCode.GOING_AWAY)) except: # ignore errors closing websocket pass @@ -1007,6 +1210,7 @@ class ReticulumMeshChat: # get query params aspect = request.query.get("aspect", None) identity_hash = request.query.get("identity_hash", None) + destination_hash = request.query.get("destination_hash", None) limit = request.query.get("limit", None) # build announces database query @@ -1020,6 +1224,10 @@ class ReticulumMeshChat: if identity_hash is not None: query = query.where(database.Announce.identity_hash == identity_hash) + # filter by provided destination hash + if destination_hash is not None: + query = query.where(database.Announce.destination_hash == destination_hash) + # limit results if limit is not None: query = query.limit(limit) @@ -1036,6 +1244,98 @@ class ReticulumMeshChat: "announces": announces, }) + # serve favourites + @routes.get("/api/v1/favourites") + async def index(request): + + # get query params + aspect = request.query.get("aspect", None) + + # build favourites database query + query = database.FavouriteDestination.select() + + # filter by provided aspect + if aspect is not None: + query = query.where(database.FavouriteDestination.aspect == aspect) + + # order favourites alphabetically + query_results = query.order_by(database.FavouriteDestination.display_name.asc()) + + # process favourites + favourites = [] + for favourite in query_results: + favourites.append(self.convert_db_favourite_to_dict(favourite)) + + return web.json_response({ + "favourites": favourites, + }) + + # add favourite + @routes.post("/api/v1/favourites/add") + async def index(request): + + # get request data + data = await request.json() + destination_hash = data.get("destination_hash", None) + display_name = data.get("display_name", None) + aspect = data.get("aspect", None) + + # destination hash is required + if destination_hash is None: + return web.json_response({ + "message": "destination_hash is required", + }, status=422) + + # display name is required + if display_name is None: + return web.json_response({ + "message": "display_name is required", + }, status=422) + + # aspect is required + if aspect is None: + return web.json_response({ + "message": "aspect is required", + }, status=422) + + # upsert favourite + self.db_upsert_favourite(destination_hash, display_name, aspect) + return web.json_response({ + "message": "Favourite has been added!", + }) + + # rename favourite + @routes.post("/api/v1/favourites/{destination_hash}/rename") + async def index(request): + + # get path params + destination_hash = request.match_info.get("destination_hash", "") + + # get request data + data = await request.json() + display_name = data.get("display_name") + + # update display name if provided + if len(display_name) > 0: + database.FavouriteDestination.update(display_name=display_name).where(database.FavouriteDestination.destination_hash == destination_hash).execute() + + return web.json_response({ + "message": "Favourite has been renamed", + }) + + # delete favourite + @routes.delete("/api/v1/favourites/{destination_hash}") + async def index(request): + + # get path params + destination_hash = request.match_info.get("destination_hash", "") + + # delete favourite + database.FavouriteDestination.delete().where(database.FavouriteDestination.destination_hash == destination_hash).execute() + return web.json_response({ + "message": "Favourite has been added!", + }) + # propagation node status @routes.get("/api/v1/lxmf/propagation-node/status") async def index(request): @@ -1433,6 +1733,10 @@ class ReticulumMeshChat: if "transport_id" in interface_stats: interface_stats["transport_id"] = interface_stats["transport_id"].hex() + # ensure network_id is hex as json_response can't serialize bytes + if "network_id" in interface_stats and interface_stats["network_id"] is not None: + interface_stats["network_id"] = interface_stats["network_id"].hex() + # ensure probe_responder is hex as json_response can't serialize bytes if "probe_responder" in interface_stats and interface_stats["probe_responder"] is not None: interface_stats["probe_responder"] = interface_stats["probe_responder"].hex() @@ -1561,6 +1865,30 @@ class ReticulumMeshChat: "lxmf_message": lxmf_message, }) + # identify self on existing nomadnetwork link + @routes.post("/api/v1/nomadnetwork/{destination_hash}/identify") + async def index(request): + + # get path params + destination_hash = request.match_info.get("destination_hash", "") + + # convert destination hash to bytes + destination_hash = bytes.fromhex(destination_hash) + + # identify to existing active link + if destination_hash in nomadnet_cached_links: + link = nomadnet_cached_links[destination_hash] + if link.status is RNS.Link.ACTIVE: + link.identify(self.identity) + return web.json_response({ + "message": "Identity has been sent!", + }) + + # failed to identify + return web.json_response({ + "message": "Failed to identify. No active link to destination.", + }, status=500) + # delete lxmf message @routes.delete("/api/v1/lxmf-messages/{hash}") async def index(request): @@ -1724,6 +2052,9 @@ class ReticulumMeshChat: # called when web app has started async def on_startup(app): + # remember main event loop + AsyncUtils.set_main_loop(asyncio.get_event_loop()) + # auto launch web browser if launch_browser: try: @@ -1863,6 +2194,7 @@ class ReticulumMeshChat: else: print(f"unhandled field: {field}") return data + def convert_nomadnet_field_data_to_map(self, field_data): data = {} if field_data is not None or "{}": @@ -1878,17 +2210,20 @@ class ReticulumMeshChat: return data - - - # handle data received from websocket client async def on_websocket_data_received(self, client, data): # get type from client data _type = data["type"] + # handle ping + if _type == "ping": + AsyncUtils.run_async(client.send_str(json.dumps({ + "type": "pong", + }))) + # handle updating config - if _type == "config.set": + elif _type == "config.set": # get config from websocket config = data["config"] @@ -1908,7 +2243,7 @@ class ReticulumMeshChat: # handle successful file download def on_file_download_success(file_name, file_bytes): - asyncio.run(client.send_str(json.dumps({ + AsyncUtils.run_async(client.send_str(json.dumps({ "type": "nomadnet.file.download", "nomadnet_file_download": { "status": "success", @@ -1921,7 +2256,7 @@ class ReticulumMeshChat: # handle file download failure def on_file_download_failure(failure_reason): - asyncio.create_task(client.send_str(json.dumps({ + AsyncUtils.run_async(client.send_str(json.dumps({ "type": "nomadnet.file.download", "nomadnet_file_download": { "status": "failure", @@ -1933,7 +2268,7 @@ class ReticulumMeshChat: # handle file download progress def on_file_download_progress(progress): - asyncio.run(client.send_str(json.dumps({ + AsyncUtils.run_async(client.send_str(json.dumps({ "type": "nomadnet.file.download", "nomadnet_file_download": { "status": "progress", @@ -1943,11 +2278,9 @@ class ReticulumMeshChat: }, }))) - # todo: handle file download progress - # download the file downloader = NomadnetFileDownloader(destination_hash, file_path, on_file_download_success, on_file_download_failure, on_file_download_progress) - await downloader.download() + AsyncUtils.run_async(downloader.download()) # handle downloading a page from a nomadnet node elif _type == "nomadnet.page.download": @@ -1983,7 +2316,7 @@ class ReticulumMeshChat: # handle successful page download def on_page_download_success(page_content): - asyncio.run(client.send_str(json.dumps({ + AsyncUtils.run_async(client.send_str(json.dumps({ "type": "nomadnet.page.download", "nomadnet_page_download": { "status": "success", @@ -1995,7 +2328,7 @@ class ReticulumMeshChat: # handle page download failure def on_page_download_failure(failure_reason): - asyncio.create_task(client.send_str(json.dumps({ + AsyncUtils.run_async(client.send_str(json.dumps({ "type": "nomadnet.page.download", "nomadnet_page_download": { "status": "failure", @@ -2007,7 +2340,7 @@ class ReticulumMeshChat: # handle page download progress def on_page_download_progress(progress): - asyncio.run(client.send_str(json.dumps({ + AsyncUtils.run_async(client.send_str(json.dumps({ "type": "nomadnet.page.download", "nomadnet_page_download": { "status": "progress", @@ -2017,11 +2350,9 @@ class ReticulumMeshChat: }, }))) - # todo: handle page download progress - # download the page downloader = NomadnetPageDownloader(destination_hash, page_path_to_download, combined_data, on_page_download_success, on_page_download_failure, on_page_download_progress) - await downloader.download() + AsyncUtils.run_async(downloader.download()) # unhandled type else: @@ -2307,6 +2638,17 @@ class ReticulumMeshChat: "updated_at": announce.updated_at, } + # convert database favourite to a dictionary + def convert_db_favourite_to_dict(self, favourite: database.FavouriteDestination): + return { + "id": favourite.id, + "destination_hash": favourite.destination_hash, + "display_name": favourite.display_name, + "aspect": favourite.aspect, + "created_at": favourite.created_at, + "updated_at": favourite.updated_at, + } + # convert database lxmf message to a dictionary def convert_db_lxmf_message_to_dict(self, db_lxmf_message: database.LxmfMessage): @@ -2393,7 +2735,7 @@ class ReticulumMeshChat: return # send received lxmf message data to all websocket clients - asyncio.run(self.websocket_broadcast(json.dumps({ + AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ "type": "lxmf.delivery", "lxmf_message": self.convert_db_lxmf_message_to_dict(db_lxmf_message), }))) @@ -2517,6 +2859,22 @@ class ReticulumMeshChat: query = query.on_conflict(conflict_target=[database.CustomDestinationDisplayName.destination_hash], update=data) query.execute() + # upserts a custom destination display name to the database + def db_upsert_favourite(self, destination_hash: str, display_name: str, aspect: str): + + # prepare data to insert or update + data = { + "destination_hash": destination_hash, + "display_name": display_name, + "aspect": aspect, + "updated_at": datetime.now(timezone.utc), + } + + # upsert to database + query = database.FavouriteDestination.insert(data) + query = query.on_conflict(conflict_target=[database.FavouriteDestination.destination_hash], update=data) + query.execute() + # upserts lxmf conversation read state to the database def db_mark_lxmf_conversation_as_read(self, destination_hash: str): @@ -2653,7 +3011,7 @@ class ReticulumMeshChat: # handle lxmf message progress loop without blocking or awaiting # otherwise other incoming websocket packets will not be processed until sending is complete # which results in the next message not showing up until the first message is finished - asyncio.create_task(self.handle_lxmf_message_progress(lxmf_message)) + AsyncUtils.run_async(self.handle_lxmf_message_progress(lxmf_message)) return lxmf_message @@ -2681,9 +3039,11 @@ class ReticulumMeshChat: has_delivered = lxmf_message.state == LXMF.LXMessage.DELIVERED has_propagated = lxmf_message.state == LXMF.LXMessage.SENT and lxmf_message.method == LXMF.LXMessage.PROPAGATED has_failed = lxmf_message.state == LXMF.LXMessage.FAILED + has_rejected = lxmf_message.state == LXMF.LXMessage.REJECTED + is_cancelled = lxmf_message.state == LXMF.LXMessage.CANCELLED # check if we should stop updating - if has_delivered or has_propagated or has_failed: + if has_delivered or has_propagated or has_failed or has_rejected or is_cancelled: should_update_message = False # handle an announce received from reticulum, for an audio call address @@ -2702,7 +3062,7 @@ class ReticulumMeshChat: return # send database announce to all websocket clients - asyncio.run(self.websocket_broadcast(json.dumps({ + AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ "type": "announce", "announce": self.convert_db_announce_to_dict(announce), }))) @@ -2723,14 +3083,14 @@ class ReticulumMeshChat: return # send database announce to all websocket clients - asyncio.run(self.websocket_broadcast(json.dumps({ + AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ "type": "announce", "announce": self.convert_db_announce_to_dict(announce), }))) # resend all failed messages that were intended for this destination if self.config.auto_resend_failed_messages_when_announce_received.get(): - asyncio.run(self.resend_failed_messages_for_destination(destination_hash.hex())) + AsyncUtils.run_async(self.resend_failed_messages_for_destination(destination_hash.hex())) # handle an announce received from reticulum, for an lxmf propagation node address # NOTE: cant be async, as Reticulum doesn't await it @@ -2748,7 +3108,7 @@ class ReticulumMeshChat: return # send database announce to all websocket clients - asyncio.run(self.websocket_broadcast(json.dumps({ + AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ "type": "announce", "announce": self.convert_db_announce_to_dict(announce), }))) @@ -2832,7 +3192,7 @@ class ReticulumMeshChat: return # send database announce to all websocket clients - asyncio.run(self.websocket_broadcast(json.dumps({ + AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ "type": "announce", "announce": self.convert_db_announce_to_dict(announce), }))) @@ -3070,7 +3430,7 @@ class Config: nomadnet_cached_links = {} class NomadnetDownloader: - def __init__(self, destination_hash: bytes, path: str, data: str|None, on_download_success: Callable[[bytes], None], on_download_failure: Callable[[str], None], on_progress_update: Callable[[float], None], timeout: int|None = None): + def __init__(self, destination_hash: bytes, path: str, data: str|None, on_download_success: Callable[[RNS.RequestReceipt], None], on_download_failure: Callable[[str], None], on_progress_update: Callable[[float], None], timeout: int|None = None): self.app_name = "nomadnetwork" self.aspects = "node" self.destination_hash = destination_hash @@ -3152,8 +3512,8 @@ class NomadnetDownloader: ) # handle successful download - def on_response(self, request_receipt): - self.on_download_success(request_receipt.response) + def on_response(self, request_receipt: RNS.RequestReceipt): + self.on_download_success(request_receipt) # handle failure def on_failed(self, request_receipt=None): @@ -3172,8 +3532,8 @@ class NomadnetPageDownloader(NomadnetDownloader): super().__init__(destination_hash, page_path, data, self.on_download_success, self.on_download_failure, on_progress_update, timeout) # page download was successful, decode the response and send to provided callback - def on_download_success(self, response_bytes): - micron_markup_response = response_bytes.decode("utf-8") + def on_download_success(self, request_receipt: RNS.RequestReceipt): + micron_markup_response = request_receipt.response.decode("utf-8") self.on_page_download_success(micron_markup_response) # page download failed, send error to provided callback @@ -3189,10 +3549,52 @@ class NomadnetFileDownloader(NomadnetDownloader): super().__init__(destination_hash, page_path, None, self.on_download_success, self.on_download_failure, on_progress_update, timeout) # file download was successful, decode the response and send to provided callback - def on_download_success(self, response): - file_name: str = response[0] - file_data: bytes = response[1] - self.on_file_download_success(file_name, file_data) + def on_download_success(self, request_receipt: RNS.RequestReceipt): + + # get response + response = request_receipt.response + + # handle buffered reader response + if isinstance(response, io.BufferedReader): + + # get file name from metadata + file_name = "downloaded_file" + metadata = request_receipt.metadata + if metadata is not None and "name" in metadata: + file_path = metadata["name"].decode("utf-8") + file_name = os.path.basename(file_path) + + # get file data + file_data: bytes = response.read() + + self.on_file_download_success(file_name, file_data) + return + + # check for list response with bytes in position 0, and metadata dict in position 1 + # e.g: [file_bytes, {name: "filename.ext"}] + if isinstance(response, list) and isinstance(response[1], dict): + + file_data: bytes = response[0] + metadata: dict = response[1] + + # get file name from metadata + file_name = "downloaded_file" + if metadata is not None and "name" in metadata: + file_path = metadata["name"].decode("utf-8") + file_name = os.path.basename(file_path) + + self.on_file_download_success(file_name, file_data) + return + + # try using original response format + # unsure if this is actually used anymore now that a buffered reader is provided + # have left here just in case... + try: + file_name: str = response[0] + file_data: bytes = response[1] + self.on_file_download_success(file_name, file_data) + except: + self.on_download_failure("unsupported_response") # page download failed, send error to provided callback def on_download_failure(self, failure_reason): diff --git a/package-lock.json b/package-lock.json index d193e2f..2ebcf4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,23 @@ { "name": "reticulum-meshchat", - "version": "1.19.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reticulum-meshchat", - "version": "1.19.0", + "version": "2.4.0", "license": "MIT", "dependencies": { "@mdi/js": "^7.4.47", "@tailwindcss/forms": "^0.5.9", "@vitejs/plugin-vue": "^5.2.1", "autoprefixer": "^10.4.20", - "axios": "^1.7.9", + "axios": "^1.10.0", "click-outside-vue3": "^4.0.1", "compressorjs": "^1.2.1", "electron-prompt": "^1.7.0", + "micron-parser": "^1.0.2", "mitt": "^3.0.1", "moment": "^2.30.1", "postcss": "^8.4.49", @@ -1405,6 +1406,12 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "node_modules/@types/verror": { "version": "1.10.10", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.10.tgz", @@ -1908,9 +1915,9 @@ } }, "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -2785,6 +2792,14 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", @@ -4168,6 +4183,14 @@ "node": ">=8.6" } }, + "node_modules/micron-parser": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.2.tgz", + "integrity": "sha512-lYrEolylOUXeSISYrPRW/ZZAH1dpZRyTJ0VzQIA4cWJy0yNCXUUs+ujuAwV2OYlAPH8tCE1Z22+zg04Ilp/JWg==", + "dependencies": { + "dompurify": "*" + } + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", diff --git a/package.json b/package.json index a13b5b6..87ac588 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reticulum-meshchat", - "version": "1.19.0", + "version": "2.4.0", "description": "", "main": "electron/main.js", "scripts": { @@ -33,7 +33,7 @@ "mac": { "target": "dmg", "identity": null, - "artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}", + "artifactName": "ReticulumMeshChat-v${version}-${os}-${arch}.${ext}", "extendInfo": { "NSMicrophoneUsageDescription": "Microphone access is only needed for Audio Calls", "com.apple.security.device.audio-input": true @@ -98,10 +98,11 @@ "@tailwindcss/forms": "^0.5.9", "@vitejs/plugin-vue": "^5.2.1", "autoprefixer": "^10.4.20", - "axios": "^1.7.9", + "axios": "^1.10.0", "click-outside-vue3": "^4.0.1", "compressorjs": "^1.2.1", "electron-prompt": "^1.7.0", + "micron-parser": "^1.0.2", "mitt": "^3.0.1", "moment": "^2.30.1", "postcss": "^8.4.49", diff --git a/requirements.txt b/requirements.txt index 95ba564..a72e138 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -aiohttp>=3.9.5 +aiohttp>=3.13.3 cx_freeze>=7.0.0 -lxmf>=0.6.0 -peewee>=3.17.3 -rns>=0.9.1 -websockets>=12.0 +lxmf>=1.0.1 +peewee>=3.18.1,<4.0.0 +rns>=1.3.7 +websockets>=16.0 diff --git a/src/backend/async_utils.py b/src/backend/async_utils.py index d20d507..b8feaca 100644 --- a/src/backend/async_utils.py +++ b/src/backend/async_utils.py @@ -1,25 +1,25 @@ import asyncio +from typing import Coroutine class AsyncUtils: - # this method allows running the provided async coroutine from within a sync function - # it will run the async function on the existing event loop if available, otherwise it will start a new event loop + # remember main loop + main_loop: asyncio.AbstractEventLoop | None = None + @staticmethod - def run_async(coroutine): + def set_main_loop(loop: asyncio.AbstractEventLoop): + AsyncUtils.main_loop = loop - # attempt to get existing event loop - existing_event_loop = None - try: - existing_event_loop = asyncio.get_running_loop() - except RuntimeError: - # 'RuntimeError: no running event loop' - pass + # this method allows running the provided async coroutine from within a sync function + # it will run the async function on the main event loop if possible, otherwise it logs a warning + @staticmethod + def run_async(coroutine: Coroutine): - # if there is an existing event loop running, submit the coroutine to that loop - if existing_event_loop and existing_event_loop.is_running(): - existing_event_loop.create_task(coroutine) + # run provided coroutine on main event loop, ensuring thread safety + if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running(): + asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop) return - # otherwise start a new event loop to run the coroutine - asyncio.run(coroutine) + # main event loop not running... + print("WARNING: Main event loop not available. Could not schedule task.") diff --git a/src/backend/interface_config_parser.py b/src/backend/interface_config_parser.py index 75ab0b2..94bf823 100644 --- a/src/backend/interface_config_parser.py +++ b/src/backend/interface_config_parser.py @@ -1,50 +1,31 @@ +import RNS.vendor.configobj + + class InterfaceConfigParser: @staticmethod def parse(text): - # process config + # get lines from provided text + lines = text.splitlines() + + # ensure [interfaces] section exists + if "[interfaces]" not in lines: + lines.insert(0, "[interfaces]") + + # parse lines as rns config object + config = RNS.vendor.configobj.ConfigObj(lines) + + # get interfaces from config + config_interfaces = config.get("interfaces") + + # process interfaces interfaces = [] - current_interface = None - for line in text.splitlines(): + for interface_name in config_interfaces: - # strip whitespace from either side of string - line = line.strip() - - # skip empty lines and commented out lines - if not line or line.startswith("#"): - continue - - # check if we found a line containing an interface name - if line.startswith("[[") and line.endswith("]]"): - - # we found a new interface, so add the previously parsed one to the interfaces list - if current_interface: - interfaces.append(current_interface) - - # get interface name by removing the "[[" and "]]" - interface_name = line[2:-2] - current_interface = { - "name": interface_name, - } - - # we found a key=value line, so we will set these values on the interface - elif current_interface is not None and "=" in line: - - # parse key and value - line_parts = line.split("=", 1) - key = line_parts[0].strip() - value = line_parts[1].strip().strip('"').strip("'") - - # skip empty values or values equal to "None" - if value is None or value == "None": - continue - - # set key/value on interface - current_interface[key] = value - - # add the final interface to the list - if current_interface: - interfaces.append(current_interface) + # ensure interface has a name + interface_config = config_interfaces[interface_name] + interface_config["name"] = interface_name + interfaces.append(interface_config) return interfaces diff --git a/src/backend/interface_editor.py b/src/backend/interface_editor.py new file mode 100644 index 0000000..e836eb9 --- /dev/null +++ b/src/backend/interface_editor.py @@ -0,0 +1,14 @@ +class InterfaceEditor: + + @staticmethod + def update_value(interface_details: dict, data: dict, key: str): + + # update value if provided and not empty + value = data.get(key) + if value is not None and value != "": + interface_details[key] = value + return + + # otherwise remove existing value + if key in interface_details: + del interface_details[key] diff --git a/src/backend/interfaces/WebsocketClientInterface.py b/src/backend/interfaces/WebsocketClientInterface.py new file mode 100644 index 0000000..01258a2 --- /dev/null +++ b/src/backend/interfaces/WebsocketClientInterface.py @@ -0,0 +1,134 @@ +import threading +import time + +import RNS +from RNS.Interfaces.Interface import Interface +from websockets.sync.client import connect +from websockets.sync.connection import Connection + + +class WebsocketClientInterface(Interface): + + # TODO: required? + DEFAULT_IFAC_SIZE = 16 + + RECONNECT_DELAY_SECONDS = 5 + + def __str__(self): + return f"WebsocketClientInterface[{self.name}/{self.target_url}]" + + def __init__(self, owner, configuration, websocket: Connection = None): + + super().__init__() + + self.owner = owner + self.parent_interface = None + + self.IN = True + self.OUT = False + self.HW_MTU = 262144 # 256KiB + self.bitrate = 1_000_000_000 # 1Gbps + self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL + + # parse config + ifconf = Interface.get_config_obj(configuration) + self.name = ifconf.get("name") + self.target_url = ifconf.get("target_url", None) + + # ensure target url is provided + if self.target_url is None: + raise SystemError(f"target_url is required for interface '{self.name}'") + + # connect to websocket server if an existing connection was not provided + self.websocket = websocket + if self.websocket is None: + thread = threading.Thread(target=self.connect) + thread.daemon = True + thread.start() + + # called when a full packet has been received over the websocket + def process_incoming(self, data): + + # do nothing if offline or detached + if not self.online or self.detached: + return + + # update received bytes counter + self.rxb += len(data) + + # update received bytes counter for parent interface + if self.parent_interface is not None: + self.parent_interface.rxb += len(data) + + # send received data to transport instance + self.owner.inbound(data, self) + + # the running reticulum transport instance will call this method whenever the interface must transmit a packet + def process_outgoing(self, data): + + # do nothing if offline or detached + if not self.online or self.detached: + return + + # send to websocket server + try: + self.websocket.send(data) + except Exception as e: + RNS.log(f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR) + RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR) + return + + # update sent bytes counter + self.txb += len(data) + + # update received bytes counter for parent interface + if self.parent_interface is not None: + self.parent_interface.txb += len(data) + + # connect to the configured websocket server + def connect(self): + + # do nothing if interface is detached + if self.detached: + return + + # connect to websocket server + try: + RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG) + self.websocket = connect(f"{self.target_url}", max_size=None, compression=None) + RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG) + self.read_loop() + except Exception as e: + RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR) + + # auto reconnect after delay + RNS.log(f"Websocket disconnected for {str(self)}...", RNS.LOG_DEBUG) + time.sleep(self.RECONNECT_DELAY_SECONDS) + self.connect() + + def read_loop(self): + + self.online = True + + try: + for message in self.websocket: + self.process_incoming(message) + except Exception as e: + RNS.log(f"{self} read loop error: {e}", RNS.LOG_ERROR) + + self.online = False + + def detach(self): + + # mark as offline + self.online = False + + # close websocket + if self.websocket is not None: + self.websocket.close() + + # mark as detached + self.detached = True + +# set interface class RNS should use when importing this external interface +interface_class = WebsocketClientInterface diff --git a/src/backend/interfaces/WebsocketServerInterface.py b/src/backend/interfaces/WebsocketServerInterface.py new file mode 100644 index 0000000..19819f5 --- /dev/null +++ b/src/backend/interfaces/WebsocketServerInterface.py @@ -0,0 +1,156 @@ +import threading +import time + +import RNS +from RNS.Interfaces.Interface import Interface +from websockets.sync.server import Server +from websockets.sync.server import serve +from websockets.sync.server import ServerConnection + +from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInterface + + +class WebsocketServerInterface(Interface): + + # TODO: required? + DEFAULT_IFAC_SIZE = 16 + + RESTART_DELAY_SECONDS = 5 + + def __str__(self): + return f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]" + + def __init__(self, owner, configuration): + + super().__init__() + + self.owner = owner + + self.IN = True + self.OUT = False + self.HW_MTU = 262144 # 256KiB + self.bitrate = 1_000_000_000 # 1Gbps + self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL + + self.server: Server | None = None + self.spawned_interfaces: [WebsocketClientInterface] = [] + + # parse config + ifconf = Interface.get_config_obj(configuration) + self.name = ifconf.get("name") + self.listen_ip = ifconf.get("listen_ip", None) + self.listen_port = ifconf.get("listen_port", None) + + # ensure listen ip is provided + if self.listen_ip is None: + raise SystemError(f"listen_ip is required for interface '{self.name}'") + + # ensure listen port is provided + if self.listen_port is None: + raise SystemError(f"listen_port is required for interface '{self.name}'") + + # convert listen port to int + self.listen_port = int(self.listen_port) + + # run websocket server + thread = threading.Thread(target=self.serve) + thread.daemon = True + thread.start() + + @property + def clients(self): + return len(self.spawned_interfaces) + + # todo docs + def received_announce(self, from_spawned=False): + if from_spawned: + self.ia_freq_deque.append(time.time()) + + # todo docs + def sent_announce(self, from_spawned=False): + if from_spawned: + self.oa_freq_deque.append(time.time()) + + # do nothing as the spawned child interface will take care of rx/tx + def process_incoming(self, data): + pass + + # do nothing as the spawned child interface will take care of rx/tx + def process_outgoing(self, data): + pass + + def serve(self): + + # handle new websocket client connections + def on_websocket_client_connected(websocket: ServerConnection): + + # create new child interface + RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE) + spawned_interface = WebsocketClientInterface(self.owner, { + "name": f"Client on {self.name}", + "target_host": websocket.remote_address[0], + "target_port": str(websocket.remote_address[1]), + }, websocket=websocket) + + # configure child interface + spawned_interface.IN = self.IN + spawned_interface.OUT = self.OUT + spawned_interface.HW_MTU = self.HW_MTU + spawned_interface.bitrate = self.bitrate + spawned_interface.mode = self.mode + spawned_interface.parent_interface = self + spawned_interface.online = True + + # todo implement? + spawned_interface.announce_rate_target = None + spawned_interface.announce_rate_grace = None + spawned_interface.announce_rate_penalty = None + + # todo ifac? + # todo announce rates? + + # activate child interface + RNS.log(f"Spawned new WebsocketClientInterface: {spawned_interface}", RNS.LOG_VERBOSE) + RNS.Transport.interfaces.append(spawned_interface) + + # associate child interface with this interface + while spawned_interface in self.spawned_interfaces: + self.spawned_interfaces.remove(spawned_interface) + self.spawned_interfaces.append(spawned_interface) + + # run read loop + spawned_interface.read_loop() + + # client must have disconnected as the read loop finished, so forget the spawned interface + self.spawned_interfaces.remove(spawned_interface) + + # run websocket server + try: + RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG) + with serve(on_websocket_client_connected, self.listen_ip, self.listen_port, compression=None) as server: + self.online = True + self.server = server + server.serve_forever() + except Exception as e: + RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR) + + # websocket server is no longer running, let's restart it + self.online = False + RNS.log(f"Websocket server stopped for {str(self)}...", RNS.LOG_DEBUG) + time.sleep(self.RESTART_DELAY_SECONDS) + self.serve() + + def detach(self): + + # mark as offline + self.online = False + + # stop websocket server + if self.server is not None: + self.server.shutdown() + + # mark as detached + self.detached = True + +# set interface class RNS should use when importing this external interface +interface_class = WebsocketServerInterface diff --git a/src/frontend/components/App.vue b/src/frontend/components/App.vue index ff92964..3e69308 100644 --- a/src/frontend/components/App.vue +++ b/src/frontend/components/App.vue @@ -448,7 +448,7 @@ export default { // ask to stop syncing if already syncing if(this.isSyncingPropagationNode){ - if(confirm("Are you sure you want to stop syncing?")){ + if(await DialogUtils.confirm("Are you sure you want to stop syncing?")){ await this.stopSyncingPropagationNode(); } return; @@ -529,7 +529,7 @@ export default { async hangupAllCalls() { // confirm user wants to hang up calls - if(!confirm("Are you sure you want to hang up all incoming and outgoing calls?")){ + if(!await DialogUtils.confirm("Are you sure you want to hang up all incoming and outgoing calls?")){ return; } diff --git a/src/frontend/components/SidebarLink.vue b/src/frontend/components/SidebarLink.vue index 50e0dc8..c3d97d6 100644 --- a/src/frontend/components/SidebarLink.vue +++ b/src/frontend/components/SidebarLink.vue @@ -1,11 +1,11 @@ @@ -174,13 +800,22 @@ diff --git a/src/frontend/components/interfaces/ExpandingSection.vue b/src/frontend/components/interfaces/ExpandingSection.vue new file mode 100644 index 0000000..9d23a17 --- /dev/null +++ b/src/frontend/components/interfaces/ExpandingSection.vue @@ -0,0 +1,35 @@ + + \ No newline at end of file diff --git a/src/frontend/components/interfaces/Interface.vue b/src/frontend/components/interfaces/Interface.vue index 5aff14a..afa9595 100644 --- a/src/frontend/components/interfaces/Interface.vue +++ b/src/frontend/components/interfaces/Interface.vue @@ -10,7 +10,7 @@ - {{ iface._stats.ifac_size * 8 }}-bit IFAC with sig <{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}> + {{ iface._stats.ifac_size * 8 }}-bit IFAC • Network Name: {{ iface._stats.ifac_netname }} • Signature <{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}> @@ -36,6 +36,14 @@ + + + + + + + + @@ -186,7 +194,11 @@
• Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}
• TX: {{ formatBytes(iface._stats?.txb ?? 0) }}
• RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}
-
• Clients: {{ iface._stats?.clients }}
+
• Noise Floor: {{ + iface._stats?.noise_floor + }} dBm +
+
• Clients: {{ iface._stats?.clients }}
diff --git a/src/frontend/components/interfaces/InterfacesPage.vue b/src/frontend/components/interfaces/InterfacesPage.vue index c25c890..ccd4f8c 100644 --- a/src/frontend/components/interfaces/InterfacesPage.vue +++ b/src/frontend/components/interfaces/InterfacesPage.vue @@ -141,57 +141,13 @@ export default { // update data const interfaces = response.data.interface_stats?.interfaces ?? []; for(const iface of interfaces){ - this.interfaceStats[iface.name] = iface; + this.interfaceStats[iface.short_name] = iface; } } catch(e) { // do nothing if failed to load interfaces } }, - findInterfaceStats(interfaceName) { - const interfaceDescription = this.getInterfaceDescription(interfaceName); - return this.interfaceStats[interfaceDescription]; - }, - getInterfaceDescription(interfaceName) { - - // the interface-stats api returns interface names like the following; - // - // "AutoInterface[Default Interface]" - // "RNodeInterface[RNode LoRa Interface Fast]" - // "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]" - // - // however, the interfaces api just returns; - // "Default Interface" - // "RNode LoRa Interface Fast" - // "RNS Testnet Amsterdam" - // - // so we need to map the basic interface name to the former, so we can lookup stats for the interface - const iface = this.interfaces[interfaceName]; - if(iface){ - switch(iface.type){ - case "TCPClientInterface": { - // yes, this is meant to be passed as TCPInterface, even though the interface type includes client... - // example: "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]"; - return `TCPInterface[${interfaceName}/${iface.target_host}:${iface.target_port}]`; - } - case "TCPServerInterface": { - // example: "TCPServerInterface[TCP Server Interface/0.0.0.0:4242]"; - return `TCPServerInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`; - } - case "UDPInterface": { - // example: "UDPInterface[UDP Interface/0.0.0.0:1234]"; - return `UDPInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`; - } - default: { - // example: "RNodeInterface[RNode LoRa Interface Fast]", - return `${iface.type}[${interfaceName}]`; - } - } - } - - return null; - - }, async enableInterface(interfaceName) { // enable interface @@ -235,7 +191,7 @@ export default { async deleteInterface(interfaceName) { // ask user to confirm deleting conversation history - if(!confirm("Are you sure you want to delete this interface? This can not be undone!")){ + if(!await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!")){ return; } @@ -301,7 +257,7 @@ export default { const results = []; for(const [interfaceName, iface] of Object.entries(this.interfaces)){ iface._name = interfaceName; - iface._stats = this.findInterfaceStats(interfaceName); + iface._stats = this.interfaceStats[interfaceName]; results.push(iface); } return results; diff --git a/src/frontend/components/messages/ConversationDropDownMenu.vue b/src/frontend/components/messages/ConversationDropDownMenu.vue index 5c1b3a0..4788744 100644 --- a/src/frontend/components/messages/ConversationDropDownMenu.vue +++ b/src/frontend/components/messages/ConversationDropDownMenu.vue @@ -73,7 +73,7 @@ export default { async onDeleteMessageHistory() { // ask user to confirm deleting conversation history - if(!confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){ + if(!await DialogUtils.confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){ return; } diff --git a/src/frontend/components/messages/ConversationViewer.vue b/src/frontend/components/messages/ConversationViewer.vue index d5158e1..3450705 100644 --- a/src/frontend/components/messages/ConversationViewer.vue +++ b/src/frontend/components/messages/ConversationViewer.vue @@ -316,6 +316,7 @@