macOS/QuickLook: support rich thumbnail in Finder

Support thumbnail that shows the file contents instead
of the default blend file icon for all files in Finder.
Some files may still have the usual blender icon as thumbnail
depending on their contents.

blender-thumbnailer process is kept alive by the system
in the background and is invoked by QuickLook when needed.

The checkbox to disable the extension is present in
the Settings app.

It will NOT be an interactive "Preview" that allows richer
interactions like Panning viewport, or rotating 3D objects.
This commit is contained in:
Ankit Meel 2025-01-23 17:00:19 +01:00
parent 92cd4ca295
commit b858319dd3
8 changed files with 305 additions and 12 deletions

View file

@ -216,20 +216,18 @@ Build \"BlendThumb.dll\" helper for Windows explorer integration to support extr
thumbnails from `.blend` files."
ON
)
else()
set(_option_default ON)
if(APPLE)
# In future, can be used with `quicklookthumbnailing/qlthumbnailreply`
# to create file thumbnails for say Finder.
# Turn it off for now, even though it can build on APPLE, it's not likely to be useful.
set(_option_default OFF)
endif()
elseif(UNIX AND NOT APPLE)
option(WITH_BLENDER_THUMBNAILER "\
Build stand-alone \"blender-thumbnailer\" command-line thumbnail extraction utility, \
intended for use by file-managers to extract PNG images from `.blend` files."
${_option_default}
ON
)
elseif(APPLE)
option(WITH_BLENDER_THUMBNAILER "\
Build \"blender-thumbnailer.appex\" extension for macOS Finder/ QuickLook to support viewing \
thumbnails from `.blend` files."
ON
)
unset(_option_default)
endif()
option(WITH_INTERNATIONAL "Enable I18N (International fonts and text)" ON)

View file

@ -69,6 +69,7 @@ set(WITH_MEM_JEMALLOC ON CACHE BOOL "" FORCE)
if(APPLE)
set(WITH_COREAUDIO ON CACHE BOOL "" FORCE)
set(WITH_CYCLES_DEVICE_METAL ON CACHE BOOL "" FORCE)
set(WITH_BLENDER_THUMBNAILER ON CACHE BOOL "" FORCE)
endif()
if(WIN32)
set(WITH_WASAPI ON CACHE BOOL "" FORCE)

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleName</key>
<string>blender-thumbnailer</string>
<key>CFBundleDisplayName</key>
<string>Blender Thumbnailer</string>
<key>CFBundleExecutable</key>
<string>blender-thumbnailer</string>
<key>CFBundleIdentifier</key>
<string>org.blenderfoundation.blender.thumbnailer</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CFBundleShortVersionString</key>
<string>${MACOSX_BUNDLE_SHORT_VERSION_STRING}</string>
<key>CFBundleVersion</key>
<string>${MACOSX_BUNDLE_LONG_VERSION_STRING}</string>
<key>CFBundleGetInfoString</key>
<string>${MACOSX_BUNDLE_LONG_VERSION_STRING}, Blender Foundation</string>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>QLSupportedContentTypes</key>
<array>
<!-- The supported file UTIs. Not inherited from parent bundle. -->
<string>org.blenderfoundation.blender.file</string>
</array>
<key>QLThumbnailMinimumDimension</key>
<integer>0</integer>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.quicklook.thumbnail</string>
<key>NSExtensionPrincipalClass</key>
<!-- Must be the same as the class implementing the reply method. -->
<string>ThumbnailProvider</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Extension must be codesigned even locally. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- XCode adds this by default to a QuickLook extension. -->
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

View file

@ -41,7 +41,37 @@ if(WIN32)
set_target_properties(BlendThumb PROPERTIES LINK_FLAGS_DEBUG "/NODEFAULTLIB:msvcrt")
set_target_properties(BlendThumb PROPERTIES VS_GLOBAL_VcpkgEnabled "false")
else()
elseif(APPLE)
# -----------------------------------------------------------------------------
# Build `blender-thumbnailer.appex` app extension.
set(SRC_APPEX
src/thumbnail_provider.mm
src/thumbnail_provider.h
)
add_executable(blender-thumbnailer MACOSX_BUNDLE ${SRC} ${SRC_APPEX})
setup_platform_linker_flags(blender-thumbnailer)
setup_platform_linker_libs(blender-thumbnailer)
target_link_libraries(blender-thumbnailer
bf_blenlib
# Avoid linker error about undefined _main symbol.
"-e _NSExtensionMain"
"-framework QuickLookThumbnailing"
)
# CMake needs the target defined in the same file as add_custom_command.
# The rpath here points to the main Blender Resources/lib directory. Avoid duplicating the large dylibs (~300MB).
add_custom_command(
TARGET blender-thumbnailer POST_BUILD
COMMAND install_name_tool -add_rpath @loader_path/../../../../Resources/lib $<TARGET_FILE:blender-thumbnailer>
)
# It needs to be codesigned (ad-hoc in this case) even on developer machine to generate thumbnails.
# Command taken from XCode build process.
add_custom_command(
TARGET blender-thumbnailer POST_BUILD
COMMAND codesign --deep --force --sign - --entitlements "${CMAKE_SOURCE_DIR}/release/darwin/thumbnailer_entitlements.plist"
--timestamp=none $<TARGET_BUNDLE_DIR:blender-thumbnailer>
)
elseif(UNIX)
# -----------------------------------------------------------------------------
# Build `blender-thumbnailer` executable

