Apple Vision Pro support: wireless pairing + install over an RP tunnel - #565
Apple Vision Pro support: wireless pairing + install over an RP tunnel#565rebelancap wants to merge 23 commits into
Conversation
…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>
d4573df to
706d2f8
Compare
|
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! |
|
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). |
|
Just had a look! Fantastic, thank you! |
|
@barlarb Unfortunately worked on this all afternoon, and visionOS 27 blocks every JIT attempt. Can mount the DDI, can attach a debugger, SideStore confirms |
|
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. |
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>
|
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 ( 2. The first "Tap to pair" always failed with |
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>
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
_remotepairing-manual-pairing._tcpwhen unpaired,_remotepairing._tcponce paired — a paired VP stops advertising manual-pairing), and lists it next to usbmux devices.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 newDeviceTransport{Usbmux,Vision}discriminator onDeviceInfo; the iOS/iPad usbmux path is untouched.Device.tsx/App.tsx) — a Vision Pro device card and a "enter the code from your headset" modal.src-tauri/Info.plist— declaresNSLocalNetworkUsageDescription+NSBonjourServices(required for mDNS multicast on macOS; without them a packaged build silently finds no devices).Notes for review
src-tauri/vendor/isideloadas a stopgap: a VP registered under Apple's separate visionOS device platform makes isideload'sensure_device_registeredhit "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.Companion visionOS patches live on the rebelancap SideStore + AltSign forks.