Skip to content

feat(mac): one MeowDisplay app that can also be a display - #26

Draft
raiseCatError wants to merge 12 commits into
feature/mac-receiver-inputfrom
feature/unified-mac-app
Draft

raiseCatError wants to merge 12 commits into
feature/mac-receiver-inputfrom
feature/unified-mac-app

Conversation

@raiseCatError

@raiseCatError raiseCatError commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #25 (which is stacked on #24). Merge #24, then #25, then retarget this PR. Its own diff is 9 commits. Draft: waiting for hardware QA of all three PRs. Not for release.

What changed

  • One app: MeowDisplay.app (same bundle ID com.raisecaterror.meowdisplay.mac, macOS 14, same Sparkle feed) now contains the Mac Receiver role behind Use This Mac as a Display (Overview + menu bar). It's off by default and remembered across launches.
  • Exclusive roles: turning the role on ends sharing sessions without suppressing their reconnect, then starts the receiver. While it's on, the central startSession gate refuses every sender session. No self-streaming, no A↔B loops.
  • Separate trust: the receiver role has its own Keychain namespace (identity + pins). The sender's original Keychain names are unchanged, and the iPhone/iPad app and standalone receiver behave exactly as before.
  • Separate approval policy (security fix): each role has its own "Automatically Allow Connections", per-Mac Connection Requests, Remote endpoint hints and Wake metadata (RolePreferences, required at every call site). The receiver role's automatic approval defaults to off. See below.
  • Settings: a This Mac as a Display page: switch and status, name, paired sending Macs (auto-allow, per-Mac requests, Connect, Forget), audio and A/V Sync. It's built from the standalone receiver's own sections. Paired Macs can be forgotten with the role off.
  • No self-discovery: each role ignores the other's Bonjour advertisements. While this Mac is a display, the sender also stops advertising itself (_opendisplay-mac-pair._tcp), so other receivers don't offer it as a Mac to share from.
  • One receiver per Mac, either launch order: the display role and the standalone receiver take one kernel file lock before binding anything (ReceiverOwnershipLock). Whichever starts second waits, says what to quit, and starts by itself afterwards. It also waits for an already-released standalone receiver (found by bundle ID).
  • Window and lifecycle:
    • The app becomes a regular, Command-Tab-able app while the display window is up, and changing the Dock/menu-bar setting meanwhile no longer undoes that.
    • Quitting sends closing first.
    • Turning the role off and on waits for the old receiver to release its ports.
    • A stopped receiver can't reopen a listener from a late retry.
    • The receiver's pairing listener retries a failed bind.
    • The Mirror-unavailable "Use Extend" offer is shown again.
  • Standalone receiver kept for macOS 12–13. A single macOS 12+ bundle was measured: 127+ availability errors across 16 sender files. See UNIFIED_MAC_APP.md for the comparison, migration, rollback and checklist.

Security fix (was the draft blocker)

IncomingSessionPolicyStore kept one global auto-allow key and one per-peer policy dictionary for both roles. The sender's setting (on by default) therefore auto-approved any paired Mac connecting to this Mac as a display, and the receiver's choices changed the sender's. The receiver role now uses receiver.-prefixed keys with its own off default. Existing sender, iPhone/iPad and standalone receiver keys and defaults are unchanged.

RemoteEndpointStore and WakeMetadataStore had the same shared-key shape. A peer pinned in one role under an id known to the other could redirect or clear the other role's hints, and the sender's auto-connect enumerated the receiver's hints. They're per role now too. InputAutoRequestStore is receiver-only but scoped the same way.

RolePreferencesIsolationTests reproduce the leak: 12 of 14 failed with the shared keys, and all pass with the fix.

Verification

  • xcodebuild test -scheme OpenSidecarMac: 1989 tests, 0 failures (43 new on this PR). The lock was also run as two separate processes, in both orders and with the owner killed with kill -9.

  • OpenSidecarMac, OpenSidecarMacReceiver (macOS 12) and OpenSidecariOS builds ✅.

  • Built bundle inspected: bundle ID, min 14.0, LSUIElement, MeowDisplayReceiverRoleTrust = separate, new settings code linked.

  • Not verified:

    • Keychain isolation on a signed build
    • TCC carry-over
    • Bonjour
    • window/activation behavior
    • the restart timing
    • the SwiftUI pages
    • the two real apps side by side

    The 14-step physical checklist (step 13 now covers both launch orders) is in UNIFIED_MAC_APP.md.

Known limitations

  • The new strings aren't in the catalog yet; it's English-only, so nothing visible changes.
  • An already-released standalone receiver opened second can't be changed: its binds fail without disturbing the display role, but it shows no explanation, and its pairing listener doesn't retry after taking over.

Prepare for one Mac app with both roles. TrustStore gains namespaces:
shared keeps the original Keychain names, so every existing identity and
pin is found as before, and TrustStore.receiverRole is the store the
receiver path (StreamReceiver, its pairing and QUIC listeners, receiver
settings) now uses. It is the same object as shared everywhere except an
app that declares MeowDisplayReceiverRoleTrust = separate, where the
receiver role gets its own identity and pins — the separation the two
standalone apps had. PairingNetwork takes the role's store.

The receiver's Auto-Reconnect setting gets a role-scoped key in that app,
and SameAppPeerFilter lets each role ignore the other's advertisements.
The iPhone/iPad app and standalone receiver behave exactly as before.
MeowDisplay.app now also contains the Mac Receiver (its code, not its app
entry point or bundle resources) behind one switch, Use This Mac as a
Display, in Overview and the menu bar. It is off by default, so an
existing install never starts advertising a receiver it didn't ask for.

