feat: MCP Bridge Workbench, just command cleanup, testing, etc. (#24)

* fix: lots of fixes and name refactoring

* feat: Add workbench preferences

* fix: MCP bridge status widget and just command fixes

* fix(tests): Use the correct mesa-glx package

* fix(ci): Add fontconfig to GUI test dependencies

FreeCAD GUI was failing to start with:
"Fontconfig error: Cannot load default config file: No such file"

Added fontconfig and fonts-dejavu-core packages to the GUI test job
dependencies to resolve the font configuration issue.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(addon): Extract path utilities into shared module

Create path_utils.py module that consolidates duplicated path-finding
logic from commands.py and InitGui.py:
- get_addon_path(): Find addon directory with caching and fallbacks
- get_icon_path(): Get full path to an icon file
- get_icons_dir(): Get path to icons directory
- get_workbench_icon(): Get path to workbench main icon

This removes ~100 lines of duplicated code while preserving the same
behavior including _addon_path_cache and all fallback methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(addon): Prevent stale plugin state on startup failure

The StartMCPBridgeCommand.Activated method could leave _mcp_plugin in
a partially initialized state if FreecadMCPPlugin.start() failed after
the plugin was instantiated.

Changes:
- Create plugin in a local variable first
- Only assign to _mcp_plugin after start() succeeds
- Explicitly clear _mcp_plugin and _running_config in exception
  handlers to ensure clean state for subsequent retry attempts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Lot of broad improvements

* fix(ci): Use blocking headless_server.py for GUI tests

The GUI test was using startup_bridge.py which is non-blocking
(designed for interactive use). For CI, even in GUI mode, we need
the blocking headless_server.py that calls run_forever() to keep
FreeCAD running. GUI features are still available since we use
the 'freecad' executable instead of 'freecadcmd'.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(addon): Rename headless_server.py to blocking_bridge.py

The old name was misleading because:
- It works with both GUI (freecad) and headless (freecadcmd) modes
- The key characteristic is that it BLOCKS with run_forever()

New naming convention clarifies the difference:
- blocking_bridge.py: Starts bridge and blocks (for CI, servers)
- startup_bridge.py: Starts bridge and returns (for interactive GUI)

Updated all references across:
- GitHub workflow (macro-test.yaml)
- Just commands (freecad.just)
- Unit tests (test_addon_structure.py)
- Documentation (5 files)
- CLAUDE.md

Also improved the script to detect GUI mode dynamically using
FreeCAD.GuiUp and display the appropriate status message.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(just): Remove erroneous rm of startup_bridge.py on error

The startup script is now a permanent source file in the repository,
not a generated temporary file. The rm -f would have deleted source
code if FreeCAD wasn't found.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: General improvements

* fix: Lots of general fixes and only stable to PyPi

* fix: small cleanup

* fix: Small fixes and hopefully fixes the GUI tests

* fix: Add proper library paths for FreeCAD GUI in CI

- Create wrapper scripts instead of symlinks for AppImage binaries
- Set LD_LIBRARY_PATH, QT_PLUGIN_PATH for GUI mode
- Add diagnostic output to identify startup failures

* fix: Use apprun for GUI tests in CI

* fix: Improving Xvfb tests

* fix: GUI tests worlk

* chore: remove invalid --no-splash comments

* fix: ARM64 architecture support and other fixes

* fix: cleanup

* test: just commands test suite

* test: improve just command tests

* fix: more general improvements

* fix: more cleanup

* fix: more updates

* fix: small tweaks

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sean P. Kane
2026-01-10 15:26:33 -08:00
committed by GitHub
co-authored by Claude Opus 4.5
parent bcc3048876
commit 8c338f6da7
110 changed files with 7821 additions and 1276 deletions
+292
View File
@@ -0,0 +1,292 @@
#!/bin/bash
# Setup FreeCAD AppImage - replicates .github/actions/setup-freecad/action.yaml
set -euo pipefail
# Validate required variables have safe defaults
FREECAD_TAG="${FREECAD_TAG:-1.0.2}"
APPIMAGE_DIR="${APPIMAGE_DIR:-$HOME/freecad-appimage}"
# Optional SHA256 checksum for verification (if provided, download will be verified)
APPIMAGE_SHA256="${APPIMAGE_SHA256:-}"
# Validate APPIMAGE_DIR is set and non-empty after defaults
if [[ -z "$APPIMAGE_DIR" ]]; then
echo "ERROR: APPIMAGE_DIR is empty - cannot determine installation path"
exit 1
fi
# Marker file to indicate complete installation
MARKER_FILE="$APPIMAGE_DIR/.freecad_installed"
# Lock file for atomic operations (prevents race conditions in parallel CI)
LOCK_FILE="$APPIMAGE_DIR/.freecad_install.lock"
# Derive APPIMAGE_PATH early for cleanup function
APPIMAGE_PATH="$APPIMAGE_DIR/FreeCAD.AppImage"
# Track whether installation completed successfully
INSTALL_SUCCESSFUL=false
# Cleanup function to remove partial artifacts on failure
cleanup_on_error() {
local exit_code=$?
if [[ $exit_code -ne 0 ]] && [[ "$INSTALL_SUCCESSFUL" != "true" ]]; then
echo "ERROR: Installation failed (exit code $exit_code), cleaning up partial artifacts..."
rm -f "$MARKER_FILE" 2>/dev/null || true
rm -f "$APPIMAGE_PATH" 2>/dev/null || true
rm -rf "$APPIMAGE_DIR/squashfs-root" 2>/dev/null || true
rm -f "$LOCK_FILE" 2>/dev/null || true
fi
}
trap cleanup_on_error EXIT
# Detect if running in CI environment
is_ci_environment() {
# Check common CI environment variables
[[ -n "${CI:-}" ]] || \
[[ -n "${GITHUB_ACTIONS:-}" ]] || \
[[ -n "${GITLAB_CI:-}" ]] || \
[[ -n "${TRAVIS:-}" ]] || \
[[ -n "${CIRCLECI:-}" ]] || \
[[ -n "${JENKINS_URL:-}" ]] || \
[[ -n "${BUILDKITE:-}" ]]
}
# In CI, require SHA256 checksum for security
if is_ci_environment && [[ -z "$APPIMAGE_SHA256" ]]; then
echo "ERROR: APPIMAGE_SHA256 is required in CI environments for security verification"
echo "Set the APPIMAGE_SHA256 environment variable to the expected checksum"
exit 1
fi
echo "=== Setting up FreeCAD $FREECAD_TAG ==="
# Create directory for lock file
mkdir -p "$APPIMAGE_DIR"
# Use flock for atomic marker file operations (prevents race conditions in parallel CI)
# The lock is held for the entire installation process
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
echo "Another installation is in progress, waiting for lock..."
flock 200
# Re-check marker file after acquiring lock (another process may have completed)
if [[ -f "$MARKER_FILE" ]] && grep -q "^${FREECAD_TAG}$" "$MARKER_FILE" 2>/dev/null; then
echo "FreeCAD $FREECAD_TAG already set up by another process, skipping"
exit 0
fi
fi
# Check for complete setup using marker file
if [[ -f "$MARKER_FILE" ]]; then
# Verify marker file contains expected version
if grep -q "^${FREECAD_TAG}$" "$MARKER_FILE" 2>/dev/null; then
echo "FreeCAD $FREECAD_TAG already set up (marker file present), skipping"
exit 0
else
echo "Different FreeCAD version detected, will reinstall"
fi
fi
# Check for and clean up partial installations
PARTIAL_INSTALL=false
if [[ -f "/usr/local/bin/freecad" ]] && [[ ! -f "/usr/local/bin/freecadcmd" ]]; then
echo "Warning: Partial installation detected (freecad exists but freecadcmd missing)"
PARTIAL_INSTALL=true
elif [[ ! -f "/usr/local/bin/freecad" ]] && [[ -f "/usr/local/bin/freecadcmd" ]]; then
echo "Warning: Partial installation detected (freecadcmd exists but freecad missing)"
PARTIAL_INSTALL=true
fi
if [[ "$PARTIAL_INSTALL" == "true" ]]; then
echo "Cleaning up partial installation..."
# Remove wrapper scripts if they exist (use sudo if needed)
if [[ $EUID -ne 0 ]] && command -v sudo &>/dev/null; then
sudo rm -f /usr/local/bin/freecad /usr/local/bin/freecadcmd 2>/dev/null || true
else
rm -f /usr/local/bin/freecad /usr/local/bin/freecadcmd 2>/dev/null || true
fi
# Remove marker file to force full reinstall
rm -f "$MARKER_FILE" 2>/dev/null || true
fi
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
ARCH_SUFFIX="x86_64"
;;
aarch64|arm64)
ARCH_SUFFIX="aarch64"
;;
*)
echo "ERROR: Unsupported architecture: $ARCH"
exit 1
;;
esac
echo "Detected architecture: $ARCH -> Linux-$ARCH_SUFFIX"
# Use direct download URL to avoid GitHub API rate limits
# Format: FreeCAD_1.0.2-conda-Linux-aarch64-py311.AppImage
APPIMAGE_URL="https://github.com/FreeCAD/FreeCAD/releases/download/${FREECAD_TAG}/FreeCAD_${FREECAD_TAG}-conda-Linux-${ARCH_SUFFIX}-py311.AppImage"
APPIMAGE_NAME="FreeCAD_${FREECAD_TAG}-conda-Linux-${ARCH_SUFFIX}-py311.AppImage"
# APPIMAGE_PATH is defined earlier for use in cleanup_on_error trap
echo "FreeCAD release: $FREECAD_TAG"
echo "AppImage URL: $APPIMAGE_URL"
echo "AppImage name: $APPIMAGE_NAME"
# Download
mkdir -p "$APPIMAGE_DIR"
if [ ! -f "$APPIMAGE_PATH" ]; then
echo "Downloading FreeCAD AppImage..."
curl -L --retry 3 --retry-delay 5 --retry-all-errors --connect-timeout 30 --max-time 600 \
-f -o "$APPIMAGE_PATH" \
"$APPIMAGE_URL"
# Verify download succeeded and file exists
if [[ ! -f "$APPIMAGE_PATH" ]]; then
echo "ERROR: Download failed - file not found at $APPIMAGE_PATH"
exit 1
fi
# Verify SHA256 checksum if provided
if [[ -n "$APPIMAGE_SHA256" ]]; then
echo "Verifying SHA256 checksum..."
COMPUTED_SHA256=$(sha256sum "$APPIMAGE_PATH" | awk '{print $1}')
if [[ "$COMPUTED_SHA256" != "$APPIMAGE_SHA256" ]]; then
echo "ERROR: SHA256 checksum mismatch!"
echo " Expected: $APPIMAGE_SHA256"
echo " Computed: $COMPUTED_SHA256"
echo "Removing corrupted/tampered download..."
rm -f "$APPIMAGE_PATH"
exit 1
fi
echo "SHA256 checksum verified successfully"
else
echo "Note: No APPIMAGE_SHA256 provided, skipping checksum verification"
fi
fi
chmod +x "$APPIMAGE_PATH"
# Extract with proper error handling
cd "$APPIMAGE_DIR"
if [ ! -d "squashfs-root" ]; then
echo "Extracting AppImage..."
EXTRACTION_ERR=$(mktemp)
# Capture exit code directly to avoid shell negation issues with $?
EXTRACTION_EXIT_CODE=0
./FreeCAD.AppImage --appimage-extract > /dev/null 2>"$EXTRACTION_ERR" || EXTRACTION_EXIT_CODE=$?
if [[ $EXTRACTION_EXIT_CODE -ne 0 ]]; then
echo "ERROR: AppImage extraction failed with exit code $EXTRACTION_EXIT_CODE"
if [[ -s "$EXTRACTION_ERR" ]]; then
echo "Extraction stderr:"
cat "$EXTRACTION_ERR"
fi
rm -f "$EXTRACTION_ERR"
exit 1
fi
# Check for any stderr output even on success
if [[ -s "$EXTRACTION_ERR" ]]; then
echo "Warning: Extraction produced stderr output:"
cat "$EXTRACTION_ERR"
fi
rm -f "$EXTRACTION_ERR"
fi
# Verify extraction produced expected structure
if [ ! -d "squashfs-root/usr/bin" ]; then
echo "ERROR: Extracted AppImage missing expected structure (squashfs-root/usr/bin not found)"
exit 1
fi
echo "Checking AppImage structure..."
# Display directory contents for diagnostic purposes (not parsed programmatically)
# shellcheck disable=SC2012 # ls output piped to head for display only, not parsed
ls -la "$APPIMAGE_DIR/squashfs-root/" 2>/dev/null | head -20
# Create wrapper scripts using AppRun
echo "Creating wrapper scripts..."
# Derive APPDIR_PATH from APPIMAGE_DIR for consistency
APPDIR_PATH="$APPIMAGE_DIR/squashfs-root"
# Helper function to install wrapper script
# Uses sudo only if necessary (not root and sudo exists)
install_wrapper() {
local wrapper_name="$1"
local wrapper_content="$2"
local wrapper_path="/usr/local/bin/$wrapper_name"
local temp_file
temp_file=$(mktemp)
echo "$wrapper_content" > "$temp_file"
chmod +x "$temp_file"
# Install with sudo if not root and sudo is available
if [[ $EUID -ne 0 ]]; then
if command -v sudo &>/dev/null; then
sudo mv "$temp_file" "$wrapper_path"
sudo chmod +x "$wrapper_path"
else
echo "ERROR: Not running as root and sudo not available, cannot install to $wrapper_path"
rm -f "$temp_file"
exit 1
fi
else
mv "$temp_file" "$wrapper_path"
chmod +x "$wrapper_path"
fi
}
# freecadcmd wrapper - avoid trailing colon in LD_LIBRARY_PATH
FREECADCMD_WRAPPER="#!/bin/bash
export APPDIR=\"$APPDIR_PATH\"
export LD_LIBRARY_PATH=\"\$APPDIR/usr/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
exec \"\$APPDIR/usr/bin/freecadcmd\" \"\$@\""
install_wrapper "freecadcmd" "$FREECADCMD_WRAPPER"
# freecad (GUI) wrapper - avoid trailing colon in LD_LIBRARY_PATH
FREECAD_WRAPPER="#!/bin/bash
export APPDIR=\"$APPDIR_PATH\"
export LD_LIBRARY_PATH=\"\$APPDIR/usr/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
exec \"\$APPDIR/usr/bin/freecad\" \"\$@\""
install_wrapper "freecad" "$FREECAD_WRAPPER"
echo "Wrapper scripts created at /usr/local/bin/freecad{,cmd}"
# Verify installation - fail the script if verification fails
# Use explicit paths to avoid PATH resolution issues
echo "=== Verifying FreeCAD installation ==="
# Check wrapper exists before running
if [[ ! -x /usr/local/bin/freecadcmd ]]; then
echo "ERROR: freecadcmd wrapper not found or not executable at /usr/local/bin/freecadcmd"
exit 1
fi
echo "--- freecadcmd --version ---"
if ! /usr/local/bin/freecadcmd --version; then
echo "ERROR: freecadcmd version check failed"
exit 1
fi
echo "--- freecadcmd Python test ---"
if ! /usr/local/bin/freecadcmd -c "import sys; print(f'FreeCAD Python: {sys.version}')"; then
echo "ERROR: FreeCAD Python test failed"
exit 1
fi
echo "--- FreeCAD module import test ---"
if ! /usr/local/bin/freecadcmd -c "import FreeCAD; print(f'FreeCAD version: {FreeCAD.Version()}')"; then
echo "ERROR: FreeCAD module import failed - FreeCAD bindings may be missing or corrupted"
exit 1
fi
# Create marker file to indicate successful installation
echo "$FREECAD_TAG" > "$MARKER_FILE"
echo "Created marker file: $MARKER_FILE"
# Mark installation as successful (prevents cleanup_on_error from removing artifacts)
INSTALL_SUCCESSFUL=true
echo "=== FreeCAD setup complete ==="