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.
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.
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.
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.
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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
大转转FE
Regularly sharing the team's thoughts and insights on frontend development
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
