# A tour of the URnetwork SDK

This is the deep dive behind [getting started](/docs/getting-started-sdk):
how each of the four bindings is put together, the defaults it ships, and the
sharp edges we know about. Everything runs against URnetwork's hosted
platform.
You bring credentials, not infrastructure. The object model (device, API,
view controllers) is in [/docs/overview](/docs/overview) and
[/docs/api](/docs/api).

One Go core implements the entire client: transport, contracts, packet path.
Every artifact (AAR, xcframework, wasm, c-shared library) is a binding of
that same core, which is why behavior is identical across platforms, and why
the artifacts are large (each carries the Go runtime). The core carries
product rules too, not only packets. Solana Pay lives here
(`CreatePaymentReference`, `BuildSolanaPaymentUrl`) because it once did not:
the web app minted the payment reference as a hex uuid where Solana Pay
requires a base58 32-byte pubkey, so a customer could pay and never be
matched back to their account, and Android hardcoded the amount and merchant
address. Reimplementing a rule the apps already follow is the class of bug
this layer exists to prevent.

## Android

Go compiled to native code and bound for Kotlin/Java by gomobile, shipped as an
AAR. One process holds everything, which makes this the simplest of the four to
reason about.


### DeviceLocal, in one process

`DeviceLocal` is the real thing, the running client engine: it owns the
transport, the contract state (URnetwork's accounting of the bytes an
account may move), and the packet path. `DeviceRemote` is a client for a
`DeviceLocal` living in another process or on the platform, with the same
API surface: a remote control for the real device. On Android one process
hosts both the UI and the `VpnService`, so the app holds the `DeviceLocal`
directly.

### IoLoop

`Sdk.newIoLoop(device, detachedFd)` is the packet pump:

- Pass a **detached, non-blocking** fd (`ParcelFileDescriptor.detachFd()`).
  After that call, **Go owns the fd and will close it**; never wrap or close
  it from Java again. Two owners means double-close: fd numbers recycle
  immediately, so a stray close from Java can stomp whatever unrelated
  descriptor got the number next, corruption far from the cause.
- The loop pumps both directions (tun→device and device→tun) inside Go,
  which avoids per-packet JNI crossings and buffer copies. That is why the
  API takes a raw fd rather than exposing read/write methods.
- Close the IoLoop (not the fd) to stop; the done callback fires when the
  loop exits. If it fires when you didn't ask, because the tun fd hit EOF or
  an error or the device shut down, treat it as "the tunnel is gone": end
  the `VpnService` session, then re-establish or show disconnected. The
  callback arrives on an SDK thread, and Go closes the fd on the way out;
  never touch it from the handler.

## iOS / macOS

The same gomobile path, bound for Swift and shipped as an xcframework — but the
OS forces a two-process shape the Android build does not have.


### The app-extension split

Apple runs VPN packet handling in its own sandboxed NetworkExtension process
(every iOS/macOS VPN is split this way), so the SDK splits with it:

- The `NEPacketTunnelProvider` extension owns the `SdkDeviceLocal` and calls
  `setRpcServer(serverPem, clientCertPem, hostPort)` on it to start the
  listener.
- The app process creates an `SdkDeviceRemote` and attaches over a
  **loopback mTLS device-RPC** connection: the mirror-image
  `setRpcServer(clientPem, serverCertPem, hostPort)`, each side naming its
  own identity first and the peer's certificate second. Device-RPC is the
  SDK's own protocol: method calls plus subscription streams over Go's
  net/rpc, not gRPC. Mutual TLS on loopback is not ceremony. Localhost is
  reachable by every local process and user, and only the holder of the
  matching PEMs can attach.
- **The app mints the key material, not the extension.**
  `SdkGenerateDeviceRpcKeyMaterial()` returns a fresh self-signed server and
  client keypair per VPN session; the app puts both PEMs and the host:port
  into the `NETunnelProviderProtocol` **provider configuration**, which is
  how the extension gets them when the tunnel starts. No app-group container
  is involved.
- Watch `RemoteChangeListener`: the first time `remoteConnected` goes true
  that material is proven, so persist it as last-known-good and re-apply it
  at next launch. That is what lets the app attach to an already-running
  extension.

Rebuilds are routine: the extension rebuilds its device whenever the tunnel
restarts, because the user toggles the VPN, the OS restarts the extension,
or iOS kills it for exceeding the extension memory limit. Wire that
re-attach (mint, start the tunnel, `setRpcServer`, re-apply your own UI
state) on day one. Everything you can do on a `DeviceLocal` you can do on a
`DeviceRemote`; the remote proxies calls and replays subscriptions across
reconnects. Two tiers: a plain **reconnect** (same device, the RPC link
dropped and came back) re-establishes subscriptions for you; a **recreate**
(a new device instance underneath) means running that sequence again. One
trap: `DeviceRecreatedListener` fires on a change of *device generation*,
and only the platform-hosted RPC path stamps one, so on loopback drive
re-attach from your own tunnel lifecycle and `RemoteChangeListener`.

