主题

macOS Launch Services window handoff

macOS Launch Services window handoff 指这样的原生 macOS 应用行为合同:用户从 Finder、Dock、Spotlight 或其他正常 Launch Services 入口再次启动一个已经运行的 app 时,系统把请求交给已有进程;已有进程复用 retained presenter 和 retained primary user window,将它恢复并前置,而不是创建第二个进程或第二个窗口。

  • 客户端
  • macOS

目录

Definition

macOS Launch Services window handoff 指这样的原生 macOS 应用行为合同:用户从 Finder、Dock、Spotlight 或其他正常 Launch Services 入口再次启动一个已经运行的 app 时,系统把请求交给已有进程;已有进程复用 retained presenter 和 retained primary user window,将它恢复并前置,而不是创建第二个进程或第二个窗口。LSMultipleInstancesProhibited=true 负责把重复启动请求指向已有实例,但不会替应用决定要显示哪一个窗口;applicationShouldHandleReopen(_:hasVisibleWindows:) 负责接住请求并进入应用自己的呈现路径。

这个 Topic 的正向模式是单一、幂等的 showPrimaryWindow() 路径:复用同一个 window controller,调用 showWindow、deminiaturize、NSApplication.shared.activate(ignoringOtherApps: true) 和 makeKeyAndOrderFront,保留窗口 frame、SwiftUI state、滚动位置和未保存编辑。菜单栏应用没有独立 desktop main-window 时,retained Advanced Settings window 也可以是这个 handoff 的 primary user window。

它不表示“启动第二个进程”“每次启动创建第二个窗口”“用 process lock、socket、distributed notification、event tap 或其他 custom IPC 自行协调实例”,也不把 applicationDidBecomeActive 当成全局显示窗口回退。event tap 若是应用已有的输入资源,验证时应保持一个,而不是把它变成重复启动协议。

When to use

当一个 macOS .app 通过正常 Launch Services 入口启动,产品禁止重复实例,并且再次启动应让已有实例显式响应时使用。典型触发条件包括菜单栏或 accessory app 有一个可隐藏/最小化的设置窗口、应用需要保留用户窗口状态,或者用户会从 Finder/Dock/Spotlight 重新打开已驻留的 app。

当 app 明确支持多实例、多文档窗口或每次启动都必须创建独立会话时,不应套用这个 Topic;那是产品的实例/文档路由合同。只有普通 macOS 进程、网页或 CLI 在 macOS 上运行,不足以命中本 Topic。若任务只是构建 app bundle、签名、公证或 activation policy 的一般生命周期,使用 macOS app;若任务需要证明真实 settings window 的外观和 WindowServer identity,再额外使用 Native macOS visual evidence。

Habits / defaults

  • 在 bundle 中显式保留 LSMultipleInstancesProhibited=true;菜单栏 app 同时使用 LSUIElement=true 时,重复启动的目标仍是已有进程。不要用第二个 app bundle、process lock 或私有 API 替代 Launch Services 的实例语义。
  • 在 NSApplicationDelegate 实现 applicationShouldHandleReopen(_:hasVisibleWindows:),无论 hasVisibleWindows 是 true 还是 false 都进入同一 primary-window presenter,并在接受请求后返回 true。这个 flag 描述当前可见性,不是“用户不想前置”的意图。
  • 让 showPrimaryWindow() 成为菜单动作、重复启动和 UI demo 入口共享的唯一 presenter 路径。lazy-create 一个 presenter 并由 app delegate 保留到进程结束;重复请求只调用现有 presenter,不能重新创建 hosting controller 或 SwiftUI state。若请求早于 launch 完成,记录 pending request,在 launch setup 完成后重放一次同一路径。
  • 将呈现操作设计为幂等序列:showWindow(nil)、window?.deminiaturize(nil)、activate(ignoringOtherApps: true)、window?.makeKeyAndOrderFront(nil)。不要因为重复启动重新居中、resize、替换 window 或清空用户状态;隐藏窗口和最小化窗口必须分别验证,并保留 frame。
  • 对使用菜单栏 accessory 生命周期的 app,启动和设置窗口关闭后保持 .accessory,设置窗口打开期间临时使用 .regular 以成为 key/front,关闭回调恢复 .accessory。把 activation-policy transitions 放到独立 presentation controller;UI demo 若有明确理由保持 .regular,应单独测试而不是改变生产默认。
  • 单元测试同时覆盖两个 hasVisibleWindows 值、重复调用只创建一个 presenter、launch 尚未完成时的 pending request、termination guard 和 close 后 policy 恢复。窗口测试覆盖隐藏/最小化恢复、frame 保留、可见、key/front;打包 app 再用正常 Launch Services 请求验证一份 PID、一份 event tap、一扇 settings window 和未保存状态保持不变。
  • 验证必须使用正常 Launch Services 启动,例如 open -a <app> 或 Finder/Dock;不要使用 open -n,因为它明确要求创建第二个实例,会绕过本 Topic 的产品合同。终止原进程后再启动,才应得到一次正常的新实例。

