iOS SDK
A Swift package for iOS 14 and newer. QEEmbedController owns a WKWebView with the trading module in embed mode and speaks the bridge; QEEmbedView (SwiftUI) and QEEmbedViewController (UIKit) show it. No third-party dependencies.
#Install
Add the package
Octolit provides the
QEEmbedSwift package on request. Xcode > File > Add Package Dependencies > Add Local… and pick the package folder, or copySources/QEEmbedinto your project.Info.plist and capabilities
Info.plist<!-- Info.plist --> <key>NSCameraUsageDescription</key> <string>Take a photo of your ID for verification</string> <key>NSPhotoLibraryUsageDescription</key> <string>Choose a photo of your ID for verification</string> <key>NSFaceIDUsageDescription</key> <string>Unlock your trading account</string> <!-- Capabilities: Push Notifications; Associated Domains: applinks:app.your-brand.com -->The two photo strings are needed for KYC uploads, the Face ID string for the lock screen. Push Notifications for trade / margin pushes, Associated Domains for universal links into the module.
#Configure
import QEEmbed
var config = QEEmbedConfig(baseURL: URL(string: "https://live.your-brand.com")!)
config.screen = "terminal"
config.language = Locale.current.languageCode
config.theme = QEEmbedTheme(mode: .dark, primary: "#ff7a1a", background: "#101418", paper: "#171c22",
radius: 14, fontFamily: "SF Pro Text")
config.depositByHost = true // the Deposit button opens YOUR cashier (deposit_requested)
config.linksByHost = true // outside links open in Safari (open_external)
config.lockEnabled = true // Face ID after 5 min in the background
config.lockAfterSeconds = 300
let controller = QEEmbedController(config: config)
controller.unlockReason = "Unlock your trading account"| Field | Default | Meaning |
|---|---|---|
baseURL | required | The trader address of your brand, https://live.your-brand.com. |
screen | "terminal" | First screen on a cold start: terminal, wallet, history, account, positions. |
account | nil | Trading account id to select. |
language | nil | UI language (en, de, es, pt, ar, …). nil = the module detects the device language. |
theme | QEEmbedTheme() | Colours, shape and font - see the theme fields below. |
hide | ["header", "nav", "branding"] | Parts of the module's chrome to hide; your app has its own navigation. |
depositByHost | true | The Deposit button sends deposit_requested instead of opening the platform cashier. |
linksByHost | true | Outside links go to onOpenExternal / Safari instead of opening inside the web view. |
lockEnabled | true | Lock screen + biometric re-unlock through the host. |
lockAfterSeconds | 300 | Seconds in the background after which the module locks itself. |
proModeAllowed | nil | false = simple terminal only; nil / true = each user keeps their own Pro mode choice. |
showLoginForm | false | true = keep the module's own sign-in form when there is no session (default: a neutral page, you sign in by SSO). |
QEEmbedTheme fields (all optional):
| Field | Default | Meaning |
|---|---|---|
mode | nil | .light or .dark. |
primary | nil | "#RRGGBB" accent colour. |
background | nil | "#RRGGBB" page background. |
paper | nil | "#RRGGBB" card background (default = background). |
text | nil | "#RRGGBB" main text colour; checked for 4.5:1 contrast. |
radius | nil | Corner radius in px, clamped to 0 … 32. |
fontFamily | nil | Font family name. |
fontURL | nil | https URL of a stylesheet that loads the font (other schemes are dropped). |
The controller injects the options as window.__QE_EMBED__ before the page scripts run and also puts them on the first URL (config.startURL(loginToken:)) so a reload inside the web view keeps embed mode. Everything is validated by the module.
#Sign-in (SSO)
Your app signs the user in with your backend; your backend asks the Partner API for a one-time trading login token (POST /api/external/v1/users/{external_id}/sso, see the SSO guide); the token goes to the module. The sample below is the whole host side.
/// One controller for the whole app; the host decides when to start it (after its own login).
final class TradingHost: ObservableObject {
static let shared = TradingHost()
let controller: QEEmbedController
@Published var signedIn = false
@Published var cashierAccount: String?
private init() {
var config = QEEmbedConfig(baseURL: URL(string: "https://live.your-brand.com")!)
config.theme = QEEmbedTheme(mode: .dark, primary: "#ff7a1a", background: "#101418", radius: 14)
controller = QEEmbedController(config: config)
wire()
}
private func wire() {
let l = controller.listener
l.onReady = { e in print("ready, module version", e.string("version") ?? "?") }
l.onLogin = { [weak self] _, _ in self?.signedIn = true }
l.onLogout = { [weak self] in self?.signedIn = false }
l.onSessionExpired = { [weak self] in self?.signedIn = false; self?.signIn() } // a fresh one-time token
l.onAuthRequired = { [weak self] in self?.signIn() }
l.onDepositRequested = { [weak self] account, _ in self?.cashierAccount = account }
l.onTradeOpened = { e in print("trade opened", e.data) }
l.onError = { code, _ in print("module error", code) }
// onUnlockRequested not set -> QEEmbedBiometrics (Face ID / Touch ID / passcode) answers by itself
}
/// 1. your app signs the user in with YOUR backend, 2. your backend asks the Partner API for a one-time
/// trading login token for that user, 3. the token goes to the module. Never put the Partner API key in the app.
func start() {
if controller.sessionStore.loadSession() != nil {
controller.start() // the kept session is restored, no new token needed
} else {
signIn()
}
}
func signIn() {
YourBackend.fetchTradingLoginToken { [weak self] token in
guard let self else { return }
if self.controller.isReady { self.controller.login(token: token) }
else { self.controller.start(loginToken: token) }
}
}
}start(loginToken:)on the first load,login(token:)while the module is already showing (controller.isReady).- The module answers
login { session, client_id, account }; the SDK saves the session in the keychain and callsonLogin. onSessionExpired/onAuthRequired: fetch a new token and calllogin(token:)- exactly what the sample does.logout()clears the store and signs the module out. Call it when the user signs out of your app.
#Session restore
On the next app start call start() with no token: the kept session is sent as restoreSession and the user is signed in without a round trip through your backend. controller.sessionStore.loadSession() tells you whether there is one.
// default: keychain, service "qe-embed", account "session", kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let controller = QEEmbedController(config: config) // QEEmbedKeychainSessionStore()
// tests / previews: nothing survives a restart
let test = QEEmbedController(config: config, sessionStore: QEEmbedMemorySessionStore())
// your own store
final class MyStore: QEEmbedSessionStore {
func loadSession() -> String? { … }
func saveSession(_ session: String) { … }
func clearSession() { … }
}#Views and app lifecycle
// SwiftUI
struct TradingTab: View {
@EnvironmentObject private var host: TradingHost
var body: some View {
QEEmbedView(controller: host.controller)
.ignoresSafeArea(edges: .bottom)
.onAppear { host.start() }
}
}
// UIKit
let vc = QEEmbedViewController(controller: TradingHost.shared.controller)
navigationController?.pushViewController(vc, animated: true)@main
struct YourApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var host = TradingHost.shared
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(host)
.onOpenURL { url in _ = host.controller.handleDeepLink(url) } // universal links, yourapp://
.onChange(of: scenePhase) { phase in
if phase == .active { host.controller.resume() } // re-checks the lock timer
}
}
}
}Create the controller when the app starts (not when the tab opens) and keep one instance for the whole app. controller.presentingViewController is used for the biometric prompt and the file sheet; set it when your view hierarchy is unusual.
#Events
controller.listener is a QEEmbedListener with one closure per event; set the ones you need. Every callback runs on the main thread.
| Callback | Event | Use |
|---|---|---|
onReady(event) | ready | The module shows its first screen - hide your splash. event.string("version"), event.bool("authenticated"). |
onState(event) | state | Answer to init / getState / resume: authenticated, locked, gate, path. |
onLogin(session, event) | login | Signed in; the SDK already saved session in the store. event has client_id, account, email. |
onLogout() | logout | Signed out; the store is cleared. |
onSessionExpired() | session_expired | The server refused the session; the store is cleared - fetch a new token. |
onAuthRequired() | auth_required | A screen needs a signed-in user and there is none - fetch a token. |
onNavigation(path) | navigation | The module moved; also in controller.currentPath. |
onTradeOpened(event) | trade_opened | event.data has position { id, symbol, side, lots, price } and request. |
onTradeClosed(event) | trade_closed | event.data has position_id, partial, lots, position, bulk?, reason?. |
onDepositRequested(account, clientId) | deposit_requested | Open your cashier. |
onOpenExternal(URL) | open_external | Open the URL yourself; when not set the SDK opens Safari. |
onUnlockRequested(reason) | unlock_requested | Run your own unlock and call unlock(ok:); when not set the SDK runs the biometric prompt. |
onUnlocked() | unlocked | The lock screen is gone. |
onThemeApplied(event) | theme_applied | What the module applied: mode, primary, bg, paper, text, radius, font. |
onPushRegistered(ok, reason) | push_registered | Answer to registerPushToken. |
onError(code, event) | error | embed_disabled, bad_command, command_failed, unlock_denied; event has command and message. |
onAny(event) | every event | Raw access: event.type, event.rawType, event.data, event.ts. |
QEEmbedEvent carries type (QEEmbedEventType), rawType, data and helpers string(_:), int(_:), bool(_:). Read-only state on the controller: isReady, isAuthenticated, isLocked, currentPath.
#Commands
Every bridge command is a method. Commands sent before ready are queued and flushed in order.
| Method | Command | Effect |
|---|---|---|
start(loginToken: String? = nil) | load + init (+ login / restoreSession) | Loads the module. With a token: signs in. Without: restores the kept session when there is one. |
login(token:account:) | login | A one-time token while the module is already showing. |
logout() | logout | Signs the module out and clears the store. |
setTheme(_ theme: QEEmbedTheme?) | setTheme | Any subset; nil clears back to the brand theme. |
setLanguage(_ lang: String) | setLanguage | Switch the UI language. |
navigate(screen:) / navigate(path:) | navigate | Move to a screen or a module path. |
lock() / unlock(ok:) | lock / unlock | Show the lock screen; answer an unlock request. |
resume() | resume | Call when the app becomes active; the module re-checks the lock timer. |
getState() | getState | Answered by onState. |
registerPushToken(_ deviceToken: Data) | pushTokenRegistered | APNs token (provider "apns"); registerPushToken(_ token: String, provider:) for others. Queued until signed in. |
handleDeepLink(_ url: URL) -> Bool | navigate | Maps a universal link / scheme URL; false = not for the module. |
send(_ command: QEEmbedCommand) | any | The raw bridge: .initialize, .login, .restoreSession, .logout, .setTheme, .clearTheme, .setLanguage, .navigate, .pushTokenRegistered, .lock, .unlock, .resume, .getState. |
#Theming and language
controller.setTheme(QEEmbedTheme(mode: .light, primary: "#2563eb")) // any subset; the module answers theme_applied
controller.setTheme(nil) // back to the brand's own theme
controller.setLanguage("de") // RTL languages flip the layoutText colours you pass are checked for 4.5:1 readability; an unreadable value is replaced by the module's own and onThemeApplied tells you what was applied. The OS dark-mode switch does not override your theme.
#Navigation and deep links
controller.navigate(screen: "wallet") // terminal | wallet | history | account | positions
controller.navigate(path: "/dashboard/positions") // a module path
controller.listener.onNavigation = { path in print("module is at", path) } // sync your tab bar / analytics
// Deep links: QEEmbedDeepLink maps three shapes to navigate
// https://app.your-brand.com/trade/positions (universal link on your host, prefix "/trade")
// yourapp://trade/wallet (custom scheme, host "trade")
// https://live.your-brand.com/dashboard/wallet (the module's own address)
QEEmbedDeepLink.linkPrefix = "/trade"
QEEmbedDeepLink.schemeHost = "trade"
let handled = controller.handleDeepLink(url) // false = not for the module, route it yourselfOnly module paths are accepted (QEEmbedDeepLink.safePath): no scheme, no host, so a link can never move the web view to another site. The web view itself navigates only to baseURL's host; a target=_blank link never opens a second web view - it goes to onOpenExternal or Safari.
#Biometric lock
With lockEnabled the module locks after lockAfterSeconds in the background (and on lock()) and sends unlock_requested { reason }. The SDK runs QEEmbedBiometrics - Face ID / Touch ID with the device passcode as fallback (deviceOwnerAuthentication; a device without biometrics passes) - and answers unlock itself.
// default: the SDK runs Face ID / Touch ID (device passcode as fallback) and answers unlock itself
controller.unlockReason = "Unlock your trading account"
// your own prompt instead
controller.listener.onUnlockRequested = { reason in // user | host | resume | timeout
MyAuth.confirm { ok in controller.unlock(ok: ok) }
}
controller.listener.onUnlocked = { print("unlocked") }
controller.lock() // lock now (for example on your own "Lock" button)Call resume() whenever the app becomes active: the module re-checks the lock timer and answers state, plus unlock_requested when locked. unlock(ok: false) answers error unlock_denied and the lock screen stays.
#Push notifications
You register with APNs as today and hand the token over once. The SDK queues it until the user is signed in, sends pushTokenRegistered { token, platform: "ios", provider: "apns" } and the module answers push_registered { ok }. Trade, margin and deposit pushes for that user then go to that device.
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
guard granted else { return }
DispatchQueue.main.async { application.registerForRemoteNotifications() }
}
return true
}
// APNs token -> the module (queued until the user is signed in; answered with push_registered)
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
TradingHost.shared.controller.registerPushToken(deviceToken) // provider "apns"
}
// a tap on a trade / margin push opens the right screen inside the module
// payload: userInfo["qe"] = { "path": "/dashboard/positions" } or { "screen": "positions" }
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
if let target = QEEmbedPush.target(in: response.notification.request.content.userInfo) {
if let screen = target.screen { TradingHost.shared.controller.navigate(screen: screen) }
else if let path = target.path { TradingHost.shared.controller.navigate(path: path) }
}
completionHandler()
}
}QEEmbedPush.hexString(_:) turns the Data token into the hex string the module gets; QEEmbedPush.target(in:) returns nil for pushes that are not for the module.
#KYC upload
The module's KYC page uses a normal file input. WKWebView shows the photo / camera / files sheet itself - nothing to code, but the two Info.plist strings above must be present or the sheet will not appear. The sheet is presented from presentingViewController.
#Deposits and outside links
depositByHost = true(default): the Deposit button only sendsdeposit_requested { account, client_id }→onDepositRequested. Open your cashier; balances refresh by themselves.false= the platform cashier inside the module.linksByHost = true(default): every link to another site goes toonOpenExternal, or to Safari when the closure is not set. The web view never leaves the module's origin.
#Test plan
Run on the demo brand before your store build:
No session
Load with no session: the neutral sign-in page shows and
ready { authenticated: false }arrives.Token sign-in
start(loginToken:)with a token from the demo Partner API:loginarrives, the terminal shows, the token is no longer in the URL. Kill the app, start again without a token: signed in.Theme
Dark / light, your primary colour, radius, font →
theme_applied. Screenshots on a small phone (390 pt).Hand-overs
Deposit button →
deposit_requested; an outside link →open_external/ Safari.Lock
Background 5 min (or
lock()) → lock screen → Face ID →unlocked.Push
Token →
push_registered { ok: true }; a test push withqe.screenopens that screen.KYC
A photo from the camera and one from the library upload.
Deep links
From a cold start and from a running app, universal link and custom scheme.
Rough conditions
Rotate, switch the OS dark mode (the module keeps your theme), airplane mode on and off.
#Performance on low-end phones
- The module is tuned for a throttled mobile profile (4× CPU slowdown, slow 4G).
- Keep the web view warm: create the controller when your app starts, call
start()early, show the tab later. - Do not recreate the web view per screen; use
navigate. One module instance per app.
#Security notes
- The session is stored in the keychain with
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly- it never leaves the device or a backup. - The web view loads only the module's origin (
about:andblob:aside); other URLs go to the system browser. - The one-time token is removed from the URL after the exchange and never stored.
- The bridge accepts only messages from the module's own scripts (
messageHandlerName = "qeEmbed"); commands are plain JSON passed towindow.QEEmbed.handle. - No certificate pinning by design - the platform's certificates rotate automatically.