View file

@ -0,0 +1,12 @@
/* SPDX-License-Identifier: GPL-2.0-or-later */
#pragma once
#import <QuickLookThumbnailing/QuickLookThumbnailing.h>
NS_ASSUME_NONNULL_BEGIN
@interface ThumbnailProvider : QLThumbnailProvider
@end
NS_ASSUME_NONNULL_END

View file

@ -0,0 +1,174 @@
/* SPDX-License-Identifier: GPL-2.0-or-later */
#import <AppKit/NSImage.h>
#include "BLI_fileops.h"
#include "BLI_filereader.h"
#include "BLI_utility_mixins.hh"
#include "blendthumb.hh"
#include "thumbnail_provider.h"
/**
* This section intends to list the important steps for creating a thumbnail extension.
* qlgenerator has been deprecated and removed in platforms we support. App extensions are the way
* forward. But there's little guidance on how to do it outside Xcode.
*
* The process of thumbnail generation goes something like this:
* 1. If an app is launched, or is registered with lsregister, its plugins also get registered.
* 2. When a file thumbnail in Finder or QuickLook is requested, the system looks for a plugin
* that supports the file type UTI.
* 3. The plugin is launched in a sandboxed environment and should call the handler with a reply.
*
* # Plugin Info.plist
* The Info.plist file should be properly configured with supported content type.
*
* # Codesigning
* The plugin should be codesigned with entitlements at least for sandbox and read-only/
* read-write (for access to the given file). It's needed to even run the plugin locally.
* com.apple.security.get-task-allow entitlement is required for debugging.
*
* # Registering the plugin
* The plugin should be registered with lsregister. Either by calling lsregister or by launching
* the parent app.
* /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
-dump | grep blender-thumbnailer
*
* # Debugging
* Since read-only entitlement is there, creating files to log is not possible. So NSLog and
* viewing it in Console.app (after triggering a thumbnail) is the way to go. Interesting processes
* are: qlmanage, quicklookd, kernel, blender-thumbnailer, secinitd,
* com.apple.quicklook.ThumbnailsAgent
*
* LLDB/ Xcode etc., debuggers can be used to get extra logs than CLI invocation but breakpoints
* still are a pain point. /usr/bin/qlmanage is the target executable. Other args to qlmanage
* follow.
*
* # Troubleshooting
* - The appex shouldn't have any quarantine flag.
xattr -rl bin/Blender.app/Contents/Plugins/blender-thumbnailer.appex
* - Is it registered with lsregister and there isn't a conflict with another plugin taking
* precedence? lsregister -dump | grep blender-thumbnailer.appex
* - For RBSLaunchRequest error: is the executable executable? chmod u+x
bin/Blender.app/Contents/PlugIns/blender-thumbnailer.appex/Contents/MacOS/blender-thumbnailer
* - Is it codesigned and sandboxed?
* codesign --display --verbose --entitlements - --xml \
bin/Blender.app/Contents/Plugins/blender-thumbnailer.appex codesign --deep --force --sign - \
--entitlements ../blender/release/darwin/thumbnailer_entitlements.plist --timestamp=none \
bin/Blender.app/Contents/Plugins/blender-thumbnailer.appex
* - Sometimes blender-thumbnailer running in background can be killed.
* - qlmanage -r && killall Finder
* - The code cannot attempt to do anything outside sandbox like writing to blend.
*
* # Triggering a thumbnail
* - qlmanage -t -s 512 -o /tmp/ /path/to/file.blend
*
* # External resources
* https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/Quicklook_Programming_Guide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40005020-CH1-SW1
*/
class FileDescriptorRAII : blender::NonCopyable, blender::NonMovable {
private:
int src_fd = -1;
public:
explicit FileDescriptorRAII(const char *file_path)
{
src_fd = BLI_open(file_path, O_BINARY | O_RDONLY, 0);
}
~FileDescriptorRAII()
{
if (good()) {
int ok = close(src_fd);
if (!ok) {
NSLog(@"Blender Thumbnailer Error: Failed to close the blend file.");
}
}
}
bool good()
{
return src_fd > 0;
}
int get()
{
return src_fd;
}
};
static NSError *create_nserror_from_string(NSString *errorStr)
{
NSLog(@"Blender Thumbnailer Error: %@", errorStr);
return [NSError errorWithDomain:@"org.blenderfoundation.blender.thumbnailer"
code:-1
userInfo:@{NSLocalizedDescriptionKey : errorStr}];
}
static NSImage *generate_nsimage_for_file(const char *src_blend_path, NSError *error)
{
/* Open source file `src_blend`. */
FileDescriptorRAII src_file_fd = FileDescriptorRAII(src_blend_path);
if (!src_file_fd.good()) {
error = create_nserror_from_string(@"Failed to open blend");
return nil;
}
FileReader *file_content = BLI_filereader_new_file(src_file_fd.get());
if (file_content == nullptr) {
error = create_nserror_from_string(@"Failed to read from blend");
return nil;
}
/* Extract thumbnail from file. */
Thumbnail thumb;
eThumbStatus err = blendthumb_create_thumb_from_file(file_content, &thumb);
if (err != BT_OK) {
error = create_nserror_from_string(@"Failed to create thumbnail from file");
return nil;
}
std::optional<blender::Vector<uint8_t>> png_buf_opt = blendthumb_create_png_data_from_thumb(
&thumb);
if (!png_buf_opt) {
error = create_nserror_from_string(@"Failed to create png data from thumbnail");
return nil;
}
NSData *ns_data = [NSData dataWithBytes:png_buf_opt->data() length:png_buf_opt->size()];
NSImage *ns_image = [[NSImage alloc] initWithData:ns_data];
return ns_image;
}
@implementation ThumbnailProvider
- (void)provideThumbnailForFileRequest:(QLFileThumbnailRequest *)request
completionHandler:(void (^)(QLThumbnailReply *_Nullable reply,
NSError *_Nullable error))handler
{
NSLog(@"Generating thumbnail for %@", request.fileURL.path);
@autoreleasepool {
NSError *error = nil;
NSImage *ns_image = generate_nsimage_for_file(request.fileURL.path.fileSystemRepresentation,
error);
if (ns_image == nil) {
handler(nil, error);
return;
}
handler([QLThumbnailReply replyWithContextSize:request.maximumSize
currentContextDrawingBlock:^BOOL {
[ns_image drawInRect:NSMakeRect(0,
0,
request.maximumSize.width,
request.maximumSize.height)];
// Release the ns_image that was strongly captured by the block.
[ns_image release];
return YES;
}],
nil);
}
NSLog(@"Thumbnail generation succcessfully completed");
}
@end

