Go layer (c-archive) provides a localhost TCP proxy through a WireGuard tunnel. Flutter connects gRPC to localhost:PORT, Go forwards via tsnet to heater's Tailscale IP. No VPN entitlement needed — uses userspace netstack. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
181 lines
7.0 KiB
Swift
181 lines
7.0 KiB
Swift
import Flutter
|
|
import UIKit
|
|
|
|
/// Swift bridge between Flutter MethodChannel and Go static library.
|
|
///
|
|
/// Calls C functions exported from Go via c-archive:
|
|
/// TailscaleStart(stateDir, authKey, hostname) → NULL or error
|
|
/// TailscaleStartProxy(ip, port, &localPort) → NULL or error
|
|
/// TailscaleStopProxy() → NULL or error
|
|
/// TailscaleStop() → NULL or error
|
|
/// TailscaleStatus(&err) → JSON string or NULL
|
|
/// TailscaleIP(&err) → IP string or NULL
|
|
/// TailscaleFreeString(ptr) → frees returned strings
|
|
public class TailscaleKitPlugin: NSObject, FlutterPlugin {
|
|
|
|
private lazy var stateDir: String = {
|
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
let dir = docs.appendingPathComponent("tailscale_state", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir.path
|
|
}()
|
|
|
|
public static func register(with registrar: FlutterPluginRegistrar) {
|
|
let channel = FlutterMethodChannel(name: "tailscale_kit", binaryMessenger: registrar.messenger())
|
|
let instance = TailscaleKitPlugin()
|
|
registrar.addMethodCallDelegate(instance, channel: channel)
|
|
}
|
|
|
|
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
switch call.method {
|
|
case "start":
|
|
handleStart(call, result: result)
|
|
case "startProxy":
|
|
handleStartProxy(call, result: result)
|
|
case "stopProxy":
|
|
handleStopProxy(result: result)
|
|
case "stop":
|
|
handleStop(result: result)
|
|
case "status":
|
|
handleStatus(result: result)
|
|
case "tailscaleIP":
|
|
handleTailscaleIP(result: result)
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
|
|
/// Consume a C string returned by Go: copy to Swift String, then free.
|
|
private func consumeCString(_ ptr: UnsafeMutablePointer<CChar>?) -> String? {
|
|
guard let ptr = ptr else { return nil }
|
|
let str = String(cString: ptr)
|
|
TailscaleFreeString(ptr)
|
|
return str
|
|
}
|
|
|
|
// TailscaleStart(stateDir, authKey, hostname) → NULL or error
|
|
private func handleStart(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
guard let args = call.arguments as? [String: Any],
|
|
let authKey = args["authKey"] as? String,
|
|
let hostname = args["hostname"] as? String else {
|
|
result(FlutterError(code: "INVALID_ARGS", message: "Missing authKey or hostname", details: nil))
|
|
return
|
|
}
|
|
|
|
let dir = self.stateDir
|
|
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
|
// Go's C.GoString() copies immediately, so withCString lifetimes are fine
|
|
let errPtr = dir.withCString { dirC in
|
|
authKey.withCString { keyC in
|
|
hostname.withCString { hostC in
|
|
TailscaleStart(
|
|
UnsafeMutablePointer(mutating: dirC),
|
|
UnsafeMutablePointer(mutating: keyC),
|
|
UnsafeMutablePointer(mutating: hostC)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
let err = self.consumeCString(errPtr)
|
|
DispatchQueue.main.async {
|
|
if let err = err {
|
|
result(FlutterError(code: "START_FAILED", message: err, details: nil))
|
|
} else {
|
|
result(nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TailscaleStartProxy(ip, port, &localPort) → NULL or error
|
|
private func handleStartProxy(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
guard let args = call.arguments as? [String: Any],
|
|
let ip = args["ip"] as? String,
|
|
let port = args["port"] as? Int else {
|
|
result(FlutterError(code: "INVALID_ARGS", message: "Missing ip or port", details: nil))
|
|
return
|
|
}
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
|
var localPort: CInt = 0
|
|
let errPtr = ip.withCString { ipC in
|
|
TailscaleStartProxy(
|
|
UnsafeMutablePointer(mutating: ipC),
|
|
CInt(port),
|
|
&localPort
|
|
)
|
|
}
|
|
let err = self.consumeCString(errPtr)
|
|
DispatchQueue.main.async {
|
|
if let err = err {
|
|
result(FlutterError(code: "PROXY_FAILED", message: err, details: nil))
|
|
} else {
|
|
result(Int(localPort))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TailscaleStopProxy() → NULL or error
|
|
private func handleStopProxy(result: @escaping FlutterResult) {
|
|
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
|
let err = self.consumeCString(TailscaleStopProxy())
|
|
DispatchQueue.main.async {
|
|
if let err = err {
|
|
result(FlutterError(code: "STOP_PROXY_FAILED", message: err, details: nil))
|
|
} else {
|
|
result(nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TailscaleStop() → NULL or error
|
|
private func handleStop(result: @escaping FlutterResult) {
|
|
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
|
let err = self.consumeCString(TailscaleStop())
|
|
DispatchQueue.main.async {
|
|
if let err = err {
|
|
result(FlutterError(code: "STOP_FAILED", message: err, details: nil))
|
|
} else {
|
|
result(nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TailscaleStatus(&errOut) → JSON string or NULL
|
|
private func handleStatus(result: @escaping FlutterResult) {
|
|
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
|
var errPtr: UnsafeMutablePointer<CChar>? = nil
|
|
let jsonPtr = TailscaleStatus(&errPtr)
|
|
let err = self.consumeCString(errPtr)
|
|
let json = self.consumeCString(jsonPtr)
|
|
DispatchQueue.main.async {
|
|
if let err = err {
|
|
result(FlutterError(code: "STATUS_FAILED", message: err, details: nil))
|
|
} else {
|
|
result(json)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TailscaleIP(&errOut) → IP string or NULL
|
|
private func handleTailscaleIP(result: @escaping FlutterResult) {
|
|
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
|
var errPtr: UnsafeMutablePointer<CChar>? = nil
|
|
let ipPtr = TailscaleIP(&errPtr)
|
|
let err = self.consumeCString(errPtr)
|
|
let ip = self.consumeCString(ipPtr)
|
|
DispatchQueue.main.async {
|
|
if let err = err {
|
|
result(FlutterError(code: "IP_FAILED", message: err, details: nil))
|
|
} else {
|
|
result(ip)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|