diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d517940..18c3502 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,10 +45,7 @@ jobs: artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe" build_mac: - strategy: - matrix: - os: [ macos-15, macos-15-intel ] - runs-on: ${{ matrix.os }} + runs-on: macos-13 permissions: contents: write steps: @@ -83,7 +80,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 6f25ccc..6c5e0e3 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,20 @@ 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 c9ad27c..11a7427 100644 --- a/database.py +++ b/database.py @@ -95,21 +95,6 @@ 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 bdaa0cc..d58ce1d 100644 --- a/electron/main.js +++ b/electron/main.js @@ -22,27 +22,6 @@ 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({ @@ -69,9 +48,6 @@ 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; @@ -82,6 +58,9 @@ function log(message) { return; } + // log to electron console + console.log(message); + // log to web console mainWindow.webContents.send('log', message); @@ -119,64 +98,50 @@ function getDefaultReticulumConfigDir() { app.whenReady().then(async () => { - // 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"); + // create browser window + mainWindow = new BrowserWindow({ + width: 1500, + height: 800, + webPreferences: { + // used to inject logging over ipc + preload: path.join(__dirname, 'preload.js'), + }, + }); - if(!shouldLaunchHeadless){ + // open external links in default web browser instead of electron + mainWindow.webContents.setWindowOpenHandler(({ url }) => { - // create browser window - mainWindow = new BrowserWindow({ - width: 1500, - height: 800, - webPreferences: { - // used to inject logging over ipc - preload: path.join(__dirname, 'preload.js'), - }, - }); + var shouldShowInNewElectronWindow = false; - // open external links in default web browser instead of electron - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - - var shouldShowInNewElectronWindow = false; - - // 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: "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'); + // 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: "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"; @@ -187,8 +152,16 @@ 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 5465420..8abc460 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -15,11 +15,6 @@ 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 448c7a3..f43201a 100644 --- a/meshchat.py +++ b/meshchat.py @@ -1,10 +1,8 @@ #!/usr/bin/env python import argparse -import io import json import os -import platform import sys import threading import time @@ -28,7 +26,6 @@ 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 @@ -79,7 +76,6 @@ class ReticulumMeshChat: database.Config, database.Announce, database.CustomDestinationDisplayName, - database.FavouriteDestination, database.LxmfMessage, database.LxmfConversationReadState, database.LxmfUserIcon, @@ -273,7 +269,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())) - AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ + asyncio.run(self.websocket_broadcast(json.dumps({ "type": "incoming_audio_call", }))) @@ -333,29 +329,8 @@ 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": processed_interfaces, + "interfaces": interfaces, }) # enable reticulum interface @@ -468,159 +443,131 @@ class ReticulumMeshChat: if "enabled" not in interface_details and "interface_enabled" not in interface_details: interface_details["interface_enabled"] = "true" - # 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 + # handle tcp client interface if interface_type == "TCPClientInterface": - # ensure target host provided interface_target_host = data.get('target_host') + interface_target_port = data.get('target_port') + + # ensure target host provided 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) - # 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') + interface_details["target_host"] = data.get('target_host') + interface_details["target_port"] = data.get('target_port') # handle tcp server interface if interface_type == "TCPServerInterface": - # ensure listen ip provided interface_listen_ip = data.get('listen_ip') + interface_listen_port = data.get('listen_port') + + # ensure listen ip provided 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) - # 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") + interface_details["listen_ip"] = data.get('listen_ip') + interface_details["listen_port"] = data.get('listen_port') # handle udp interface if interface_type == "UDPInterface": - # ensure listen ip provided 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 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) - # 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 + 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 optional UDPInterface options - InterfaceEditor.update_value(interface_details, data, "device") - - # handle RNodeInterface + # handle rnode interface if interface_type == "RNodeInterface": - # ensure port provided interface_port = data.get('port') + interface_frequency = data.get('frequency') + interface_bandwidth = data.get('bandwidth') + interface_txpower = data.get('txpower') + interface_spreadingfactor = data.get('spreadingfactor') + interface_codingrate = data.get('codingrate') + + # ensure port provided 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 @@ -628,134 +575,6 @@ class ReticulumMeshChat: 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 == "": - return web.json_response({ - "message": "Port is required", - }, status=422) - - # ensure sub interfaces provided - if not isinstance(sub_interfaces, list) or not sub_interfaces: - return web.json_response({ - "message": "At least one sub-interface is required", - }, status=422) - - # set required RNodeMultiInterface options - interface_details["port"] = interface_port - - # 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 self.reticulum.config["interfaces"] = interfaces @@ -798,22 +617,9 @@ class ReticulumMeshChat: # add interface to output output.append(f"[[{interface_name}]]") for key, value in interface.items(): - if not isinstance(value, dict): - output.append(f" {key} = {value}") + 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", @@ -945,7 +751,6 @@ 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), @@ -980,10 +785,6 @@ 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() @@ -996,10 +797,6 @@ 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() @@ -1127,7 +924,7 @@ class ReticulumMeshChat: def on_audio_packet(data): if websocket_response.closed is False: try: - AsyncUtils.run_async(websocket_response.send_bytes(data)) + asyncio.run(websocket_response.send_bytes(data)) except: # ignore errors sending audio packets to websocket pass @@ -1136,7 +933,7 @@ class ReticulumMeshChat: def on_hangup(): if websocket_response.closed is False: try: - AsyncUtils.run_async(websocket_response.close(code=WSCloseCode.GOING_AWAY)) + asyncio.run(websocket_response.close(code=WSCloseCode.GOING_AWAY)) except: # ignore errors closing websocket pass @@ -1210,7 +1007,6 @@ 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 @@ -1224,10 +1020,6 @@ 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) @@ -1244,98 +1036,6 @@ 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): @@ -1733,10 +1433,6 @@ 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() @@ -1865,30 +1561,6 @@ 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): @@ -2052,9 +1724,6 @@ 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: @@ -2194,7 +1863,6 @@ 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 "{}": @@ -2210,20 +1878,17 @@ 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 - elif _type == "config.set": + if _type == "config.set": # get config from websocket config = data["config"] @@ -2243,7 +1908,7 @@ class ReticulumMeshChat: # handle successful file download def on_file_download_success(file_name, file_bytes): - AsyncUtils.run_async(client.send_str(json.dumps({ + asyncio.run(client.send_str(json.dumps({ "type": "nomadnet.file.download", "nomadnet_file_download": { "status": "success", @@ -2256,7 +1921,7 @@ class ReticulumMeshChat: # handle file download failure def on_file_download_failure(failure_reason): - AsyncUtils.run_async(client.send_str(json.dumps({ + asyncio.create_task(client.send_str(json.dumps({ "type": "nomadnet.file.download", "nomadnet_file_download": { "status": "failure", @@ -2268,7 +1933,7 @@ class ReticulumMeshChat: # handle file download progress def on_file_download_progress(progress): - AsyncUtils.run_async(client.send_str(json.dumps({ + asyncio.run(client.send_str(json.dumps({ "type": "nomadnet.file.download", "nomadnet_file_download": { "status": "progress", @@ -2278,9 +1943,11 @@ 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) - AsyncUtils.run_async(downloader.download()) + await downloader.download() # handle downloading a page from a nomadnet node elif _type == "nomadnet.page.download": @@ -2316,7 +1983,7 @@ class ReticulumMeshChat: # handle successful page download def on_page_download_success(page_content): - AsyncUtils.run_async(client.send_str(json.dumps({ + asyncio.run(client.send_str(json.dumps({ "type": "nomadnet.page.download", "nomadnet_page_download": { "status": "success", @@ -2328,7 +1995,7 @@ class ReticulumMeshChat: # handle page download failure def on_page_download_failure(failure_reason): - AsyncUtils.run_async(client.send_str(json.dumps({ + asyncio.create_task(client.send_str(json.dumps({ "type": "nomadnet.page.download", "nomadnet_page_download": { "status": "failure", @@ -2340,7 +2007,7 @@ class ReticulumMeshChat: # handle page download progress def on_page_download_progress(progress): - AsyncUtils.run_async(client.send_str(json.dumps({ + asyncio.run(client.send_str(json.dumps({ "type": "nomadnet.page.download", "nomadnet_page_download": { "status": "progress", @@ -2350,9 +2017,11 @@ 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) - AsyncUtils.run_async(downloader.download()) + await downloader.download() # unhandled type else: @@ -2638,17 +2307,6 @@ 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): @@ -2735,7 +2393,7 @@ class ReticulumMeshChat: return # send received lxmf message data to all websocket clients - AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ + asyncio.run(self.websocket_broadcast(json.dumps({ "type": "lxmf.delivery", "lxmf_message": self.convert_db_lxmf_message_to_dict(db_lxmf_message), }))) @@ -2859,22 +2517,6 @@ 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): @@ -3011,7 +2653,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 - AsyncUtils.run_async(self.handle_lxmf_message_progress(lxmf_message)) + asyncio.create_task(self.handle_lxmf_message_progress(lxmf_message)) return lxmf_message @@ -3039,11 +2681,9 @@ 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 or has_rejected or is_cancelled: + if has_delivered or has_propagated or has_failed: should_update_message = False # handle an announce received from reticulum, for an audio call address @@ -3062,7 +2702,7 @@ class ReticulumMeshChat: return # send database announce to all websocket clients - AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ + asyncio.run(self.websocket_broadcast(json.dumps({ "type": "announce", "announce": self.convert_db_announce_to_dict(announce), }))) @@ -3083,14 +2723,14 @@ class ReticulumMeshChat: return # send database announce to all websocket clients - AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ + asyncio.run(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(): - AsyncUtils.run_async(self.resend_failed_messages_for_destination(destination_hash.hex())) + asyncio.run(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 @@ -3108,7 +2748,7 @@ class ReticulumMeshChat: return # send database announce to all websocket clients - AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ + asyncio.run(self.websocket_broadcast(json.dumps({ "type": "announce", "announce": self.convert_db_announce_to_dict(announce), }))) @@ -3192,7 +2832,7 @@ class ReticulumMeshChat: return # send database announce to all websocket clients - AsyncUtils.run_async(self.websocket_broadcast(json.dumps({ + asyncio.run(self.websocket_broadcast(json.dumps({ "type": "announce", "announce": self.convert_db_announce_to_dict(announce), }))) @@ -3430,7 +3070,7 @@ class Config: nomadnet_cached_links = {} class NomadnetDownloader: - 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): + 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): self.app_name = "nomadnetwork" self.aspects = "node" self.destination_hash = destination_hash @@ -3512,8 +3152,8 @@ class NomadnetDownloader: ) # handle successful download - def on_response(self, request_receipt: RNS.RequestReceipt): - self.on_download_success(request_receipt) + def on_response(self, request_receipt): + self.on_download_success(request_receipt.response) # handle failure def on_failed(self, request_receipt=None): @@ -3532,8 +3172,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, request_receipt: RNS.RequestReceipt): - micron_markup_response = request_receipt.response.decode("utf-8") + def on_download_success(self, response_bytes): + micron_markup_response = response_bytes.decode("utf-8") self.on_page_download_success(micron_markup_response) # page download failed, send error to provided callback @@ -3549,52 +3189,10 @@ 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, 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") + 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) # 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 2ebcf4d..d193e2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,22 @@ { "name": "reticulum-meshchat", - "version": "2.4.0", + "version": "1.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reticulum-meshchat", - "version": "2.4.0", + "version": "1.19.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.10.0", + "axios": "^1.7.9", "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", @@ -1406,12 +1405,6 @@ "@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", @@ -1915,9 +1908,9 @@ } }, "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -2792,14 +2785,6 @@ "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", @@ -4183,14 +4168,6 @@ "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 87ac588..a13b5b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reticulum-meshchat", - "version": "2.4.0", + "version": "1.19.0", "description": "", "main": "electron/main.js", "scripts": { @@ -33,7 +33,7 @@ "mac": { "target": "dmg", "identity": null, - "artifactName": "ReticulumMeshChat-v${version}-${os}-${arch}.${ext}", + "artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}", "extendInfo": { "NSMicrophoneUsageDescription": "Microphone access is only needed for Audio Calls", "com.apple.security.device.audio-input": true @@ -98,11 +98,10 @@ "@tailwindcss/forms": "^0.5.9", "@vitejs/plugin-vue": "^5.2.1", "autoprefixer": "^10.4.20", - "axios": "^1.10.0", + "axios": "^1.7.9", "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 a72e138..95ba564 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -aiohttp>=3.13.3 +aiohttp>=3.9.5 cx_freeze>=7.0.0 -lxmf>=1.0.1 -peewee>=3.18.1,<4.0.0 -rns>=1.3.7 -websockets>=16.0 +lxmf>=0.6.0 +peewee>=3.17.3 +rns>=0.9.1 +websockets>=12.0 diff --git a/src/backend/async_utils.py b/src/backend/async_utils.py index b8feaca..d20d507 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: - # remember main loop - main_loop: asyncio.AbstractEventLoop | None = None - - @staticmethod - def set_main_loop(loop: asyncio.AbstractEventLoop): - AsyncUtils.main_loop = loop - # 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 + # it will run the async function on the existing event loop if available, otherwise it will start a new event loop @staticmethod - def run_async(coroutine: Coroutine): + def run_async(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) + # 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 + + # 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) return - # main event loop not running... - print("WARNING: Main event loop not available. Could not schedule task.") + # otherwise start a new event loop to run the coroutine + asyncio.run(coroutine) diff --git a/src/backend/interface_config_parser.py b/src/backend/interface_config_parser.py index 94bf823..75ab0b2 100644 --- a/src/backend/interface_config_parser.py +++ b/src/backend/interface_config_parser.py @@ -1,31 +1,50 @@ -import RNS.vendor.configobj - - class InterfaceConfigParser: @staticmethod def parse(text): - # 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 + # process config interfaces = [] - for interface_name in config_interfaces: + current_interface = None + for line in text.splitlines(): - # ensure interface has a name - interface_config = config_interfaces[interface_name] - interface_config["name"] = interface_name - interfaces.append(interface_config) + # 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) return interfaces diff --git a/src/backend/interface_editor.py b/src/backend/interface_editor.py deleted file mode 100644 index e836eb9..0000000 --- a/src/backend/interface_editor.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 01258a2..0000000 --- a/src/backend/interfaces/WebsocketClientInterface.py +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index 19819f5..0000000 --- a/src/backend/interfaces/WebsocketServerInterface.py +++ /dev/null @@ -1,156 +0,0 @@ -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 3e69308..ff92964 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(await DialogUtils.confirm("Are you sure you want to stop syncing?")){ + if(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(!await DialogUtils.confirm("Are you sure you want to hang up all incoming and outgoing calls?")){ + if(!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 c3d97d6..50e0dc8 100644 --- a/src/frontend/components/SidebarLink.vue +++ b/src/frontend/components/SidebarLink.vue @@ -1,11 +1,11 @@ @@ -800,22 +174,13 @@ diff --git a/src/frontend/components/interfaces/ExpandingSection.vue b/src/frontend/components/interfaces/ExpandingSection.vue deleted file mode 100644 index 9d23a17..0000000 --- a/src/frontend/components/interfaces/ExpandingSection.vue +++ /dev/null @@ -1,35 +0,0 @@ - - \ No newline at end of file diff --git a/src/frontend/components/interfaces/Interface.vue b/src/frontend/components/interfaces/Interface.vue index afa9595..5aff14a 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 • Network Name: {{ iface._stats.ifac_netname }} • Signature <{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}> + {{ iface._stats.ifac_size * 8 }}-bit IFAC with sig <{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}> @@ -36,14 +36,6 @@ - - - - - - - - @@ -194,11 +186,7 @@
• Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}
• TX: {{ formatBytes(iface._stats?.txb ?? 0) }}
• RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}
-
• Noise Floor: {{ - iface._stats?.noise_floor - }} dBm -
-
• Clients: {{ iface._stats?.clients }}
+
• Clients: {{ iface._stats?.clients }}
diff --git a/src/frontend/components/interfaces/InterfacesPage.vue b/src/frontend/components/interfaces/InterfacesPage.vue index ccd4f8c..c25c890 100644 --- a/src/frontend/components/interfaces/InterfacesPage.vue +++ b/src/frontend/components/interfaces/InterfacesPage.vue @@ -141,13 +141,57 @@ export default { // update data const interfaces = response.data.interface_stats?.interfaces ?? []; for(const iface of interfaces){ - this.interfaceStats[iface.short_name] = iface; + this.interfaceStats[iface.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 @@ -191,7 +235,7 @@ export default { async deleteInterface(interfaceName) { // ask user to confirm deleting conversation history - if(!await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!")){ + if(!confirm("Are you sure you want to delete this interface? This can not be undone!")){ return; } @@ -257,7 +301,7 @@ export default { const results = []; for(const [interfaceName, iface] of Object.entries(this.interfaces)){ iface._name = interfaceName; - iface._stats = this.interfaceStats[interfaceName]; + iface._stats = this.findInterfaceStats(interfaceName); results.push(iface); } return results; diff --git a/src/frontend/components/messages/ConversationDropDownMenu.vue b/src/frontend/components/messages/ConversationDropDownMenu.vue index 4788744..5c1b3a0 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(!await DialogUtils.confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){ + if(!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 3450705..d5158e1 100644 --- a/src/frontend/components/messages/ConversationViewer.vue +++ b/src/frontend/components/messages/ConversationViewer.vue @@ -316,7 +316,6 @@