### The packet path

Apple does not use IoLoop: the tunnel provider moves packets through
`packetFlow`, the only interface NetworkExtension offers. Every iOS VPN pays
that crossing, and its batched reads amortize the cost.

## cgo (Windows, Linux)

The core as a c-shared library behind a generated C ABI, with a header-only
C++17 wrapper over it. The widest reach of the four and the least ergonomic:
handles and JSON rather than typed objects, which is exactly what lets any
language with an FFI bind to it.


### The daemon split

The shipping URnetwork Linux and Windows apps are built on this layer, and
both use the same two-process pattern, the shape to copy for any cgo
integration that touches a tun device. The SDK ships the engine and the C
API; the daemon is yours, with those two released apps as open-source
references:

- A **root/service process** (systemd unit, Windows service) runs the
  `DeviceLocal` and owns the tun interface.
- The **unprivileged UI process** runs a `DeviceRemote` and attaches over
  loopback mTLS device-RPC; the SDK's default address is `127.0.0.1:12025`.
- The mTLS PEMs are handed to the UI over a channel the OS itself
  authorizes: a **`SO_PEERCRED`-checked unix socket** on Linux (checked at
  accept, before any frame is read), a **named pipe** on Windows. That
  handshake, not the TCP port, is the real authorization boundary; loopback
  mTLS only keeps other local users off the port.

### RAII wrapper semantics (`urnetwork_sdk.hpp`)

- Handles are wrapped in owning types whose destructor calls
  `urnet_release`. But **release is not close/stop**: dropping the last
  wrapper does not stop a device or close a connection. Call `*_close` /
  `*_stop` (wrapper `.close()` / `.stop()`) first, then let the wrapper
  release. Several handles can refer to one live object, so a scope exit
  must never silently kill a live session.
- Subscriptions come back as `urnet::Sub`; the destructor unsubscribes.
- Errors surface as `urnet::Error` exceptions carrying the `out_error` text.

One hard rule, the opposite of what people expect:

```cpp
sub = device.addConnectChangeListener([&](bool enabled) { /* ... */ });
sub.close();   // returns immediately; a callback may still be running
// do NOT free what that lambda captured here
```

Listener lists are copy-on-write, so unsubscribe removes the entry and
returns. That is deliberate (a callback is *allowed* to remove itself from
inside itself), but it means a callback can still be running on another
thread after the `Sub` is gone. Destroying state that listener captured is
the real crash; keep it alive past teardown with a `shared_ptr` or a flag
the callback checks.

For leak discipline, snapshot `urnet_live_handle_count()` before a scenario,
run construct/use/close/release, and assert it returns to baseline.
`cgo/smoke` does exactly this and is the pattern to copy.

### What is actually bound

The C ABI is *generated* from the Go surface, and the generator writes
`cgo/coverage_report.txt`: every exported symbol, and for each one that did
not cross, the reason (Go contexts, `net.Conn` internals, function
parameters, pool-ownership calls, RPC gob types). Read that file, not the Go
source, to answer "is this callable from C?" A new Go export reaches the
header only when the generator runs, so the header ships beside the library
it was cut with. Pin them together.

### Wire compatibility

The device-RPC wire protocol is **version-pinned (`DeviceRpcVersion`,
currently 1) and enforced** by the local on every sync. It is deliberately
*not* the release version: the hosted halves deploy independently, so tying
them would reject every browser after a server deploy. A mismatch does not
throw. The remote stays unsynced and retries, which looks identical to "the
daemon isn't running". Distinguish them with `GetRemoteConnected()` plus
`GetSyncError()`: an empty sync error means not reachable yet, while
`"device rpc version mismatch: ..."` or `"device instance mismatch: ..."` is
a rejection reconnecting will never fix. Ship daemon and UI from the same
SDK release.

## JavaScript (wasm/web)

The core compiled to WebAssembly and run in the page, driven by whatever
browser framework you already use. The one binding that cannot move packets —
which changes what you build, not just how you write it.


### Why there is no DeviceLocal in wasm

A browser page cannot own a tun interface, so a wasm `DeviceLocal` would
have nothing to pump. The JS layer ships only the client-side halves: the
API surface, the view controllers, and `DeviceRemote`, a full device
*client* whose device lives elsewhere. The page holds the control plane
(connect state, locations, stats, account) while the traffic path is
consumed by whatever can use the hosted proxy. Hence the two factories:
`createProxyDevice` is the thin model, resolving hosted proxy URLs and
leaving traffic to whatever consumes them (an extension proxy config, a
fetch agent); `createPlatformDeviceRemote` is the thick model, a real
`DeviceRemote` with the full listener and view-controller surface over a
device the platform hosts for you.

