experiments in a post-browser web
peek docs design kernel-desktops-implementation.md
33 kB
Markdown
at main

Desktops are kernel state: implementation notes #

Requirements, rules, model and acceptance checks: specs/kernel-desktops.md. This file holds what is specific to building it in this repo: motivation, the baseline, the decisions to pull into the kernel, the order of work and probe findings.

Motivation #

Peek item a0ea0c2d; parked track close-raises-other-desktop. Defect on main: with another app in front and two macOS desktops, a page opened from the palette on desktop 2 can follow the user to desktop 1, drop behind the other app, and vanish from desktop 2, because the window kernel knows nothing about desktops and every desktop rule in docs/design/window-rules.md is decided by native-call timing.

Baseline on main #

  • electron-window-backend.ts attachSpaceChangeListener only logs space-changed (macOS only); no event reaches the kernel.
  • window-kernel.ts State has no desktop. WindowRecord.desktopMembership records intent only ('follow' / 'anchor'), set for canvas windows by role (ipc.ts window-open handler, kernelSetDesktopMembership(…, desktopMembershipForRole(role))) and on key-reuse; page hosts from ipc.ts assembleHybridPageHost never get it and land on the desktop current at first show.
  • ElectronWindowBackend.setDesktopMembership realizes 'anchor' as the all-desktops-on-then-off pair, moving the window to the current desktop; no-op off macOS.
  • A transient page-host window is built with type: 'panel' (ipc.ts, pageOpensTransient() branch, options.type = 'panel'); Electron keeps such windows on every desktop (electron_ns_panel.mm setCollectionBehavior:).
  • isTransientPageHost reads only appActive, key and the record; a desktop switch replaces a transient page-host window only via the OsFocusLost from ElectronWindowBackend.armBlurSettle → kernelOsFocusSettled, so 5c and 5d are indistinguishable.
  • react emits ReopenAsOrdinaryWindow { id }; ipc.ts reopenWindowAsOrdinary() opens the reopened window then closes the transient page-host window via a promise chain; the reopened window lands on the desktop switched to.
  • successorOnHide() (WindowHidden / WindowClosed arms of evolve) picks the prior key window, else topmost(state, isContentWindow), ignoring desktops. Raising a window on another desktop switches desktop (raise-realized id=wm-3 then space-changed).
  • Switcher selection (tile-ipc.ts → kernelArmSelectionFocus) is spent in the WindowClosed arm as a lastFocusAssert, which carries no user intent.
  • Hybrid URL dedup in the ipc.ts window-open handler (findHybridWindowsByUrl → kernelShowRequested(reusedId, true)) runs before the pageOpensTransient() check and reuses a window on any desktop.

Known gap: modal browser windows are also built with type: 'panel' on macOS (ipc.ts, options.modal === true branch) but are not nonActivating, so the backend does not declare them on every desktop. The macOS helper's placement read reports them; revisit before the F9 gate.

Out-of-kernel decisions to move #

One commit each, order per "Order of work":

  1. Dropped: 5e now closes the page, so there is no reopen to order.
  2. ipc.ts hybrid URL dedup → kernel predicate admitsReuse(state, record); the pageOpensTransient() check moves ahead of the dedup. The rule check watches for a hidden window re-shown on a desktop that is not current.
  3. window-kernel-executor.ts RaiseWindow / planRaise → react stops emitting what the 'suppressed' plan (app inactive, no user intent) drops, together with F9; planRaise keeps headless and non-activating realization choices.
  4. electron-window-backend.ts focus filters (win.on('focus') drops reports on pendingStackingFocus, pendingHideProvokedFocus, activationFocusTarget) → the kernel records each expectation when it emits the causing effect (stacking replay, hide of the key window, activation with pendingColdStartFocus) and classifies the report; the backend reports every focus with its raw cause. hideProvokesFocusReassignment() becomes a kernel fact likewise.
  5. electron-window-backend.ts appResignSettleTimer (reads izui overlay cooldowns, drops the settle) → the timer stays; the overlay transition becomes a kernel fact that makes AppResignSettled inert in evolve.
  6. main.ts devtools-opened / devtools-closed (kernelAssertFocus() on the topmost content window, possibly on another desktop) → retargeted to the host whose devtools opened or closed by commit 5f888241 of track close-raises-other-desktop, cherry-picked here; F9 also refuses a machine focus elsewhere.

