VPN and LocalDevVPN

VPN is an injected global for active scripts in the main app. No import is required. Use VPN.getState(), VPN.start() and VPN.stop() to query or control this device's Scripting VPN. Start and stop require the vpn script permission. Reads do not request control access.

The following example starts Scripting’s VPN. When an external VPN already provides reflection, skip it and use the RSD section below.

import { Script } from "scripting"
const granted = await Script.requestAccess(["vpn", "localDevVPN"])
if (granted.includes("vpn")) {
  const state = await VPN.start()
  console.log(state.status, state.selectedConfigurationId)
}
if (granted.includes("localDevVPN")) {
  const before = await VPN.localDevVPN.getState()
  await VPN.localDevVPN.setEnabled(true, {expectedState: before.expectedState})
}

start() resolves after the owned tunnel is connected. A transitional connecting, reasserting or disconnecting state rejects as busy; read again before retrying. stop() resolves after the owned tunnel is disconnected and its stop preferences have been processed. Stop also closes owned RSD sessions. Script exit does not stop an already accepted global VPN operation. A completion timeout can reject while the accepted device operation continues; query state before retrying. Connection status alone does not prove traffic succeeded.

VPNState fieldMeaning
status / activeScripting VPN status; active is true only for connected.
selectedConfigurationId / selectedConfigurationNameEffective selected Scripting configuration, or null.
providerBundleIdOwned VPN provider bundle identifier, or null.
effectiveConfigurationIdActual configuration verified by provider diagnostics, or null if unavailable.
operation / problemCurrent API starting/stopping operation and reported failure, or null.

VPN.localDevVPN.getState() reads saved device intent and actual reflection state. setEnabled(enabled, {expectedState?}) changes only intent and retains addresses. Setting it while disconnected keeps the VPN off. The setting survives configuration switches and script exit. The localDevVPN permission is separate from vpn. A recorded denial is respected; errors retain machine-readable code, reason, permission and permissionScope where applicable.

VPNLocalDevState fieldMeaning
enabled / activeSaved intent / verified active reflection plan.
vpnStatus / effectiveStatusScripting status / inactive, pending, active, error or unavailable.
addresses / resolvedAddresses / effectiveAddressesSaved addresses / expected composition / verified installed addresses.
expectedStateOpaque compare-and-set token; return unchanged to guard a write.
profileId / problemEffective configuration ID / reported problem, or null.

A stale token rejects without overwriting intent. Omit the token to use the state immediately before the atomic save. Concurrent state changes during sampling reject. A successful save followed by a failed read can reject after saving: read again to reconcile.

RSD tunnel

Reflection and ordinary networking

VPN.localDevVPN.setEnabled(true) enables local-device reflection intent. VPN.start() starts the selected full Scripting proxy configuration, including its routing rules and DNS policy. Reflection does not automatically replace that policy with a reflection-only tunnel or DIRECT networking. Make sure ordinary traffic is allowed by the selected configuration; an isolated test profile that rejects other destinations is unsuitable for geocoding and web requests.

When another app supplies reflection, keep its VPN connected and call openRSDTunnel directly. Its own rules and DNS determine ordinary networking; Scripting does not modify them. Scripting's effectiveStatus: "inactive" is expected in that case.

Location.reverseGeocode uses the system geocoding service. A kCLErrorDomain code 2 rejection can mean connectivity problems or app-level rate limiting. Cache and coalesce lookups, avoid querying every map-camera update, and avoid rapid retries. Code 8 means no result. Compare a fixed coordinate at a low rate with VPN off, an allowed DIRECT configuration, and the external VPN if applicable. A successful web request alone does not prove the geocoder's backend path is healthy; retain the geocoder result too.

Open and exchange

VPN.localDevVPN.openRSDTunnel({host, port, preSharedKey, identity?, mtu?}) requires a reachable reflected numeric IPv4 address, localDevVPN permission and a previously obtained 32-byte pairing PSK (Uint8Array or ArrayBuffer). Identity is an optional UTF-8 string of at most 128 bytes. MTU defaults to 16000 and supports 1280–16384. This API opens the CDTunnel transport and bounded private IPv6 TCP connections. It does not discover or pair Apple devices or implement Apple RSD services.

The host uses the current system route. An external proxy or wloc may provide the reflection: configure and connect it first, then call this method directly. Do not call VPN.start() or require effectiveStatus === "active" for this path. getState() continues to describe Scripting only; it does not report or control another app's VPN. No preflight connection is made: the actual PSK handshake and service payload prove reachability. A one-use listener must never be probed before opening. Special, loopback, multicast and FakeIP addresses are rejected.