### signedProxyId auth

`createPlatformDeviceRemote` opens a device-RPC websocket to
`wss://<proxy>/device-rpc`. That websocket does **not** authenticate with
the account JWT (`byJwt`, the bearer token from URnetwork login; the "by" is
a BringYour holdover). It authenticates with `signedProxyId`, the
`auth_token` the platform's `/network/auth-client` endpoint returns
alongside the proxy URL. That is deliberate scope separation: the signed
proxy id authorizes exactly one websocket and carries no account authority,
so the broad token never rides the data-plane socket. Treat (`proxyUrl`,
`signedProxyId`) as one credential. Request, pass and refresh them together,
and re-request the pair when the proxy rejects the socket rather than
caching a piece.

### Listener pattern and view controllers

Every subscription follows the same shape: `add*ChangeListener(fn)` returns
an **unsubscribe function**. Hold it and call it on teardown. In React,
return it:

```js
useEffect(() => {
  const unsub = device.addConnectChangeListener(setConnectEnabled);
return unsub; }, [device]);
```

That survives React strict mode's double-invoked effects (add, unsub, add),
leaving exactly one live subscription. The view-controller surface (connect,
locations, devices, contracts, block actions) is bound into the wasm too,
hanging off the device, as in `device.openConnectViewController()`, so a web
app reuses the same presentation logic the mobile apps do instead of
re-deriving state from raw listeners.

### How ur.io uses it

The `/app` surface on ur.io is the reference consumer: the wasm is
**lazy-loaded** only when the user reaches a connect surface, so landing
pages never pay the ~43 MB cost, and there is one `DeviceRemote` per tab.
Exports are global to the page, so `init` sits behind a singleton.

The browser extension packages the same wasm but does not instantiate it
today: it drives connectivity through the platform proxy plus the browser's
own proxy APIs, and consumes the npm package's **other** entry point,
`@urnetwork/sdk-js/react`, plain `fetch`-based API hooks and generated types
needing no wasm. If you only want the REST surface, import that and never
call `init`: the loader resolves the wasm URL at runtime rather than via a
static `new URL(..., import.meta.url)`, so bundlers don't emit it for
consumers who don't.

## Cross-cutting

### Provide modes, and why key material matters

`SetProvideMode` controls whether the device offers capacity to the network.
The default is off: a freshly constructed device has provide mode "none" and
offers nothing until you set a mode, so embedding the SDK never silently
shares your users' bandwidth. Two modes do something today: **public**, and
**network**, which limits providing to other devices in the same URnetwork
account. The protocol enum carries more values, but the platform resolves
every peer outside your network to public, so the real choice is off, my own
devices, or anyone.

Providing is where **key material persistence** matters: the
`DeviceLocalKeyMaterial` you pass at construction *is* the device's provider
identity, a client key seed plus the provide-TLS certificate and key.
Persist it in platform secure storage (Keystore, Keychain) and pass the same
material every launch, or the network sees a brand-new provider each time
and loses the reliability history that selection prefers. Ephemeral (`null`)
material is fine for pure clients.