Reference implementations

  • projects/spotibind.md:SpotiBind 的直接 Solution 是 docs/solutions/macos/duplicate-launch-settings-window.md。它明确说明该菜单栏 app 没有独立 desktop main-window,所以重复启动前置 retained Advanced Settings window;ApplicationDelegate 负责 Launch Services reopen 和 presenter retention,SettingsWindowController 负责复用窗口并恢复/前置,ApplicationPresentationController 负责 .accessory/.regular 生命周期。
  • macOS app Topic:承接 app bundle、平台生命周期、签名/打包等更宽的交付面;本 Topic 只补充重复启动请求如何交给已有进程并前置已有窗口。
  • Native macOS visual evidence Topic:承接真实目标窗口的 PID、bundle、AX 和 WindowServer 截图证据;本 Topic 只要求在需要时验证窗口身份/状态,不把截图脚本或 PNG 资产复制进来。

Failure modes

  • 把 LSMultipleInstancesProhibited 误解成“系统会自动显示主窗口”,或只实现 bundle key 而忽略 applicationShouldHandleReopen,会让第二次正常启动看起来无响应。应把 callback 路由到 retained primary-window presenter。
  • 只在 hasVisibleWindows == true 时处理请求,把 false 当成拒绝前置的理由,会漏掉隐藏、最小化或菜单栏驻留场景。两个值必须走同一幂等路径。
  • 在每次 reopen 中创建新的 SettingsWindowController、hosting controller 或 window,会产生第二窗口并丢失 frame、滚动位置和未保存 state。应保留一个 presenter/window,并用 factory seam 做单元测试。
  • 用 applicationDidBecomeActive 作为全局 fallback,会让普通切换焦点也意外打开设置。只响应明确的 Launch Services reopen 和产品已有的打开设置动作。
  • 永久切换到 .regular 会让 accessory app 在窗口关闭后继续出现在 Dock 或保持错误的前台语义;应在打开/关闭边界显式切换并恢复生产 policy。
  • 为绕过 Launch Services 增加 custom process lock、socket、distributed notification、private API 或第二个 event tap,会扩大状态和清理面,也无法替代系统回调。除非产品有独立的跨进程协议需求,否则拒绝这类方案。
  • 只跑 presenter unit tests,不用 packaged app 做正常 Launch Services 验证,会漏掉 bundle key、PID、event tap、窗口数量和 activation policy 的组合错误。应记录第二次请求前后的一个 PID、一个 event tap、一扇窗口,并单独检查终止原进程后的 fresh launch。
  • 用 open -n 作为“重复启动测试”会主动制造第二个进程,得到的结果不能证明产品合同;改用普通 open -a 或 Finder/Dock 请求。

Platform boundary

本 Topic 是 macOS-specific:Launch Services、LSMultipleInstancesProhibited、LSUIElement、AppKit delegate callback 和 activation policy 都属于 macOS app bundle/runtime。它不能直接移植为 Windows single-instance mutex、Linux desktop activation 或 Web tab coordination 的通用规则;其他平台应使用各自的系统实例/窗口交接合同。它也不规定 signing/notarization、Keychain、app distribution 或真实窗口截图的完整流程,这些责任分别回到相邻 Topic。

References

Evidence index

  • docs/solutions/macos/duplicate-launch-settings-window.md
  • Sources/SpotiBind/ApplicationDelegate.swift
  • Sources/SpotiBind/SettingsWindowController.swift
  • Sources/SpotiBind/ApplicationPresentationController.swift
  • packaging/macos/Info.plist
  • Tests/SpotiBindAppTests/ApplicationDelegateTests.swift
  • Tests/SpotiBindAppTests/ApplicationPresentationTests.swift
  • projects/spotibind.md
  • skills/style-playbook/references/topics/macos-app/TOPIC.md
  • skills/style-playbook/references/topics/native-macos-visual-evidence/TOPIC.md