Stays outside: armBlurSettle (translates Electron's event order into "focus left every window"; decides no rule).

Relationship to close-raises-other-desktop #

  • One commit of the parked track carries over: 5f888241 (the devtools-opened / devtools-closed handlers in main.ts assert focus on the devtools host, not the topmost content window). It is cherry-picked into this track at commit 12 below (decision 6). Its lines in docs/design/window-rules.md and docs/design/window-rules-audit.md that describe the parked track's F7 are dropped in favour of the spec's F7 when resolving the pick.
  • Everything else in the parked track is superseded by the spec's F7 and F9 design: its transient / application split, its removal of the successor choice for application-window closes, and its focusing of the reopened window on activation (covered by the spec's close and focus ordering).
  • The parked track is retired once this track lands; it is not rebased.

Order of work #

The macOS helper and its probe come before the kernel reshape because everything rests on the helper's reads; if they fail, nothing has yet been built on them.

  1. Landed as 250051ad (minted ids, DesktopChanged); reshaped by commit 5.

    • Survives: DesktopId; WindowRecord.desktop; WindowRecord.onEveryDesktop with its registration plumbing (WindowRegistered event and RegisterWindow command fields, window-machine.ts registerWindowWithMachine, ElectronWindowBackend.registerWindow returning it for windows built with type: 'panel' on macOS); isOnEveryDesktop; the provisional stamp on a genuine WindowShown (desktopOnShow, changed to read the window's screen's current desktop instead of State.desktop); the attachSpaceChangeListener subscription as switch trigger; the native-blur and os-focus-left-app log lines; desktop-state tests not depending on minting.
    • Removed by commit 5: State.desktop, State.desktopCounter, mintedDesktopId, DesktopChanged and its evolve arm, kernelDesktopChanged in window-kernel-dispatch.ts, the focused read in attachSpaceChangeListener, the stamp in the OsFocusGained arm, and tests for minting, name, focused stamping and focus stamping.
    • Changed by commit 5: onCurrentDesktop keeps name and meaning over the new state; the space-changed log line prints each screen's current desktop and each window's id:screen/desktop|*|?:v|h from the new fields.
  2. Rename the old identifiers, log lines and prose to the transient-window names, no behaviour change, own commit. The code and docs carry an older, retired name for the transient page-host window. In the table, ⟨old⟩ stands for that retired two-word term in the case form of the name it sits in: camelCase or PascalCase in identifiers, upper snake case in constants, hyphenated in log lines, file names, profile names and rule labels. grep -rn -i 'side.\?task' apps/desktop docs specs lists every occurrence and returns nothing once the step is done.

    Old New Kind
    is⟨old⟩ isTransientPageHost kernel predicate (window-kernel.ts)
    pageOpensAs⟨old⟩ pageOpensTransient open-time predicate (izui-roles.ts)
    ⟨old⟩Setup transientPageHostSetup executor test fixture
    ⟨old⟩InFlight transientPageHostHoldingKey kernel test fixture
    ⟨old⟩_REOPEN_STRIP TRANSIENT_PAGE_REOPEN_STRIP constant (ipc.ts)
    ⟨old⟩-reopen transient-page-reopen log line, and the […] prefix of two console.error lines
    ⟨old⟩-reopen-dropped transient-page-reopen-dropped log line
    page-opens-as-⟨old⟩ page-opens-transient log line
    ⟨old⟩-standin-misplaced transient-placeholder-misplaced planned log line, since dropped with the placeholder window; only docs carried the old name
    G13 ⟨old⟩-ends G13 transient-page-ends rule label in window-rules.md
    specs/⟨old⟩-ends-on-blur.md specs/transient-page-ends-on-blur.md spec file, moved with git mv
    ⟨old⟩Escape (type), ⟨old⟩_ESCAPE, ⟨old⟩Escape (parameter) TransientPageEscape, TRANSIENT_PAGE_ESCAPE, transientPageEscape already removed from code; named in the docs' history
    agent-⟨old⟩-1 agent-transient-page-1 profile name in a live-check recipe
    the retired term in prose "transient page-host window" for the window; "replaced by the reopened window" for its end comments, test titles, assertion messages, doc prose

    Files, with what each carries:

    • apps/desktop/main/window-kernel.ts: is⟨old⟩ (definition and its use in react), comments.
    • apps/desktop/main/window-kernel.test.ts: is⟨old⟩ import and uses, ⟨old⟩InFlight, describe and test titles.
    • apps/desktop/main/window-kernel-executor.test.ts: ⟨old⟩Setup and its comment.
    • apps/desktop/main/izui-roles.ts: pageOpensAs⟨old⟩ and its comment.
    • apps/desktop/main/izui-roles.test.ts: pageOpensAs⟨old⟩ import and uses, describe and test titles.
    • apps/desktop/main/ipc.ts: pageOpensAs⟨old⟩ import and call, ⟨old⟩_REOPEN_STRIP, the log lines ⟨old⟩-reopen, ⟨old⟩-reopen-dropped and page-opens-as-⟨old⟩, the console.error prefixes, comments on reopenWindowAsOrdinary() and in registerWindowHandlers.
    • apps/desktop/main/window-backend.ts, apps/desktop/main/electron-window-backend.ts, apps/desktop/main/window-escape.ts: comments.
    • apps/desktop/main/window-escape.test.ts: one test title.
    • apps/desktop/tests/desktop/cmd-url-unfocused.spec.ts: header and inline comments, one test title, one assertion message.
    • docs/design/window-rules.md: prose, the G13 label, the removed ⟨old⟩_ESCAPE constant.
    • specs/⟨old⟩-ends-on-blur.md: moved as above; prose, log lines, the three removed Escape names, the profile name.
    • specs/cmd-url-linux-failures.md: prose.

    Verify: the tracks.json verify, node scripts/check-platform-leak.mjs, and the grep above returning nothing.

  3. Done after the live checks: the PEEK_DESKTOP_PROBE hooks (installDesktopProbeShortcut, probeRead, probeWriteTest and their test windows) are removed from electron-window-backend.ts. MacDesktopProvider.probePlace and the helper's place op stay.

  4. Providers and the macOS helper, no kernel change: desktop-provider.ts, desktop-provider-fallback.ts, main.swift, build-desktop-helper.mjs, the extraResources entry, desktop-provider-mac.ts, the fail-safe, the check-platform-leak.mjs additions, and temporary probe hooks behind PEEK_DESKTOP_PROBE=1. The kernel has no desktop events yet, so the probe hooks are the provider's only report sink: they log its reads directly. Then the probe (the spec's macOS provider acceptance check) on a real Mac with a person. Stop and report if ids are not stable across returns; never fall back to letting the page follow the user. Two backend fixes found by the probe follow here, one commit each: the shared page chrome window on every desktop (hybrid-overlay.ts ensureOverlay()), and the transient page-host window pinned to the desktop it is shown on — not possible for a panel, which Electron keeps on every desktop; done instead by building it as an ordinary window flagged non-activating (4e6ada21), later every transient window (52e4f46a, 26b5be31).

  5. Kernel model reshape. Kernel: State.screens / State.desktops, WindowRecord.screen, the three spec events plus DesktopSwitchObserved, isCurrentDesktop, reshaped onCurrentDesktop. Backend: the report → event loop (electron-window-backend.ts onDesktopTrigger / syncDesktops, reads serialized), fed by whichever provider started. No rule reads the new state; behaviour unchanged on every platform (pinned by the property test "the fallback provider changes no effect"). DesktopSwitchObserved carries the switch-closes-the-page behaviour of 96fe4006, which lived in the removed DesktopChanged arm: it is sent synchronously from the switch notification because the provider's read that yields CurrentDesktopChanged is asynchronous and an activation can arrive first.

  6. Dropped: 5e now closes the page, so there is no reopen to order.

  7. Done ahead of the reshape, 84a123ee: a transient page-host window that loses focus with the app inactive (click-away, desktop switch) closes instead of being reopened, because Electron panels are on every desktop (see "Research: Electron panels and desktops"). The earlier plan (defer the reopen until the user returns, reopenPending) is dropped.

  8. Split in two:

    • 8a Done: F9 gate + F7 rewrite. successorOnHide() applies onCurrentDesktop to both branches, and every raise react emits without user intent (successor, parent refocus, key move on an OS focus report, focus assert and its content focus, raise request, activation latch) passes one predicate, admitsMachineRaise(), which evolve also asks where it stamps a token, so an ungated raise mints none. The WindowClosed arm already chose a successor for any key holder, application windows included. The switcher selection spend is exempt: a pick is an explicit choice and rule 1 takes the user to the picked window's desktop, so it is gated on admitsFocusAssert() only, marked by FocusAssertRequest.switcherPick. Until 8b it still carries no intent: 'user', and no ShowDesktop precedes it.
    • 8b-1 Done: one desktop rule. admitsFocus(state, record, requester) with requesters MACHINE_REQUEST, USER_REQUEST, SWITCHER_PICK replaces admitsMachineRaise and admitsFocusAssert at every site in evolve and react, including successorOnHide; withinReach is its desktop half. With it:
      • user intent alone no longer reaches another desktop (rule 5); only a switcher pick does;
      • a switcher pick's RaiseWindow carries intent: 'user', preceded by ShowDesktop when the window is not on a current desktop (pushRaise); the executor only logs ShowDesktop, since no backend can switch desktops;
      • FocusAssertRequested and FocusRaiseRequested stamp lastOpSeq only when they issue an op;
      • the hold above other apps (and its cold-start latch) needs the target within reach. Kept as it was: a key move the OS reports is still gated on the desktop, because a late report for a window on a desktop the user just left must not pull the user back.
    • 8b-2: the app-active half. App inactive and no user intent → not admitted, so react stops emitting what planRaise suppresses (decision 3), and planRaise keeps headless and non-activating choices only. The hold above other apps asks withinReach only, since it stands in for exactly that raise. Also: the cold-start latch never names a window dismissed in the same transition; the property test of spec section 4 "Enforcement".
    • Settled: successorOnHide returns nothing when the window that hid or closed took key from nothing, so a transient window opened over another app returns the user to that app; spec rule 9 and F7 now say so. Four kernel tests pin it.
    • 8c: one desktop stamp on show, chrome on this desktop. Every write flipping visible to true goes through one helper that stamps the desktop (today the re-show on app activation and the overlay reveal skip it). topmostBaseLayerPageHost picks only a page-host window on a current desktop.
  9. Choosing a window moves into the kernel. reusableHere and frontWindowHere (spec section 4 "Queries"), then every site outside the kernel that picks a window uses them, one commit per group:

    • 9a Done — reuse: the finders return every match (findHybridWindowsByUrl, findHybridWindowsByKey, main.ts findWindowsByKey / findWindowsByUrl) and window-machine.ts pickReusableHere takes the kernel's reusableHere answer. A keyed BrowserWindow found only off every current desktop is closed there (closeHostWindow, log window-open-closed-elsewhere); a page-host window found there stays. Workspace restore reaches reuse through the same handler, so it is covered too. Was: ipc.ts windowOpenHandler (findHybridWindowsByUrl, findHybridWindowsByKey, main.ts findWindowsByKey / findWindowsByUrl), chrome-extensions.ts openChromeExtensionPage, and workspace restore through the same handler. The pageOpensTransient() check moves ahead of reuse. A one-per-app window found only off every current desktop is closed there and opened here. Replaces decision 2.
    • 9b the window the user is looking at: window-machine.ts getTopmostContentWindowId and its consumers (tile:window:get-focused-visible-id, tile:dialogs:save / open, tileResolveWebWindow, the fullscreen and colour-scheme defaults, tile:window:devtools), windows.ts resolveFocusedWindowIdForClose, the Cmd+W devtools guard in entry.ts, and ipc.ts openNewPageWindow. The devtools-opened / devtools-closed handlers in main.ts take 5f888241 from track close-raises-other-desktop (decision 6).
    • 9c requests by id: tile:window:focus and tile:window:show carry named, false unless the request is a switcher pick (features/windows/windows.js).
    • 9d the static check of spec section 4 "Enforcement", with the finders and topmost as its listed names and an allowlist that only shrinks.
  10. Decision 4 (focus filters). Done: State.expectedPlatformFocus holds the three expectations. window-kernel.ts expectPlatformFocus records them from the effects react emits: a hide of the key window while the app is active and windows hold real focus, and a restack where the backend's restackingTakesFocus says restacking takes focus. AppActivated records the activation's asked-for window. platformChoseFocus classifies each OsFocusGained, and a matched world-caused report is spent and otherwise ignored. AppResigned clears all three, which also retires a hide the platform made itself (the app hidden), recorded like the kernel's own because both arrive as WindowHidden. The backend reports every focus with its raw cause.

  11. Decision 5 (resign settle). Done: the switcher opening (ipc.ts windowOpenHandler, beside enterOverlay) and closing (main.ts, beside exitOverlay) dispatch OverlayTransitionObserved, which sets State.overlayTransitionSinceResign; AppResigned resets it, and AppResignSettled is inert while it holds. The backend's settle timer no longer reads the coordinator's cooldowns. With the 500 ms settle, those cooldowns (500 ms after entry, 300 ms after exit) only ever held for a transition after the resign, so the kernel rule needs no clock; it is slightly wider for an exit in the first 200 ms after a resign.

  12. Folded into 9b.

  13. Linux on X11, separate unscheduled track: EWMH provider with reads and writes.

