Desktop DMG Startup Window Recovery Spec
Background
用户报告了两个 macOS DMG 安装问题:
- 安装最新 DMG 后,首次启动不显示任何窗口。
- 在较早的 DMG 中,用红色关闭按钮关闭客户端窗口会让 OpenSquilla 应用继续在 Dock 中运行,但点击 Dock 图标不会重新打开任何窗口。
本 spec 涵盖桌面端启动和打包修复。它独立于 gateway/yoyo 迁移锁恢复 spec,尽管两者都可能在首次运行 gateway 启动期间出现。
Evidence
本地打包日志显示捆绑的 gateway 在首次启动期间崩溃:
AttributeError: '_AsyncConnection' object has no attribute 'create_function'
崩溃路径为:
opensquilla/session/storage.py -> SessionStorage.connect()
已构建应用下的打包运行时在 opensquilla.compat.aiosqlite._AsyncConnection 中缺少 create_function,而当前源码已包含该兼容方法。
打包的 Electron 应用也仍然采用如下启动顺序:
const gateway = await startGateway()
const window = await createMainWindow()
这意味着任何 gateway 崩溃或长时间等待都可能阻止第一个可见的桌面窗口被创建。
对于 Dock 行为,macOS 在所有窗口关闭后会有意保持应用进程存活。只有当 activate 能够可靠地重建或聚焦主窗口,并复用已运行的 gateway 实例(而不是尝试执行一次完整的重复启动)时,现有行为才可接受。
Goals
- 在启动和 Dock 激活时立即显示桌面窗口,先于 gateway 启动可能发生的阻塞或失败。
- 在桌面 UI 内呈现 gateway 启动失败,而不是让用户看不到任何窗口。
- 确保打包的 gateway 运行时包含 session storage 所需的 aiosqlite 兼容 API。
- 在 Dock 激活时复用已拥有的健康 gateway,而不是 spawn 一个重复进程。
- 在启动尝试已在进行中时,阻止重复的 gateway 启动。
- 让发布验证在分发 DMG 之前捕获陈旧的打包运行时内容。
Non-Goals
- 默认情况下不更改 macOS 关闭按钮的语义。关闭最后一个窗口可能让应用继续运行,除非产品决定改为 quit-on-close。
- 不要禁用 yoyo 迁移锁。
- 不要更改 gateway state directory、auth token 或持久化布局。
- 不要将先前构建的 DMG 视为已修复,除非它从修复后的源码重新构建并重新验证。
- 不要将未签名的本地验证等同于可分发的发布验证。
Root Causes
1. Packaged Gateway Runtime Was Stale
已构建应用包含了一份不含 _AsyncConnection.create_function 的 opensquilla.compat.aiosqlite 运行时副本。源码已向前推进,但打包运行时未包含该修复。
当 session storage 调用 create_function 时,打包的 gateway 会在变为健康状态之前崩溃。
2. Electron Created the Window After Gateway Startup
应用在调用 createMainWindow() 之前先等待 startGateway()。如果 gateway 启动崩溃、挂起或等待锁,桌面应用可能在没有任何可见窗口的情况下继续运行。
3. macOS Activation Path Did Not Reliably Resume UI
在 macOS 上,window-all-closed 不会退出应用。在按下红色关闭按钮后,Dock 圆点仍然存在,因为应用进程和 gateway 可能仍然存活。
点击 Dock 图标必须聚焦一个已有窗口,或创建一个新窗口并将其附加到已有的 gateway 状态。如果激活重新进入完整的启动路径,它可能会不必要地等待、触发重复启动 guard,或静默失败。
Design
Electron Startup
bootDesktopApp() 必须在 gateway 启动之前创建主窗口:
const window = await createMainWindow()
const gateway = await startGateway()
初始窗口应显示 boot/loading 状态。如果 gateway 启动成功,它加载 gateway URL。如果 gateway 启动失败,它渲染现有的启动错误状态,并附带日志详情和重试操作。
Idempotent Gateway Startup
startGateway() 应变为显式幂等:
- 如果一个被拥有的 gateway 进程已经健康,返回现有的
gatewayState。 - 如果启动已在进行中,不要 spawn 另一个 gateway。聚焦或保持 boot 窗口可见。
- 如果被拥有的进程已退出,在启动新进程之前清除陈旧的被拥有状态。
- 如果存在 gateway URL,在决定复用它之前对其进行健康检查。
这可以防止 Dock 激活、第二实例激活和重试操作竞争进入重复的 gateway 进程。
Dock Activation
activate handler 应调用一个单一的 helper,该 helper:
- 聚焦一个未被销毁的现有主窗口,或创建一个新的 boot 窗口。
- 如果
gatewayState.status === "ready"且 URL 通过健康检查,则加载该 URL。 - 如果启动正在进行中,保持 boot 窗口可见。
- 否则启动一次 gateway,然后加载或显示错误状态。
second-instance 处理应使用同一个 helper,使得第二次启动聚焦原始实例,而不会针对同一 state directory 启动另一个 gateway。
Window Close Semantics
macOS 红色关闭按钮可以让进程继续存活,但实现必须在 closed 时清除陈旧的窗口引用,并使激活能够重建窗口。
如果产品之后选择 quit-on-close,那应当是一个独立的产品决策。它会改变预期的 macOS 应用行为和 gateway 生命周期,因此不应被捆绑进此 bug 修复。
Gateway Runtime Compatibility
兼容层必须在 protocol 和 wrapper 实现上都提供 create_function:
class Connection(Protocol):
async def create_function(...)
class _AsyncConnection:
async def create_function(...)
wrapper 应以与其他兼容方法相同的线程安全风格委托给底层 sqlite 连接。
Packaging Guard
在构建可分发的 DMG 之前:
- 从当前源码重新构建 gateway 运行时。
- 如果
desktop/electron/runtime/gateway为空或陈旧,则使发布失败。 - 从当前源码构建 Electron 应用。
- 检查打包运行时并断言
create_function存在。 - 检查打包的
app.asar并断言主窗口在 gateway 启动之前创建。 - 对最终 DMG 进行签名、公证、stapling 和验证。
dist/desktop-electron 中先前生成的 DMG 应针对此 bug 视为陈旧,因为它是在最新的桌面/运行时修复之前构建的。
Unsigned Functional Testing
可以先构建一个未签名或 ad-hoc 签名的 DMG,用于快速的本地功能测试。此阶段应仅验证应用行为:
- 首次启动立即显示窗口;
- 进阶配置可以启动 gateway;
- 用红色关闭按钮关闭窗口后点击 Dock 图标可重新打开 UI;
- 不会创建重复的 gateway 进程;
- 日志中不包含
create_function崩溃或重复锁失败。
代码签名和公证不会重写 Electron 或 Python 的业务逻辑,因此它们不应改变预期的桌面/gateway 行为。然而,它们可以通过 Gatekeeper、quarantine 处理、Hardened Runtime、entitlements、嵌套可执行文件签名、动态库加载和子进程启动策略,改变 macOS 的实际运行时环境。
因此,未签名验证只是一次预发布冒烟测试。发送给外部用户的最终 artifact 仍必须经过重新构建、签名、公证、stapling 和端到端验证。
最终发布不得只对外层 .dmg 签名。.app bundle 和嵌套可执行文件必须在创建/签名/公证 DMG 之前完成签名。
Validation Plan
Automated
- 运行覆盖
_AsyncConnection.create_function的 gateway 兼容性测试。 - 运行 gateway 启动/锁测试,以确保重复启动和陈旧 yoyo 锁处理仍然有效。
- 添加或保留一项 Electron 端的启动顺序回归检查,理想情况下断言在桌面 boot 路径中
createMainWindow()在startGateway()之前运行。 - 添加一项 Electron 端测试或抽取的 helper 测试,用于激活复用:
- 复用 ready 的 gateway 状态;
- 启动进行中不会 spawn 第二个 gateway;
- 在重启前清除已退出的被拥有进程。
Manual DMG Smoke Test
对于首次本地通过,此测试可以使用未签名或 ad-hoc 签名的 DMG。对于外部分发,使用全新构建、已签名、已公证并已 stapling 的 DMG 重复同一测试。
- 移除或挪开现有的用户数据,以进行干净的首次运行测试。
- 从 DMG 安装应用。
- 从
/Applications启动;必须立即出现一个窗口。 - 完成或进入进阶配置;gateway 应启动,UI 应加载。
- 用红色关闭按钮关闭窗口。
- 确认 Dock 圆点仍然存在。
- 点击 Dock 图标;窗口必须重新打开并加载现有的 gateway UI。
- 确认只有一个
opensquilla-gateway进程在运行。 - 确认日志中不包含
create_function的 AttributeError。 - 确认日志中不显示 Dock 激活期间重复的 gateway state-dir 锁失败。
Release Validation
发布验证仅适用于最终的已签名、已公证 artifact。
在最终 artifact 上运行以下检查:
spctl --assess --type open --context context:primary-signature -v dist/desktop-electron/OpenSquilla-*.dmg
spctl --assess --type execute -v dist/desktop-electron/mac-arm64/OpenSquilla.app
xcrun stapler validate dist/desktop-electron/OpenSquilla-*.dmg
hdiutil verify dist/desktop-electron/OpenSquilla-*.dmg
同时挂载 DMG 并检查安装窗口布局。
Acceptance Criteria
- 从干净 DMG 安装的首次启动总是在 gateway 健康检查完成之前显示一个桌面窗口。
- gateway 启动失败在应用窗口中可见,并带有可操作的错误 UI。
- 打包的 gateway 运行时包含
_AsyncConnection.create_function。 - 红色窗口关闭后的 Dock 激活会重新打开 UI 并复用现有的健康 gateway。
- 第二次启动/聚焦行为不会 spawn 重复的 gateway 进程。
- 最终 DMG 经过签名、公证、stapling,并通过 Gatekeeper 验证。