Skip to content

feat(mac): independent Mirror/Extend per connected device - #24

Draft
raiseCatError wants to merge 32 commits into
dev/opendisplay-nextfrom
feature/per-device-display-mode
Draft

raiseCatError wants to merge 32 commits into
dev/opendisplay-nextfrom
feature/per-device-display-mode

Conversation

@raiseCatError

Copy link
Copy Markdown
Owner

Closes #20 once it's verified on devices.

What changed

Each connected iPhone, iPad or receiving Mac now has its own Mirror/Extend mode and its own Mirror display. Changing one device rebuilds only that device's stream.

  • Mac/DisplayModeAssignment.swift (new, pure): DeviceDisplayModeStore keeps each device's choice under its install id. DisplayModeCoordinator decides every live session's mode and source, and names exactly the sessions a change must rebuild. MirrorSourceResolver keeps Automatic, capture recovery and wake recovery off other devices' MeowDisplay virtual displays. VirtualDisplayRegistry and VirtualDisplayIdentityPlanner let an Extend display skip an identity another live session holds.
  • SenderController: all mode-affecting events go through the coordinator and rebuild only the sessions it names. That covers a receiver's mode/source requests, Connect with Mirror/Extend, approval, the hello of a late-identified USB device, Video Off and the Displays defaults. The global mode/mirrorDisplayUUID become defaults, with unchanged storage. Forget clears the device's choice.
  • MacSender / VirtualDisplay: Mirror source decisions use the resolver. Extend probes skip identities another live session holds. Virtual displays register for their own lifetime.
  • UI: device settings gain a Display Mode section (Mac Default / Extend / Mirror, Mirror Display, current state, refusal reason). Displays presents the mode and Mirror display as defaults and lists each connected device's mode.
  • Docs: MULTI_DEVICE.md covers the architecture, what changes what, the limits and a 12-step physical checklist. ARCHITECTURE.md links to it.

No wire-protocol change: these messages were already per connection, so existing receivers work unchanged.

Why

Every device already had its own pipeline, but each one was built from the Mac-wide mode, and any change rebuilt every device. Automatic Mirror also took the first display ScreenCaptureKit listed, which could be another device's extended desktop.

Base

This branch sits on the shipped 1.20.0 source (cbbad7f, release/app-store-rc) plus dev/opendisplay-next's launch docs (merge 3432705; one RELEASE.md conflict resolved to dev's newer text). dev/opendisplay-next lacks the 25 release commits, so this PR's diff against it includes them. The feature itself is the 5 commits after 3432705.

Verification

  • xcodebuild test -scheme OpenSidecarMac (unsigned, Xcode 27.1): 1892 tests, 0 failures. The baseline on the base commit was 1845; 47 are new in DisplayModeAssignmentTests.

  • The new tests drive the real coordinator and registry with synthetic receivers: Mirror+Extend, Extend+Extend, Mirror+Mirror, 3 and 5 receivers, switching one while others run, interleaved connect/disconnect, crash mid-switch, sender restart, route changes, late USB identification, rejections, Video Off/On, lost displays, identity collisions, and per-receiver input mapping.

  • git diff --check clean. Receiver targets don't compile any changed file, and Shared/ is unchanged.

  • Not verified on hardware. No multi-device run with real displays has been done yet; see the checklist in MULTI_DEVICE.md.

  • The change is focused, without unrelated refactors

  • Relevant behaviour was verified (tests and/or on device): tests only, devices pending

  • Documentation updated if needed

  • No credentials, secrets or signing material included

  • No unrelated generated or build artifacts

raiseCatError and others added 30 commits September 27, 2026 12:46
…reams only when ready

On iPhone every QUIC dial failed. The listener's new-flow path received
datagrams of an already accepted connection, failed to register a duplicate
flow for the same 4-tuple (NECP ADD_FLOW EEXIST) and took the live
connection down (EINVAL); its streams then failed their preface read with
ENOTCONN, which was misreported as invalidStreamPreface.

- The QUIC listener no longer opts into local-endpoint reuse, which the
  TCP listener needs but a UDP QUIC server does not: a second binding of
  UDP 9001 now fails and retries instead of silently sharing the port.
- Listener parameters come from one factory shared with the loopback
  tests, so CI exercises the production configuration.
- Accepted streams are read only after reaching .ready, and a failed read
  or stream is a transport failure, never a protocol violation.