Open, not scheduled: DesktopSwitchObserved carries no screen, so a switch on another screen still ends a transient page-host window (spec rule 3 says it changes nothing); and whether the stacking replay's move-to-top of a window on another desktop can switch desktop is unverified.

The macOS provider acceptance check runs after commit 4; the rule acceptance check after commit 8.

Running the live checks #

Run from the kernel-desktops worktree with PROFILE=kernel-desktops PEEK_PROFILE=kernel-desktops TMPDIR=<worktree>/.tmp (create .tmp first). The agent builds, starts the app, reads main.log, and asks the person only for the gestures, stdout to <worktree>/.tmp/<name>.log. The PEEK_DESKTOP_PROBE=1 mode that logged the helper's placements is removed (step 3).

Done when #

Kernel, property, provider and executor tests pass; the tracks.json verify and the platform-leak check are green; probe answers are recorded under "Probe findings"; the rule check behaves as listed in the spec; the full gate (yarn test:desktop:electron, only with an explicit OK) shows no failure outside apps/desktop/tests/gate-baseline.md.

Linux verification #

apps/desktop/CLAUDE.md lists desktops among what Linux runs do not verify. X11 behaviour is checkable on the remote Linux dev box's virtual X server; whether its window manager implements EWMH desktops is unverified. Wayland cannot be checked there.

