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 |
Install
Section titled “Install”.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 MoqomKitimport MoqomCoreThe SDK never prompts for camera or microphone permission on its own. Request access in your UI where you can explain why, before starting capture.
The object model
Section titled “The object model”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.
Device identity
Section titled “Device identity”Every device has a key, generated on the device and never exported:
let deviceKey = try DeviceKey.installed() // Secure Enclave where available, Keychain otherwisedeviceKey.thumbprint // send this to your backend when asking for a tokenYour 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.
Connect
Section titled “Connect”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 and renewal
Section titled “Tokens and renewal”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)Session state
Section titled “Session state”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.
Join a room
Section titled “Join a room”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 channellet theirs = try await session.watch(try Username("bea")) // watch someone else'sPublish
Section titled “Publish”Publishing is always an explicit act by the participant. A grant (permission to publish video) never starts a camera.
// Camera → videolet 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 videolet microphone = try MicrophoneSource()try microphone.start()let audio = try await room.startAudio(from: microphone)await audio.start()
// Laterawait 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.
Muting yourself
Section titled “Muting yourself”await room.muteSelf(.audio)await room.unmuteSelf(.audio)room.selfMuted // what you have chosen not to sendSelf-mute is reported to the room so other clients can show it.
Subscribe and watch
Section titled “Subscribe and watch”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.
Raw media
Section titled “Raw media”For on-device transcription, captioning or classification, the SDK hands out decoded media:
let beaVideo = await room.tapVideo(from: bea, policy: .classification) // keyframes onlylet 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 state
Section titled “Room state”room.currentState is a snapshot; room.events is the stream of changes.
let state = await room.currentStatestate.roster // participants on stage and in the roomstate.backstage // participants waiting backstagestate.broadcasters // who is publishingstate.stage // spotlight, stage lock, invitationsstate.lifecycle // live, ended, …state.isRecording // a recording watchdog is attachedstate.participantCountEach 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 }}Moderation callbacks
Section titled “Moderation callbacks”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.forceMutedreflects it. When it is lifted,room.resumableAfterForceMutelists what you may turn back on, androom.resumeAfterForceMute()does so — the SDK never re-enables your camera or microphone without you. - Grants changed —
.grantsChangedfor 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.
Moderating from the client
Section titled “Moderating from the client”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.
Stage invitations
Section titled “Stage invitations”// 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.
Statistics
Section titled “Statistics”let snapshot = await room.statistics // one consistent snapshotfor 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.
Logging
Section titled “Logging”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.
Sample app
Section titled “Sample app”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).