- Bounded DEBUG logging of accepted connections and ready streams.
…ramework's stream 0

Physical iPhone QUIC failed on every dial, and the same receiver path fails
over macOS loopback once it is actually exercised:

- Network.framework delivers one object of its own per QUIC connection to
  newConnectionHandler (no stream ID, never ready, no data). The receiver
  treated it as a MEOW stream: it counted it against the three-stream
  topology, armed a preface timeout on it and read from it. On iPhone that
  read fails at once (ENOTCONN) and every connection was closed as
  invalidStreamPreface. Delivered objects now count and are read only once
  they are .ready; a never-ready object that fails is dropped. Delivered
  objects stay bounded.
- The sender's streams start at stream ID 4 because stream 0 is Network.
  framework's, so initial_max_streams_bidi = 3 left the sender's third
  stream blocked forever. The limit is now 4, set from one shared place.
- Reverts the listener local-endpoint-reuse change: the evidence did not
  support it.
- The loopback test now runs the production QUICReceiverGroup with three
  streams, production listener parameters and stream limits.
… gestures

QUIC (Mac sender): NWConnectionGroup `.waiting` is no longer fatal. A
physical Mac -> iPhone dial reported `.waiting(ENETDOWN)` and then `.ready`;
failing on `.waiting` tore the tunnel down before its streams opened. The
state handling moves into a pure QUICGroupStatePolicy: `.waiting` is
uniformly nonterminal, `.failed` stays fatal, `.cancelled` is unchanged,
and the existing 8 s application-handshake timeout still bounds a group
that never becomes ready. Control-stream adoption (becomeReady), endpoint
selection, includePeerToPeer, stream limits, pinned mutual TLS, ALPN and
no-0-RTT are unchanged.

iPad receiver:
- VideoView opts out of the three-finger editing interactions.
- Screen-edge system gestures are deferred only while the remote surface
  is shown and input may reach the Mac.
- While VoiceOver runs, every custom multi-finger recognizer (two-finger
  viewport, two-finger double-tap, three-finger swipe/tap, 4/5-finger
  pinch/spread) steps aside, following runtime VoiceOver changes;
  force-cancel toggles restore the gate's state, never a blind `true`.
- Opt-in (hidden) Function Tray items for Launchpad and Show Desktop,
  which have no reliable keyboard shortcut; saved profiles gain them
  hidden.
- Document why UIRequiresFullScreen stays for now.

Tests cover the group-state policy, the handshake-timeout class, explicit
QUIC never falling back to TCP, a loopback run of the production dialer
adopting the Control stream, the VoiceOver gate and the new tray items.
…ort freedom

Preferences (schema 16): new installs default to Smart Touch on, Auto-hide
on, Pinch -> Viewport, Rotation -> Disabled, Prefer MeowDisplay Gestures on
and the iPad Strip layout. Pre-release installs migrate those four older
values once; choices saved under schema 16 are never re-migrated. Custom
layouts decode lossily so one unreadable entry can't reset preferences.

Settings: the single NavigationStack + Form becomes one category model and
per-category pages. Regular width shows a NavigationSplitView sidebar
(presented full screen on iPad); compact width collapses the same view into
a category list with pushed pages. Pages observe only what they show.

iPad controls (never on iPhone):
- Strip: a solid edge rail on any edge; the Mac canvas fits beside it.
  Optional control hints.
- Overlay: full-screen Mac, floating controls with small material halos
  drawn only where a control covers the rendered display.
- Main and Function controls choose independent edges; Main is centered
  and grows outward, Function groups split toward the edge's ends.
- Control Size (0.85-1.4).
- Custom: up to five layouts, separate landscape/portrait arrangements, a
  visual editor, and a radial corner template whose chord palettes bloom as
  arcs/rings/rows/columns clear of other controls.
- Two-Hand Assist: opposite-side idle Function actions, replaced by helper
  modifiers that act on the same ControlInteractionState chord.
Layouts store placement only; actions still come from Control Profiles and
the Function Tray.

Viewport: panning no longer requires zoom; any edge of the remote display
may reach the viewport center but never pass it (rotation-aware). Move View
(iPad) lets one finger pan and pinch zoom without sending input to the Mac;
two-finger scrolling is unchanged.

Gestures: Prefer MeowDisplay Gestures controls screen-edge deferral and the
three-finger editing opt-out. VoiceOver always wins, and Move View parks the
multi-finger recognizers.

