Add Stream Deck plugin to move Zoom's dual-monitor second window #1

Merged
aporter merged 9 commits from feat/zoom-second-window into main 2026-11-02 00:05:18 +10:00
Owner

Windows-only Stream Deck plugin (SDK v3, Node 24). One action moves Zoom's dual-monitor second window, and never the main meeting window, between two saved locations, A and B.

Why

In Zoom's dual-screen mode, both meeting windows share the Win32 class ZPContentViewWndClass, and usually the same title. Generic window movers can't tell them apart.

How

  • Learn-first, fail-closed. Hold the key about 1 s while the second window is focused. The plugin remembers it by HWND, PID, process start time and class. A short press moves the window, on key-up only.
  • It refuses when there are fewer than two meeting windows, and forgets the learned window on any topology or identity change. It re-checks identity before every mutation.
  • Every move is verified before success is shown. If the window was rescaled (DPI change), the position is applied once more. "Maximize" locations are matched by monitor.
  • Auto-detect is opt-in and experimental. Ownership is the only evidence it accepts, a main-looking title vetoes, and a guess is never remembered.
  • The Property Inspector captures positions, has a "learn in 3 s" button, and a Diagnose view that lists every Zoom window with digits in titles masked. Replies are scoped to the key that started them.
  • Win32 access is via koffi. The build stages only the win32-x64/arm64 binaries, so the plugin builds on any OS.
  • CI typechecks, tests, builds, validates and packs. It then checks the package contents and uploads the .streamDeckPlugin as an artifact.

Test evidence

  • tsc --noEmit is clean. npm test: 52/52 pass (classifier, geometry, and the controller against a fake native adapter).
  • npm run build, streamdeck validate and streamdeck pack all succeed (37 files, 2.6 MB, both win32 koffi.node binaries present). The staged koffi loader was smoke-tested with the host binary.
  • gitleaks: no leaks. The whitespace check is clean.
  • Not run on Windows. src/win32.ts and the PI HTML have not been exercised. Validate on hardware with Diagnose first.

Review

Review-gate: codex gpt-6.1-sol ship

  • Gate 1 (plan): fix-then-ship. Adopted: learn-first/fail-closed instead of heuristics, a durable identity (HWND + process start), a key-up/hold gesture state machine, a single toggle rule including maximized handling, an explicit koffi ABI and verified moves, and CI packing.
  • Gate 2 (diff): fix-then-ship → ship after feb2bbd.
    1. A PID cache defeated the reuse check. The cache is now per-scan only.
    2. No re-check ran between mutations. A guard now runs before restore, each SetWindowPos and maximize.
    3. The same-title veto could be bypassed. The veto is now absolute.
    4. A maximized window could pass verification. Verification now checks full state.
    5. PI replies could reach the wrong key. They are now context-scoped.
    6. CI ran checkout before installing Node. Withdrawn: the same runner's workbench CI does this.
  • The reviewer noted, as non-blocking, that regression tests cover 2–4 directly. Items 1 and 5 were verified by inspection, not tests.

🤖 Generated with Claude Code

