Hacking the iOS Spotlight, v2
Introduction
This article is purely technical. If you want to know a bit about the history of SearchLoader, go to this blog post.
All of the data in this article reflects iOS 6. There has been an intermediate number of changes from iOS 5.
History: The TL prefix for everything around SearchLoader and my projects related to Spotlight stands for theiostream spotlight. It looked better than SL, I could certainly not take SP, and TL also looked nicer than TP.
This article limits itself to the basic process of a search, and the creation of Search Bundles and Extended Domains. It does not go into detail on how each component of the Search framework work internally.
There are a couple of things which are irrelevant for this article, but some supposedly interesting areas of this subject might still make new articles. More precisely:
SPContentIndexer: how does it interact with the Content Index; how can we perform our own searches? (SMS app does that!)
IPC: How do the search-related process intercommunicate? This should be an easy question to answer, but I was never interested enough to look it up! :o
Domain registration: How are domains registered out of the Extended domain scope? Regarding the search process itself’s internals, this is the only key point I’ve never closely looked into, and which I suspect isn’t much simple.
An overview of the search process
Spotlight is divided between two layers: SpringBoard (UI) and searchd (search).
SpringBoard has four SBSearch... classes:
SBSearchTableViewCell: The UITableViewCell subclass for the Spotlight table view. It has nothing special regarding the search process. Whole articles could be written on cheating the Search Table View, though.
SBSearchView: The main view for Spotlight. It has as subviews an UISearchBar, an UITableView and, on the iPad, a SBNoResultsView for stylish purposes. This view also handles some layouting code for the table view.
SBSearchController: It serves as a bridge between the table view and SBSearchModel. It is the table view and search bar delegate, and transfers information from searched content to your nice-looking results.
SBSearchModel: A subclass of SPSearchAgent. As a subclass, it handles the timer for search results to vanish after a while, obtaining images for display identifiers and the final launching URL for the result, from data it holds.
SPSearchAgent, in SpringBoard, is incorporated by SBSearchModel’s shared instance.
SBSearchController asks for it to do what it’s meant to do: Take a query string, turn it into a SPSearchQuery and send it to searchd.
The searchd layer uses SPBundleManager to load all existent search bundles (placed at /System/Library/SearchBundles or, with SearchLoader, /Library/SearchLoader/SearchBundles, and then it gets out of them a set of datastore objects.
Datastores on Search Bundles are NSObject<SPSearchDatastore> * objects. These objects, through an API specified by the SPSearchDatastore protocol, perform a search through the query they receive and produce a SPSearchResult.
Search Results are sorted by an integer named a search domain. Each search bundle provides a set of domains it “owns”.
When creating SPSearchResults, internally or externally they will get placed inside a SPSearchResultSection object, which will be assigned to a domain.
Multiple sections can be added under the same domain, as done by Application.searchBundle. The only search bundles to use multiple domains are iPod.searchBundle (due to unknown purposes) and Extended.searchBundle (each index gets one domain).
Back to SpringBoard, it gets sections for each domain and places them in the table view.
Yet, there is some special attention that should be paid to Extended.searchBundle and Spotlight Bundles.
Extended.searchBundle reads from database or database-like entries in some files to generate its results. The following content describes the generation of these entries. But as you can notice, they are database entries. Therefore, this method of displaying search results should be used when results are not generated dynamically, but when they can be indexed.
SearchLoader edits com.apple.search.appindexer.plist (the AppIndexer daemon’s launchd.plist) so it’ll load MobileSubstrate. With this, it manages to control Spotlight Bundle loading.
Every time searchd is initialized (when Spotlight is brought up), it invokes the AppIndexer daemon.
On launch (usually), this daemon finds existing Extended Domains through SPGetExtendedDomains(). This function reads from /System/Library/Spotlight/domains.plist and returns an array of dictionaries. These dictionaries contain this domain’s display identifier (which reflects the generated search results refer to), a category (a string which usually has the format <Name>Search) for it (a way to differentiate different search bundles/extended domains with the same display identifier due to referring to the same app) and required capabilities. This sort of dictionary is, therefore, named extended domain.
Before going further, it’s important to introduce the file hierarchy for Spotlight Bundle databases, etc. Files related to extended domain with display identifier com.yourcompany.test and category TestSearch will be placed at /var/mobile/Library/Spotlight/com.yourcompany.test. The files are:
- TestSearchindex.spotlight: Content index to index search results. Managed by
CPRecordStore in AppSupport and the ContentIndex framework;
- TestSearchindex.sqlite: SQLite database managed by
CPRecordStore and ContentIndex.framework to index search results;
- updates.TestSearch.spotlight: Content index to track desired updates to the database/content index.
From these extended domains, it uses SPDomainHasUpdatesFile() to determine whether the updates file for an extended domain is empty. In case it is non-existent or contains updates, an AppIndexer instance is initialized with information about this extended domain.
Here, Spotlight Bundles (finally) get in the scene. They are loaded from /System/Library/Spotlight/SearchBundles. Through a principal class of NSObject<SPSpotlightDatastore> * type, it generates a dictionary with specific keys which tells AppIndexer how it should index results into the content index/database, from an identifier. If the updates file is empty, a list of identifiers is created by the spotlight bundle itself. Else, the contents of the updates file are used. Therefore, it can be said that the updates file tracks identifiers that require indexing from the Spotlight bundle.
After getting this data, AppIndexer asks searchd to update the actual database/content index. Meanwhile, one might ask Where do the identifiers for the update file come from?. The only native extended domain, SMSSearch, uses a whole direct ContentIndex wrapper to write to its update file (the IMDSpotlight function family from IMCore). But happily, we don’t have to either use ContentIndex, nor link to IMCore. Apple provides some APIs in SPDaemonConnection, or even a whole framework just about that: Spotlight.framework with SPSpotlightManager.
And then we go back to the top. Extended.searchBundle will use SPContentIndexer to look up contents of existent content indexes/databases and from them build Search Bundle-like results which will go to SpringBoard.
This finishes my Spotlight overview. The further sections will describe, respectively, the structure of a search bundle, an extended domain Spotlight Bundle (documenting libspotlight – a part of SearchLoader –’s APIs), how to make your bundle loadable by SearchLoader, some details about the SearchLoader tweak itself, SearchLoaderPreferences, and then will conclude.
Search Bundles
Search Bundles are composed of a principal class, which is the Search Bundle datastore. It conforms to the SPSearchDatastore protocol.
SearchLoader plugins actually should conform to the TLSearchDatastore protocol, which conforms to SPSearchDatastore and adds one method.
TLSearchDatastore
- - (void)performQuery:(SPSearchQuery *)query withResultsPipe:(SDSearchQuery *)pipe;
In this method, the search bundle should send its generated SPSearchResult or SPSearchResultSection objects back to searchd so it can be shown in the SpringBoard layer.
This method takes as arguments query and pipe. They are the same (as of iOS 6: SDSearchQuery is a subclass of SPSearchQuery), yet theoretically query should be used to obtain information regarding the search query (essentially, its query string, obtainable through the - (NSString *)searchString; method), and pipe to send results back to searchd, through the following methods:
- (void)appendSection:(SPSearchResultSection *)section toSerializerDomain:(NSUInteger)domain;
- (void)appendResults:(NSArray *)results toSerializerDomain:(NSUInteger)domain;
In these methods, the domain argument should always be the search domain taken by the search bundle, the section parameter should be an initialized SPSearchResultSection to contain the desired search results, and results should be an array of SPSearchResult objects.
In case there is usage of the below-described -(BOOL)blockDatastoreComplete method and at some point asynchronous behavior happens, you should call -[SDSearchQuery storeCompletedSearch:], passing self as a parameter, and pipe as an object.
- - (NSArray *)searchDomains;
It should return a NSArray object with NSInteger objects as its contents. Each NSInteger should hold an integer to serve as its taken search domain.
Due to a Loader limitation, in SearchLoader-loaded plugins only one search domain should be taken, else unknown results may be yielded.
- - (NSString *)displayIdentifierForDomain:(int)domain;
This method should return a NSString object to represent the display identifier for a given domain. This display identifier is usually the application which search results reflect.
- - (BOOL)blockDatastoreComplete;
To perform some asynchronous-only tasks inside your search bundle or delegate-calling requesters, you can return YES on this method to block the -[SDSearchQuery storeCompletedSearch:] method, therefore not progressing further in the search process and then rendering result committing from the datastore impossible.
Later, a call to -[SDSearchQuery storeCompletedSearch:] should be placed as described above for the result to be actually committed, and obviously, NO should be returned here then for your call not to be subsequently blocked.
libspotlight APIs
The following libspotlight functions can be used for convenience or are required during the development of SearchLoader-loaded search bundles:
- NSUInteger TLDomain(NSString *displayID, NSString *category);
(The internals of this function will be discussed further, and with it the need for a category parameter, which is characteristic of extended domains.)
This function gets the domain for a given display identifier (usually of the application which search results reflect) and a category string (defined above).
This must be the way to obtain the domain for a SearchLoader plugin, to avoid issues with other plugins.
- void TLRequireInternet(BOOL require);
This enables or disables the status bar activity indicator in SpringBoard. This should be used if you are loading content from the Internet.
This function is completely unrelated to the -blockDatastoreComplete method from TLSearchDatastore.
Miscellaneous
- If you, for some reason, cannot use
-blockDatastoreComplete to order searchd to wait for asynchronous tasks, you can use CFRunLoopRunInMode() (so you can set timeouts, rather than CFRunLoopRun() where you can’t) to stop it from progressing without results being properly committed. It can later be CFRunLoopStop()ped.
A convention (made by me) states that you should unless extremely required never take over 3 seconds with Internet requests.
Spotlight Bundles
SPSpotlightDatastore
- - (NSDictionary *)contentToIndexForID:(NSString *)anId inCategory:(NSString *)category;
From parameter anId, a string which serves as an *identifier, this method should return a NSDictionary object with specific keys to represent a result. The category parameter is the extended domain’s category.
The following keys can have values assigned for in the returned dictionary. They are all constants defined in the SearchLoader headers, and part of Spotlight.framework:
- kSPContentContentKey: The content of the search result. The query string matching this one is what defines whether this result should or not be displayed.
- kSPContentTitleKey: The title for the search result.
- kSPContentSummaryKey: The summary label of the search result.
- kSPContentSubtitleKey: The subtitle label of the search result.
- kSPContentAuxiliaryTitleKey: (iPad only) The auxiliary title for the search result.
- kSPContentAuxiliarySubtitleKey: (iPad only) The auxiliary subtitle for the search result.
- kSPContentCategoryKey: The category. Use is unknown.
- kSPContentExternalIDKey: The external identifier of the result. Use is unknown.
I should provide an image specifying which labels are which graphically soon. Meanwhile, you’ll have to experiment with it ;)
- - (NSArray *)allIdentifiersInCategory:(NSString *)category;
This should return an array of NSString objects to be passed into -contentToIndexForID:inCategory: as the anId parameter.
This method is called when the content index/database for given category is empty, and therefore it needs all existing data related to it put into identifiers, which will initially populate them.
An identifier has no proper definition nor standard, except the one that if it does not conform to URL standards it will not be put into the default-generated URL for it. The domain will be used instead. It can be as it best fits your parsing needs on -contentToIndexForID:inCategory:. More details regarding default URL generation can be found below on the URL correction InfoBundle plist keys’ documentation.
To set a custom URL for a Spotlight bundle, the TLCorrectURL... InfoBundle keys should be used. More details can be found below. This API is quite limited at the moment, but it can be expanded if a specific request regarding it is placed.
Updates File Manipulation
The following SPSpotlightManager method from Spotlight.framework can be used to modify the Updates file:
Obtains the shared instance for the SPSpotlightManager class.
- - (void)application:(NSString *)displayID modifiedRecordIDs:(NSArray *)identifiers forCategory:(NSString *)category;
This method adds the identifiers, described as NSString objects inside the identifiers parameter, to the updates file of extended domain of display identifier displayID and category category.
Content Index/Database Manipulation
SPSpotlightManager: - (void)eraseIndexForApplication:(NSString *)displayID category:(NSString *)category;
This method deletes the /var/mobile/Library/Spotlight files for certain category of certain application for given display identifier.
SPDomainManager: - (void)notifyIndexer;
This method triggers AppIndexer, which will perform its on-launch tasks (update extended domains which require updating).
InfoBundles
InfoBundles are document packages (bundles without executables) placed inside /Library/SearchLoader/Applications/. They tell SearchLoader which search/spotlight bundles placed at their respective directories should be loaded.
Required Keys
- LSTypeIsPackage: Should always be set to true.
- SPDisplayIdentifier: String representing the display identifier of the app which search results refer to.
- SPCategory: Category for the plugin.
- TLDisplayName: Display name for the plugin.
Required Keys for Search Bundles
- TLIsSearchBundle: If set to true, defines that this plugin is a search bundle.
Required Keys for Extended Domains
- SPSearchBundle: The name of the Spotlight bundle related to the extended domain.
- SPDatastoreVersion: This value is mostly unused. Should be set to integer 1.
Optional Keys
- TLCorrectURL: Boolean which defines whether SearchLoader should attempt to correct the URL generated by a search bundle/extended domain. Since you can generate your own URLs with search bundles, this is only intended to be used with extended domains.
The below keys show the process of creating your corrected URL with InfoBundle keys. It should be noted that the generated string should be a valid URL, else it will have no effect.
Correction works based on the manipulation of the original URL string. On search bundles, they are custom, and on Spotlight bundles, they take the following format: search://displayID/category/identifier.
It shall be noted that if identifier does not conform to URL standards, the original output URL after processing this string will have the search://domain/record-entry-ID format.
If there is no defined format and yet a delimiter, the default format search://<$ID$>/<$C$>/%@ will be used.
TLCorrectURLStartZero: Boolean which defines whether the selection from the original URL’s text starts at the beginning. Takes precedence over TLCorrectURLStartDelimiter.
TLCorrectURLEndLength: Boolean which defined whether the selection from the original URL’s text ends at the string’s end. Takes precedence over TLCorrectURLEndDelimiter.
TLCorrectURLStartDelimiter: Required if TLCorrectURLStartZero is absent. Defines the delimiter string for the start of the selection.
TLCorrectURLEndDelimiter: Required if TLCorrectURLEndLength is absent. Defines delimiter string for the end of the selection.
Optional Keys for Extended Domains
- TLQueryLengthMinimum: Integer which represents the minimum character count for the content index/database for this extended domain to be searched.
SearchLoader
In the SpringBoard layer, SearchLoader changes:
Emptying the _prefixWithNoResults instance variable every time -[SPSearchAgent setQueryString:] is called, therefore making every query valid, as opposed to queries with only valid prefixes. This logic works with cases such as “if there’s no Nol contact, there’ll be no Nolan, but doesn’t work with, for instance, Calculator, in which 1- is valid and 1-1 is not.
Hooking -[SBSearchModel _imageForDomain:andDisplayID:], allowing search results for non-existent apps to exist and still have icons in the table view. For instance, YouTube Search doesn’t require the YouTube app, yet should have an icon.
It hooks -[SBSearchModel launchingURLForResult:withDisplayIdentifier: andSection:], to apply the changes asked for by the TLCorrectURL... InfoBundle keys.
In the searchd layer, the core hooks are:
SPGetExtendedDomains(): Every SearchLoader plugin is faked as an existing extended domain, even being a search bundle. This provides a healthy domain for our search bundles and lets our spotlight bundles to be loaded.
-[SPExtendedDatastore searchDomains]: This prevents our search bundles’ domains to be registered by Extended.searchBundle. This is essential, else an exception will be thrown due to two search bundles having the same domain – our plugin and Extended. With this put aside, our bundle is loaded without any major complication by SPBundleManager.
These are the other hooks:
SPDisplayNameForExtendedDomain(int): This applies the chosen display name on the InfoBundle.
-[SPContentIndexer beginSearch:]: This is used to apply the restriction from the TLQueryLengthMinimum InfoBundle key.
NSBundle/NSFileManager Path Hooks: Hooked to allow bundles at custom locations (in this case, /Library/SearchLoader/SearchBundles) to be loaded.
-[SDSearchQuery storeCompletedSearch:]: Hooked to allow TLSearchDatastore’s -blockDatastoreComplete method to be implemented.
-[SDClient removeActiveQuery:]: Avoids a crash previously caused by a cached value which states SearchLoader was loading from Extended.searchBundle after finishing a query.
On AppIndexer, domain hooks are placed and the following:
SBSCopyBundlePathForDisplayIdentifier(NSString *): This tricks AppIndexer into getting SPSearchBundle and SPDatastoreVersion keys from our InfoBundle, not the actual app bundle. This is required to avoid file patching and allow extended domains for apps which are not installed.
Lastly, for the TLRequireInternet(BOOL) function from libspotlight to work, a small Darwin notification system is placed inside the SpringBoard layer of the tweak, and when it receives a notification, it accordingly changes whether the status bar activity indicator is or not activated.
SearchLoaderPreferences
SearchLoader also hooks into Preferences to allow the native Spotlight preferences to know about our own plugins. It only applies the core hooks when SearchSettings.bundle is loaded.
Yet, it also adds a preference bundle of its own which thanks to rpetrich and DHowett’s libprefs, can load – just like PreferenceLoader – preference entry plists! So you are allowed to – much like PreferenceLoader – place your plists exactly as you would with PL on /Library/SearchLoader/Preferences. Neat, huh? :)
SearchLoader Limitations/Bugs
It does not allow multiple domains for search bundles, and unknown consequences may happen if a search bundle attempts to do so. There is, though, no known reason for this to be allowed.
While search bundles can be placed in /Library/SearchLoader/SearchBundles, Spotlight bundles require to be placed at /System/Library/Spotlight/SearchBundles. The reason for this is purely laziness.
SearchLoader creates empty Content Indexes for every search bundle, generating a number of empty-and-unused indexes at ~/Library/Spotlight.
Conclusion
After 8 months of work in this area and some hours in this blog post (naturally not only on Loader, I’ve made my own share of Loader plugins to be released alongside it), I present this research. I hope it turns out to be useful. Seeing cool things being done with this is the best thing I could ever hope to achieve by making this.
I’d like to thank AAouiz and cydevlop for coordinating the Spotlight+ and SearchResults projects, which drove Loader to be created, and in no particular order Maximus, Cykey, DHowett, rms, fr0st, Nolan, ac3xx, his delightful wife, cj, Optimo, saurik and so many others who helped (directly or indirectly) to make this possible.
Finally, Loader has some fails, as the above section states, but I gladly take feature requests or bug reports.
About me:
I’m Daniel Ferreira (@ferreiradaniel2), typically called theiostream around the jailbreak community, author of some jailbreak tweaks, and other stuff. For more, http://theiostream.com. ;)