If you surface providing, state the trade: strangers' traffic egresses from
the user's IP, and the provider position sees destination IPs and TLS SNI
(like an ISP), though by default not the originating user's real IP.
Provider safety is engineered in the engine. The open-source ip_security
layer inspects the provider's own egress and drops DMCA-class and CFAA-class
traffic before it leaves. The verdict is a dropped packet, with no
destination, domain or contents recorded anywhere. A BitTorrent signature
match also emits an abuse flag to the operator carrying only the peer's
device id and a boolean, and the operator ships no handler for it today;
opaque-encrypted drops are silent. Providers participate in the UR protocol;
[ur.xyz](https://ur.xyz) documents rewards.

### Runtime notes

- GC pacing is tuned per OS automatically: a pacing factor of 10 on iOS
  (extension memory limits are brutal) against 50 on Android and 100
  elsewhere. You do not set it.
- `memoryTargetByteCount` is a different knob: a byte budget the device
  *splits* to size its own buffers, dns 2 : client 14 : provider 4, with the
  provider share backing the client pair while providing is off (default
  20 MB). Neither a cap nor a GC setting: the process-wide soft footprint
  limit is the separate `SetMemoryLimit`, and the hard kill is the OS's
  extension limit. Set the target under it; keep the extension nearly
  logic-free.
- The gomobile bind is gated in the build, and the mechanism matters: gobind
  silently omits anything it cannot bind, leaving only a `// skipped`
  comment in the generated sources, so the build greps those sources and
  **fails on any omission not on an explicit allowlist**. The allowed skips
  are internal (RPC gob payloads, the proxy/platform surface,
  `uint64`/`[][]byte` shapes gomobile can't express): deliberate, not drift.

### Settings that carry policy

These flags hold product decisions, not tuning, and their defaults are the
privacy posture your users get. What a stock device ships:

| Setting | Default | Effect | Main cost |
|---|---|---|---|
| `SetPerformanceProfile` | nil (auto) | quality and speed windows run side by side; traffic exits through several providers at once (commonly 3–8), with per-site affinity | pinning a `WindowType` narrows to one window |
| `AllowDirect` | off | keeps the anonymization hop, so no provider sees the user's real IP | on: more throughput, and the provider sees the user's real IP |
| `PostQuantumEncryption` | on | seals the client↔provider session, so the operator relays bytes it cannot read | a provider it cannot seal to is skipped, not used unsealed |
| `SetRouteLocal` | allow | traffic falls back to the local route when the tunnel drops | disallow is the kill switch: traffic stops instead |
| `SetProvideMode` | none | the device offers no capacity to the network | public or network providing egresses others' traffic from the user's IP |

Caveats that go with the table:

- `AllowDirect` is the speed setting, and the IP it exposes is exactly the
  hop it removes. The apps surface it inverted as "Strong Anonymization", on
  by default, and it is forced off on hosted device profiles whatever a
  caller sets. Never present it to your users as free speed; present it as
  the trade it is.
- `PostQuantumEncryption` is the end-to-end client↔provider session the apps
  ship as "Post Quantum Encryption", on by default at launch and
  provider-ready: every current provider build enables the responder side.
  While it is on the client is fail-closed — it will not carry application
  data in the clear, and a provider it cannot seal to is skipped rather than
  used unsealed. The cost is availability, not confidentiality. Turn the
  flag off and traffic can take the standard path again.
- `SetRouteLocal` is the kill-switch primitive ("allow local traffic").
  Turn it off, and give users the toggle every URnetwork app has, so traffic
  stops rather than falling back when the tunnel drops.

Provider blindness to your user's identity is unconditional; operator
blindness to content is too, by default. Two properties, both true of a
stock profile, which is what makes it accurate to say no single party holds
both your user's identity and their activity. Flip either flag and you trade
the matching property away, not earn it.

The three modes those two flags select, stated for the users you route:

| Mode | Operator sees | Provider sees | How you get it |
|---|---|---|---|
| **Relayed sealed** | account/source connection, provider association, ciphertext and timing/volume | destination traffic and a device/contract id, **not** the user's real IP | the default: `PostQuantumEncryption` on, `AllowDirect` off |
| **Relayed standard** | account/source connection, provider association, inner destinations and packet bytes | destination traffic and a device/contract id, **not** the user's real IP | opt-out: `PostQuantumEncryption` off |
| **Direct** | less relay involvement | **the user's real IP** and destination traffic | opt-in: `AllowDirect` on (forced off on hosted profiles) |

The default already ships the first row with nothing to set. The second
requires turning `PostQuantumEncryption` off: while it is on the client
skips a provider it cannot seal to rather than dropping to that row. No
per-connection API reports which row a given session ended up on. The
[threat model](/docs/threat-model) works these rows against named
adversaries, and is explicit about where each one fails.

### Threading rules

- Non-view objects (`DeviceLocal`, `DeviceRemote`, `Api`, network spaces)
  are concurrent-safe. Call them from any thread.
- **View controllers are single-threaded** unless one documents otherwise.
  Drive each from one thread (normally your UI thread). Browser JS satisfies
  this for free; it bites on Android, Apple, and cgo, where callbacks arrive
  on Go-managed threads.
- Callbacks fire on arbitrary SDK-managed threads. Marshal to your UI thread
  before touching UI, and never block in one: a `DeviceRemote` serializes
  its callbacks through a single buffered channel, so a slow listener
  back-pressures every subscription on it.

### Artifact sizes

Plan download and packaging budgets around these:

| Artifact | Size (compressed where noted) |
| --- | --- |
| Android AAR | ~37 MB |
| Apple xcframework (zip) | ~116 MB |
| JS wasm | ~43.5 MB |
| Linux c-shared (zip) | ~25 MB |
| Windows c-shared (zip) | ~21 MB |

### API stability

Versions are date-based (`vYYYY.M.D-<code>`); there is no SemVer contract
yet, and the npm package is beta and republished nightly. Treat all four
surfaces as moving until a 1.0: pin exact versions, read release notes on
every bump, and keep paired artifacts (daemon and UI, extension and app) on
one release.
