Skip to main content

iOS

This guide takes you from nothing to an iOS app that sends an HTTP request over SCION. You do not need access to a real SCION network, and you do not need to know SCION yet. Everything runs against a test network on your own Mac.

The SDK reaches iOS as a Swift package with a prebuilt binary XCFramework. The same package runs on macOS 12 and later.

At the end you have an app with one button. The button sends GET /hello over HTTP/3 and shows the response.

Prerequisites

  • Xcode 16 or newer, with an iOS simulator runtime. The library runs on iOS 15 or later.
  • Rust, for the test network only. The SDK pins its toolchain in rust-toolchain.toml. If you have rustup, it gets the correct version for you.
  • git, to clone the repository.

Your app does not need the Rust side. It depends on the package and on nothing else.

Start a local SCION network

scion-h3-test-server is a whole SCION network in one process, with an HTTP/3 server inside it. Clone the SDK and start it:

git clone https://github.com/Anapaya/scion-sdk.git
cd scion-sdk
cargo run -p scion-h3-test-server -- --control-port 7443

The simulator shares the network of your Mac, so the test network needs no options for it. An address on the loopback interface of the Mac is the same address inside the simulator.

The server prints one line of JSON that describes everything a client needs. Then it runs until its standard input closes:

{"endhost_api_url":"http://127.0.0.1:35395/","auth_token":"eyJ0eXAiOiJKV1Qi...",
"base_url":"https://localhost:41013","target":"2-ff00:0:212,127.0.0.1",
"ca_pem":"-----BEGIN CERTIFICATE-----\n...","control_url":"http://127.0.0.1:7443",
"underlay":"udp"}

An app has to know five of these fields:

  • endhost_api_url is where a client discovers its SCION connectivity.
  • auth_token authenticates the client there.
  • base_url is where the HTTP/3 server is.
  • target is the SCION address of that server.
  • ca_pem is the authority that signed the certificate of the server.

Add the SDK to your app

Every release carries the Swift package as scion-http3-swift-<version>.zip. Its Package.swift names the XCFramework on the same release by URL and checksum, and SwiftPM downloads it. Download the package zip and unpack it. In Xcode, choose File > Add Package Dependencies > Add Local... and select the unpacked package. In a Package.swift, the same dependency is:

dependencies: [
.package(path: "libs/scion-http3-swift"),
],
targets: [
.target(name: "App", dependencies: [.product(name: "ScionHTTP3", package: "scion-http3-swift")]),
]

ScionHTTP3 is the one module you import. The XCFramework carries slices for iOS devices, the iOS simulator and macOS, so the same package serves all three.

The client opens its own UDP sockets. App Transport Security does not govern these sockets, so the SCION request needs no exception and no entitlement. Add NSAllowsLocalNetworking under NSAppTransportSecurity while you work against the test network. The control API of the test network is plain HTTP.

The library README covers the other ways to depend on the library.

Build a client

Build one client and keep it. A client owns the connections it establishes. An app builds the client once and shuts it down when the app is done:

HelloScion.swift
init(network: LocalNetwork) throws {
self.network = network
var configuration = ScionHttp3Client.Configuration(
// Where the client discovers its SCION connectivity.
endhostApi: network.endhostApiUrl,
authToken: network.authToken)
configuration.trust = try .pinned(Data(network.caPem.utf8))
configuration.connectTimeout = 30
configuration.requestTimeout = 60
client = try ScionHttp3Client(configuration: configuration)
}

endhostApi is the setting that says which SCION network this app is on. The initializer does no I/O. The first request is what brings connectivity up. The initializer throws ScionHttp3Error.invalidConfiguration for a setting that cannot be correct, for example a timeout that is not positive.

The sample reads the five values from the control API of the test network, in LocalNetwork.swift, because the network selects them when it starts. Your own app has them in its configuration.

Send your first request

A request is a value. Build it, execute it, and read the response:

HelloScion.swift
/// `GET /hello`, which the test server answers with `world`.
func hello() async throws -> Reply {
var request = ScionHttp3Request(url: network.baseUrl + "/hello")
// The test network publishes no records for its server, so address it directly.
request.target = try ScionAddress(network.target)

let response = try await client.execute(request)
return Reply(code: response.code, body: try await response.body.string())
}

target is for a server that has no SCION address records. It sets the address the client sends the request to, and nothing else. The port and the name that the certificate must match still come from the URL. To offer several addresses, set targets instead. The client then races them.

Run the app and press the button. You see:

200

world

That is a full SCION round trip: an HTTP/3 request from the simulator, across a SCION network, to a server in a different SCION AS.

Timeouts

The client above sets two limits, in seconds. connectTimeout limits the time to establish connectivity to an origin. On a first request, this includes the discovery of the origin. requestTimeout limits a whole request, from the send to the receipt of the body.

To change the second limit for one request, set requestTimeout on the request. This is for a call that you know is slower than the rest. A passed limit arrives as .timeout. This error carries the phase it was in and the value it passed, so an app can report which limit stopped the request.

Certificates

The client verifies the certificate of the server. trust decides which authorities it verifies against.

TrustAnchors.systemDefault is the default. It is what a server with a publicly trusted certificate needs. It checks the certificate the way URLSession does, against the authorities of the system and with the policy of the system. This includes authorities that the user or a device profile installed and trusted.

TrustAnchors.pinned(pem) is for an internal authority. The sample above uses it. It throws invalidConfiguration if the bundle holds no certificate. The sample reads the PEM at run time only because the test network generates a new authority on every run. An app ships its own PEM, usually as a bundle resource.

