Compare commits

..

3 Commits

Author SHA1 Message Date
e83769090b fix: use Qt enum for setWindowModality instead of raw integer
PySide6 requires the proper enum type QtCore.Qt.WindowModal, not the
raw integer 2. The integer form was accepted by PySide2/Qt5 but raises
TypeError in PySide6.
2026-02-11 07:40:05 -06:00
6c9789fdf3 fix: use FreeCADGui.Document.Modified instead of App.Document.IsModified()
App.Document has no IsModified() method, causing Silo_Pull to crash with
AttributeError. The correct API is to get the Gui document and check its
Modified property, consistent with the pattern used elsewhere in this file
(lines 891, 913).
2026-02-10 10:39:13 -06:00
85bfb17854 feat: native Qt start panel with Silo API + kindred:// URL scheme
Replace QWebEngineView-based start page with a rich native Qt panel that
fetches items directly from the Silo REST API. QWebEngineView is not
available on conda-forge for Qt6.

Start panel features:
- Database Items list with search (from SiloClient.list_items)
- Recent Files list from FreeCAD preferences
- Real-time Activity Feed via SSE (SiloEventListener)
- Context menu: Open in Create, Open in Browser, Copy Part Number
- Open in Browser button (QDesktopServices)
- Catppuccin Mocha dark theme styling

URL scheme support:
- handle_kindred_url() function for kindred://item/{part_number} URLs
- Startup hook in InitGui.py for cold-start URL arguments

Closes #167
2026-02-10 10:30:12 -06:00
4 changed files with 568 additions and 391 deletions

View File

@@ -76,3 +76,22 @@ try:
silo_start.register()
except Exception as e:
FreeCAD.Console.PrintWarning(f"Silo Start page override failed: {e}\n")
# Handle kindred:// URLs passed as command-line arguments on cold start.
# Delayed to run after the GUI is fully initialised and the Silo addon has
# loaded its client/sync objects.
def _handle_startup_urls():
"""Process any kindred:// URLs passed as command-line arguments."""
import sys
from silo_commands import handle_kindred_url
for arg in sys.argv[1:]:
if arg.startswith("kindred://"):
handle_kindred_url(arg)
from PySide import QtCore
QtCore.QTimer.singleShot(500, _handle_startup_urls)

View File

