From 45a4cb11a7f58b726bd57ebbbe0ceedeacdb2d87 Mon Sep 17 00:00:00 2001 From: windingwind <33902321+windingwind@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:40:42 +0200 Subject: [PATCH] Implement MacOS Spotlight Support Use a in-app dylib for indexing control Patch the xul lib for spotlight action handling --- app/build.sh | 27 +- app/mac/Contents/Info.plist | 4 + app/mac/mozilla-140.patch | 33 + app/mac/spotlight/ZoteroSpotlight.m | 385 +++++++ .../content/zotero/ZoteroProtocolHandler.mjs | 74 +- .../preferences/preferences_advanced.js | 4 + .../preferences/preferences_advanced.xhtml | 97 +- .../preferences/preferences_spotlight.js | 194 ++++ chrome/content/zotero/xpcom/attachments.js | 89 +- .../zotero/xpcom/commandLineHandler.js | 7 + .../content/zotero/xpcom/preferencePanes.js | 5 +- chrome/content/zotero/xpcom/spotlight.js | 967 ++++++++++++++++++ chrome/content/zotero/xpcom/zotero.js | 5 +- chrome/content/zotero/zotero.mjs | 1 + chrome/locale/en-US/zotero/preferences.ftl | 29 + defaults/preferences/zotero.js | 11 + test/tests/spotlightTest.js | 580 +++++++++++ 17 files changed, 2478 insertions(+), 34 deletions(-) create mode 100644 app/mac/mozilla-140.patch create mode 100644 app/mac/spotlight/ZoteroSpotlight.m create mode 100644 chrome/content/zotero/preferences/preferences_spotlight.js create mode 100644 chrome/content/zotero/xpcom/spotlight.js create mode 100644 test/tests/spotlightTest.js diff --git a/app/build.sh b/app/build.sh index ff1e862e16..3707b6cee1 100755 --- a/app/build.sh +++ b/app/build.sh @@ -727,7 +727,20 @@ if [ $BUILD_MAC == 1 ]; then mkdir "$CONTENTSDIR/Resources/integration" cp -RH "$CALLDIR/modules/zotero-libreoffice-integration/install" "$CONTENTSDIR/Resources/integration/libreoffice" cp -RH "$CALLDIR/modules/zotero-word-for-mac-integration/install" "$CONTENTSDIR/Resources/integration/word-for-mac" - + + # Build the Core Spotlight bridge dylib + if command -v clang >/dev/null 2>&1; then + clang -dynamiclib -fobjc-arc -O2 -mmacosx-version-min=11.0 \ + -arch x86_64 -arch arm64 \ + -framework Foundation -framework AppKit -framework CoreServices \ + -framework CoreSpotlight -framework UniformTypeIdentifiers \ + -o "$CONTENTSDIR/Resources/libZoteroSpotlight.dylib" \ + "$CALLDIR/mac/spotlight/ZoteroSpotlight.m" \ + || echo " ! libZoteroSpotlight.dylib build failed -- continuing without Spotlight" + else + echo " ! clang not found -- skipping libZoteroSpotlight.dylib build" + fi + # Delete extraneous files find "$CONTENTSDIR" -depth -type d -name .git -exec rm -rf {} \; find "$CONTENTSDIR" \( -name .DS_Store -or -name update.rdf \) -exec rm -f {} \; @@ -813,6 +826,18 @@ if [ $BUILD_MAC == 1 ]; then echo /usr/bin/codesign --verify -vvvv "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex" fi + else + # Ad-hoc sign unsigned (source/dev) builds: Core Spotlight only indexes + # for a signed app. No hardened runtime here, so Gecko's JIT still works. + echo "Ad-hoc signing $APP_NAME.app (unsigned build)" + /usr/bin/xattr -cr "$APPDIR" + # Sign our dylib, then the whole bundle (shallow if --deep trips on a + # nested component, so ad-hoc signing can't fail the build). + if [ -f "$APPDIR/Contents/Resources/libZoteroSpotlight.dylib" ]; then + /usr/bin/codesign --force --sign - "$APPDIR/Contents/Resources/libZoteroSpotlight.dylib" + fi + /usr/bin/codesign --force --deep --sign - "$APPDIR" \ + || /usr/bin/codesign --force --sign - "$APPDIR" fi # Build and notarize disk image diff --git a/app/mac/Contents/Info.plist b/app/mac/Contents/Info.plist index 7ab7a86c35..b416a5918e 100644 --- a/app/mac/Contents/Info.plist +++ b/app/mac/Contents/Info.plist @@ -200,5 +200,9 @@ LSMinimumSystemVersion 10.9.0 + NSUserActivityTypes + + com.apple.corespotlightitem + diff --git a/app/mac/mozilla-140.patch b/app/mac/mozilla-140.patch new file mode 100644 index 0000000000..b28b647974 --- /dev/null +++ b/app/mac/mozilla-140.patch @@ -0,0 +1,33 @@ +diff --git a/toolkit/xre/MacApplicationDelegate.mm b/toolkit/xre/MacApplicationDelegate.mm +index b7db989..dc0798b 100644 +--- a/toolkit/xre/MacApplicationDelegate.mm ++++ b/toolkit/xre/MacApplicationDelegate.mm +@@ -334,6 +334,28 @@ - (BOOL)application:(NSApplication*)application + continueUserActivity:(NSUserActivity*)userActivity + restorationHandler: + (void (^)(NSArray>*))restorationHandler { ++ ++ // Handle a clicked Core Spotlight result: its identifier is ++ // ":", so route it as zotero://spotlight/ for the ++ // JS handler to open or select the item, even on a cold launch. ++ if ([userActivity.activityType isEqualToString:@"com.apple.corespotlightitem"]) { ++ NSString* zoteroIdent = ++ userActivity.userInfo[@"kCSSearchableItemActivityIdentifier"]; ++ if ([zoteroIdent isKindOfClass:[NSString class]]) { ++ NSRange zoteroSep = [zoteroIdent rangeOfString:@":"]; ++ if (zoteroSep.location != NSNotFound) { ++ NSURL* zoteroURL = [NSURL ++ URLWithString:[@"zotero://spotlight/" ++ stringByAppendingString: ++ [zoteroIdent ++ substringFromIndex:zoteroSep.location + 1]]]; ++ if (zoteroURL) { ++ return [self openURLs:@[ zoteroURL ]]; ++ } ++ } ++ } ++ } ++ + if (![userActivity.activityType + isEqualToString:NSUserActivityTypeBrowsingWeb]) { + return NO; diff --git a/app/mac/spotlight/ZoteroSpotlight.m b/app/mac/spotlight/ZoteroSpotlight.m new file mode 100644 index 0000000000..40a7f136e4 --- /dev/null +++ b/app/mac/spotlight/ZoteroSpotlight.m @@ -0,0 +1,385 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2026 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://www.zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +// In-process Core Spotlight bridge, built as a dylib and loaded via js-ctypes. +// Indexed items are attributed to Zotero.app's own bundle id. +// The C entry points below are called from spotlight.js. +// Entry activities are handled in XUL (the patched MacApplicationDelegate), +// as on a cold launch the activity arrives before this dylib is loaded. + +#import +#import +#import +#import + +#define EXPORT __attribute__((visibility("default"))) + +// Spotlight domain is the app's own bundle id (per channel), so channels never collide. +static NSString *ZSDomain(void) { + static NSString *domain = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + domain = [[NSBundle mainBundle] bundleIdentifier] ?: @"org.zotero.zotero"; + }); + return domain; +} + +static NSString *ZSUniqueIdentifier(NSString *path) { + return [NSString stringWithFormat:@"%@:%@", ZSDomain(), path]; +} + +// Per-library subdomain of the channel domain, e.g. ".library" or +// ".groups.123". Domain deletes are hierarchical, so deleting the +// channel domain clears everything while a library subdomain clears just one. +static NSString *ZSDomainForLibrary(NSString *token) { + if (![token isKindOfClass:[NSString class]] || !token.length) { + return ZSDomain(); + } + return [NSString stringWithFormat:@"%@.%@", ZSDomain(), token]; +} + +static CSSearchableIndex *ZSIndex(void) { + static CSSearchableIndex *index = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + index = [[CSSearchableIndex alloc] initWithName:ZSDomain()]; + }); + return index; +} + +EXPORT const char *zotero_spotlight_bundle_id(void) { + static const char *cached = NULL; + if (!cached) { + cached = strdup([ZSDomain() UTF8String]); + } + return cached; +} + +// Diagnostics for why indexing might fail (Core Spotlight silently no-ops for an unsigned app). + +static NSString *gLastError = nil; + +static void ZSSetError(NSError *error) { + @synchronized ([CSSearchableIndex class]) { + gLastError = error.localizedDescription; + } + if (error) { + NSLog(@"ZoteroSpotlight error: %@", error); + } +} + +EXPORT int zotero_spotlight_indexing_available(void) { + return [CSSearchableIndex isIndexingAvailable] ? 1 : 0; +} + +EXPORT const char *zotero_spotlight_last_error(void) { + static char *buf = NULL; + @synchronized ([CSSearchableIndex class]) { + if (buf) { + free(buf); + buf = NULL; + } + if (gLastError) { + buf = strdup([gLastError UTF8String]); + } + } + return buf; +} + +static NSData *ZSAppIconPNG(void) { + static NSData *png = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSImage *icon = [NSApp applicationIconImage]; + if (!icon) { + return; + } + NSSize size = NSMakeSize(64, 64); + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] + initWithBitmapDataPlanes:NULL + pixelsWide:(NSInteger)size.width + pixelsHigh:(NSInteger)size.height + bitsPerSample:8 + samplesPerPixel:4 + hasAlpha:YES + isPlanar:NO + colorSpaceName:NSDeviceRGBColorSpace + bytesPerRow:0 + bitsPerPixel:0]; + if (!rep) { + return; + } + [NSGraphicsContext saveGraphicsState]; + NSGraphicsContext.currentContext = [NSGraphicsContext graphicsContextWithBitmapImageRep:rep]; + [icon drawInRect:NSMakeRect(0, 0, size.width, size.height)]; + [NSGraphicsContext restoreGraphicsState]; + png = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + }); + return png; +} + +static NSDate *ZSParseYear(NSString *date) { + if (![date isKindOfClass:[NSString class]] || date.length < 4) { + return nil; + } + NSDateComponents *comps = [[NSDateComponents alloc] init]; + comps.year = [[date substringToIndex:4] integerValue]; + if (comps.year <= 0) { + return nil; + } + comps.month = 1; + comps.day = 1; + return [[NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian] dateFromComponents:comps]; +} + +static id ZSParseJSON(const char *json) { + if (!json) { + return nil; + } + NSData *data = [NSData dataWithBytes:json length:strlen(json)]; + return [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL]; +} + +// recordsJSON: array of { path, title, library?, description?, keywords?, content?, date? } +EXPORT int zotero_spotlight_index(const char *recordsJSON) { + @autoreleasepool { + id parsed = ZSParseJSON(recordsJSON); + if (![parsed isKindOfClass:[NSArray class]]) { + return 1; + } + NSData *icon = ZSAppIconPNG(); + NSMutableArray *items = [NSMutableArray array]; + for (NSDictionary *record in (NSArray *)parsed) { + if (![record isKindOfClass:[NSDictionary class]]) { + continue; + } + NSString *path = record[@"path"]; + NSString *title = record[@"title"]; + if (![path isKindOfClass:[NSString class]] || ![title isKindOfClass:[NSString class]]) { + continue; + } + CSSearchableItemAttributeSet *attrs = + [[CSSearchableItemAttributeSet alloc] initWithContentType:UTTypeText]; + attrs.title = title; + if ([record[@"description"] isKindOfClass:[NSString class]]) { + attrs.contentDescription = record[@"description"]; + } + if ([record[@"keywords"] isKindOfClass:[NSArray class]]) { + attrs.keywords = record[@"keywords"]; + } + // Searchable but not displayed -- fields, notes, filenames, full text. + if ([record[@"content"] isKindOfClass:[NSString class]]) { + attrs.textContent = record[@"content"]; + } + NSDate *date = ZSParseYear(record[@"date"]); + if (date) { + attrs.contentCreationDate = date; + } + if (icon) { + attrs.thumbnailData = icon; + } + [items addObject:[[CSSearchableItem alloc] + initWithUniqueIdentifier:ZSUniqueIdentifier(path) + domainIdentifier:ZSDomainForLibrary(record[@"library"]) + attributeSet:attrs]]; + } + if (items.count) { + [ZSIndex() indexSearchableItems:items + completionHandler:^(NSError *error) { + ZSSetError(error); + }]; + } + return 0; + } +} + +// pathsJSON: array of zotero paths (e.g. "library/items/KEY") +EXPORT int zotero_spotlight_remove(const char *pathsJSON) { + @autoreleasepool { + id parsed = ZSParseJSON(pathsJSON); + if (![parsed isKindOfClass:[NSArray class]]) { + return 1; + } + NSMutableArray *ids = [NSMutableArray array]; + for (NSString *path in (NSArray *)parsed) { + if ([path isKindOfClass:[NSString class]]) { + [ids addObject:ZSUniqueIdentifier(path)]; + } + } + if (ids.count) { + [ZSIndex() deleteSearchableItemsWithIdentifiers:ids + completionHandler:^(NSError *error) { + ZSSetError(error); + }]; + } + return 0; + } +} + +// tokensJSON: array of library domain tokens (e.g. "groups.123"). Deletes every +// indexed item under each "." domain (and its subdomains). +EXPORT int zotero_spotlight_remove_libraries(const char *tokensJSON) { + @autoreleasepool { + id parsed = ZSParseJSON(tokensJSON); + if (![parsed isKindOfClass:[NSArray class]]) { + return 1; + } + NSMutableArray *domains = [NSMutableArray array]; + for (NSString *token in (NSArray *)parsed) { + if ([token isKindOfClass:[NSString class]] && token.length) { + [domains addObject:ZSDomainForLibrary(token)]; + } + } + if (domains.count) { + [ZSIndex() deleteSearchableItemsWithDomainIdentifiers:domains + completionHandler:^(NSError *error) { + ZSSetError(error); + }]; + } + return 0; + } +} + +EXPORT int zotero_spotlight_reset(void) { + @autoreleasepool { + [ZSIndex() deleteSearchableItemsWithDomainIdentifiers:@[ZSDomain()] + completionHandler:^(NSError *error) { + ZSSetError(error); + }]; + return 0; + } +} + +// Read back the index for tests/diagnostics (mdfind can't see Core Spotlight): +// run a query (e.g. `title == "*term*"c`) -> JSON array of matching ids. Blocks briefly. +EXPORT const char *zotero_spotlight_query(const char *queryStringC) { + static char *buf = NULL; + @autoreleasepool { + if (buf) { + free(buf); + buf = NULL; + } + if (!queryStringC) { + return NULL; + } + NSString *queryString = [NSString stringWithUTF8String:queryStringC]; + if (!queryString.length) { + return NULL; + } + + NSMutableArray *found = [NSMutableArray array]; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + + // initWithQueryString:attributes: is deprecated in favor of the + // queryContext: variant (macOS 12+), but works back to our 11.0 minimum. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + CSSearchQuery *query = [[CSSearchQuery alloc] + initWithQueryString:queryString + attributes:@[@"title", @"displayName"]]; +#pragma clang diagnostic pop + query.foundItemsHandler = ^(NSArray *items) { + @synchronized (found) { + for (CSSearchableItem *item in items) { + if (item.uniqueIdentifier) { + [found addObject:item.uniqueIdentifier]; + } + } + } + }; + query.completionHandler = ^(NSError *error) { + ZSSetError(error); + dispatch_semaphore_signal(sem); + }; + @try { + [query start]; + dispatch_semaphore_wait(sem, + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(8 * NSEC_PER_SEC))); + } + @catch (NSException *e) { + // An invalid query string raises rather than calling the handler. + NSLog(@"ZoteroSpotlight query error: %@", e); + } + + NSArray *snapshot; + @synchronized (found) { + snapshot = [found copy]; + } + NSData *json = [NSJSONSerialization dataWithJSONObject:snapshot options:0 error:NULL]; + if (json) { + NSString *str = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding]; + if (str) { + buf = strdup([str UTF8String]); + } + } + } + return buf; +} + +EXPORT int zotero_spotlight_set_client_state(const char *stateC) { + @autoreleasepool { + NSData *state = stateC + ? [NSData dataWithBytes:stateC length:strlen(stateC)] + : [NSData data]; + CSSearchableIndex *index = ZSIndex(); + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + [index beginIndexBatch]; + [index endIndexBatchWithClientState:state completionHandler:^(NSError *error) { + ZSSetError(error); + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(8 * NSEC_PER_SEC))); + return 0; + } +} + +EXPORT const char *zotero_spotlight_get_client_state(void) { + static char *buf = NULL; + @autoreleasepool { + if (buf) { + free(buf); + buf = NULL; + } + __block NSData *state = nil; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + [ZSIndex() + fetchLastClientStateWithCompletionHandler:^(NSData *clientState, NSError *error) { + ZSSetError(error); + state = clientState; + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(8 * NSEC_PER_SEC))); + if (state.length) { + NSString *str = [[NSString alloc] initWithData:state encoding:NSUTF8StringEncoding]; + if (str) { + buf = strdup([str UTF8String]); + return buf; + } + } + return NULL; + } +} diff --git a/chrome/content/zotero/ZoteroProtocolHandler.mjs b/chrome/content/zotero/ZoteroProtocolHandler.mjs index 29ca5afd8d..58b95b336e 100644 --- a/chrome/content/zotero/ZoteroProtocolHandler.mjs +++ b/chrome/content/zotero/ZoteroProtocolHandler.mjs @@ -681,7 +681,78 @@ export function ZoteroProtocolHandler() { this.doAction(uri); } }; - + + + /** + * zotero://spotlight/library/items/[itemKey] + * zotero://spotlight/groups/[groupID]/items/[itemKey] + * + * Target of a clicked macOS Core Spotlight result. + */ + var SpotlightExtension = { + noContent: true, + + // Resolve the item for the two path formats spotlight.js indexes + // (library/items/KEY and groups/GROUPID/items/KEY). + _getItem: async function (uri) { + var path = uri.pathQueryRef; + if (!path) { + return null; + } + path = path.substring(1); + + var params = { objectType: 'item' }; + var router = new Zotero.Router(params); + router.add('library/items/:objectKey', function () { + params.libraryID = Zotero.Libraries.userLibraryID; + }); + router.add('groups/:groupID/items/:objectKey'); + router.run(path); + + Zotero.API.parseParams(params); + if (!params.objectKey && !params.objectID && !params.itemKey) { + return null; + } + var results = await Zotero.API.getResultsFromParams(params); + return results.length ? results[0] : null; + }, + + doAction: async function (uri) { + if (Zotero.Prefs.get('spotlight.openOnConfirm')) { + let item = await this._getItem(uri); + let attachment = null; + if (item) { + if (item.isFileAttachment()) { + attachment = item; + } + else if (item.isRegularItem()) { + attachment = await item.getBestAttachment(); + } + } + if (attachment && attachment.isFileAttachment()) { + try { + await Zotero.FileHandlers.open(attachment, { + openInWindow: Zotero.Prefs.get('openReaderInNewWindow'), + }); + Zotero.Notifier.trigger('open', 'file', attachment.id); + return; + } + catch (e) { + Zotero.logError(e); + // Fall through to selecting the item + } + } + } + + // Default, and fallback when there's nothing to open + await SelectExtension.doAction(uri); + }, + + newChannel: function (uri) { + this.doAction(uri); + } + }; + /* zotero://debug/ */ @@ -888,6 +959,7 @@ export function ZoteroProtocolHandler() { this._extensions[ZOTERO_SCHEME + "://data"] = DataExtension; this._extensions[ZOTERO_SCHEME + "://report"] = ReportExtension; this._extensions[ZOTERO_SCHEME + "://select"] = SelectExtension; + this._extensions[ZOTERO_SCHEME + "://spotlight"] = SpotlightExtension; this._extensions[ZOTERO_SCHEME + "://debug"] = DebugExtension; this._extensions[ZOTERO_SCHEME + "://pdf.js"] = PDFJSExtension; this._extensions[ZOTERO_SCHEME + "://open"] = OpenExtension; diff --git a/chrome/content/zotero/preferences/preferences_advanced.js b/chrome/content/zotero/preferences/preferences_advanced.js index 7c831f471c..e42db00620 100644 --- a/chrome/content/zotero/preferences/preferences_advanced.js +++ b/chrome/content/zotero/preferences/preferences_advanced.js @@ -64,6 +64,10 @@ Zotero_Preferences.Advanced = { document.getElementById('zotero-prefpane-advanced-enable-local-api').addEventListener('synctopreference', () => { this.updateLocalAPIUI(); }); + + if (Zotero_Preferences.Spotlight) { + Zotero_Preferences.Spotlight.init(); + } }, diff --git a/chrome/content/zotero/preferences/preferences_advanced.xhtml b/chrome/content/zotero/preferences/preferences_advanced.xhtml index cc0901cfcc..571d2b941b 100644 --- a/chrome/content/zotero/preferences/preferences_advanced.xhtml +++ b/chrome/content/zotero/preferences/preferences_advanced.xhtml @@ -281,9 +281,102 @@