Mobile Development 19 min read

Starship: iOS Test Device Proxy Management in Practice

The article details the design and implementation of “Starship,” an internal iOS tool that automates proxy configuration for shared test devices, eliminating manual Wi‑Fi settings, providing real‑time status, domain‑based routing, and health checks, while comparing existing solutions and sharing practical lessons.

大转转FE
大转转FE
大转转FE
Starship: iOS Test Device Proxy Management in Practice

Problem

Developers sharing iOS test devices had to change proxy settings manually in the Wi‑Fi settings each time they switched environments. This caused repetitive UI navigation, configuration errors, and made it difficult to identify which proxy was active on a handed‑off device. The system also provided no visible indication of proxy status, and enabling a proxy broke third‑party SDKs that required direct connections.

Design Goals

Switch proxy instantly by scanning a QR code, without entering system settings.

Show the currently active configuration inside the app.

Support domain‑based split routing.

Visualize real‑time link health.

Architecture

Core Components

Main App : UI for scanning QR codes, switching proxies, and displaying status.

PacketTunnel Extension : Network Extension process that captures traffic (iOS forbids direct packet access from a normal app).

Tun2socks + V2Ray : Translates IP packets to a format the corporate proxy understands (HTTP CONNECT).

Upstream HTTP Proxy : Whistle or corporate proxy acting as the exit point.

Architecture diagram
Architecture diagram

The data flow consists of two steps: (1) capture system traffic into a virtual utun interface using Tun2socks, and (2) convert the resulting TCP stream from SOCKS5 to HTTP CONNECT with V2Ray.

App‑Extension Communication

The Main App and PacketTunnel run in separate processes without shared memory. Configuration sync uses providerConfiguration for cold‑start data and sendProviderMessage for runtime updates.

let messageData = try JSONEncoder().encode(message)
var dict = proto.providerConfiguration ?? [:]
dict["proxyMessage"] = messageData // write to dict
proto.providerConfiguration = dict
manager.protocolConfiguration = proto
try await manager.saveToPreferences()
try await manager.loadFromPreferences()
Task { try? await self.sendMessageToExtension(message) }

On the extension side, startTunnel reads the message from providerConfiguration and passes it to the Go runtime.

override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
    if let proto = protocolConfiguration as? NETunnelProviderProtocol,
       let data = proto.providerConfiguration?["proxyMessage"] as? Data {
        self.proxyMessage = try? JSONDecoder().decode(ProxyTunnelMessage.self, from: data)
    }
    guard let msg = proxyMessage else { return }
    let configData = buildV2RayHTTPOutboundConfig(host: msg.host, port: msg.port)
    Tun2socksStartV2Ray(self, configData)
    setupHTTPProxy(config: msg) { [weak self] in self?.proxyPackets() }
    completionHandler(nil)
}

Protocol Translation Layer

Tun2socks restores IP packets to TCP streams, while V2Ray converts SOCKS5 to HTTP CONNECT. The V2Ray configuration disables UDP because the corporate proxy only understands TCP.

private func buildV2RayHTTPOutboundConfig(host: String, port: Int) -> Data? {
    let json: [String: Any] = [
        "log": ["loglevel": "warning"],
        "inbounds": [[
            "tag": "socks-in",
            "port": 10808,
            "listen": "127.0.0.1",
            "protocol": "socks",
            "settings": ["udp": false]
        ]]],
        "outbounds": [[
            "tag": "http-out",
            "protocol": "http",
            "settings": ["servers": [["address": host, "port": port]]]
        ]]
    ]
    return try? JSONSerialization.data(withJSONObject: json, options: [])
}

Fast Proxy Switching

Scanning a QR code generated by the Beetle platform triggers a URL‑scheme that the app parses, stores the configuration, and activates it immediately, eliminating any manual Wi‑Fi steps.

QR code flow diagram
QR code flow diagram

Proxy Status Visualization

The app publishes activeConfiguration and connectionStatus via @Published. The UI card subscribes to these publishers, instantly reflecting the proxy name, IP + port, VPN state, and health (green/yellow/red). A fallback 1‑second polling loop corrects occasional missed system notifications.

@Published var connectionStatus: NEVPNStatus = .invalid
@Published var isConnecting: Bool = false

private func startStatusPolling() {
    statusPoller = Timer.publish(every: 1.0, on: .main, in: .common)
        .autoconnect()
        .sink { [weak self] _ in
            guard let self = self else { return }
            let status = self.manager?.connection.status ?? .invalid
            if status != self.connectionStatus { self.connectionStatus = status }
        }
}

iOS Routing: Two Mutually Exclusive Modes

iOS provides two proxy mechanisms that cannot coexist directly:

HTTP Proxy (App Layer) : Uses proxySettings.matchDomains to route specific domains via CFNetwork. Works for URLSession, WKWebView, Alamofire, etc., but bypasses non‑CFNetwork traffic (e.g., WeChat mini‑programs, Flutter, custom sockets).

TUN Global Proxy (Network Layer) : Captures all traffic via includedRoutes = [NEIPv4Route.default()]. Guarantees all sockets are intercepted but prevents domain‑level matching because routing occurs before matchDomains can be evaluated.

Starship bridges the two by maintaining a domain whitelist. If the whitelist is populated, traffic matching those domains follows the HTTP‑proxy path; otherwise, the TUN path handles everything.

Domain split routing
Domain split routing

Link Health Checks

Three layers ensure accurate proxy health reporting:

Pre‑flight TCP check before VPN start to verify basic connectivity.

Extension‑side TCP probe every 30 seconds using NWConnection to confirm the tunnel remains alive.

HTTP probe that performs a real request (e.g., http://httpbin.org/ip) and validates a 200 response.

func enableProxy(config: ProxyConfiguration) async throws {
    do {
        try await preflightConnectivity(host: config.host, port: config.port, timeout: 3.0)
        print("[VPN] preflight successful")
    } catch {
        print("[VPN] preflight failed: \(error), but continuing connection...")
    }
    try await setupVPNManager()
    try await configureProxySettings(config: config)
    try await startVPNConnection()
}

Pitfalls and Lessons Learned

When attempting to capture traffic from mini‑programs (WeChat, Flutter, Cronet), the team discovered that these frameworks use low‑level sockets that bypass CFNetwork, so domain‑based routing never applied. The solution was to clear the domain whitelist, forcing the global TUN path, which successfully captured the traffic.

Key takeaway: Populated domain rules give precise capture; an empty rule switches to full‑device capture.

Outcome

Starship has been adopted across QA, client, and backend teams, turning a repetitive manual proxy‑switching workflow into a single QR‑code scan. The lightweight in‑app proxy manager, combined with careful routing choices and health monitoring, dramatically improves developer productivity on shared iOS test devices.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

iOSProxySwiftQR CodeTun2socksNetworkExtensionV2Ray
大转转FE
Written by

大转转FE

Regularly sharing the team's thoughts and insights on frontend development

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.