Research: Electron panels and desktops #

Web and history research, 2026-09-28, Electron 43.0.0. Nothing here was run.

Electron cannot keep a type: 'panel' window on one desktop. ElectronNSPanel (shell/browser/ui/cocoa/electron_ns_panel.mm, unchanged since PR #34388, Electron 20) overrides the setter:

- (void)setCollectionBehavior:(NSWindowCollectionBehavior)collectionBehavior {
  NSWindowCollectionBehavior panelBehavior =
      (NSWindowCollectionBehaviorCanJoinAllSpaces |
       NSWindowCollectionBehaviorFullScreenAuxiliary);
  [super setCollectionBehavior:collectionBehavior | panelBehavior];
}

NativeWindowMac::SetVisibleOnAllWorkspaces only sets or clears join-all-Spaces and full-screen auxiliary through that setter, so on a panel the clear has no effect; so do setFullScreenable and setHiddenInMissionControl. Electron's docs (base-window-options.md, type: 'panel') say the panel "will appear on all spaces (desktops)". No API changes a window's type after creation. ElectronNSPanel is an ElectronNSWindow subclass whose styleMask getter adds the non-activating bit, not a real NSPanel. No Electron issue asks to pin a panel to one desktop. Links: https://github.com/electron/electron/blob/v43.0.0/shell/browser/ui/cocoa/electron_ns_panel.mm, https://github.com/electron/electron/pull/34388, https://github.com/electron/electron/pull/52914 (open, keeps the override), https://github.com/electron/electron/issues/53889 (macOS 27: clicking a panel activates the app; workaround _setPreventsActivation:).

Repo history. Ordinary windows stayed on their desktop through the on-then-off setVisibleOnAllWorkspaces pair right after construction and before any show (6b3651b9, 2026-05-15, the ancestor of desktop membership anchor); TRANSIENT_ROLES windows kept join-all-Spaces. Pages opened from the palette became panels in e77f6cdc (2026-09-20); an attempt to open them as ordinary windows outright (41b76804) was reverted minutes later (e5046f79). The reopen path (a2137355, ipc.ts reopenWindowAsOrdinary()) creates a new window through the generic open, so it lands on the desktop the user is on when it runs. docs/macos-spaces-analysis.md records the earlier Spaces root causes.

AppKit facts. A managed window (default collection behaviour) belongs to the desktop active when it is first ordered on screen and stays bound to it after orderOut; showing it again takes the user back there (electron#8734). Only one of managed, join-all-Spaces and move-to-active-Space may be set. Full-screen auxiliary does not mean every desktop; appearing over another app's full-screen desktop needs join-all-Spaces or move-to-active-Space as well. A non-activating window that is managed is legal AppKit. Toggling the non-activating style bit after creation leaves the window server's prevents-activation tag stale; _setPreventsActivation: (private) re-syncs it (https://philz.blog/nspanel-nonactivating-style-mask-flag/, https://github.com/mitchchn/FunWithPanels). Moving a window to a given desktop needs private SkyLight calls that broke in macOS 14.5, 15 and 27 (Hammerspoon libspaces.m, issue #3698, PR #3889).

Prior art. No shipped app or library was found doing the whole flow (transient non-activating page → persistent window pinned to its desktop). Pieces:

  • @akiflow/electron-panel-window (maintained, shipped in Akiflow, Electron 43.1.0): makePanel swaps the window's class to a panel class with object_setClass and calls _setPreventsActivation:YES; makeWindow swaps it back and calls NO. Its panel class overrides the collectionBehavior getter (join-all-Spaces). Warnings: never call setVisibleOnAllWorkspaces(true) on it; call makeWindow before close. Its desktop behaviour after makeWindow is undocumented. https://github.com/akiflow/electron-panel-window
  • goabstract/electron-panel-window: class swap with move-to-active-Space instead of join-all-Spaces. https://github.com/goabstract/electron-panel-window

Candidate realizations for a transient page-host window that stays on its desktop, none verified:

  1. An ordinary window (not type: 'panel') plus a small native addon calling _setPreventsActivation:YES and making it key, NO to promote it to an ordinary window. Being managed, it stays on the desktop it opened on, and promotion replaces nothing. Unverified: keyboard focus without activating the app from a plain window on current macOS. Cost: cannot show over another app's full-screen desktop.
  2. akiflow's class swap, with the desktop set while the user is still on the original desktop (managed behaviour, then swap back on loss of focus or activation, before any switch).
  3. Stock Electron: keep the panel, and create the reopened window at open time on the same desktop, ordered on screen once (transparent or behind) and then out, so it stays bound there; show it at reopen time. Showing it later may take the user back to that desktop.
  4. Record the desktop at open and move the reopened window back with private SkyLight calls. Most fragile.

The panel travelling with the user during the switch gesture is fixed only by 1 or 2; 3 and 4 fix only where the reopened window lands.

Chosen for now (84a123ee): none of the four. Losing focus with the app inactive closes the transient page-host window, and nothing is reopened. The panel was since replaced by an ordinary window flagged non-activating (4e6ada21), which stays on its own desktop, so the page no longer shows on both desktops during a switch gesture. Keeping a page open across a click-away is not planned: a transient window ends when the user leaves it.

Probe findings #

First probe (inferred ids):

  • macOS order for a switch away from a transient page-host window: space-changed, then native-blur and os-focus-left-app ~20ms later, then OsFocusLost and the reopen. The switch always preceded the blur. The design does not depend on this order.
  • Inferred ids fail: every switch minted a new id (two desktops → desktop-2 … desktop-15), so returns were never recognised. Electron exposes no desktop identity on macOS. Hence real desktop ids read through a provider.
  • Behaviour matched main (page reopened on desktop 1).

Second probe, native helper (2026-09-28, stopped after gesture 3): one screen, two desktops, build c3647846, log kept only locally.

  • Precondition met: desktop-provider started name=mac; desktop-probe-hello with all four symbols resolved, including CGSMoveWindowsToManagedSpace.
  • Gesture 1 passed: the transient page-host window read * (every desktop); layout 1@1,3@1, desktop 2 (id 3) current.
  • Gesture 2 passed: current went 3 → 1 → 3; ids are stable across returns (the old space-changed line kept minting desktop-N in the same run).
  • Gesture 3 passed: the reopened window read 1/3 (desktop 2) on every read, matching what was on screen.
  • Gestures 4–6 not run, blocked by a new defect: the page chrome window (role overlay, one shared window) sits on desktop 1. Arriving on desktop 2, where the reopened window is the front Peek window, activates Peek; the overlay is shown for that window from desktop 1 and macOS switches back to desktop 1 about 0.4s later, on every arrival. Clicking the page on desktop 2 does the same. The space-changed lines show each arrival on desktop 2 followed by a switch with appActive=true key=wm-9 and wm-1 (the overlay) turning visible.
  • Consequence for the spec: rule 1's TRANSIENT_ROLES exception (they appear on the desktop the user is on) needs a realization for the overlay window, for example declaring it onEveryDesktop in the Electron backend, before any gesture that visits a desktop holding a page-host window.

Third check, overlay on every desktop (2026-09-28, after 31c4ac84): one screen, two desktops.

  • The overlay reads * from start. Switching onto desktop 2 while it holds a page-host window no longer pulls the user back to desktop 1: the person stayed on desktop 2.
  • Remaining defect, seen by the person and in the log: after opening a URL from the palette on desktop 2 and switching to desktop 1, the page is visible on desktop 1. Log order: space-changed (the transient page-host window wm-9 read *), native-blur and os-focus-left-app 7ms later, then effect-reopen-as-ordinary and transient-page-reopen; the reopened window wm-10 then reads 1/1, desktop 1. Two causes: the panel is on every desktop, so it travels with the switch, and the notification arrives only after the switch, too late to hide it; and the reopened window is created on the desktop the user is on.
  • Decision taken from it: the transient page-host window is pinned to the desktop it is shown on, and a switch away defers the reopen until the user returns to that desktop (spec rules 2 and 3). The placeholder window and its probe gestures (the helper write test, the Mission Control check) are dropped.

Fourth probe, a kept-alive flagged window re-shown on another desktop (2026-09-29). Throwaway Electron script (log kept only locally): an ordinary window flagged non-activating (nonactivating-window.ts flagNonActivating), floating level, Finder frontmost; shown and made key on desktop 1, hidden, desktop switched right, then shown and made key again, as the palette and the switcher are.

  • With AppKit's move-to-active-desktop collection behaviour set on the window: the re-show appears on desktop 2, no desktop switch follows, Finder stays frontmost, the window is key and receives typing. Switching back to desktop 1 while it is visible leaves it on desktop 2: it moves only when shown, never follows a switch.
  • Without it: the re-show stays on desktop 1, invisible from desktop 2, yet becomes key, so typing lands in a window the user cannot see. No desktop switch follows either, since the app is not activated.
  • Consequence: the palette and the switcher can be flagged ordinary windows kept alive, with the move behaviour set by the addon; the close-and-rebuild fallback is not needed.

Fifth probe: the page chrome overlay on a switch (live, macOS) #

  • As a window on every desktop the overlay was drawn on the new desktop during a switch, until the switch notice arrived.
  • Anchoring it and re-applying Electron's setVisibleOnAllWorkspaces pair while it was hidden did not move it: showing it over a page on another desktop made macOS switch back to the overlay's old desktop, about 0.4 s after each show, in the log as an unrequested space-changed.
  • Setting only the move-to-active-desktop collection behaviour through the addon (setMoveToActiveDesktop, applied in ElectronWindowBackend.showWindow to the anchored overlay before each show) fixed both: no bar on the new desktop during a switch, no switch back, the bar shown on both desktops, and a page opened over another app kept that app frontmost with the bar taking key. Log line overlay-move-to-active.

Default-browser prompt during a gate run #

A packaged gate run raised the OS prompt to make Peek the default browser. The system log (/usr/bin/log show, predicate on setting handler for scheme) showed a packaged Peek process asking about 0.6 s after launch, then exiting while the dialog stayed up. The source has one registration call, the Settings button handler; no spec reaches it and single specs did not reproduce it, so the trigger is unknown. default-protocol-guard.ts refuses any registration in a headless run and logs the caller (default-protocol-client-requested); the handler also logs the requesting page (set-default-browser-requested).