From c83d1173865c3ab1a3b326d15c27a38a87ff2079 Mon Sep 17 00:00:00 2001 From: graelo Date: Mon, 10 Nov 2025 22:43:28 +0100 Subject: [PATCH 1/5] feat(macos): add quicklook extensions --- src/Gui/MainWindow.cpp | 40 ++ src/Gui/MainWindow.h | 6 + src/MacAppBundle/CMakeLists.txt | 138 +++-- src/MacAppBundle/QuickLook/CMakeLists.txt | 174 ++++++- .../QuickLook/legacy/CMakeLists.txt | 97 ++++ .../{ => legacy}/GeneratePreviewForURL.m | 0 .../{ => legacy}/GenerateThumbnailForURL.m | 0 .../Contents/Info.plist | 0 .../QuickLook/{ => legacy}/main.c | 3 +- .../QuickLook/modern/CMakeLists.txt | 220 ++++++++ .../modern/PreviewExtension.entitlements | 14 + .../modern/PreviewExtensionInfo.plist | 48 ++ .../QuickLook/modern/PreviewProvider.swift | 77 +++ .../modern/ThumbnailExtension.entitlements | 14 + .../modern/ThumbnailExtensionInfo.plist | 49 ++ .../QuickLook/modern/ThumbnailProvider.swift | 42 ++ .../QuickLook/modern/ZipExtractor.swift | 474 ++++++++++++++++++ .../QuickLook/test_integration.sh | 371 ++++++++++++++ 18 files changed, 1710 insertions(+), 57 deletions(-) create mode 100644 src/MacAppBundle/QuickLook/legacy/CMakeLists.txt rename src/MacAppBundle/QuickLook/{ => legacy}/GeneratePreviewForURL.m (100%) rename src/MacAppBundle/QuickLook/{ => legacy}/GenerateThumbnailForURL.m (100%) rename src/MacAppBundle/QuickLook/{ => legacy}/QuicklookFCStd.qlgenerator/Contents/Info.plist (100%) rename src/MacAppBundle/QuickLook/{ => legacy}/main.c (99%) create mode 100644 src/MacAppBundle/QuickLook/modern/CMakeLists.txt create mode 100644 src/MacAppBundle/QuickLook/modern/PreviewExtension.entitlements create mode 100644 src/MacAppBundle/QuickLook/modern/PreviewExtensionInfo.plist create mode 100644 src/MacAppBundle/QuickLook/modern/PreviewProvider.swift create mode 100644 src/MacAppBundle/QuickLook/modern/ThumbnailExtension.entitlements create mode 100644 src/MacAppBundle/QuickLook/modern/ThumbnailExtensionInfo.plist create mode 100644 src/MacAppBundle/QuickLook/modern/ThumbnailProvider.swift create mode 100644 src/MacAppBundle/QuickLook/modern/ZipExtractor.swift create mode 100755 src/MacAppBundle/QuickLook/test_integration.sh diff --git a/src/Gui/MainWindow.cpp b/src/Gui/MainWindow.cpp index b5266e8e81..e4e5450511 100644 --- a/src/Gui/MainWindow.cpp +++ b/src/Gui/MainWindow.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -1793,8 +1794,47 @@ void MainWindow::delayedStartup() ); safeModePopup.exec(); } + +#ifdef Q_OS_MAC + // Register QuickLook extensions on first launch + registerQuickLookExtensions(); +#endif } +#ifdef Q_OS_MAC +void MainWindow::registerQuickLookExtensions() +{ + // Check if we've already registered extensions to avoid repeated registration + static bool quickLookRegistered = false; + if (quickLookRegistered) { + return; + } + + // Get the path to FreeCAD.app/Contents/PlugIns + QString appPath = QApplication::applicationDirPath(); + QString plugInsPath = appPath + "/../PlugIns"; + + QString thumbnailExt = plugInsPath + "/FreeCADThumbnailExtension.appex"; + QString previewExt = plugInsPath + "/FreeCADPreviewExtension.appex"; + + // Check if extensions exist before attempting registration + if (QFileInfo::exists(thumbnailExt) && QFileInfo::exists(previewExt)) { + // Register extensions with pluginkit + QProcess::execute("pluginkit", QStringList() << "-a" << thumbnailExt); + QProcess::execute("pluginkit", QStringList() << "-a" << previewExt); + + // Activate extensions + QProcess::execute("pluginkit", QStringList() << "-e" << "use" << "-i" << "org.freecad.FreeCAD.quicklook.thumbnail"); + QProcess::execute("pluginkit", QStringList() << "-e" << "use" << "-i" << "org.freecad.FreeCAD.quicklook.preview"); + + quickLookRegistered = true; + + // Optional: Log successful registration (will appear in system notification) + Base::Console().log("QuickLook extensions registered successfully\n"); + } +} +#endif + void MainWindow::appendRecentFile(const QString& filename) { auto recent = this->findChild(QStringLiteral("recentFiles")); diff --git a/src/Gui/MainWindow.h b/src/Gui/MainWindow.h index da34ead986..933d29e7ed 100644 --- a/src/Gui/MainWindow.h +++ b/src/Gui/MainWindow.h @@ -370,6 +370,12 @@ private Q_SLOTS: * \internal */ void delayedStartup(); +#ifdef Q_OS_MAC + /** + * \internal + */ + void registerQuickLookExtensions(); +#endif /** * \internal */ diff --git a/src/MacAppBundle/CMakeLists.txt b/src/MacAppBundle/CMakeLists.txt index b0c04ff432..09edde3e37 100644 --- a/src/MacAppBundle/CMakeLists.txt +++ b/src/MacAppBundle/CMakeLists.txt @@ -6,14 +6,7 @@ # if(FREECAD_CREATE_MAC_APP OR (APPLE AND BUILD_WITH_CONDA)) add_subdirectory(QuickLook) - install( - DIRECTORY "${CMAKE_CURRENT_LIST_DIR}/QuickLook/QuicklookFCStd.qlgenerator" - DESTINATION "${CMAKE_INSTALL_PREFIX}/Library/QuickLook" - ) - install( - PROGRAMS "${PROJECT_BINARY_DIR}/src/MacAppBundle/QuickLook/QuicklookFCStd.framework/Versions/A/QuicklookFCStd" - DESTINATION "${CMAKE_INSTALL_PREFIX}/Library/QuickLook/QuicklookFCStd.qlgenerator/Contents/MacOS/" - ) + # Installation handled by QuickLook/CMakeLists.txt endif() @@ -25,13 +18,21 @@ if(FREECAD_CREATE_MAC_APP) set(PYTHON_DIR_BASENAME python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}) -if(PYTHON_LIBRARY MATCHES "(.*Python\\.framework).*") - #framework - set(PYTHON_DIR ${CMAKE_MATCH_1}/Versions/${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR}/lib/${PYTHON_DIR_BASENAME}) +if(Python3_LIBRARY_DIRS) + # Get first library directory + list(GET Python3_LIBRARY_DIRS 0 FIRST_PYTHON_LIB_DIR) + if(FIRST_PYTHON_LIB_DIR MATCHES "(.*Python\\.framework).*") + #framework + set(PYTHON_DIR ${CMAKE_MATCH_1}/Versions/${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/lib/${PYTHON_DIR_BASENAME}) + else() + #unix + set(PYTHON_DIR ${FIRST_PYTHON_LIB_DIR}/${PYTHON_DIR_BASENAME}) + endif() else() - #unix - get_filename_component(PYTHON_DIR ${PYTHON_LIBRARY} PATH) - set(PYTHON_DIR ${PYTHON_DIR}/${PYTHON_DIR_BASENAME}) + # Fallback: derive from Python3_EXECUTABLE + get_filename_component(PYTHON_BIN_DIR ${Python3_EXECUTABLE} DIRECTORY) + get_filename_component(PYTHON_PREFIX ${PYTHON_BIN_DIR} DIRECTORY) + set(PYTHON_DIR ${PYTHON_PREFIX}/lib/${PYTHON_DIR_BASENAME}) endif() message(" PYTHON_DIR is ${PYTHON_DIR} --------------------ipatch--") @@ -51,7 +52,7 @@ if(HOMEBREW_PREFIX) message(" PYTHON_DIR is ${PYTHON_DIR} --------------------ipatch--") - file(GLOB HOMEBREW_PTH_FILES "${HOMEBREW_PREFIX}/lib/python${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR}/site-packages/freecad*.pth") + file(GLOB HOMEBREW_PTH_FILES "${HOMEBREW_PREFIX}/lib/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages/freecad*.pth") message(STATUS "HOMEBREW_PTH_FILES are ${HOMEBREW_PTH_FILES} -----------------------ipatch--") @@ -120,13 +121,13 @@ if(HOMEBREW_PREFIX) if(IS_DIRECTORY "${CONTENT_ITEM}") # Install directory - install(DIRECTORY "${CONTENT_ITEM}/" + install(DIRECTORY "${CONTENT_ITEM}/" DESTINATION "${BUNDLE_SITE_PACKAGES_DIR}/${ITEM_NAME}" USE_SOURCE_PERMISSIONS) message(STATUS " Installing directory: ${ITEM_NAME}") else() # Install file - install(FILES "${CONTENT_ITEM}" + install(FILES "${CONTENT_ITEM}" DESTINATION "${BUNDLE_SITE_PACKAGES_DIR}") message(STATUS " Installing file: ${ITEM_NAME}") endif() @@ -149,7 +150,21 @@ if(HOMEBREW_PREFIX) endforeach(PTH_FILE) endif() -set(QT_PLUGINS_DIR "${Qt5Core_DIR}/../../../plugins") +if(FREECAD_QT_MAJOR_VERSION STREQUAL "6") + set(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../plugins") + # Fallback for Homebrew Qt6 layout + if(NOT EXISTS "${QT_PLUGINS_DIR}") + set(QT_PLUGINS_DIR "/opt/homebrew/share/qt/plugins") + endif() + set(QT_ASSISTANT_PATH "${Qt6Core_DIR}/../../../libexec/Assistant.app/Contents/MacOS/Assistant") + set(QT_PLUGIN_SUBDIRS "platforms;imageformats;styles;iconengines") +else() + set(QT_PLUGINS_DIR "${Qt5Core_DIR}/../../../plugins") + set(QT_ASSISTANT_PATH "${Qt5Core_DIR}/../../../libexec/Assistant.app/Contents/MacOS/Assistant") + set(QT_PLUGIN_SUBDIRS "platforms;imageformats;styles;iconengines") +endif() + + execute_process(COMMAND "xcode-select" "--print-path" OUTPUT_VARIABLE XCODE_PATH ERROR_QUIET @@ -159,22 +174,33 @@ string(STRIP ${XCODE_PATH} XCODE_PATH) set(XCTEST_PATH "${XCODE_PATH}/Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework/Versions/Current") # add qt assistant to bundle -install(PROGRAMS "${Qt5Core_DIR}/../../../libexec/Assistant.app/Contents/MacOS/Assistant" DESTINATION ${CMAKE_INSTALL_PREFIX}/MacOS) +if(EXISTS "${QT_ASSISTANT_PATH}") + install(PROGRAMS "${QT_ASSISTANT_PATH}" DESTINATION ${CMAKE_INSTALL_PREFIX}/MacOS) +else() + message(WARNING "Qt Assistant not found at ${QT_ASSISTANT_PATH}, skipping installation") +endif() -# Ensure the actual plugin files are installed instead of symlinks. -file(GLOB _subdirs RELATIVE "${QT_PLUGINS_DIR}" "${QT_PLUGINS_DIR}/*") - -foreach(_subdir ${_subdirs}) +# Install specific Qt plugin subdirectories that FreeCAD needs +foreach(_subdir ${QT_PLUGIN_SUBDIRS}) if(IS_DIRECTORY "${QT_PLUGINS_DIR}/${_subdir}") set(_resolved_files "") - file(GLOB _plugin_files RELATIVE "${QT_PLUGINS_DIR}/${_subdir}" "${QT_PLUGINS_DIR}/${_subdir}/*") + file(GLOB _plugin_files "${QT_PLUGINS_DIR}/${_subdir}/*.dylib") foreach(_plugin_file ${_plugin_files}) - get_filename_component(_resolved_file "${QT_PLUGINS_DIR}/${_subdir}/${_plugin_file}" REALPATH) - list(APPEND _resolved_files ${_resolved_file}) + if(EXISTS "${_plugin_file}" AND NOT IS_DIRECTORY "${_plugin_file}") + get_filename_component(_resolved_file "${_plugin_file}" REALPATH) + list(APPEND _resolved_files ${_resolved_file}) + endif() endforeach() - install(FILES ${_resolved_files} DESTINATION "${CMAKE_INSTALL_LIBDIR}/qtplugins/${_subdir}") + if(_resolved_files) + install(FILES ${_resolved_files} DESTINATION "${CMAKE_INSTALL_LIBDIR}/qtplugins/${_subdir}") + + # Also install platform plugins to standard Qt location for runtime loading + if("${_subdir}" STREQUAL "platforms") + install(FILES ${_resolved_files} DESTINATION "${CMAKE_INSTALL_PREFIX}/PlugIns/platforms") + endif() + endif() endif() endforeach() @@ -194,9 +220,65 @@ install(CODE ${HOMEBREW_PREFIX}${MACPORTS_PREFIX}/lib ${HOMEBREW_PREFIX}/opt ${HOMEBREW_PREFIX}/opt/*/lib - ${Qt5Core_DIR}/../../.. + ${Qt${FREECAD_QT_MAJOR_VERSION}Core_DIR}/../../.. ${XCTEST_PATH} )" ) +# Fix Qt platform plugin dependencies after bundle relocation +# This addresses the SIGKILL issue where Qt plugins reference framework paths +# but the Qt libraries are installed as flat dylibs +install(CODE " + message(STATUS \"Fixing Qt platform plugin dependencies...\") + + # Find platform plugins + file(GLOB_RECURSE PLATFORM_PLUGINS \"${CMAKE_INSTALL_PREFIX}/PlugIns/platforms/*.dylib\") + + foreach(PLUGIN \${PLATFORM_PLUGINS}) + message(STATUS \"Processing plugin: \${PLUGIN}\") + + # Get current dependencies + execute_process( + COMMAND otool -L \"\${PLUGIN}\" + OUTPUT_VARIABLE PLUGIN_DEPS + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + # Fix framework-style Qt dependencies to use flat dylib names + if(FREECAD_QT_MAJOR_VERSION STREQUAL \"6\") + set(QT_FRAMEWORKS \"QtCore;QtGui;QtWidgets;QtOpenGL;QtPrintSupport;QtSvg;QtNetwork\") + else() + set(QT_FRAMEWORKS \"QtCore;QtGui;QtWidgets;QtOpenGL;QtPrintSupport;QtSvg;QtNetwork\") + endif() + + foreach(FRAMEWORK \${QT_FRAMEWORKS}) + # Change @rpath/QtXXX.framework/Versions/A/QtXXX to @rpath/QtXXX + execute_process( + COMMAND install_name_tool -change + \"@rpath/\${FRAMEWORK}.framework/Versions/A/\${FRAMEWORK}\" + \"@rpath/\${FRAMEWORK}\" + \"\${PLUGIN}\" + OUTPUT_QUIET + ERROR_QUIET + ) + + # Also handle versioned framework paths for Qt5 + if(FREECAD_QT_MAJOR_VERSION STREQUAL \"5\") + execute_process( + COMMAND install_name_tool -change + \"@rpath/\${FRAMEWORK}.framework/Versions/5/\${FRAMEWORK}\" + \"@rpath/\${FRAMEWORK}\" + \"\${PLUGIN}\" + OUTPUT_QUIET + ERROR_QUIET + ) + endif() + endforeach() + + message(STATUS \"Fixed dependencies for: \${PLUGIN}\") + endforeach() + + message(STATUS \"Qt platform plugin dependency fixing complete.\") +") + endif(FREECAD_CREATE_MAC_APP) diff --git a/src/MacAppBundle/QuickLook/CMakeLists.txt b/src/MacAppBundle/QuickLook/CMakeLists.txt index 542b5ee811..3b15d0a35f 100644 --- a/src/MacAppBundle/QuickLook/CMakeLists.txt +++ b/src/MacAppBundle/QuickLook/CMakeLists.txt @@ -1,36 +1,156 @@ # SPDX-License-Identifier: LGPL-2.1-or-later -#cmake_minimum_required(VERSION 3.23) -#project(FCQuickLook) +cmake_minimum_required(VERSION 3.22) +project(FreeCADQuickLook) -add_library( - QuicklookFCStd - SHARED - GeneratePreviewForURL.m - GenerateThumbnailForURL.m - main.c +# Configuration options for QuickLook support +option(FREECAD_QUICKLOOK_LEGACY_SUPPORT "Build legacy .qlgenerator for older macOS compatibility" ON) +option(FREECAD_QUICKLOOK_MODERN_SUPPORT "Build modern .appex extensions for macOS 15.0+" ON) + + +# Only build QuickLook extensions with Unix Makefiles or Ninja generator +if(NOT CMAKE_GENERATOR MATCHES "Unix Makefiles|Ninja") + message(STATUS "QuickLook extensions only supported with Unix Makefiles or Ninja generators (current: ${CMAKE_GENERATOR})") + add_custom_target(FreeCADQuickLook + COMMAND ${CMAKE_COMMAND} -E echo "QuickLook extensions require Unix Makefiles or Ninja generator" + COMMENT "QuickLook placeholder target" + ) + return() +endif() + +# Validate CMAKE_OSX_DEPLOYMENT_TARGET is set (or use system default) +if(NOT CMAKE_OSX_DEPLOYMENT_TARGET) + # If not explicitly set, query the system default + execute_process( + COMMAND xcrun --show-sdk-version + OUTPUT_VARIABLE MACOS_SDK_VERSION + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if(MACOS_SDK_VERSION) + set(CMAKE_OSX_DEPLOYMENT_TARGET ${MACOS_SDK_VERSION}) + message(STATUS "CMAKE_OSX_DEPLOYMENT_TARGET not set, using SDK version: ${CMAKE_OSX_DEPLOYMENT_TARGET}") + else() + message(WARNING "CMAKE_OSX_DEPLOYMENT_TARGET not set and could not determine SDK version. Defaulting to 15.0") + set(CMAKE_OSX_DEPLOYMENT_TARGET "15.0") + endif() +endif() + +# Determine which QuickLook implementations to build +set(BUILD_MODERN_EXTENSIONS OFF) +set(BUILD_LEGACY_GENERATOR OFF) + +if(FREECAD_QUICKLOOK_MODERN_SUPPORT) + if(CMAKE_OSX_DEPLOYMENT_TARGET VERSION_GREATER_EQUAL "15.0") + set(BUILD_MODERN_EXTENSIONS ON) + message(STATUS "Building modern Swift .appex QuickLook extensions (macOS 15.0+)") + else() + message(STATUS "Modern QuickLook extensions require macOS 15.0+, current target: ${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() +endif() + +if(FREECAD_QUICKLOOK_LEGACY_SUPPORT) + set(BUILD_LEGACY_GENERATOR ON) + message(STATUS "Building legacy .qlgenerator QuickLook support") +endif() + +if(NOT BUILD_MODERN_EXTENSIONS AND NOT BUILD_LEGACY_GENERATOR) + message(WARNING "No QuickLook implementations enabled. Enable FREECAD_QUICKLOOK_MODERN_SUPPORT or FREECAD_QUICKLOOK_LEGACY_SUPPORT") + add_custom_target(FreeCADQuickLook + COMMAND ${CMAKE_COMMAND} -E echo "No QuickLook implementations enabled" + COMMENT "QuickLook disabled target" + ) + return() +endif() + +# Global configuration +set(FREECAD_BUNDLE_ID "org.freecad.FreeCAD") +set(TARGET_APP_BUNDLE "${CMAKE_BINARY_DIR}/src/MacAppBundle/FreeCAD.app") + +# Detect target architecture +if(CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" OR CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") + set(TARGET_ARCH "arm64") +else() + set(TARGET_ARCH "x86_64") +endif() + +# Build subdirectories conditionally +set(QUICKLOOK_TARGETS "") + +if(BUILD_MODERN_EXTENSIONS) + add_subdirectory(modern) + list(APPEND QUICKLOOK_TARGETS FreeCADModernQuickLook) +endif() + +if(BUILD_LEGACY_GENERATOR) + add_subdirectory(legacy) + list(APPEND QUICKLOOK_TARGETS FreeCADLegacyQuickLook) +endif() + +# Main target that coordinates all QuickLook implementations +add_custom_target(FreeCADQuickLook ALL + DEPENDS ${QUICKLOOK_TARGETS} + COMMENT "FreeCAD QuickLook support integrated into main app" ) -set_target_properties( - QuicklookFCStd - PROPERTIES - FRAMEWORK TRUE - MACOSX_FRAMEWORK_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/QuicklookFCStd.qlgenerator/Contents/Info.plist" - #SUFFIX .qlgenerator +# Ensure QuickLook is built after main FreeCAD app if it's available as a target +if(TARGET FreeCAD) + add_dependencies(FreeCADQuickLook FreeCAD) +endif() + +# Combined verification target +set(VERIFY_COMMANDS "") +if(BUILD_MODERN_EXTENSIONS) + list(APPEND VERIFY_COMMANDS + COMMAND ${CMAKE_COMMAND} --build . --target verify_modern_build + ) +endif() +if(BUILD_LEGACY_GENERATOR) + list(APPEND VERIFY_COMMANDS + COMMAND ${CMAKE_COMMAND} --build . --target verify_legacy_build + ) +endif() + +add_custom_target(verify_build + ${VERIFY_COMMANDS} + DEPENDS FreeCADQuickLook + COMMENT "Verifying all QuickLook implementations" ) -target_link_libraries( - QuicklookFCStd - "-framework AppKit" - "-framework ApplicationServices" - "-framework CoreData" - "-framework CoreFoundation" - "-framework CoreServices" - "-framework Foundation" - "-framework QuickLook" +# Combined test registration target +set(REGISTRATION_COMMANDS "") +if(BUILD_MODERN_EXTENSIONS) + list(APPEND REGISTRATION_COMMANDS + COMMAND ${CMAKE_COMMAND} --build . --target test_modern_registration + ) +endif() +if(BUILD_LEGACY_GENERATOR) + list(APPEND REGISTRATION_COMMANDS + COMMAND ${CMAKE_COMMAND} --build . --target test_legacy_registration + ) +endif() + +add_custom_target(test_registration + ${REGISTRATION_COMMANDS} + DEPENDS FreeCADQuickLook + COMMENT "Testing all QuickLook registrations" ) -set_target_properties( - QuicklookFCStd - PROPERTIES LINK_FLAGS "-Wl,-F/Library/Frameworks" -) +# Installation components +if(BUILD_MODERN_EXTENSIONS) + # Modern extensions install themselves via their CMakeLists.txt +endif() + +if(BUILD_LEGACY_GENERATOR) + # Legacy generator installs itself via its CMakeLists.txt +endif() + +# Status summary +message(STATUS "FreeCAD QuickLook Configuration Summary:") +message(STATUS " Generator: ${CMAKE_GENERATOR}") +message(STATUS " Target Architecture: ${TARGET_ARCH}") +message(STATUS " Deployment Target: ${CMAKE_OSX_DEPLOYMENT_TARGET}") +message(STATUS " Target FreeCAD.app: ${TARGET_APP_BUNDLE}") +message(STATUS " Modern Extensions (.appex): ${BUILD_MODERN_EXTENSIONS}") +message(STATUS " Legacy Generator (.qlgenerator): ${BUILD_LEGACY_GENERATOR}") + +message(STATUS " Code Signing: Handled by official src/Tools/macos_sign_and_notarize.sh script") diff --git a/src/MacAppBundle/QuickLook/legacy/CMakeLists.txt b/src/MacAppBundle/QuickLook/legacy/CMakeLists.txt new file mode 100644 index 0000000000..c083af31c0 --- /dev/null +++ b/src/MacAppBundle/QuickLook/legacy/CMakeLists.txt @@ -0,0 +1,97 @@ +cmake_minimum_required(VERSION 3.22) + +# Legacy Objective-C QuickLook Generator for older macOS compatibility +# This file handles building .qlgenerator bundles using Objective-C/C + +# Configuration options (inherited from parent) +# CMAKE_OSX_DEPLOYMENT_TARGET + +# Paths (inherited from parent) +set(TARGET_APP_BUNDLE "${CMAKE_BINARY_DIR}/src/MacAppBundle/FreeCAD.app") +set(LEGACY_QUICKLOOK_DIR "${TARGET_APP_BUNDLE}/Contents/Library/QuickLook") + +# Build legacy QuickLook generator using traditional CMake approach +add_library( + QuicklookFCStd + SHARED + GeneratePreviewForURL.m + GenerateThumbnailForURL.m + main.c +) + +set_target_properties( + QuicklookFCStd + PROPERTIES + FRAMEWORK TRUE + MACOSX_FRAMEWORK_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/QuicklookFCStd.qlgenerator/Contents/Info.plist" +) + +target_link_libraries( + QuicklookFCStd + "-framework AppKit" + "-framework ApplicationServices" + "-framework CoreData" + "-framework CoreFoundation" + "-framework CoreServices" + "-framework Foundation" + "-framework QuickLook" +) + +set_target_properties( + QuicklookFCStd + PROPERTIES LINK_FLAGS "-Wl,-F/Library/Frameworks" +) + +# Create bundle directory structure +add_custom_target(create_legacy_bundle_dirs ALL + COMMAND ${CMAKE_COMMAND} -E make_directory ${LEGACY_QUICKLOOK_DIR} + COMMENT "Creating legacy QuickLook directory structure" +) + +# Install legacy generator to the system QuickLook directory within the app bundle +add_custom_target(embed_legacy_generator ALL + COMMAND ${CMAKE_COMMAND} -E copy_directory + "$" + "${LEGACY_QUICKLOOK_DIR}/QuicklookFCStd.qlgenerator" + DEPENDS + QuicklookFCStd + create_legacy_bundle_dirs + COMMENT "Embedding legacy QuickLook generator in main FreeCAD.app" +) + +# Main legacy generator target +add_custom_target(FreeCADLegacyQuickLook ALL + DEPENDS embed_legacy_generator + COMMENT "Legacy Objective-C QuickLook generator built" +) + +# Install target for legacy generator +install(DIRECTORY ${LEGACY_QUICKLOOK_DIR}/QuicklookFCStd.qlgenerator + DESTINATION "${CMAKE_INSTALL_PREFIX}/Library/QuickLook" + COMPONENT LegacyQuickLook + USE_SOURCE_PERMISSIONS +) + +# Verification target +add_custom_target(verify_legacy_build + COMMAND test -f ${LEGACY_QUICKLOOK_DIR}/QuicklookFCStd.qlgenerator/Contents/MacOS/QuicklookFCStd + COMMAND echo "Legacy generator successfully embedded in FreeCAD.app" + DEPENDS FreeCADLegacyQuickLook + COMMENT "Verifying legacy QuickLook generator integration" +) + +# Test registration target +add_custom_target(test_legacy_registration + COMMAND echo "Legacy QuickLook generator will be registered automatically by the system" + COMMAND qlmanage -r || echo "QuickLook cache reset attempted" + DEPENDS FreeCADLegacyQuickLook + COMMENT "Testing legacy QuickLook generator registration" +) + +# Status messages +message(STATUS "Legacy Objective-C QuickLook Generator Configuration:") +message(STATUS " Target Library: QuicklookFCStd") +message(STATUS " Deployment Target: ${CMAKE_OSX_DEPLOYMENT_TARGET}") +message(STATUS " Legacy QuickLook Directory: ${LEGACY_QUICKLOOK_DIR}") +message(STATUS " Bundle ID: org.freecad.qlgenerator.QuicklookFCStd") +message(STATUS " Code Signing: Handled by official macos_sign_and_notarize.sh script") diff --git a/src/MacAppBundle/QuickLook/GeneratePreviewForURL.m b/src/MacAppBundle/QuickLook/legacy/GeneratePreviewForURL.m similarity index 100% rename from src/MacAppBundle/QuickLook/GeneratePreviewForURL.m rename to src/MacAppBundle/QuickLook/legacy/GeneratePreviewForURL.m diff --git a/src/MacAppBundle/QuickLook/GenerateThumbnailForURL.m b/src/MacAppBundle/QuickLook/legacy/GenerateThumbnailForURL.m similarity index 100% rename from src/MacAppBundle/QuickLook/GenerateThumbnailForURL.m rename to src/MacAppBundle/QuickLook/legacy/GenerateThumbnailForURL.m diff --git a/src/MacAppBundle/QuickLook/QuicklookFCStd.qlgenerator/Contents/Info.plist b/src/MacAppBundle/QuickLook/legacy/QuicklookFCStd.qlgenerator/Contents/Info.plist similarity index 100% rename from src/MacAppBundle/QuickLook/QuicklookFCStd.qlgenerator/Contents/Info.plist rename to src/MacAppBundle/QuickLook/legacy/QuicklookFCStd.qlgenerator/Contents/Info.plist diff --git a/src/MacAppBundle/QuickLook/main.c b/src/MacAppBundle/QuickLook/legacy/main.c similarity index 99% rename from src/MacAppBundle/QuickLook/main.c rename to src/MacAppBundle/QuickLook/legacy/main.c index 970b0885a3..869ab7fff6 100644 --- a/src/MacAppBundle/QuickLook/main.c +++ b/src/MacAppBundle/QuickLook/legacy/main.c @@ -210,5 +210,4 @@ void *QuickLookGeneratorPluginFactory(CFAllocatorRef allocator,CFUUIDRef typeID) } /* If the requested type is incorrect, return NULL. */ return NULL; -} - +} \ No newline at end of file diff --git a/src/MacAppBundle/QuickLook/modern/CMakeLists.txt b/src/MacAppBundle/QuickLook/modern/CMakeLists.txt new file mode 100644 index 0000000000..5fd713663f --- /dev/null +++ b/src/MacAppBundle/QuickLook/modern/CMakeLists.txt @@ -0,0 +1,220 @@ +cmake_minimum_required(VERSION 3.22) + +# Modern Swift QuickLook Extensions for macOS 15.0+ +# This file handles building .appex extensions using Swift + +# Configuration options (inherited from parent) +# CMAKE_OSX_DEPLOYMENT_TARGET + +# Check for required tools +find_program(SWIFTC_EXECUTABLE swiftc REQUIRED) +find_program(PLUTIL_EXECUTABLE plutil REQUIRED) + +# Find macOS SDK +execute_process( + COMMAND xcrun --show-sdk-path --sdk macosx + OUTPUT_VARIABLE MACOS_SDK_PATH + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE SDK_RESULT +) + +if(NOT SDK_RESULT EQUAL 0 OR NOT EXISTS "${MACOS_SDK_PATH}") + message(FATAL_ERROR "macOS SDK not found. Please install Xcode Command Line Tools.") +endif() + +message(STATUS "Modern QuickLook: Using macOS SDK: ${MACOS_SDK_PATH}") + +# Bundle identifiers (inherited from parent) +set(FREECAD_BUNDLE_ID "org.freecad.FreeCAD") +set(THUMBNAIL_BUNDLE_ID "${FREECAD_BUNDLE_ID}.quicklook.thumbnail") +set(PREVIEW_BUNDLE_ID "${FREECAD_BUNDLE_ID}.quicklook.preview") + +# Paths (inherited from parent) +set(TARGET_APP_BUNDLE "${CMAKE_BINARY_DIR}/src/MacAppBundle/FreeCAD.app") +set(EXTENSIONS_DIR "${TARGET_APP_BUNDLE}/Contents/PlugIns") + +# Build directories +set(BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}") +set(THUMBNAIL_EXT_DIR "${BUILD_DIR}/FreeCADThumbnailExtension.appex") +set(PREVIEW_EXT_DIR "${BUILD_DIR}/FreeCADPreviewExtension.appex") + +# Detect target architecture +if(CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" OR CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") + set(TARGET_ARCH "arm64") +else() + set(TARGET_ARCH "x86_64") +endif() + +# Swift compilation flags +set(SWIFT_FLAGS + -target ${TARGET_ARCH}-apple-macosx${CMAKE_OSX_DEPLOYMENT_TARGET} + -sdk ${MACOS_SDK_PATH} + -O + -whole-module-optimization + -enable-library-evolution + -swift-version 5 +) + + + +# Function to process Info.plist files +function(process_plist input_plist output_plist bundle_id executable_name product_name principal_class) + add_custom_command( + OUTPUT ${output_plist} + COMMAND ${CMAKE_COMMAND} -E copy ${input_plist} ${output_plist} + COMMAND ${PLUTIL_EXECUTABLE} -replace CFBundleIdentifier -string ${bundle_id} ${output_plist} + COMMAND ${PLUTIL_EXECUTABLE} -replace CFBundleExecutable -string ${executable_name} ${output_plist} + COMMAND ${PLUTIL_EXECUTABLE} -replace CFBundleName -string ${product_name} ${output_plist} + COMMAND ${PLUTIL_EXECUTABLE} -replace NSExtension.NSExtensionPrincipalClass -string ${principal_class} ${output_plist} + DEPENDS ${input_plist} + COMMENT "Processing extension ${input_plist} -> ${output_plist}" + ) +endfunction() + +# Create bundle directory structure +add_custom_target(create_modern_bundle_dirs ALL + COMMAND ${CMAKE_COMMAND} -E make_directory ${EXTENSIONS_DIR} + COMMAND ${CMAKE_COMMAND} -E make_directory ${THUMBNAIL_EXT_DIR}/Contents/MacOS + COMMAND ${CMAKE_COMMAND} -E make_directory ${THUMBNAIL_EXT_DIR}/Contents/Resources + COMMAND ${CMAKE_COMMAND} -E make_directory ${PREVIEW_EXT_DIR}/Contents/MacOS + COMMAND ${CMAKE_COMMAND} -E make_directory ${PREVIEW_EXT_DIR}/Contents/Resources + COMMENT "Creating modern extensions directory structure" +) + +# Process Info.plist files +process_plist( + "${CMAKE_CURRENT_SOURCE_DIR}/ThumbnailExtensionInfo.plist" + "${THUMBNAIL_EXT_DIR}/Contents/Info.plist" + ${THUMBNAIL_BUNDLE_ID} + "FreeCADThumbnailExtension" + "FreeCADThumbnailExtension" + "FreeCADThumbnailExtension.ThumbnailProvider" +) + +process_plist( + "${CMAKE_CURRENT_SOURCE_DIR}/PreviewExtensionInfo.plist" + "${PREVIEW_EXT_DIR}/Contents/Info.plist" + ${PREVIEW_BUNDLE_ID} + "FreeCADPreviewExtension" + "FreeCADPreviewExtension" + "FreeCADPreviewExtension.PreviewProvider" +) + +# Compile Thumbnail Extension +add_custom_target(compile_thumbnail_extension ALL + COMMAND ${SWIFTC_EXECUTABLE} + ${SWIFT_FLAGS} + -emit-executable + -module-name FreeCADThumbnailExtension + -parse-as-library + -Xlinker -e -Xlinker _NSExtensionMain + -Xlinker -rpath -Xlinker @executable_path/../Frameworks + -Xlinker -rpath -Xlinker @executable_path/../../../../Frameworks + -framework Foundation + -framework CoreGraphics + -framework ImageIO + -framework QuickLookThumbnailing + -framework AppKit + -lcompression + -o ${THUMBNAIL_EXT_DIR}/Contents/MacOS/FreeCADThumbnailExtension + ${CMAKE_CURRENT_SOURCE_DIR}/ThumbnailProvider.swift + ${CMAKE_CURRENT_SOURCE_DIR}/ZipExtractor.swift + DEPENDS + create_modern_bundle_dirs + ${CMAKE_CURRENT_SOURCE_DIR}/ThumbnailProvider.swift + ${CMAKE_CURRENT_SOURCE_DIR}/ZipExtractor.swift + ${THUMBNAIL_EXT_DIR}/Contents/Info.plist + COMMENT "Compiling Thumbnail Extension" +) + +# Compile Preview Extension +add_custom_target(compile_preview_extension ALL + COMMAND ${SWIFTC_EXECUTABLE} + ${SWIFT_FLAGS} + -emit-executable + -module-name FreeCADPreviewExtension + -parse-as-library + -Xlinker -e -Xlinker _NSExtensionMain + -Xlinker -rpath -Xlinker @executable_path/../Frameworks + -Xlinker -rpath -Xlinker @executable_path/../../../../Frameworks + -framework Foundation + -framework CoreGraphics + -framework ImageIO + -framework Quartz + -framework Cocoa + -framework UniformTypeIdentifiers + -lcompression + -o ${PREVIEW_EXT_DIR}/Contents/MacOS/FreeCADPreviewExtension + ${CMAKE_CURRENT_SOURCE_DIR}/PreviewProvider.swift + ${CMAKE_CURRENT_SOURCE_DIR}/ZipExtractor.swift + DEPENDS + create_modern_bundle_dirs + ${CMAKE_CURRENT_SOURCE_DIR}/PreviewProvider.swift + ${CMAKE_CURRENT_SOURCE_DIR}/ZipExtractor.swift + ${PREVIEW_EXT_DIR}/Contents/Info.plist + COMMENT "Compiling Preview Extension" +) + +# Embed unsigned extensions in main FreeCAD.app +add_custom_target(embed_modern_extensions ALL + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${THUMBNAIL_EXT_DIR} + ${EXTENSIONS_DIR}/FreeCADThumbnailExtension.appex + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${PREVIEW_EXT_DIR} + ${EXTENSIONS_DIR}/FreeCADPreviewExtension.appex + DEPENDS + compile_thumbnail_extension + compile_preview_extension + COMMENT "Embedding modern extensions in main FreeCAD.app" +) + +# Main modern extensions target +add_custom_target(FreeCADModernQuickLook ALL + DEPENDS embed_modern_extensions + COMMENT "Modern Swift QuickLook extensions built" +) + +# Install targets for modern extensions +install(DIRECTORY ${EXTENSIONS_DIR}/FreeCADThumbnailExtension.appex + DESTINATION "${CMAKE_INSTALL_PREFIX}/PlugIns" + COMPONENT ModernQuickLook + USE_SOURCE_PERMISSIONS +) + +install(DIRECTORY ${EXTENSIONS_DIR}/FreeCADPreviewExtension.appex + DESTINATION "${CMAKE_INSTALL_PREFIX}/PlugIns" + COMPONENT ModernQuickLook + USE_SOURCE_PERMISSIONS +) + +# Verification target +add_custom_target(verify_modern_build + COMMAND test -f ${EXTENSIONS_DIR}/FreeCADThumbnailExtension.appex/Contents/MacOS/FreeCADThumbnailExtension + COMMAND test -f ${EXTENSIONS_DIR}/FreeCADPreviewExtension.appex/Contents/MacOS/FreeCADPreviewExtension + COMMAND echo "Modern extensions successfully embedded in FreeCAD.app" + DEPENDS FreeCADModernQuickLook + COMMENT "Verifying modern QuickLook extension integration" +) + +# Test registration target +add_custom_target(test_modern_registration + COMMAND pluginkit -a ${EXTENSIONS_DIR}/FreeCADThumbnailExtension.appex + COMMAND pluginkit -a ${EXTENSIONS_DIR}/FreeCADPreviewExtension.appex + COMMAND pluginkit -e use -i ${THUMBNAIL_BUNDLE_ID} + COMMAND pluginkit -e use -i ${PREVIEW_BUNDLE_ID} + COMMAND pluginkit -m -v -i ${THUMBNAIL_BUNDLE_ID} || echo "Thumbnail extension registration status unknown" + COMMAND pluginkit -m -v -i ${PREVIEW_BUNDLE_ID} || echo "Preview extension registration status unknown" + DEPENDS FreeCADModernQuickLook + COMMENT "Testing modern QuickLook extension registration" +) + +# Status messages +message(STATUS "Modern Swift QuickLook Extensions Configuration:") +message(STATUS " Swift Compiler: ${SWIFTC_EXECUTABLE}") +message(STATUS " Target Architecture: ${TARGET_ARCH}") +message(STATUS " Deployment Target: ${CMAKE_OSX_DEPLOYMENT_TARGET}") +message(STATUS " Thumbnail Bundle ID: ${THUMBNAIL_BUNDLE_ID}") +message(STATUS " Preview Bundle ID: ${PREVIEW_BUNDLE_ID}") +message(STATUS " Extensions Directory: ${EXTENSIONS_DIR}") +message(STATUS " Code Signing: Handled by official macos_sign_and_notarize.sh script") diff --git a/src/MacAppBundle/QuickLook/modern/PreviewExtension.entitlements b/src/MacAppBundle/QuickLook/modern/PreviewExtension.entitlements new file mode 100644 index 0000000000..154df28d59 --- /dev/null +++ b/src/MacAppBundle/QuickLook/modern/PreviewExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.bookmarks.app-scope + + com.apple.security.files.downloads.read-only + + com.apple.security.files.user-selected.read-only + + + diff --git a/src/MacAppBundle/QuickLook/modern/PreviewExtensionInfo.plist b/src/MacAppBundle/QuickLook/modern/PreviewExtensionInfo.plist new file mode 100644 index 0000000000..c9e45f13dc --- /dev/null +++ b/src/MacAppBundle/QuickLook/modern/PreviewExtensionInfo.plist @@ -0,0 +1,48 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + QuickLookPreviewExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1 + LSMinimumSystemVersion + 15.0 + NSExtension + + NSExtensionAttributes + + QLIsDataBasedPreview + + QLSupportedContentTypes + + org.freecad.fcstd + + QLSupportsSearchableItems + + + NSExtensionPointIdentifier + com.apple.quicklook.preview + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PreviewProvider + + + diff --git a/src/MacAppBundle/QuickLook/modern/PreviewProvider.swift b/src/MacAppBundle/QuickLook/modern/PreviewProvider.swift new file mode 100644 index 0000000000..b2d2008eb1 --- /dev/null +++ b/src/MacAppBundle/QuickLook/modern/PreviewProvider.swift @@ -0,0 +1,77 @@ +import Cocoa +import Quartz +import UniformTypeIdentifiers +import os.log + +private let logger = Logger( + subsystem: Bundle(for: PreviewProvider.self).bundleIdentifier + ?? "org.freecad.quicklook.fallback", + category: "PreviewProvider" +) + +class PreviewProvider: QLPreviewProvider, QLPreviewingController { + + func providePreview(for request: QLFilePreviewRequest) async throws + -> QLPreviewReply + { + logger.debug( + "--- PreviewProvider: providePreview CALLED for \(request.fileURL.lastPathComponent) ---" + ) + + let fileURL = request.fileURL + logger.info("Received file URL: \(fileURL.path)") + + guard let image = try? SwiftZIPParser.extractThumbnail(from: fileURL) else { + let errorMessage = + "Failed to extract thumbnail from FreeCAD file: \(fileURL.lastPathComponent)" + logger.error("\(errorMessage)") + throw NSError( + domain: Bundle(for: PreviewProvider.self).bundleIdentifier + ?? "org.freecad.quicklook.fallback", + code: 1001, + userInfo: [NSLocalizedDescriptionKey: errorMessage] + ) + } + + logger.info( + "Thumbnail extracted successfully. Image size: \(image.width)x\(image.height)" + ) + + let imageSize = CGSize( + width: CGFloat(image.width), + height: CGFloat(image.height) + ) + logger.debug( + "Preview contextSize will be: \(imageSize.width)x\(imageSize.height)" + ) + + // Ensure imageSize is valid and positive + if imageSize.width <= 0 || imageSize.height <= 0 { + let errorMessage = + "Cannot create preview with zero or negative dimensions: \(imageSize) for file: \(fileURL.lastPathComponent)" + logger.error("\(errorMessage)") + throw NSError( + domain: Bundle(for: PreviewProvider.self).bundleIdentifier + ?? "org.freecad.quicklook.fallback", + code: 1003, + userInfo: [NSLocalizedDescriptionKey: errorMessage] + ) + } + + let reply = QLPreviewReply(contextSize: imageSize, isBitmap: true) { + context, + _ in + logger.info("Drawing block started. Drawing extracted thumbnail.") + + // Draw the extracted thumbnail image + context.draw(image, in: CGRect(origin: .zero, size: imageSize)) + logger.debug("Thumbnail image drawn in context.") + + logger.info("Drawing block finished.") + return + } + + logger.notice("QLPreviewReply created. Returning reply.") + return reply + } +} diff --git a/src/MacAppBundle/QuickLook/modern/ThumbnailExtension.entitlements b/src/MacAppBundle/QuickLook/modern/ThumbnailExtension.entitlements new file mode 100644 index 0000000000..154df28d59 --- /dev/null +++ b/src/MacAppBundle/QuickLook/modern/ThumbnailExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.bookmarks.app-scope + + com.apple.security.files.downloads.read-only + + com.apple.security.files.user-selected.read-only + + + diff --git a/src/MacAppBundle/QuickLook/modern/ThumbnailExtensionInfo.plist b/src/MacAppBundle/QuickLook/modern/ThumbnailExtensionInfo.plist new file mode 100644 index 0000000000..2df7bf0a70 --- /dev/null +++ b/src/MacAppBundle/QuickLook/modern/ThumbnailExtensionInfo.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + QuickLookThumbnailExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1 + LSMinimumSystemVersion + 15.0 + + NSExtension + + NSExtensionAttributes + + QLIsDataBasedThumbnail + + QLSupportedContentTypes + + org.freecad.fcstd + + QLSupportsSearchableItems + + + NSExtensionPointIdentifier + com.apple.quicklook.thumbnail + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ThumbnailProvider + + + diff --git a/src/MacAppBundle/QuickLook/modern/ThumbnailProvider.swift b/src/MacAppBundle/QuickLook/modern/ThumbnailProvider.swift new file mode 100644 index 0000000000..704795d2b2 --- /dev/null +++ b/src/MacAppBundle/QuickLook/modern/ThumbnailProvider.swift @@ -0,0 +1,42 @@ +import AppKit +import QuickLookThumbnailing +import os.log + +private let logger = Logger( + subsystem: Bundle(for: ThumbnailProvider.self).bundleIdentifier + ?? "org.freecad.quicklook.thumbnail.fallback", + category: "ThumbnailProvider" +) + +class ThumbnailProvider: QLThumbnailProvider { + + override func provideThumbnail( + for request: QLFileThumbnailRequest, + _ handler: @escaping (QLThumbnailReply?, Error?) -> Void + ) { + + logger.debug( + "Providing thumbnail for: \(request.fileURL.path)" + ) + + guard + let cgImage = try? SwiftZIPParser.extractThumbnail( + from: request.fileURL, maxSize: request.maximumSize) + else { + logger.warning("No valid thumbnail found; returning empty reply.") + handler(nil, nil) + return + } + + let reply = QLThumbnailReply( + contextSize: request.maximumSize, + currentContextDrawing: { () -> Bool in + let image = NSImage(cgImage: cgImage, size: request.maximumSize) + image.draw(in: CGRect(origin: .zero, size: request.maximumSize)) + return true + } + ) + + handler(reply, nil) + } +} diff --git a/src/MacAppBundle/QuickLook/modern/ZipExtractor.swift b/src/MacAppBundle/QuickLook/modern/ZipExtractor.swift new file mode 100644 index 0000000000..700f3a6ff1 --- /dev/null +++ b/src/MacAppBundle/QuickLook/modern/ZipExtractor.swift @@ -0,0 +1,474 @@ +// +// ZipExtractor.swift +// FreeCAD QuickLook Swift Implementation +// +// Pure Swift ZIP parser for extracting thumbnails from FreeCAD (.FCStd) files +// This removes external dependencies while maintaining modern Swift APIs +// +// Created for integration with FreeCAD upstream +// + +import Compression +import CoreGraphics +import Foundation +import ImageIO +import os.log + +// MARK: - ZIP File Format Constants + +private struct ZIPConstants { + static let localFileSignature: UInt32 = 0x0403_4b50 + static let centralDirSignature: UInt32 = 0x0201_4b50 + static let endOfCentralDirSignature: UInt32 = 0x0605_4b50 + + static let compressionStored: UInt16 = 0 + static let compressionDeflate: UInt16 = 8 +} + +// MARK: - ZIP Structures + +private struct ZIPLocalFileHeader { + let signature: UInt32 + let version: UInt16 + let flags: UInt16 + let compression: UInt16 + let modTime: UInt16 + let modDate: UInt16 + let crc32: UInt32 + let compressedSize: UInt32 + let uncompressedSize: UInt32 + let filenameLength: UInt16 + let extraFieldLength: UInt16 + + static let size = 30 +} + +private struct ZIPCentralDirHeader { + let signature: UInt32 + let versionMadeBy: UInt16 + let versionNeeded: UInt16 + let flags: UInt16 + let compression: UInt16 + let modTime: UInt16 + let modDate: UInt16 + let crc32: UInt32 + let compressedSize: UInt32 + let uncompressedSize: UInt32 + let filenameLength: UInt16 + let extraFieldLength: UInt16 + let commentLength: UInt16 + let diskNumber: UInt16 + let internalAttributes: UInt16 + let externalAttributes: UInt32 + let localHeaderOffset: UInt32 + + static let size = 46 +} + +private struct ZIPEndOfCentralDir { + let signature: UInt32 + let diskNumber: UInt16 + let centralDirDisk: UInt16 + let entriesOnDisk: UInt16 + let totalEntries: UInt16 + let centralDirSize: UInt32 + let centralDirOffset: UInt32 + let commentLength: UInt16 + + static let size = 22 +} + +// MARK: - Error Types + +enum ZIPParserError: Error, LocalizedError { + case fileNotFound + case invalidZipFile + case corruptedZipFile + case thumbnailNotFound + case compressionUnsupported + case decompressionFailed + + var errorDescription: String? { + switch self { + case .fileNotFound: + return "FCStd file not found" + case .invalidZipFile: + return "Invalid ZIP file format" + case .corruptedZipFile: + return "Corrupted ZIP file" + case .thumbnailNotFound: + return "No thumbnail found in FCStd file" + case .compressionUnsupported: + return "Unsupported compression method" + case .decompressionFailed: + return "Failed to decompress file data" + } + } +} + +// MARK: - Pure Swift ZIP Parser + +struct SwiftZIPParser { + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "org.freecad.quicklook", + category: "SwiftZIPParser" + ) + + /// Extract thumbnail from FCStd file + static func extractThumbnail(from fileURL: URL, maxSize: CGSize? = nil) throws -> CGImage { + logger.debug("Extracting thumbnail from file: \(fileURL.path)") + logger.info("=== SwiftZIPParser.extractThumbnail called ===") + logger.info("File URL: \(fileURL.path)") + logger.info("File exists: \(FileManager.default.fileExists(atPath: fileURL.path))") + do { + let isReachable = try fileURL.checkResourceIsReachable() + logger.info("File is readable: \(isReachable)") + } catch { + logger.info("File is readable: false (error: \(error.localizedDescription))") + } + + // Handle security scoped resources + let didStartAccessing = fileURL.startAccessingSecurityScopedResource() + logger.info("Started accessing security scoped resource: \(didStartAccessing)") + defer { + if didStartAccessing { + fileURL.stopAccessingSecurityScopedResource() + logger.debug("Stopped accessing security scoped resource") + } + } + + // Read file data + let zipData: Data + do { + zipData = try Data(contentsOf: fileURL, options: .mappedIfSafe) + logger.info("Successfully read \(zipData.count) bytes from file") + } catch { + logger.error( + "Failed to read file data: \(error.localizedDescription)") + logger.error("Failed to read file data: \(error.localizedDescription)") + throw ZIPParserError.fileNotFound + } + + return try extractThumbnail(from: zipData, maxSize: maxSize) + } + + /// Extract thumbnail from ZIP data + static func extractThumbnail(from zipData: Data, maxSize: CGSize? = nil) throws -> CGImage { + logger.info("=== Processing ZIP data ===") + logger.info("ZIP data size: \(zipData.count) bytes") + + guard zipData.count >= ZIPLocalFileHeader.size else { + logger.error("ZIP data too small") + logger.error("ZIP data too small: \(zipData.count) < \(ZIPLocalFileHeader.size)") + throw ZIPParserError.invalidZipFile + } + + // Verify ZIP signature + let signature = zipData.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) } + logger.info("ZIP signature: 0x\(String(signature, radix: 16))") + guard signature == ZIPConstants.localFileSignature else { + logger.error("Invalid ZIP signature: 0x\(String(signature, radix: 16))") + logger.error( + "Invalid ZIP signature: 0x\(String(signature, radix: 16)), expected: 0x\(String(ZIPConstants.localFileSignature, radix: 16))" + ) + throw ZIPParserError.invalidZipFile + } + + // Find end of central directory + logger.info("Searching for end of central directory...") + guard let endOfCentralDir = findEndOfCentralDirectory(in: zipData) else { + logger.error("Could not find end of central directory") + throw ZIPParserError.corruptedZipFile + } + logger.info("Found end of central directory with \(endOfCentralDir.totalEntries) entries") + + // Look for thumbnail files + let thumbnailPaths = ["thumbnails/Thumbnail.png", "Thumbnail.png"] + logger.info("Searching for thumbnail files: \(thumbnailPaths)") + + for thumbnailPath in thumbnailPaths { + logger.info("Trying path: \(thumbnailPath)") + if let thumbnailData = try? extractFile( + from: zipData, + endOfCentralDir: endOfCentralDir, + filename: thumbnailPath + ) { + logger.debug("Found thumbnail at path: \(thumbnailPath)") + logger.info( + "Found thumbnail at path: \(thumbnailPath), size: \(thumbnailData.count) bytes") + + if let image = createImage(from: thumbnailData, maxSize: maxSize) { + logger.debug("Successfully created CGImage from thumbnail data") + logger.info("Successfully created CGImage from thumbnail data") + return image + } else { + logger.warning( + "Failed to create CGImage from thumbnail data at path: \(thumbnailPath)") + } + } else { + logger.info("No thumbnail found at path: \(thumbnailPath)") + } + } + + logger.info("No thumbnail found in FCStd file") + logger.warning("No valid thumbnail found in FCStd file") + throw ZIPParserError.thumbnailNotFound + } + + /// Validate if file is a valid FCStd (ZIP) file + static func isValidFCStdFile(at url: URL) -> Bool { + guard let headerData = try? Data(contentsOf: url, options: .uncached), + headerData.count >= 4 + else { + return false + } + + let signature = headerData.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) } + return signature == ZIPConstants.localFileSignature + } +} + +// MARK: - Private Extensions + +extension SwiftZIPParser { + + /// Find the end of central directory record + private static func findEndOfCentralDirectory(in data: Data) -> ZIPEndOfCentralDir? { + // Search backwards from the end of the file + let minOffset = max(0, data.count - 65557) // Max comment length + EOCD size + for offset in stride(from: data.count - ZIPEndOfCentralDir.size, through: minOffset, by: -1) + { + let signature = readUInt32(from: data, at: offset) + if signature == ZIPConstants.endOfCentralDirSignature { + return parseEndOfCentralDir(from: data, at: offset) + } + } + return nil + } + + /// Parse end of central directory record + private static func parseEndOfCentralDir(from data: Data, at offset: Int) -> ZIPEndOfCentralDir + { + return ZIPEndOfCentralDir( + signature: readUInt32(from: data, at: offset), + diskNumber: readUInt16(from: data, at: offset + 4), + centralDirDisk: readUInt16(from: data, at: offset + 6), + entriesOnDisk: readUInt16(from: data, at: offset + 8), + totalEntries: readUInt16(from: data, at: offset + 10), + centralDirSize: readUInt32(from: data, at: offset + 12), + centralDirOffset: readUInt32(from: data, at: offset + 16), + commentLength: readUInt16(from: data, at: offset + 20) + ) + } + + /// Extract a specific file from the ZIP archive + private static func extractFile( + from zipData: Data, + endOfCentralDir: ZIPEndOfCentralDir, + filename: String + ) throws -> Data { + logger.debug("Searching for file: \(filename)") + + // Search through central directory + var offset = Int(endOfCentralDir.centralDirOffset) + for _ in 0.. ZIPCentralDirHeader + { + return ZIPCentralDirHeader( + signature: readUInt32(from: data, at: offset), + versionMadeBy: readUInt16(from: data, at: offset + 4), + versionNeeded: readUInt16(from: data, at: offset + 6), + flags: readUInt16(from: data, at: offset + 8), + compression: readUInt16(from: data, at: offset + 10), + modTime: readUInt16(from: data, at: offset + 12), + modDate: readUInt16(from: data, at: offset + 14), + crc32: readUInt32(from: data, at: offset + 16), + compressedSize: readUInt32(from: data, at: offset + 20), + uncompressedSize: readUInt32(from: data, at: offset + 24), + filenameLength: readUInt16(from: data, at: offset + 28), + extraFieldLength: readUInt16(from: data, at: offset + 30), + commentLength: readUInt16(from: data, at: offset + 32), + diskNumber: readUInt16(from: data, at: offset + 34), + internalAttributes: readUInt16(from: data, at: offset + 36), + externalAttributes: readUInt32(from: data, at: offset + 38), + localHeaderOffset: readUInt32(from: data, at: offset + 42) + ) + } + + /// Extract file data using central directory header + private static func extractFileData(from zipData: Data, centralHeader: ZIPCentralDirHeader) + throws -> Data + { + let localHeaderOffset = Int(centralHeader.localHeaderOffset) + let localHeader = parseLocalFileHeader(from: zipData, at: localHeaderOffset) + + guard localHeader.signature == ZIPConstants.localFileSignature else { + throw ZIPParserError.corruptedZipFile + } + + let dataStart = + localHeaderOffset + ZIPLocalFileHeader.size + Int(localHeader.filenameLength) + + Int(localHeader.extraFieldLength) + let dataEnd = dataStart + Int(localHeader.compressedSize) + + guard dataEnd <= zipData.count else { + throw ZIPParserError.corruptedZipFile + } + + let compressedData = zipData.subdata(in: dataStart.. ZIPLocalFileHeader + { + return ZIPLocalFileHeader( + signature: readUInt32(from: data, at: offset), + version: readUInt16(from: data, at: offset + 4), + flags: readUInt16(from: data, at: offset + 6), + compression: readUInt16(from: data, at: offset + 8), + modTime: readUInt16(from: data, at: offset + 10), + modDate: readUInt16(from: data, at: offset + 12), + crc32: readUInt32(from: data, at: offset + 14), + compressedSize: readUInt32(from: data, at: offset + 18), + uncompressedSize: readUInt32(from: data, at: offset + 22), + filenameLength: readUInt16(from: data, at: offset + 26), + extraFieldLength: readUInt16(from: data, at: offset + 28) + ) + } + + /// Decompress deflate-compressed data + private static func deflateDecompress(data: Data, expectedSize: Int) throws -> Data { + guard expectedSize > 0 else { + throw ZIPParserError.decompressionFailed + } + + return try data.withUnsafeBytes { bytes in + let buffer = UnsafeMutablePointer.allocate(capacity: expectedSize) + defer { buffer.deallocate() } + + let actualSize = compression_decode_buffer( + buffer, expectedSize, + bytes.bindMemory(to: UInt8.self).baseAddress!, data.count, + nil, COMPRESSION_ZLIB + ) + + guard actualSize == expectedSize else { + throw ZIPParserError.decompressionFailed + } + + return Data(bytes: buffer, count: expectedSize) + } + } + + /// Create CGImage from PNG data with optional size constraints + private static func createImage(from pngData: Data, maxSize: CGSize? = nil) -> CGImage? { + guard let dataProvider = CGDataProvider(data: pngData as CFData), + let image = CGImage( + pngDataProviderSource: dataProvider, decode: nil, shouldInterpolate: true, + intent: .defaultIntent) + else { + return nil + } + + // If no max size specified, return original + guard let maxSize = maxSize else { + return image + } + + // Calculate scaled size + let originalSize = CGSize(width: CGFloat(image.width), height: CGFloat(image.height)) + let scaledSize = scaleToFit(originalSize: originalSize, maxSize: maxSize) + + // If no scaling needed, return original + if scaledSize == originalSize { + return image + } + + // Scale the image + return scaleImage(image, to: scaledSize) + } + + /// Safely read UInt32 from Data at offset + fileprivate static func readUInt32(from data: Data, at offset: Int) -> UInt32 { + let subdata = data.subdata(in: offset..<(offset + 4)) + return subdata.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) } + } + + /// Safely read UInt16 from Data at offset + fileprivate static func readUInt16(from data: Data, at offset: Int) -> UInt16 { + let subdata = data.subdata(in: offset..<(offset + 2)) + return subdata.withUnsafeBytes { $0.loadUnaligned(as: UInt16.self) } + } + + /// Calculate size that fits within maxSize while maintaining aspect ratio + fileprivate static func scaleToFit(originalSize: CGSize, maxSize: CGSize) -> CGSize { + let widthRatio = maxSize.width / originalSize.width + let heightRatio = maxSize.height / originalSize.height + let scaleFactor = min(widthRatio, heightRatio) + + return CGSize( + width: originalSize.width * scaleFactor, + height: originalSize.height * scaleFactor + ) + } + + /// Scale CGImage to specified size + fileprivate static func scaleImage(_ image: CGImage, to size: CGSize) -> CGImage? { + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard + let context = CGContext( + data: nil, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) + else { + logger.error("Failed to create CGContext for image scaling") + return nil + } + + context.interpolationQuality = .high + context.draw(image, in: CGRect(origin: .zero, size: size)) + + return context.makeImage() + } +} diff --git a/src/MacAppBundle/QuickLook/test_integration.sh b/src/MacAppBundle/QuickLook/test_integration.sh new file mode 100755 index 0000000000..1165ec16bc --- /dev/null +++ b/src/MacAppBundle/QuickLook/test_integration.sh @@ -0,0 +1,371 @@ +#!/bin/bash + +# FreeCAD QuickLook Extensions - Integration Test Script +# This script tests the QuickLook extension integration in FreeCAD.app + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Test configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Look for FreeCAD.app in common install locations +if [[ -d "${SCRIPT_DIR}/../../../FreeCAD.app" ]]; then + FREECAD_APP="${SCRIPT_DIR}/../../../FreeCAD.app" +elif [[ -d "${SCRIPT_DIR}/../../../../FreeCAD.app" ]]; then + FREECAD_APP="${SCRIPT_DIR}/../../../../FreeCAD.app" +else + # Default to relative path from script + FREECAD_APP="${SCRIPT_DIR}/../../../FreeCAD.app" +fi + +EXTENSIONS_DIR="${FREECAD_APP}/Contents/PlugIns" +THUMBNAIL_EXT="${EXTENSIONS_DIR}/FreeCADThumbnailExtension.appex" +PREVIEW_EXT="${EXTENSIONS_DIR}/FreeCADPreviewExtension.appex" + +THUMBNAIL_BUNDLE_ID="org.freecad.FreeCAD.quicklook.thumbnail" +PREVIEW_BUNDLE_ID="org.freecad.FreeCAD.quicklook.preview" + +# Function to print colored output +print_status() { + local status=$1 + local message=$2 + case $status in + "OK") + echo -e "${GREEN}✓${NC} $message" + ;; + "FAIL") + echo -e "${RED}✗${NC} $message" + ;; + "WARN") + echo -e "${YELLOW}⚠${NC} $message" + ;; + "INFO") + echo -e "${BLUE}ℹ${NC} $message" + ;; + esac +} + +# Main test function +main() { + echo "FreeCAD QuickLook Extensions - Integration Test" + echo "==============================================" + echo "Testing app at: $FREECAD_APP" + echo + + local total_tests=0 + local passed_tests=0 + + # Test 1: Check if FreeCAD.app exists + ((total_tests++)) + if [[ -d "$FREECAD_APP" ]]; then + print_status "OK" "FreeCAD.app exists at: $FREECAD_APP" + ((passed_tests++)) + else + print_status "FAIL" "FreeCAD.app not found at: $FREECAD_APP" + print_status "INFO" "Please build and install FreeCAD first with: make install" + exit 1 + fi + + # Test 2: Check if main FreeCAD executable exists + ((total_tests++)) + if [[ -f "$FREECAD_APP/Contents/MacOS/FreeCAD" ]]; then + print_status "OK" "FreeCAD executable exists" + ((passed_tests++)) + else + print_status "FAIL" "FreeCAD executable not found" + fi + + # Test 3: Check if PlugIns directory exists + ((total_tests++)) + if [[ -d "$EXTENSIONS_DIR" ]]; then + print_status "OK" "Extensions directory exists: $EXTENSIONS_DIR" + ((passed_tests++)) + else + print_status "FAIL" "Extensions directory not found: $EXTENSIONS_DIR" + print_status "INFO" "QuickLook extensions may not have been built. Check cmake configuration." + fi + + # Test 4: Check if thumbnail extension exists + ((total_tests++)) + if [[ -d "$THUMBNAIL_EXT" ]]; then + print_status "OK" "Thumbnail extension exists" + ((passed_tests++)) + else + print_status "FAIL" "Thumbnail extension not found: $THUMBNAIL_EXT" + fi + + # Test 5: Check if preview extension exists + ((total_tests++)) + if [[ -d "$PREVIEW_EXT" ]]; then + print_status "OK" "Preview extension exists" + ((passed_tests++)) + else + print_status "FAIL" "Preview extension not found: $PREVIEW_EXT" + fi + + # Test 6: Check if thumbnail extension executable exists + ((total_tests++)) + if [[ -f "$THUMBNAIL_EXT/Contents/MacOS/FreeCADThumbnailExtension" ]]; then + print_status "OK" "Thumbnail extension executable exists" + ((passed_tests++)) + else + print_status "FAIL" "Thumbnail extension executable not found" + fi + + # Test 7: Check if preview extension executable exists + ((total_tests++)) + if [[ -f "$PREVIEW_EXT/Contents/MacOS/FreeCADPreviewExtension" ]]; then + print_status "OK" "Preview extension executable exists" + ((passed_tests++)) + else + print_status "FAIL" "Preview extension executable not found" + fi + + # Test 8: Check if thumbnail extension Info.plist exists + ((total_tests++)) + if [[ -f "$THUMBNAIL_EXT/Contents/Info.plist" ]]; then + print_status "OK" "Thumbnail extension Info.plist exists" + ((passed_tests++)) + else + print_status "FAIL" "Thumbnail extension Info.plist not found" + fi + + # Test 9: Check if preview extension Info.plist exists + ((total_tests++)) + if [[ -f "$PREVIEW_EXT/Contents/Info.plist" ]]; then + print_status "OK" "Preview extension Info.plist exists" + ((passed_tests++)) + else + print_status "FAIL" "Preview extension Info.plist not found" + fi + + # Test 10: Check thumbnail extension bundle ID + ((total_tests++)) + if [[ -f "$THUMBNAIL_EXT/Contents/Info.plist" ]]; then + local bundle_id=$(plutil -extract CFBundleIdentifier raw "$THUMBNAIL_EXT/Contents/Info.plist" 2>/dev/null) + if [[ "$bundle_id" == "$THUMBNAIL_BUNDLE_ID" ]]; then + print_status "OK" "Thumbnail extension has correct bundle ID: $bundle_id" + ((passed_tests++)) + else + print_status "FAIL" "Thumbnail extension bundle ID incorrect: $bundle_id (expected: $THUMBNAIL_BUNDLE_ID)" + fi + fi + + # Test 11: Check preview extension bundle ID + ((total_tests++)) + if [[ -f "$PREVIEW_EXT/Contents/Info.plist" ]]; then + local bundle_id=$(plutil -extract CFBundleIdentifier raw "$PREVIEW_EXT/Contents/Info.plist" 2>/dev/null) + if [[ "$bundle_id" == "$PREVIEW_BUNDLE_ID" ]]; then + print_status "OK" "Preview extension has correct bundle ID: $bundle_id" + ((passed_tests++)) + else + print_status "FAIL" "Preview extension bundle ID incorrect: $bundle_id (expected: $PREVIEW_BUNDLE_ID)" + fi + fi + + # Test 12: Basic FreeCAD launch test + ((total_tests++)) + print_status "INFO" "Testing FreeCAD launch (--version)..." + if timeout 10 "$FREECAD_APP/Contents/MacOS/FreeCAD" --version >/dev/null 2>&1; then + print_status "OK" "FreeCAD launches successfully" + ((passed_tests++)) + else + print_status "FAIL" "FreeCAD failed to launch or crashed" + print_status "WARN" "This will prevent QuickLook registration from working" + fi + + # Extension signing tests (optional - don't count toward pass/fail) + echo + print_status "INFO" "Code Signing Status:" + + if codesign -v "$THUMBNAIL_EXT" >/dev/null 2>&1; then + print_status "OK" "Thumbnail extension is signed" + else + print_status "WARN" "Thumbnail extension is unsigned (normal for development builds)" + fi + + if codesign -v "$PREVIEW_EXT" >/dev/null 2>&1; then + print_status "OK" "Preview extension is signed" + else + print_status "WARN" "Preview extension is unsigned (normal for development builds)" + fi + + # App signing status + if codesign -v "$FREECAD_APP" >/dev/null 2>&1; then + print_status "OK" "FreeCAD.app is signed" + else + print_status "WARN" "FreeCAD.app is unsigned (normal for development builds)" + fi + + # Optional tests (don't count toward pass/fail) + echo + print_status "INFO" "Additional Information:" + + # Show signing details if available + if command -v codesign >/dev/null 2>&1; then + echo + print_status "INFO" "Signing Details:" + + echo " FreeCAD.app:" + codesign -dv "$FREECAD_APP" 2>&1 | grep -E "(Identifier|Authority|Signature)" | head -3 | sed 's/^/ /' || echo " No signature information" + + echo " Thumbnail Extension:" + codesign -dv "$THUMBNAIL_EXT" 2>&1 | grep -E "(Identifier|Authority|Signature)" | head -3 | sed 's/^/ /' || echo " No signature information" + + echo " Preview Extension:" + codesign -dv "$PREVIEW_EXT" 2>&1 | grep -E "(Identifier|Authority|Signature)" | head -3 | sed 's/^/ /' || echo " No signature information" + fi + + # Show current registration status (if pluginkit is available) + if command -v pluginkit >/dev/null 2>&1; then + echo + print_status "INFO" "Current Registration Status:" + + if pluginkit -m -v -i "$THUMBNAIL_BUNDLE_ID" >/dev/null 2>&1; then + print_status "OK" "Thumbnail extension is registered with system" + else + print_status "WARN" "Thumbnail extension not registered (normal before first successful FreeCAD launch)" + fi + + if pluginkit -m -v -i "$PREVIEW_BUNDLE_ID" >/dev/null 2>&1; then + print_status "OK" "Preview extension is registered with system" + else + print_status "WARN" "Preview extension not registered (normal before first successful FreeCAD launch)" + fi + fi + + # Check for gatekeeper issues + echo + print_status "INFO" "Security Status:" + + if command -v spctl >/dev/null 2>&1; then + if spctl -a -v "$FREECAD_APP" >/dev/null 2>&1; then + print_status "OK" "FreeCAD.app passes Gatekeeper checks" + else + print_status "WARN" "FreeCAD.app rejected by Gatekeeper (normal for unsigned development builds)" + print_status "INFO" "You may need to: sudo xattr -rd com.apple.quarantine '$FREECAD_APP'" + fi + fi + + # Check for quarantine attributes + if xattr "$FREECAD_APP" 2>/dev/null | grep -q quarantine; then + print_status "WARN" "FreeCAD.app has quarantine attributes" + print_status "INFO" "Remove with: sudo xattr -rd com.apple.quarantine '$FREECAD_APP'" + else + print_status "OK" "No quarantine attributes found" + fi + + # Summary + echo + echo "Test Results:" + echo "============" + echo "Passed: $passed_tests/$total_tests core tests" + + if [[ $passed_tests -eq $total_tests ]]; then + print_status "OK" "All core tests passed! QuickLook extensions are properly built and integrated." + echo + echo "Next Steps:" + echo " 1. Ensure FreeCAD launches successfully to trigger extension registration" + echo " 2. Test QuickLook functionality with .FCStd files in Finder" + echo " 3. Look for system notification about Quick Look extensions being added" + return 0 + else + print_status "FAIL" "Some core tests failed. Please check the build configuration." + echo + echo "Troubleshooting:" + echo " 1. Ensure you're using Unix Makefiles generator: cmake -G 'Unix Makefiles'" + echo " 2. Check that FREECAD_CREATE_MAC_APP=ON in cmake configuration" + echo " 3. Run 'make install' to build and install FreeCAD with QuickLook extensions" + echo " 4. If FreeCAD crashes, try removing quarantine attributes or ad-hoc signing" + return 1 + fi +} + +# Test registration functionality (optional) +test_registration() { + if [[ "$1" == "--test-registration" ]]; then + echo + print_status "INFO" "Testing extension registration..." + + if command -v pluginkit >/dev/null 2>&1; then + print_status "INFO" "Attempting to register extensions manually..." + + # Try to register extensions + if pluginkit -a "$THUMBNAIL_EXT" >/dev/null 2>&1; then + print_status "OK" "Thumbnail extension registration command succeeded" + else + print_status "WARN" "Thumbnail registration command failed (may already be registered)" + fi + + if pluginkit -a "$PREVIEW_EXT" >/dev/null 2>&1; then + print_status "OK" "Preview extension registration command succeeded" + else + print_status "WARN" "Preview registration command failed (may already be registered)" + fi + + # Try to enable extensions + pluginkit -e use -i "$THUMBNAIL_BUNDLE_ID" >/dev/null 2>&1 || print_status "WARN" "Thumbnail activation command failed" + pluginkit -e use -i "$PREVIEW_BUNDLE_ID" >/dev/null 2>&1 || print_status "WARN" "Preview activation command failed" + + sleep 2 # Give system time to process + + # Check final status + if pluginkit -m -v -i "$THUMBNAIL_BUNDLE_ID" >/dev/null 2>&1; then + print_status "OK" "Thumbnail extension successfully registered and active" + else + print_status "FAIL" "Thumbnail extension registration failed" + fi + + if pluginkit -m -v -i "$PREVIEW_BUNDLE_ID" >/dev/null 2>&1; then + print_status "OK" "Preview extension successfully registered and active" + else + print_status "FAIL" "Preview extension registration failed" + fi + + echo + print_status "INFO" "Try testing with a .FCStd file in Finder now" + else + print_status "WARN" "pluginkit not available for registration testing" + fi + fi +} + +# Show usage information +show_usage() { + echo "Usage: $0 [--test-registration] [--help]" + echo + echo "Options:" + echo " --test-registration Also test extension registration with pluginkit" + echo " --help Show this help message" + echo + echo "This script tests the QuickLook extension integration in FreeCAD.app." + echo "Run this after building and installing FreeCAD: 'make install'" + echo + echo "The script will look for FreeCAD.app in common install locations relative to the script." +} + +# Handle command line arguments +case "${1:-}" in + --help) + show_usage + exit 0 + ;; + --test-registration) + main + test_registration "$1" + ;; + "") + main + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; +esac From ee29d8c915ae4d40bf17447a46bfabf315754c57 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 20:13:14 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/Gui/MainWindow.cpp | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Gui/MainWindow.cpp b/src/Gui/MainWindow.cpp index e4e5450511..40b9f3a811 100644 --- a/src/Gui/MainWindow.cpp +++ b/src/Gui/MainWindow.cpp @@ -1813,22 +1813,28 @@ void MainWindow::registerQuickLookExtensions() // Get the path to FreeCAD.app/Contents/PlugIns QString appPath = QApplication::applicationDirPath(); QString plugInsPath = appPath + "/../PlugIns"; - + QString thumbnailExt = plugInsPath + "/FreeCADThumbnailExtension.appex"; QString previewExt = plugInsPath + "/FreeCADPreviewExtension.appex"; - + // Check if extensions exist before attempting registration if (QFileInfo::exists(thumbnailExt) && QFileInfo::exists(previewExt)) { // Register extensions with pluginkit QProcess::execute("pluginkit", QStringList() << "-a" << thumbnailExt); QProcess::execute("pluginkit", QStringList() << "-a" << previewExt); - + // Activate extensions - QProcess::execute("pluginkit", QStringList() << "-e" << "use" << "-i" << "org.freecad.FreeCAD.quicklook.thumbnail"); - QProcess::execute("pluginkit", QStringList() << "-e" << "use" << "-i" << "org.freecad.FreeCAD.quicklook.preview"); - + QProcess::execute( + "pluginkit", + QStringList() << "-e" << "use" << "-i" << "org.freecad.FreeCAD.quicklook.thumbnail" + ); + QProcess::execute( + "pluginkit", + QStringList() << "-e" << "use" << "-i" << "org.freecad.FreeCAD.quicklook.preview" + ); + quickLookRegistered = true; - + // Optional: Log successful registration (will appear in system notification) Base::Console().log("QuickLook extensions registered successfully\n"); } From b2e9d879712a59bc3942df89b1a108f75221141c Mon Sep 17 00:00:00 2001 From: graelo Date: Tue, 18 Nov 2025 13:41:34 +0100 Subject: [PATCH 3/5] chore(sign): update macos signing script --- package/scripts/macos_sign_and_notarize.zsh | 38 ++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/package/scripts/macos_sign_and_notarize.zsh b/package/scripts/macos_sign_and_notarize.zsh index 80c90f4271..e1e2deeaa0 100755 --- a/package/scripts/macos_sign_and_notarize.zsh +++ b/package/scripts/macos_sign_and_notarize.zsh @@ -96,6 +96,13 @@ function run_codesign { /usr/bin/codesign --options runtime -f -s ${SIGNING_KEY_ID} --timestamp --entitlements entitlements.plist "$1" } +function run_codesign_extension { + local target="$1" + local entitlements_file="$2" + echo "Signing extension $target with entitlements $entitlements_file" + /usr/bin/codesign --options runtime -f -s ${SIGNING_KEY_ID} --timestamp --entitlements "$entitlements_file" "$target" +} + IFS=$'\n' dylibs=($(/usr/bin/find "${CONTAINING_FOLDER}/${APP_NAME}" -name "*.dylib")) shared_objects=($(/usr/bin/find "${CONTAINING_FOLDER}/${APP_NAME}" -name "*.so")) @@ -108,13 +115,42 @@ signed_files=("${dylibs[@]}" "${shared_objects[@]}" "${bundles[@]}" "${executabl # This list of files is generated from: # file `find . -type f -perm +111 -print` | grep "Mach-O 64-bit executable" | sed 's/:.*//g' for exe in ${signed_files}; do - run_codesign "${exe}" + # Skip .appex executables as they will be signed separately with their bundles + if [[ "$exe" != */Contents/PlugIns/*.appex/* ]]; then + run_codesign "${exe}" + fi done # Two additional files that must be signed that aren't caught by the above searches: run_codesign "${CONTAINING_FOLDER}/${APP_NAME}/Contents/packages.txt" run_codesign "${CONTAINING_FOLDER}/${APP_NAME}/Contents/Library/QuickLook/QuicklookFCStd.qlgenerator/Contents/MacOS/QuicklookFCStd" +# Sign new Swift QuickLook extensions (macOS 15.0+) with their specific entitlements +# These must be signed before the app itself to avoid overriding the extension signatures +if [ -d "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns" ]; then + # Find the entitlements files relative to script location + # Script is in package/scripts/, entitlements are in src/MacAppBundle/QuickLook/modern/ + SCRIPT_DIR="${0:A:h}" # zsh equivalent of dirname with full path resolution + PREVIEW_ENTITLEMENTS="${SCRIPT_DIR}/../../src/MacAppBundle/QuickLook/modern/PreviewExtension.entitlements" + THUMBNAIL_ENTITLEMENTS="${SCRIPT_DIR}/../../src/MacAppBundle/QuickLook/modern/ThumbnailExtension.entitlements" + + # Sign individual executables within .appex bundles first + if [ -f "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADThumbnailExtension.appex/Contents/MacOS/FreeCADThumbnailExtension" ]; then + run_codesign "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADThumbnailExtension.appex/Contents/MacOS/FreeCADThumbnailExtension" + fi + if [ -f "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADPreviewExtension.appex/Contents/MacOS/FreeCADPreviewExtension" ]; then + run_codesign "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADPreviewExtension.appex/Contents/MacOS/FreeCADPreviewExtension" + fi + + # Then sign the .appex bundles themselves with extension-specific entitlements + if [ -d "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADThumbnailExtension.appex" ] && [ -f "$THUMBNAIL_ENTITLEMENTS" ]; then + run_codesign_extension "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADThumbnailExtension.appex" "$THUMBNAIL_ENTITLEMENTS" + fi + if [ -d "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADPreviewExtension.appex" ] && [ -f "$PREVIEW_ENTITLEMENTS" ]; then + run_codesign_extension "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADPreviewExtension.appex" "$PREVIEW_ENTITLEMENTS" + fi +fi + # Finally, sign the app itself (must be done last) run_codesign "${CONTAINING_FOLDER}/${APP_NAME}" From 0307de98d41babccf7e9ace9ab3cbc99a975601a Mon Sep 17 00:00:00 2001 From: graelo Date: Fri, 16 Jan 2026 16:10:02 +0100 Subject: [PATCH 4/5] chore(clean): remove support for Qt5 in src/MacAppBundle/CmakeLists.txt --- src/MacAppBundle/CMakeLists.txt | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/MacAppBundle/CMakeLists.txt b/src/MacAppBundle/CMakeLists.txt index 09edde3e37..79e474829b 100644 --- a/src/MacAppBundle/CMakeLists.txt +++ b/src/MacAppBundle/CMakeLists.txt @@ -150,19 +150,13 @@ if(HOMEBREW_PREFIX) endforeach(PTH_FILE) endif() -if(FREECAD_QT_MAJOR_VERSION STREQUAL "6") - set(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../plugins") - # Fallback for Homebrew Qt6 layout - if(NOT EXISTS "${QT_PLUGINS_DIR}") - set(QT_PLUGINS_DIR "/opt/homebrew/share/qt/plugins") - endif() - set(QT_ASSISTANT_PATH "${Qt6Core_DIR}/../../../libexec/Assistant.app/Contents/MacOS/Assistant") - set(QT_PLUGIN_SUBDIRS "platforms;imageformats;styles;iconengines") -else() - set(QT_PLUGINS_DIR "${Qt5Core_DIR}/../../../plugins") - set(QT_ASSISTANT_PATH "${Qt5Core_DIR}/../../../libexec/Assistant.app/Contents/MacOS/Assistant") - set(QT_PLUGIN_SUBDIRS "platforms;imageformats;styles;iconengines") +set(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../plugins") +# Fallback for Homebrew Qt6 layout +if(NOT EXISTS "${QT_PLUGINS_DIR}") + set(QT_PLUGINS_DIR "/opt/homebrew/share/qt/plugins") endif() +set(QT_ASSISTANT_PATH "${Qt6Core_DIR}/../../../libexec/Assistant.app/Contents/MacOS/Assistant") +set(QT_PLUGIN_SUBDIRS "platforms;imageformats;styles;iconengines") execute_process(COMMAND "xcode-select" "--print-path" From 237b0a64b75c1e6ac75f5adfd31966c0488b7bbd Mon Sep 17 00:00:00 2001 From: graelo Date: Fri, 16 Jan 2026 22:25:55 +0100 Subject: [PATCH 5/5] fix(macos): check quicklook registration state before registering --- src/Gui/MainWindow.cpp | 58 ++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/src/Gui/MainWindow.cpp b/src/Gui/MainWindow.cpp index 40b9f3a811..70f5d207d5 100644 --- a/src/Gui/MainWindow.cpp +++ b/src/Gui/MainWindow.cpp @@ -1804,11 +1804,12 @@ void MainWindow::delayedStartup() #ifdef Q_OS_MAC void MainWindow::registerQuickLookExtensions() { - // Check if we've already registered extensions to avoid repeated registration - static bool quickLookRegistered = false; - if (quickLookRegistered) { + // Only check once per session + static bool quickLookChecked = false; + if (quickLookChecked) { return; } + quickLookChecked = true; // Get the path to FreeCAD.app/Contents/PlugIns QString appPath = QApplication::applicationDirPath(); @@ -1818,26 +1819,39 @@ void MainWindow::registerQuickLookExtensions() QString previewExt = plugInsPath + "/FreeCADPreviewExtension.appex"; // Check if extensions exist before attempting registration - if (QFileInfo::exists(thumbnailExt) && QFileInfo::exists(previewExt)) { - // Register extensions with pluginkit - QProcess::execute("pluginkit", QStringList() << "-a" << thumbnailExt); - QProcess::execute("pluginkit", QStringList() << "-a" << previewExt); - - // Activate extensions - QProcess::execute( - "pluginkit", - QStringList() << "-e" << "use" << "-i" << "org.freecad.FreeCAD.quicklook.thumbnail" - ); - QProcess::execute( - "pluginkit", - QStringList() << "-e" << "use" << "-i" << "org.freecad.FreeCAD.quicklook.preview" - ); - - quickLookRegistered = true; - - // Optional: Log successful registration (will appear in system notification) - Base::Console().log("QuickLook extensions registered successfully\n"); + if (!QFileInfo::exists(thumbnailExt) || !QFileInfo::exists(previewExt)) { + return; } + + // Check if extensions are already registered with pluginkit + QProcess checkProcess; + checkProcess.start("pluginkit", QStringList() << "-m"); + checkProcess.waitForFinished(); + QString registeredPlugins = QString::fromUtf8(checkProcess.readAllStandardOutput()); + + const QString thumbnailId = QStringLiteral("org.freecad.FreeCAD.quicklook.thumbnail"); + const QString previewId = QStringLiteral("org.freecad.FreeCAD.quicklook.preview"); + + bool thumbnailRegistered = registeredPlugins.contains(thumbnailId); + bool previewRegistered = registeredPlugins.contains(previewId); + + if (thumbnailRegistered && previewRegistered) { + Base::Console().log("QuickLook extensions already registered\n"); + return; + } + + // Register and activate only the extensions that are not yet registered + if (!thumbnailRegistered) { + QProcess::execute("pluginkit", QStringList() << "-a" << thumbnailExt); + QProcess::execute("pluginkit", QStringList() << "-e" << "use" << "-i" << thumbnailId); + } + + if (!previewRegistered) { + QProcess::execute("pluginkit", QStringList() << "-a" << previewExt); + QProcess::execute("pluginkit", QStringList() << "-e" << "use" << "-i" << previewId); + } + + Base::Console().log("QuickLook extensions registered successfully\n"); } #endif