livqenoDocs

SDKsiOS & macOS

iOS & macOS SDK

LivqenoRTC — native Swift calls for iOS 15+ and macOS 12+, with CallKit and screen-share helpers.

The iOS SDK is for native Swift apps on iPhone, iPad and Mac. It adds video and audio calls. It uses the same words as the web SDK — Room, participants, tracks, and events like participantJoined and trackSubscribed — written the Swift way, with async/await and AsyncStream.

It runs on the user's device and holds only a token, never your API key. Your backend makes the token with the TypeScript, Python, Go, Java or PHP SDK. Your app fetches it from your own endpoint and passes on three fields: token, endpoint and iceServers.

Why a backend, and how to write it: the API key can't live in an app, so one small route on your server makes the token. Build your token endpoint has the full route in TypeScript, Python, Go, Java, PHP and Dart, plus the plain HTTP call for any other language. You don't need Node.js unless your backend already uses it.

The shortest version, in your app:

import LivqenoRTC
 
var options = RoomOptions()
options.iceServers = grant.iceServers   // always pass these: they include TURN
 
let room = Room(options: options)
try await room.connect(token: grant.token, endpoint: grant.endpoint)
try await room.enableMicrophone()
try await room.enableCamera()

Without iceServers there's no relay (TURN), and calls on mobile data often fail.

Install

Add the package with Swift Package Manager. It lives in the Livqeno repository at sdks/ios:

  • Xcode: File → Add Package Dependencies… → Add Local… → choose sdks/ios, then add the LivqenoRTC library to your app target.
  • Package.swift: .package(path: "path/to/sdks/ios"), then depend on the LivqenoRTC product.

Add permissions

Apple won't let an app use the camera or mic unless the app says why, up front. Add these to your app's Info.plist:

<key>NSCameraUsageDescription</key>
<string>Camera access is used for video calls.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is used for calls.</string>
<key>UIBackgroundModes</key>
<array>
  <string>audio</string>   <!-- keep call audio alive in the background -->
  <string>voip</string>    <!-- only if you use CallKit with VoIP pushes -->
</array>

On macOS, turn on Camera, Audio Input and Outgoing Connections (Client) under App Sandbox in your target's Signing & Capabilities.

Listen to events

room.events() gives you a stream of events. Start listening before connect, so you also see the people already in the room:

let room = Room(options: options)
 
Task {
    for await event in room.events() {
        switch event {
        case let .participantJoined(participant):
            addTile(for: participant)
        case let .trackSubscribed(track, participant):
            attach(track, to: participant)          // show video here
        case let .trackMuted(kind, participant):
            showMutedIcon(kind, participant)
        case let .activeSpeakersChanged(speakers):
            highlight(speakers.first)
        case let .disconnected(reason):
            showCallEnded(reason)
        default:
            break
        }
    }
}
 
try await room.connect(token: grant.token, endpoint: grant.endpoint)

Prefer a delegate? Set room.delegate and implement room(_:didEmit:). The event names and what they carry match RTC Events exactly; there are two extra disconnect reasons, .tokenRevoked and .replaced (the same person joined from another device).

Show video

let view = LivqenoVideoView()   // UIView on iOS, NSView on macOS
view.track = track

LivqenoVideoView also tells the SDK when the view is on screen and how big it is. Videos you can't see are paused after half a second, and small tiles get the low-quality layer, which saves the user's data and battery. You can also control this yourself:

track.setEnabled(false)   // pause this video; the server stops sending it
track.setEnabled(true)    // resume it straight away
track.setLayer(.low)      // pin a quality: .low, .medium, .high or .auto

Check track.subscriptionState when trackSubscriptionStateChanged fires: .pausedBandwidth means the user's connection can't carry this video right now, so show a "paused" overlay instead of a frozen frame.

Camera, microphone, screen share

try await room.enableCamera()                                  // front camera
try await room.enableCamera(options: .init(position: .back))
try await room.enableMicrophone()
try await room.enableScreenShare()                             // this app only
 
await track.mute()        // stop sending, keep the tile
await track.unmute()
 
