macOS Look Up 弹窗逆向:LookupViewService 的查询链路
从 XPC View Service、DictionaryServices、Parsec 和结果聚合链路,梳理 macOS Look Up 弹窗的内部执行路径与可用 Hook 入口。
macOS 里重按压触发的 Look Up 弹窗,基本上是一个轻量词典窗口(而且挺无用的)。我一直想如果它能扩展出更多的功能,比如接入LLM,或者跟某些阅读app联动这样,就会更好。不过我在苹果的文档里面没有找到很多关于这个的接口,貌似这个是一个很封闭的东西。那么就只能逆向看看了。
先说结论:这个 Look Up 实际上是一个由 LookupViewService 聚合的查询服务:宿主应用提交选中文本和上下文,服务端标准化 query,再把请求分发到本地词典、Wikipedia、宿主自定义结果和 Parsec/Spotlight 网络结果,最后统一排序、分页、显示。
纵览
主服务是系统里的 XPC View Service:
/System/Library/PrivateFrameworks/Lookup.framework/Versions/A/XPCServices/LookupViewService.xpc/Contents/MacOS/LookupViewService其他的包括:
DictionaryServices.frameworkDictionary.appLookupViewService.xpc/Contents/Info.plist
大概是这样工作的,Look Up 弹窗直接依赖 SearchUI、SearchFoundation、CoreParsec、WebKit、MapKit 和 DataDetectorsCore。本地词典查询使用 DictionaryServices.framework 的 _DCSCopyRecordsForSearchString,然后通过 showResults 显示结果。
过程
从结构上看,Look Up 是一个 service-host 形态的查询聚合器。宿主应用负责发起 session,LookupViewService 负责查询编排,然后是 LVSResultNavigatorViewController、NSPageController 和 LVSLookupWindow 负责 UI 的绘制。
sequenceDiagram
participant App as Host App
participant Service as LookupViewService
participant Dict as LVSDictionaryQuery
participant Parsec as LVSParsecSession
participant Host as Host Query Path
participant UI as Result Navigator
App->>Service: configureWithQuery(term, context, domain, hostId)
App->>Service: startQuery()
Service->>Service: normalize term and trigger metadata
par Local dictionary
Service->>Dict: startLVSDictionaryQueryWithId(queryId)
Dict->>Dict: run DictionaryServices search
Dict-->>Service: dictionary results
and Parsec or Spotlight
Service->>Parsec: parsecAvailableWithCompletion()
Service->>Parsec: startQuery(queryContext, domain, queryId)
Parsec-->>Service: remote cards
and Host results
Service->>Host: startHostQueryWithId(queryId)
Host-->>Service: custom host results
end
Service->>Service: addResults / addSections
Service->>Service: resultsRanked()
Service->>UI: showResults()Look Up 的 UI 其实可以说是这样数据驱动的。
关键协议
这个过程里面有两个协议比较关键:
LookupViewServiceProtocolLookupViewHostProtocol
正好就是按“宿主应用到 View Service”拆分。说明 Look Up 在设计之初就考虑到了扩展性。(实际上我后续发现在 XCode 里面的 Look Up 居然是调用了 LSP 来显示文档)
这其中涉及到下面几个类:
| 类 | 角色 |
|---|---|
LookupViewService | session 配置、查询编排、结果聚合、显示状态流转 |
LVSDictionaryQuery | 本地词典与网络词典查询 |
LVSParsecSession | Spotlight/Parsec 能力探测与远端结果查询 |
LVSDictionaryResult / LVSWikipediaResult | 词典和 Wikipedia 结果对象 |
LVSResultNavigatorViewController | 分页、tab 控制和结果切换 |
LVSLookupWindow | 顶层 Look Up 窗口 |
Session 入口:configureWithQuery
LookupViewService 的入口方法接收宿主的上下文,并初始化当前 lookup session 的基础状态。
-[LookupViewService configureWithQuery:term:context:domain:hostId:selectionType:triggerType:remoteTextQuery:]这个方法会保存:
- 原始 query 与 term
- 上下文和搜索 domain
- 宿主标识
hostId selectionTypetriggerType- 是否存在 remote text query provider
它还会读取 Spotlight 相关偏好,推导当前 session 是否允许网络结果。启用网络路径时,服务会把 CLLocationManager 的默认 bundle id 设置为 com.apple.metadata.SpotlightFramework,随后创建 LVSParsecSession 并进入下一阶段。
这里其实就已经得到了很多信息。
startQuery
startQuery 里面会进行 query 标准化和 fan-out 编排。硬编码了输入文本里的若干边界情况:
- Hebrew quote 变体
׳到'的替换- 尾部逗号
- 特定条件下的尾部句点
- 首尾弯引号
- 尾部
's
然后再根据当前 session 状态选择查询路径。
sequenceDiagram
participant Service as LookupViewService
participant Remote as Remote Host Query
participant Dict as Local Dictionary
participant Wiki as Wikipedia
participant Parsec as Parsec
Service->>Service: normalize queryTerm
Service->>Service: clamp selectionType and triggerType
alt remoteQuery exists
Service->>Remote: startHostQueryWithId(queryId)
else queryTerm exists
Service->>Service: allocate queryId
opt network enabled and Parsec available
Service->>Parsec: startParsecQueryWithId(queryId)
opt selectionType allows wiki
Service->>Wiki: startLVSWikipediaQueryWithId(queryId)
end
end
Service->>Dict: startLVSDictionaryQueryWithId(queryId)
end
Service->>Service: showResults()这个基本上是一个输入的清洗,看起来挺直接的。
本地词典查询路径
本地词典查询从 startLVSDictionaryQueryWithId: 进入。它会创建 SFStartLocalSearchFeedback,增加 remainingTaskBeforeDisplay,然后把真正的查询放到后台队列。
逻辑位于 LVSDictionaryQuery run:
CFArrayRef records = _DCSCopyRecordsForSearchString(
(__bridge CFStringRef)self.searchTerm,
0x10000,
0
);拿到 records 后,服务还会用 headword 做一次扩展:
NSArray *headwords = (__bridge_transfer NSArray *)_DCSCreateHeadwordList(records);
NSArray *expandedRecords = (__bridge_transfer NSArray *)_DCSCopyRecordsWithHeadword(firstHeadword, session);这个 headword 是原来做补充扩展记录。术语别名、实体补全、代码符号解释等增强能力这些的。
结果封装与显示
LVSDictionaryQuery queryComplete 会把 records 封装成最终 result 对象。单词典且 identifier 为 com.apple.dictionary.Wikipedia 时,结果会被构造成 LVSWikipediaResult,其余情况走 LVSDictionaryResult。
随后 LookupViewService 通过 addResults:title:ranking: 和 addSections:ranking: 把结果塞进 tab control。ranking <= 1 会把 localSearchGotResults 赋值为真,这会影响显示的时机。
最后就是 showResults:
if (remainingTaskBeforeDisplay <= 0 || localSearchGotResults) {
[self resultsRanked];
[pageController setArrangedObjects:representedResults];
[self setDisplayState:representedResults.count > 0 ? 5 : 4];
}这说明 Look Up 允许本地结果先到先显示,远端路径可以继续完成。
还有一个反馈
resultsRanked 会构造 SFResultRankingFeedback、SFSectionRankingFeedback 和 SFRankingFeedback,并把排序结果上报给 feedback listener。随后还会发送 SFSearchViewAppearFeedback。
sequenceDiagram
participant Service as LookupViewService
participant Tabs as LVSTabControl
participant Feedback as Parsec Feedback Listener
participant UI as Page Controller
Service->>Tabs: representedResults and representedSections
Service->>Service: build result ranking feedback
opt can send ranking feedback
Service->>Feedback: didRankSections(feedback)
end
opt can send appear feedback
Service->>Feedback: searchViewDidAppear(event)
end
Service->>UI: sync selected index怎么 Hook
如果要在这上面扩展的话,有下面几个候选的地方:
| 优先级 | Hook 点 | 适合用途 |
|---|---|---|
| 1 | configureWithQuery:term:context:domain:hostId:selectionType:triggerType:remoteTextQuery: | 捕获原始 query、宿主信息和 session 上下文 |
| 2 | startQuery | 输入处理 |
| 3 | startLVSDictionaryQueryWithId: | 增强本地词典路径,统计 local query 生命周期 |
| 4 | LVSDictionaryQuery queryComplete | 拿到封装前后的 dictionary result |
| 5 | LVSParsecSession startQuery:queryContext:domain:lookupSelectionType:hostId:queryId:completion: | 观测或调整 Parsec/Spotlight 网络结果 |
| 6 | addResults:title:ranking: / addSections:ranking: | 插入自定义 section,调整排序 |
| 7 | showResults | 最终渲染输出 |
插件框架的一个设想
第一版插件框架也许可以把 LookupViewService 作为宿主,注入层负责采集参数和转发生命周期,然后业务逻辑交给插件管理器。
sequenceDiagram participant Hook as Hook Layer participant Host as Lookup Plugin Host participant Plugin as Plugin participant Service as LookupViewService participant Tabs as Result Sections Hook->>Host: create session context from configureWithQuery Hook->>Host: update normalized term from startQuery Host->>Plugin: preflightSession(context) Service->>Service: run system dictionary and remote queries Hook->>Host: local results arrived Host->>Plugin: augmentLocalResults(results, context) Hook->>Host: before addSections Host->>Plugin: provideCustomSections(context) Host-->>Tabs: merge system and custom sections Hook->>Host: before showResults Host->>Plugin: willRenderResults(results, context)
分成五个阶段:
-
input从configureWithQuery...获取原始上下文。 -
normalized-query在startQuery后拿到系统标准化后的 term。 -
local-results在LVSDictionaryQuery路径后处理本地结果。 -
aggregation在addResults/addSections处插入自定义 section。 -
render在showResults前做最终排序、过滤或收尾记录。
一个最小插件 ABI 可以长这样:
@protocol LookupPlugin <NSObject>
- (BOOL)canHandleSession:(id)sessionContext;
- (void)preflightSession:(id)sessionContext;
- (NSArray *)augmentLocalResults:(NSArray *)results
sessionContext:(id)sessionContext;
- (NSArray *)provideCustomSectionsForSession:(id)sessionContext;
- (NSArray *)rewriteAggregatedResults:(NSArray *)results
sessionContext:(id)sessionContext;
- (void)willRenderResults:(NSArray *)results
sessionContext:(id)sessionContext;
@endLookupSessionContext 至少需要有这样的上下文:
rawTermnormalizedTermcontextdomainhostIdselectionTypetriggerTypequeryIdlocalResultsremoteResultscustomSections
不过 LookupViewService 属于私有的 XPC service,没有稳定 ABI,系统升级后需要重新确认类名,selector,ivar 偏移和对象布局这些是否变化。
最后
我觉得 Look Up 挺好的一个东西,但是现在就是很鸡肋的感觉。希望苹果早点给它整个公开插件接口,这个东西感觉前景也就是接入 AI 会很不错,不然就跟老 Siri 那样了,(现在谁还用传统词典呀)。