glo/desktop/main.py
2026-11-03 21:22:45 +00:00

1569 lines
50 KiB
Python

import os
import sys
import time
import stat
import signal
import platform
import traceback
import logging
import webbrowser
import http.client
import threading
import secrets
import hashlib
from collections import deque
from pathlib import Path
from logging.handlers import RotatingFileHandler
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
from typing import Optional
APP_NAME = "angelhome"
APP_VERSION = "1.1.1"
UPSTREAM_URL = "https://angel.33.backend-3.com"
NETWORK_MODE = "proxy"
LOCAL_PROXY_HOST = "127.0.0.1"
LOCAL_PROXY_PORT = 0
LOCAL_ROUTE_CODE = "app"
CALLS_HTTPS_HOST = "calls.backend-3.com"
CALLS_WSS_HOST = "calls.backend-3.com"
ALLOWED_PATH_PREFIXES = [
f"/p/{LOCAL_ROUTE_CODE}",
f"/p/{LOCAL_ROUTE_CODE}/",
]
OPEN_EXTERNAL_LINKS_IN_SYSTEM_BROWSER = False
OFF_THE_RECORD = False
WINDOW_TITLE = APP_NAME
WINDOW_WIDTH = 1200
WINDOW_HEIGHT = 800
LOG_LEVEL = logging.INFO
LOG_MAX_BYTES = 2 * 1024 * 1024
LOG_BACKUP_COUNT = 3
ALLOW_CLIPBOARD = False
ALLOW_GEOLOCATION = False
ALLOW_NOTIFICATIONS = False
ALLOW_MEDIA_AUDIO_CAPTURE = True
ALLOW_MEDIA_VIDEO_CAPTURE = False
ALLOW_SCREEN_CAPTURE = False
ALLOW_WEBRTC = True
ENABLE_SINGLE_INSTANCE_IPC = True
IPC_CONNECT_TIMEOUT_MS = 1500
ACTIVATE_WINDOW_MESSAGE = "ACTIVATE"
MAX_RENDER_CRASHES = 3
RENDER_CRASH_WINDOW_SECONDS = 60
ENABLE_CONSOLE_LOGGING_WHEN_FROZEN = False
ENABLE_JS_CONSOLE_LOGGING = False
REDACT_URL_PATH = False
DETACH_STDIO_WHEN_FROZEN = True
MAX_BODY = 2_000_000
PROXY_TIMEOUT_SECONDS = 20
ALLOWED_PROXY_METHODS = {"GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"}
LOCAL_PROXY_CSRF_HEADER = "X-Angelhome-Proxy-Token"
LOCAL_PROXY_CSRF_TOKEN = secrets.token_urlsafe(32)
ALLOW_QTWEBENGINE_NO_SANDBOX = False
RATE_LIMIT_WINDOW_SECONDS = 10.0
RATE_LIMIT_MAX_REQUESTS = 120
RATE_LIMIT_MAX_BURST = 40
HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"proxy-connection",
}
DROP_REQUEST_HEADERS = {
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-real-ip",
"origin",
}
DROP_RESPONSE_HEADERS = {
"server",
"x-powered-by",
"alt-svc",
}
_runtime_proxy_port: Optional[int] = None
_runtime_start_url: Optional[str] = None
_proxy_server = None
_proxy_thread = None
def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def is_proxy_mode() -> bool:
return str(NETWORK_MODE).strip().lower() == "proxy"
def is_direct_mode() -> bool:
return str(NETWORK_MODE).strip().lower() == "direct"
def detach_stdio_if_needed() -> None:
try:
if not is_frozen():
return
if not DETACH_STDIO_WHEN_FROZEN:
return
devnull_r = open(os.devnull, "r", encoding="utf-8", errors="ignore")
devnull_w = open(os.devnull, "a", encoding="utf-8", errors="ignore")
try:
sys.stdin = devnull_r
except Exception:
pass
try:
sys.stdout = devnull_w
except Exception:
pass
try:
sys.stderr = devnull_w
except Exception:
pass
except Exception:
pass
def resource_dir() -> Path:
if is_frozen() and hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS)
if is_frozen():
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def base_dir() -> Path:
if is_frozen():
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def set_directory_permissions(path: Path, mode: int) -> None:
try:
if os.name == "posix":
os.chmod(path, mode)
except Exception:
pass
def ensure_dir(path: Path, mode: int | None = None) -> Path:
path.mkdir(parents=True, exist_ok=True)
if mode is not None:
set_directory_permissions(path, mode)
return path
def harden_directory_permissions(paths: list[tuple[Path, int]]) -> None:
for path, mode in paths:
try:
set_directory_permissions(path, mode)
except Exception:
pass
DATA_DIR = ensure_dir(base_dir() / f"{APP_NAME}_data", 0o700)
LOG_DIR = ensure_dir(DATA_DIR / "logs", 0o700)
RUNTIME_DIR = ensure_dir(DATA_DIR / "runtime", 0o700)
CACHE_DIR = ensure_dir(DATA_DIR / "cache", 0o700)
STORAGE_DIR = ensure_dir(DATA_DIR / "storage", 0o700)
LOCK_FILE = RUNTIME_DIR / f"{APP_NAME}.lock"
def build_ipc_socket_name() -> str:
uid = os.getuid() if hasattr(os, "getuid") else "nouid"
seed = f"{APP_NAME}|{base_dir()}|{uid}"
digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
return f"{APP_NAME}_single_instance_{digest}"
IPC_SOCKET_NAME = build_ipc_socket_name()
def configure_runtime_environment() -> None:
system = platform.system().lower()
existing_flags = os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "").strip()
flags = existing_flags.split() if existing_flags else []
def add_flag(flag: str) -> None:
if flag not in flags:
flags.append(flag)
def remove_flag(flag: str) -> None:
while flag in flags:
flags.remove(flag)
os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1")
os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1")
if system == "linux":
ensure_dir(RUNTIME_DIR, 0o700)
try:
current_mode = stat.S_IMODE(os.stat(RUNTIME_DIR).st_mode)
if current_mode != 0o700:
os.chmod(RUNTIME_DIR, 0o700)
except Exception:
pass
os.environ["XDG_RUNTIME_DIR"] = str(RUNTIME_DIR)
add_flag("--disable-extensions")
add_flag("--disable-plugins")
add_flag("--disable-background-networking")
add_flag("--disable-component-update")
add_flag("--disable-sync")
add_flag("--no-default-browser-check")
add_flag("--no-first-run")
add_flag("--password-store=basic")
add_flag("--use-mock-keychain")
if ALLOW_QTWEBENGINE_NO_SANDBOX:
os.environ["QTWEBENGINE_DISABLE_SANDBOX"] = "1"
add_flag("--no-sandbox")
else:
os.environ.pop("QTWEBENGINE_DISABLE_SANDBOX", None)
remove_flag("--no-sandbox")
elif system in {"windows", "darwin"}:
add_flag("--disable-extensions")
add_flag("--disable-plugins")
add_flag("--disable-background-networking")
add_flag("--disable-component-update")
add_flag("--disable-sync")
add_flag("--no-default-browser-check")
add_flag("--no-first-run")
if not ALLOW_WEBRTC:
add_flag("--disable-webrtc")
if flags:
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = " ".join(flags)
else:
os.environ.pop("QTWEBENGINE_CHROMIUM_FLAGS", None)
detach_stdio_if_needed()
configure_runtime_environment()
from PySide6.QtCore import QCoreApplication, QLockFile, QStandardPaths, QTimer, QUrl, Slot
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import QApplication, QMainWindow, QMessageBox
from PySide6.QtWebEngineCore import (
QWebEngineCertificateError,
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
QWebEngineUrlRequestInfo,
QWebEngineUrlRequestInterceptor,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
def safe_url_string(url) -> str:
try:
return url.toString() if isinstance(url, QUrl) else str(url)
except Exception:
return "<unavailable-url>"
def current_proxy_port() -> int:
if _runtime_proxy_port is None:
raise RuntimeError("Local proxy port is not initialized")
return _runtime_proxy_port
def make_proxy_start_url(port: int) -> str:
return f"http://{LOCAL_PROXY_HOST}:{port}/p/{LOCAL_ROUTE_CODE}/"
def current_start_url() -> str:
if _runtime_start_url is None:
raise RuntimeError("Start URL is not initialized")
return _runtime_start_url
def initialize_start_url() -> None:
global _runtime_start_url
if is_proxy_mode():
_runtime_start_url = make_proxy_start_url(current_proxy_port())
else:
_runtime_start_url = UPSTREAM_URL
def redact_url(url: QUrl | str, hide_path: bool = REDACT_URL_PATH) -> str:
try:
qurl = url if isinstance(url, QUrl) else QUrl(str(url))
if not qurl.isValid():
return "<invalid-url>"
scheme = (qurl.scheme() or "").lower()
host = qurl.host() or ""
port = qurl.port()
path = qurl.path() or "/"
if not scheme or not host:
return "<redacted-url>"
if port and port > 0:
base = f"{scheme}://{host}:{port}"
else:
base = f"{scheme}://{host}"
if hide_path:
return base
return f"{base}{path}"
except Exception:
return "<unavailable-url>"
def configure_logging() -> logging.Logger:
app_logger = logging.getLogger(APP_NAME)
app_logger.setLevel(LOG_LEVEL)
if app_logger.handlers:
return app_logger
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
if not (is_frozen() and not ENABLE_CONSOLE_LOGGING_WHEN_FROZEN):
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(fmt)
app_logger.addHandler(sh)
fh = RotatingFileHandler(
str(LOG_DIR / f"{APP_NAME}.log"),
maxBytes=LOG_MAX_BYTES,
backupCount=LOG_BACKUP_COUNT,
encoding="utf-8",
)
fh.setFormatter(fmt)
app_logger.addHandler(fh)
app_logger.propagate = False
return app_logger
logger = configure_logging()
def is_root() -> bool:
try:
return hasattr(os, "geteuid") and os.geteuid() == 0
except Exception:
return False
def upstream_parts():
parsed = urlsplit(UPSTREAM_URL)
base_path = parsed.path or "/"
return parsed, base_path.rstrip("/")
def is_allowed_path(path: str) -> bool:
path = path or "/"
return any(path.startswith(prefix) for prefix in ALLOWED_PATH_PREFIXES)
def is_allowed_local_proxy_url(url: QUrl) -> bool:
try:
if not (url and url.isValid()):
return False
return bool(
(url.scheme() or "").lower() == "http"
and (url.host() or "").lower() == LOCAL_PROXY_HOST
and url.port() in (-1, current_proxy_port())
and is_allowed_path(url.path())
and not url.userName()
and not url.password()
)
except Exception:
return False
def is_allowed_upstream_url(url: QUrl) -> bool:
try:
if not (url and url.isValid()):
return False
parsed, base_path = upstream_parts()
expected_scheme = (parsed.scheme or "").lower()
expected_host = (parsed.hostname or "").lower()
expected_port = parsed.port or (443 if expected_scheme == "https" else 80)
if (url.scheme() or "").lower() != expected_scheme:
return False
if (url.host() or "").lower() != expected_host:
return False
if url.userName() or url.password():
return False
actual_port = url.port() if url.port() != -1 else expected_port
if actual_port != expected_port:
return False
path = url.path() or "/"
if base_path and base_path != "/":
return path == base_path or path.startswith(base_path + "/")
return path.startswith("/")
except Exception:
return False
def is_allowed_calls_https_url(url: QUrl) -> bool:
try:
if not (url and url.isValid()):
return False
if (url.scheme() or "").lower() != "https":
return False
if (url.host() or "").lower() != CALLS_HTTPS_HOST:
return False
if url.userName() or url.password():
return False
path = url.path() or "/"
return (
path == "/api/call/session"
or path == "/healthz"
or path == "/status"
or path == "/logs"
or path.startswith("/api/call/")
)
except Exception:
return False
def is_allowed_calls_wss_url(url: QUrl) -> bool:
try:
if not (url and url.isValid()):
return False
if (url.scheme() or "").lower() != "wss":
return False
if (url.host() or "").lower() != CALLS_WSS_HOST:
return False
if url.userName() or url.password():
return False
return (url.path() or "/") == "/ws"
except Exception:
return False
def is_allowed_navigation_url(url: QUrl) -> bool:
return is_allowed_local_proxy_url(url) if is_proxy_mode() else is_allowed_upstream_url(url)
def is_allowed_request_url(url: QUrl) -> bool:
return (
is_allowed_navigation_url(url)
or is_allowed_calls_https_url(url)
or is_allowed_calls_wss_url(url)
)
def validate_configuration() -> None:
if not APP_NAME or not isinstance(APP_NAME, str):
raise ValueError("APP_NAME must be a non-empty string")
if NETWORK_MODE not in {"proxy", "direct"}:
raise ValueError("NETWORK_MODE must be 'proxy' or 'direct'")
if not LOCAL_ROUTE_CODE or "/" in LOCAL_ROUTE_CODE:
raise ValueError("LOCAL_ROUTE_CODE must be a simple path segment")
upstream = urlsplit(UPSTREAM_URL)
if upstream.scheme not in {"https", "http"} or not upstream.hostname:
raise ValueError(f"Invalid UPSTREAM_URL: {UPSTREAM_URL}")
if is_direct_mode() and upstream.scheme != "https":
raise ValueError("Direct mode requires HTTPS upstream for session safety")
def open_in_system_browser(url: QUrl) -> None:
try:
url_str = safe_url_string(url)
ok = webbrowser.open(url_str)
if ok:
logger.info("Opened external link in system browser: %s", redact_url(url))
else:
logger.warning("System browser did not confirm opening URL: %s", redact_url(url))
except Exception:
logger.exception("Failed to open URL in system browser: %s", redact_url(url))
def join_paths(base_path: str, rest_path: str) -> str:
base = (base_path or "").rstrip("/")
rest = rest_path or "/"
if not rest.startswith("/"):
rest = "/" + rest
return (base + rest) if base else rest
def rewrite_location(location: str):
if not location:
return location, False
upstream = urlsplit(UPSTREAM_URL)
prefix = f"/p/{LOCAL_ROUTE_CODE}"
try:
if location.startswith("/"):
return prefix + location, False
parsed = urlsplit(location)
if parsed.scheme in ("http", "https") and parsed.hostname:
same_origin = (
parsed.scheme == upstream.scheme
and parsed.hostname == upstream.hostname
and (parsed.port or (443 if parsed.scheme == "https" else 80))
== (upstream.port or (443 if upstream.scheme == "https" else 80))
)
if same_origin:
path = parsed.path or "/"
base = upstream.path.rstrip("/")
if base and path.startswith(base + "/"):
path = path[len(base):]
elif base and path == base:
path = "/"
new_location = prefix + path
if parsed.query:
new_location += "?" + parsed.query
if parsed.fragment:
new_location += "#" + parsed.fragment
return new_location, False
return None, True
return None, True
except Exception:
return None, True
def normalize_host_header(value: str) -> str:
return (value or "").strip().lower().rstrip(".")
def is_valid_local_host_header(value: str) -> bool:
host = normalize_host_header(value)
port = current_proxy_port()
allowed = {
f"{LOCAL_PROXY_HOST}:{port}",
LOCAL_PROXY_HOST,
f"localhost:{port}",
"localhost",
}
return host in allowed
class LocalRateLimiter:
def __init__(self, window_seconds: float, max_requests: int, max_burst: int):
self.window_seconds = float(window_seconds)
self.max_requests = int(max_requests)
self.max_burst = int(max_burst)
self._lock = threading.Lock()
self._events: dict[str, deque[float]] = {}
def allow(self, key: str) -> bool:
now = time.monotonic()
burst_cutoff = now - 1.0
window_cutoff = now - self.window_seconds
with self._lock:
q = self._events.get(key)
if q is None:
q = deque()
self._events[key] = q
while q and q[0] < window_cutoff:
q.popleft()
total_recent = len(q)
burst_recent = 0
for ts in reversed(q):
if ts >= burst_cutoff:
burst_recent += 1
else:
break
if total_recent >= self.max_requests or burst_recent >= self.max_burst:
return False
q.append(now)
if len(q) == 1:
stale_keys = [k for k, v in self._events.items() if not v or v[-1] < window_cutoff]
for stale_key in stale_keys:
self._events.pop(stale_key, None)
return True
_rate_limiter = LocalRateLimiter(
RATE_LIMIT_WINDOW_SECONDS,
RATE_LIMIT_MAX_REQUESTS,
RATE_LIMIT_MAX_BURST,
)
class LocalProxyHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = APP_NAME
sys_version = ""
def version_string(self) -> str:
return APP_NAME
def log_message(self, format: str, *args) -> None:
logger.info("LocalProxy: " + format, *args)
def _send_security_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
self.send_header("Cross-Origin-Resource-Policy", "same-origin")
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
self.send_header(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=(), display-capture=(), notifications=()",
)
self.send_header("Cache-Control", "no-store")
def _deny(self, status: int, message: str) -> None:
body = message.encode("utf-8")
self.send_response(status)
self._send_security_headers()
self.send_header("content-type", "text/plain; charset=utf-8")
self.send_header("content-length", str(len(body)))
self.send_header("connection", "close")
self.end_headers()
if self.command.upper() != "HEAD":
self.wfile.write(body)
self.close_connection = True
def _client_key(self) -> str:
return self.client_address[0] if self.client_address else "unknown"
def _validate_request(self) -> Optional[tuple[str, str]]:
if self.command.upper() not in ALLOWED_PROXY_METHODS:
return ("method", "method not allowed")
if not _rate_limiter.allow(self._client_key()):
return ("rate", "too many requests")
host = self.headers.get("host", "")
if not is_valid_local_host_header(host):
return ("host", "invalid host header")
token = self.headers.get(LOCAL_PROXY_CSRF_HEADER, "")
if token != LOCAL_PROXY_CSRF_TOKEN:
return ("csrf", "forbidden")
parts = self.path.split("/", 3)
if len(parts) < 3 or parts[1] != "p" or parts[2] != LOCAL_ROUTE_CODE:
return ("path", "not found")
return None
def do_GET(self):
self._handle()
def do_POST(self):
self._handle()
def do_PUT(self):
self._handle()
def do_DELETE(self):
self._handle()
def do_HEAD(self):
self._handle()
def do_OPTIONS(self):
self._handle()
def _handle(self):
validation_error = self._validate_request()
if validation_error:
kind, message = validation_error
if kind == "method":
return self._deny(405, message)
if kind == "rate":
body = b"too many requests"
self.send_response(429)
self._send_security_headers()
self.send_header("Retry-After", "5")
self.send_header("content-type", "text/plain; charset=utf-8")
self.send_header("content-length", str(len(body)))
self.send_header("connection", "close")
self.end_headers()
if self.command.upper() != "HEAD":
self.wfile.write(body)
self.close_connection = True
return
if kind == "host":
return self._deny(400, message)
if kind == "csrf":
return self._deny(403, message)
return self._deny(404, message)
parts = self.path.split("/", 3)
rest = "/" + (parts[3] if len(parts) >= 4 else "")
upstream = urlsplit(UPSTREAM_URL)
target = join_paths(upstream.path, rest.split("?", 1)[0])
if "?" in self.path:
query = self.path.split("?", 1)[1]
target = target + ("?" if "?" not in target else "&") + query
try:
content_length = int(self.headers.get("content-length") or "0")
except ValueError:
return self._deny(400, "invalid content-length")
if content_length < 0:
return self._deny(400, "invalid content-length")
if content_length > MAX_BODY:
return self._deny(413, "payload too large")
body = self.rfile.read(content_length) if content_length else b""
connection_cls = (
http.client.HTTPSConnection
if upstream.scheme == "https"
else http.client.HTTPConnection
)
conn = connection_cls(
upstream.hostname,
upstream.port or (443 if upstream.scheme == "https" else 80),
timeout=PROXY_TIMEOUT_SECONDS,
)
headers = {}
for key, value in self.headers.items():
key_lower = key.lower()
if key_lower in HOP_HEADERS:
continue
if key_lower in DROP_REQUEST_HEADERS:
continue
if key_lower == LOCAL_PROXY_CSRF_HEADER.lower():
continue
headers[key] = value
headers["Host"] = (
upstream.hostname
if not upstream.port
else f"{upstream.hostname}:{upstream.port}"
)
headers["Connection"] = "close"
try:
conn.request(self.command, target, body=body, headers=headers)
response = conn.getresponse()
data = b"" if self.command.upper() == "HEAD" else response.read()
blocked_redirect = False
response_headers = []
for key, value in response.getheaders():
key_lower = key.lower()
if key_lower in HOP_HEADERS:
continue
if key_lower in DROP_RESPONSE_HEADERS:
continue
if key_lower == "location":
new_location, should_block = rewrite_location(value)
if should_block:
blocked_redirect = True
continue
response_headers.append(
("Location", new_location if new_location is not None else value)
)
continue
response_headers.append((key, value))
if blocked_redirect:
return self._deny(403, "blocked external redirect")
self.send_response(response.status, response.reason)
self._send_security_headers()
sent_content_length = False
for key, value in response_headers:
if key.lower() == "content-length":
sent_content_length = True
if key.lower() == "connection":
continue
self.send_header(key, value)
if not sent_content_length:
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
if self.command.upper() != "HEAD":
self.wfile.write(data)
self.close_connection = True
except Exception as exc:
logger.warning("Local proxy request failed: %s", exc)
return self._deny(502, "bad gateway")
finally:
conn.close()
def start_local_proxy_if_enabled() -> None:
global _proxy_server, _proxy_thread, _runtime_proxy_port
if not is_proxy_mode():
return
if _proxy_server is not None:
return
_proxy_server = ThreadingHTTPServer(
(LOCAL_PROXY_HOST, LOCAL_PROXY_PORT),
LocalProxyHandler,
)
_runtime_proxy_port = int(_proxy_server.server_address[1])
_proxy_thread = threading.Thread(
target=_proxy_server.serve_forever,
daemon=True,
name=f"{APP_NAME}-proxy",
)
_proxy_thread.start()
logger.info("Local proxy started: %s", make_proxy_start_url(_runtime_proxy_port))
def stop_local_proxy_if_running() -> None:
global _proxy_server, _proxy_thread
try:
if _proxy_server is not None:
_proxy_server.shutdown()
_proxy_server.server_close()
except Exception:
logger.debug("Failed to stop local proxy cleanly", exc_info=True)
finally:
_proxy_server = None
_proxy_thread = None
class RequestInterceptor(QWebEngineUrlRequestInterceptor):
def interceptRequest(self, info: QWebEngineUrlRequestInfo) -> None:
try:
url = info.requestUrl()
scheme = (url.scheme() or "").lower()
host = (url.host() or "").lower()
port = url.port()
method = bytes(info.requestMethod()).decode("utf-8", "ignore")
path = url.path() or "/"
if is_proxy_mode() and is_allowed_local_proxy_url(url):
try:
info.setHttpHeader(
LOCAL_PROXY_CSRF_HEADER.encode("ascii"),
LOCAL_PROXY_CSRF_TOKEN.encode("ascii"),
)
except Exception:
logger.debug("Could not attach proxy CSRF header", exc_info=True)
if not is_allowed_request_url(url):
logger.warning(
"[BLOCKED REQUEST] scheme=%s host=%s port=%s method=%s path=%s url=%s resource_type=%s",
scheme,
host,
port,
method,
path,
redact_url(url),
info.resourceType(),
)
info.block(True)
return
except Exception:
logger.exception("Error while intercepting request")
try:
info.block(True)
except Exception:
pass
class SingleInstance:
def __init__(self, lock_path: Path):
self.lock = QLockFile(str(lock_path))
try:
self.lock.setStaleLockTime(0)
except Exception:
pass
def acquire(self) -> bool:
try:
return self.lock.tryLock(0)
except Exception:
logger.exception("Failed to acquire single-instance lock")
return False
def release(self) -> None:
try:
self.lock.unlock()
except Exception:
logger.debug("Failed to release single-instance lock", exc_info=True)
class ActivationServer:
def __init__(self, server_name: str, activate_callback):
self.server_name = server_name
self.activate_callback = activate_callback
self.server = None
def start(self) -> bool:
if not ENABLE_SINGLE_INSTANCE_IPC:
return True
try:
QLocalServer.removeServer(self.server_name)
except Exception:
pass
self.server = QLocalServer()
self.server.newConnection.connect(self._handle_connection)
if not self.server.listen(self.server_name):
logger.warning(
"Failed to start local activation server: %s",
self.server.errorString() if hasattr(self.server, "errorString") else "unknown",
)
return False
logger.info("Activation server listening: %s", self.server_name)
return True
def close(self) -> None:
try:
if self.server is not None:
self.server.close()
except Exception:
logger.debug("Failed to close activation server", exc_info=True)
try:
QLocalServer.removeServer(self.server_name)
except Exception:
logger.debug("Failed to remove activation server name", exc_info=True)
@Slot()
def _handle_connection(self) -> None:
try:
while self.server.hasPendingConnections():
socket_ = self.server.nextPendingConnection()
if socket_ is None:
continue
socket_.waitForReadyRead(500)
data = bytes(socket_.readAll()).decode("utf-8", "ignore").strip()
logger.info("Received IPC message: %s", data)
if data == ACTIVATE_WINDOW_MESSAGE:
try:
self.activate_callback()
except Exception:
logger.exception("Failed to activate existing window")
socket_.disconnectFromServer()
except Exception:
logger.exception("Failed to handle IPC activation connection")
def signal_existing_instance(server_name: str) -> bool:
if not ENABLE_SINGLE_INSTANCE_IPC:
return False
try:
socket_ = QLocalSocket()
socket_.connectToServer(server_name)
if not socket_.waitForConnected(IPC_CONNECT_TIMEOUT_MS):
return False
socket_.write(ACTIVATE_WINDOW_MESSAGE.encode("utf-8"))
socket_.flush()
socket_.waitForBytesWritten(IPC_CONNECT_TIMEOUT_MS)
socket_.disconnectFromServer()
return True
except Exception:
logger.debug("Failed to signal existing instance", exc_info=True)
return False
class LockedPage(QWebEnginePage):
def __init__(self, profile: QWebEngineProfile, parent=None):
super().__init__(profile, parent)
try:
self.certificateError.connect(self._on_certificate_error)
except Exception:
logger.debug("certificateError signal unavailable", exc_info=True)
try:
self.featurePermissionRequested.connect(self._on_feature_permission_requested)
except Exception:
logger.debug("featurePermissionRequested signal unavailable", exc_info=True)
try:
self.windowCloseRequested.connect(self._on_window_close_requested)
except Exception:
logger.debug("windowCloseRequested signal unavailable", exc_info=True)
def javaScriptConsoleMessage(self, level, message, line_number, source_id):
if not ENABLE_JS_CONSOLE_LOGGING:
return
try:
logger.warning(
"[JS CONSOLE] level=%s source=%s line=%s message=%s",
level,
source_id,
line_number,
message,
)
except Exception:
logger.exception("Failed to log JavaScript console message")
def acceptNavigationRequest(self, url, nav_type, is_main_frame):
try:
if is_allowed_navigation_url(url):
return super().acceptNavigationRequest(url, nav_type, is_main_frame)
logger.warning(
"[BLOCKED NAVIGATION] url=%s is_main_frame=%s nav_type=%s",
redact_url(url),
is_main_frame,
nav_type,
)
if OPEN_EXTERNAL_LINKS_IN_SYSTEM_BROWSER and url.scheme().lower() in {"http", "https"}:
open_in_system_browser(url)
return False
except Exception:
logger.exception("Error while processing navigation request")
return False
def createWindow(self, _type):
logger.warning("[BLOCKED POPUP] createWindow request denied")
return None
@Slot()
def _on_window_close_requested(self):
logger.warning("[BLOCKED WINDOW CLOSE] windowCloseRequested denied")
@Slot(QUrl, object)
def _on_feature_permission_requested(self, security_origin, feature):
try:
allowed = False
try:
feature_enum = QWebEnginePage.Feature
except Exception:
feature_enum = None
if feature_enum is not None:
feature_map = {
getattr(feature_enum, "Geolocation", None): ALLOW_GEOLOCATION,
getattr(feature_enum, "Notifications", None): ALLOW_NOTIFICATIONS,
getattr(feature_enum, "MediaAudioCapture", None): ALLOW_MEDIA_AUDIO_CAPTURE,
getattr(feature_enum, "MediaVideoCapture", None): ALLOW_MEDIA_VIDEO_CAPTURE,
getattr(feature_enum, "MediaAudioVideoCapture", None): (
ALLOW_MEDIA_AUDIO_CAPTURE and ALLOW_MEDIA_VIDEO_CAPTURE
),
getattr(feature_enum, "DesktopVideoCapture", None): ALLOW_SCREEN_CAPTURE,
getattr(feature_enum, "DesktopAudioVideoCapture", None): (
ALLOW_SCREEN_CAPTURE and ALLOW_MEDIA_AUDIO_CAPTURE
),
getattr(feature_enum, "ClipboardReadWrite", None): ALLOW_CLIPBOARD,
getattr(feature_enum, "ClipboardRead", None): ALLOW_CLIPBOARD,
}
if feature in feature_map:
allowed = bool(feature_map[feature])
logger.warning(
"[FEATURE PERMISSION] origin=%s feature=%s allowed=%s",
redact_url(security_origin, hide_path=True),
str(feature),
allowed,
)
self.setFeaturePermission(
security_origin,
feature,
QWebEnginePage.PermissionPolicy.PermissionGrantedByUser
if allowed
else QWebEnginePage.PermissionPolicy.PermissionDeniedByUser,
)
except Exception:
logger.exception("Failed to process feature permission request")
try:
self.setFeaturePermission(
security_origin,
feature,
QWebEnginePage.PermissionPolicy.PermissionDeniedByUser,
)
except Exception:
pass
@Slot(QWebEngineCertificateError)
def _on_certificate_error(self, error):
try:
logger.error(
"[TLS ERROR] %s @ %s",
error.description(),
redact_url(error.url()),
)
error.rejectCertificate()
except Exception:
logger.exception("Failed to reject certificate")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.render_crash_times = []
self.setWindowTitle(WINDOW_TITLE)
self.resize(WINDOW_WIDTH, WINDOW_HEIGHT)
self.browser = QWebEngineView(self)
self.setCentralWidget(self.browser)
self.profile = self._build_profile()
self.page = LockedPage(self.profile, self.browser)
self.browser.setPage(self.page)
self._apply_webengine_settings()
self._connect_browser_signals()
self._load_start_url()
def _build_profile(self) -> QWebEngineProfile:
try:
ensure_dir(CACHE_DIR, 0o700)
ensure_dir(STORAGE_DIR, 0o700)
if OFF_THE_RECORD:
profile = QWebEngineProfile(self.browser)
logger.info("Using off-the-record web profile")
else:
profile = QWebEngineProfile(APP_NAME, self.browser)
profile.setCachePath(str(CACHE_DIR))
profile.setPersistentStoragePath(str(STORAGE_DIR))
logger.info(
"Using persistent profile cache=%s storage=%s",
str(CACHE_DIR),
str(STORAGE_DIR),
)
try:
if OFF_THE_RECORD:
profile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies
)
else:
profile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.AllowPersistentCookies
)
except Exception:
logger.debug("setPersistentCookiesPolicy unavailable", exc_info=True)
try:
profile.setHttpCacheType(
QWebEngineProfile.HttpCacheType.MemoryHttpCache
if OFF_THE_RECORD
else QWebEngineProfile.HttpCacheType.DiskHttpCache
)
except Exception:
logger.debug("setHttpCacheType unavailable", exc_info=True)
try:
self.request_interceptor = RequestInterceptor()
profile.setUrlRequestInterceptor(self.request_interceptor)
logger.info("Installed QWebEngineUrlRequestInterceptor")
except Exception:
logger.exception("Failed to install request interceptor")
raise
try:
profile.downloadRequested.connect(self._deny_download)
except Exception:
logger.debug("downloadRequested signal unavailable", exc_info=True)
return profile
except Exception as exc:
logger.exception("Failed to build QWebEngineProfile")
raise RuntimeError("Could not initialize browser profile") from exc
def _apply_webengine_settings(self) -> None:
try:
settings = self.browser.settings()
settings.setAttribute(
QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows,
False,
)
settings.setAttribute(
QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls,
False,
)
settings.setAttribute(
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls,
False,
)
settings.setAttribute(
QWebEngineSettings.WebAttribute.FullScreenSupportEnabled,
False,
)
settings.setAttribute(
QWebEngineSettings.WebAttribute.PluginsEnabled,
False,
)
settings.setAttribute(
QWebEngineSettings.WebAttribute.ErrorPageEnabled,
True,
)
for attr_name, value in (
("HyperlinkAuditingEnabled", False),
("PdfViewerEnabled", False),
("PlaybackRequiresUserGesture", True),
("JavascriptCanAccessClipboard", ALLOW_CLIPBOARD),
("WebRTCPublicInterfacesOnly", not ALLOW_WEBRTC),
("ScreenCaptureEnabled", ALLOW_SCREEN_CAPTURE),
):
try:
attr = getattr(QWebEngineSettings.WebAttribute, attr_name)
settings.setAttribute(attr, value)
except Exception:
logger.debug("WebAttribute unavailable: %s", attr_name, exc_info=True)
logger.info("Applied WebEngine security settings")
except Exception:
logger.exception("Failed to apply WebEngine settings")
raise
def _connect_browser_signals(self) -> None:
for signal_name, handler in (
("loadStarted", self._on_load_started),
("loadProgress", self._on_load_progress),
("loadFinished", self._on_load_finished),
("urlChanged", self._on_url_changed),
("renderProcessTerminated", self._on_render_process_terminated),
("titleChanged", self._on_title_changed),
):
try:
getattr(self.browser, signal_name).connect(handler)
except Exception:
logger.debug("Could not connect signal: %s", signal_name, exc_info=True)
def _load_start_url(self) -> None:
try:
qurl = QUrl(current_start_url())
if not is_allowed_navigation_url(qurl):
raise ValueError("Refusing to load disallowed start URL")
self.browser.setUrl(qurl)
logger.info("Loading start URL: %s", redact_url(qurl))
except Exception:
logger.exception("Failed to load start URL")
QMessageBox.critical(self, APP_NAME, "Failed to load the application start URL.")
raise
def _deny_download(self, item) -> None:
try:
logger.warning("[BLOCKED DOWNLOAD] %s", redact_url(item.url()))
item.cancel()
except Exception:
logger.exception("Failed while denying download request")
def activate_and_raise(self) -> None:
try:
if self.isMinimized():
self.showNormal()
else:
self.show()
self.raise_()
self.activateWindow()
logger.info("Activated existing main window")
except Exception:
logger.exception("Failed to activate main window")
def _record_render_crash(self) -> int:
current = time.time()
self.render_crash_times.append(current)
self.render_crash_times = [
ts for ts in self.render_crash_times
if current - ts <= RENDER_CRASH_WINDOW_SECONDS
]
return len(self.render_crash_times)
@Slot()
def _on_load_started(self):
logger.info("Page load started")
@Slot(int)
def _on_load_progress(self, progress):
logger.debug("Page load progress: %s%%", progress)
@Slot(bool)
def _on_load_finished(self, ok):
current_url = redact_url(self.browser.url())
if ok:
logger.info("Page load finished successfully: %s", current_url)
else:
logger.error("Page load failed: %s", current_url)
QTimer.singleShot(
0,
lambda: QMessageBox.warning(
self,
APP_NAME,
"The page failed to load. Please verify network and server availability.",
),
)
@Slot(QUrl)
def _on_url_changed(self, url):
logger.info("URL changed: %s", redact_url(url))
@Slot(str)
def _on_title_changed(self, title):
try:
self.setWindowTitle(f"{WINDOW_TITLE} - {title}" if title else WINDOW_TITLE)
except Exception:
logger.exception("Failed to update window title")
@Slot(object, int)
def _on_render_process_terminated(self, termination_status, exit_code):
crash_count = self._record_render_crash()
logger.error(
"Render process terminated: status=%s exit_code=%s recent_crashes=%s",
termination_status,
exit_code,
crash_count,
)
if crash_count >= MAX_RENDER_CRASHES:
try:
QMessageBox.critical(
self,
APP_NAME,
"The embedded browser process has crashed repeatedly.\n\n"
"Automatic reload has been stopped to avoid a crash loop.\n"
"Please restart the application or contact support.",
)
except Exception:
logger.exception("Failed showing crash-loop dialog")
return
try:
QMessageBox.critical(
self,
APP_NAME,
"The embedded browser process terminated unexpectedly. The page will be reloaded.",
)
except Exception:
logger.exception("Failed showing render-process-terminated dialog")
try:
self.browser.reload()
except Exception:
logger.exception("Failed to reload browser after render process termination")
def excepthook(exc_type, exc_value, exc_tb):
tb_text = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
logger.critical("Unhandled exception:\n%s", tb_text)
try:
QMessageBox.critical(None, APP_NAME, f"An unexpected error occurred:\n\n{exc_value}")
except Exception:
pass
def install_signal_handlers(app: QApplication) -> None:
try:
signal.signal(signal.SIGINT, lambda *_: app.quit())
except Exception:
logger.debug("SIGINT handler unavailable", exc_info=True)
try:
signal.signal(signal.SIGTERM, lambda *_: app.quit())
except Exception:
logger.debug("SIGTERM handler unavailable", exc_info=True)
def configure_qt_paths() -> None:
try:
QCoreApplication.setApplicationName(APP_NAME)
QCoreApplication.setOrganizationName(APP_NAME)
QCoreApplication.setApplicationVersion(APP_VERSION)
if is_frozen():
helper_name = (
"QtWebEngineProcess.exe"
if platform.system().lower() == "windows"
else "QtWebEngineProcess"
)
candidates = [
resource_dir() / helper_name,
resource_dir() / "PySide6" / "Qt" / "libexec" / helper_name,
resource_dir() / "Qt" / "libexec" / helper_name,
base_dir() / helper_name,
base_dir() / "PySide6" / "Qt" / "libexec" / helper_name,
base_dir() / "Qt" / "libexec" / helper_name,
]
for candidate in candidates:
if candidate.exists() and candidate.is_file():
os.environ["QTWEBENGINEPROCESS_PATH"] = str(candidate)
logger.info("Using QtWebEngineProcess: %s", candidate)
break
else:
logger.warning("QtWebEngineProcess helper not found in expected locations")
else:
os.environ.pop("QTWEBENGINEPROCESS_PATH", None)
except Exception:
logger.debug("Failed to configure Qt paths/environment", exc_info=True)
def harden_runtime_paths() -> None:
try:
harden_directory_permissions(
[
(DATA_DIR, 0o700),
(LOG_DIR, 0o700),
(RUNTIME_DIR, 0o700),
(CACHE_DIR, 0o700),
(STORAGE_DIR, 0o700),
]
)
logger.info("Hardened directory permissions for app data paths")
except Exception:
logger.exception("Failed to harden runtime paths")
def main():
instance = None
activation_server = None
try:
validate_configuration()
if is_root():
logger.error("Refusing to run as root")
sys.exit(1)
sys.excepthook = excepthook
configure_qt_paths()
harden_runtime_paths()
QApplication.setApplicationName(APP_NAME)
QApplication.setOrganizationName(APP_NAME)
start_local_proxy_if_enabled()
initialize_start_url()
app = QApplication(sys.argv)
install_signal_handlers(app)
instance = SingleInstance(LOCK_FILE)
if not instance.acquire():
logger.warning("Another instance is already running")
activated = signal_existing_instance(IPC_SOCKET_NAME)
if activated:
logger.info("Signaled existing instance to activate window")
else:
QMessageBox.warning(
None,
APP_NAME,
"Another instance of this application is already running.",
)
sys.exit(1)
try:
logger.info(
"Qt AppDataLocation(s): %s",
QStandardPaths.standardLocations(
QStandardPaths.StandardLocation.AppDataLocation
),
)
except Exception:
logger.debug("Failed to query Qt standard paths", exc_info=True)
window = MainWindow()
activation_server = ActivationServer(
IPC_SOCKET_NAME,
window.activate_and_raise,
)
activation_server.start()
try:
app.aboutToQuit.connect(
lambda: activation_server.close() if activation_server else None
)
except Exception:
logger.debug("Could not connect activation server cleanup", exc_info=True)
try:
app.aboutToQuit.connect(lambda: instance.release() if instance else None)
except Exception:
logger.debug("Could not connect lock cleanup", exc_info=True)
try:
app.aboutToQuit.connect(stop_local_proxy_if_running)
except Exception:
logger.debug("Could not connect proxy cleanup", exc_info=True)
window.show()
logger.info(
"Application started platform=%s python=%s frozen=%s version=%s network_mode=%s resource_dir=%s base_dir=%s data_dir=%s start_url=%s sandbox_disabled=%s",
platform.platform(),
sys.version.split()[0],
is_frozen(),
APP_VERSION,
NETWORK_MODE,
resource_dir(),
base_dir(),
DATA_DIR,
current_start_url(),
os.environ.get("QTWEBENGINE_DISABLE_SANDBOX") == "1",
)
rc = app.exec()
if activation_server:
activation_server.close()
if instance:
instance.release()
stop_local_proxy_if_running()
sys.exit(rc)
except Exception as exc:
logger.critical("Fatal startup error: %s", exc, exc_info=True)
try:
if QApplication.instance() is not None:
QMessageBox.critical(None, APP_NAME, f"Fatal startup error:\n\n{exc}")
except Exception:
pass
try:
if activation_server:
activation_server.close()
except Exception:
pass
try:
if instance:
instance.release()
except Exception:
pass
try:
stop_local_proxy_if_running()
except Exception:
pass
sys.exit(1)
if __name__ == "__main__":
main()