// VPN is injected globally; no import is required.
// Your pairing workflow supplies a fresh listener port and this session's PSK.
// servicePort and request must follow the protocol of the discovered service.
async function exchangeRSDService({host, port, preSharedKey, servicePort, request}: {
  host: string; port: number; preSharedKey: Uint8Array;
  servicePort: number; request: Uint8Array;
}): Promise<Uint8Array | null> {
  const tunnel = await VPN.localDevVPN.openRSDTunnel({host, port, preSharedKey})
  try {
    const connection = await tunnel.connect({port: servicePort})
    try {
      await connection.write(request)
      return await connection.read(16384) // A chunk, or null for peer EOF
    } finally { await connection.close() }
  } finally { await tunnel.close() }
}

One tunnel and four connections are allowed. Each read/write is bounded to 16384 bytes, with one pending read and one pending write per connection. Read maximum must be 1–16384; an idle read times out after 120 seconds. Stream methods require the original active App script. Script exit and explicit close release the tunnel. Sessions bound to a verified Scripting reflection also close on Scripting VPN stop and relevant local-device/configuration changes. External-route sessions remain independent of those settings; actual transport failures are reported by the session. Closing a tunnel waits for its cleanup; closing an old handle cannot close a replacement. A failed transport can still supply already verified buffered bytes before reporting failure. getState() reports negotiated addresses, MTU, RSD port, lifecycle state and bounded buffer counts. Keep session handles and close them when finished.

Each VPN state/start/stop method allows one pending request of its own kind. State reads and stop remain available while start is pending. A tunnel allows up to four pending connect requests; tunnel state/close and connection end/close each allow one pending request of their own kind. Await completion before issuing another request of the same kind.

Migration

The standalone LocalDevVPN global has been removed. Replace LocalDevVPN.getState() and LocalDevVPN.setEnabled(...) with VPN.localDevVPN.getState() and VPN.localDevVPN.setEnabled(...). Remove imports of these APIs from scripting; use the global declarations in the latest documentation and SDK. Permission identifiers remain localDevVPN for device intent/RSD, and vpn for VPN start/stop.

RSD lifecycle diagnostics

await tunnel.getState() includes closedReason for explicit lifecycle cleanup and terminalError for the first underlying terminal failure, or null if none. Explicit cleanup does not erase an earlier terminal failure. terminalError contains code, stage, nativeDomain, nativeCode, and optional networkErrorCategory (string) / networkErrorCode (number) details. RSD promise errors carry the same diagnostic fields; a closed-session rejection also carries closedReason. Permission errors retain their existing permission fields.

Common codes are RSD_SESSION_CLOSED, RSD_STREAM_CLOSED, RSD_TRANSPORT_EOF, RSD_TRANSPORT_TIMEOUT, RSD_TCP_TIMEOUT, RSD_PEER_RESET, RSD_NETWORK_FAILURE, RSD_PROTOCOL_ERROR, and RSD_CANCELLED. The numeric NSError code alone (including 0) does not identify an iOS termination or suspension. A single TCP reset can leave the tunnel usable; inspect both the operation error and tunnel state. Keep-alive state does not replace this check.

// VPN is global; tunnel is the VPNRSDTunnel returned by openRSDTunnel above.
async function inspectTunnel(tunnel: VPNRSDTunnel) {
  const state = await tunnel.getState()
  console.log(state.state, state.closedReason, state.terminalError)
}

Handling access errors

Network operations check access before starting and may throw or reject with error.code === "ERR_PRO_REQUIRED". Attach socket/server error handlers before connecting or listening, and catch promises from Bonjour and VPN APIs. Keep cleanup in finally: closing sockets and RSD sessions, stopping Bonjour resources, VPN.stop() and disabling VPN.localDevVPN remain available after access expires.

Opening deadline and cancellation

openRSDTunnel({...options, timeout:5000}) adds a deadline in milliseconds (integer 100...120000), from authorization completion through old-cleanup waiting, route sampling, TLS and session publication. Permission-dialog time is excluded. Omit it to retain existing policy, including the transport's 10-second startup timer. A deadline rejects with RSD_OPEN_TIMEOUT / TimeoutError.

await VPN.localDevVPN.cancelRSDTunnelOpening() cancels only this context's pending opening and waits for native cleanup. It does not close an established tunnel; use tunnel.close(). The open rejects with RSD_CANCELLED / AbortError. If a script permission dialog is awaiting the user, cancellation prevents transport admission but completion waits for that dialog to finish. Cleanup remains available after access expires. Always create a fresh pairing listener before retrying.

A deadline initiates cancellation; rejection follows the required native cleanup, which can add time.