Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c3d98bd
fix: give the iOS QUIC listener exclusive use of UDP 9001 and read st…
raiseCatError Sep 27, 2026
49a8d06
test: temporary QUIC stream-delivery diagnostics
raiseCatError Sep 27, 2026
eb2e483
test: fix the QUIC diagnostics' readiness wait; log stream IDs in DEBUG
raiseCatError Sep 27, 2026
f0656a6
fix: accept QUIC streams only once ready and leave room for Network.f…
raiseCatError Sep 27, 2026
5e9f45f
fix: let a waiting QUIC group reach ready; step aside for iPad system…
raiseCatError Sep 27, 2026
fd21e5b
feat: first-class iPad receiver layouts, adaptive Settings, and viewp…
raiseCatError Sep 29, 2026
84fd41f
feat: refine iPad/iPhone receiver controls from physical QA
raiseCatError Sep 29, 2026
5f74a97
fix(mac): keep the session through display sleep and screen lock (#320)
peetzweg Sep 29, 2026
eaf3102
chore(mac-receiver): sync generated Info.plist with project.yml
raiseCatError Sep 29, 2026
dd3b117
fix(mac): recover a broken HEVC stream to H.264 and re-check HEVC on …
raiseCatError Sep 29, 2026
c99feae
docs(mac): point debug-knob comments at MeowDisplay's defaults domain
raiseCatError Sep 29, 2026
fc4e366
feat: link every About screen to GitHub and prepare an optional Ko-fi…
raiseCatError Sep 29, 2026
84536f9
docs: bring release docs up to date with the shipping apps
raiseCatError Sep 29, 2026
824ff21
feat(ios): point update links at MeowDisplay's App Store listing
raiseCatError Sep 30, 2026
a252e82
chore(ios): declare no non-exempt encryption for App Store uploads
raiseCatError Sep 30, 2026
7a5045e
feat(mac): enable Sparkle updates with MeowDisplay's own feeds and key
raiseCatError Sep 30, 2026
f4f097d
feat(ios): add populated demo mode for App Review
raiseCatError Oct 11, 2026
146df17
fix(ios): fully isolate demo mode state
raiseCatError Oct 11, 2026
4e95c43
feat(ios): start the Function Tray opposite the Main Tray
raiseCatError Oct 11, 2026
a38c4ab
feat(ios): make demo mode an interactive sample desktop
raiseCatError Oct 11, 2026
7bda99f
fix(ios): keep demo accessible across home layouts
raiseCatError Oct 11, 2026
dbb8a8d
fix: drop build-captured bundle names from InfoPlist catalogs
raiseCatError Oct 11, 2026
1858ff7
chore(ios): sync demo strings in the String Catalog
raiseCatError Oct 11, 2026
6faadcd
Merge branch 'fix/app-review-demo-mode' into release/app-store-rc
raiseCatError Oct 11, 2026
cbbad7f
fix(ios): link the privacy policy from Settings > About
raiseCatError Oct 11, 2026
3432705
chore: merge dev/opendisplay-next launch docs onto the 1.20.0 release…
raiseCatError Oct 25, 2026
4e8e4e8
feat(mac): add a per-device Mirror/Extend assignment model
raiseCatError Oct 25, 2026
cdb2f2d
feat(mac): give each connected device its own Mirror/Extend mode
raiseCatError Oct 25, 2026
b48fc01
feat(mac): show and change each device's display mode in its settings
raiseCatError Oct 25, 2026
4f4fbb9
fix(mac): keep the refused-Mirror notice true for a device already mi…
raiseCatError Oct 25, 2026
66de410
docs: describe per-device Mirror/Extend and its physical test checklist
raiseCatError Oct 25, 2026
9512ef2
fix(ios): build the edge-layout touch region from typed pieces
raiseCatError Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 48 additions & 61 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ on:
permissions:
contents: write
pull-requests: write
# Lets the build-mac job dispatch the Pages deploy after committing the
# Sparkle appcast (a GITHUB_TOKEN push won't auto-trigger pages.yml).
actions: write

# Serialize release runs on the same ref so two quick merges can't race two
# uploads. Never cancel an in-flight run — it may be mid-upload or notarizing.
Expand Down Expand Up @@ -37,11 +34,6 @@ jobs:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: macos-26
# The `secrets` context can't be referenced in a step-level `if:` (it's a
# workflow-validation error). Map the Sparkle key to a job env var so the
# appcast step can guard on its presence via the allowed `env` context.
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
steps:
- uses: actions/checkout@v6

Expand Down Expand Up @@ -72,9 +64,13 @@ jobs:
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
TAG_NAME: ${{ needs.release-please.outputs.tag_name }}
APP_BUILD_NUMBER: ${{ github.run_number }}
run: |
export APP_MARKETING_VERSION="${TAG_NAME#v}"
# Sparkle orders updates by CFBundleVersion, so the Mac build number
# must grow with every release, including the first one (1.20.0),
# which was built by hand. The marketing version does; this
# workflow's run number restarts at 1 on the fork and would not.
export APP_BUILD_NUMBER="$APP_MARKETING_VERSION"
bundle exec fastlane mac build_release
bundle exec fastlane mac build_receiver_release

Expand All @@ -90,79 +86,70 @@ jobs:

# --- Sparkle auto-update: generate + publish the appcasts (issue #52) ---
# Builds appcast.xml (sender) and appcast-receiver.xml (receiver app)
# from the just-released, notarized DMGs and commits them to public/ on
# main. One feed per app: each bundle has its own SUFeedURL, and a mixed
# feed would offer the wrong app's update.
# from the just-released, notarized DMGs and uploads them to the same
# GitHub Release. One feed per app: each bundle has its own SUFeedURL,
# and a mixed feed would offer the wrong app's update.
#
# Currently a no-op regardless: client-side Sparkle checks are disabled
# (no SUFeedURL/SUPublicEDKey in project.yml — see its Sparkle comment)
# until MeowDisplay has its own hosted domain and a signing key pair
# that isn't upstream OpenDisplay's. Re-wire this step's target URL/feed
# once that infrastructure exists.
# The apps' SUFeedURLs (https://meowdisplay.app/appcast.xml and
# /appcast-receiver.xml) redirect on Cloudflare to
# releases/latest/download/<feed>, so the feed goes live together with
# the release and needs no website deploy. See RELEASE.md.
#
# No-ops gracefully until the maintainer adds the SPARKLE_PRIVATE_KEY
# secret (the EdDSA private key from Sparkle's `generate_keys`), so this
# never hard-fails a release before auto-update is switched on.
- name: Publish Sparkle appcast to GitHub Pages
if: ${{ env.SPARKLE_PRIVATE_KEY != '' }}
# The apps require signed feeds (SURequireSignedFeed), which
# generate_appcast produces when it finds that key in the archived app.
# Required: without SPARKLE_PRIVATE_KEY no feed can be signed, and
# installed apps would never be offered this release.
- name: Publish Sparkle appcasts to the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ needs.release-please.outputs.tag_name }}
# Sparkle release that ships the generate_appcast / sign_update tools.
SPARKLE_TOOLS_VERSION: "2.6.4"
# Only this step sees the signing key.
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
# Sparkle release that ships the generate_appcast / sign_update
# tools, and the SHA-256 GitHub publishes for its tarball.
SPARKLE_TOOLS_VERSION: "2.10.0"
SPARKLE_TOOLS_SHA256: "c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c"
run: |
set -euo pipefail

# Sparkle's CLI tools (generate_appcast, sign_update) ship in the
# official release tarball — download the matching version.
if [ -z "$SPARKLE_PRIVATE_KEY" ]; then
echo "::error::SPARKLE_PRIVATE_KEY is not set; cannot sign the appcasts"
exit 1
fi

curl -fsSL -o /tmp/sparkle.tar.xz \
"https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_TOOLS_VERSION}/Sparkle-${SPARKLE_TOOLS_VERSION}.tar.xz"
echo "${SPARKLE_TOOLS_SHA256} /tmp/sparkle.tar.xz" | shasum -a 256 -c -
mkdir -p /tmp/sparkle && tar -xf /tmp/sparkle.tar.xz -C /tmp/sparkle
GEN_APPCAST="$(find /tmp/sparkle -name generate_appcast -type f | head -1)"
echo "Using $GEN_APPCAST"

# The EdDSA private key (CI secret) → file for --ed-key-file.
printf '%s' "$SPARKLE_PRIVATE_KEY" > /tmp/sparkle_ed_priv
SPARKLE_BIN=/tmp/sparkle/bin

# generate_appcast wants a directory of update archives per feed.
# Point the download links at the GitHub Release assets where the
# DMGs live.
# Download links point at this release's immutable per-tag assets.
mkdir -p /tmp/appcast-updates /tmp/appcast-receiver-updates
cp build/export/MeowDisplay.dmg /tmp/appcast-updates/
cp build/export/MeowDisplayReceiver.dmg /tmp/appcast-receiver-updates/

