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/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..604748f 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({ @@ -120,7 +99,7 @@ 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 ignoredArguments = ["--no-sandbox"]; 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 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..4d4b984 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 @@ -79,7 +77,6 @@ class ReticulumMeshChat: database.Config, database.Announce, database.CustomDestinationDisplayName, - database.FavouriteDestination, database.LxmfMessage, database.LxmfConversationReadState, database.LxmfUserIcon, @@ -945,7 +942,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 +976,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 +988,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() @@ -1244,98 +1232,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 +1629,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 +1757,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 +1920,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: @@ -2278,9 +2143,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": @@ -2350,9 +2217,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 +2507,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): @@ -2859,22 +2717,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): @@ -3039,11 +2881,10 @@ 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 or is_cancelled: should_update_message = False # handle an announce received from reticulum, for an audio call address @@ -3430,7 +3271,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 +3353,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 +3373,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 +3390,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..3fabffa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,23 @@ { "name": "reticulum-meshchat", - "version": "2.4.0", + "version": "1.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reticulum-meshchat", - "version": "2.4.0", + "version": "1.22.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", + "micron-parser": "^1.0.1", "mitt": "^3.0.1", "moment": "^2.30.1", "postcss": "^8.4.49", @@ -1915,9 +1915,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", @@ -4184,9 +4184,9 @@ } }, "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==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.1.tgz", + "integrity": "sha512-fh3BWIoowiYtSnXg7O8cYnF+qZhGmxTU0S1fwVGtSXb6j04MSfe+8R0KzOgIn2eAij682QGqoeaz5mM2+kCl6Q==", "dependencies": { "dompurify": "*" } diff --git a/package.json b/package.json index 87ac588..cd379c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reticulum-meshchat", - "version": "2.4.0", + "version": "1.22.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,11 @@ "@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", + "micron-parser": "^1.0.1", "mitt": "^3.0.1", "moment": "^2.30.1", "postcss": "^8.4.49", diff --git a/requirements.txt b/requirements.txt index a72e138..8484006 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.3 +peewee>=3.17.3 +rns>=0.9.5 +websockets>=14.2 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/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/about/AboutPage.vue b/src/frontend/components/about/AboutPage.vue index 1a5bc36..d12c96d 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 }} • Python v{{ appInfo.python_version }} + MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }}