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/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 604748f..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({ @@ -99,7 +120,7 @@ function getDefaultReticulumConfigDir() { app.whenReady().then(async () => { // get arguments passed to application, and remove the provided application path - const ignoredArguments = ["--no-sandbox"]; + const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"]; const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg)); const shouldLaunchHeadless = userProvidedArguments.includes("--headless"); 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 4d4b984..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 @@ -77,6 +79,7 @@ class ReticulumMeshChat: database.Config, database.Announce, database.CustomDestinationDisplayName, + database.FavouriteDestination, database.LxmfMessage, database.LxmfConversationReadState, database.LxmfUserIcon, @@ -942,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), @@ -976,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() @@ -988,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() @@ -1232,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): @@ -1629,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() @@ -1757,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): @@ -1920,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: @@ -2143,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": @@ -2217,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: @@ -2507,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): @@ -2717,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): @@ -2881,10 +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 or is_cancelled: + 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 @@ -3271,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 @@ -3353,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): @@ -3373,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 @@ -3390,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 3fabffa..2ebcf4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,23 @@ { "name": "reticulum-meshchat", - "version": "1.22.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reticulum-meshchat", - "version": "1.22.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.1", + "micron-parser": "^1.0.2", "mitt": "^3.0.1", "moment": "^2.30.1", "postcss": "^8.4.49", @@ -1915,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", @@ -4184,9 +4184,9 @@ } }, "node_modules/micron-parser": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.1.tgz", - "integrity": "sha512-fh3BWIoowiYtSnXg7O8cYnF+qZhGmxTU0S1fwVGtSXb6j04MSfe+8R0KzOgIn2eAij682QGqoeaz5mM2+kCl6Q==", + "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": "*" } diff --git a/package.json b/package.json index cd379c6..87ac588 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reticulum-meshchat", - "version": "1.22.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,11 +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.1", + "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 8484006..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.3 -peewee>=3.17.3 -rns>=0.9.5 -websockets>=14.2 +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/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/about/AboutPage.vue b/src/frontend/components/about/AboutPage.vue index d12c96d..1a5bc36 100644 --- a/src/frontend/components/about/AboutPage.vue +++ b/src/frontend/components/about/AboutPage.vue @@ -12,7 +12,7 @@
Versions
- MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} + MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} • Python v{{ appInfo.python_version }}