Home: centered two-card iPad composition and an optically aligned icon.
Pairing: a high-contrast success banner announced to VoiceOver.

No wire-protocol, transport or security changes.
Rail and controls:
- Strip/Overlay rail groups: system actions (Menu Bar, Dock, Show Desktop,
  Control Center) at the rail's start, modifiers and keys centered, Move
  View and Settings at its end. A lane packer keeps Main and Function
  groups from ever overlapping on a shared edge, moving overflow to an
  inner lane (a Strip widens to hold it).
- Standard Controls: per-control visibility on iPad; Settings always stays.
- Overlay groups sit in compact dark trays; the per-control blur/backdrop
  and its display-footprint plumbing are removed. Control Hints default off.
- Menu Bar (Control-F2) and Control Center (Fn-C) are new semantic
  gestures on the existing path; older Macs ignore them.
- While the software keyboard is up, a keyboard accessory bar (⌘ ⌥ ⌃ ⇧ esc
  tab, hide) replaces the normal controls; its modifiers are the same
  chord state, and a latched chord combines with typed keys (⌘ then C →
  ⌘C). Normal controls return unchanged afterwards.

Actions:
- Editable Function Tray: add, remove, reorder, rename, and a face (SF
  Symbol, text, emoji, keys) stored apart from the action.
- One-tap keyboard chords (including multi-key) and bounded sequences of
  existing keyboard/semantic actions, with balanced key events.
- Chord palettes take user-defined actions, edited from the Control Profile
  or a Custom layout's modifier; runtime and Preview read the same data.

Custom layouts:
- Concentric corner arcs: Settings → utilities → modifiers → palette on the
  next arc; Two-Hand Assist mirrors the modifier arc; Arc fans into free
  space from any corner or moved anchor; new Wheel palette style.
- Full-screen editor with Edit/Preview, Alignment Guides, Snap to Edges and
  Snap to Guides, contextual help, a dormant Two-Hand Assist while editing,
  per-layout Function profile, system controls, and shortcut buttons.
- MDL1 share codes with validated import (as a pushed page); custom actions
  a layout uses travel with it as new local definitions.

Input and viewport:
- Allow Local View Navigation (default on) is separate from Allow Input:
  pan/zoom/rotate/reset/Move View work with input off and send nothing.
- A blocked remote touch shows an ephemeral Request/Enable Input prompt.
  It never changes a setting; automatic requests need the explicit
  per-Mac Request Input Automatically switch (not the connection Always
  Allow policy), are rate-limited, and the Mac still decides.
- Per-axis scroll inversion (both on by default).

Settings: search on iPad and iPhone over a shared index, scroll-to and
highlight of results, and Settings owning its status bar so its header
stays below the system area.

Preferences schema 16 → 18 with one-time migrations; explicit choices are
never re-migrated.
…tzweg#320)

Ported onto this line's capture-recovery design (CaptureRecoveryBudget /
captureRecoveryScheduled) rather than upstream's replaced recovery loop:
a recovery tick that finds the main display asleep or the screen locked
re-arms without running a round, so a sleep longer than the budget no
longer ends the session. The wait logs once and recovery resumes when the
console can capture again. The existing single-pending-tick flag already
prevents two recovery chains, so upstream's work-item/in-flight tracking
is not needed here.

(cherry picked from commit 09e717e)
The tracked copy predates the QUIC listener and lacked its
_meowdisp-q._udp Bonjour type, which every generated build already
declares.
…reconnect

The encode-failure safety net only ever dropped the frame rate, so an HEVC
session that was created but produced no frames stayed black or spent the
capture-recovery budget recreating HEVC. It now falls back to H.264 once
per sender (the existing runtimeFallback reason) and re-arms the streak so
the H.264 encoder keeps its own one-time FPS recovery. Synchronous
VTCompressionSessionEncodeFrame rejections now count toward the same
zero-success streak as failed outputs.

A hello that no longer offers HEVC while HEVC is running (a reconnect to an
H.264-only receiver build) now rebuilds the live encoder for the codec that
hello allows, instead of re-confirming HEVC.

Auto stays H.264-first; the user's codec preference model is unchanged.
… link