There is also TrustAnchors.insecureNoVerify. It logs an error every time a client is built with it, and it is only for tests. A real app must never use it!

Handling errors

Every failure the client reports is a ScionHttp3Error. It is an enum, so a switch covers it:

ContentView.swift
/// Every failure the client reports is a `ScionHttp3Error`. A non-2xx status arrives as a
/// response instead, carrying a code and a body.
private func describe(_ error: ScionHttp3Error) -> String {
let cause: String
switch error {
case .connectivity:
cause = "No usable network."
case .connect(let host, let port, _, _):
cause = "Could not reach \(host):\(port). Is the test server still running?"
case .tls(let host, _, _):
cause = "The certificate \(host) presented was rejected."
case .timeout(let phase, let after, _, _):
cause = "Gave up in the \(phase) phase after \(after) s."
default:
cause = error.localizedDescription
}
let advice = "Sending it again can succeed. The client does not retry on its own."
return error.isRetryable ? "\(cause)\n\n\(advice)" : cause
}

Know three things before you write that switch:

  • A non-2xx status is a response, not an error. It arrives with a code and a body. Errors are for requests that produced no response.
  • isRetryable says whether a retry can help. The client does not retry for you, because only your app knows whether the request is safe to send twice.
  • A cancelled request throws CancellationError, as every cancelled task does. It never throws one of these cases.

Some cases carry what an app needs to act. .connect has the host and the port the client could not reach. .timeout has the phase and the limit it passed. .bodyTooLarge has the limit, which is the one you set with maxResponseBodyBytes. Every case has detail, the underlying failure for a log.

One case is not obvious. A certificate that the pinned anchors reject arrives as .connect, because the handshake fails before the client learns the cause. A certificate that the system rejects arrives as .tls.

Cancelling a request

Requests are ordinary async functions, so cancellation needs nothing from the library. Cancel the Task that awaits the request. The library then cancels the request, down to the HTTP/3 stream. This test from the SDK shows it against the test network:

FacadeCancellationTests.swift
let request = try facadeRequest(server, "/slow?ms=600000")
let client = self.client!
let task = Task { try await client.execute(request) }

try await server.waitUntil("the request to reach the server") {
($0.started["/slow"] ?? 0) > 0
}
task.cancel()
assertCancelled(await task.result)

try await assertHelloWorks()

The helpers around it belong to the test. facadeRequest builds a request like the one above. assertCancelled checks that the call threw CancellationError. assertHelloWorks sends a request over the same connection afterwards. That last line is what matters: the connection stays usable, so a cancelled request does not slow down the next one.

When the network changes

You do not have to handle a network change. When the device moves between Wi-Fi and cellular, the library notices the change, rebuilds connectivity, and authenticates again. A request that is in flight fails, and isRetryable is true for it. If the app was suspended during the change, the first request after a long idle gap checks the network itself.

One case needs your help. A VPN that comes up or goes down changes which addresses work, but it does not change the network the library sees. Call reset() when your app knows that this happened.

If your SNAP token expires, renew it in place with setAuthToken(refreshed). Do not build a new client. The next request uses the new token, and the established connections survive.

What this version does not do

  • No URLSession or Alamofire drop-in. URLSession has no pluggable transport. Its one hook, URLProtocol, hides the transport behind an interface for HTTP/1, so an adapter on it cannot expose what HTTP/3 over SCION offers. Such an adapter is a possible later module. Alamofire sits on URLSession, so the same applies.
  • No background transfers. A background URLSession hands its transfers to a system process that runs while the app does not, and that process speaks only the transports of the system. SCION is not one of them, so a transfer here runs while the app runs.

The client's lifetime

HelloScion.swift
/// Releases the client's connections.
///
/// Waits until each connection has told its peer it is going away. A client that is dropped
/// without this still closes its connections in the background and whenever ARC gets to it.
func shutdown() async {
await client.shutdown()
}

shutdown() waits until each connection has told its peer that it closes. A request issued after it fails with .closed. It is idempotent. A client that is dropped without shutdown() still closes its connections, but in the background and whenever ARC releases the client. Do not rely on that where the timing matters.

The sample never calls shutdown(), because its client lives as long as the app. Call it when your app is done with a client before then, for example on sign-out.

Where to go next

Full sample

The complete class, for reference. It lives in the SDK repo at bindings/apple/hello-scion/HelloScion/HelloScion.swift.

HelloScion.swift
import Foundation
import ScionHTTP3

/// One HTTP/3 request over SCION.
final class HelloScion: Sendable {
struct Reply {
let code: Int
let body: String
}

private let network: LocalNetwork
private let client: ScionHttp3Client

init(network: LocalNetwork) throws {
self.network = network
var configuration = ScionHttp3Client.Configuration(
// Where the client discovers its SCION connectivity.
endhostApi: network.endhostApiUrl,
authToken: network.authToken)
configuration.trust = try .pinned(Data(network.caPem.utf8))
configuration.connectTimeout = 30
configuration.requestTimeout = 60
client = try ScionHttp3Client(configuration: configuration)
}

/// `GET /hello`, which the test server answers with `world`.
func hello() async throws -> Reply {
var request = ScionHttp3Request(url: network.baseUrl + "/hello")
// The test network publishes no records for its server, so address it directly.
request.target = try ScionAddress(network.target)

let response = try await client.execute(request)
return Reply(code: response.code, body: try await response.body.string())
}

/// Releases the client's connections.
///
/// Waits until each connection has told its peer it is going away. A client that is dropped
/// without this still closes its connections in the background and whenever ARC gets to it.
func shutdown() async {
await client.shutdown()
}
}