try await room.enableCamera(false)                             // stop publishing

The camera is sent in three quality layers, so people with weak networks still see you. Layers nobody is watching are switched off automatically.

Share the whole screen

enableScreenShare() shares only your app, and only while it's open. To share the whole screen, iOS needs a Broadcast Upload Extension: a small extra target in your Xcode project. The SDK ships a ready-made one in sdks/ios/Templates/BroadcastUploadExtension, with step-by-step setup. It joins the call as a second participant (for example alice#screen) that only sends the screen.

Phone-style calls with CallKit

CallKit gives your app the system call screen, lock-screen controls, and correct behaviour when a normal phone call comes in. CallKitManager wires it to a room:

let callKit = CallKitManager(configuration: .init(localizedName: "Acme"))
callKit.onAnswer = { _ in try await room.connect(token: grant.token, endpoint: grant.endpoint) }
callKit.onEnd = { _ in await room.leave() }
callKit.onMute = { _, muted in
    await room.localParticipant.tracks.first { $0.source == .microphone }?.setMuted(muted)
}
 
try await callKit.reportIncomingCall(uuid: callId, handle: "Alice", hasVideo: true)

Livqeno doesn't ring phones. Your backend sends a VoIP push, and your app reports the call to CallKit when it arrives.

Not using CallKit? Set up audio yourself before the call:

try AudioSessionConfigurator.configureForCall(mode: .videoChat)   // speaker on
try AudioSessionConfigurator.setSpeakerOutput(false)              // earpiece

Reconnecting and long calls

You don't need to do anything for a dropped connection. The SDK reconnects by itself, and usually the call continues without the video ever stopping. You'll see reconnecting, then reconnected.

Tokens are short-lived. To keep long calls going, give the room a way to get a new one:

options.refreshToken = { try await myBackend.fetchRtcToken() }

The SDK calls it before the token runs out and swaps it in without reconnecting. If you leave it out, you'll get a TOKEN_EXPIRING warning, and a server that enforces expiry will end the call.

When the device's network changes (Wi-Fi to cellular), call room.handleNetworkChange() from your NWPathMonitor handler so the SDK recovers at once instead of waiting.

The SDK never rejoins after .removed or .roomClosed. Those mean your backend removed this person or closed the room.

Moderation

Your backend can change what someone may do during a call. Read room.permissions to decide which buttons to show, and listen for permissionsChanged. If a right was taken away, the SDK has already stopped those tracks. If one was granted (a live-stream viewer brought on stage), enableCamera() works straight away.

If your backend mutes someone, they get localTrackMutedByServer and their track is already muted. They can unmute it themselves.

End-to-end encryption

For rooms created with e2ee: true. The media is encrypted on the device and only decrypted by the other participants; the server never has the key.

var options = RoomOptions()
options.e2eeKeyProvider = E2EEKeyProvider()
let room = Room(options: options)
try await room.connect(token: grant.token, endpoint: grant.endpoint)
try room.setE2EEPassphrase(sharedSecret)   // after connect: the room id salts the key
try await room.enableMicrophone()

Everyone in the room must use the same passphrase. iOS, web, Android and Flutter users can be in the same encrypted call: the keys and the frame format match byte for byte. room.isE2EEActive tells you media is being encrypted, and problems arrive as e2eeStateChanged (.decryptFailed usually means a wrong passphrase, .missingKey that no key is set yet).

If your app hands out keys itself, create the provider with E2EEKeyProvider(sharedKeyMode: false) and call room.setKey(participantId:key:keyIndex:) with 32-byte keys (your own identity's key is the one you send with). room.ratchetKey(participantId:) moves a key forward.

The SDK never sends unencrypted media into an encrypted room. Without a key provider, publishing throws RTCError(.e2eeRequired). In an ordinary room a configured key provider does nothing.

Testing your integration

The SDK's own tests run on a Mac with sdks/ios/scripts/test-macos.sh. To test calls, the camera, CallKit or screen sharing in your app, you need Xcode and a real iPhone or iPad. The simulator has no camera and can't capture the screen.

Next steps