External monitor/docking support (#87) #89

Merged
albert merged 8 commits from u/albert/87/external-monitor-docking into main 2026-07-04 22:15:44 +00:00
Owner

Mirror the primary panel onto a docked external display by default, with a
HUD toggle to switch to external-only at the display's native resolution.
Exactly one logical output is active in every mode, preserving the
one-activity-at-a-time invariant.

  • DisplayManager state machine (SingleInternal / Mirror / ExternalOnly) in
    shepherdd, driven by a swaymsg output-event hotplug watcher. Primary is the
    first-enumerated output; a new connection resets to Mirror.
  • Mirroring via wl-mirror (sway cannot mirror from config); falls back to
    external-only if wl-mirror is unavailable so the invariant still holds.
  • sway output primitives behind an OutputBackend trait (unit-tested), mirror
    mode selection (highest mutually-compatible resolution), and PipeWire audio
    routing to the external device while docked.
  • DisplayController trait + get_display_state/set_display_mode RPCs (HTTP/BLE/
    IPC), DisplayModeChanged event, and a HUD mirror/external toggle that
    re-anchors the layer-shell surface to the active output.
  • [service.display] config (docking_enabled, mirror_audio), sway.conf rule for
    the wl-mirror window, and wl-mirror runtime dependency.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com

Fixes #87

Mirror the primary panel onto a docked external display by default, with a HUD toggle to switch to external-only at the display's native resolution. Exactly one logical output is active in every mode, preserving the one-activity-at-a-time invariant. - DisplayManager state machine (SingleInternal / Mirror / ExternalOnly) in shepherdd, driven by a swaymsg output-event hotplug watcher. Primary is the first-enumerated output; a new connection resets to Mirror. - Mirroring via wl-mirror (sway cannot mirror from config); falls back to external-only if wl-mirror is unavailable so the invariant still holds. - sway output primitives behind an OutputBackend trait (unit-tested), mirror mode selection (highest mutually-compatible resolution), and PipeWire audio routing to the external device while docked. - DisplayController trait + get_display_state/set_display_mode RPCs (HTTP/BLE/ IPC), DisplayModeChanged event, and a HUD mirror/external toggle that re-anchors the layer-shell surface to the active output. - [service.display] config (docking_enabled, mirror_audio), sway.conf rule for the wl-mirror window, and wl-mirror runtime dependency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Fixes #87
External monitor/docking support (#87)
Some checks failed
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Failing after 20s
CI / CI image (Android) (pull_request) Has been skipped
CI / Build (pull_request) Has been skipped
CI / Test (pull_request) Has been skipped
CI / E2E (pull_request) Has been skipped
CI / Clippy (pull_request) Has been skipped
CI / Rustfmt (pull_request) Has been skipped
CI / Firewall E2E (pull_request) Has been skipped
CI / Android portability (shepherd-media-core) (pull_request) Has been skipped
CI / Android companion (unit tests) (pull_request) Has been skipped
ad76ce7b74
Mirror the primary panel onto a docked external display by default, with a
HUD toggle to switch to external-only at the display's native resolution.
Exactly one logical output is active in every mode, preserving the
one-activity-at-a-time invariant.

- DisplayManager state machine (SingleInternal / Mirror / ExternalOnly) in
  shepherdd, driven by a swaymsg output-event hotplug watcher. Primary is the
  first-enumerated output; a new connection resets to Mirror.
- Mirroring via wl-mirror (sway cannot mirror from config); falls back to
  external-only if wl-mirror is unavailable so the invariant still holds.
- sway output primitives behind an OutputBackend trait (unit-tested), mirror
  mode selection (highest mutually-compatible resolution), and PipeWire audio
  routing to the external device while docked.
- DisplayController trait + get_display_state/set_display_mode RPCs (HTTP/BLE/
  IPC), DisplayModeChanged event, and a HUD mirror/external toggle that
  re-anchors the layer-shell surface to the active output.
- [service.display] config (docking_enabled, mirror_audio), sway.conf rule for
  the wl-mirror window, and wl-mirror runtime dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ci: copy VERSION into the deps image so scripts/shepherd can start
All checks were successful
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 9m56s
CI / CI image (Android) (pull_request) Successful in 3m34s
CI / Rustfmt (pull_request) Successful in 8s
CI / Build (pull_request) Successful in 4m42s
CI / E2E (pull_request) Successful in 5m9s
CI / Test (pull_request) Successful in 5m12s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 36s
CI / Clippy (pull_request) Successful in 6m22s
CI / Android companion (unit tests) (pull_request) Successful in 1m14s
CI / Firewall E2E (pull_request) Successful in 3m51s
a2492dfc6d
The CI base image runs `./scripts/shepherd deps install` with only `scripts/`
copied into the build context. Since the version-harmonization work,
`scripts/shepherd` reads the repo-root VERSION file at startup for every
subcommand (resolved as scripts/../VERSION), so the deps step now dies with
"Version file not found: /opt/shepherd-deps/VERSION".

This was latent: the RUN layer stayed cached until a deps-input change (adding
wl-mirror to run.pkgs for #87) forced an image rebuild and surfaced it. Copy
VERSION into the image before the RUN. Its value is irrelevant to what gets
installed — only its presence matters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
display: trap the pointer on the primary in mirror mode (#87)
All checks were successful
CI / Version harmony (pull_request) Successful in 4s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 19s
CI / CI image (Android) (pull_request) Successful in 37s
CI / Rustfmt (pull_request) Successful in 6s
CI / Build (pull_request) Successful in 5m3s
CI / Test (pull_request) Successful in 5m9s
CI / E2E (pull_request) Successful in 5m11s
CI / Firewall E2E (pull_request) Successful in 4m44s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 27s
CI / Clippy (pull_request) Successful in 5m59s
CI / Android companion (unit tests) (pull_request) Successful in 55s
b2551f0085
Mirror mode enabled the external output beside the primary in the layout, so
the pointer could travel onto the external and land on the (uninteractive)
wl-mirror surface, where clicks never reached the real content.

Overlap the two outputs at the same origin (0,0) with matching logical size —
the canonical sway mirroring layout — so they form one logical region the
cursor can't leave, and sway routes pointer input to the primary (first in the
layout). The primary runs at the highest mutually-compatible mode; the external
is driven at a mode no larger than the primary (fit_secondary_mode) so it never
extends the layout past the primary, its own hardware upscaling to its panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
albert force-pushed u/albert/87/external-monitor-docking from b2551f0085
All checks were successful
CI / Version harmony (pull_request) Successful in 4s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 19s
CI / CI image (Android) (pull_request) Successful in 37s
CI / Rustfmt (pull_request) Successful in 6s
CI / Build (pull_request) Successful in 5m3s
CI / Test (pull_request) Successful in 5m9s
CI / E2E (pull_request) Successful in 5m11s
CI / Firewall E2E (pull_request) Successful in 4m44s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 27s
CI / Clippy (pull_request) Successful in 5m59s
CI / Android companion (unit tests) (pull_request) Successful in 55s
to 240f5f6526
All checks were successful
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 9s
CI / CI image (pull_request) Successful in 22s
CI / CI image (Android) (pull_request) Successful in 38s
CI / Rustfmt (pull_request) Successful in 6s
CI / Build (pull_request) Successful in 5m4s
CI / Test (pull_request) Successful in 5m9s
CI / E2E (pull_request) Successful in 5m12s
CI / Firewall E2E (pull_request) Successful in 4m46s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 27s
CI / Clippy (pull_request) Successful in 5m59s
CI / Android companion (unit tests) (pull_request) Successful in 55s
2026-07-04 19:19:34 +00:00
Compare
display: serialize mode changes and restore primary mode on undock (#87)
All checks were successful
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 23s
CI / CI image (Android) (pull_request) Successful in 38s
CI / Rustfmt (pull_request) Successful in 6s
CI / Build (pull_request) Successful in 5m3s
CI / Test (pull_request) Successful in 5m9s
CI / E2E (pull_request) Successful in 5m11s
CI / Firewall E2E (pull_request) Successful in 4m44s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 28s
CI / Clippy (pull_request) Successful in 6m0s
CI / Android companion (unit tests) (pull_request) Successful in 54s
7fa0f58072
Two docking fixes:

- HUD vanished after a few mirror-toggle clicks. set_mode and reconcile weren't
  serialized, so rapid toggles ran apply() concurrently — interleaving
  enable/disable, mode-set, wl-mirror start/stop (incl. its 500ms settle) and
  pointer mapping, corrupting the arrangement and the HUD's layer surface. Guard
  every reconcile/apply with an apply_lock so they run one at a time. Also
  re-present the HUD's layer surface when it re-anchors, so it remaps cleanly
  after the output it was on is disabled.

- The primary's resolution wasn't restored on disconnect. Mirror mode drives the
  primary to the highest mutually-compatible mode, which can differ from its
  native mode; capture the native mode at startup and restore it whenever the
  external display disconnects and the primary becomes the sole output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

implementation looks reasonable and behaves correctly on the launcher when docking and undocking and while toggling mirror mode

now need to test with activities

implementation looks reasonable and behaves correctly on the launcher when docking and undocking and while toggling mirror mode now need to test with activities
display: coordinate the HiDPI workaround with the mirror (#87)
All checks were successful
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 21s
CI / CI image (Android) (pull_request) Successful in 38s
CI / Rustfmt (pull_request) Successful in 6s
CI / Build (pull_request) Successful in 5m0s
CI / Test (pull_request) Successful in 5m6s
CI / E2E (pull_request) Successful in 5m9s
CI / Firewall E2E (pull_request) Successful in 4m41s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 27s
CI / Clippy (pull_request) Successful in 5m55s
CI / Android companion (unit tests) (pull_request) Successful in 55s
bf445796be
The XWayland HiDPI workaround (#45) and the docking controller both mutate sway
outputs independently, so a native-resolution activity could leave the mirror
mode, the wl-mirror window's pin, or the pointer confinement broken.

Give DisplayManager a `reassert()` that re-applies the current arrangement in
place — re-set the primary mirror mode, re-pin the (still-running) wl-mirror
window, re-confine the pointer — without tearing down and respawning the mirror,
serialized on the same apply_lock. XwaylandHidpi holds the DisplayManager and
calls reassert() after it changes scales on apply and restore, i.e. right around
an activity launch/exit.

Scope: this fixes activities that go through the HiDPI path
(`xwayland_native_resolution`). It does not address a mirror going black *during*
a fullscreen activity via direct scanout (that's the force-composition path) —
we're trying this coordination first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
display: restart the mirror on reconfigure + force composition (#87)
All checks were successful
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 20s
CI / CI image (Android) (pull_request) Successful in 39s
CI / Rustfmt (pull_request) Successful in 6s
CI / Build (pull_request) Successful in 5m4s
CI / Test (pull_request) Successful in 5m10s
CI / E2E (pull_request) Successful in 5m12s
CI / Firewall E2E (pull_request) Successful in 4m41s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 27s
CI / Clippy (pull_request) Successful in 5m53s
CI / Android companion (unit tests) (pull_request) Successful in 51s
58a98c3f0a
Fullscreen activities (e.g. HRM with the native-resolution DPI hack) broke
mirroring two ways:

- On activity open the TV went black and on exit the mirror never restored.
  Root cause: the DPI hack reconfigures the primary output's scale, which leaves
  wl-mirror showing a black frame or exited; reassert only re-*pinned* the dead
  process. Factor the mirror bring-up into `establish_mirror` and always
  *restart* wl-mirror (fresh capture) from both apply and reassert, so it
  recovers after any source-output reconfiguration.

- Force composition via WLR_SCENE_DISABLE_DIRECT_SCANOUT=1 in the sway
  environment (dev launcher + documented for production). A fullscreen surface
  would otherwise direct-scan-out and starve wl-mirror's screencopy of frames,
  blacking the mirror even while wl-mirror is alive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hud: force a clean remap when following the active output (#87)
All checks were successful
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 21s
CI / CI image (Android) (pull_request) Successful in 40s
CI / Rustfmt (pull_request) Successful in 6s
CI / Build (pull_request) Successful in 4m59s
CI / Test (pull_request) Successful in 5m6s
CI / E2E (pull_request) Successful in 5m9s
CI / Firewall E2E (pull_request) Successful in 4m41s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 28s
CI / Clippy (pull_request) Successful in 5m53s
CI / Android companion (unit tests) (pull_request) Successful in 54s
02cc925d2c
Switching to external-only disables the primary output, and the compositor
destroys the HUD's layer surface that was anchored there. GTK still believed the
window was visible, so set_monitor + present() had nothing to remap and the HUD
vanished, with the anchor cache left pointing at the (now unmapped) surface so it
never retried.

Re-anchor with an explicit set_visible(false) → set_monitor → set_visible(true)
cycle. GTK4 unmaps/maps synchronously, so this tears down the dead surface and
builds a fresh one on the live output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

had to debug a bunch of issues regarding pointer capture, interactions between activities that force fullscreen and XWayland DPI, and HUD disappearance, but it's working now -- see the history log

had to debug a bunch of issues regarding pointer capture, interactions between activities that force fullscreen and XWayland DPI, and HUD disappearance, but it's working now -- see the history log
docs: record the docking hardware-testing investigation (#87)
All checks were successful
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 9s
CI / CI image (pull_request) Successful in 25s
CI / CI image (Android) (pull_request) Successful in 39s
CI / Rustfmt (pull_request) Successful in 6s
CI / Build (pull_request) Successful in 5m3s
CI / Test (pull_request) Successful in 5m9s
CI / E2E (pull_request) Successful in 5m12s
CI / Firewall E2E (pull_request) Successful in 4m42s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 28s
CI / Clippy (pull_request) Successful in 5m59s
CI / Android companion (unit tests) (pull_request) Successful in 55s
3e6e9c8098
Append a post-implementation section to the plan doc capturing each issue found
on real hardware, its root cause, the fix, and the commit — the pointer-confine
vs. output-overlap decision, the apply_lock serialization, native-mode restore,
the wl-mirror restart + force-composition fix for fullscreen activities, and the
gtk4-layer-shell remap fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
albert merged commit 98080fb013 into main 2026-07-04 22:15:44 +00:00
Sign in to join this conversation.
No reviewers
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
albert/shepherd-launcher!89
No description provided.