This example demonstrates how to integrate the SpeedOf.Me speed test API into an iOS app using WKWebView.
The SpeedOf.Me API is JavaScript-based, so iOS apps use WKWebView to:
Copy SpeedTestView.swift into your Xcode project. speedtest.html does not go in the app: it is served from your own domain, see step 3.
In speedtest.html, replace the placeholder values:
SomApi.account = "YOUR_API_KEY";
SomApi.domainName = "your-domain.com";
Upload speedtest.html to the domain registered on your SpeedOf.Me account, and point the WKWebView at that URL:
if let url = URL(string: "https://your-domain.com/speedtest.html") {
webView.load(URLRequest(url: url))
}
This step is not optional. The engine validates the page that embeds api.js against your registered domain, and it reads that from the page's own URL. A page loaded from the app bundle has no host, so it can never match and every test fails with error 1002 (Domain Mismatch).
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
SpeedTestView()
}
}
}
iOS App
└── SpeedTestView (SwiftUI)
└── SpeedTestWebView (UIViewRepresentable)
└── WKWebView
└── speedtest.html (on your registered domain)
└── api.js (defines the SomApi global)
▼
window.webkit.messageHandlers.speedTest.postMessage()
▼
WKScriptMessageHandler.didReceive(message:)
The HTML page sends messages to Swift via:
window.webkit.messageHandlers.speedTest.postMessage({
type: 'completed',
data: result
});
speedtest.html normalizes what the engine sends before forwarding it, so the Swift Codable structs always decode. A blank number arrives as 0, and a blank string (testServer, ip_address, hostname) arrives as "".
| Field | Value |
|---|---|
type | "download", "upload" or "latency" |
pass | Pass number, '' on latency events |
percentDone | 0-100 |
currentSpeed | Mbps, only when SomApi.config.progress.verbose is true, otherwise ''. Also '' on latency events |
maxSpeed | Mbps, same rule as currentSpeed, and also '' while the running maximum is still 0 |
latency | ms, only on the final latency event, otherwise '' |
jitter | ms, same rule as latency |
The template turns verbose mode on and forwards type, pass, percentDone and currentSpeed.
Every field is always present. A disabled sub-test sends a blank value rather than omitting the key.
| Field | Value |
|---|---|
download | Mbps |
upload | Mbps, '' when uploadTestEnabled is false |
maxDownload | Mbps |
maxUpload | Mbps, '' when uploadTestEnabled is false |
latency | ms, '' when latencyTestEnabled is false |
jitter | ms, '' when latencyTestEnabled is false |
testServer | '' when testServerEnabled is false |
ip_address | '0.0.0.0' when userInfoEnabled is false |
hostname | '' when userInfoEnabled is false |
userAgent | User agent string |
testDate | ISO 8601 timestamp |
The template forwards download, upload, latency, jitter, testServer, ip_address and hostname.
onError receives { code, message }.
| Code | Meaning |
|---|---|
| 1001 | Invalid Account: the API key is wrong or inactive |
| 1002 | Domain Mismatch: the page URL does not match your registered domain |
| 2001 | Test Error |
| 2002 | Invalid server response |
| 2003 | Request timeout |
| 2004 | Test timeout: the run exceeded config.testTimeout |
| 2005 | Speed test could not start (status N) |
| 2006 | Speed test engine did not load |
Code 2006, and code 2005 with status 0, mean the request never left the device, which is almost always a content blocker, or a frame-src policy that does not allow https://speedof.me. A 2005 carrying any other status is a real HTTP response from the server, so read the status in the message.
If you are not using SwiftUI, here is a UIKit version.
Register the handler through a proxy that holds it weakly, and remove it on teardown. The user content controller retains its message handler and the view controller retains the web view that owns it, so passing self straight to add(_:name:) closes that loop and neither object is ever released.
import UIKit
import WebKit
// The user content controller retains its message handler, and the view
// controller retains the web view that owns it. Registering `self` directly
// would close that loop and neither object would ever deallocate, so route
// through a proxy that holds the real handler weakly.
final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
weak var delegate: WKScriptMessageHandler?
init(delegate: WKScriptMessageHandler) {
self.delegate = delegate
}
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
delegate?.userContentController(userContentController, didReceive: message)
}
}
class SpeedTestViewController: UIViewController, WKScriptMessageHandler {
private var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
let config = WKWebViewConfiguration()
config.userContentController.add(WeakScriptMessageHandler(delegate: self),
name: "speedTest")
webView = WKWebView(frame: view.bounds, configuration: config)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(webView)
if let url = URL(string: "https://your-domain.com/speedtest.html") {
webView.load(URLRequest(url: url))
}
}
deinit {
webView?.configuration.userContentController
.removeScriptMessageHandler(forName: "speedTest")
}
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any],
let type = body["type"] as? String,
let data = body["data"] else { return }
switch type {
case "completed":
if let result = data as? [String: Any] {
print("Download: \(result["download"] ?? "N/A") Mbps")
}
case "progress":
// Handle progress
break
case "error":
// Handle error
break
default:
break
}
}
}
SomApi.config.sustainTime = 6; // 1-8 seconds
SomApi.config.testServerEnabled = true; // Report the test server
SomApi.config.userInfoEnabled = true; // Report IP and hostname
SomApi.config.latencyTestEnabled = true; // Include latency test
SomApi.config.uploadTestEnabled = true; // Include upload test
SomApi.config.progress.enabled = true; // Fire onProgress during the test
SomApi.config.progress.verbose = true; // Required for progress.currentSpeed