10 KiB
Desktop DMG Startup Window Recovery Spec
Background
Users reported two macOS DMG installation issues:
- After installing the latest DMG, the first launch shows no window.
- In an earlier DMG, closing the client window with the red close button leaves the OpenSquilla app running in the Dock, but clicking the Dock icon does not reopen any window.
This spec covers the desktop startup and packaging fix. It is separate from the gateway/yoyo migration lock recovery spec, although both can surface during first-run gateway startup.
Evidence
Local packaged logs showed the bundled gateway crashing during first launch:
AttributeError: '_AsyncConnection' object has no attribute 'create_function'
The crash path is:
opensquilla/session/storage.py -> SessionStorage.connect()
The packaged runtime under the built app was missing create_function in opensquilla.compat.aiosqlite._AsyncConnection, while current source has the compatibility method.
The packaged Electron app also still had this startup order:
const gateway = await startGateway()
const window = await createMainWindow()
That means any gateway crash or long wait can prevent the first visible desktop window from being created.
For the Dock behavior, macOS intentionally keeps the application process alive after all windows are closed. The existing behavior is acceptable only if activate reliably recreates or focuses the main window and reuses the already-running gateway instead of trying to perform a full duplicate startup.
Goals
- Show a desktop window immediately on launch and Dock activation, before gateway startup can block or fail.
- Surface gateway startup failure inside the desktop UI instead of leaving the user with no visible window.
- Ensure the packaged gateway runtime contains the aiosqlite compatibility API needed by session storage.
- Reuse an already-owned, healthy gateway on Dock activation instead of spawning a duplicate process.
- Prevent duplicate gateway startup while a startup attempt is already in progress.
- Make the release validation catch stale packaged runtime contents before distributing a DMG.
Non-Goals
- Do not change macOS close-button semantics by default. Closing the last window may keep the app running unless product decides to switch to quit-on-close.
- Do not disable yoyo migration locking.
- Do not change gateway state directory, auth token, or persistence layout.
- Do not treat a previously built DMG as fixed unless it is rebuilt from the fixed source and revalidated.
- Do not treat unsigned local validation as equivalent to a distributable release validation.
Root Causes
1. Packaged Gateway Runtime Was Stale
The built app included a runtime copy of opensquilla.compat.aiosqlite without _AsyncConnection.create_function. Source had moved forward, but the packaged runtime did not contain that fix.
When session storage calls create_function, the packaged gateway crashes before becoming healthy.
2. Electron Created the Window After Gateway Startup
The app waited for startGateway() before calling createMainWindow(). If gateway startup crashes, hangs, or waits on a lock, the desktop app can remain running without any visible window.
3. macOS Activation Path Did Not Reliably Resume UI
On macOS, window-all-closed does not quit the app. After the red close button, the Dock dot remains because the app process and gateway may still be alive.
Clicking the Dock icon must focus an existing window or create a new one and attach it to the existing gateway state. If activation re-enters a full startup path, it can wait unnecessarily, hit duplicate startup guards, or fail silently.
Design
Electron Startup
bootDesktopApp() must create the main window before gateway startup:
const window = await createMainWindow()
const gateway = await startGateway()
The initial window should display a boot/loading state. If gateway startup succeeds, it loads the gateway URL. If gateway startup fails, it renders the existing startup error state with log details and retry actions.
Idempotent Gateway Startup
startGateway() should become explicitly idempotent:
- If an owned gateway process is already healthy, return the existing
gatewayState. - If startup is already in progress, do not spawn another gateway. Focus or keep the boot window visible.
- If the owned process exited, clear stale owned state before starting a new process.
- If a gateway URL is present, health-check it before deciding to reuse it.
This prevents Dock activation, second-instance activation, and retry actions from racing into duplicate gateway processes.
Dock Activation
The activate handler should call a single helper that:
- Focuses an existing non-destroyed main window, or creates a new boot window.
- If
gatewayState.status === "ready"and the URL passes health check, loads that URL. - If startup is in progress, keeps the boot window visible.
- Otherwise starts gateway once and then loads or shows an error state.
The same helper should be used by second-instance handling so a second launch focuses the original instance and does not start another gateway against the same state directory.
Window Close Semantics
The macOS red close button can keep the process alive, but the implementation must clear stale window references on closed and make activation recreate the window.
If product later chooses quit-on-close, that should be a separate product decision. It changes expected macOS app behavior and gateway lifetime, so it should not be bundled into this bug fix.
Gateway Runtime Compatibility
The compatibility layer must provide create_function on both the protocol and wrapper implementation:
class Connection(Protocol):
async def create_function(...)
class _AsyncConnection:
async def create_function(...)
The wrapper should delegate to the underlying sqlite connection in the same thread-safe style as the other compatibility methods.
Packaging Guard
Before building a distributable DMG:
- Rebuild the gateway runtime from current source.
- Fail the release if
desktop/electron/runtime/gatewayis empty or stale. - Build the Electron app from current source.
- Inspect the packaged runtime and assert
create_functionexists. - Inspect packaged
app.asarand assert the main window is created before gateway startup. - Sign, notarize, staple, and validate the final DMG.
The previously generated DMG in dist/desktop-electron should be treated as stale for this bug because it was built before the latest desktop/runtime fixes.
Unsigned Functional Testing
It is acceptable to build an unsigned or ad-hoc-signed DMG first for fast local functional testing. This phase should verify application behavior only:
- first launch shows a window immediately;
- advanced configuration can start the gateway;
- closing the window with the red close button and clicking the Dock icon reopens the UI;
- no duplicate gateway process is created;
- logs do not contain the
create_functioncrash or duplicate lock failures.
Code signing and notarization do not rewrite Electron or Python business logic, so they should not change the intended desktop/gateway behavior. However, they can change the effective macOS runtime environment through Gatekeeper, quarantine handling, Hardened Runtime, entitlements, nested executable signing, dynamic library loading, and child process launch policy.
Therefore, unsigned validation is only a pre-release smoke test. The final artifact sent to external users must still be rebuilt, signed, notarized, stapled, and validated end to end.
The final release must not sign only the outer .dmg. The .app bundle and nested executables must be signed before creating/signing/notarizing the DMG.
Validation Plan
Automated
- Run gateway compatibility tests covering
_AsyncConnection.create_function. - Run gateway startup/lock tests to ensure duplicate startup and stale yoyo lock handling still work.
- Add or keep an Electron-side regression check for startup order, ideally asserting
createMainWindow()runs beforestartGateway()in the desktop boot path. - Add an Electron-side test or factored helper test for activation reuse:
- ready gateway state is reused;
- startup-in-progress does not spawn a second gateway;
- exited owned process is cleared before restart.
Manual DMG Smoke Test
For the first local pass, this test may use an unsigned or ad-hoc-signed DMG. For external distribution, repeat the same test with a freshly built, signed, notarized, and stapled DMG.
- Remove or move aside existing user data for a clean first-run test.
- Install the app from the DMG.
- Launch from
/Applications; a window must appear immediately. - Complete or enter advanced configuration; the gateway should start and the UI should load.
- Close the window with the red close button.
- Confirm the Dock dot remains.
- Click the Dock icon; the window must reopen and load the existing gateway UI.
- Confirm only one
opensquilla-gatewayprocess is running. - Confirm logs do not contain the
create_functionAttributeError. - Confirm logs do not show duplicate gateway state-dir lock failures during Dock activation.
Release Validation
Release validation applies only to the final signed and notarized artifact.
Run these checks on the final 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
Also mount the DMG and inspect the installation window layout.
Acceptance Criteria
- First launch from a clean DMG install always shows a desktop window before gateway health checks finish.
- Gateway startup failures are visible in the app window with actionable error UI.
- Packaged gateway runtime contains
_AsyncConnection.create_function. - Dock activation after red-window-close reopens the UI and reuses the existing healthy gateway.
- Second launch/focus behavior does not spawn a duplicate gateway process.
- Final DMG is signed, notarized, stapled, and passes Gatekeeper validation.