Windows-only Stream Deck plugin (SDK v3, Node 24). One action moves Zoom's **dual-monitor second window**, and never the main meeting window, between two saved locations, A and B. ## Why In Zoom's dual-screen mode, both meeting windows share the Win32 class `ZPContentViewWndClass`, and usually the same title. Generic window movers can't tell them apart. ## How - **Learn-first, fail-closed.** Hold the key about 1 s while the second window is focused. The plugin remembers it by HWND, PID, process start time and class. A short press moves the window, on key-up only. - It refuses when there are fewer than two meeting windows, and forgets the learned window on any topology or identity change. It re-checks identity before every mutation. - Every move is verified before success is shown. If the window was rescaled (DPI change), the position is applied once more. "Maximize" locations are matched by monitor. - Auto-detect is opt-in and experimental. Ownership is the only evidence it accepts, a main-looking title vetoes, and a guess is never remembered. - The Property Inspector captures positions, has a "learn in 3 s" button, and a Diagnose view that lists every Zoom window with digits in titles masked. Replies are scoped to the key that started them. - Win32 access is via koffi. The build stages only the win32-x64/arm64 binaries, so the plugin builds on any OS. - CI typechecks, tests, builds, validates and packs. It then checks the package contents and uploads the `.streamDeckPlugin` as an artifact. ## Test evidence - `tsc --noEmit` is clean. `npm test`: 52/52 pass (classifier, geometry, and the controller against a fake native adapter). - `npm run build`, `streamdeck validate` and `streamdeck pack` all succeed (37 files, 2.6 MB, both win32 `koffi.node` binaries present). The staged koffi loader was smoke-tested with the host binary. - gitleaks: no leaks. The whitespace check is clean. - **Not run on Windows.** `src/win32.ts` and the PI HTML have not been exercised. Validate on hardware with *Diagnose* first. ## Review Review-gate: codex gpt-6.1-sol ship - **Gate 1 (plan): fix-then-ship.** Adopted: learn-first/fail-closed instead of heuristics, a durable identity (HWND + process start), a key-up/hold gesture state machine, a single toggle rule including maximized handling, an explicit koffi ABI and verified moves, and CI packing. - **Gate 2 (diff): fix-then-ship → ship after feb2bbd.** 1. A PID cache defeated the reuse check. The cache is now per-scan only. 2. No re-check ran between mutations. A guard now runs before restore, each SetWindowPos and maximize. 3. The same-title veto could be bypassed. The veto is now absolute. 4. A maximized window could pass verification. Verification now checks full state. 5. PI replies could reach the wrong key. They are now context-scoped. 6. CI ran checkout before installing Node. **Withdrawn:** the same runner's workbench CI does this. - The reviewer noted, as non-blocking, that regression tests cover 2–4 directly. Items 1 and 5 were verified by inspection, not tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Windows-only Stream Deck (SDK v3, Node 24) plugin with one action that
moves Zoom's dual-screen second window between two saved locations.

Both Zoom meeting windows share a Win32 class and title, so the plugin is
learn-first and fails closed: hold the key while the second window is
focused to learn it (HWND + PID + process start time + class), press to
move. It refuses with fewer than two meeting windows, forgets the learned
window when it disappears, verifies every move before reporting success,
and re-applies once after a DPI rescale. Auto-detect (ownership only) is
opt-in and experimental.

Win32 access is via koffi; the build stages only its win32-x64/arm64
binaries so the plugin can be built on any OS. Includes unit tests for
the classifier, geometry and controller (fake native adapter), a
diagnose command for tuning on real hardware, and Forgejo CI that
builds, validates and packs the .streamDeckPlugin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Address Gate 2 review: identity re-checks, verified state, PI scoping
Some checks failed
ci / secrets (pull_request) Successful in 10s
ci / build (pull_request) Failing after 10s
feb2bbd8df
- win32: read process creation time fresh on every identity check (cache
  only within one scan) so PID reuse is always detected; set DPI
  awareness in every adapter method.
- controller: re-validate the window's identity and that two meeting
  windows still exist immediately before every mutation (restore, each
  SetWindowPos attempt, maximize); forget the learned window and stop on
  loss. Success for a normal location now requires not minimized, not
  maximized and the matching rect. forget() is serialized and cancels a
  pending countdown learn.
- auto-detect: an owned window whose title looks like the main window is
  always vetoed (covers the same-title case).
- Property Inspector replies carry the originating action context; the
  inspector ignores replies meant for another key.
- Regression tests for each finding (52 tests).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Production need (verified in a Win11 VM with Zoom Workplace in a
dual-monitor meeting, JW Library 15.9.54 and OnlyT):

- Zoom's two meeting windows share class, title and owner; the main one
  has a ZPControlPanelClass (meeting toolbar) child, the second doesn't.
  roles.ts identifies them with no user interaction and fails closed
  unless exactly one of exactly two meeting windows has the toolbar.
- JW Library's media window is a monitor-sized ApplicationFrameWindow in
  z-band 8 (invisible to EnumWindows, found via FindWindowEx) hosting
  JWLibrary.exe; it can't be covered by z-order, but can be minimised
  and restored from outside.

