Skip to content

Apple Vision Pro support: wireless pairing + install over an RP tunnel - #565

Open
rebelancap wants to merge 23 commits into
nab138:mainfrom
rebelancap:visionos-tunnel
Open

Apple Vision Pro support: wireless pairing + install over an RP tunnel#565
rebelancap wants to merge 23 commits into
nab138:mainfrom
rebelancap:visionos-tunnel

Conversation

@rebelancap

Copy link
Copy Markdown

A Wi-Fi Apple Vision Pro isn't carried by usbmuxd, so it can't be reached through iloader's usbmux device path like an iPhone/iPad. This adds a parallel Vision Pro transport that reuses the RemotePairing (RSD) tunnel, alongside the completely unchanged iOS/iPad usbmux path.

What it does

  • Discovers a Vision Pro over mDNS via a persistent background browser (_remotepairing-manual-pairing._tcp when unpaired, _remotepairing._tcp once paired — a paired VP stops advertising manual-pairing), and lists it next to usbmux devices.
  • First-time pairs with the 6-digit code shown on the headset (Settings → General → Remote Devices) — no cable, dev strap, or Xcode.
  • Reaches install services over the RP tunnel: pair-verify on RSD :49152 → TLS-PSK CDTunnel → software TCP stack → RSD handshake → installation_proxy/AFC. Signs with isideload (transport-agnostic) and installs over the tunnel, then places the RP pairing file into the app.

Structure

  • src-tauri/src/vision.rs — the whole VP transport (new module).
  • device.rs / sideload.rs / pairing.rs — branch on a new DeviceTransport{Usbmux,Vision} discriminator on DeviceInfo; the iOS/iPad usbmux path is untouched.
  • Frontend (Device.tsx / App.tsx) — a Vision Pro device card and a "enter the code from your headset" modal.
  • src-tauri/Info.plist — declares NSLocalNetworkUsageDescription + NSBonjourServices (required for mDNS multicast on macOS; without them a packaged build silently finds no devices).

Notes for review

  • isideload is vendored in src-tauri/vendor/isideload as a stopgap: a VP registered under Apple's separate visionOS device platform makes isideload's ensure_device_registered hit "device already registered" (developer error 35); the tolerance for that is on isideload's git HEAD but not yet in a crates.io release. Once released, this reverts to a normal dependency.
  • For a VP, the installers fetch the patched visionOS SideStore / LiveContainer builds (rebelancap forks), since the stock arm64 builds lack the visionOS fixes.
  • Validated end-to-end on a real Apple Vision Pro (visionOS 27): wireless pair → install SideStore → sideload an app over Wi-Fi.

Companion visionOS patches live on the rebelancap SideStore + AltSign forks.

…unnel

A Wi-Fi Apple Vision Pro isn't carried by usbmuxd, so it can't be reached
through iloader's usbmux device path like an iPhone/iPad. This adds a parallel
Vision Pro transport that reuses the RemotePairing (RSD) tunnel, alongside the
unchanged iOS/iPad usbmux path.

- src-tauri/src/vision.rs: the whole VP transport — a persistent mDNS browser
  (browses _remotepairing-manual-pairing._tcp for unpaired VPs and
  _remotepairing._tcp for already-paired ones), first-time pairing via the
  headset 6-digit code (vision_pair, driven by vision-pair-status/-code events),
  the tunnel (pair-verify on RSD 49152 -> TLS-PSK CDTunnel -> RSD services), and
  sign-then-install over the tunnel. RP pairing cached by a canonical device name.
- device.rs: DeviceTransport{Usbmux,Vision} + ip/paired on DeviceInfo;
  list_devices joins usbmux and mDNS discovery; set_selected_device verifies a
  stored VP pairing.
- sideload.rs / pairing.rs: branch on transport — a VP signs via isideload
  sign_app then installs over the tunnel and places rp_pairing_file.plist; routes
  to the patched visionOS SideStore / LiveContainer builds.