"$GEN_APPCAST" \
--ed-key-file /tmp/sparkle_ed_priv \
# The key goes in on stdin (`--ed-key-file -`), never to disk.
printf '%s' "$SPARKLE_PRIVATE_KEY" | "$SPARKLE_BIN/generate_appcast" \
--ed-key-file - \
--download-url-prefix "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG_NAME}/" \
--link "https://meowdisplay.app/" \
-o /tmp/appcast-updates/appcast.xml \
/tmp/appcast-updates
"$GEN_APPCAST" \
--ed-key-file /tmp/sparkle_ed_priv \
printf '%s' "$SPARKLE_PRIVATE_KEY" | "$SPARKLE_BIN/generate_appcast" \
--ed-key-file - \
--download-url-prefix "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG_NAME}/" \
--link "https://meowdisplay.app/" \
-o /tmp/appcast-receiver-updates/appcast-receiver.xml \
/tmp/appcast-receiver-updates

# Commit the feed into public/ on main; Pages picks it up from there.
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout main
mkdir -p public
cp /tmp/appcast-updates/appcast.xml public/appcast.xml
cp /tmp/appcast-receiver-updates/appcast-receiver.xml public/appcast-receiver.xml
rm -f /tmp/sparkle_ed_priv
git add public/appcast.xml public/appcast-receiver.xml
# Compare the STAGED files: plain `git diff` ignores untracked files,
# so the first-ever appcast (a brand-new public/appcast.xml) looked
# "unchanged" and was never committed. `--cached` sees the staged add.
if ! git diff --cached --quiet -- public/appcast.xml public/appcast-receiver.xml; then
git commit -m "chore(release): publish Sparkle appcasts for ${TAG_NAME} [skip ci]"
git push origin main
# Pushes made with GITHUB_TOKEN do NOT trigger other workflows
# (GitHub blocks recursive runs), so kick the Pages deploy
# explicitly via its workflow_dispatch entry point.
gh workflow run pages.yml --ref main || \
echo "could not dispatch pages.yml — deploy it manually"
else
echo "appcast.xml unchanged — nothing to publish"
fi
# The apps reject an unsigned feed or update, so check both
# signatures with Sparkle's own verifier before publishing.
for feed in /tmp/appcast-updates/appcast.xml /tmp/appcast-receiver-updates/appcast-receiver.xml; do
grep -q 'sparkle:edSignature=' "$feed"
printf '%s' "$SPARKLE_PRIVATE_KEY" | "$SPARKLE_BIN/sign_update" --ed-key-file - --verify "$feed"
done

gh release upload "$TAG_NAME" --clobber \
/tmp/appcast-updates/appcast.xml \
/tmp/appcast-receiver-updates/appcast-receiver.xml
7 changes: 7 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ compatibility; the product name is MeowDisplay. `src/`, `public/` and
| Capture (ScreenCaptureKit) and its lifecycle/recovery | `Mac/MacSender.swift`, `Mac/CaptureLifecycle.swift`, `Mac/CaptureOwnership.swift`, `Mac/MacSenderPipelineState.swift` |
| Virtual display for Extend (private `CGVirtualDisplay` SPI, isolated here) | `Mac/VirtualDisplay.swift`, `Mac/DisplayArrangement.swift` |
| Mirror source selection | `Mac/MirrorDisplaySelection.swift` |
| Per-device Mirror/Extend, live virtual displays ([MULTI_DEVICE.md](MULTI_DEVICE.md)) | `Mac/DisplayModeAssignment.swift` |
| Video encode (VideoToolbox) | `Mac/MacSenderVideoEncoder.swift` |
| System audio capture + AAC encode | `Mac/AudioCaptureEncoder.swift` |
| Input injection and targeting | `Mac/InputInjector.swift`, `Mac/InputRouting.swift`, `Mac/SystemGestureInvoker.swift` |
Expand Down Expand Up @@ -142,6 +143,11 @@ framed media over the active route ───────────────
private `CGVirtualDisplay` SPI is used.
- Capture lifecycle and transport lifecycle are related but deliberately
separate: a route change or reconnect should not have to rebuild capture.
- Mirror/Extend is chosen **per device**: each connected device has its own
pipeline, and `DisplayModeCoordinator` decides each one's mode and Mirror
source and which single session a change rebuilds. The Mac-wide settings
are defaults for devices without their own choice. See
[MULTI_DEVICE.md](MULTI_DEVICE.md).