View file

@ -325,6 +325,9 @@ if(WITH_PYTHON_MODULE)
if(APPLE)
set_target_properties(blender PROPERTIES MACOSX_BUNDLE TRUE)
if(WITH_BLENDER_THUMBNAILER)
set_target_properties(blender-thumbnailer PROPERTIES MACOSX_BUNDLE TRUE)
endif()
endif()
if(WIN32)
@ -438,6 +441,9 @@ elseif(APPLE)
set(TARGETDIR_SITE_PACKAGES "${TARGETDIR_VER}/python/lib/python${PYTHON_VERSION}/site-packages")
# Skip re-linking on CPACK / install.
set_target_properties(blender PROPERTIES BUILD_WITH_INSTALL_RPATH true)
if(WITH_BLENDER_THUMBNAILER)
set_target_properties(blender-thumbnailer PROPERTIES BUILD_WITH_INSTALL_RPATH true)
endif()
endif()
@ -1581,6 +1587,16 @@ elseif(APPLE)
MACOSX_BUNDLE_LONG_VERSION_STRING "${BLENDER_VERSION}.${BLENDER_VERSION_PATCH} ${BLENDER_DATE}"
)
if(WITH_BLENDER_THUMBNAILER)
set(OSX_THUMBNAILER_SOURCEDIR ${OSX_APP_SOURCEDIR}/Contents/PlugIns/blender-thumbnailer.appex)
set_target_properties(blender-thumbnailer PROPERTIES
BUNDLE_EXTENSION appex
MACOSX_BUNDLE_INFO_PLIST ${OSX_THUMBNAILER_SOURCEDIR}/Contents/Info.plist
MACOSX_BUNDLE_SHORT_VERSION_STRING "${BLENDER_VERSION}.${BLENDER_VERSION_PATCH}"
MACOSX_BUNDLE_LONG_VERSION_STRING "${BLENDER_VERSION}.${BLENDER_VERSION_PATCH} ${BLENDER_DATE}"
)
endif()
# Gather the date in finder-style.
execute_process(
COMMAND date "+%m/%d/%Y/%H:%M"
@ -1617,7 +1633,7 @@ elseif(APPLE)
if(WITH_BLENDER_THUMBNAILER)
install(
TARGETS blender-thumbnailer
DESTINATION "Blender.app/Contents/MacOS"
DESTINATION "./Blender.app/Contents/PlugIns"
)
endif()