diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index fd188a7..6cc42b8 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -201,6 +201,8 @@ class ReticulumMeshChat: auth_enabled: bool = False, public_dir: str | None = None, emergency: bool = False, + gitea_base_url: str | None = None, + docs_download_urls: str | None = None, ): self.running = True self.reticulum_config_dir = reticulum_config_dir @@ -210,6 +212,8 @@ class ReticulumMeshChat: self.emergency = emergency self.auth_enabled_initial = auth_enabled self.public_dir_override = public_dir + self.gitea_base_url_override = gitea_base_url + self.docs_download_urls_override = docs_download_urls self.websocket_clients: list[web.WebSocketResponse] = [] # track announce timestamps for rate calculation @@ -1177,11 +1181,15 @@ class ReticulumMeshChat: def get_app_version() -> str: return app_version - def get_lxst_version(self) -> str: + @staticmethod + def get_package_version(package_name: str, default: str = "unknown") -> str: try: - return importlib.metadata.version("lxst") + return importlib.metadata.version(package_name) except Exception: - return getattr(LXST, "__version__", "unknown") + return default + + def get_lxst_version(self) -> str: + return self.get_package_version("lxst", getattr(LXST, "__version__", "unknown")) # automatically announces based on user config async def announce_loop(self, session_id, context=None): @@ -2272,9 +2280,16 @@ class ReticulumMeshChat: if not url: return web.json_response({"error": "URL is required"}, status=400) - # Restrict to GitHub for safety - if not url.startswith("https://git.quad4.io/") and not url.startswith( - "https://objects.githubusercontent.com/" + # Restrict to allowed sources for safety + gitea_url = "https://git.quad4.io" + if self.current_context and self.current_context.config: + gitea_url = self.current_context.config.gitea_base_url.get() + + if ( + not url.startswith(gitea_url + "/") + and not url.startswith("https://git.quad4.io/") + and not url.startswith("https://github.com/") + and not url.startswith("https://objects.githubusercontent.com/") ): return web.json_response({"error": "Invalid download URL"}, status=403) @@ -3172,21 +3187,21 @@ class ReticulumMeshChat: "lxst_version": self.get_lxst_version(), "python_version": platform.python_version(), "dependencies": { - "aiohttp": importlib.metadata.version("aiohttp"), - "aiohttp_session": importlib.metadata.version( + "aiohttp": self.get_package_version("aiohttp"), + "aiohttp_session": self.get_package_version( "aiohttp-session" ), - "cryptography": importlib.metadata.version("cryptography"), - "psutil": importlib.metadata.version("psutil"), - "requests": importlib.metadata.version("requests"), - "websockets": importlib.metadata.version("websockets"), + "cryptography": self.get_package_version("cryptography"), + "psutil": self.get_package_version("psutil"), + "requests": self.get_package_version("requests"), + "websockets": self.get_package_version("websockets"), "audioop_lts": ( - importlib.metadata.version("audioop-lts") + self.get_package_version("audioop-lts") if sys.version_info >= (3, 13) else "n/a" ), - "ply": importlib.metadata.version("ply"), - "bcrypt": importlib.metadata.version("bcrypt"), + "ply": self.get_package_version("ply"), + "bcrypt": self.get_package_version("bcrypt"), }, "storage_path": self.storage_path, "database_path": self.database_path, @@ -7290,16 +7305,59 @@ class ReticulumMeshChat: response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + # CSP: allow localhost for development and Electron, websockets, and blob URLs # Add 'unsafe-inline' and 'unsafe-eval' for some legacy doc scripts if needed, # and allow framing ourselves for the docs page. + gitea_url = "https://git.quad4.io" + connect_sources = [ + "'self'", + "ws://localhost:*", + "wss://localhost:*", + "blob:", + "https://*.tile.openstreetmap.org", + "https://tile.openstreetmap.org", + "https://nominatim.openstreetmap.org", + ] + + if self.current_context and self.current_context.config: + # Add configured Gitea base URL + gitea_url = self.current_context.config.gitea_base_url.get() + if gitea_url not in connect_sources: + connect_sources.append(gitea_url) + + # Add configured docs download URLs domains + docs_urls_str = self.current_context.config.docs_download_urls.get() + docs_urls = [ + u.strip() + for u in docs_urls_str.replace("\n", ",").split(",") + if u.strip() + ] + for url in docs_urls: + try: + from urllib.parse import urlparse + + parsed = urlparse(url) + if parsed.netloc: + domain = f"{parsed.scheme}://{parsed.netloc}" + if domain not in connect_sources: + connect_sources.append(domain) + + # If GitHub is used, also allow objects.githubusercontent.com for redirects + if "github.com" in domain: + content_domain = "https://objects.githubusercontent.com" + if content_domain not in connect_sources: + connect_sources.append(content_domain) + except Exception: # noqa: S110 + pass + csp = ( "default-src 'self'; " "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; " "font-src 'self' data:; " - "connect-src 'self' ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://git.quad4.io; " + f"connect-src {' '.join(connect_sources)}; " "media-src 'self' blob:; " "worker-src 'self' blob:; " "frame-src 'self'; " @@ -10153,6 +10211,18 @@ def main(): default=os.environ.get("MESHCHAT_PUBLIC_DIR"), help="Path to the directory containing the frontend static files (default: bundled public folder). Can also be set via MESHCHAT_PUBLIC_DIR environment variable.", ) + parser.add_argument( + "--gitea-base-url", + type=str, + default=os.environ.get("MESHCHAT_GITEA_BASE_URL"), + help="Base URL for Gitea instance (default: https://git.quad4.io). Can also be set via MESHCHAT_GITEA_BASE_URL environment variable.", + ) + parser.add_argument( + "--docs-download-urls", + type=str, + default=os.environ.get("MESHCHAT_DOCS_DOWNLOAD_URLS"), + help="Comma-separated list of URLs to download documentation from. Can also be set via MESHCHAT_DOCS_DOWNLOAD_URLS environment variable.", + ) parser.add_argument( "--test-exception-message", type=str, @@ -10278,6 +10348,8 @@ def main(): auth_enabled=args.auth, public_dir=args.public_dir, emergency=args.emergency, + gitea_base_url=args.gitea_base_url, + docs_download_urls=args.docs_download_urls, ) # update recovery with known paths diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py index 8ac0122..c86d249 100644 --- a/meshchatx/src/backend/config_manager.py +++ b/meshchatx/src/backend/config_manager.py @@ -118,6 +118,14 @@ class ConfigManager: "initial_docs_download_attempted", False, ) + self.gitea_base_url = self.StringConfig( + self, "gitea_base_url", "https://git.quad4.io" + ) + self.docs_download_urls = self.StringConfig( + self, + "docs_download_urls", + "https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip,https://github.com/markqvist/reticulum_website/archive/refs/heads/main.zip", + ) # desktop config self.desktop_open_calls_in_separate_window = self.BoolConfig( diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py index 513839e..954f91a 100644 --- a/meshchatx/src/backend/docs_manager.py +++ b/meshchatx/src/backend/docs_manager.py @@ -86,7 +86,10 @@ class DocsManager: try: # Try symlink first as it's efficient - os.symlink(version_path, self.docs_dir) + # We use a relative path for the symlink target to make the storage directory portable + # version_path is relative to CWD, so we need it relative to the parent of self.docs_dir + rel_target = os.path.relpath(version_path, os.path.dirname(self.docs_dir)) + os.symlink(rel_target, self.docs_dir) except (OSError, AttributeError): # Fallback to copy shutil.copytree(version_path, self.docs_dir) @@ -440,54 +443,68 @@ class DocsManager: self.download_progress = 0 self.last_error = None - try: - # We use the reticulum_website repository which contains the built HTML docs - # Default to git.quad4.io as requested - url = "https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip" - zip_path = os.path.join(self.docs_base_dir, "website.zip") + # Get URLs from config + urls_str = self.config.docs_download_urls.get() + urls = [u.strip() for u in urls_str.replace("\n", ",").split(",") if u.strip()] + if not urls: + urls = ["https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip"] - # Download ZIP - response = requests.get(url, stream=True, timeout=60) - response.raise_for_status() + last_exception = None + for url in urls: + try: + logging.info(f"Attempting to download docs from {url}") + zip_path = os.path.join(self.docs_base_dir, "website.zip") - total_size = int(response.headers.get("content-length", 0)) - downloaded_size = 0 + # Download ZIP + response = requests.get(url, stream=True, timeout=60) + response.raise_for_status() - with open(zip_path, "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - downloaded_size += len(chunk) - if total_size > 0: - self.download_progress = int( - (downloaded_size / total_size) * 90 - ) + total_size = int(response.headers.get("content-length", 0)) + downloaded_size = 0 - # Extract - self.download_status = "extracting" - # For automatic downloads from git, we'll use a timestamp as version if none provided - if version == "latest": - import time + with open(zip_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded_size += len(chunk) + if total_size > 0: + self.download_progress = int( + (downloaded_size / total_size) * 90 + ) - version = f"git-{int(time.time())}" + # Extract + self.download_status = "extracting" + # For automatic downloads from git, we'll use a timestamp as version if none provided + if version == "latest": + import time - self._extract_docs(zip_path, version) + version = f"git-{int(time.time())}" - # Cleanup - if os.path.exists(zip_path): - os.remove(zip_path) + self._extract_docs(zip_path, version) - self.config.docs_downloaded.set(True) - self.download_progress = 100 - self.download_status = "completed" + # Cleanup + if os.path.exists(zip_path): + os.remove(zip_path) - # Switch to the new version - self.switch_version(version) + self.config.docs_downloaded.set(True) + self.download_progress = 100 + self.download_status = "completed" - except Exception as e: - self.last_error = str(e) - self.download_status = "error" - logging.exception(f"Failed to update docs: {e}") + # Switch to the new version + self.switch_version(version) + return # Success, exit task + + except Exception as e: + logging.warning(f"Failed to download docs from {url}: {e}") + last_exception = e + if os.path.exists(os.path.join(self.docs_base_dir, "website.zip")): + os.remove(os.path.join(self.docs_base_dir, "website.zip")) + continue # Try next URL + + # If we got here, all URLs failed + self.last_error = str(last_exception) + self.download_status = "error" + logging.error(f"All docs download sources failed. Last error: {last_exception}") def upload_zip(self, zip_bytes, version): self.download_status = "extracting" diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py index 2d3c2d5..78f4d6a 100644 --- a/meshchatx/src/backend/identity_context.py +++ b/meshchatx/src/backend/identity_context.py @@ -129,6 +129,19 @@ class IdentityContext: # 3. Initialize Config and Managers self.config = ConfigManager(self.database) + + # Apply overrides from CLI/ENV if provided + if ( + hasattr(self.app, "gitea_base_url_override") + and self.app.gitea_base_url_override + ): + self.config.gitea_base_url.set(self.app.gitea_base_url_override) + if ( + hasattr(self.app, "docs_download_urls_override") + and self.app.docs_download_urls_override + ): + self.config.docs_download_urls.set(self.app.docs_download_urls_override) + self.message_handler = MessageHandler(self.database) self.announce_manager = AnnounceManager(self.database) self.archiver_manager = ArchiverManager(self.database) diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py index 08ea766..1779df7 100644 --- a/meshchatx/src/backend/telephone_manager.py +++ b/meshchatx/src/backend/telephone_manager.py @@ -141,19 +141,12 @@ class TelephoneManager: self.call_start_time = time.time() self.call_was_established = True - # Clear initiation status as soon as call is established - self._update_initiation_status(None, None) - # Track per-call stats from the active link (uses RNS Link counters) link = getattr(self.telephone, "active_call", None) self.call_stats = { "link": link, } - # Recording disabled for now due to stability issues with LXST - # if self.config_manager and self.config_manager.call_recording_enabled.get(): - # self.start_recording() - if self.on_established_callback: self.on_established_callback(caller_identity) @@ -351,6 +344,14 @@ class TelephoneManager: await asyncio.sleep(3) raise finally: + # If call was successful, keep status for a moment to prevent UI flicker + # while the frontend picks up the new active_call state + if ( + self.telephone + and self.telephone.active_call + and self.telephone.call_status == 6 + ): + await asyncio.sleep(1.5) self._update_initiation_status(None, None) def mute_transmit(self): diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 68810c7..c0a8642 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -50,7 +50,7 @@ {{ $t("app.custom_fork_by") }} Sudo-Ivan @@ -573,6 +573,9 @@ export default { }; }, computed: { + giteaBaseUrl() { + return this.config?.gitea_base_url || "https://git.quad4.io"; + }, currentPopoutType() { if (this.$route?.meta?.popoutType) { return this.$route.meta.popoutType; diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue index fca70f5..abeb991 100644 --- a/meshchatx/src/frontend/components/call/CallPage.vue +++ b/meshchatx/src/frontend/components/call/CallPage.vue @@ -2304,7 +2304,6 @@ export default { const response = await window.axios.get("/api/v1/telephone/status"); const oldCall = this.activeCall; const newCall = response.data.active_call; - const callStatus = response.data.call_status; // Sync local mute state from backend if (newCall) { @@ -2319,14 +2318,6 @@ export default { this.initiationTargetHash = response.data.initiation_target_hash; this.initiationTargetName = response.data.initiation_target_name; - // If no active call and status is idle/busy/rejected/available, clear stale initiation UI - const isIdleState = !this.activeCall && ![2, 4, 5].includes(callStatus); - if (isIdleState && this.initiationStatus) { - this.initiationStatus = null; - this.initiationTargetHash = null; - this.initiationTargetName = null; - } - if (this.activeCall?.is_voicemail) { this.wasVoicemail = true; } diff --git a/meshchatx/src/frontend/components/docs/DocsPage.vue b/meshchatx/src/frontend/components/docs/DocsPage.vue index c5af436..52c4e67 100644 --- a/meshchatx/src/frontend/components/docs/DocsPage.vue +++ b/meshchatx/src/frontend/components/docs/DocsPage.vue @@ -367,12 +367,48 @@
{{ $t("docs.error") }}
{{ status.last_error }}
- +
+ + +
+ +
+ or use alternate source +
+
+ +
+ + +
+ + + Manage all sources in settings + +
@@ -535,6 +571,7 @@ export default { meshchatxDocs: [], selectedDocPath: null, selectedDocContent: null, + alternateDocsUrl: "", languages: { en: "English", de: "Deutsch", @@ -636,6 +673,27 @@ export default { console.error("Failed to trigger docs update:", error); } }, + async addAndRetryDocs() { + if (!this.alternateDocsUrl) return; + try { + // Get current config + const configResponse = await window.axios.get("/api/v1/config"); + const currentUrls = configResponse.data.config.docs_download_urls || ""; + const newUrls = currentUrls ? `${currentUrls},${this.alternateDocsUrl}` : this.alternateDocsUrl; + + // Update config + await window.axios.patch("/api/v1/config", { + docs_download_urls: newUrls, + }); + + // Clear input and retry + this.alternateDocsUrl = ""; + await this.updateDocs(); + } catch (error) { + console.error("Failed to add alternate source:", error); + ToastUtils.error("Failed to update documentation sources"); + } + }, async switchVersion(version) { try { await window.axios.post("/api/v1/docs/switch", { version }); diff --git a/meshchatx/src/frontend/components/forms/Toggle.vue b/meshchatx/src/frontend/components/forms/Toggle.vue index 0146320..3fe2501 100644 --- a/meshchatx/src/frontend/components/forms/Toggle.vue +++ b/meshchatx/src/frontend/components/forms/Toggle.vue @@ -1,11 +1,16 @@