Exactly one role has live sessions: turning it on ends this Mac's sharing
sessions without suppressing their reconnect and starts the receiver;
while on, startSession — the gate every auto-connect, Connect, receiver
connect request and Remote knock goes through — refuses, and turning it
off stops the receiver and lets the devices reconnect. That rules out a
Mac streaming to itself or two Macs streaming into each other. Each role
also filters the other's Bonjour advertisements, the app becomes a regular
Command-Tab-able app while the display window is up, quitting sends the
receiver's closing message first, and the role doesn't start while the
standalone MeowDisplay Receiver is running.

The sender's bundle id, macOS 14 floor, Keychain names, settings and
Sparkle feed are unchanged; the standalone receiver still builds for
macOS 12 with its own id and feed.
Record why there were two Mac apps, the measured case against one
macOS 12+ bundle, how the roles and their trust are kept apart, what
existing sender and receiver users see, rollback, and what still needs
physical testing.
In the unified Mac app both roles read and wrote one "Automatically Allow
Connections" key and one per-peer policy dictionary. The sender's setting
(on by default) therefore auto-approved any paired Mac connecting to this
Mac as a display, and the receiver role's choices changed the sender's.

IncomingSessionPolicyStore, RemoteEndpointStore and WakeMetadataStore now
take an explicit RolePreferences at every call site. The sender and the
receiver-only apps (iPhone/iPad, standalone Mac Receiver) keep the original
key names and defaults. The unified app's receiver role uses receiver.-
prefixed keys, and its automatic approval defaults to off. Pairing records
routing hints in the role of the trust store it pinned into, and the
receiver's settings toggle binds the same key the receiver decides with.
InputAutoRequestStore's receiver-only key is scoped the same way.

RolePreferencesIsolationTests reproduce the leak (12 of 14 failed with the
shared keys) and pass with the fix.
…itching

Settings gains a "This Mac as a Display" page, separate from the sender's
pages: the role switch and status, the receiver name, the sending Macs
paired with this role (Automatically Allow Connections, each Mac's
Connection Requests, Connect, Forget) and audio with A/V Sync. Its sections
are the standalone receiver's own, extracted so both apps share them
(ReceiverPairedMacsSection, ReceiverAudioSection, ReceiverNameField). With
the role off, paired Macs can still be forgotten (StreamReceiver.
removeStoredTrust) without putting this Mac on the network. Overview links
to the page.

- The Mirror-unavailable "Use Extend" offer is back: the receiver's
  attention hook opens Settings, which shows the standalone receiver's
  alert (now MirrorUnavailableOfferAlert).
- Changing Dock/menu-bar presentation while the display window is up no
  longer drops the app out of Command-Tab (MacAppActivationPolicy).
- Turning the role off and straight back on waits for the old receiver
  to release its listeners before starting a new one.
- The receiver's own-sender discovery filter reads the sender's id on
  every update instead of caching a possibly missing one.

Tests cover the activation rule, the settings page and search, and guard
that the receiver's settings never reach the sender's trust or keys.
MeowDisplay's display role and the standalone MeowDisplay Receiver bind the
same ports and Bonjour services. A second bind fails with EADDRINUSE
(measured; the port is never shared), but that failure was silent: the
second receiver retried forever while saying "Waiting for a Mac…", and its
pairing listener never retried, so pairing stayed dead after the first
quit.

Both now take ReceiverOwnershipLock (a kernel flock on
/Users/Shared/.meowdisplay-receiver.lock, released by the kernel on quit or
crash) before creating any listener. Whichever starts second binds and
advertises nothing, shows what to quit, checks again every 2 s and starts
by itself; the owner releases only after its listeners closed. MeowDisplay
also waits while an already-released standalone receiver (no lock) runs.

A stopped StreamReceiver is retired, so a listener retry scheduled before
stop() can't reopen a port, and the pairing listener retries a failed bind
like the TLS listener.
With "Use This Mac as a Display" on, the sender kept advertising
_opendisplay-mac-pair._tcp, so other receivers listed this Mac as one that
would share with them, and their Connect went nowhere. The role now
withdraws that advertisement and restores it when turned off; receivers use
it only for listing and pairing, so reconnect is unaffected.

The display role no longer refuses to turn on (or switches itself off at
launch) while MeowDisplay Receiver runs: it stays on, says what to quit,
and starts by itself afterwards.
…il closed

The ownership lock lived in /Users/Shared, where another account could
pre-create it, plant a symlink, or hold it to block this Mac's display.

- Lock at ~/Library/Application Support/MeowDisplay/receiver.lock, reached
  with openat(O_DIRECTORY | O_NOFOLLOW) one component at a time from the
  account's home directory. Every folder must be this user's and not
  writable by others; the file is opened O_NOFOLLOW | O_NONBLOCK, created
  0600 and must be a single-link regular file this user owns (fstat).
  One location, no fallback, and release never unlinks it.
- An unusable lock no longer lets the receiver start: it says what's wrong
  and where, and Try Again checks again. No automatic retry.
- Media and pairing listener bind failures are reported
  (ReceiverPortHealth) so a port held by another account or app shows as
  "in use" instead of "Waiting for a Mac".
- Tests use a real second process for the lock (both orders, normal exit,
  SIGKILL, release and reacquire) and for a taken port, plus symlink,
  folder symlink, hard link, FIFO, foreign owner and permission cases.
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.

1 participant