Skip to content

iOS & macOS (Swift)

MoqomKit is the Apple-platform client SDK. It is available now for iOS, iPadOS, macOS, visionOS and tvOS, and ships as a Swift package with a binary core.

Platforms iOS, iPadOS, macOS, visionOS, tvOS 26 and later
Language Swift 6, complete strict concurrency
Transport System QUIC (Network.framework)
Media VideoToolbox (H.264 / HEVC), Opus audio
Licence Apache-2.0 for the package source; binary core under its own licence
Package.swift
.package(url: "https://github.com/moqom-cloud/moqom-swift", from: "0.1.0")
// target
.product(name: "MoqomKit", package: "moqom-swift")

MoqomKit re-uses types from MoqomCore (identity, names, frame formats), so most files import both:

import MoqomKit
import MoqomCore

The SDK never prompts for camera or microphone permission on its own. Request access in your UI where you can explain why, before starting capture.

classDiagram
    class MoqomSession {
      identity
      tenant
      state
      states : AsyncStream
      connect(to:)
      join(room:) RoomSession
      goLive() RoomSession
      watch(username) RoomSession
    }
    class RoomSession {
      currentState : RoomState
      roster : Roster
      watchdogs
      events : AsyncStream~RoomEvent~
      startBroadcast(from:)
      startAudio(from:)
      muteSelf / unmuteSelf
      localMute / localUnmute
      moderate(action, as:)
      statistics
    }
    MoqomSession "1" --> "*" RoomSession : one connection, many rooms
  • MoqomSession — one connection from this device to a relay. Rooms and personal channels hang off it; moving between rooms never opens a new connection.
  • RoomSession — a joined room: roster, state, events, publishing, moderation.

Every device has a key, generated on the device and never exported:

let deviceKey = try DeviceKey.installed() // Secure Enclave where available, Keychain otherwise
deviceKey.thumbprint // send this to your backend when asking for a token

Your backend passes the thumbprint to MintClientToken, which binds the token to this device. Use the same thumbprint for the participant’s DeviceID:

let identity = ParticipantIdentity(
username: try Username("alice"),
device: try DeviceID(thumbprint: deviceKey.thumbprint)
)

DeviceKey.ephemeral() creates a throwaway key for tests and previews.

let session = MoqomSession(
transport: MoqtTransport.network(), // system QUIC
credentials: DeviceCredentials(
identity: identity,
token: issued.token, // from your backend
deviceKey: deviceKey,
expiresAt: issued.expiresAt,
renew: { try await myBackend.freshToken() } // returns IssuedToken
),
tenant: TenantID("t_3f9a0c1d2e4b5a69")
)
try await session.connect(to: RelayEndpoint(host: "relay.example.moqom.cloud"))

Tokens live at most 15 minutes. Provide expiresAt and a renew closure and the SDK fetches a replacement before expiry and keeps the session running — no reconnect, no interruption. If you pass no renew closure, the session ends with .tokenExpired when the token runs out.

IssuedToken carries the token, its expiry, and the username actually minted (usernames are normalised server-side; display this one, not the one you asked for).

If you do not have a backend yet, TokenService can fetch tokens from the Join HTTP endpoint instead:

let service = TokenService(
endpoint: RelayEndpoint(host: "join.example.moqom.cloud"),
credential: deviceCredentialFromKeychain
)
let issued = try await service.token(username: "alice", room: "demo", device: deviceKey)
for await state in session.states {
switch state {
case .idle, .connecting: break
case .established: showConnected()
case .reconnecting: showBanner("Reconnecting…")
case .away: showBanner("Connection lost")
case .closed(let reason): handleEnd(reason)
}
}

.closed carries why the session ended:

TerminalEvent Meaning
.moderated(code:) Removed by a moderator. code is a moderation code you can show to the user.
.hostEnded The host ended the room or broadcast.
.networkFailure The network went away and did not come back within the reconnect window.
.tokenExpired The token lapsed and could not be renewed.

Reconnection is automatic. While a session is reconnecting, other participants see your presence as reconnecting rather than left, and on return your subscriptions, mutes, grants and stage position are restored.

let room = try await session.join(room: RoomID("demo"))

Personal channels — one user broadcasting under their own name, without a room record:

let mine = try await session.goLive() // publish on my channel
let theirs = try await session.watch(try Username("bea")) // watch someone else's

Publishing is always an explicit act by the participant. A grant (permission to publish video) never starts a camera.

// Camera → video
let camera = try CameraSource(
format: CaptureFormat(size: PixelSize(width: 1280, height: 720), frameRate: 30),
position: .front
)
camera.start()
let broadcast = try await room.startBroadcast(from: camera)
await broadcast.start()
// Microphone → audio, independently of video
let microphone = try MicrophoneSource()
try microphone.start()
let audio = try await room.startAudio(from: microphone)
await audio.start()
// Later
await room.stopBroadcast()
await room.stopAudio()