cover.ts: press 1 minimises JW's media window and makes Zoom's second
window borderless over that display; press 2 restores only the style
bits removed, replays the saved WINDOWPLACEMENT, and restores JW (it
returns on top). A journal is persisted before every mutation; restore
treats Zoom and JW independently, never retargets a recycled handle,
tolerates late ShowWindowAsync completion, and keeps the journal until
both are verified. Win32 adapter gains monitors, FindWindowEx,
placement, style and async show bindings; a Node harness drives it in
the VM. Verified there: press 1 and press 2 round-trip correctly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Wire cover mode into the Stream Deck action (default mode)
Some checks failed
ci / secrets (pull_request) Successful in 7s
ci / build (pull_request) Failing after 11s
e043b3ab80
Cover mode acts on key up (no learning), keeps its journal in the
plugin's global settings (single plugin-owned copy that survives
restarts), and shows state 1 on every cover-mode key while Zoom covers
the JW Library display. The Property Inspector offers it as the default
and hides the A/B-only controls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Add Windows test rig with emulated USB Stream Deck MK.2
Some checks failed
ci / secrets (pull_request) Has been cancelled
ci / build (pull_request) Has been cancelled
d3a0d9b2a1
testing/ holds the end-to-end test tooling for the dockur/windows VM
in the boat sandbox: host-side helpers (send/run into the VM's
interactive session), the VM PowerShell helpers (UI Automation,
input, window layout, Zoom/JW Library/OnlyT/Stream Deck), and
testing/virtual-streamdeck/.

The virtual Stream Deck is a single-file Node USB/IP server that
emulates a Stream Deck MK.2 (0FD9:0080, 15 keys) attached with
usbip-win2. The Stream Deck app treats it as a real device; a key
window on display 5 shows the images it sends, and presskey.ps1
-Key 1..15 presses keys over HTTP. Also includes the hidden
test-device notes, MCP pipe client and the vsd2 schema extracted
from StreamDeck.exe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Make the plugin a single cover/restore toggle; fix maximised/minimised starts
Some checks failed
ci / secrets (pull_request) Has been cancelled
ci / build (pull_request) Has been cancelled
ab01754696
Remove the A/B mode and all of its controls (learn, forget, capture,
move here, diagnose): the plugin now has one action, "Zoom over JW
Library (toggle)", with nothing to configure. Its settings panel only
shows the result of the last press; key states are "Show Zoom" /
"Back to JW".

Fix found by the starting-position matrix in the Win11 VM: covering
from a maximised or minimised start failed safely (rolled back) because
the style was read before leaving that state and still carried
WS_MAXIMIZE/WS_MINIMIZE; writing it back re-flagged the window. The
style is now read after a single synchronous SetWindowPlacement puts
the window in the normal state at the target. The fake adapter models
those bits and two regression tests cover both starts.

Harness: cover-only, plus `matrix` (10 starting positions, verifies
cover and exact restore and that Zoom's main window is untouched),
`arrange:<preset>` and `where`. Results: 10/10 via the harness and
10/10 via real key presses on the emulated Stream Deck MK.2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Key icons: make it clear the key puts a Zoom participant on the TV
Some checks failed
ci / secrets (pull_request) Has been cancelled
ci / build (pull_request) Has been cancelled
ec75bd764b
State 0 "ZOOM → TV": a Zoom-blue participant tile with an arrow into a
TV. State 1 "BACK TO JW": the participant filling the TV (red live dot)
with the label saying what the next press does. Text sized to stay
legible at the real 72 px key size; matching action-list and category
icons; action renamed "Zoom participant → TV (toggle)".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Back-to-JW key icon: TV showing the JW Library badge
Some checks failed
ci / secrets (pull_request) Has been cancelled
ci / build (pull_request) Has been cancelled
389cdce18b
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Fix Gate 2 blockers and a JW restore that could report success while invisible
Some checks failed
ci / secrets (pull_request) Has been cancelled
ci / build (pull_request) Has been cancelled
84e404d2b0
- Late minimise: recovery now always posts the non-activating restore.
  ShowWindowAsync requests to one window are processed in order, so it
  lands after any still-queued minimise (previously a minimise delayed
  past the rollback window could hide JW after the journal was cleared;
  Codex's in-memory repro is now a regression test and fails without
  the fix).
- Unconfirmed journal writes: the global-settings store reads the
  record back from Stream Deck after every write (order-independent
  comparison) and throws on mismatch; the controller turns that into
  "nothing changed" before mutating, or a rollback after.
- JW suspended while minimised (seen in the VM after ~7 s) came back
  restored but shell-cloaked, i.e. invisible, and was counted as
  restored. "Back" now requires not cloaked; if still cloaked, JW is
  minimised again and SW_RESTOREd (the only thing that uncloaks it),
  then keyboard focus is handed back to the previous window.
- placementsMatch also checks WPF_RESTORETOMAXIMIZED; tests for a
  minimised-from-maximised start, save failures at each step and the
  cloaked restore. README intro describes the toggle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
aporter/jwzoomswitcher!1
No description provided.