- secure_storage.rs: memoize the keychain-availability probe.
- Info.plist: declare NSLocalNetworkUsageDescription + NSBonjourServices, or
  macOS silently blocks the mDNS multicast a packaged build discovers the VP over.
- Frontend: a Vision Pro device card + headset code-entry modal.
- Vendor the patched isideload in-repo (stopgap until the dev-error-35 tolerance
  for a visionOS-registered device ships in a crates.io release).

Validated on a real Apple Vision Pro (visionOS 27): pair -> install SideStore ->
sideload over Wi-Fi.

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

barlarb commented Jul 19, 2026

Copy link
Copy Markdown

This is amazing! Thank you so much for your work on this! I have now successfully installed Livecontainer and Sidestore and sideloaded apps on my Vision Pro. The only issue I've come across is I am unable to enable JIT despite having the pairing file. It says the Developer Disk Image is not mounted. Any suggestions you have would be really appreciated—and thank you again!

@rebelancap

Copy link
Copy Markdown
Author

Glad it’s working for you. Check out my repo for ports for visionOS with SideStore sources! I’m working on several others yet to come.

I was not optimistic about JIT so I didn’t give it a real try. But I should take another look (well Claude Fable should - he’s doing the legwork here).

@barlarb

barlarb commented Jul 19, 2026

Copy link
Copy Markdown