Pass backstage: true to either call to publish backstage — visible to hosts, not to the audience — and go live later without a new token.

Any FrameSource can be published: the camera, a RealityKit / Metal scene (SceneCapture), or your own frames.

await room.muteSelf(.audio)
await room.unmuteSelf(.audio)
room.selfMuted // what you have chosen not to send

Self-mute is reported to the room so other clients can show it.

Joining a room subscribes you to it: everyone on stage appears in the roster, including people who arrive later. Tell the SDK what you are drawing and how big, and it subscribes at a suitable quality for each tile:

await room.updateLayout([
bea: RenderBox(width: 390, height: 220, scale: 3),
carol: RenderBox(width: 120, height: 68, scale: 3),
])

Off-screen participants keep their audio. Rendering is provided by the SDK’s binary core; for grids, MoqomGalleryView lays out tiles and follows the active speaker.

To stop hearing or seeing one person just for yourself:

await room.localMute(bea, .audio)
await room.localUnmute(bea, .audio)

A local mute costs nothing: the media simply stops being sent to you.

For on-device transcription, captioning or classification, the SDK hands out decoded media:

let beaVideo = await room.tapVideo(from: bea, policy: .classification) // keyframes only
let beaAudio = await room.tapAudio(from: bea, policy: .transcription)
let myMic = await room.tapCapturedAudio(policy: .transcription)

In encrypted rooms taps receive plaintext, because they run on the device that holds the key. If you send tapped media anywhere, disclose it — attaching a watchdog is the mechanism for that.

room.currentState is a snapshot; room.events is the stream of changes.

let state = await room.currentState
state.roster // participants on stage and in the room
state.backstage // participants waiting backstage
state.broadcasters // who is publishing
state.stage // spotlight, stage lock, invitations
state.lifecycle // live, ended, …
state.isRecording // a recording watchdog is attached
state.participantCount

Each Participant carries username, device, grants, role (display label), presence, mute (self, forced-for-everyone, forced-for-audience) and isSpotlighted.

for await event in room.events {
switch event {
case .participantJoined(let p): add(p)
case .participantLeft(let who, let why): remove(who, why) // .left, .evicted, .timedOut, .connectionLost
case .presenceChanged(let who, let presence): update(who, presence)
case .muteChanged(let who, let mute): update(who, mute)
case .grantsChanged(let who, let grants): update(who, grants)
case .spotlightChanged(let who): spotlight(who)
case .stageChanged(let stage): render(stage)
case .watchdogChanged(let watchdogs): showRecordingBadge(watchdogs.isRecording)
case .lifecycleChanged(let lifecycle): handle(lifecycle)
case .captureRestricted(let held, _): disable(held) // a moderator force-muted you
default: break
}
}

When a moderator acts on you, the room tells you, and the SDK enforces it:

  • Force-muted — .captureRestricted(held:resumable:) names the media you may not send. room.forceMuted reflects it. When it is lifted, room.resumableAfterForceMute lists what you may turn back on, and room.resumeAfterForceMute() does so — the SDK never re-enables your camera or microphone without you.
  • Grants changed — .grantsChanged for your own identity. A demotion takes effect immediately; a promotion makes new actions available.
  • Kicked, banned, or the room closed — the session ends with .closed(.moderated(code:)) or .closed(.hostEnded). Show the user something true; the code says why.

Participants who hold capabilities can moderate from the app:

try await room.moderate(.forceMute(bea, .audio, until: nil), as: myGrants)
try await room.moderate(.kick(bea), as: myGrants)
try await room.moderate(.banFromRoom(bea, until: Date().addingTimeInterval(86_400)), as: myGrants)
try await room.moderate(.spotlight(bea), as: myGrants)
try await room.moderate(.lockStage(true), as: myGrants)

The SDK checks the capability and the strict-superset rule locally so you can disable buttons without a round trip; the relay and control plane enforce it regardless. For anything audited or automated, prefer moderating from your backend.

// Host invites a viewer up, offering camera rights for 30 seconds.
let invite = try await room.invite(try Username("dan"), offered: .camera, expiresAfter: .seconds(30))
// A viewer asks to come up.
let request = try await room.requestStage(offered: .camera)
// Either side resolves it.
try await room.accept(invite.id)
try await room.decline(invite.id)
try await room.cancel(request.id)

Accepting grants publish rights and starts nothing — the guest still chooses to go live.

let snapshot = await room.statistics // one consistent snapshot
for await s in await room.statistics(every: .seconds(1)) { render(s) }

RoomStatistics reports the link, your uplink, and per-participant receive quality, read at one instant so the numbers agree with each other.

The SDK is silent by default. Pass a MoqomLogger to MoqomSession to route its diagnostics into your own logging. Identities are hashed in logs and payloads are only ever written as byte counts.

Samples/MoqomRoom in the SDK repository is a complete SwiftUI room app: join screen, gallery, stage, mutes, moderation and statistics. Configure it with your own relay endpoint and token source in Local.xcconfig (copy Local.xcconfig.example).