ProjectLinks is now the one definition of the project's external links.
The Mac app, Mac Receiver and iPhone/iPad About sections link to the
GitHub repository. A "Support MeowDisplay" link appears only once
ProjectLinks.koFiPage holds a real https://ko-fi.com/<page> address; it is
nil until that page exists, so nothing ships pointing nowhere.
README: iPad Strip/Overlay/Custom controls, the editable Function Tray,
local view navigation apart from input, the input request prompt, system
requirements, a GitHub link, and Smart Touch's new default.
PROTOCOL.md: document optional HEVC (hello.codecs, streamCodecState, pv 19)
and the missing pv 15/16/18 rows.
APP_STORE_LISTING.md: iPad controls in the description, drop Apple Pencil
per the listing's own rule, explain Local Network, Face ID and background
audio in the review notes, and allow a manual Organizer upload.
RELEASE.md: the manual first-release path and the two values left to fill.
Set AppStore.iOSAppID to 6817552864, the app's Apple ID in App Store
Connect, so the iOS update screen and the Mac's updateRequired message
open MeowDisplay's own listing instead of the GitHub project page. Record
the app's identity in the listing reference and check off the release item.
Set ITSAppUsesNonExemptEncryption to NO in the iOS target's Info.plist
properties, matching the App Store Connect export-compliance answer for
1.20.0: the app uses only standard, exempt encryption and needs no
documentation, so later uploads skip the questionnaire. It declares no
non-exempt encryption, not an absence of cryptography. The listing
reference records the answer and that 1.20.0 is not distributed in France.
Both Mac apps now point Sparkle at MeowDisplay-owned feeds
(meowdisplay.app/appcast.xml and appcast-receiver.xml) and trust a new
MeowDisplay EdDSA public key. Signed feeds and verify-before-extraction
are required. SUEnableAutomaticChecks is no longer forced off, so Sparkle
asks the user before checking in the background.

The release workflow now signs and verifies both appcasts with Sparkle
2.10.0 tools (tarball pinned by SHA-256) and uploads them to the GitHub
Release instead of the unused GitHub Pages path. meowdisplay.app redirects
the feed URLs to the latest release. The Mac build number is now the
marketing version, so Sparkle sees every release as newer, and the
signing key is scoped to the one step that uses it.

SparkleConfigurationTests pins the feeds, key and consent behaviour, and
rejects upstream OpenDisplay's feeds and key.
Adds an Explore Demo entry on the home screen that opens a clearly
labeled sample session with a fictional "Review Mac", so the receiver,
its controls and every Settings page can be explored without a Mac.

- DemoSession holds all sample state in memory. It has no
  StreamReceiver, and never touches TrustStore, RemoteEndpointStore,
  the connection log or the standard defaults.
- ReceiverControlOverlay is generic over ReceiverControlSource;
  StreamReceiver is the real source, DemoSession turns sends into
  local "Demo, not sent" feedback.
- Demo control preferences use a separate defaults domain, cleared
  on entry and exit. Allow Input is a local demo state.
- Settings routes Mac-dependent categories to demo pages; local-only
  pages are reused with the demo store. A real session ends the demo.
- Unit tests for the isolation boundary and an iPad/iPhone UI smoke
  test (launch, Explore Demo, all Settings categories, exit).
- Use the reserved review-mac.example hostname for the sample Remote
  Access entry instead of a name under the real ts.net domain.
- Shared Settings pages take their @AppStorage store from
  DemoSession.preferenceDefaults: the real .standard domain normally,
  the demo domain (wiped on entry and exit) in Demo Mode. Zoom While
  Typing and Cat Mode taps no longer reach real preferences.
- Inset the Demo Settings banner inside each column so it no longer
  covers the lower Settings rows on iPhone in landscape.
- Tests for the zoom and Cat Mode isolation and the real-mode store;
  UI smoke test waits for transitions and runs in landscape too.
- New preferences place the Function Tray on the opposite side of the
  Main Tray on iPhone, and the iPad Function controls on the leading
  edge, opposite the trailing Main controls.
- Saved preferences always win. A saved blob without the key (written
  before this default) keeps the earlier same-side / trailing placement.
- The schema 5 migration test now drops the Function Tray keys, as a
  real schema 5 save had none, and still expects same-side.
Demo Mode now shows a working sample Mac desktop instead of a static
placeholder, so App Review can try display, input and controls without
a Mac. Everything stays in memory and nothing is sent anywhere.

- DemoDesktop: the sample desktop model (windows, Files list, Preview,
  Notes, pointer) and what each interaction did, for local feedback.
- DemoSurface: the receiver screen. Direct Touch and Trackpad input,
  Extend (automatic 16:10 or Use Full Display) and Mirror with a dock,
  and Video Off as a Trackpad Preview that still moves the pointer.
- A DEMO banner stays on screen with Allow Input, options and Exit
  Demo; with Allow Input off, touches meant for the Mac are blocked.
- Extend Shape is demo-local and starts on automatic.
- Explore Demo sits above the connection instructions in portrait.
- Touch timing uses CACurrentMediaTime, like the rest of the app, so
  the privacy manifest needs no new required-reason category.
- Unit tests for the desktop model, demo-local sends, blocked input
  and Extend Shape. A visual tour UI test walks every mode in portrait
  and landscape; the smoke test scrolls Explore Demo into view first.
Explore Demo sat below the fold on iPhone in landscape. The home screen
now places it by whether a Mac is paired, in every layout.

- No paired Mac: a compact Explore Demo card ahead of the connection
  instructions in portrait, in the left landscape column and on iPad.
  The left landscape column scrolls only when its content can't fit.
- Paired Mac: a collapsed Demo Mode section, in the Connection
  Instructions style, after the connection sections.
- Pairing state comes from the trust store's in-memory pin snapshot,
  re-read after pairing, Forget and Settings. Forgetting the last
  paired Mac brings the card back. No Keychain or network I/O.
- The demo can't start while a real session's surface is up, so it
  never waits behind one.
- The card uses the plain card color on iPhone, so it no longer
  disappears into the white page in light mode.
- Tests for the presentation decision and the demo entry rule.
a38c4ab committed CFBundleName and CFBundleDisplayName entries that
Xcode extracted from whichever build last ran: "MeowDisplay Dev" from a
Debug build in the Mac catalog, and the iOS target name in the iOS one.
These would override the configured names if they were ever compiled
into the app. Restore both catalogs to their earlier content, so the
names come only from project.yml.
Xcode's extraction of the current Demo Mode strings: adds the
interactive desktop and home entry strings and drops the ones the
earlier demo used. English source text only; no translations change.
Demo Mode for App Review, approved after physical-device testing.
App Store review needs the privacy policy reachable inside the app.
Settings > About now has a Privacy Policy link to
https://meowdisplay.app/privacy, kept with the other project links in
ProjectLinks. Demo Mode shows the same About page, so the link is there
in both modes.

- Tests pin the URL and check that the About page links it and that
  Demo Mode uses the shared About page.
- The Demo Mode smoke test checks for the link on About.
Each receiver already owns its own MacSender pipeline, but every pipeline
was built from the Mac-wide mode and Mirror source, and any change rebuilt
every device. Add the pure decision layer that replaces that: per-device
preferences persisted by install id (following the Mac default when unset),
a coordinator that names exactly the sessions a change must rebuild, a
Mirror source resolver whose Automatic never picks another device's
MeowDisplay virtual display, and a live virtual-display registry with an
identity planner that skips identities another session holds.

Refs #20
Route every mode-affecting event through DisplayModeCoordinator and rebuild
only the session it names: a receiver's displayModeRequest and
mirrorDisplayRequest, Connect with Mirror/Extend, session approval and the
hello of a newly identified USB device now change that device alone and are
remembered for it. The Mac-wide mode and Mirror display become defaults for
devices without their own choice, so a single device behaves as before.
Video Off still hands every Extend session to Mirror in place.

Mirror's Automatic source, capture recovery and wake recovery no longer pick
another device's MeowDisplay virtual display, and an Extend display skips an
identity another live session holds instead of retrying into a refusal.
Forget clears the device's mode.

Refs #20
Add a Display Mode section to a connected device's settings: Mac Default,
Extend or Mirror, its own Mirror display (Mac Default, Automatic or a
specific display), what it is currently doing, and why a request was
refused. The Displays page now presents the mode and Mirror display as
defaults for devices without their own choice and lists each connected
device's mode when more than one is connected.

Refs #20
Xcode 26.6's type checker gives up on the Strip/Overlay hit region in
edgeLayoutContent (ReceiverControls.swift:1426): two `+` concatenations
over an untyped ternary, `Optional.map { [...] } ?? []` and a `??` chain
make the solver try every `+` overload together with each `??` and
literal choice. Built as three [CGRect] lets ahead of the ZStack, the
same rects, in the same order, reach ControlRegionShape.

(cherry picked from commit 18bc616)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: independent Mirror/Extend mode per connected device

2 participants