This example demonstrates how to integrate the SpeedOf.Me speed test API into a macOS app using WKWebView.
The SpeedOf.Me API is JavaScript-based, so macOS apps use WKWebView to:
This is very similar to the iOS example, with macOS-specific UI adaptations.
| Aspect | iOS | macOS |
|---|---|---|
| View wrapper | UIViewRepresentable | NSViewRepresentable |
| Make view | makeUIView | makeNSView |
| Update view | updateUIView | updateNSView |
| Window style | N/A | .windowStyle(.titleBar) |
In Xcode: File > New > Project > macOS > App (SwiftUI)
Copy SpeedTestView.swift into your project. speedtest.html does not go in the app: it is served from your own domain, see step 4.
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).
Replace the auto-generated @main App struct with the one in SpeedTestView.swift, or integrate SpeedTestView into your existing app structure.
For network access, add to your .entitlements file:
<key>com.apple.security.network.client</key>
<true/>
That is the only entitlement this example needs.
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 an AppKit 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 Cocoa
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: NSViewController, WKScriptMessageHandler {
private var webView: WKWebView!
override func loadView() {
let config = WKWebViewConfiguration()
config.userContentController.add(WeakScriptMessageHandler(delegate: self),
name: "speedTest")
webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 400, height: 500), configuration: config)
self.view = 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 else { return }
switch type {
case "completed":
if let data = body["data"] as? [String: Any] {
print("Download: \(data["download"] ?? "N/A") Mbps")
}
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
WindowGroup {
SpeedTestView()
.frame(minWidth: 400, idealWidth: 450, minHeight: 500, idealHeight: 600)
}
.windowStyle(.titleBar)
.windowResizability(.contentMinSize)
@main
struct SpeedTestApp: App {
var body: some Scene {
MenuBarExtra("Speed Test", systemImage: "speedometer") {
SpeedTestView()
.frame(width: 350, height: 450)
}
.menuBarExtraStyle(.window)
}
}
com.apple.security.network.client entitlementminWidth/minHeight in frame modifier