SpeedOf.Me API - macOS Integration

This example demonstrates how to integrate the SpeedOf.Me speed test API into a macOS app using WKWebView.

How It Works

The SpeedOf.Me API is JavaScript-based, so macOS apps use WKWebView to:

  1. Load an HTML page containing the speed test, served from your registered domain
  2. Receive results via WKScriptMessageHandler

This is very similar to the iOS example, with macOS-specific UI adaptations.

Files

Prerequisites

Key Differences from iOS

AspectiOSmacOS
View wrapperUIViewRepresentableNSViewRepresentable
Make viewmakeUIViewmakeNSView
Update viewupdateUIViewupdateNSView
Window styleN/A.windowStyle(.titleBar)

Quick Start

1. Create a New macOS App

In Xcode: File > New > Project > macOS > App (SwiftUI)

2. Add Files

Copy SpeedTestView.swift into your project. speedtest.html does not go in the app: it is served from your own domain, see step 4.

3. Configure API Credentials

SomApi.account = "YOUR_API_KEY";
SomApi.domainName = "your-domain.com";

4. Host speedtest.html on Your Registered Domain

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).

5. Update App Entry Point

Replace the auto-generated @main App struct with the one in SpeedTestView.swift, or integrate SpeedTestView into your existing app structure.

App Sandbox Entitlements

For network access, add to your .entitlements file:

<key>com.apple.security.network.client</key>
<true/>

That is the only entitlement this example needs.

Message Payloads

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 "".

progress

FieldValue
type"download", "upload" or "latency"
passPass number, '' on latency events
percentDone0-100
currentSpeedMbps, only when SomApi.config.progress.verbose is true, otherwise ''. Also '' on latency events
maxSpeedMbps, same rule as currentSpeed, and also '' while the running maximum is still 0
latencyms, only on the final latency event, otherwise ''
jitterms, same rule as latency

The template turns verbose mode on and forwards type, pass, percentDone and currentSpeed.

completed

Every field is always present. A disabled sub-test sends a blank value rather than omitting the key.

FieldValue
downloadMbps
uploadMbps, '' when uploadTestEnabled is false
maxDownloadMbps
maxUploadMbps, '' when uploadTestEnabled is false
latencyms, '' when latencyTestEnabled is false
jitterms, '' when latencyTestEnabled is false
testServer'' when testServerEnabled is false
ip_address'0.0.0.0' when userInfoEnabled is false
hostname'' when userInfoEnabled is false
userAgentUser agent string
testDateISO 8601 timestamp

The template forwards download, upload, latency, jitter, testServer, ip_address and hostname.

Error Codes

onError receives { code, message }.

CodeMeaning
1001Invalid Account: the API key is wrong or inactive
1002Domain Mismatch: the page URL does not match your registered domain
2001Test Error
2002Invalid server response
2003Request timeout
2004Test timeout: the run exceeded config.testTimeout
2005Speed test could not start (status N)
2006Speed 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.

AppKit Alternative

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
        }
    }
}

Configuration Options

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

Window Configuration

WindowGroup {
    SpeedTestView()
        .frame(minWidth: 400, idealWidth: 450, minHeight: 500, idealHeight: 600)
}
.windowStyle(.titleBar)
.windowResizability(.contentMinSize)

Menu Bar App (Alternative)

@main
struct SpeedTestApp: App {
    var body: some Scene {
        MenuBarExtra("Speed Test", systemImage: "speedometer") {
            SpeedTestView()
                .frame(width: 350, height: 450)
        }
        .menuBarExtraStyle(.window)
    }
}

Distribution

Mac App Store

  1. Archive your app in Xcode
  2. Upload to App Store Connect
  3. Requires Apple Developer account

Direct Distribution

  1. Archive in Xcode
  2. Export as "Developer ID" signed app
  3. Notarize with Apple
  4. Distribute via your website

Troubleshooting

Links