import logging import os import platform import signal import sys import traceback import webbrowser from logging.handlers import RotatingFileHandler from pathlib import Path APP_NAME = "angelhome" START_URL = "https://angel.33.backend-3.com" ALLOWED_HOSTS = {"angel.33.backend-3.com"} ALLOW_SUBDOMAINS = False OPEN_EXTERNAL_LINKS_IN_SYSTEM_BROWSER = False # Set False so cookies/local storage/session data can persist 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 def is_frozen() -> bool: return bool(getattr(sys, "frozen", False)) def resource_dir() -> Path: """ Directory where bundled resources live. In PyInstaller one-file mode, this may point to _MEIPASS. In one-folder mode, it is usually the executable folder. """ 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: """ Writable base directory for persistent app data. Always use executable folder when frozen. Never write into _MEIPASS. """ if is_frozen(): return Path(sys.executable).resolve().parent return Path(__file__).resolve().parent def ensure_dir(path: Path) -> Path: path.mkdir(parents=True, exist_ok=True) return path DATA_DIR = ensure_dir(base_dir() / f"{APP_NAME}_data") LOG_DIR = ensure_dir(DATA_DIR / "logs") RUNTIME_DIR = ensure_dir(DATA_DIR / "runtime") CACHE_DIR = ensure_dir(DATA_DIR / "cache") STORAGE_DIR = ensure_dir(DATA_DIR / "storage") LOCK_FILE = RUNTIME_DIR / f"{APP_NAME}.lock" 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) os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1") os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1") if system == "linux": os.environ.setdefault("QTWEBENGINE_DISABLE_SANDBOX", "1") os.environ.setdefault("XDG_RUNTIME_DIR", str(RUNTIME_DIR)) add_flag("--no-sandbox") 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") 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 flags: os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = " ".join(flags) configure_runtime_environment() from PySide6.QtCore import ( QCoreApplication, QLockFile, QStandardPaths, QTimer, QUrl, Slot, ) from PySide6.QtWidgets import QApplication, QMainWindow, QMessageBox from PySide6.QtWebEngineCore import ( QWebEngineCertificateError, QWebEnginePage, QWebEngineProfile, QWebEngineSettings, ) from PySide6.QtWebEngineWidgets import QWebEngineView def configure_logging() -> logging.Logger: logger = logging.getLogger(APP_NAME) logger.setLevel(LOG_LEVEL) if logger.handlers: return logger fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") sh = logging.StreamHandler(sys.stdout) sh.setFormatter(fmt) 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) logger.addHandler(fh) logger.propagate = False return logger logger = configure_logging() def is_root() -> bool: try: return hasattr(os, "geteuid") and os.geteuid() == 0 except Exception: return False def safe_url_string(url) -> str: try: return url.toString() if isinstance(url, QUrl) else str(url) except Exception: return "" def is_allowed_host(host: str) -> bool: host = (host or "").lower().strip() if host in ALLOWED_HOSTS: return True if ALLOW_SUBDOMAINS: for allowed in ALLOWED_HOSTS: allowed = (allowed or "").lower().strip() if host == allowed or host.endswith("." + allowed): return True return False def is_allowed_url(url: QUrl) -> bool: try: return bool( url and url.isValid() and url.scheme().lower() == "https" and is_allowed_host(url.host()) ) except Exception: return False 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 not ALLOWED_HOSTS or not isinstance(ALLOWED_HOSTS, (set, list, tuple)): raise ValueError("ALLOWED_HOSTS must be a non-empty set/list/tuple") qurl = QUrl(START_URL) if not qurl.isValid(): raise ValueError(f"Invalid START_URL: {START_URL}") if not is_allowed_url(qurl): raise ValueError("START_URL is not allowed by current security policy") 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", url_str) else: logger.warning("System browser did not confirm opening URL: %s", url_str) except Exception: logger.exception( "Failed to open URL in system browser: %s", safe_url_string(url) ) class SingleInstance: def __init__(self, lock_path: Path): self.lock = QLockFile(str(lock_path)) self.lock.setStaleLockTime(0) 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 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): 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_url(url): return super().acceptNavigationRequest(url, nav_type, is_main_frame) logger.warning( "[BLOCKED NAVIGATION] url=%s is_main_frame=%s nav_type=%s", safe_url_string(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: logger.warning( "[DENIED PERMISSION] origin=%s feature=%s", safe_url_string(security_origin), feature, ) self.setFeaturePermission( security_origin, feature, QWebEnginePage.PermissionPolicy.PermissionDeniedByUser, ) except Exception: logger.exception("Failed to deny feature permission request") @Slot(QWebEngineCertificateError) def _on_certificate_error(self, error): try: logger.error( "[TLS ERROR] %s @ %s", error.description(), safe_url_string(error.url()) ) error.rejectCertificate() except Exception: logger.exception("Failed to reject certificate") class MainWindow(QMainWindow): def __init__(self): super().__init__() 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) ensure_dir(STORAGE_DIR) 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", CACHE_DIR, 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: 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), ): 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(START_URL) if not is_allowed_url(qurl): raise ValueError("Refusing to load disallowed START_URL") self.browser.setUrl(qurl) logger.info("Loading start URL: %s", START_URL) 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", safe_url_string(item.url())) item.cancel() except Exception: logger.exception("Failed while denying download request") @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 = safe_url_string(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", safe_url_string(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): logger.error( "Render process terminated: status=%s exit_code=%s", termination_status, exit_code, ) 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("2.0") 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(): 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 main(): instance = None try: validate_configuration() if is_root(): logger.error("Refusing to run as root") print("Refusing to run as root.") sys.exit(1) sys.excepthook = excepthook configure_qt_paths() QApplication.setApplicationName(APP_NAME) QApplication.setOrganizationName(APP_NAME) app = QApplication(sys.argv) install_signal_handlers(app) instance = SingleInstance(LOCK_FILE) if not instance.acquire(): logger.warning("Another instance is already running") 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() window.show() logger.info( "Application started platform=%s python=%s frozen=%s resource_dir=%s base_dir=%s data_dir=%s", platform.platform(), sys.version.split()[0], is_frozen(), resource_dir(), base_dir(), DATA_DIR, ) rc = app.exec() if instance: instance.release() 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}") else: print(f"Fatal startup error: {exc}", file=sys.stderr) except Exception: print(f"Fatal startup error: {exc}", file=sys.stderr) try: if instance: instance.release() except Exception: pass sys.exit(1) if __name__ == "__main__": main()