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 @@