@@ -26,9 +26,7 @@ from silo_client import (
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
# Configuration - preferences take priority over env vars
SILO_PROJECTS_DIR = os.environ.get(
"SILO_PROJECTS_DIR", os.path.expanduser("~/projects")
)
SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects"))
# ---------------------------------------------------------------------------
@@ -66,9 +64,7 @@ class FreeCADSiloSettings(SiloSettings):
param = FreeCAD.ParamGet(_PREF_GROUP)
return param.GetString("SslCertPath", "")
def save_auth(
self, username: str, role: str = "", source: str = "", token: str = ""
):
def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""):
param = FreeCAD.ParamGet(_PREF_GROUP)
param.SetString("AuthUsername", username)
param.SetString("AuthRole", role)
@@ -126,9 +122,7 @@ def _get_ssl_verify() -> bool:
def _get_ssl_context():
from silo_client._ssl import build_ssl_context
return build_ssl_context(
_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path()
)
return build_ssl_context(_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path())
def _get_auth_headers() -> Dict[str, str]:
@@ -185,9 +179,7 @@ def _fetch_server_mode() -> str:
# Icon helper
# ---------------------------------------------------------------------------
_ICON_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "resources", "icons"
)
_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons")
def _icon(name):
@@ -557,6 +549,35 @@ class SiloSync:
_sync = SiloSync()
# ---------------------------------------------------------------------------
# kindred:// URL handler
# ---------------------------------------------------------------------------
def handle_kindred_url(url: str):
"""Handle a ``kindred://`` URL by opening the referenced item.
URL format::
kindred://item/{part_number}
kindred://item/{part_number}/revision/{rev_number}
Called from C++ ``MainWindow::processMessages()`` when a ``kindred://``
URL arrives via IPC, or from ``InitGui.py`` for cold-start URL arguments.
"""
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme != "kindred":
return
# urlparse treats "kindred://item/PN-001" as netloc="item", path="/PN-001"
parts = [parsed.netloc] + [p for p in parsed.path.split("/") if p]
if len(parts) >= 2 and parts[0] == "item":
part_number = parts[1]
FreeCAD.Console.PrintMessage(f"Silo: Opening item {part_number} from kindred:// URL\n")
_sync.open_item(part_number)
# ============================================================================
# COMMANDS
# ============================================================================
@@ -642,11 +663,7 @@ class Silo_Open:
try:
for item in search_local_files(search_term):
existing = next(
(
r
for r in results_data
if r["part_number"] == item["part_number"]
),
(r for r in results_data if r["part_number"] == item["part_number"]),
None,
)
if existing:
@@ -670,12 +687,8 @@ class Silo_Open:
results_table.setRowCount(len(results_data))
for row, data in enumerate(results_data):
results_table.setItem(
row, 0, QtGui.QTableWidgetItem(data["part_number"])
)
results_table.setItem(
row, 1, QtGui.QTableWidgetItem(data["description"])
)
results_table.setItem(row, 0, QtGui.QTableWidgetItem(data["part_number"]))
results_table.setItem(row, 1, QtGui.QTableWidgetItem(data["description"]))
results_table.setItem(row, 2, QtGui.QTableWidgetItem(data["item_type"]))
results_table.setItem(row, 3, QtGui.QTableWidgetItem(data["source"]))
results_table.setItem(row, 4, QtGui.QTableWidgetItem(data["modified"]))
@@ -741,13 +754,9 @@ class Silo_New:
try:
schema = _client.get_schema()
categories = schema.get("segments", [])
cat_segment = next(
(s for s in categories if s.get("name") == "category"), None
)
cat_segment = next((s for s in categories if s.get("name") == "category"), None)
if cat_segment and cat_segment.get("values"):
cat_list = [
f"{k} - {v}" for k, v in sorted(cat_segment["values"].items())
]
cat_list = [f"{k} - {v}" for k, v in sorted(cat_segment["values"].items())]
category_str, ok = QtGui.QInputDialog.getItem(
None, "New Item", "Category:", cat_list, 0, False
)
@@ -755,15 +764,11 @@ class Silo_New:
return
category = category_str.split(" - ")[0]
else:
category, ok = QtGui.QInputDialog.getText(
None, "New Item", "Category code:"
)
category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:")
if not ok:
return
except Exception:
category, ok = QtGui.QInputDialog.getText(
None, "New Item", "Category code:"
)
category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:")
if not ok:
return
@@ -808,9 +813,7 @@ class Silo_New:
ok_btn.clicked.connect(dialog.accept)
if dialog.exec_() == QtGui.QDialog.Accepted:
selected_projects = [
item.text() for item in list_widget.selectedItems()
]
selected_projects = [item.text() for item in list_widget.selectedItems()]
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not fetch projects: {e}\n")
@@ -917,9 +920,7 @@ class Silo_Save:
# Try to upload to MinIO
try:
result = _client._upload_file(
part_number, str(file_path), properties, "Auto-save"
)
result = _client._upload_file(part_number, str(file_path), properties, "Auto-save")
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
@@ -952,9 +953,7 @@ class Silo_Commit:
obj = get_tracked_object(doc)
if not obj:
FreeCAD.Console.PrintError(
"No tracked object. Use 'New' to register first.\n"
)
FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n")
return
part_number = obj.SiloPartNumber
@@ -971,9 +970,7 @@ class Silo_Commit:
if not file_path:
return
result = _client._upload_file(
part_number, str(file_path), properties, comment
)
result = _client._upload_file(part_number, str(file_path), properties, comment)
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
@@ -993,8 +990,10 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
conflicts = []
# Check for unsaved changes in an open document
if doc is not None and doc.IsModified():
conflicts.append("Document has unsaved local changes.")
if doc is not None:
gui_doc = FreeCADGui.getDocument(doc.Name) if doc.Name else None
if gui_doc and gui_doc.Modified:
conflicts.append("Document has unsaved local changes.")
# Check local revision vs server latest
if doc is not None:
@@ -1020,9 +1019,7 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
server_updated = item.get("updated_at", "")
if server_updated:
# Parse ISO format timestamp
server_dt = datetime.datetime.fromisoformat(
server_updated.replace("Z", "+00:00")
)
server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00"))
if server_dt > local_mtime:
conflicts.append("Server version is newer than local file.")
except Exception:
@@ -1052,9 +1049,7 @@ class SiloPullDialog:
# Revision table
self._table = QtGui.QTableWidget()
self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels(
["Rev", "Date", "Comment", "Status", "File"]
)
self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"])
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -1168,18 +1163,14 @@ class Silo_Pull:
if not has_any_file:
if existing_local:
FreeCAD.Console.PrintMessage(
f"Opening existing local file: {existing_local}\n"
)
FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n")
FreeCAD.openDocument(str(existing_local))
else:
try:
item = _client.get_item(part_number)
new_doc = _sync.create_document_for_item(item, save=True)
if new_doc:
FreeCAD.Console.PrintMessage(
f"Created local file for {part_number}\n"
)
FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n")
else:
QtGui.QMessageBox.warning(
None,
@@ -1224,7 +1215,7 @@ class Silo_Pull:
progress = QtGui.QProgressDialog(
f"Downloading {part_number} rev {rev_num}...", "Cancel", 0, 100
)
progress.setWindowModality(2) # Qt.WindowModal
progress.setWindowModality(QtCore.Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setValue(0)
@@ -1310,9 +1301,7 @@ class Silo_Push:
server_dt = datetime.fromisoformat(
server_time_str.replace("Z", "+00:00")
)
local_dt = datetime.fromtimestamp(
local_mtime, tz=timezone.utc
)
local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc)
if local_dt > server_dt:
unuploaded.append(lf)
else:
@@ -1325,9 +1314,7 @@ class Silo_Push:
pass # Not in DB, skip
if not unuploaded:
QtGui.QMessageBox.information(
None, "Push", "All local files are already uploaded."
)
QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.")
return
msg = f"Found {len(unuploaded)} files to upload:\n\n"
@@ -1345,9 +1332,7 @@ class Silo_Push:
uploaded = 0
for item in unuploaded:
result = _sync.upload_file(
item["part_number"], item["path"], "Synced from local"
)
result = _sync.upload_file(item["part_number"], item["path"], "Synced from local")
if result:
uploaded += 1
@@ -1396,7 +1381,9 @@ class Silo_Info:
msg = f"<h3>{part_number}</h3>"
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
msg += f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
msg += (
f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
)
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
@@ -1462,9 +1449,7 @@ class Silo_TagProjects:
try:
# Get current projects for item
current_projects = _client.get_item_projects(part_number)
current_codes = {
p.get("code", "") for p in current_projects if p.get("code")
}
current_codes = {p.get("code", "") for p in current_projects if p.get("code")}
# Get all available projects
all_projects = _client.get_projects()
@@ -1575,9 +1560,7 @@ class Silo_Rollback:
dialog.setMinimumHeight(300)
layout = QtGui.QVBoxLayout(dialog)
label = QtGui.QLabel(
f"Select a revision to rollback to (current: Rev {current_rev}):"
)
label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):")
layout.addWidget(label)
# Revision table
@@ -1592,12 +1575,8 @@ class Silo_Rollback:
for i, rev in enumerate(prev_revisions):
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
table.setItem(
i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])
)
table.setItem(
i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")
)
table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]))
table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or ""))
table.resizeColumnsToContents()
layout.addWidget(table)
@@ -1623,9 +1602,7 @@ class Silo_Rollback:
def on_rollback():
selected = table.selectedItems()
if not selected:
QtGui.QMessageBox.warning(
dialog, "Rollback", "Please select a revision"
)
QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision")
return
selected_rev[0] = int(table.item(selected[0].row(), 0).text())
dialog.accept()
@@ -1723,9 +1700,7 @@ class Silo_SetStatus:
# Update status
_client.update_revision(part_number, rev_num, status=status)
FreeCAD.Console.PrintMessage(
f"Updated Rev {rev_num} status to '{status}'\n"
)
FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n")
QtGui.QMessageBox.information(
None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
)
@@ -1789,9 +1764,7 @@ class Silo_Settings:
ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
layout.addWidget(ssl_checkbox)
ssl_hint = QtGui.QLabel(
"Disable only for internal servers with self-signed certificates."
)
ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.")
ssl_hint.setWordWrap(True)
ssl_hint.setStyleSheet("color: #888; font-size: 11px;")
layout.addWidget(ssl_hint)
@@ -2068,9 +2041,7 @@ class Silo_BOM:
wu_table = QtGui.QTableWidget()
wu_table.setColumnCount(5)
wu_table.setHorizontalHeaderLabels(
["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]
)
wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"])
wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -2099,16 +2070,12 @@ class Silo_BOM:
bom_table.setItem(
row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
)
bom_table.setItem(
row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
)
bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
qty = entry.get("quantity")
bom_table.setItem(
row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
)
bom_table.setItem(
row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")
)
bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or ""))
ref_des = entry.get("reference_designators") or []
bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
bom_table.setItem(
@@ -2130,16 +2097,12 @@ class Silo_BOM:
wu_table.setItem(
row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
)
wu_table.setItem(
row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
)
wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
qty = entry.get("quantity")
wu_table.setItem(
row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
)
wu_table.setItem(
row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")
)
wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or ""))
ref_des = entry.get("reference_designators") or []
wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
wu_table.resizeColumnsToContents()
@@ -2192,9 +2155,7 @@ class Silo_BOM:
try:
qty = float(qty_text)
except ValueError:
QtGui.QMessageBox.warning(
dialog, "BOM", "Quantity must be a number."
)
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
return
unit = unit_input.text().strip() or None
@@ -2273,9 +2234,7 @@ class Silo_BOM:
try:
new_qty = float(qty_text)
except ValueError:
QtGui.QMessageBox.warning(
dialog, "BOM", "Quantity must be a number."
)
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
return
new_unit = unit_input.text().strip() or None
@@ -2299,9 +2258,7 @@ class Silo_BOM:
)
load_bom()
except Exception as exc:
QtGui.QMessageBox.warning(
dialog, "BOM", f"Failed to update entry:\n{exc}"
)
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}")
def on_remove():
selected = bom_table.selectedItems()
@@ -2327,9 +2284,7 @@ class Silo_BOM:
_client.delete_bom_entry(part_number, child_pn)
load_bom()
except Exception as exc:
QtGui.QMessageBox.warning(
dialog, "BOM", f"Failed to remove entry:\n{exc}"
)
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}")
add_btn.clicked.connect(on_add)
edit_btn.clicked.connect(on_edit)
@@ -2368,9 +2323,7 @@ class SiloEventListener(QtCore.QThread):
item_updated = QtCore.Signal(str) # part_number
revision_created = QtCore.Signal(str, int) # part_number, revision
connection_status = QtCore.Signal(
str, int, str
) # (status, retry_count, error_message)
connection_status = QtCore.Signal(str, int, str) # (status, retry_count, error_message)
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
_MAX_RETRIES = 10
@@ -2437,9 +2390,7 @@ class SiloEventListener(QtCore.QThread):
req = urllib.request.Request(url, headers=headers, method="GET")
try:
self._response = urllib.request.urlopen(
req, context=_get_ssl_context(), timeout=90
)
self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90)
except urllib.error.HTTPError as e:
if e.code in (404, 501):
raise _SSEUnsupported()
@@ -2720,9 +2671,7 @@ class SiloAuthDockWidget:
self._sse_label.setToolTip("")
FreeCAD.Console.PrintMessage("Silo: SSE connected\n")
elif status == "disconnected":
self._sse_label.setText(
f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..."
)
self._sse_label.setText(f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})...")
self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;")
self._sse_label.setToolTip(error or "Connection lost")
FreeCAD.Console.PrintWarning(
@@ -2732,9 +2681,7 @@ class SiloAuthDockWidget:
self._sse_label.setText("Disconnected")
self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;")
self._sse_label.setToolTip(error or "Max retries reached")
FreeCAD.Console.PrintError(
f"Silo: SSE gave up after {retry} retries: {error}\n"
)
FreeCAD.Console.PrintError(f"Silo: SSE gave up after {retry} retries: {error}\n")
elif status == "unsupported":
self._sse_label.setText("Not available")
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
@@ -2776,14 +2723,10 @@ class SiloAuthDockWidget:
self._refresh_activity_panel()
def _on_remote_revision(self, part_number, revision):
FreeCAD.Console.PrintMessage(
f"Silo: New revision {revision} for {part_number}\n"
)
FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n")
mw = FreeCADGui.getMainWindow()
if mw is not None:
mw.statusBar().showMessage(
f"Silo: {part_number} rev {revision} available", 5000
)
mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000)
self._refresh_activity_panel()
def _refresh_activity_panel(self):
@@ -2849,9 +2792,7 @@ class SiloAuthDockWidget:
rev_part = f" \u2013 Rev {rev_num}" if rev_num else ""
date_part = f" \u2013 {updated}" if updated else ""
local_badge = " \u25cf local" if pn in local_pns else ""
line1 = (
f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}"
)
line1 = f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}"
if comment:
line1 += f'\n "{comment}"'
@@ -3307,9 +3248,7 @@ class Silo_StartPanel:
dock = QtGui.QDockWidget("Silo", mw)
dock.setObjectName("SiloStartPanel")
dock.setWidget(content.widget)
dock.setAllowedAreas(
QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea
)
dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea)
mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
def IsActive(self):
@@ -3343,9 +3282,7 @@ class _DiagWorker(QtCore.QThread):
self.result.emit("DNS", False, "no hostname in URL")
return
try:
addrs = socket.getaddrinfo(
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
)
addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
first_ip = addrs[0][4][0] if addrs else "?"
self.result.emit("DNS", True, f"{hostname} -> {first_ip}")
except socket.gaierror as e:

View File

@@ -392,10 +392,8 @@ class SiloOrigin:
obj.SiloPartNumber, str(file_path), properties, comment=""
)
# Clear modified flag (Modified is on Gui.Document, not App.Document)
gui_doc = FreeCADGui.getDocument(doc.Name)
if gui_doc:
gui_doc.Modified = False
# Clear modified flag
doc.Modified = False
return True
except Exception as e:

View File

@@ -1,18 +1,14 @@
"""Silo Start Page — dual-mode start view for Kindred Create.
"""Silo Start Page — native Qt 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
Replaces the default Start page with a rich native panel that fetches data
from the Silo REST API, shows real-time activity via SSE, and provides quick
access to database items and recent local files.
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
@@ -20,17 +16,6 @@ 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
# ---------------------------------------------------------------------------
@@ -53,8 +38,6 @@ _MOCHA = {
}
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
_POLL_INTERVAL_MS = 5000
_CONNECT_TIMEOUT_S = 2
# ---------------------------------------------------------------------------
@@ -69,34 +52,11 @@ def _get_silo_base_url() -> str:
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")
@@ -111,63 +71,285 @@ def _get_recent_files() -> list:
return files
def _relative_time(dt: datetime) -> str:
"""Format a datetime as a human-friendly relative string."""
now = datetime.now()
diff = now - dt
seconds = int(diff.total_seconds())
if seconds < 60:
return "just now"
minutes = seconds // 60
if minutes < 60:
return f"{minutes}m ago"
hours = minutes // 60
if hours < 24:
return f"{hours}h ago"
days = hours // 24
if days < 30:
return f"{days}d ago"
return dt.strftime("%Y-%m-%d")
# ---------------------------------------------------------------------------
# Offline fallback widget
# Stylesheet
# ---------------------------------------------------------------------------
_STYLESHEET = f"""
SiloStartView {{
background-color: {_MOCHA["base"]};
}}
/* --- Status banner --- */
#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"]};
}}
/* --- Section headers --- */
.SiloSectionHeader {{
color: {_MOCHA["text"]};
font-size: 14px;
font-weight: bold;
}}
/* --- Search field --- */
#SiloSearchField {{
background-color: {_MOCHA["surface0"]};
border: 1px solid {_MOCHA["surface1"]};
border-radius: 4px;
padding: 6px 10px;
color: {_MOCHA["text"]};
font-size: 13px;
}}
#SiloSearchField:focus {{
border-color: {_MOCHA["blue"]};
}}
/* --- List widgets --- */
.SiloList {{
background-color: {_MOCHA["mantle"]};
border: 1px solid {_MOCHA["surface0"]};
border-radius: 6px;
padding: 4px;
}}
.SiloList::item {{
padding: 8px 10px;
border-bottom: 1px solid {_MOCHA["surface0"]};
color: {_MOCHA["text"]};
}}
.SiloList::item:last {{
border-bottom: none;
}}
.SiloList::item:hover {{
background-color: {_MOCHA["surface0"]};
}}
.SiloList::item:selected {{
background-color: {_MOCHA["surface1"]};
}}
/* --- Activity feed --- */
#SiloActivityFeed {{
background-color: {_MOCHA["mantle"]};
border: 1px solid {_MOCHA["surface0"]};
border-radius: 6px;
padding: 4px;
}}
#SiloActivityFeed::item {{
padding: 6px 10px;
border-bottom: 1px solid {_MOCHA["surface0"]};
color: {_MOCHA["subtext0"]};
font-size: 12px;
}}
#SiloActivityFeed::item:last {{
border-bottom: none;
}}
/* --- Footer checkbox --- */
QCheckBox {{
color: {_MOCHA["subtext0"]};
font-size: 12px;
}}
QCheckBox::indicator {{
width: 14px;
height: 14px;
}}
"""
# ---------------------------------------------------------------------------
# Main start view
# ---------------------------------------------------------------------------
class _OfflineWidget(QtWidgets.QWidget):
"""Native Qt fallback showing Silo status and recent local files."""
retry_clicked = QtCore.Signal()
class SiloStartView(QtWidgets.QWidget):
"""Native Qt start page with Silo database items, recent files, and
real-time activity feed."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("SiloStartView")
self._silo_url = _get_silo_base_url()
self._connected = False
self._event_listener = None
self._activity_events = [] # list of (datetime, text) tuples
self._silo_imported = False
self._silo_cmds = None # lazy ref to silo_commands module
self._build_ui()
self.update_status(False, _get_silo_base_url())
self.setStyleSheet(_STYLESHEET)
# Debounce timer for search
self._search_timer = QtCore.QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.setInterval(300)
self._search_timer.timeout.connect(self._refresh_items)
# Periodic refresh
self._poll_timer = QtCore.QTimer(self)
self._poll_timer.setInterval(30000)
self._poll_timer.timeout.connect(self._periodic_refresh)
self._poll_timer.start()
# Initial load after event loop starts
QtCore.QTimer.singleShot(100, self._initial_load)
# -- lazy import --------------------------------------------------------
def _silo(self):
"""Lazy-import silo_commands to avoid circular import at module load."""
if not self._silo_imported:
try:
import silo_commands
self._silo_cmds = silo_commands
except Exception as e:
FreeCAD.Console.PrintWarning(f"Silo Start: cannot import silo_commands: {e}\n")
self._silo_cmds = None
self._silo_imported = True
return self._silo_cmds
# -- UI construction ----------------------------------------------------
def _build_ui(self):
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(40, 30, 40, 20)
layout.setSpacing(0)
root = QtWidgets.QVBoxLayout(self)
root.setContentsMargins(32, 24, 32, 16)
root.setSpacing(0)
# --- Status banner ---
banner = QtWidgets.QFrame()
banner.setObjectName("SiloStatusBanner")
banner_layout = QtWidgets.QHBoxLayout(banner)
banner_layout.setContentsMargins(16, 12, 16, 12)
banner_layout.setContentsMargins(16, 10, 16, 10)
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 = QtWidgets.QLabel("Checking Silo connection...")
self._status_label.setWordWrap(True)
banner_layout.addWidget(self._status_label, 1)
self._browser_btn = QtWidgets.QPushButton("Open in Browser")
self._browser_btn.setFixedWidth(130)
self._browser_btn.setCursor(QtCore.Qt.PointingHandCursor)
self._browser_btn.clicked.connect(self._open_in_browser)
banner_layout.addWidget(self._browser_btn)
self._retry_btn = QtWidgets.QPushButton("Retry")
self._retry_btn.setFixedWidth(80)
self._retry_btn.setFixedWidth(70)
self._retry_btn.setCursor(QtCore.Qt.PointingHandCursor)
self._retry_btn.clicked.connect(self.retry_clicked.emit)
self._retry_btn.clicked.connect(self._initial_load)
self._retry_btn.hide()
banner_layout.addWidget(self._retry_btn)
layout.addWidget(banner)
layout.addSpacing(24)
root.addWidget(banner)
root.addSpacing(20)
# --- Recent files header ---
header = QtWidgets.QLabel("Recent Files")
header.setObjectName("SiloRecentHeader")
layout.addWidget(header)
layout.addSpacing(12)
# --- Main content: items (left) + recent files (right) ---
content_splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
content_splitter.setHandleWidth(12)
content_splitter.setStyleSheet(
f"QSplitter::handle {{ background-color: {_MOCHA['base']}; }}"
)
# Left: Database Items
left = QtWidgets.QWidget()
left_layout = QtWidgets.QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(8)
items_header = QtWidgets.QLabel("Database Items")
items_header.setProperty("class", "SiloSectionHeader")
left_layout.addWidget(items_header)
self._search_field = QtWidgets.QLineEdit()
self._search_field.setObjectName("SiloSearchField")
self._search_field.setPlaceholderText("Search items...")
self._search_field.textChanged.connect(self._on_search_changed)
left_layout.addWidget(self._search_field)
self._items_list = QtWidgets.QListWidget()
self._items_list.setProperty("class", "SiloList")
self._items_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self._items_list.itemDoubleClicked.connect(self._on_item_double_clicked)
self._items_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self._items_list.customContextMenuRequested.connect(self._on_item_context_menu)
left_layout.addWidget(self._items_list, 1)
content_splitter.addWidget(left)
# Right: Recent Files
right = QtWidgets.QWidget()
right_layout = QtWidgets.QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(8)
recent_header = QtWidgets.QLabel("Recent Files")
recent_header.setProperty("class", "SiloSectionHeader")
right_layout.addWidget(recent_header)
# --- Recent files list ---
self._file_list = QtWidgets.QListWidget()
self._file_list.setObjectName("SiloRecentList")
self._file_list.setProperty("class", "SiloList")
self._file_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self._file_list.itemDoubleClicked.connect(self._on_file_clicked)
layout.addWidget(self._file_list, 1)
right_layout.addWidget(self._file_list, 1)
layout.addSpacing(16)
content_splitter.addWidget(right)
content_splitter.setStretchFactor(0, 3)
content_splitter.setStretchFactor(1, 2)
root.addWidget(content_splitter, 1)
root.addSpacing(12)
# --- Activity Feed (bottom) ---
activity_header = QtWidgets.QLabel("Activity")
activity_header.setProperty("class", "SiloSectionHeader")
root.addWidget(activity_header)
root.addSpacing(6)
self._activity_list = QtWidgets.QListWidget()
self._activity_list.setObjectName("SiloActivityFeed")
self._activity_list.setMaximumHeight(140)
self._activity_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
root.addWidget(self._activity_list)
root.addSpacing(12)
# --- Footer ---
footer = QtWidgets.QHBoxLayout()
@@ -178,103 +360,234 @@ class _OfflineWidget(QtWidgets.QWidget):
self._startup_cb.setChecked(not show)
self._startup_cb.toggled.connect(self._on_startup_toggled)
footer.addWidget(self._startup_cb)
layout.addLayout(footer)
root.addLayout(footer)
self._apply_style()
# -- data loading -------------------------------------------------------
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 _initial_load(self):
"""First-time data load and SSE connection."""
self._refresh_status()
self._refresh_items()
self._refresh_recent_files()
self._start_sse()
def update_status(self, connected: bool, url: str, error: str = ""):
def _periodic_refresh(self):
"""Periodic refresh of items and connection status."""
self._refresh_status()
self._refresh_items()
self._refresh_recent_files()
def _refresh_status(self):
"""Update the connection status banner."""
sc = self._silo()
if sc is None:
self._set_status(False, "Silo addon not available")
return
try:
reachable, _ = sc._client.check_connection()
except Exception:
reachable = False
self._silo_url = _get_silo_base_url()
if reachable:
self._set_status(True, f"Connected to {self._silo_url}")
else:
self._set_status(False, f"Cannot reach {self._silo_url}")
def _set_status(self, connected: bool, message: str):
self._connected = connected
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()
self._browser_btn.show()
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()
self._browser_btn.hide()
self._status_label.setText(message)
def refresh_recent_files(self):
def _refresh_items(self):
"""Fetch items from Silo API and populate the items list."""
sc = self._silo()
if sc is None:
return
self._items_list.clear()
search = self._search_field.text().strip()
try:
if search:
items = sc._client.list_items(search=search)
else:
items = sc._client.list_items()
except Exception:
item = QtWidgets.QListWidgetItem("(Unable to fetch items)")
item.setFlags(QtCore.Qt.NoItemFlags)
self._items_list.addItem(item)
return
if not isinstance(items, list) or not items:
item = QtWidgets.QListWidgetItem("(No items found)")
item.setFlags(QtCore.Qt.NoItemFlags)
self._items_list.addItem(item)
return
# Collect local part numbers for badge
local_pns = set()
try:
for lf in sc.search_local_files():
local_pns.add(lf.get("part_number", ""))
except Exception:
pass
for entry in items[:30]:
pn = entry.get("part_number", "")
desc = entry.get("description", "")
rev = entry.get("current_revision", "")
desc_display = desc
if len(desc_display) > 50:
desc_display = desc_display[:47] + "..."
local_badge = " [local]" if pn in local_pns else ""
rev_part = f" Rev {rev}" if rev else ""
label = f"{pn} \u2014 {desc_display}{rev_part}{local_badge}"
list_item = QtWidgets.QListWidgetItem(label)
list_item.setData(QtCore.Qt.UserRole, pn)
if desc and len(desc) > 50:
list_item.setToolTip(desc)
if pn in local_pns:
list_item.setForeground(QtGui.QColor(_MOCHA["green"]))
self._items_list.addItem(list_item)
def _refresh_recent_files(self):
"""Populate the recent files list."""
self._file_list.clear()
files = _get_recent_files()
if not files:
item = QtWidgets.QListWidgetItem("No recent 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}"
label = f"{f['name']}\n{_relative_time(f['modified'])}"
item = QtWidgets.QListWidgetItem(label)
item.setData(QtCore.Qt.UserRole, f["path"])
item.setToolTip(f["path"])
self._file_list.addItem(item)
# -- SSE ----------------------------------------------------------------
def _start_sse(self):
"""Connect to SSE for live activity updates."""
sc = self._silo()
if sc is None:
return
if self._event_listener is not None:
return # already running
try:
self._event_listener = sc.SiloEventListener()
self._event_listener.item_updated.connect(self._on_sse_item_updated)
self._event_listener.revision_created.connect(self._on_sse_revision_created)
self._event_listener.connection_status.connect(self._on_sse_status)
self._event_listener.start()
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo Start: SSE listener failed to start: {e}\n")
def _stop_sse(self):
"""Stop SSE listener when the view is destroyed."""
if self._event_listener is not None:
self._event_listener.stop()
self._event_listener = None
def _on_sse_item_updated(self, part_number: str):
self._add_activity_event(f"{part_number} updated")
self._refresh_items()
def _on_sse_revision_created(self, part_number: str, revision: int):
self._add_activity_event(f"{part_number} Rev {revision} created")
self._refresh_items()
def _on_sse_status(self, status: str, retry: int, error: str):
if status == "connected":
FreeCAD.Console.PrintLog("Silo Start: SSE connected\n")
elif status == "disconnected":
FreeCAD.Console.PrintLog(f"Silo Start: SSE disconnected (retry {retry}): {error}\n")
def _add_activity_event(self, text: str):
"""Add an event to the activity feed."""
now = datetime.now()
self._activity_events.insert(0, (now, text))
self._activity_events = self._activity_events[:20]
self._rebuild_activity_feed()
def _rebuild_activity_feed(self):
"""Rebuild the activity list widget from stored events."""
self._activity_list.clear()
if not self._activity_events:
item = QtWidgets.QListWidgetItem("(No recent activity)")
item.setFlags(QtCore.Qt.NoItemFlags)
self._activity_list.addItem(item)
return
for ts, text in self._activity_events:
label = f"{text} \u00b7 {_relative_time(ts)}"
self._activity_list.addItem(label)
# -- interaction --------------------------------------------------------
def _on_search_changed(self, _text: str):
"""Debounce search input."""
self._search_timer.start()
def _on_item_double_clicked(self, item: QtWidgets.QListWidgetItem):
pn = item.data(QtCore.Qt.UserRole)
if not pn:
return
sc = self._silo()
if sc is None:
return
local_path = sc.find_file_by_part_number(pn)
if local_path and local_path.exists():
FreeCAD.openDocument(str(local_path))
else:
sc._sync.open_item(pn)
def _on_item_context_menu(self, pos):
item = self._items_list.itemAt(pos)
if item is None:
return
pn = item.data(QtCore.Qt.UserRole)
if not pn:
return
menu = QtWidgets.QMenu()
open_action = menu.addAction("Open in Create")
browser_action = menu.addAction("Open in Browser")
copy_action = menu.addAction("Copy Part Number")
action = menu.exec_(self._items_list.mapToGlobal(pos))
sc = self._silo()
if action == open_action:
if sc:
local_path = sc.find_file_by_part_number(pn)
if local_path and local_path.exists():
FreeCAD.openDocument(str(local_path))
else:
sc._sync.open_item(pn)
elif action == browser_action:
url = f"{_get_silo_base_url()}/items/{pn}"
QtGui.QDesktopServices.openUrl(QtCore.QUrl(url))
elif action == copy_action:
QtWidgets.QApplication.clipboard().setText(pn)
def _on_file_clicked(self, item: QtWidgets.QListWidgetItem):
path = item.data(QtCore.Qt.UserRole)
if path:
@@ -283,112 +596,22 @@ class _OfflineWidget(QtWidgets.QWidget):
except Exception as e:
FreeCAD.Console.PrintError(f"Silo Start: failed to open {path}: {e}\n")
def _open_in_browser(self):
"""Open Silo web UI in the system browser."""
url = _get_silo_base_url()
QtGui.QDesktopServices.openUrl(QtCore.QUrl(url))
@staticmethod
def _on_startup_toggled(checked: bool):
prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Start")
prefs.SetBool("ShowOnStartup", not checked)
# -- cleanup ------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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)
def closeEvent(self, event):
self._stop_sse()
self._poll_timer.stop()
super().closeEvent(event)
# ---------------------------------------------------------------------------