## Input pipeline

Expand Down Expand Up @@ -377,3 +383,4 @@ hints and an in-memory cooldown. USB never uses QUIC.
- [SECURITY.md](SECURITY.md) — security model and vulnerability reporting
- [SUPPORT.md](SUPPORT.md) — where to get help
- [TECHNICAL_NOTES.md](TECHNICAL_NOTES.md) — platform constraints behind the design
- [MULTI_DEVICE.md](MULTI_DEVICE.md) — per-device Mirror/Extend and its physical test checklist
2 changes: 1 addition & 1 deletion COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ We cut Mac and iOS from one release tag, but distribution is not simultaneous:

| | Update channel | Time to reach a user |
|---|---|---|
| **Mac** | Sparkle appcast (upstream: `opendisplay.app/appcast.xml`; MeowDisplay's Sparkle checks are off until it hosts its own) | hours — auto, silent |
| **Mac** | Sparkle appcast (`meowdisplay.app/appcast.xml`, `appcast-receiver.xml`) | within a day once the user allows automatic checks (Sparkle asks); installs when the user accepts, unless they opted into automatic installs |
| **iOS** | App Store (review + user tapping Update) | days → weeks, with a **long tail** |

So at any moment the field holds many version pairings. The dominant one is
Expand Down
138 changes: 138 additions & 0 deletions MULTI_DEVICE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Multiple devices: per-device Mirror/Extend

Each connected iPhone, iPad or receiving Mac chooses its own display mode
([issue #20](https://github.com/raiseCatError/meowdisplay/issues/20)). One
device can mirror the Mac's built-in display while two iPads extend it as two
separate desktops, and changing one device never changes or restarts the
others.

## How it works

Every connected device already had its own `MacSender` pipeline: its own
virtual display (Extend), ScreenCaptureKit stream, encoder and connection.
What made devices share a mode was the Mac-wide `mode` and
`mirrorDisplayUUID` settings that every pipeline was built from, plus
`restartAll()`, which rebuilt every device whenever either changed.

| Piece | Where | Role |
| --- | --- | --- |
| Per-device choice | `DeviceDisplayModeStore` | Mode and Mirror source per receiver install id (`displayMode.<id>` in the Mac's defaults). Unset fields follow the Mac default. |
| Mac defaults | `SenderController.mode`, `.mirrorDisplayUUID` | The pre-#20 global settings, same storage, now the default for devices without their own choice. |
| Authority | `DisplayModeCoordinator` | Resolves each live session's assignment and names exactly the sessions a change must rebuild. |
| Live assignment | `DeviceSession.displayAssignment` | What that session's pipeline was actually built with. |
| Rebuild | `SenderController.rebuild(_:)` | `restartAll()` for one session: same teardown, same carried invitation. |
| Mirror source | `MirrorSourceResolver` | Automatic, capture recovery and wake recovery skip MeowDisplay virtual displays. |
| Live virtual displays | `VirtualDisplayRegistry` | Every MeowDisplay virtual display alive in this process and its owning session; `VirtualDisplay` registers for its own lifetime. |
| Display identity | `VirtualDisplayIdentityPlanner` | An Extend display skips a serial/product identity another live session holds. |

All of these except the controller and views are Foundation/CoreGraphics
only and run in the hostless `OpenSidecarMacTests` bundle.

### What changes what

| Event | Effect |
| --- | --- |
| Displays → Default Display Mode / Default Mirror Display | Rebuilds only devices that follow the default and now capture differently. |
| Device settings → Display Mode / Mirror Display | That device only; remembered for it. **Mac Default** returns it to following the default. |
| The receiver's own Mirror/Extend or Mirror display control | That device only; remembered for it. |
| Connect with Mirror / Connect with Extend | That device only; remembered for it. |
| Session approval (Allow & Mirror / Allow & Extend) | That device only. |
| Video Off (Mac-wide) | With a physical display: every Extend device moves to Mirror in place (no reconnect), the default becomes Mirror, and a device whose own choice was Extend now remembers Mirror. Video On never restores Extend. Headless: nothing changes mode. |
| Streaming profile, priority, codec, quality | Still Mac-wide `restartAll()`; each device keeps its own mode across it. |
| Forget | Removes the device's display mode with its other per-device state. |

A rejected request (Mirror without a usable physical display, Extend while
video is off) leaves that device as it was, tells only that receiver, and
shows the reason in the device's settings.

### Mirror source and lost displays

- **Automatic** is the first capturable display that is not a MeowDisplay
virtual display. Before #20 it was simply the first display, which with an
Extend device connected could be that device's desktop. With no
MeowDisplay displays present, behavior is unchanged.
- An explicit pick of another device's extended desktop is honored, so a
phone can deliberately mirror an iPad's Extend desktop.
- If the chosen display is missing at start, the device mirrors Automatic and
keeps its choice; the picker says so. If the mirrored display disappears
mid-session, capture recovery moves to Automatic under the same exclusion.
If only MeowDisplay displays remain, Mirror fails with the existing
"No display available to mirror" error instead of mirroring another device.

### No protocol change

`displayModeRequest`, `displayModeState`, `mirrorDisplayRequest` and
`mirrorDisplayState` were already per connection. Existing iPhone, iPad and
Mac receivers work unchanged. Their mode control now affects only themselves.

### Limits

- macOS decides how many virtual displays and simultaneous captures the Mac
can sustain. MeowDisplay sets no limit of its own and has not measured one.
- A device without a known install id when it connects (a USB device whose
install id isn't resolved yet) starts with the Mac default. Its own choice
is applied by rebuilding that session once its hello authenticates.
- Changing a device's mode restarts that device's stream (a short reconnect)
and, like any new connection, its input grant: it must request control
again. Other devices are not touched.

## Automated coverage

`MacTests/DisplayModeAssignmentTests.swift` drives the real coordinator and
registry with synthetic receivers:

- Mirror + Extend, Extend + Extend, Mirror + Mirror (different sources)
- three receivers (the issue's table) and five in every mix
- changing one device while others are active; default changes reaching only
followers
- interleaved connects and disconnects with no leaked entries or displays;
a receiver crash mid-switch; route changes; sender restart from the store
- a USB session identified late; staged one-off choices
- rejections (no physical display, Video Off, ended session)
- Video Off and Video On semantics
- Automatic exclusion, explicit pick of a MeowDisplay display, a lost
selected display, recovery after the mirrored display is unplugged
- identity collisions and registry ownership, including a late release of an
old display
- input from three receivers landing in three different display rectangles

These prove the decision logic. They do not prove WindowServer,
ScreenCaptureKit or multi-device hardware behavior.

## Physical test checklist

Needs one Mac with a built-in display (plus an external display for the
Mirror-source rows), an iPhone and two iPads (or an iPad and a receiving Mac),
all paired. Run on a build from this branch.

1. **Single device, unchanged.** One iPhone: switch Mirror ⇄ Extend from the
Displays page and from the iPhone. Both work as in 1.20.0.
2. **Mirror + Extend.** iPad A Extend, then iPhone → Mirror from the iPhone.
iPad A keeps its desktop and windows, no reconnect. iPhone shows the
built-in display, not iPad A's desktop.
3. **Extend + Extend.** iPad A and iPad B in Extend: System Settings →
Displays shows two separate MeowDisplay displays; arrange them apart; drag
a window onto each.
4. **The issue's table.** iPad A Extend · iPhone Mirror · iPad B Extend at
once. Touch on each device lands on its own screen.
5. **Switch one while others stream.** Change iPad B to Mirror from its device
settings: only iPad B reconnects; iPad A's display, window placement and
input grant survive.
6. **Mirror + Mirror.** With an external display: iPhone mirrors built-in,
iPad mirrors the external display (each from its device settings).
7. **Lost display.** While a device mirrors the external display, unplug it:
that device moves to the built-in display (not an iPad's desktop); the
others are unaffected. Plug it back and reselect.
8. **Defaults.** Set Default Display Mode to Mirror: only devices on
**Mac Default** switch.
9. **Reconnect and restart.** Disconnect and reconnect each device, then quit
and relaunch the Mac app: every device returns in its own mode, and
System Settings → Displays shows no leftover MeowDisplay displays after
all devices disconnect.
10. **Video Off.** With two Extend devices, turn Video Off: both move to
Mirror without reconnecting. Turn it back on: both stay in Mirror.
11. **Headless.** On a Mac with no active physical display (for example a
Mac mini with its monitor unplugged) and one device extending: a Mirror
request from that device is refused with a message on that device only.
12. **Forget.** Forget a device with its own mode, pair again: it follows the
Mac default.
Loading
Loading