From a53cd52c73b0e8297690004690c5acc7c3ce2f05 Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Mon, 9 Feb 2026 11:28:16 -0600 Subject: [PATCH] feat(freecad): add Silo-aware start page with webview and offline fallback Replaces the default FreeCAD Start page with a dual-mode view: - Online: QWebEngineView loading the Silo web app - Offline: native Qt fallback with recent files and connectivity status The command override is registered at InitGui.py load time, before the C++ StartLauncher fires. --- freecad/InitGui.py | 9 + freecad/silo_start.py | 444 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100644 freecad/silo_start.py diff --git a/freecad/InitGui.py b/freecad/InitGui.py index 8f33e2b..683c38f 100644 --- a/freecad/InitGui.py +++ b/freecad/InitGui.py @@ -67,3 +67,12 @@ class SiloWorkbench(FreeCADGui.Workbench): FreeCADGui.addWorkbench(SiloWorkbench()) FreeCAD.Console.PrintMessage("Silo workbench registered\n") + +# Override the Start page with Silo-aware version (must happen before +# the C++ StartLauncher fires at ~100ms after GUI init) +try: + import silo_start + + silo_start.register() +except Exception as e: + FreeCAD.Console.PrintWarning(f"Silo Start page override failed: {e}\n") diff --git a/freecad/silo_start.py b/freecad/silo_start.py new file mode 100644 index 0000000..00a4cee --- /dev/null +++ b/freecad/silo_start.py @@ -0,0 +1,444 @@ +"""Silo Start Page — dual-mode start view for Kindred Create. + +Replaces the default Start page with either: +- A QWebEngineView showing the Silo web app (when Silo is reachable) +- A native Qt offline fallback with recent files and connectivity status + +The command override is activated by calling ``register()`` at module level +from InitGui.py, which overwrites the C++ ``Start_Start`` command. +""" + +import os +import ssl +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime +from pathlib import Path + +import FreeCAD +import FreeCADGui +from PySide import QtCore, QtGui, QtWidgets + +# Try to import QtWebEngineWidgets — not all builds ship it +_HAS_WEBENGINE = False +try: + from PySide import QtWebEngineWidgets + + _HAS_WEBENGINE = True +except ImportError: + FreeCAD.Console.PrintLog( + "Silo Start: QtWebEngineWidgets not available, using offline mode only\n" + ) + +# --------------------------------------------------------------------------- +# Catppuccin Mocha palette +# --------------------------------------------------------------------------- +_MOCHA = { + "base": "#1e1e2e", + "mantle": "#181825", + "crust": "#11111b", + "surface0": "#313244", + "surface1": "#45475a", + "surface2": "#585b70", + "text": "#cdd6f4", + "subtext0": "#a6adc8", + "subtext1": "#bac2de", + "blue": "#89b4fa", + "green": "#a6e3a1", + "red": "#f38ba8", + "peach": "#fab387", + "lavender": "#b4befe", + "overlay0": "#6c7086", +} + +_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo" +_POLL_INTERVAL_MS = 5000 +_CONNECT_TIMEOUT_S = 2 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_silo_base_url() -> str: + """Return the Silo web UI root URL (without /api).""" + param = FreeCAD.ParamGet(_PREF_GROUP) + url = param.GetString("ApiUrl", "") + if not url: + url = os.environ.get("SILO_API_URL", "http://localhost:8080/api") + url = url.rstrip("/") + # Strip trailing /api to get the web root + if url.endswith("/api"): + url = url[:-4] + return url + + +def _get_ssl_context() -> ssl.SSLContext: + """Build an SSL context respecting the Silo SSL preference.""" + param = FreeCAD.ParamGet(_PREF_GROUP) + if param.GetBool("SslVerify", True): + return ssl.create_default_context() + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +def _check_connectivity(url: str) -> bool: + """HEAD-request the Silo base URL, return True if reachable.""" + try: + req = urllib.request.Request(url, method="HEAD") + req.add_header("User-Agent", "kindred-create/1.0") + urllib.request.urlopen(req, timeout=_CONNECT_TIMEOUT_S, context=_get_ssl_context()) + return True + except Exception: + return False + + +def _get_recent_files() -> list: + """Read recent files from FreeCAD preferences.""" + group = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/RecentFiles") + count = group.GetInt("RecentFiles", 0) + files = [] + for i in range(count): + path = group.GetString(f"MRU{i}", "") + if path and os.path.exists(path): + p = Path(path) + mtime = datetime.fromtimestamp(p.stat().st_mtime) + files.append({"path": str(p), "name": p.name, "modified": mtime}) + return files + + +# --------------------------------------------------------------------------- +# Offline fallback widget +# --------------------------------------------------------------------------- + + +class _OfflineWidget(QtWidgets.QWidget): + """Native Qt fallback showing Silo status and recent local files.""" + + retry_clicked = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._build_ui() + self.update_status(False, _get_silo_base_url()) + + def _build_ui(self): + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(40, 30, 40, 20) + layout.setSpacing(0) + + # --- Status banner --- + banner = QtWidgets.QFrame() + banner.setObjectName("SiloStatusBanner") + banner_layout = QtWidgets.QHBoxLayout(banner) + banner_layout.setContentsMargins(16, 12, 16, 12) + + self._status_icon = QtWidgets.QLabel() + self._status_icon.setFixedSize(12, 12) + banner_layout.addWidget(self._status_icon) + + self._status_label = QtWidgets.QLabel() + self._status_label.setWordWrap(True) + banner_layout.addWidget(self._status_label, 1) + + self._retry_btn = QtWidgets.QPushButton("Retry") + self._retry_btn.setFixedWidth(80) + self._retry_btn.setCursor(QtCore.Qt.PointingHandCursor) + self._retry_btn.clicked.connect(self.retry_clicked.emit) + banner_layout.addWidget(self._retry_btn) + + layout.addWidget(banner) + layout.addSpacing(24) + + # --- Recent files header --- + header = QtWidgets.QLabel("Recent Files") + header.setObjectName("SiloRecentHeader") + layout.addWidget(header) + layout.addSpacing(12) + + # --- Recent files list --- + self._file_list = QtWidgets.QListWidget() + self._file_list.setObjectName("SiloRecentList") + self._file_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self._file_list.itemDoubleClicked.connect(self._on_file_clicked) + layout.addWidget(self._file_list, 1) + + layout.addSpacing(16) + + # --- Footer --- + footer = QtWidgets.QHBoxLayout() + footer.addStretch() + self._startup_cb = QtWidgets.QCheckBox("Don't show this page on startup") + start_prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Start") + show = start_prefs.GetBool("ShowOnStartup", True) + self._startup_cb.setChecked(not show) + self._startup_cb.toggled.connect(self._on_startup_toggled) + footer.addWidget(self._startup_cb) + layout.addLayout(footer) + + self._apply_style() + + def _apply_style(self): + self.setStyleSheet(f""" + _OfflineWidget {{ + background-color: {_MOCHA["base"]}; + }} + #SiloStatusBanner {{ + background-color: {_MOCHA["surface0"]}; + border-radius: 8px; + }} + #SiloStatusBanner QLabel {{ + color: {_MOCHA["text"]}; + font-size: 13px; + }} + #SiloStatusBanner QPushButton {{ + background-color: {_MOCHA["blue"]}; + color: {_MOCHA["crust"]}; + border: none; + border-radius: 4px; + padding: 6px 12px; + font-weight: bold; + font-size: 12px; + }} + #SiloStatusBanner QPushButton:hover {{ + background-color: {_MOCHA["lavender"]}; + }} + #SiloRecentHeader {{ + color: {_MOCHA["text"]}; + font-size: 20px; + font-weight: bold; + }} + #SiloRecentList {{ + background-color: {_MOCHA["mantle"]}; + border: 1px solid {_MOCHA["surface0"]}; + border-radius: 6px; + padding: 4px; + }} + #SiloRecentList::item {{ + padding: 10px 12px; + border-bottom: 1px solid {_MOCHA["surface0"]}; + color: {_MOCHA["text"]}; + }} + #SiloRecentList::item:last {{ + border-bottom: none; + }} + #SiloRecentList::item:hover {{ + background-color: {_MOCHA["surface0"]}; + }} + #SiloRecentList::item:selected {{ + background-color: {_MOCHA["surface1"]}; + }} + QCheckBox {{ + color: {_MOCHA["subtext0"]}; + font-size: 12px; + }} + QCheckBox::indicator {{ + width: 14px; + height: 14px; + }} + """) + + def update_status(self, connected: bool, url: str, error: str = ""): + if connected: + self._status_icon.setStyleSheet( + f"background-color: {_MOCHA['green']}; border-radius: 6px;" + ) + self._status_label.setText(f"Silo connected — {url}") + self._retry_btn.hide() + else: + self._status_icon.setStyleSheet( + f"background-color: {_MOCHA['red']}; border-radius: 6px;" + ) + msg = f"Silo not reachable — {url}" + if error: + msg += f" ({error})" + if not _HAS_WEBENGINE: + msg += " [WebEngine not available]" + self._status_label.setText(msg) + self._retry_btn.show() + + def refresh_recent_files(self): + self._file_list.clear() + files = _get_recent_files() + if not files: + item = QtWidgets.QListWidgetItem("No recent files") + item.setFlags(QtCore.Qt.NoItemFlags) + self._file_list.addItem(item) + return + for f in files: + label = f"{f['name']}\n{f['path']}\nModified: {f['modified']:%Y-%m-%d %H:%M}" + item = QtWidgets.QListWidgetItem(label) + item.setData(QtCore.Qt.UserRole, f["path"]) + self._file_list.addItem(item) + + def _on_file_clicked(self, item: QtWidgets.QListWidgetItem): + path = item.data(QtCore.Qt.UserRole) + if path: + try: + FreeCADGui.open(path) + except Exception as e: + FreeCAD.Console.PrintError(f"Silo Start: failed to open {path}: {e}\n") + + @staticmethod + def _on_startup_toggled(checked: bool): + prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Start") + prefs.SetBool("ShowOnStartup", not checked) + + +# --------------------------------------------------------------------------- +# Web engine page (navigation filter) +# --------------------------------------------------------------------------- + +if _HAS_WEBENGINE: + + class _SiloPage(QtWebEngineWidgets.QWebEnginePage): + """Custom page that keeps navigation within the Silo origin.""" + + def __init__(self, silo_origin: str, parent=None): + super().__init__(parent) + self._silo_origin = silo_origin + + def acceptNavigationRequest(self, url, nav_type, is_main_frame): + if nav_type == QtWebEngineWidgets.QWebEnginePage.NavigationTypeLinkClicked: + target = url.toString() + if not target.startswith(self._silo_origin): + QtGui.QDesktopServices.openUrl(url) + return False + return super().acceptNavigationRequest(url, nav_type, is_main_frame) + + +# --------------------------------------------------------------------------- +# Main start view +# --------------------------------------------------------------------------- + + +class SiloStartView(QtWidgets.QWidget): + """Dual-mode start page: Silo webview (online) / offline fallback.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("SiloStartView") + + self._silo_url = _get_silo_base_url() + self._connected = False + + # Stack: page 0 = webview (or placeholder), page 1 = offline + self._stack = QtWidgets.QStackedWidget(self) + root_layout = QtWidgets.QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.addWidget(self._stack) + + # Page 0: web view + self._web_view = None + if _HAS_WEBENGINE: + self._web_view = QtWebEngineWidgets.QWebEngineView() + page = _SiloPage(self._silo_url, self._web_view) + self._web_view.setPage(page) + self._stack.addWidget(self._web_view) # index 0 + else: + # placeholder so indices stay consistent + placeholder = QtWidgets.QWidget() + self._stack.addWidget(placeholder) # index 0 + + # Page 1: offline fallback + self._offline = _OfflineWidget() + self._offline.retry_clicked.connect(self._check_now) + self._offline.refresh_recent_files() + self._stack.addWidget(self._offline) # index 1 + + # Start on offline page, then check connectivity + self._stack.setCurrentIndex(1) + + # Connectivity polling timer + self._poll_timer = QtCore.QTimer(self) + self._poll_timer.setInterval(_POLL_INTERVAL_MS) + self._poll_timer.timeout.connect(self._poll) + self._poll_timer.start() + + # Immediate first check + QtCore.QTimer.singleShot(0, self._check_now) + + def _check_now(self): + """Run an immediate connectivity check and update the view.""" + self._silo_url = _get_silo_base_url() + connected = _check_connectivity(self._silo_url) + self._set_connected(connected) + + def _poll(self): + """Periodic connectivity check.""" + self._silo_url = _get_silo_base_url() + connected = _check_connectivity(self._silo_url) + self._set_connected(connected) + + def _set_connected(self, connected: bool): + if connected == self._connected: + return + self._connected = connected + if connected and self._web_view: + self._web_view.setUrl(QtCore.QUrl(self._silo_url)) + self._stack.setCurrentIndex(0) + FreeCAD.Console.PrintLog(f"Silo Start: connected to {self._silo_url}\n") + else: + self._offline.update_status(False, self._silo_url) + self._offline.refresh_recent_files() + self._stack.setCurrentIndex(1) + if not connected: + FreeCAD.Console.PrintLog(f"Silo Start: cannot reach {self._silo_url}\n") + self._offline.update_status(connected, self._silo_url) + + +# --------------------------------------------------------------------------- +# Command override +# --------------------------------------------------------------------------- + + +class _SiloStartCommand: + """Replacement for the C++ Start_Start command.""" + + def Activated(self): + mw = FreeCADGui.getMainWindow() + mdi = mw.findChild(QtWidgets.QMdiArea) + if not mdi: + return + + # Reuse existing view if open + for sw in mdi.subWindowList(): + if sw.widget() and sw.widget().objectName() == "SiloStartView": + mdi.setActiveSubWindow(sw) + sw.show() + return + + # Create new view as MDI subwindow + view = SiloStartView() + sw = mdi.addSubWindow(view) + sw.setWindowTitle("Start") + sw.setWindowIcon(QtGui.QIcon(":/icons/StartCommandIcon.svg")) + sw.show() + mdi.setActiveSubWindow(sw) + + def GetResources(self): + return { + "MenuText": "&Start Page", + "ToolTip": "Displays the start page", + "Pixmap": "StartCommandIcon", + } + + def IsActive(self): + return True + + +def register(): + """Override the Start_Start command with the Silo start page. + + Call this from InitGui.py at module level so the override is in + place before the C++ StartLauncher fires (100ms after GUI init). + """ + try: + FreeCADGui.addCommand("Start_Start", _SiloStartCommand()) + FreeCAD.Console.PrintMessage("Silo Start: registered start page override\n") + except Exception as e: + FreeCAD.Console.PrintWarning(f"Silo Start: failed to register override: {e}\n")