Just had a look! Fantastic, thank you!
If you could (with Claude's help) I would be really grateful! I really hope to play PS2 games on it. Do you have a Ko-fi page or similar to support you?

@rebelancap

Copy link
Copy Markdown
Author

@barlarb Unfortunately worked on this all afternoon, and visionOS 27 blocks every JIT attempt. Can mount the DDI, can attach a debugger, SideStore confirms CS_DEBUGGED, but visionOS blocks every attempt to actually execute JIT code. This is different than iOS. We can revisit in the future, perhaps when current OS is out of beta. But I'm not optimistic.

@barlarb

barlarb commented Jul 20, 2026

Copy link
Copy Markdown

Thank you for trying! Hopefully it can be figured out at some point, once OS27 is out, as you say. Finger's crossed! It feels a shame for Vision Pro to miss out on JIT. Thanks again for making sideloading such a breeze now! Really appreciate it.

rebelancap and others added 2 commits July 27, 2026 14:19
Two field-reported failures in the Vision Pro flow, plus better diagnostics:

1. macOS: discover over the system Bonjour daemon instead of raw multicast.
   On macOS 15+ the Local Network privacy layer frequently fails to
   attribute an app's own multicast sockets (mdns_sd): the app never shows
   the permission prompt, never appears in System Settings > Privacy &
   Security > Local Network, and its mDNS packets are silently dropped —
   affected user logs show the multicast joins succeeding and then zero mDNS
   traffic for the whole session. Terminal-launched dev builds are exempt
   (they inherit automatic local-network access, per TN3179), which is why
   this only bit Finder-launched release builds. Browsing through
   mDNSResponder (async-dnssd) restores proper attribution: the prompt
   appears reliably (verified on a fresh macOS account) and the daemon
   handles interface churn for us. Other platforms keep mdns_sd.
   Discovery-startup errors (e.g. kDNSServiceErr_PolicyDenied while the
   permission is off) are now surfaced in the device list's empty state
   instead of looking like "no device found", and the browse retries so
   granting the permission recovers without a relaunch.

2. Fix the first "Tap to pair" always failing with "missing server proof in
   SRP response". idevice 0.1.65 hardcodes PIN "000000" when the device
   answers `awaitingUserConsent` (an iPhone Trust-dialog assumption); a
   Vision Pro answers that whenever its pairing-code screen isn't up yet,
   then delivers the SRP pairing data once the code is displayed, so the
   first attempt could never succeed. Vendor idevice (same pattern as
   vendor/isideload) with that branch routed to the pin callback; to be
   upstreamed to jkcoxson/idevice and dropped once a release carries it.
   Also retry the pairing session silently when it fails before the user was
   ever asked for a code, re-resolving the (rotating) manual-pairing port
   per attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fork-release housekeeping for the signed rebelancap builds — drop this
commit if upstream manages versions separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rebelancap

rebelancap commented Jul 27, 2026

Copy link
Copy Markdown
Author

Pushed an update fixing two bugs that surfaced once people started using this on macOS — the branch now matches the build my fork's users have confirmed working

1. The macOS Local Network permission prompt never appeared (iloader wasn't listed under System Settings ▸ Privacy & Security ▸ Local Network, so discovery silently found nothing). Root cause: macOS 15+ frequently fails to attribute an app's own multicast sockets (mdns_sd), so instead of prompting it silently drops the packets — user logs showed the multicast joins succeeding and then zero mDNS traffic for the whole session. Dev builds never reproduce it because terminal-launched processes get automatic local-network access (TN3179); only Finder-launched builds were affected. Discovery on macOS now goes through the system Bonjour daemon (async-dnssd → mDNSResponder), which is attributed correctly — verified on a clean macOS account that the prompt now appears and discovery works. Windows/Linux keep mdns-sd. Discovery-startup errors (e.g. permission denied) are now also surfaced in the device list's empty state, and the browse retries so toggling the permission on mid-session recovers without a relaunch.

2. The first "Tap to pair" always failed with missing server proof in SRP response (second attempt worked). Root cause is in idevice 0.1.65: when the device answers awaitingUserConsent it hardcodes PIN 000000 (an iPhone Trust-dialog assumption) — but a Vision Pro answers awaitingUserConsent whenever its pairing-code screen isn't up yet, then sends the SRP data once the code is displayed, so the first attempt could never succeed. I've vendored idevice with a one-branch patch (same pattern as the existingvendor/isideload) that routes that case to the pin callback; I'll file it upstream at jkcoxson/idevice and drop the vendor once a release carries it. Also added a silent retry when pairing fails before the user has been asked for a code, re-resolving the manual-pairing port each attempt (it rotates).

rebelancap and others added 20 commits July 27, 2026 15:35
Field reports from the 2.2.9 build:

- A headset left with the default device name advertises the hostname
  "Apple-Vision-Pro.local." (hyphenated), which the Vision-Pro-vs-iPhone
  hostname filter rejected — the device was discovered and then dropped
  ("not a Vision Pro, ignoring"), leaving the list empty. Match on
  alphanumerics only (the same normalization used for device identity), and
  add unit tests over the known hostname shapes. Example names in docs and
  tests use a generic "Sam" placeholder.

- "connect a.b.c.d:port: No route to host" is opaque: discovery worked (it
  found ip:port) but ARP got no answer. Map HostUnreachable/TimedOut on the
  three headset connects to a hint about the real causes — headset asleep,
  router AP/client isolation (guest Wi-Fi), or a VPN/firewall — and
  ConnectionRefused to a try-again-in-a-few-seconds hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- A headset is seen through two mDNS records with different name quality:
  the manual-pairing instance carries the real friendly name, while the
  remotepairing record only yields a hostname-derived label (no apostrophe
  or inner spaces). Merging previously took whichever resolved last, so the
  displayed name flip-flopped. Keep the known name when the label-only
  record refreshes an existing device.

- The "already paired" pairing error assumed the existing pairing was
  iloader's own. In the field it is often Xcode's, which also suppresses
  the headset's manual-pairing advertisement. Explain that: whose pairing
  it may be, that removing it pauses Xcode wireless debugging until
  re-paired, that both pairings coexist once set up, and that an
  Xcode-paired headset can be sideloaded to via its usbmux-bridged
  "Network" entry without pairing iloader at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A headset re-advertises its manual-pairing service every ~30-60s with a NEW
port each time, and the record is absent between announcements. When it went
away the device survived in the list (its remotepairing record still maps to
the same key) but kept the stale port, so a pairing attempt landing in that
gap dialed a port nothing was listening on. A field log shows the port walking
64421 -> 64429 within 20 minutes while the failing connect targeted 64423; a
dozing headset also stops answering ARP, which is why this surfaced as
"No route to host" rather than a refused connection.

Clear the port when the manual-pairing record is withdrawn, and wait (up to
40s, cancellable) for a live announcement before pairing instead of trusting
the last cached value. Distinguish "headset not visible at all" from "visible
but never offers pairing", so the existing-pairing/Xcode guidance is only
shown when it actually applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two failures a user hit as "No route to host" when re-installing, on a headset
that had paired fine before:

- Discovery kept only the FIRST advertised IPv4. A headset advertises several
  (its Wi-Fi address plus a 169.254.x link-local from another link, and DHCP
  moves it around), and the first one out of the resolver is often not the one
  this Mac can route to. Collect them all, prefer routable over link-local, and
  walk the list on connect instead of trusting one address.

- Selecting a paired headset DELETED the stored pairing whenever verification
  failed for any reason — including simply not reaching the device. A dozing
  headset therefore threw away a perfectly good pairing and forced a re-pair,
  which then failed too. Add AppError::VisionUnreachable for "no TCP connection
  established" and keep the pairing in that case; only discard it when the
  device answered and pair-verify actually rejected us.

Selection now also re-reads the addresses from the live browser rather than
using whatever the frontend last saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2.2.13 collected the advertised addresses inside one timeout wrapped around the
whole loop, and only broke early once it had MORE than one. The address stream
never ends on its own — it reports future changes — so a headset advertising a
single IPv4 ran the loop until the budget expired, and timeout() discarded every
address collected so far. The device then had no address and was dropped from
discovery: "no devices found", with no vision log lines at all.

Keep the addresses in a vec that survives the deadline, and stop at whichever
comes first: a short grace once a routable address is in hand, a longer wait
while everything so far is link-local (169.254.x, which the resolver often
reports first and which usually isn't reachable from the Mac's Wi-Fi), or the
overall budget.

Only headsets advertising several addresses were unaffected, which is why this
got through testing here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes with fresh eyes on the 2.2.13/2.2.14 discovery work:

- merge_device replaced the address list wholesale, so whichever service view
  resolved LAST won. The remotepairing view routinely catches only the
  169.254.x link-local within its settle window (observed in the field), and
  every ~30-60s re-announcement re-rolled those dice — silently discarding the
  routable address the other view had found and reintroducing "No route to
  host". Addresses are now unioned (fresh first, routable before link-local,
  capped) so a known-good address survives a partial resolve.

- The resolver stopped listening for addresses at its settle cutoff, so a
  routable address arriving after 2.5s of link-local-only was never seen and
  the device stayed on 169.254.x until the next announcement. The device is
  still published at the same cutoffs (no UI delay), but the address stream is
  now drained for the rest of the 10s budget, folding late arrivals into the
  live device entry ("Bonjour: <name> gained address <ip>" marks these).

- 2.2.13 taught SELECTION to walk every advertised address, but the sideload
  itself — and pairing-file placement, app listing, and the pre-signing UDID
  read — still dialed the single cached frontend address, which is stale by
  the time signing (minutes) finishes and is sometimes the link-local. All
  Vision connects now go through live_ips(): the browser's current list,
  routable first, cached address as a last resort.

- Selecting a paired headset deleted the stored pairing on ANY non-unreachable
  error — including failures AFTER pair-verify had already succeeded (tunnel
  listener, TLS-PSK, RSD handshake), where the pairing is proven good, and
  socket drops mid-verify, which prove nothing. Only an explicit pair-verify
  rejection (new AppError::VisionPairingRejected) discards it now.

- AFC uploads of large apps over the tunnel occasionally drop mid-file
  ("AFC write …: Socket(NotConnected)"). The upload loop is now flat (walk
  first, then upload) and a failed file is retried up to twice on a freshly
  opened AFC connection; only the failed file is resent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report on 2.2.15: pairing fails immediately with "No route to host" on a
Mac where ping and nc reach the headset fine and Xcode pairs — but those run
from Terminal (terminal processes get automatic local-network access, TN3179)
or under Xcode's own grant. An EHOSTUNREACH that arrives instantly on a
directly-attached subnet isn't a routing verdict at all: a genuinely absent
peer fails only after seconds of ARP probes. It's macOS's per-app Local
Network filter refusing to send — which is known to get stuck out of sync
with the Settings switch after app or macOS updates, even while Bonjour
browsing (done by mDNSResponder on our behalf) keeps working.

Connect attempts are now timed: a HostUnreachable/NetworkUnreachable under
300ms gets a hint to toggle iloader's Local Network permission off and on
(then restart the Mac), while slow failures keep the asleep/AP-isolation
hint. Every attempt logs its elapsed time, and a successful connect logs
BEFORE any protocol byte is exchanged — with the local address the kernel
chose — so a later protocol failure can never masquerade as a connect
failure in a user log, and the log now proves which interface carried the
connection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reported "iloader window goes completely black and never recovers" after
a failed pairing was a frontend crash, live since 2.2.13: the error modal
looks up suggestions with errorSuggestionKeys[type].flatMap(...), and the
vision_unreachable variant introduced in 2.2.13 was never added to that map.
Every "couldn't reach the headset" error therefore threw TypeError inside
ErrorProvider's effect, React unmounted the entire root (no error boundary),
and the user saw a solid black window while the backend kept running — the
carefully-worded network hints were never displayed at all.

- errorSuggestionKeys gains the vision_* entries, and getErrorSuggestions
  falls back to no-suggestions for any unmapped variant instead of throwing.
- A top-level CrashBoundary now renders any future render crash as a
  copyable error with a Reload button, never a black window.

Also from the same field report (an instant EHOSTUNREACH on a Mac where
iloader never appears in the Local Network pane at all):

- The instant-refusal hint now covers the missing-from-the-pane case: restart
  the Mac, and if the entry still doesn't appear, delete
  /Library/Preferences/com.apple.networkextension.plist and restart to make
  macOS rebuild its Local Network database.
- The pairing routine logs its timeline: when pairing is requested (pinning
  down the click, so dead time between discovery and the first connect is
  attributable), whether it is waiting for a live manual-pairing announcement
  and how long that took, and each attempt's dial targets. The log's "29
  missing seconds" in the report turned out to be time-to-click; now that is
  provable from the log alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From a user's independent investigation (thanks Rafael Torres): their
from-source build could discover the headset but every connect failed with
an instant EHOSTUNREACH, and pairing worked the moment the app was signed
with a real Apple Development identity under a fresh bundle id. A source
build without APPLE_SIGNING_IDENTITY is ad-hoc signed, and macOS 15+ can't
attribute Local Network permission to such an app — often no prompt, no
entry in the privacy pane, and instantly refused unicast. Worse, the broken
permission entry is keyed by bundle id, so an ad-hoc build can poison
me.nabdev.iloader for a properly signed copy installed later (their Mac:
the signed, notarized release DMG kept failing until the fresh bundle id —
i.e. a fresh Local Network database entry — was used).

iloader now checks its own signature once (codesign -dv on the bundle):
an ad-hoc build logs a warning at startup, and an instant connect refusal
adds a targeted explanation — sign with any Apple Development certificate
(a free account works) or use the official DMG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second confirmed field case (Jake) of the instant-EHOSTUNREACH block: his log
shows the pairing dial refused in 284µs — and four lines later usbmuxd opens a
TCP connection to his iPhone on the same subnet just fine, because that socket
belongs to the daemon, not to iloader. Same routing table, same moment: daemon
traffic passes, app traffic is refused. Both affected users also report
iloader missing from the Local Network pane entirely, which fits: on those
machines nothing the app does through mDNSResponder ever registers it.

At startup iloader now sends one well-formed mDNS PTR query for its own
pairing service from its own UDP socket. App-originated local traffic is what
makes macOS create the app's Local Network entry and show the prompt, and the
sendto verdict is logged, so every log states the filter's disposition from
launch. On an instant connect refusal the probe runs again fresh (the user
may have just toggled the permission) and, if also refused, the error states
outright that macOS is blocking iloader itself and the headset and Wi-Fi are
fine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants