[osx] Add QuickLook plugin to generate thumbnails and previews (#834, #3615)

This commit is contained in:
David Capello 2022-12-08 12:05:17 -03:00
parent 6bef687401
commit 3a8596d02b
9 changed files with 533 additions and 6 deletions

View File

@ -1,12 +1,14 @@
# Desktop Integration
# Copyright (C) 2022 Igara Studio S.A.
# Copyright (C) 2017-2018 David Capello
# Windows
if(WIN32)
# Thumbnails for Windows File Explorer
add_subdirectory(win)
endif()
# Linux-like
if(UNIX AND NOT APPLE)
elseif(APPLE)
# QuickLook preview / macOS Finder
add_subdirectory(osx)
else(UNIX)
# Linux-like
add_subdirectory(linux)
endif()

View File

@ -1,4 +1,4 @@
Copyright (C) 2021 Igara Studio S.A.
Copyright (C) 2021-2022 Igara Studio S.A.
Copyright (C) 2017-2018 David Capello
Permission is hereby granted, free of charge, to any person obtaining

View File

@ -22,3 +22,28 @@ create registry keys to associate your file type extension with
your thumbnail handler.
More information [in the MSDN](https://msdn.microsoft.com/en-us/library/windows/desktop/cc144118.aspx).
## macOS
On macOS we have to create a QuickLook plugin/extension to display
thumbnails and previews. The plugin is a `.qlgenerator` bundle which
can be installed in the `~/Library/QuickLook`,
`/System/Library/QuickLook`, or `/Library/QuickLook` directories, or
included the in the same app bundle.
[macOS pre-10.15](https://developer.apple.com/documentation/quicklook/previews_or_thumbnail_images_for_macos_10_14_or_earlier?language=objc),
has a COM-like functionality to load QuickLook plugins: the QuickLook
daemon will use the information in our bundle
[Info.plist](osx/Info.plist) to know which function to load and call
from our library to create the plugin object. (The function generally
is called `QuickLookGeneratorPluginFactory` but can has any name, the
name is specified in a child element of `CFPlugInFactories` inside the
`Info.plist` file.) Then the created object is used abstractly as a
`IUnknown`, and the `QLGeneratorInterface` interface is queried to
generate then thumbnails and previews.
We can test our `.qlgenerator` without installing it using the
[`qlmanage` utility](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/Quicklook_Programming_Guide/Articles/QLDebugTest.html).
We target to macOS 10.9, but [we should migrate](https://developer.apple.com/videos/play/wwdc2019/719?time=944)
to the new macOS 10.15 API in a near future (or provide both).

View File

@ -0,0 +1,10 @@
<?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>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,37 @@
# Desktop Integration
# Copyright (c) 2022 Igara Studio S.A.
find_library(QUARTZ_LIBRARY Quartz)
add_library(AsepriteThumbnailer SHARED
main.mm
thumbnail.mm)
target_link_libraries(AsepriteThumbnailer
laf-base
dio-lib
render-lib
${QUARTZ_LIBRARY})
set_target_properties(AsepriteThumbnailer PROPERTIES
FRAMEWORK TRUE
MACOSX_FRAMEWORK_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist")
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/lib/AsepriteThumbnailer.qlgenerator
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMAND ${CMAKE_COMMAND} -E make_directory lib/AsepriteThumbnailer.qlgenerator
COMMAND ${CMAKE_COMMAND} -E make_directory lib/AsepriteThumbnailer.qlgenerator/Contents
COMMAND ${CMAKE_COMMAND} -E make_directory lib/AsepriteThumbnailer.qlgenerator/Contents/MacOS
COMMAND ${CMAKE_COMMAND} -E copy
lib/AsepriteThumbnailer.framework/Versions/A/AsepriteThumbnailer
lib/AsepriteThumbnailer.qlgenerator/Contents/MacOS
COMMAND ${CMAKE_COMMAND} -E copy
lib/AsepriteThumbnailer.framework/Versions/A/Resources/Info.plist
lib/AsepriteThumbnailer.qlgenerator/Contents
BYPRODUCTS ${CMAKE_BINARY_DIR}/lib/AsepriteThumbnailer.qlgenerator/Contents/MacOS/AsepriteThumbnailer
${CMAKE_BINARY_DIR}/lib/AsepriteThumbnailer.qlgenerator/Contents/Info.plist
DEPENDS AsepriteThumbnailer)
add_custom_target(AsepriteThumbnailer.qlgenerator
DEPENDS ${CMAKE_BINARY_DIR}/lib/AsepriteThumbnailer.qlgenerator)

View File

@ -0,0 +1,58 @@
<?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>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>QLGenerator</string>
<key>LSItemContentTypes</key>
<array>
<string>dyn.ah62d4rv4ge80c65f</string>
<string>dyn.ah62d4rv4ge80c65fsb3gw7df</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>AsepriteThumbnailer</string>
<key>CFBundleIdentifier</key>
<string>org.aseprite.AsepriteThumbnailer</string>
<key>CFBundleName</key>
<string>AsepriteThumbnailer</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFPlugInFactories</key>
<dict>
<key>A5E9417E-6E7A-4B2D-85A4-84E114D7A960</key>
<string>QuickLookGeneratorPluginFactory</string>
</dict>
<key>CFPlugInTypes</key>
<dict>
<key>5E2D9680-5022-40FA-B806-43349622E5B9</key>
<array>
<string>A5E9417E-6E7A-4B2D-85A4-84E114D7A960</string>
</array>
</dict>
<key>CFPlugInUnloadFunction</key>
<string></string>
<key>QLThumbnailMinimumSize</key>
<real>32</real>
<key>QLPreviewWidth</key>
<real>256</real>
<key>QLPreviewHeight</key>
<real>256</real>
<key>QLNeedsToBeRunInMainThread</key>
<true/>
<key>QLSupportsConcurrentRequests</key>
<false/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © Igara Studio S.A. All rights reserved.</string>
</dict>
</plist>

209
src/desktop/osx/main.mm Normal file
View File

@ -0,0 +1,209 @@
// Desktop Integration
// Copyright (c) 2022 Igara Studio S.A.
//
// This file is released under the terms of the MIT license.
// Read LICENSE.txt for more information.
#include <CoreFoundation/CFPlugInCOM.h>
#include <CoreFoundation/CoreFoundation.h>
#include <CoreServices/CoreServices.h>
#include <QuickLook/QuickLook.h>
#include "base/debug.h"
#include "thumbnail.h"
// Just as a side note: We're using the same UUID as the Windows
// Aseprite thumbnailer.
//
// If you're going to use this code, remember to change this UUID and
// change it in the Info.plist file.
#define PLUGIN_ID "A5E9417E-6E7A-4B2D-85A4-84E114D7A960"
static HRESULT Plugin_QueryInterface(void*, REFIID, LPVOID*);
static ULONG Plugin_AddRef(void*);
static ULONG Plugin_Release(void*);
static OSStatus Plugin_GenerateThumbnailForURL(void*, QLThumbnailRequestRef, CFURLRef, CFStringRef, CFDictionaryRef, CGSize);
static void Plugin_CancelThumbnailGeneration(void*, QLThumbnailRequestRef);
static OSStatus Plugin_GeneratePreviewForURL(void*, QLPreviewRequestRef, CFURLRef, CFStringRef, CFDictionaryRef);
static void Plugin_CancelPreviewGeneration(void*, QLPreviewRequestRef);
static QLGeneratorInterfaceStruct Plugin_vtbl = { // kQLGeneratorTypeID interface
// IUnknown
nullptr, // void* reserved
Plugin_QueryInterface,
Plugin_AddRef,
Plugin_Release,
// QLGeneratorInterface
Plugin_GenerateThumbnailForURL,
Plugin_CancelThumbnailGeneration,
Plugin_GeneratePreviewForURL,
Plugin_CancelPreviewGeneration
};
// TODO it would be nice to create a C++ smart pointer/wrapper for CFUUIDRef type
struct Plugin {
QLGeneratorInterfaceStruct* interface; // Must be a pointer
CFUUIDRef factoryID;
ULONG refCount = 1; // Starts with one reference when it's created
Plugin(CFUUIDRef factoryID)
: interface(new QLGeneratorInterfaceStruct(Plugin_vtbl))
, factoryID(factoryID) {
CFPlugInAddInstanceForFactory(factoryID);
}
~Plugin() {
delete interface;
if (factoryID) {
CFPlugInRemoveInstanceForFactory(factoryID);
CFRelease(factoryID);
}
}
// IUnknown impl
HRESULT QueryInterface(REFIID iid, LPVOID* ppv) {
CFUUIDRef interfaceID = CFUUIDCreateFromUUIDBytes(kCFAllocatorDefault, iid);
if (CFEqual(interfaceID, kQLGeneratorCallbacksInterfaceID)) {
*ppv = this;
AddRef();
CFRelease(interfaceID);
return S_OK;
}
else {
*ppv = nullptr;
CFRelease(interfaceID);
return E_NOINTERFACE;
}
}
ULONG AddRef() {
return ++refCount;
}
ULONG Release() {
if (refCount == 1) {
delete this;
return 0;
}
else {
ASSERT(refCount != 0);
return --refCount;
}
}
// QLGeneratorInterfaceStruct impl
static OSStatus GenerateThumbnailForURL(QLThumbnailRequestRef thumbnail,
CFURLRef url,
CFStringRef contentTypeUTI,
CFDictionaryRef options,
CGSize maxSize) {
CGImageRef image = desktop::get_thumbnail(url, options, maxSize);
if (!image)
return -1;
QLThumbnailRequestSetImage(thumbnail, image, nullptr);
CGImageRelease(image);
return 0;
}
static void CancelThumbnailGeneration(QLThumbnailRequestRef thumbnail) {
// TODO
}
OSStatus GeneratePreviewForURL(QLPreviewRequestRef preview,
CFURLRef url,
CFStringRef contentTypeUTI,
CFDictionaryRef options) {
CGImageRef image = desktop::get_thumbnail(url, options, CGSizeMake(0, 0));
if (!image)
return -1;
int w = CGImageGetWidth(image);
int h = CGImageGetHeight(image);
int wh = std::min(w, h);
if (wh < 128) {
w = 128 * w / wh;
h = 128 * h / wh;
}
CGContextRef cg = QLPreviewRequestCreateContext(preview, CGSizeMake(w, h), YES, options);
CGContextSetInterpolationQuality(cg, kCGInterpolationNone);
CGContextDrawImage(cg, CGRectMake(0, 0, w, h), image);
QLPreviewRequestFlushContext(preview, cg);
CGImageRelease(image);
CGContextRelease(cg);
return 0;
}
void CancelPreviewGeneration(QLPreviewRequestRef preview) {
// TODO
}
};
static HRESULT Plugin_QueryInterface(void* p, REFIID iid, LPVOID* ppv)
{
ASSERT(p);
return reinterpret_cast<Plugin*>(p)->QueryInterface(iid, ppv);
}
static ULONG Plugin_AddRef(void* p)
{
ASSERT(p);
return reinterpret_cast<Plugin*>(p)->AddRef();
}
static ULONG Plugin_Release(void* p)
{
ASSERT(p);
return reinterpret_cast<Plugin*>(p)->Release();
}
static OSStatus Plugin_GenerateThumbnailForURL(void* p, QLThumbnailRequestRef thumbnail, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options, CGSize maxSize)
{
ASSERT(p);
return reinterpret_cast<Plugin*>(p)->GenerateThumbnailForURL(thumbnail, url, contentTypeUTI, options, maxSize);
}
static void Plugin_CancelThumbnailGeneration(void* p, QLThumbnailRequestRef thumbnail)
{
ASSERT(p);
reinterpret_cast<Plugin*>(p)->CancelThumbnailGeneration(thumbnail);
}
static OSStatus Plugin_GeneratePreviewForURL(void* p, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options)
{
ASSERT(p);
return reinterpret_cast<Plugin*>(p)->GeneratePreviewForURL(preview, url, contentTypeUTI, options);
}
static void Plugin_CancelPreviewGeneration(void* p, QLPreviewRequestRef preview)
{
ASSERT(p);
reinterpret_cast<Plugin*>(p)->CancelPreviewGeneration(preview);
}
// This is the only public entry point of the framework/plugin (the
// "QuickLookGeneratorPluginFactory" name is specified in the
// Info.list file): the factory of objects. Similar than the Win32 COM
// IClassFactory::CreateInstance()
//
// This function is used to create an instance of an object of
// kQLGeneratorTypeID type, which should implement the
// QLGeneratorInterfaceStruct interface.
extern "C" void* QuickLookGeneratorPluginFactory(CFAllocatorRef allocator,
CFUUIDRef typeID)
{
if (CFEqual(typeID, kQLGeneratorTypeID)) {
CFUUIDRef uuid = CFUUIDCreateFromString(kCFAllocatorDefault, CFSTR(PLUGIN_ID));
auto plugin = new Plugin(uuid);
CFRelease(uuid);
return plugin;
}
return nullptr; // Unknown typeID
}

View File

@ -0,0 +1,21 @@
// Desktop Integration
// Copyright (c) 2022 Igara Studio S.A.
//
// This file is released under the terms of the MIT license.
// Read LICENSE.txt for more information.
#ifndef DESKTOP_THUMBNAIL_H_INCLUDED
#define DESKTOP_THUMBNAIL_H_INCLUDED
#include <CoreFoundation/CoreFoundation.h>
#include <CoreGraphics/CoreGraphics.h>
namespace desktop {
CGImageRef get_thumbnail(CFURLRef url,
CFDictionaryRef options,
CGSize maxSize);
} // namespace desktop
#endif

View File

@ -0,0 +1,165 @@
// Desktop Integration
// Copyright (c) 2022 Igara Studio S.A.
//
// This file is released under the terms of the MIT license.
// Read LICENSE.txt for more information.
#include "thumbnail.h"
#include "dio/decode_delegate.h"
#include "dio/decode_file.h"
#include "dio/file_interface.h"
#include "doc/sprite.h"
#include "render/render.h"
#include <Cocoa/Cocoa.h>
#include <algorithm>
namespace desktop {
namespace {
class DecodeDelegate : public dio::DecodeDelegate {
public:
DecodeDelegate() : m_sprite(nullptr) { }
~DecodeDelegate() { delete m_sprite; }
bool decodeOneFrame() override { return true; }
void onSprite(doc::Sprite* sprite) override {
m_sprite = sprite;
}
doc::Sprite* sprite() { return m_sprite; }
private:
doc::Sprite* m_sprite;
};
class StreamAdaptor : public dio::FileInterface {
public:
StreamAdaptor(NSData* data)
: m_data(data)
, m_ok(m_data != nullptr)
, m_pos(0) {
}
bool ok() const {
return m_ok;
}
size_t tell() {
return m_pos;
}
void seek(size_t absPos) {
m_pos = absPos;
}
uint8_t read8() {
if (!m_ok)
return 0;
if (m_pos < m_data.length)
return ((const uint8_t*)m_data.bytes)[m_pos++];
else {
m_ok = false;
return 0;
}
}
size_t readBytes(uint8_t* buf, size_t n) {
if (!m_ok)
return 0;
if (m_pos < m_data.length) {
n = std::min(n, m_data.length - m_pos);
memcpy(buf, ((const uint8_t*)m_data.bytes)+m_pos, n);
m_pos += n;
return n;
}
else {
m_ok = false;
return 0;
}
}
void write8(uint8_t value) {
// Do nothing, we don't write in the file
}
NSData* m_data;
bool m_ok;
size_t m_pos;
};
} // anonymous namespace
CGImageRef get_thumbnail(CFURLRef url,
CFDictionaryRef options,
CGSize maxSize)
{
auto data = [[NSData alloc] initWithContentsOfURL:(NSURL*)url];
if (!data)
return nullptr;
doc::ImageRef image;
int w, h;
try {
DecodeDelegate delegate;
StreamAdaptor adaptor(data);
if (!dio::decode_file(&delegate, &adaptor))
return nullptr;
const doc::Sprite* spr = delegate.sprite();
if (!spr)
return nullptr;
w = spr->width();
h = spr->height();
int wh = std::max<int>(w, h);
int cx;
if (maxSize.width && maxSize.height)
cx = std::min<int>(maxSize.width, maxSize.height);
else
cx = wh;
image.reset(doc::Image::create(doc::IMAGE_RGB,
cx * w / wh,
cx * h / wh));
image->clear(0);
render::Render render;
render.setBgOptions(render::BgOptions::MakeTransparent());
render.setProjection(render::Projection(doc::PixelRatio(1, 1),
render::Zoom(cx, wh)));
render.renderSprite(image.get(), spr, 0,
gfx::ClipF(0, 0, 0, 0,
image->width(), image->height()));
w = image->width();
h = image->height();
}
catch (const std::exception& e) {
NSLog(@"AsepriteThumbnailer error: %s", e.what());
return nullptr;
}
// TODO Premultiply alpha because CGBitmapContextCreate doesn't
// support unpremultiplied alpha (kCGImageAlphaFirst).
CGColorSpaceRef cs = CGColorSpaceCreateWithName(kCGColorSpaceGenericRGB);
CGContextRef gc = CGBitmapContextCreate(
image->getPixelAddress(0, 0),
w, h, 8, image->getRowStrideSize(), cs,
kCGImageAlphaPremultipliedLast);
CGColorSpaceRelease(cs);
CGImageRef img = CGBitmapContextCreateImage(gc);
CGContextRelease(gc);
return img;
}
} // namespace desktop