From ded6a5e1a3b56ee6c90db0ca15033aebebf62847 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Fri, 10 Jul 2026 22:54:59 -0500 Subject: [PATCH 01/10] initial spm --- .gitignore | 2 ++ Package.swift | 27 +++++++++++++++++++ swift/Sources/CTailscale/module.modulemap | 5 ++++ swift/Sources/CTailscale/shim.h | 1 + swift/Sources/CTstestControl/module.modulemap | 5 ++++ swift/Sources/CTstestControl/shim.h | 1 + swift/TailscaleKit/Listener.swift | 3 +++ swift/TailscaleKit/OutgoingConnection.swift | 3 +++ swift/TailscaleKit/TailscaleError.swift | 3 +++ swift/TailscaleKit/TailscaleNode.swift | 4 +++ swift/TailscaleKit/URLSession+Tailscale.swift | 1 + .../TailscaleKitTests.swift | 3 +++ 12 files changed, 58 insertions(+) create mode 100644 Package.swift create mode 100644 swift/Sources/CTailscale/module.modulemap create mode 100644 swift/Sources/CTailscale/shim.h create mode 100644 swift/Sources/CTstestControl/module.modulemap create mode 100644 swift/Sources/CTstestControl/shim.h diff --git a/.gitignore b/.gitignore index 49fb623..fde5bd1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ libtailscale.bundle /sourcepkg/libtailscale.tar* /vendor/ + +.build/ \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..d4398c9 --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version:6.3 +import PackageDescription + +let package = Package( + name: "TailscaleKit", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [ + .library(name: "TailscaleKit", targets: ["TailscaleKit"]) + ], + targets: [ + .systemLibrary(name: "CTailscale", path: "swift/Sources/CTailscale"), + .systemLibrary(name: "CTstestControl", path: "swift/Sources/CTstestControl"), + .target( + name: "TailscaleKit", + dependencies: ["CTailscale"], + path: "swift/TailscaleKit", + exclude: ["TailscaleKit.h"], + linkerSettings: [.unsafeFlags(["-L", "."])] + ), + .testTarget( + name: "TailscaleKitTests", + dependencies: ["TailscaleKit", "CTstestControl"], + path: "swift/TailscaleKitXCTests", + linkerSettings: [.unsafeFlags(["-L", "tstestcontrol"])] + ), + ] +) diff --git a/swift/Sources/CTailscale/module.modulemap b/swift/Sources/CTailscale/module.modulemap new file mode 100644 index 0000000..d7007ce --- /dev/null +++ b/swift/Sources/CTailscale/module.modulemap @@ -0,0 +1,5 @@ +module CTailscale [system] { + header "shim.h" + link "tailscale" + export * +} diff --git a/swift/Sources/CTailscale/shim.h b/swift/Sources/CTailscale/shim.h new file mode 100644 index 0000000..7092316 --- /dev/null +++ b/swift/Sources/CTailscale/shim.h @@ -0,0 +1 @@ +#include "../../../tailscale.h" diff --git a/swift/Sources/CTstestControl/module.modulemap b/swift/Sources/CTstestControl/module.modulemap new file mode 100644 index 0000000..f94740f --- /dev/null +++ b/swift/Sources/CTstestControl/module.modulemap @@ -0,0 +1,5 @@ +module CTstestControl [system] { + header "shim.h" + link "tstestcontrol" + export * +} diff --git a/swift/Sources/CTstestControl/shim.h b/swift/Sources/CTstestControl/shim.h new file mode 100644 index 0000000..2e337fd --- /dev/null +++ b/swift/Sources/CTstestControl/shim.h @@ -0,0 +1 @@ +#include "../../../tstestcontrol/tstestcontrol.h" diff --git a/swift/TailscaleKit/Listener.swift b/swift/TailscaleKit/Listener.swift index 67d89e3..323ef9e 100644 --- a/swift/TailscaleKit/Listener.swift +++ b/swift/TailscaleKit/Listener.swift @@ -1,6 +1,9 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(CTailscale) +import CTailscale +#endif import Combine import Foundation diff --git a/swift/TailscaleKit/OutgoingConnection.swift b/swift/TailscaleKit/OutgoingConnection.swift index 97195f9..678807c 100644 --- a/swift/TailscaleKit/OutgoingConnection.swift +++ b/swift/TailscaleKit/OutgoingConnection.swift @@ -1,6 +1,9 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(CTailscale) +import CTailscale +#endif import Foundation import Combine diff --git a/swift/TailscaleKit/TailscaleError.swift b/swift/TailscaleKit/TailscaleError.swift index d979101..04f9e5c 100644 --- a/swift/TailscaleKit/TailscaleError.swift +++ b/swift/TailscaleKit/TailscaleError.swift @@ -1,6 +1,9 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(CTailscale) +import CTailscale +#endif import Foundation public enum TailscaleError: Error { diff --git a/swift/TailscaleKit/TailscaleNode.swift b/swift/TailscaleKit/TailscaleNode.swift index 34a6f94..b54fa5f 100644 --- a/swift/TailscaleKit/TailscaleNode.swift +++ b/swift/TailscaleKit/TailscaleNode.swift @@ -1,6 +1,10 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(CTailscale) +import CTailscale +#endif + public let kDefaultControlURL = "https://controlplane.tailscale.com" diff --git a/swift/TailscaleKit/URLSession+Tailscale.swift b/swift/TailscaleKit/URLSession+Tailscale.swift index e0afbc2..b2a2806 100644 --- a/swift/TailscaleKit/URLSession+Tailscale.swift +++ b/swift/TailscaleKit/URLSession+Tailscale.swift @@ -5,6 +5,7 @@ import UIKit #endif +import Foundation import Network public extension URLSessionConfiguration { diff --git a/swift/TailscaleKitXCTests/TailscaleKitTests.swift b/swift/TailscaleKitXCTests/TailscaleKitTests.swift index 8f6333e..21affc5 100644 --- a/swift/TailscaleKitXCTests/TailscaleKitTests.swift +++ b/swift/TailscaleKitXCTests/TailscaleKitTests.swift @@ -1,6 +1,9 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(CTstestControl) +import CTstestControl +#endif import XCTest @testable import TailscaleKit From 7a8a19f3c9abf662029c6720b81181fd12ddd5a7 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Fri, 10 Jul 2026 23:02:24 -0500 Subject: [PATCH 02/10] devcontainer --- .devcontainer/swift/Dockerfile | 22 ++++++++++++++++++++++ .devcontainer/swift/devcontainer.json | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 .devcontainer/swift/Dockerfile create mode 100644 .devcontainer/swift/devcontainer.json diff --git a/.devcontainer/swift/Dockerfile b/.devcontainer/swift/Dockerfile new file mode 100644 index 0000000..11cc874 --- /dev/null +++ b/.devcontainer/swift/Dockerfile @@ -0,0 +1,22 @@ +FROM swift:6.3 + +ARG GO_VERSION=1.25.5 +ARG TARGETARCH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + build-essential \ + libcurl4-openssl-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH:-amd64}.tar.gz" -o /tmp/go.tar.gz \ + && tar -C /usr/local -xzf /tmp/go.tar.gz \ + && rm /tmp/go.tar.gz + +ENV PATH="/usr/local/go/bin:${PATH}" +ENV GOPATH="/go" +ENV PATH="${GOPATH}/bin:${PATH}" + +RUN go version && swift --version diff --git a/.devcontainer/swift/devcontainer.json b/.devcontainer/swift/devcontainer.json new file mode 100644 index 0000000..d12b538 --- /dev/null +++ b/.devcontainer/swift/devcontainer.json @@ -0,0 +1,18 @@ +{ + "name": "swift", + "build": { + "dockerfile": "Dockerfile", + "context": "../.." + }, + "workspaceFolder": "/workspace", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", + "customizations": { + "vscode": { + "extensions": [ + "swiftlang.swift-vscode", + "golang.go" + ] + } + }, + "postCreateCommand": "go version && swift --version" +} From 7be71070b694ca1f08f5a3896981f83ebd3d00cb Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Sat, 11 Jul 2026 08:48:31 -0500 Subject: [PATCH 03/10] linux build --- .devcontainer/swift/devcontainer.json | 7 +- .../HelloFromTailscale/HelloViewModel.swift | 6 +- swift/TailscaleKit/IncomingConnection.swift | 45 +++++----- swift/TailscaleKit/Listener.swift | 30 ++++--- swift/TailscaleKit/LocalAPI/GoTime.swift | 4 +- .../LocalAPI/LocalAPIClient.swift | 82 ++++++++++--------- .../LocalAPI/MessageProcessor.swift | 14 ++-- .../TailscaleKit/LocalAPI/MessageReader.swift | 4 + swift/TailscaleKit/LocalAPI/Types.swift | 29 ++++--- swift/TailscaleKit/LogSink.swift | 8 ++ swift/TailscaleKit/OutgoingConnection.swift | 18 ++-- swift/TailscaleKit/PlatformShims.swift | 28 +++++++ swift/TailscaleKit/StateBroadcaster.swift | 66 +++++++++++++++ swift/TailscaleKit/TailscaleError.swift | 18 ++-- swift/TailscaleKit/URLSession+Tailscale.swift | 10 +-- 15 files changed, 246 insertions(+), 123 deletions(-) create mode 100644 swift/TailscaleKit/PlatformShims.swift create mode 100644 swift/TailscaleKit/StateBroadcaster.swift diff --git a/.devcontainer/swift/devcontainer.json b/.devcontainer/swift/devcontainer.json index d12b538..02a2d97 100644 --- a/.devcontainer/swift/devcontainer.json +++ b/.devcontainer/swift/devcontainer.json @@ -11,7 +11,12 @@ "extensions": [ "swiftlang.swift-vscode", "golang.go" - ] + ], + "settings": { + "[swift]": { + "editor.formatOnSave": false + } + } } }, "postCreateCommand": "go version && swift --version" diff --git a/swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloViewModel.swift b/swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloViewModel.swift index d23ab18..7e08988 100644 --- a/swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloViewModel.swift +++ b/swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloViewModel.swift @@ -1,13 +1,11 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - import SwiftUI -@preconcurrency import Combine import TailscaleKit @Observable -class HelloViewModel: @unchecked Sendable { +class HelloViewModel: @unchecked Sendable { var message: String = "Ready to phone home!" var peerCountMessage = "Waiting for peers...." var stateMessage = "Waiting for state...." @@ -75,7 +73,7 @@ class HelloViewModel: @unchecked Sendable { @MainActor func setMessage(_ message: String) { - self.message = message + self.message = message } func runRequest(_ dialer: Dialer) { diff --git a/swift/TailscaleKit/IncomingConnection.swift b/swift/TailscaleKit/IncomingConnection.swift index a67766a..668baad 100644 --- a/swift/TailscaleKit/IncomingConnection.swift +++ b/swift/TailscaleKit/IncomingConnection.swift @@ -1,11 +1,18 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import Combine -import Foundation +import FoundationEssentials + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif /// IncomingConnection is use to read incoming message from an inbound -/// connection. IncomingConnections are not instantiated directly, +/// connection. IncomingConnections are not instantiated directly, /// they are returned by Listener.accept public actor IncomingConnection { private let logger: LogSink? @@ -14,41 +21,39 @@ public actor IncomingConnection { public let remoteAddress: String? - @Published var _state: ConnectionState = .idle + private var stateBroadcaster = StateBroadcaster(.idle) - public func state() -> any AsyncSequence { - $_state - .removeDuplicates() - .eraseToAnyPublisher() - .values + public func state() -> some AsyncSequence { + stateBroadcaster.subscribe() } init(conn: TailscaleConnection, remoteAddress: String?, logger: LogSink? = nil) async { self.logger = logger self.conn = conn - _state = .connected + stateBroadcaster.set(.connected) self.remoteAddress = remoteAddress reader = SocketReader(conn: conn) } deinit { if conn != 0 { - Darwin.close(conn) + _ = System.close(conn) } } public func close() { if conn != 0 { - Darwin.close(conn) + _ = System.close(conn) conn = 0 } - _state = .closed + stateBroadcaster.set(.closed) + stateBroadcaster.finish() } /// Returns up to size bytes from the connection. Blocks until /// data is available public func receive(maximumLength: Int = 4096, timeout: Int32) async throws -> Data { - guard _state == .connected else { + guard stateBroadcaster.value == .connected else { throw TailscaleError.connectionClosed } @@ -56,8 +61,8 @@ public actor IncomingConnection { } /// Reads a complete message from the connection - public func receiveMessage( timeout: Int32) async throws -> Data { - guard _state == .connected else { + public func receiveMessage(timeout: Int32) async throws -> Data { + guard stateBroadcaster.value == .connected else { throw TailscaleError.connectionClosed } @@ -71,7 +76,7 @@ private actor SocketReader { // of a single packet private static let maxBufferSize = 2048 private let conn: TailscaleConnection - private var buffer = [UInt8](repeating:0, count: maxBufferSize) + private var buffer = [UInt8](repeating: 0, count: maxBufferSize) init(conn: TailscaleConnection) { self.conn = conn @@ -85,9 +90,8 @@ private actor SocketReader { } let bytesToRead = min(len, Self.maxBufferSize) - var bytesRead = 0 - buffer.withUnsafeMutableBufferPointer { ptr in - bytesRead = Darwin.read(conn, ptr.baseAddress, bytesToRead) + let bytesRead = buffer.withUnsafeMutableBufferPointer { ptr in + System.read(conn, ptr.baseAddress, bytesToRead) } if bytesRead < 0 { @@ -108,4 +112,3 @@ private actor SocketReader { return data } } - diff --git a/swift/TailscaleKit/Listener.swift b/swift/TailscaleKit/Listener.swift index 323ef9e..f312abe 100644 --- a/swift/TailscaleKit/Listener.swift +++ b/swift/TailscaleKit/Listener.swift @@ -1,29 +1,26 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +import Foundation + #if canImport(CTailscale) -import CTailscale + import CTailscale #endif -import Combine -import Foundation /// A Listener is used to await incoming connections from another /// Tailnet node. public actor Listener { - private var tailscale: TailscaleHandle + private var tailscale: TailscaleHandle private var listener: TailscaleListener = 0 private var proto: NetProtocol private var address: String private let logger: LogSink? - @Published var _state: ListenerState = .idle + private var stateBroadcaster = StateBroadcaster(.idle) - public func state() -> any AsyncSequence { - $_state - .removeDuplicates() - .eraseToAnyPublisher() - .values + public func state() -> some AsyncSequence { + stateBroadcaster.subscribe() } /// Initializes and readies a new listener @@ -44,18 +41,18 @@ public actor Listener { let res = tailscale_listen(tailscale, proto.rawValue, address, &listener) guard res == 0 else { - _state = .failed + stateBroadcaster.set(.failed) let msg = tailscale.getErrorMessage() let err = TailscaleError.fromPosixErrCode(res, msg) logger?.log("Listener failed to initialize: \(msg) (\(err.localizedDescription))") throw err } - _state = .listening + stateBroadcaster.set(.listening) } deinit { if listener != 0 { - Darwin.close(listener) + _ = System.close(listener) } } @@ -63,10 +60,11 @@ public actor Listener { /// Listeners will be closed automatically on deallocation public func close() { if listener != 0 { - Darwin.close(listener) + _ = System.close(listener) listener = 0 } - _state = .closed + stateBroadcaster.set(.closed) + stateBroadcaster.finish() } /// Blocks and awaits a new incoming connection @@ -109,7 +107,7 @@ public actor Listener { /// We extract the remove address here for utility so you know /// who's calling, so you can dial back. var remoteAddress: String? - var buffer = [Int8](repeating:0, count: 64) + var buffer = [Int8](repeating: 0, count: 64) buffer.withUnsafeMutableBufferPointer { buf in let err = tailscale_getremoteaddr(listener, connfd, buf.baseAddress, 64) if err == 0 { diff --git a/swift/TailscaleKit/LocalAPI/GoTime.swift b/swift/TailscaleKit/LocalAPI/GoTime.swift index 211277a..c548fac 100644 --- a/swift/TailscaleKit/LocalAPI/GoTime.swift +++ b/swift/TailscaleKit/LocalAPI/GoTime.swift @@ -21,13 +21,13 @@ extension String { let iso8601DateFormatter = { ISO8601DateFormatter() }() - + let iso8601DateFormatterFractionalSeconds: ISO8601DateFormatter = { let dateFormatter = ISO8601DateFormatter() dateFormatter.formatOptions.insert(.withFractionalSeconds) return dateFormatter }() - + // Fractional seconds are optional in RFC3339 as generated by Go/control, // but Foundation date formatters do not parse dates with and without // fractional seconds without specifying the option to look for them. diff --git a/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift b/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift index 7133505..4b68377 100644 --- a/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift +++ b/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift @@ -1,7 +1,11 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import Foundation +import FoundationEssentials + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif let kLocalAPIPath = "/localapi/v0/" @@ -38,7 +42,7 @@ public actor LocalAPIClient { /// The local node that will be handling our localAPI requests. let node: TailscaleNode - + let logger: LogSink? public init(localNode: TailscaleNode, logger: LogSink?) { @@ -46,7 +50,6 @@ public actor LocalAPIClient { self.logger = logger } - // MARK: - IPN Bus /// watchIPNBus subscribes to the IPN notification bus. This is the primary mechanism that should be implemented for observing @@ -177,15 +180,15 @@ public actor LocalAPIClient { resultTransformer: jsonDecodeTransformer(IpnLocal.LoginProfile.self)) switch result { - case .success(let result): return result - case .failure(let error): throw error + case .success(let result): return result + case .failure(let error): throw error } } public func addProfile() async throws { let error = await doSimpleAPIRequest( endpoint: .profiles, - path: "", // Important, we need the trailing / + path: "", // Important, we need the trailing / method: .PUT, resultTransformer: errorTransformer) @@ -196,7 +199,7 @@ public actor LocalAPIClient { } public func switchProfile(profileID: String) async throws { - let error = await doSimpleAPIRequest( + let error = await doSimpleAPIRequest( endpoint: .profiles, path: profileID, method: .POST, @@ -227,14 +230,14 @@ public actor LocalAPIClient { /// /// The majority of the information this returns can be observed using watchIPNBus. public func backendStatus() async throws -> IpnState.Status { - let result = await doSimpleAPIRequest( + let result = await doSimpleAPIRequest( endpoint: .status, method: .GET, resultTransformer: jsonDecodeTransformer(IpnState.Status.self)) switch result { - case .success(let result): return result - case .failure(let error): throw error + case .success(let result): return result + case .failure(let error): throw error } } @@ -246,7 +249,12 @@ public actor LocalAPIClient { headers: [String: String]? = nil, params: [URLQueryItem]? = nil) async throws -> (URLRequest, URLSessionConfiguration) { + #if canImport(Network) let (sessionConfig, loopbackConfig) = try await URLSessionConfiguration.tailscaleSession(node) + #else + let sessionConfig = URLSessionConfiguration.default + let loopbackConfig = try await node.loopback() + #endif var endpointPath = endpoint.rawValue if let path { @@ -337,39 +345,39 @@ public actor LocalAPIClient { timeoutInterval: TimeInterval = 60, resultTransformer: @escaping (_ result: Result) -> T) async -> T { - var request: URLRequest - var sessionConfig: URLSessionConfiguration - do { - (request, sessionConfig) = try await self.basicAuthURLRequest(endpoint: endpoint, - path: path, - method: method, - headers: headers, - params: params) + var request: URLRequest + var sessionConfig: URLSessionConfiguration + do { + (request, sessionConfig) = try await self.basicAuthURLRequest(endpoint: endpoint, + path: path, + method: method, + headers: headers, + params: params) - } catch { - return resultTransformer(.failure(error)) - } + } catch { + return resultTransformer(.failure(error)) + } - if let body { - request.httpBody = body - } + if let body { + request.httpBody = body + } - request.timeoutInterval = timeoutInterval - - do { - let session = URLSession(configuration: sessionConfig) - let (data, response) = try await session.data(for: request) - switch self.parseAPIResponse(data: data, response: response, error: nil) { - case .success(let data): - return resultTransformer(.success(data)) - case .failure(let error): - logger?.log("LocalAPI request to \(path ?? "") failed with \(error)") - return resultTransformer(.failure(error)) - } - } catch { + request.timeoutInterval = timeoutInterval + + do { + let session = URLSession(configuration: sessionConfig) + let (data, response) = try await session.data(for: request) + switch self.parseAPIResponse(data: data, response: response, error: nil) { + case .success(let data): + return resultTransformer(.success(data)) + case .failure(let error): + logger?.log("LocalAPI request to \(path ?? "") failed with \(error)") return resultTransformer(.failure(error)) } + } catch { + return resultTransformer(.failure(error)) } + } // MARK: - Transformers diff --git a/swift/TailscaleKit/LocalAPI/MessageProcessor.swift b/swift/TailscaleKit/LocalAPI/MessageProcessor.swift index a851378..8eb2d54 100644 --- a/swift/TailscaleKit/LocalAPI/MessageProcessor.swift +++ b/swift/TailscaleKit/LocalAPI/MessageProcessor.swift @@ -3,19 +3,22 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + let kJsonNewline = UInt8(ascii: "\n") /// The polling interval for the message queue -let kProcessorQueuePollInterval: UInt64 = 100_000_000 // Nanos +let kProcessorQueuePollInterval: UInt64 = 100_000_000 // Nanos /// A MessageConsumer consumes incoming messages from the IPNBus and handles any /// potential errors. public protocol MessageConsumer: Actor { - func notify(_ notify: Ipn.Notify) - func error(_ error: Error) + func notify(_ notify: Ipn.Notify) + func error(_ error: Error) } - /// MessageProcessor pulls queued Decodable messages from a MessageReader, deserializes them /// and forwards the deserialized objects and any errors to the consumer. public class MessageProcessor: @unchecked Sendable { @@ -24,7 +27,6 @@ public class MessageProcessor: @unchecked Sendable { let workQueue = OperationQueue() var logger: LogSink? - // A long running task to poll the queue var pollTask: Task? @@ -56,7 +58,7 @@ public class MessageProcessor: @unchecked Sendable { } } - public func cancel() { + public func cancel() { pollTask?.cancel() } diff --git a/swift/TailscaleKit/LocalAPI/MessageReader.swift b/swift/TailscaleKit/LocalAPI/MessageReader.swift index 9dd8ff9..91c9f47 100644 --- a/swift/TailscaleKit/LocalAPI/MessageReader.swift +++ b/swift/TailscaleKit/LocalAPI/MessageReader.swift @@ -3,6 +3,10 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + enum MessageQueueError: Error { case queueCongested } diff --git a/swift/TailscaleKit/LocalAPI/Types.swift b/swift/TailscaleKit/LocalAPI/Types.swift index 76132e6..a843439 100644 --- a/swift/TailscaleKit/LocalAPI/Types.swift +++ b/swift/TailscaleKit/LocalAPI/Types.swift @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import Foundation +import FoundationEssentials public struct Empty: Sendable { public struct Message: Codable, Sendable {} @@ -248,7 +248,7 @@ public struct IpnState: Sendable { public var Expired: Bool? } - public struct PeerStatusLite: Codable, Sendable, Equatable { + public struct PeerStatusLite: Codable, Sendable, Equatable { public var RxBytes: Int64 public var TxBytes: Int64 public var LastHandshake: Time.Time @@ -258,16 +258,16 @@ public struct IpnState: Sendable { public struct Status: Codable, Sendable { enum CodingKeys: String, CodingKey { case Version, - BackendState, - AuthURL, - TailscaleIPs, - ExitNodeStatus, - Health, - CurrentTailnet, - CertDomains, - Peer, - User, - ClientVersion + BackendState, + AuthURL, + TailscaleIPs, + ExitNodeStatus, + Health, + CurrentTailnet, + CertDomains, + Peer, + User, + ClientVersion case SelfStatus = "Self" } @@ -310,7 +310,7 @@ public struct Netmap: Sendable { public var NodeKey: Key.NodePublic public var Peers: [Tailcfg.Node]? public var Domain: String - public var UserProfiles: [String: Tailcfg.UserProfile] // Keys are tailcfg.UserIDs thet get stringified + public var UserProfiles: [String: Tailcfg.UserProfile] // Keys are tailcfg.UserIDs thet get stringified public var DNS: Tailcfg.DNSConfig? public func currentUserProfile() -> Tailcfg.UserProfile? { @@ -400,7 +400,7 @@ public struct Tailcfg: Sendable { } if let expiryDate = KeyExpiry?.iso8601Date() { - return (expiryDate as NSDate).earlierDate(Date()) == expiryDate && !KeyDoesNotExpire + return expiryDate < Date() } return false @@ -496,4 +496,3 @@ struct GoError: Codable, Sendable, LocalizedError { return Error } } - diff --git a/swift/TailscaleKit/LogSink.swift b/swift/TailscaleKit/LogSink.swift index 25fe60d..9ea3bbe 100644 --- a/swift/TailscaleKit/LogSink.swift +++ b/swift/TailscaleKit/LogSink.swift @@ -3,6 +3,14 @@ import Foundation +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + /// A generic interface for sinking log messages from the Swift wrapper /// and go public protocol LogSink: Sendable { diff --git a/swift/TailscaleKit/OutgoingConnection.swift b/swift/TailscaleKit/OutgoingConnection.swift index 678807c..a233f07 100644 --- a/swift/TailscaleKit/OutgoingConnection.swift +++ b/swift/TailscaleKit/OutgoingConnection.swift @@ -1,14 +1,14 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +import Foundation + #if canImport(CTailscale) -import CTailscale + import CTailscale #endif -import Foundation -import Combine /// ConnectionState indicates the state of individual TSConnection instances -public enum ConnectionState { +public enum ConnectionState: Sendable { case idle ///< Reads and writes are not possible. Connections will transition to connected automatically case connected ///< Connected and ready to read/write case closed ///< Closed and ready to be disposed of. Closed connections cannot be reconnected. @@ -16,7 +16,7 @@ public enum ConnectionState { } /// ListenerState indicates the state of individual TSListener instances -public enum ListenerState { +public enum ListenerState: Sendable { case idle ///< Waiting. case listening ///< Listening case closed ///< Closed and ready to be disposed of. @@ -68,7 +68,7 @@ public actor OutgoingConnection { /// @See tailscale_dial in Tailscale.h /// /// @throws TailscaleError on failure - public func connect() async throws { + public func connect() async throws { let res = tailscale_dial(tailscale, proto.rawValue, address, &conn) guard res == 0 else { @@ -81,7 +81,7 @@ public actor OutgoingConnection { deinit { if conn != 0 { - Darwin.close(conn) + _ = System.close(conn) } } @@ -90,7 +90,7 @@ public actor OutgoingConnection { /// state to .closed public func close() { if conn != 0 { - Darwin.close(conn) + _ = System.close(conn) conn = 0 } state = .closed @@ -104,7 +104,7 @@ public actor OutgoingConnection { throw TailscaleError.connectionClosed } - let bytesWritten = Darwin.write(conn, data.withUnsafeBytes { $0.baseAddress! }, data.count) + let bytesWritten = System.write(conn, data.withUnsafeBytes { $0.baseAddress! }, data.count) if bytesWritten != data.count { throw TailscaleError.shortWrite diff --git a/swift/TailscaleKit/PlatformShims.swift b/swift/TailscaleKit/PlatformShims.swift new file mode 100644 index 0000000..67aa56c --- /dev/null +++ b/swift/TailscaleKit/PlatformShims.swift @@ -0,0 +1,28 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + +// Namespace for the platform libc calls. Actor types in this module declare +// their own close()/read() methods, which shadow the global libc functions +enum System { + #if canImport(Darwin) + static let close = Darwin.close + static let read = Darwin.read + static let write = Darwin.write + #elseif canImport(Glibc) + static let close = Glibc.close + static let read = Glibc.read + static let write = Glibc.write + #elseif canImport(Musl) + static let close = Musl.close + static let read = Musl.read + static let write = Musl.write + #endif +} diff --git a/swift/TailscaleKit/StateBroadcaster.swift b/swift/TailscaleKit/StateBroadcaster.swift new file mode 100644 index 0000000..5611f77 --- /dev/null +++ b/swift/TailscaleKit/StateBroadcaster.swift @@ -0,0 +1,66 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +/// Holds a current value and broadcasts changes to any number of independent +/// AsyncStream subscribers. New subscribers immediately receive the current +/// value (matching CurrentValueSubject semantics). Duplicate sets are +/// suppressed at the source (matching `removeDuplicates()`). +struct StateBroadcaster: ~Copyable { + private(set) var value: Value + private var continuations: [Int: AsyncStream.Continuation] = [:] + private var nextID = 0 + + init(_ initial: Value) { + self.value = initial + } + + deinit { + // Dropping an unfinished continuation finishes its stream anyway; + // this just makes that behavior explicit rather than incidental. + for continuation in continuations.values { + continuation.finish() + } + } + + /// Sets a new value and notifies all live subscribers. + /// No-op if the value is unchanged (removeDuplicates behavior). + mutating func set(_ newValue: Value) { + guard newValue != value else { return } + value = newValue + for (id, continuation) in continuations { + // Lazily prune subscribers whose iteration was cancelled. + if case .terminated = continuation.yield(newValue) { + continuations[id] = nil + } + } + } + + /// Returns a new stream that yields the current value immediately, + /// then every subsequent (distinct) value. + mutating func subscribe() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream( + of: Value.self, + bufferingPolicy: .bufferingNewest(1) + ) + continuation.yield(value) + continuations[nextID] = continuation + nextID += 1 + return stream + } + + /// Ends all subscriber streams. Call this when the owning object + /// reaches a terminal state (e.g. after yielding .closed). + mutating func finish() { + for continuation in continuations.values { + continuation.finish() + } + continuations.removeAll() + } +} + +/// StateBroadcaster must be owned by exactly one actor; it must never cross an +/// isolation boundary +/// TODO: Once the minimum toolchain is Swift 6.4, delete this extension and +/// add `~Sendable` to the type declaration instead (SE-0518; the swap is source-compatible). +@available(*, unavailable) +extension StateBroadcaster: Sendable {} diff --git a/swift/TailscaleKit/TailscaleError.swift b/swift/TailscaleKit/TailscaleError.swift index 04f9e5c..95ef717 100644 --- a/swift/TailscaleKit/TailscaleError.swift +++ b/swift/TailscaleKit/TailscaleError.swift @@ -1,10 +1,19 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +import FoundationEssentials + #if canImport(CTailscale) -import CTailscale + import CTailscale +#endif + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl #endif -import Foundation public enum TailscaleError: Error { case badInterfaceHandle ///< The tailscale handle is bad. @@ -26,14 +35,13 @@ public enum TailscaleError: Error { if code == -1 { return .internalError(details) } - if let code = POSIXErrorCode(rawValue: code){ - return .posixError( POSIXError(code)) + if let code = POSIXErrorCode(rawValue: code) { + return .posixError(POSIXError(code)) } return unknownPosixError(code, details) } } - extension TailscaleHandle { static let kMaxErrorMessageLength: Int = 256 diff --git a/swift/TailscaleKit/URLSession+Tailscale.swift b/swift/TailscaleKit/URLSession+Tailscale.swift index b2a2806..ab2d23c 100644 --- a/swift/TailscaleKit/URLSession+Tailscale.swift +++ b/swift/TailscaleKit/URLSession+Tailscale.swift @@ -1,15 +1,10 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -#if os(iOS) -import UIKit -#endif - -import Foundation +#if canImport(Network) import Network -public extension URLSessionConfiguration { - +extension URLSessionConfiguration { /// Adds the a ProxyConfiguration to a URLSessionConfiguration to /// proxy all requests through the given TailscaleNode. /// @@ -40,3 +35,4 @@ public extension URLSessionConfiguration { return (session, config) } } +#endif \ No newline at end of file From 24cd8ae99bc3487bec271f31828bea5e463ad447 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Sat, 11 Jul 2026 10:11:22 -0500 Subject: [PATCH 04/10] artifact bundle and swift testing --- .github/workflows/swift.yml | 22 +++- Package.swift | 18 ++- .../Examples/TailscaleKitCLI/Package.resolved | 15 +++ swift/Examples/TailscaleKitCLI/Package.swift | 21 ++++ swift/Examples/TailscaleKitCLI/README.md | 72 +++++++++++ .../Sources/tsdemo/Listen.swift | 54 +++++++++ .../TailscaleKitCLI/Sources/tsdemo/Send.swift | 55 +++++++++ .../Sources/tsdemo/Status.swift | 36 ++++++ .../Sources/tsdemo/TSDemo.swift | 108 +++++++++++++++++ swift/Makefile | 14 ++- swift/Sources/CTailscale/module.modulemap | 5 - swift/Sources/CTailscale/shim.h | 1 - swift/TailscaleKit/IncomingConnection.swift | 4 + .../LocalAPI/LocalAPIClient.swift | 4 + swift/TailscaleKit/LocalAPI/Types.swift | 4 + swift/TailscaleKit/LogSink.swift | 4 + swift/TailscaleKit/TailscaleError.swift | 4 + swift/TailscaleKit/TailscaleNode.swift | 2 +- .../TailscaleKitTests.swift | 114 +++++++++--------- swift/script/build-artifactbundle.sh | 102 ++++++++++++++++ swift/script/fix-tstestcontrol-archive.sh | 55 +++++++++ 21 files changed, 646 insertions(+), 68 deletions(-) create mode 100644 swift/Examples/TailscaleKitCLI/Package.resolved create mode 100644 swift/Examples/TailscaleKitCLI/Package.swift create mode 100644 swift/Examples/TailscaleKitCLI/README.md create mode 100644 swift/Examples/TailscaleKitCLI/Sources/tsdemo/Listen.swift create mode 100644 swift/Examples/TailscaleKitCLI/Sources/tsdemo/Send.swift create mode 100644 swift/Examples/TailscaleKitCLI/Sources/tsdemo/Status.swift create mode 100644 swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift delete mode 100644 swift/Sources/CTailscale/module.modulemap delete mode 100644 swift/Sources/CTailscale/shim.h create mode 100755 swift/script/build-artifactbundle.sh create mode 100755 swift/script/fix-tstestcontrol-archive.sh diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 0a3afb8..f983363 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -3,7 +3,7 @@ name: swift on: [push] jobs: - build: + build-macos: runs-on: macos-15 steps: @@ -22,3 +22,23 @@ jobs: - name: macos run: cd swift && make macos + + build-linux: + + runs-on: linux + container: swift:6.3 + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25.5" + + - name: Install cross toolchains + run: | + apt-get update + apt-get install -y --no-install-recommends build-essential gcc-x86-64-linux-gnu gcc-aarch64-linux-gnu + + - name: Test + run: cd swift && make test-spm diff --git a/Package.swift b/Package.swift index d4398c9..16b889b 100644 --- a/Package.swift +++ b/Package.swift @@ -3,25 +3,35 @@ import PackageDescription let package = Package( name: "TailscaleKit", + // NOTE: `swift build`/`swift test` only work on Linux today, regardless of this + // declaration: CTailscale's artifactbundle only ships Linux triples (see + // swift/script/build-artifactbundle.sh). macOS/iOS consumers use + // swift/TailscaleKit.xcodeproj and the .xcframework built by swift/Makefile's + // `macos`/`ios-fat` targets instead. platforms: [.macOS(.v15), .iOS(.v18)], products: [ .library(name: "TailscaleKit", targets: ["TailscaleKit"]) ], targets: [ - .systemLibrary(name: "CTailscale", path: "swift/Sources/CTailscale"), + // Built by swift/script/build-artifactbundle.sh, which cross-compiles + // libtailscale.a per Linux triple (no Go toolchain needed to consume it). + .binaryTarget(name: "CTailscale", path: "swift/build/TailscaleKit.artifactbundle"), .systemLibrary(name: "CTstestControl", path: "swift/Sources/CTstestControl"), .target( name: "TailscaleKit", dependencies: ["CTailscale"], path: "swift/TailscaleKit", - exclude: ["TailscaleKit.h"], - linkerSettings: [.unsafeFlags(["-L", "."])] + exclude: ["TailscaleKit.h"] ), .testTarget( name: "TailscaleKitTests", dependencies: ["TailscaleKit", "CTstestControl"], path: "swift/TailscaleKitXCTests", - linkerSettings: [.unsafeFlags(["-L", "tstestcontrol"])] + // Links against swift/build/libtstestcontrol.a, a copy of + // tstestcontrol/libtstestcontrol.a with its cgo runtime glue + // symbols renamed to avoid colliding with libtailscale.a's copy. + // Run swift/script/fix-tstestcontrol-archive.sh to (re)generate it. + linkerSettings: [.unsafeFlags(["-L", "swift/build"])] ), ] ) diff --git a/swift/Examples/TailscaleKitCLI/Package.resolved b/swift/Examples/TailscaleKitCLI/Package.resolved new file mode 100644 index 0000000..5b6b054 --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e0bd85dc1927ca0cae966d816fc15ac0f9a26d11de398ae44526534ec15c2132", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + } + ], + "version" : 3 +} diff --git a/swift/Examples/TailscaleKitCLI/Package.swift b/swift/Examples/TailscaleKitCLI/Package.swift new file mode 100644 index 0000000..5bf2c4b --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version:6.3 +import PackageDescription + +let package = Package( + name: "TailscaleKitCLI", + // Linux only: this depends on the root package's CTailscale binaryTarget, which + // only ships Linux triples today. See ../../../Package.swift. + dependencies: [ + .package(name: "TailscaleKit", path: "../../.."), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.0"), + ], + targets: [ + .executableTarget( + name: "tsdemo", + dependencies: [ + .product(name: "TailscaleKit", package: "TailscaleKit"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ) + ] +) diff --git a/swift/Examples/TailscaleKitCLI/README.md b/swift/Examples/TailscaleKitCLI/README.md new file mode 100644 index 0000000..424194b --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/README.md @@ -0,0 +1,72 @@ +# TailscaleKitCLI + +A minimal SwiftPM executable, built with +[swift-argument-parser](https://github.com/apple/swift-argument-parser), that joins your +tailnet from a Swift command-line tool using TailscaleKit. Needs no Xcode project. + +Unlike `TailscaleKitHello` (an Xcode app that links a prebuilt `.xcframework`), this +package depends directly on the root `Package.swift`, exercising the same SwiftPM path +covered by `swift test`/`swift test-spm`. + +**Linux only for now.** The root package's `CTailscale` binaryTarget +(`swift/build/TailscaleKit.artifactbundle`) currently only ships `x86_64`/`aarch64` Linux +variants, so `swift build`/`swift run` here only work on Linux. On macOS/iOS, use +`TailscaleKitHello` (Xcode + the `.xcframework` built by `swift/Makefile`'s +`macos`/`ios-fat` targets) instead. + +## Setup + +From `/swift`, build the `libtailscale.a` artifact bundle this package links against +(this is also done automatically by `make test-spm`): + +``` +$ ./script/build-artifactbundle.sh +``` + +Generate a reusable auth key at +https://login.tailscale.com/admin/settings/keys and export it: + +``` +$ export TS_AUTHKEY=tskey-auth-... +``` + +## Usage + +Each subcommand brings up its own in-process tsnet node (its own device on your tailnet), +so run `status`/`listen`/`send` as separate invocations, or from separate machines/devices +on the same tailnet entirely. + +``` +$ swift run tsdemo status +``` +Brings up a node and prints the tailnet's backend state and peer list. + +``` +$ swift run tsdemo listen --port 8081 +``` +Brings up a node, listens on port 8081, and prints every message it receives from peers. + +``` +$ swift run tsdemo send --to 100.x.y.z:8081 "hello" +``` +Brings up a separate node and sends a one-shot message to whatever is listening at +`--to` (an IP or MagicDNS name from `tsdemo status`/`tsdemo listen`'s output). + +Pass `--hostname`/`--state-dir` to give a node a stable identity across runs (otherwise +each run gets a random hostname and a throwaway state directory, and defaults to +`--ephemeral`, so it disappears from the admin console when the process exits). + +## What this is showing off + +- `TailscaleNode` — bringing up an in-process node against the control plane, and reading + back its tailnet addresses. +- `Listener`/`IncomingConnection` and `OutgoingConnection` — the raw one-directional + send/receive primitives TailscaleKit exposes for talking directly to other devices on + the tailnet, with no port forwarding, VPN config, or public exposure required. +- `LocalAPIClient` — querying the node's own local API for backend/peer status, the same + API `tailscale status` itself uses. + +`tsdemo send` sleeps briefly after writing before it closes the connection and tears the +node down — tsnet's virtual network stack may still be relaying the write (e.g. through +DERP) when `send()` returns, and a `--ephemeral` node exiting immediately can beat that +flush. diff --git a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Listen.swift b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Listen.swift new file mode 100644 index 0000000..3c4cd78 --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Listen.swift @@ -0,0 +1,54 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import ArgumentParser +import TailscaleKit + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +extension TSDemo { + struct Listen: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Bring up a tsnet node and print every message received from peers on the tailnet." + ) + + @OptionGroup var node: NodeOptions + + @Option(help: "Port to listen on.") + var port: Int = 8081 + + @Option(help: "IP protocol to listen with: tcp or udp.") + var proto: String = "tcp" + + func run() async throws { + guard let ipProto = NetProtocol(rawValue: proto) else { + throw ValidationError("--proto must be tcp or udp") + } + + try await withTailscaleNode(node) { ts in + guard let handle = await ts.tailscale else { + throw ValidationError("Node has no handle") + } + + let listener = try await Listener( + tailscale: handle, proto: ipProto, address: ":\(port)", logger: node.logger) + + print( + "Listening on \(proto)/\(port). From another node, run: tsdemo send --to :\(port) \"hello\". Ctrl-C to stop." + ) + + while true { + let inbound = try await listener.accept(timeout: 300) + let data = try await inbound.receiveMessage(timeout: 5000) + let text = String(data: data, encoding: .utf8) ?? "<\(data.count) bytes>" + let remote = await inbound.remoteAddress ?? "unknown" + print("[\(remote)] \(text)") + } + } + } + } +} diff --git a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Send.swift b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Send.swift new file mode 100644 index 0000000..aeb53eb --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Send.swift @@ -0,0 +1,55 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import ArgumentParser +import TailscaleKit + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +extension TSDemo { + struct Send: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Bring up a tsnet node and send a one-shot message to a peer running \"tsdemo listen\"." + ) + + @OptionGroup var node: NodeOptions + + @Argument(help: "Message to send.") + var message: String + + @Option(help: "Destination on the tailnet, e.g. 100.x.y.z:8081 or a MagicDNS name:8081.") + var to: String + + @Option(help: "IP protocol to dial with: tcp or udp.") + var proto: String = "tcp" + + func run() async throws { + guard let ipProto = NetProtocol(rawValue: proto) else { + throw ValidationError("--proto must be tcp or udp") + } + + try await withTailscaleNode(node) { ts in + guard let handle = await ts.tailscale else { + throw ValidationError("Node has no handle") + } + + let outgoing = try await OutgoingConnection( + tailscale: handle, to: to, proto: ipProto, logger: node.logger) + try await outgoing.connect() + try await outgoing.send(Data(message.utf8)) + + // Give tsnet's virtual network stack a moment to actually flush the + // write onto the wire (it may still be relaying through DERP) before + // we tear the connection and node down. + try await Task.sleep(for: .seconds(2)) + await outgoing.close() + + print("Sent \(message.utf8.count) bytes to \(to).") + } + } + } +} diff --git a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Status.swift b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Status.swift new file mode 100644 index 0000000..459c64a --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Status.swift @@ -0,0 +1,36 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import ArgumentParser +import TailscaleKit + +extension TSDemo { + struct Status: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Bring up a tsnet node and print its tailnet identity, peers, and backend status." + ) + + @OptionGroup var node: NodeOptions + + func run() async throws { + try await withTailscaleNode(node) { ts in + let api = LocalAPIClient(localNode: ts, logger: node.logger) + let status = try await api.backendStatus() + + print("Backend state: \(status.BackendState)") + + let peers = (status.Peer?.values).map(Array.init)?.sorted { $0.HostName < $1.HostName } ?? [] + if peers.isEmpty { + print("No peers visible on this tailnet yet.") + } else { + print("Peers:") + for peer in peers { + let ip = peer.TailscaleIPs?.first ?? "?" + let mark = peer.Online ? "online " : "offline" + print(" [\(mark)] \(peer.HostName) \(ip) \(peer.DNSName)") + } + } + } + } + } +} diff --git a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift new file mode 100644 index 0000000..daa9e5b --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift @@ -0,0 +1,108 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import ArgumentParser +import TailscaleKit + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +#if canImport(Darwin) +@preconcurrency import Darwin +#elseif canImport(Glibc) +@preconcurrency import Glibc +#endif + +@main +struct TSDemo: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tsdemo", + abstract: "A minimal example of joining a tailnet from a Swift command-line tool using TailscaleKit.", + discussion: """ + Each invocation brings up its own in-process tsnet node, so "tsdemo status" and + "tsdemo listen" are different nodes on your tailnet even if you run them back to back. + + All subcommands need a Tailscale auth key. Generate a reusable one at + https://login.tailscale.com/admin/settings/keys and pass it via --auth-key or the + TS_AUTHKEY environment variable. + """, + subcommands: [Status.self, Listen.self, Send.self] + ) +} + +/// Options shared by every subcommand for bringing up a tsnet node. +struct NodeOptions: ParsableArguments { + @Option(help: "Hostname to advertise on the tailnet. Defaults to a random tsdemo- name.") + var hostname: String = "tsdemo-\(UUID().uuidString.prefix(8))" + + @Option(help: "Tailscale auth key. Defaults to the TS_AUTHKEY environment variable.") + var authKey: String? + + @Option(help: "Control plane URL.") + var controlURL: String = kDefaultControlURL + + @Option( + help: + "Directory used to persist this node's tsnet state. Defaults to a fresh temporary directory; pass a stable path to reuse the same node identity across runs." + ) + var stateDir: String? + + @Flag(inversion: .prefixedNo, help: "Register the node as ephemeral, so it disappears from the tailnet admin console when this process exits.") + var ephemeral: Bool = true + + @Flag(help: "Log tsnet's internal activity to stderr instead of discarding it.") + var verbose: Bool = false + + func makeConfiguration() throws -> Configuration { + guard let authKey = authKey ?? ProcessInfo.processInfo.environment["TS_AUTHKEY"] else { + throw ValidationError( + "Provide --auth-key or set TS_AUTHKEY. Generate one at https://login.tailscale.com/admin/settings/keys" + ) + } + + let dir = stateDir ?? FileManager.default.temporaryDirectory + .appendingPathComponent("tsdemo-\(hostname)").path + + return Configuration( + hostName: hostname, + path: dir, + authKey: authKey, + controlURL: controlURL, + ephemeral: ephemeral) + } + + var logger: LogSink { + verbose ? DefaultLogger() : BlackholeLogger() + } +} + +/// Brings a node up, waits for it to associate an address, and hands it to `body`. +/// The node is always torn down afterwards, even if `body` throws. +func withTailscaleNode( + _ options: NodeOptions, + _ body: (TailscaleNode) async throws -> T +) async throws -> T { + setvbuf(stdout, nil, _IOLBF, 0) + + let config = try options.makeConfiguration() + let node = try TailscaleNode(config: config, logger: options.logger) + + print("Bringing up \"\(config.hostName)\", waiting to associate with the tailnet...") + try await node.up() + + let addrs = try await node.addrs() + let addrDescription = [addrs.ip4, addrs.ip6].compactMap { $0 }.joined(separator: ", ") + print("\"\(config.hostName)\" is up at \(addrDescription)") + + do { + let result = try await body(node) + try await node.close() + return result + } catch { + try? await node.close() + throw error + } +} diff --git a/swift/Makefile b/swift/Makefile index 4071ff0..a076a04 100644 --- a/swift/Makefile +++ b/swift/Makefile @@ -61,9 +61,11 @@ ios-fat: ios-sim ios ## Builds TailscaleKit.xcframework to swift/build/Build/Pro .PHONY: test test: ## Run tests (macOS) - @echo + @echo @echo "::: Running tests for TailscaleKit :::" + rm -f ../tstestcontrol/libtstestcontrol.a cd ../tstestcontrol && make all + rm -f ../libtailscale.a ../libtailscale.h cd .. && make c-archive mkdir -p build xcodebuild build-for-testing -scheme TailscaleKitXCTests \ @@ -78,6 +80,16 @@ test: ## Run tests (macOS) -destination 'platform=macOS,arch=arm64' \ CODE_SIGNING_ALLOWED=NO +.PHONY: test-spm +test-spm: ## Run tests via Swift Package Manager (cross-platform, incl. Linux) + @echo + @echo "::: Running tests for TailscaleKit via swift test :::" + ./script/build-artifactbundle.sh + rm -f ../tstestcontrol/libtstestcontrol.a + cd ../tstestcontrol && make all + ./script/fix-tstestcontrol-archive.sh + cd .. && swift test + .PHONY: clean clean: ## Clean up build artifacts (including the libtailscale dependencies) cd .. && make clean diff --git a/swift/Sources/CTailscale/module.modulemap b/swift/Sources/CTailscale/module.modulemap deleted file mode 100644 index d7007ce..0000000 --- a/swift/Sources/CTailscale/module.modulemap +++ /dev/null @@ -1,5 +0,0 @@ -module CTailscale [system] { - header "shim.h" - link "tailscale" - export * -} diff --git a/swift/Sources/CTailscale/shim.h b/swift/Sources/CTailscale/shim.h deleted file mode 100644 index 7092316..0000000 --- a/swift/Sources/CTailscale/shim.h +++ /dev/null @@ -1 +0,0 @@ -#include "../../../tailscale.h" diff --git a/swift/TailscaleKit/IncomingConnection.swift b/swift/TailscaleKit/IncomingConnection.swift index 668baad..285cda1 100644 --- a/swift/TailscaleKit/IncomingConnection.swift +++ b/swift/TailscaleKit/IncomingConnection.swift @@ -1,7 +1,11 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(FoundationEssentials) import FoundationEssentials +#else +import Foundation +#endif #if canImport(Darwin) import Darwin diff --git a/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift b/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift index 4b68377..a99c4f3 100644 --- a/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift +++ b/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift @@ -1,7 +1,11 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(FoundationEssentials) import FoundationEssentials +#else +import Foundation +#endif #if canImport(FoundationNetworking) import FoundationNetworking diff --git a/swift/TailscaleKit/LocalAPI/Types.swift b/swift/TailscaleKit/LocalAPI/Types.swift index a843439..0ac9cae 100644 --- a/swift/TailscaleKit/LocalAPI/Types.swift +++ b/swift/TailscaleKit/LocalAPI/Types.swift @@ -1,7 +1,11 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(FoundationEssentials) import FoundationEssentials +#else +import Foundation +#endif public struct Empty: Sendable { public struct Message: Codable, Sendable {} diff --git a/swift/TailscaleKit/LogSink.swift b/swift/TailscaleKit/LogSink.swift index 9ea3bbe..f08b683 100644 --- a/swift/TailscaleKit/LogSink.swift +++ b/swift/TailscaleKit/LogSink.swift @@ -26,6 +26,8 @@ public protocol LogSink: Sendable { public struct DefaultLogger: LogSink { public var logFileHandle: Int32? = STDOUT_FILENO + public init() {} + public func log(_ message: String) { NSLog(message) } @@ -35,6 +37,8 @@ public struct DefaultLogger: LogSink { public struct BlackholeLogger: LogSink { public var logFileHandle: Int32? + public init() {} + public func log(_ message: String) { // Go back to the Shadow! } diff --git a/swift/TailscaleKit/TailscaleError.swift b/swift/TailscaleKit/TailscaleError.swift index 95ef717..a2a5cfe 100644 --- a/swift/TailscaleKit/TailscaleError.swift +++ b/swift/TailscaleKit/TailscaleError.swift @@ -1,7 +1,11 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(FoundationEssentials) import FoundationEssentials +#else +import Foundation +#endif #if canImport(CTailscale) import CTailscale diff --git a/swift/TailscaleKit/TailscaleNode.swift b/swift/TailscaleKit/TailscaleNode.swift index b54fa5f..9b751c5 100644 --- a/swift/TailscaleKit/TailscaleNode.swift +++ b/swift/TailscaleKit/TailscaleNode.swift @@ -32,7 +32,7 @@ public struct Configuration: Sendable { } /// The layer 3 protocol to use -public enum NetProtocol: String { +public enum NetProtocol: String, Sendable { case tcp = "tcp" case udp = "udp" } diff --git a/swift/TailscaleKitXCTests/TailscaleKitTests.swift b/swift/TailscaleKitXCTests/TailscaleKitTests.swift index 21affc5..5dc1e8c 100644 --- a/swift/TailscaleKitXCTests/TailscaleKitTests.swift +++ b/swift/TailscaleKitXCTests/TailscaleKitTests.swift @@ -1,41 +1,49 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif #if canImport(CTstestControl) import CTstestControl #endif -import XCTest +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing @testable import TailscaleKit -final class TailscaleKitTests: XCTestCase { - var controlURL: String = "" +@Suite +struct TailscaleKitTests: ~Copyable { + let controlURL: String - override func setUp() async throws { - if controlURL == "" { - var buf = [CChar](repeating:0, count: 1024) - let res = buf.withUnsafeMutableBufferPointer { ptr in - return run_control(ptr.baseAddress!, 1024) - } - let len = buf.firstIndex(where: { $0 == 0 }) ?? 0 - let str = buf[0.. Data { let inbound = try await listener.accept() await listener.close() // We can trust the backend here but this is slightly flaky since remoteAddress can be // nil for legitimate reasons. // let inboundIP = await inbound.remoteAddress - // XCTAssertEqual(inboundIP, writerAddr) - - let got = try await inbound.receiveMessage(timeout: 2) - print("got \(got)") - XCTAssert(got == want) + // #expect(inboundIP == writerAddr) - msgReceived.fulfill() + return try await inbound.receiveMessage(timeout: 2) } - - //Make sure somebody is listening - await fulfillment(of: [lisetnerUp], timeout: 5.0) + async let receivedMessage = receiveMessage() let outgoing = try await OutgoingConnection(tailscale: ts2Handle, to: "\(listenerAddr):8081", @@ -116,7 +117,9 @@ final class TailscaleKitTests: XCTestCase { print("sending \(want)") try await outgoing.send(want) - await fulfillment(of: [msgReceived], timeout: 5.0) + let got = try await receivedMessage + print("got \(got)") + #expect(got == want) print("closing conn") await outgoing.close() @@ -124,27 +127,27 @@ final class TailscaleKitTests: XCTestCase { try await ts1.down() try await ts2.down() } catch { - XCTFail("Init Failed: \(error)") + Issue.record("Init Failed: \(error)") } } - /// The hostCount here is load bearing. Each mock host must have a unique - /// path and hostname. - var hostCount = 0 + /// Each mock host must have a unique path and hostname; a UUID guarantees + /// that without needing mutable state on the suite. func mockConfig() -> Configuration { - let temp = getDocumentDirectoryPath().absoluteString + "tailscale\(hostCount)" - hostCount += 1 + let id = UUID().uuidString + let temp = getDocumentDirectoryPath().appending(path: "tailscale-\(id)").path return Configuration( - hostName: "testHost-\(hostCount)", + hostName: "testHost-\(id)", path: temp, authKey: nil, controlURL: controlURL, ephemeral: false) } - + #if canImport(Network) /// Tests that we can fetch a URL via our proxy (though this isn't a URL /// on the tailnet...) + @Test func testProxy() async throws { let config = mockConfig() let logger = BlackholeLogger() @@ -161,11 +164,13 @@ final class TailscaleKitTests: XCTestCase { let (data, _) = try await session.data(for: req) print("Got proxied data \(data.count)") - XCTAssert(data.count > 0) + #expect(data.count > 0) } } + #endif /// Tests that localAPI is functional + @Test func testStatus() async throws { let config = mockConfig() let logger = BlackholeLogger() @@ -177,17 +182,16 @@ final class TailscaleKitTests: XCTestCase { // The local node should be running and online let api = LocalAPIClient(localNode: ts1, logger: logger) let status = try await api.backendStatus() - XCTAssertEqual(status.BackendState, "Running") + #expect(status.BackendState == "Running") let peerStatus = status.SelfStatus! - XCTAssertTrue(peerStatus.Online) + #expect(peerStatus.Online) } catch { - XCTFail(error.localizedDescription) + Issue.record("\(error.localizedDescription)") } } } - func getDocumentDirectoryPath() -> URL { let arrayPaths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) let docDirectoryPath = arrayPaths[0] diff --git a/swift/script/build-artifactbundle.sh b/swift/script/build-artifactbundle.sh new file mode 100755 index 0000000..d1cc890 --- /dev/null +++ b/swift/script/build-artifactbundle.sh @@ -0,0 +1,102 @@ +#!/bin/sh +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +# Cross-compiles libtailscale.a for each supported Linux triple and packages +# the results into a Swift Package Manager static-library artifact bundle +# Package.swift consumes that bundle via a binaryTarget instead of requiring a +# prebuilt libtailscale.a to already sit at the repo root. +# +# Requires: go, and a cross C toolchain per target triple (on Debian/Ubuntu, +# `apt-get install gcc-x86-64-linux-gnu gcc-aarch64-linux-gnu` covers both; +# override CC_ to point at a different compiler). + +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +SWIFT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) +REPO_ROOT=$(cd "$SWIFT_DIR/.." && pwd) + +BUNDLE_NAME="TailscaleKit.artifactbundle" +BUNDLE_DIR="$SWIFT_DIR/build/$BUNDLE_NAME" +ARTIFACT_ID="libtailscale" +VERSION="1.0.0" + +rm -rf "$BUNDLE_DIR" +mkdir -p "$BUNDLE_DIR" + +# triple:GOARCH:default-cc +TARGETS=" +x86_64-unknown-linux-gnu:amd64:x86_64-linux-gnu-gcc +aarch64-unknown-linux-gnu:arm64:aarch64-linux-gnu-gcc +" + +variants_json="" + +for entry in $TARGETS; do + triple=$(echo "$entry" | cut -d: -f1) + goarch=$(echo "$entry" | cut -d: -f2) + default_cc=$(echo "$entry" | cut -d: -f3) + + cc_var="CC_$goarch" + cc=$(eval "echo \${$cc_var:-$default_cc}") + + if ! command -v "$cc" >/dev/null 2>&1; then + echo "error: cross compiler '$cc' not found for $triple (set $cc_var to override)" >&2 + exit 1 + fi + + echo "::: Building libtailscale.a for $triple (GOARCH=$goarch, CC=$cc) :::" + + variant_dir="$BUNDLE_DIR/$ARTIFACT_ID-$triple" + mkdir -p "$variant_dir/include" + + ( + cd "$REPO_ROOT" + CC="$cc" CGO_ENABLED=1 GOOS=linux GOARCH="$goarch" \ + go build -buildmode=c-archive -o "$variant_dir/libtailscale.a" . + ) + rm -f "$variant_dir/libtailscale.h" # go build also writes a header; we ship our own below + + cp "$REPO_ROOT/tailscale.h" "$variant_dir/include/tailscale.h" + cat > "$variant_dir/include/module.modulemap" <<'EOF' +module CTailscale [system] { + header "tailscale.h" + export * +} +EOF + + variant_json=$(cat < "$BUNDLE_DIR/info.json" <&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +cp "$SRC_ARCHIVE" "$WORK_DIR/libtstestcontrol.a" +cd "$WORK_DIR" +mkdir extract +cd extract +ar x ../libtstestcontrol.a + +for f in *.o; do + objcopy \ + --redefine-sym _cgo_topofstack=_cgo_topofstack_tstestcontrol \ + --redefine-sym _cgo_panic=_cgo_panic_tstestcontrol \ + --redefine-sym crosscall2=crosscall2_tstestcontrol \ + "$f" +done + +rm -f "$OUT_ARCHIVE" +ar rcs "$OUT_ARCHIVE" *.o + +echo "wrote $OUT_ARCHIVE" From 50b3f7733171ca9dcf147aa2b982f71b91a71f12 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Sat, 11 Jul 2026 13:37:07 -0500 Subject: [PATCH 05/10] spm on macos --- .github/workflows/swift.yml | 2 +- Package.swift | 37 ------ .../Examples/TailscaleKitCLI/Package.resolved | 2 +- swift/Examples/TailscaleKitCLI/Package.swift | 4 +- swift/Examples/TailscaleKitCLI/README.md | 21 ++-- swift/Makefile | 2 +- swift/Package.swift | 38 ++++++ swift/TailscaleKit/URLSession+Tailscale.swift | 1 + swift/script/build-artifactbundle.sh | 108 ++++++++++++++---- swift/script/fix-tstestcontrol-archive.sh | 55 +++++++-- 10 files changed, 187 insertions(+), 83 deletions(-) delete mode 100644 Package.swift create mode 100644 swift/Package.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index f983363..c7b058f 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,7 +17,7 @@ jobs: - name: Test run: cd swift && make test - - name: iOS + - name: iOS run: cd swift && make ios-fat - name: macos diff --git a/Package.swift b/Package.swift deleted file mode 100644 index 16b889b..0000000 --- a/Package.swift +++ /dev/null @@ -1,37 +0,0 @@ -// swift-tools-version:6.3 -import PackageDescription - -let package = Package( - name: "TailscaleKit", - // NOTE: `swift build`/`swift test` only work on Linux today, regardless of this - // declaration: CTailscale's artifactbundle only ships Linux triples (see - // swift/script/build-artifactbundle.sh). macOS/iOS consumers use - // swift/TailscaleKit.xcodeproj and the .xcframework built by swift/Makefile's - // `macos`/`ios-fat` targets instead. - platforms: [.macOS(.v15), .iOS(.v18)], - products: [ - .library(name: "TailscaleKit", targets: ["TailscaleKit"]) - ], - targets: [ - // Built by swift/script/build-artifactbundle.sh, which cross-compiles - // libtailscale.a per Linux triple (no Go toolchain needed to consume it). - .binaryTarget(name: "CTailscale", path: "swift/build/TailscaleKit.artifactbundle"), - .systemLibrary(name: "CTstestControl", path: "swift/Sources/CTstestControl"), - .target( - name: "TailscaleKit", - dependencies: ["CTailscale"], - path: "swift/TailscaleKit", - exclude: ["TailscaleKit.h"] - ), - .testTarget( - name: "TailscaleKitTests", - dependencies: ["TailscaleKit", "CTstestControl"], - path: "swift/TailscaleKitXCTests", - // Links against swift/build/libtstestcontrol.a, a copy of - // tstestcontrol/libtstestcontrol.a with its cgo runtime glue - // symbols renamed to avoid colliding with libtailscale.a's copy. - // Run swift/script/fix-tstestcontrol-archive.sh to (re)generate it. - linkerSettings: [.unsafeFlags(["-L", "swift/build"])] - ), - ] -) diff --git a/swift/Examples/TailscaleKitCLI/Package.resolved b/swift/Examples/TailscaleKitCLI/Package.resolved index 5b6b054..b960c1f 100644 --- a/swift/Examples/TailscaleKitCLI/Package.resolved +++ b/swift/Examples/TailscaleKitCLI/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e0bd85dc1927ca0cae966d816fc15ac0f9a26d11de398ae44526534ec15c2132", + "originHash" : "59e434ccdf862e4f79fe7b34e3a584141df1df45722582e843cad945bedff4e6", "pins" : [ { "identity" : "swift-argument-parser", diff --git a/swift/Examples/TailscaleKitCLI/Package.swift b/swift/Examples/TailscaleKitCLI/Package.swift index 5bf2c4b..1e01072 100644 --- a/swift/Examples/TailscaleKitCLI/Package.swift +++ b/swift/Examples/TailscaleKitCLI/Package.swift @@ -3,10 +3,8 @@ import PackageDescription let package = Package( name: "TailscaleKitCLI", - // Linux only: this depends on the root package's CTailscale binaryTarget, which - // only ships Linux triples today. See ../../../Package.swift. dependencies: [ - .package(name: "TailscaleKit", path: "../../.."), + .package(name: "TailscaleKit", path: "../.."), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.0"), ], targets: [ diff --git a/swift/Examples/TailscaleKitCLI/README.md b/swift/Examples/TailscaleKitCLI/README.md index 424194b..f692a18 100644 --- a/swift/Examples/TailscaleKitCLI/README.md +++ b/swift/Examples/TailscaleKitCLI/README.md @@ -5,14 +5,19 @@ A minimal SwiftPM executable, built with tailnet from a Swift command-line tool using TailscaleKit. Needs no Xcode project. Unlike `TailscaleKitHello` (an Xcode app that links a prebuilt `.xcframework`), this -package depends directly on the root `Package.swift`, exercising the same SwiftPM path -covered by `swift test`/`swift test-spm`. - -**Linux only for now.** The root package's `CTailscale` binaryTarget -(`swift/build/TailscaleKit.artifactbundle`) currently only ships `x86_64`/`aarch64` Linux -variants, so `swift build`/`swift run` here only work on Linux. On macOS/iOS, use -`TailscaleKitHello` (Xcode + the `.xcframework` built by `swift/Makefile`'s -`macos`/`ios-fat` targets) instead. +package depends directly on `swift/Package.swift`, exercising the same SwiftPM path +covered by `swift test`/`make test-spm`. + +**Linux and macOS** (arm64/x86_64). `swift/script/build-artifactbundle.sh` builds a Linux +variant of `CTailscale` from any host via cross-compilation, plus a native macOS variant +when run *on* macOS (Go/cgo needs a real Mach-O toolchain, so that leg can't be +cross-built from Linux). iOS has no SPM path; for that, use `TailscaleKitHello` (Xcode + +the `.xcframework` built by `swift/Makefile`'s `ios-fat` target). + +On a fresh macOS checkout, make sure `objcopy`/`llvm-objcopy` is reachable (`xcrun -f +llvm-objcopy` if you have a swift.org toolchain installed, otherwise `brew install llvm` +or `brew install binutils`) - `fix-tstestcontrol-archive.sh` needs it, and macOS doesn't +ship one by default. ## Setup diff --git a/swift/Makefile b/swift/Makefile index a076a04..ac26729 100644 --- a/swift/Makefile +++ b/swift/Makefile @@ -88,7 +88,7 @@ test-spm: ## Run tests via Swift Package Manager (cross-platform, incl. Linux) rm -f ../tstestcontrol/libtstestcontrol.a cd ../tstestcontrol && make all ./script/fix-tstestcontrol-archive.sh - cd .. && swift test + swift test .PHONY: clean clean: ## Clean up build artifacts (including the libtailscale dependencies) diff --git a/swift/Package.swift b/swift/Package.swift new file mode 100644 index 0000000..15df9e1 --- /dev/null +++ b/swift/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version:6.3 +import PackageDescription + +let package = Package( + name: "TailscaleKit", + // NOTE: script/build-artifactbundle.sh builds arm64/x86_64 macOS variants of + // CTailscale when run on a Darwin host, so `swift build`/`swift test` work on macOS + // as well as Linux (confirmed on real hardware). iOS still has no SPM path; use + // TailscaleKit.xcodeproj and the .xcframework built by this Makefile's + // `ios-fat` target for that. + platforms: [.macOS(.v15), .iOS(.v18)], + products: [ + .library(name: "TailscaleKit", targets: ["TailscaleKit"]) + ], + targets: [ + // Built by script/build-artifactbundle.sh, which builds libtailscale.a + // per supported triple (Linux via cross-compilation; macOS natively when run + // on a Mac). No Go toolchain needed to consume it. + .binaryTarget(name: "CTailscale", path: "build/TailscaleKit.artifactbundle"), + .systemLibrary(name: "CTstestControl", path: "Sources/CTstestControl"), + .target( + name: "TailscaleKit", + dependencies: ["CTailscale"], + path: "TailscaleKit", + exclude: ["TailscaleKit.h"] + ), + .testTarget( + name: "TailscaleKitTests", + dependencies: ["TailscaleKit", "CTstestControl"], + path: "TailscaleKitXCTests", + // Links against build/libtstestcontrol.a, a copy of + // ../tstestcontrol/libtstestcontrol.a with its cgo runtime glue + // symbols renamed to avoid colliding with libtailscale.a's copy. + // Run script/fix-tstestcontrol-archive.sh to (re)generate it. + linkerSettings: [.unsafeFlags(["-L", "build"])] + ), + ] +) diff --git a/swift/TailscaleKit/URLSession+Tailscale.swift b/swift/TailscaleKit/URLSession+Tailscale.swift index ab2d23c..13efca9 100644 --- a/swift/TailscaleKit/URLSession+Tailscale.swift +++ b/swift/TailscaleKit/URLSession+Tailscale.swift @@ -2,6 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause #if canImport(Network) +import Foundation import Network extension URLSessionConfiguration { diff --git a/swift/script/build-artifactbundle.sh b/swift/script/build-artifactbundle.sh index d1cc890..0ea90d6 100755 --- a/swift/script/build-artifactbundle.sh +++ b/swift/script/build-artifactbundle.sh @@ -2,14 +2,27 @@ # Copyright (c) Tailscale Inc & AUTHORS # SPDX-License-Identifier: BSD-3-Clause # -# Cross-compiles libtailscale.a for each supported Linux triple and packages +# Cross/native-compiles libtailscale.a for each supported triple and packages # the results into a Swift Package Manager static-library artifact bundle -# Package.swift consumes that bundle via a binaryTarget instead of requiring a -# prebuilt libtailscale.a to already sit at the repo root. +# (SE-0482). swift/Package.swift consumes that bundle via a binaryTarget +# instead of requiring a prebuilt libtailscale.a to already sit on disk. # -# Requires: go, and a cross C toolchain per target triple (on Debian/Ubuntu, -# `apt-get install gcc-x86-64-linux-gnu gcc-aarch64-linux-gnu` covers both; -# override CC_ to point at a different compiler). +# Linux triples are cross-compiled from any host given a real cross C +# toolchain per target triple (on Debian/Ubuntu, `apt-get install +# gcc-x86-64-linux-gnu gcc-aarch64-linux-gnu` covers both). Override +# CC_ to point at a different compiler for a given triple. +# +# macOS triples can only be built when this script itself runs on macOS - +# Go's cgo needs a real Mach-O-emitting C toolchain, which isn't available +# cross-platform the way Linux's cross-gcc toolchains are. Apple's own clang +# can target the other CPU architecture via `-arch`, so a single Mac (either +# arch) covers both arm64 and x86_64 without needing two machines. macOS +# variants are only attempted on a Darwin host. +# +# Any triple whose compiler isn't found is skipped with a warning rather than +# failing the whole run - e.g. a plain Mac without the Linux cross-gcc +# toolchains installed will still get its two macOS variants built, which is +# all that's needed for `swift build`/`swift test` on that same Mac. set -eu @@ -25,35 +38,43 @@ VERSION="1.0.0" rm -rf "$BUNDLE_DIR" mkdir -p "$BUNDLE_DIR" -# triple:GOARCH:default-cc -TARGETS=" -x86_64-unknown-linux-gnu:amd64:x86_64-linux-gnu-gcc -aarch64-unknown-linux-gnu:arm64:aarch64-linux-gnu-gcc -" +HOST_OS=$(uname -s) -variants_json="" +# triple:GOOS:GOARCH:default-cc +LINUX_TARGETS=" +x86_64-unknown-linux-gnu:linux:amd64:x86_64-linux-gnu-gcc +aarch64-unknown-linux-gnu:linux:arm64:aarch64-linux-gnu-gcc +" -for entry in $TARGETS; do - triple=$(echo "$entry" | cut -d: -f1) - goarch=$(echo "$entry" | cut -d: -f2) - default_cc=$(echo "$entry" | cut -d: -f3) +# Only attempted when HOST_OS = Darwin; see note above. +MACOS_TARGETS=" +arm64-apple-macosx:darwin:arm64:clang -arch arm64 +x86_64-apple-macosx:darwin:amd64:clang -arch x86_64 +" - cc_var="CC_$goarch" - cc=$(eval "echo \${$cc_var:-$default_cc}") +variants_json="" - if ! command -v "$cc" >/dev/null 2>&1; then - echo "error: cross compiler '$cc' not found for $triple (set $cc_var to override)" >&2 - exit 1 +# build_variant +build_variant() { + triple="$1" + goos="$2" + goarch="$3" + cc="$4" + + cc_bin=$(echo "$cc" | cut -d' ' -f1) + if ! command -v "$cc_bin" >/dev/null 2>&1; then + echo "warning: C compiler '$cc_bin' not found, skipping $triple (set $(cc_override_var "$triple") to point at one)" >&2 + return 0 fi - echo "::: Building libtailscale.a for $triple (GOARCH=$goarch, CC=$cc) :::" + echo "::: Building libtailscale.a for $triple (GOOS=$goos GOARCH=$goarch, CC=$cc) :::" variant_dir="$BUNDLE_DIR/$ARTIFACT_ID-$triple" mkdir -p "$variant_dir/include" ( cd "$REPO_ROOT" - CC="$cc" CGO_ENABLED=1 GOOS=linux GOARCH="$goarch" \ + CC="$cc" CGO_ENABLED=1 GOOS="$goos" GOARCH="$goarch" \ go build -buildmode=c-archive -o "$variant_dir/libtailscale.a" . ) rm -f "$variant_dir/libtailscale.h" # go build also writes a header; we ship our own below @@ -82,7 +103,46 @@ EOF else variants_json="$variants_json,$variant_json" fi -done +} + +# cc_override_var -> env var name a caller can set to override the +# compiler used for that triple, e.g. arm64-apple-macosx -> CC_arm64_apple_macosx. +cc_override_var() { + printf '%s' "CC_$1" | tr -c 'A-Za-z0-9_' '_' +} + +build_target_table() { + table="$1" + # Some default_cc values contain a space (e.g. "clang -arch arm64"); split + # entries on newline only, or the default whitespace-IFS word-splitting + # would tear a single entry into multiple bogus ones. + old_ifs="$IFS" + IFS=' +' + for entry in $table; do + IFS="$old_ifs" + triple=$(echo "$entry" | cut -d: -f1) + goos=$(echo "$entry" | cut -d: -f2) + goarch=$(echo "$entry" | cut -d: -f3) + default_cc=$(echo "$entry" | cut -d: -f4) + + cc_var=$(cc_override_var "$triple") + cc=$(eval "echo \${$cc_var:-\$default_cc}") + + build_variant "$triple" "$goos" "$goarch" "$cc" + IFS=' +' + done + IFS="$old_ifs" +} + +build_target_table "$LINUX_TARGETS" + +if [ "$HOST_OS" = "Darwin" ]; then + build_target_table "$MACOS_TARGETS" +else + echo "::: Skipping macOS variants (not running on a Darwin host) :::" +fi cat > "$BUNDLE_DIR/info.json" < `_crosscall2`, `_cgo_panic` -> +# `__cgo_panic`); ELF (Linux) doesn't. objcopy/llvm-objcopy operate on the +# raw on-disk name, not the source-level one, so the symbol list below is +# platform-dependent. set -eu @@ -31,6 +40,36 @@ if [ ! -f "$SRC_ARCHIVE" ]; then exit 1 fi +# macOS doesn't ship objcopy on PATH by default. Xcode's command line tools are +# built on LLVM but don't expose llvm-objcopy; a swift.org open-source toolchain +# sometimes does under its own usr/bin, which `xcrun -f` can find even when it's +# not on PATH. Otherwise fall back to a Homebrew binutils/llvm install. +OBJCOPY="" +for candidate in objcopy gobjcopy llvm-objcopy; do + if command -v "$candidate" >/dev/null 2>&1; then + OBJCOPY="$candidate" + break + fi +done +if [ -z "$OBJCOPY" ] && command -v xcrun >/dev/null 2>&1; then + OBJCOPY=$(xcrun -f llvm-objcopy 2>/dev/null || true) +fi +if [ -z "$OBJCOPY" ]; then + echo "error: no objcopy found. On macOS, install one with:" >&2 + echo " brew install binutils # provides gobjcopy" >&2 + echo " brew install llvm # provides llvm-objcopy" >&2 + exit 1 +fi +echo "using $OBJCOPY" + +# See the Mach-O note above: on-disk symbol names need an extra leading `_` +# on Darwin that ELF doesn't have. +if [ "$(uname -s)" = "Darwin" ]; then + SYM_PREFIX="_" +else + SYM_PREFIX="" +fi + mkdir -p "$OUT_DIR" WORK_DIR=$(mktemp -d) trap 'rm -rf "$WORK_DIR"' EXIT @@ -42,10 +81,10 @@ cd extract ar x ../libtstestcontrol.a for f in *.o; do - objcopy \ - --redefine-sym _cgo_topofstack=_cgo_topofstack_tstestcontrol \ - --redefine-sym _cgo_panic=_cgo_panic_tstestcontrol \ - --redefine-sym crosscall2=crosscall2_tstestcontrol \ + "$OBJCOPY" \ + --redefine-sym "${SYM_PREFIX}_cgo_topofstack=${SYM_PREFIX}_cgo_topofstack_tstestcontrol" \ + --redefine-sym "${SYM_PREFIX}_cgo_panic=${SYM_PREFIX}_cgo_panic_tstestcontrol" \ + --redefine-sym "${SYM_PREFIX}crosscall2=${SYM_PREFIX}crosscall2_tstestcontrol" \ "$f" done From 53e275f3312787482eff00dd0f3ced840005f695 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Sat, 11 Jul 2026 16:36:46 -0500 Subject: [PATCH 06/10] example cli and fixes --- .github/workflows/swift.yml | 2 +- swift/Examples/TailscaleKitCLI/Makefile | 14 +++ swift/Examples/TailscaleKitCLI/README.md | 100 ++++++++-------- .../Sources/tsdemo/Listen.swift | 54 --------- .../TailscaleKitCLI/Sources/tsdemo/Send.swift | 55 --------- .../Sources/tsdemo/Status.swift | 36 ------ .../Sources/tsdemo/TSDemo.swift | 112 +++++++----------- swift/Makefile | 16 ++- swift/Package.swift | 25 ++-- swift/README.md | 56 ++++----- swift/TailscaleKit/IncomingConnection.swift | 4 +- swift/TailscaleKit/Listener.swift | 4 +- .../LocalAPI/LocalAPIClient.swift | 83 ++++++------- .../LocalAPI/MessageProcessor.swift | 12 +- .../TailscaleKit/LocalAPI/MessageReader.swift | 10 +- swift/TailscaleKit/OutgoingConnection.swift | 4 +- swift/TailscaleKit/TailscaleNode.swift | 4 +- swift/TailscaleKit/URLSession+Tailscale.swift | 2 +- 18 files changed, 216 insertions(+), 377 deletions(-) create mode 100644 swift/Examples/TailscaleKitCLI/Makefile delete mode 100644 swift/Examples/TailscaleKitCLI/Sources/tsdemo/Listen.swift delete mode 100644 swift/Examples/TailscaleKitCLI/Sources/tsdemo/Send.swift delete mode 100644 swift/Examples/TailscaleKitCLI/Sources/tsdemo/Status.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index c7b058f..78ba191 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -25,7 +25,7 @@ jobs: build-linux: - runs-on: linux + runs-on: ubuntu-latest container: swift:6.3 steps: - uses: actions/checkout@v3 diff --git a/swift/Examples/TailscaleKitCLI/Makefile b/swift/Examples/TailscaleKitCLI/Makefile new file mode 100644 index 0000000..ebfa8a3 --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Makefile @@ -0,0 +1,14 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause + +.PHONY: spm-setup +spm-setup: ## Build the TailscaleKit artifact bundle this example depends on (Linux and macOS; see swift/README.md) + @$(MAKE) -C ../.. spm-setup + +.PHONY: help +help: ## Show this help + @echo "\nSpecify a command. The choices are:\n" + @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-12s\033[m %s\n", $$1, $$2}' + @echo "" + +.DEFAULT_GOAL := help diff --git a/swift/Examples/TailscaleKitCLI/README.md b/swift/Examples/TailscaleKitCLI/README.md index f692a18..7c617bc 100644 --- a/swift/Examples/TailscaleKitCLI/README.md +++ b/swift/Examples/TailscaleKitCLI/README.md @@ -1,77 +1,71 @@ # TailscaleKitCLI -A minimal SwiftPM executable, built with -[swift-argument-parser](https://github.com/apple/swift-argument-parser), that joins your -tailnet from a Swift command-line tool using TailscaleKit. Needs no Xcode project. +A minimal SwiftPM executable, built with [swift-argument-parser](https://github.com/apple/swift-argument-parser), that joins your tailnet and lists its devices using TailscaleKit. Needs no Xcode project — it exists purely as a proof that TailscaleKit works from plain SwiftPM, including on Linux. -Unlike `TailscaleKitHello` (an Xcode app that links a prebuilt `.xcframework`), this -package depends directly on `swift/Package.swift`, exercising the same SwiftPM path -covered by `swift test`/`make test-spm`. - -**Linux and macOS** (arm64/x86_64). `swift/script/build-artifactbundle.sh` builds a Linux -variant of `CTailscale` from any host via cross-compilation, plus a native macOS variant -when run *on* macOS (Go/cgo needs a real Mach-O toolchain, so that leg can't be -cross-built from Linux). iOS has no SPM path; for that, use `TailscaleKitHello` (Xcode + -the `.xcframework` built by `swift/Makefile`'s `ios-fat` target). - -On a fresh macOS checkout, make sure `objcopy`/`llvm-objcopy` is reachable (`xcrun -f -llvm-objcopy` if you have a swift.org toolchain installed, otherwise `brew install llvm` -or `brew install binutils`) - `fix-tstestcontrol-archive.sh` needs it, and macOS doesn't -ship one by default. +Unlike `TailscaleKitHello` (an Xcode app that links a prebuilt `.xcframework`), this package depends directly on `swift/Package.swift`, exercising the same SwiftPM path covered by `swift test`/`make test-spm`. ## Setup -From `/swift`, build the `libtailscale.a` artifact bundle this package links against -(this is also done automatically by `make test-spm`): +From this directory, build the artifact bundle this package needs. Run this once: -``` -$ ./script/build-artifactbundle.sh +```sh +$ make spm-setup ``` -Generate a reusable auth key at -https://login.tailscale.com/admin/settings/keys and export it: +Generate and export an auth key at https://login.tailscale.com/admin/settings/keys. -``` +```sh $ export TS_AUTHKEY=tskey-auth-... ``` ## Usage -Each subcommand brings up its own in-process tsnet node (its own device on your tailnet), -so run `status`/`listen`/`send` as separate invocations, or from separate machines/devices -on the same tailnet entirely. - +```sh +$ swift run tsdemo ``` -$ swift run tsdemo status -``` -Brings up a node and prints the tailnet's backend state and peer list. -``` -$ swift run tsdemo listen --port 8081 -``` -Brings up a node, listens on port 8081, and prints every message it receives from peers. +Example output: ``` -$ swift run tsdemo send --to 100.x.y.z:8081 "hello" +make spm-setup +make[1]: Entering directory '/workspace/swift' + +::: Building SwiftPM artifact bundle for TailscaleKit ::: +./script/build-artifactbundle.sh +::: Building libtailscale.a for x86_64-unknown-linux-gnu (GOOS=linux GOARCH=amd64, CC=x86_64-linux-gnu-gcc) ::: +::: Building libtailscale.a for aarch64-unknown-linux-gnu (GOOS=linux GOARCH=arm64, CC=aarch64-linux-gnu-gcc) ::: +::: Skipping macOS variants (not running on a Darwin host) ::: +wrote /workspace/swift/build/TailscaleKit.artifactbundle +make[1]: Leaving directory '/workspace/swift' +root@82cf0493d6f1:/workspace/swift/Examples/TailscaleKitCLI# export TS_AUTHKEY=tskey-auth-... +root@82cf0493d6f1:/workspace/swift/Examples/TailscaleKitCLI# swift run tsdemo +[1/1] Planning build +Building for debugging... +clang: warning: argument unused during compilation: '-F/workspace/swift/Examples/TailscaleKitCLI/.build/aarch64-unknown-linux-gnu/debug' [-Wunused-command-line-argument] +[40/40] Linking tsdemo +Build of product 'tsdemo' complete! (2.97s) +2026/07/11 21:14:20 tsnet running state path /tmp/tsdemo-FF27454D/tailscaled.state +2026/07/11 21:14:20 tsnet starting with hostname "tsdemo-FF27454D", varRoot "/tmp/tsdemo-FF27454D" +2026/07/11 21:14:20 LocalBackend state is NeedsLogin; running StartLoginInteractive... +Bringing up "tsdemo-FF27454D", waiting to associate with the tailnet... +2026/07/11 21:14:22 localapi tcp serve error: use of closed network connection +2026/07/11 21:14:22 socks5: SOCKS5 server exited: use of closed network connection +Backend state: Running +Devices on this tailnet: + [offline] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [online ] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [online ] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [offline] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [online ] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [online ] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [offline] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. ``` -Brings up a separate node and sends a one-shot message to whatever is listening at -`--to` (an IP or MagicDNS name from `tsdemo status`/`tsdemo listen`'s output). -Pass `--hostname`/`--state-dir` to give a node a stable identity across runs (otherwise -each run gets a random hostname and a throwaway state directory, and defaults to -`--ephemeral`, so it disappears from the admin console when the process exits). +Brings up a throwaway, ephemeral in-process tsnet node, then prints the tailnet's backend state and every other device currently visible on it. Needs a real Tailscale auth key and network access. ## What this is showing off -- `TailscaleNode` — bringing up an in-process node against the control plane, and reading - back its tailnet addresses. -- `Listener`/`IncomingConnection` and `OutgoingConnection` — the raw one-directional - send/receive primitives TailscaleKit exposes for talking directly to other devices on - the tailnet, with no port forwarding, VPN config, or public exposure required. -- `LocalAPIClient` — querying the node's own local API for backend/peer status, the same - API `tailscale status` itself uses. - -`tsdemo send` sleeps briefly after writing before it closes the connection and tears the -node down — tsnet's virtual network stack may still be relaying the write (e.g. through -DERP) when `send()` returns, and a `--ephemeral` node exiting immediately can beat that -flush. +- `TailscaleNode` — bringing up an in-process node against the control plane. +- `LocalAPIClient` — querying the node's own local API for backend/peer status, the same API `tailscale status` itself uses. + +For the raw send/receive primitives (`Listener`/`IncomingConnection`/`OutgoingConnection`), see `swift/TailscaleKitXCTests/TailscaleKitTests.swift`. diff --git a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Listen.swift b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Listen.swift deleted file mode 100644 index 3c4cd78..0000000 --- a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Listen.swift +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import ArgumentParser -import TailscaleKit - -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -extension TSDemo { - struct Listen: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Bring up a tsnet node and print every message received from peers on the tailnet." - ) - - @OptionGroup var node: NodeOptions - - @Option(help: "Port to listen on.") - var port: Int = 8081 - - @Option(help: "IP protocol to listen with: tcp or udp.") - var proto: String = "tcp" - - func run() async throws { - guard let ipProto = NetProtocol(rawValue: proto) else { - throw ValidationError("--proto must be tcp or udp") - } - - try await withTailscaleNode(node) { ts in - guard let handle = await ts.tailscale else { - throw ValidationError("Node has no handle") - } - - let listener = try await Listener( - tailscale: handle, proto: ipProto, address: ":\(port)", logger: node.logger) - - print( - "Listening on \(proto)/\(port). From another node, run: tsdemo send --to :\(port) \"hello\". Ctrl-C to stop." - ) - - while true { - let inbound = try await listener.accept(timeout: 300) - let data = try await inbound.receiveMessage(timeout: 5000) - let text = String(data: data, encoding: .utf8) ?? "<\(data.count) bytes>" - let remote = await inbound.remoteAddress ?? "unknown" - print("[\(remote)] \(text)") - } - } - } - } -} diff --git a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Send.swift b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Send.swift deleted file mode 100644 index aeb53eb..0000000 --- a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Send.swift +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import ArgumentParser -import TailscaleKit - -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -extension TSDemo { - struct Send: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Bring up a tsnet node and send a one-shot message to a peer running \"tsdemo listen\"." - ) - - @OptionGroup var node: NodeOptions - - @Argument(help: "Message to send.") - var message: String - - @Option(help: "Destination on the tailnet, e.g. 100.x.y.z:8081 or a MagicDNS name:8081.") - var to: String - - @Option(help: "IP protocol to dial with: tcp or udp.") - var proto: String = "tcp" - - func run() async throws { - guard let ipProto = NetProtocol(rawValue: proto) else { - throw ValidationError("--proto must be tcp or udp") - } - - try await withTailscaleNode(node) { ts in - guard let handle = await ts.tailscale else { - throw ValidationError("Node has no handle") - } - - let outgoing = try await OutgoingConnection( - tailscale: handle, to: to, proto: ipProto, logger: node.logger) - try await outgoing.connect() - try await outgoing.send(Data(message.utf8)) - - // Give tsnet's virtual network stack a moment to actually flush the - // write onto the wire (it may still be relaying through DERP) before - // we tear the connection and node down. - try await Task.sleep(for: .seconds(2)) - await outgoing.close() - - print("Sent \(message.utf8.count) bytes to \(to).") - } - } - } -} diff --git a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Status.swift b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Status.swift deleted file mode 100644 index 459c64a..0000000 --- a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/Status.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import ArgumentParser -import TailscaleKit - -extension TSDemo { - struct Status: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Bring up a tsnet node and print its tailnet identity, peers, and backend status." - ) - - @OptionGroup var node: NodeOptions - - func run() async throws { - try await withTailscaleNode(node) { ts in - let api = LocalAPIClient(localNode: ts, logger: node.logger) - let status = try await api.backendStatus() - - print("Backend state: \(status.BackendState)") - - let peers = (status.Peer?.values).map(Array.init)?.sorted { $0.HostName < $1.HostName } ?? [] - if peers.isEmpty { - print("No peers visible on this tailnet yet.") - } else { - print("Peers:") - for peer in peers { - let ip = peer.TailscaleIPs?.first ?? "?" - let mark = peer.Online ? "online " : "offline" - print(" [\(mark)] \(peer.HostName) \(ip) \(peer.DNSName)") - } - } - } - } - } -} diff --git a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift index daa9e5b..f62586a 100644 --- a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift +++ b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift @@ -10,33 +10,18 @@ import FoundationEssentials import Foundation #endif -#if canImport(Darwin) -@preconcurrency import Darwin -#elseif canImport(Glibc) -@preconcurrency import Glibc -#endif - +/// A minimal proof that TailscaleKit works via plain SwiftPM (including on Linux) @main struct TSDemo: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "tsdemo", - abstract: "A minimal example of joining a tailnet from a Swift command-line tool using TailscaleKit.", + abstract: "Joins a tailnet and lists its devices, using TailscaleKit via SwiftPM.", discussion: """ - Each invocation brings up its own in-process tsnet node, so "tsdemo status" and - "tsdemo listen" are different nodes on your tailnet even if you run them back to back. - - All subcommands need a Tailscale auth key. Generate a reusable one at + Needs a Tailscale auth key. Generate a reusable one at https://login.tailscale.com/admin/settings/keys and pass it via --auth-key or the TS_AUTHKEY environment variable. - """, - subcommands: [Status.self, Listen.self, Send.self] + """ ) -} - -/// Options shared by every subcommand for bringing up a tsnet node. -struct NodeOptions: ParsableArguments { - @Option(help: "Hostname to advertise on the tailnet. Defaults to a random tsdemo- name.") - var hostname: String = "tsdemo-\(UUID().uuidString.prefix(8))" @Option(help: "Tailscale auth key. Defaults to the TS_AUTHKEY environment variable.") var authKey: String? @@ -44,65 +29,52 @@ struct NodeOptions: ParsableArguments { @Option(help: "Control plane URL.") var controlURL: String = kDefaultControlURL - @Option( - help: - "Directory used to persist this node's tsnet state. Defaults to a fresh temporary directory; pass a stable path to reuse the same node identity across runs." - ) - var stateDir: String? - - @Flag(inversion: .prefixedNo, help: "Register the node as ephemeral, so it disappears from the tailnet admin console when this process exits.") - var ephemeral: Bool = true - @Flag(help: "Log tsnet's internal activity to stderr instead of discarding it.") var verbose: Bool = false - func makeConfiguration() throws -> Configuration { + func run() async throws { guard let authKey = authKey ?? ProcessInfo.processInfo.environment["TS_AUTHKEY"] else { throw ValidationError( "Provide --auth-key or set TS_AUTHKEY. Generate one at https://login.tailscale.com/admin/settings/keys" ) } - let dir = stateDir ?? FileManager.default.temporaryDirectory - .appendingPathComponent("tsdemo-\(hostname)").path - - return Configuration( - hostName: hostname, - path: dir, - authKey: authKey, - controlURL: controlURL, - ephemeral: ephemeral) - } - - var logger: LogSink { - verbose ? DefaultLogger() : BlackholeLogger() - } -} - -/// Brings a node up, waits for it to associate an address, and hands it to `body`. -/// The node is always torn down afterwards, even if `body` throws. -func withTailscaleNode( - _ options: NodeOptions, - _ body: (TailscaleNode) async throws -> T -) async throws -> T { - setvbuf(stdout, nil, _IOLBF, 0) - - let config = try options.makeConfiguration() - let node = try TailscaleNode(config: config, logger: options.logger) - - print("Bringing up \"\(config.hostName)\", waiting to associate with the tailnet...") - try await node.up() - - let addrs = try await node.addrs() - let addrDescription = [addrs.ip4, addrs.ip6].compactMap { $0 }.joined(separator: ", ") - print("\"\(config.hostName)\" is up at \(addrDescription)") - - do { - let result = try await body(node) - try await node.close() - return result - } catch { - try? await node.close() - throw error + let hostname = "tsdemo-\(UUID().uuidString.prefix(8))" + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(hostname).path + let config = Configuration( + hostName: hostname, + path: dir, + authKey: authKey, + controlURL: controlURL, + ephemeral: true + ) + let logger: LogSink = verbose ? DefaultLogger() : BlackholeLogger() + let node = try TailscaleNode(config: config, logger: logger) + + print("Bringing up \"\(hostname)\", waiting to associate with the tailnet...") + try await node.up() + + do { + let api = LocalAPIClient(localNode: node, logger: logger) + let status = try await api.backendStatus() + + print("Backend state: \(status.BackendState)") + + let peers = (status.Peer?.values).map(Array.init)?.sorted { $0.HostName < $1.HostName } ?? [] + if peers.isEmpty { + print("No other devices visible on this tailnet yet.") + } else { + print("Devices on this tailnet:") + for peer in peers { + let ip = peer.TailscaleIPs?.first ?? "?" + let mark = peer.Online ? "online " : "offline" + print(" [\(mark)] \(peer.HostName) \(ip) \(peer.DNSName)") + } + } + try await node.close() + } catch { + try? await node.close() + throw error + } } } diff --git a/swift/Makefile b/swift/Makefile index ac26729..31d9c6c 100644 --- a/swift/Makefile +++ b/swift/Makefile @@ -80,14 +80,24 @@ test: ## Run tests (macOS) -destination 'platform=macOS,arch=arm64' \ CODE_SIGNING_ALLOWED=NO -.PHONY: test-spm -test-spm: ## Run tests via Swift Package Manager (cross-platform, incl. Linux) +.PHONY: spm-setup +spm-setup: ## Build the artifact bundle needed for swift build/swift run. Run once, then swift build/run as many times as you like (cross-platform, incl. Linux) @echo - @echo "::: Running tests for TailscaleKit via swift test :::" + @echo "::: Building SwiftPM artifact bundle for TailscaleKit :::" ./script/build-artifactbundle.sh + +.PHONY: spm-test-setup +spm-test-setup: spm-setup ## Additionally build libtstestcontrol.a needed for swift test. Run once, then swift test as many times as you like + @echo + @echo "::: Building libtstestcontrol.a for TailscaleKit tests :::" rm -f ../tstestcontrol/libtstestcontrol.a cd ../tstestcontrol && make all ./script/fix-tstestcontrol-archive.sh + +.PHONY: test-spm +test-spm: spm-test-setup ## Run tests via Swift Package Manager (cross-platform, incl. Linux) + @echo + @echo "::: Running tests for TailscaleKit via swift test :::" swift test .PHONY: clean diff --git a/swift/Package.swift b/swift/Package.swift index 15df9e1..bd716af 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -1,37 +1,36 @@ // swift-tools-version:6.3 import PackageDescription +let swiftSettings: [SwiftSetting] = [ + /// https://github.com/apple/swift-evolution/blob/main/proposals/0335-existential-any.md + /// Require `any` for existential types. + .enableUpcomingFeature("ExistentialAny") +] + let package = Package( name: "TailscaleKit", - // NOTE: script/build-artifactbundle.sh builds arm64/x86_64 macOS variants of - // CTailscale when run on a Darwin host, so `swift build`/`swift test` work on macOS - // as well as Linux (confirmed on real hardware). iOS still has no SPM path; use - // TailscaleKit.xcodeproj and the .xcframework built by this Makefile's - // `ios-fat` target for that. - platforms: [.macOS(.v15), .iOS(.v18)], + platforms: [.macOS(.v15), .iOS(.v18)], // same as xcodeproj products: [ .library(name: "TailscaleKit", targets: ["TailscaleKit"]) ], targets: [ - // Built by script/build-artifactbundle.sh, which builds libtailscale.a - // per supported triple (Linux via cross-compilation; macOS natively when run - // on a Mac). No Go toolchain needed to consume it. + // No Go toolchain needed to consume it. .binaryTarget(name: "CTailscale", path: "build/TailscaleKit.artifactbundle"), .systemLibrary(name: "CTstestControl", path: "Sources/CTstestControl"), .target( name: "TailscaleKit", dependencies: ["CTailscale"], path: "TailscaleKit", - exclude: ["TailscaleKit.h"] + exclude: ["TailscaleKit.h"], + swiftSettings: swiftSettings ), .testTarget( name: "TailscaleKitTests", dependencies: ["TailscaleKit", "CTstestControl"], path: "TailscaleKitXCTests", - // Links against build/libtstestcontrol.a, a copy of - // ../tstestcontrol/libtstestcontrol.a with its cgo runtime glue + swiftSettings: swiftSettings, + // Links against build/libtstestcontrol.a, a copy with its cgo runtime // symbols renamed to avoid colliding with libtailscale.a's copy. - // Run script/fix-tstestcontrol-archive.sh to (re)generate it. linkerSettings: [.unsafeFlags(["-L", "build"])] ), ] diff --git a/swift/README.md b/swift/README.md index f71666c..ba72324 100644 --- a/swift/README.md +++ b/swift/README.md @@ -1,20 +1,20 @@ # TailscaleKit -The TailscaleKit Swift package provides an embedded network interface that can be -used to listen for and dial connections to other [Tailscale](https://tailscale.com) nodes in addition -to an extension to URLSession which allows you to make URL requests to nodes on you Tailnet directly. +The TailscaleKit Swift package provides an embedded network interface that can be used to listen for and dial connections to other [Tailscale](https://tailscale.com) nodes in addition to an extension to URLSession which allows you to make URL requests to nodes on you Tailnet directly. The interfaces are similar in design to NWConnection, but are Swift 6 compliant and -designed to be used in modern async/await style code. +designed to be used in modern async/await style code. ## Build and Install Build Requirements: - - XCode 16.1 or newer + +- XCode 16.1 or newer Building Tailscale.framework: -From /swift +From /swift + ```bash $ make macos $ make ios @@ -24,45 +24,40 @@ $ make ios-fat These recipes build different variants of TailscaleKit.framework into /swift/build/Build/Products. -Separate frameworks will be built for macOS and iOS and the iOS Simulator. All dependencies (libtailscale*.a) -are built automatically. Swift 6 is supported. +Separate frameworks will be built for macOS and iOS and the iOS Simulator. All dependencies (libtailscale\*.a) are built automatically. Swift 6 is supported. -The ios and ios-sim frameworks are purposefully separated. The former is free of any simulator segments -and is suitable for app-store submissions. The latter is suitable for embedding when you -wish to run on a simulator in dev though 'make ios-fat' will produce an xcframework bundle including -both simulator and device frameworks for development. +The ios and ios-sim frameworks are purposefully separated. The former is free of any simulator segments and is suitable for app-store submissions. The latter is suitable for embedding when you wish to run on a simulator in dev though 'make ios-fat' will produce an xcframework bundle including both simulator and device frameworks for development. The frameworks are not signed and must be signed when they are embedded. -Alternatively, you may build from xCode using the Tailscale scheme but the -libraries must be built first (since xCode will complain about paths and -permissions) +Alternatively, you may build from xCode using the Tailscale scheme but the libraries must be built first (since xCode will complain about paths and permissions) + +To build only the static libraries, from / -To build only the static libraries, from / ```bash $ make c-archive -$ make c-archive-ios +$ make c-archive-ios $ make c-archive-ios-sim ``` -If you're writing pure C, or C++, link these and use the generated tailscale.h header. -make c-archive builds for the local machine architecture/platform (arm64 macOS from a mac) +If you're writing pure C, or C++, link these and use the generated tailscale.h header. `make c-archive` builds for the local machine architecture/platform (arm64 macOS from a mac) -Non-apple swift builds are not supported (yet) but should be possible with a little tweaking. +Non-Apple platforms are supported via SwiftPM instead of Xcode: `make spm-setup` builds a `TailscaleKit.artifactbundle` (Linux always, plus native macOS variants when run on a Mac) that `swift build`/`swift test`/`swift run` consume directly, with no Xcode project or Go toolchain required by downstream consumers. See `swift/Examples/TailscaleKitCLI` for a minimal example. ## Tests From /swift + ```bash -$ make test +$ make test # Xcode/XCTest, macOS only +$ make test-spm # swift test, cross-platform (incl. Linux) ``` +On a fresh macOS checkout, make sure `objcopy`/`llvm-objcopy` is reachable (`xcrun -f llvm-objcopy` if you have a swift.org toolchain installed, otherwise `brew install llvm` or `brew install binutils`) before running `make test-spm` - `fix-tstestcontrol-archive.sh` needs it to build `libtstestcontrol.a` for the test target, and macOS doesn't ship one by default. Not needed for `make spm-setup`/`swift build`/`swift run` on their own. ## Usage -Nodes need to be authorized in order to function. Set an auth key via -the config.authKey parameter, or watch the ipn bus (see the example) for -the browseToURL field for interactive web-based auth. +Nodes need to be authorized in order to function. Set an auth key via the config.authKey parameter, or watch the ipn bus (see the example) for the browseToURL field for interactive web-based auth. Here's a working example using an auth key: @@ -78,8 +73,8 @@ func start() -> TailscaleNode { // The logger is configurable. The default will just print. let node = try TailscaleNode(config: config, logger: DefaultLogger()) - - // Bring the node up + + // Bring the node up try await node.up() return node } @@ -90,7 +85,7 @@ func fetchURL(_ url: URL, tailscale: TailscaleNode) async throws -> Data { // You can cache this. It will not change once the node is up. let sessionConfig = try await URLSessionConfiguration.tailscaleSession(tailscale) let session = URLSession(configuration: sessionConfig) - + // Make the request let req = URLRequest(url: url) let (data, _) = try await session.data(for: req) @@ -102,14 +97,11 @@ The "node" created here should show up in the Tailscale admin panel as "TSNet-Te ### LocalAPI -TailscaleKit.framework also includes a functional (though somewhat incomplete) implementation of -LocalAPI which can be used to track the state of the embedded tailscale instance in much greater -detail. +TailscaleKit.framework also includes a functional (though somewhat incomplete) implementation of LocalAPI which can be used to track the state of the embedded tailscale instance in much greater detail. ### Examples -See the TailscaleKitHello example for a relatively complete implementation demonstrating proxied -HTTP and usage of LocalAPI to track the tailnet state. +See the TailscaleKitHello example for a relatively complete implementation demonstrating proxied HTTP and usage of LocalAPI to track the tailnet state. ## Contributing diff --git a/swift/TailscaleKit/IncomingConnection.swift b/swift/TailscaleKit/IncomingConnection.swift index 285cda1..663f1ed 100644 --- a/swift/TailscaleKit/IncomingConnection.swift +++ b/swift/TailscaleKit/IncomingConnection.swift @@ -19,7 +19,7 @@ import Foundation /// connection. IncomingConnections are not instantiated directly, /// they are returned by Listener.accept public actor IncomingConnection { - private let logger: LogSink? + private let logger: (any LogSink)? private var conn: TailscaleConnection = 0 private let reader: SocketReader @@ -31,7 +31,7 @@ public actor IncomingConnection { stateBroadcaster.subscribe() } - init(conn: TailscaleConnection, remoteAddress: String?, logger: LogSink? = nil) async { + init(conn: TailscaleConnection, remoteAddress: String?, logger: (any LogSink)? = nil) async { self.logger = logger self.conn = conn stateBroadcaster.set(.connected) diff --git a/swift/TailscaleKit/Listener.swift b/swift/TailscaleKit/Listener.swift index f312abe..92acbd7 100644 --- a/swift/TailscaleKit/Listener.swift +++ b/swift/TailscaleKit/Listener.swift @@ -15,7 +15,7 @@ public actor Listener { private var proto: NetProtocol private var address: String - private let logger: LogSink? + private let logger: (any LogSink)? private var stateBroadcaster = StateBroadcaster(.idle) @@ -32,7 +32,7 @@ public actor Listener { public init(tailscale: TailscaleHandle, proto: NetProtocol, address: String, - logger: LogSink? = nil) async throws { + logger: (any LogSink)? = nil) async throws { self.logger = logger self.tailscale = tailscale self.address = address diff --git a/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift b/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift index a99c4f3..97b654b 100644 --- a/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift +++ b/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift @@ -47,9 +47,9 @@ public actor LocalAPIClient { /// The local node that will be handling our localAPI requests. let node: TailscaleNode - let logger: LogSink? + let logger: (any LogSink)? - public init(localNode: TailscaleNode, logger: LogSink?) { + public init(localNode: TailscaleNode, logger: (any LogSink)?) { self.node = localNode self.logger = logger } @@ -69,7 +69,7 @@ public actor LocalAPIClient { /// - consumer: an actor implementing MessageConsumer to which incoming events will be sent /// - Returns: The MessageProcessor handling the incoming event stream. This should be destroyed/stopped when the caller /// wishes to unsubscribe from the event stream. - public func watchIPNBus(mask: Ipn.NotifyWatchOpt, consumer: MessageConsumer) async throws -> MessageProcessor { + public func watchIPNBus(mask: Ipn.NotifyWatchOpt, consumer: any MessageConsumer) async throws -> MessageProcessor { let params = [URLQueryItem(name: "mask", value: String(mask.rawValue))] let (request, sessionConfig) = try await self.basicAuthURLRequest(endpoint: .watchIPNBus, method: .GET, @@ -265,11 +265,14 @@ public actor LocalAPIClient { endpointPath = endpointPath + "/" + path } - logger?.log("Requesting \(endpointPath) via \(loopbackConfig.ip!):\(loopbackConfig.port!)") + guard let ip = loopbackConfig.ip, let port = loopbackConfig.port else { + throw LocalAPIError.localAPIURLRequestError + } + logger?.log("Requesting \(endpointPath) via \(ip):\(port)") var urlComponents = URLComponents() - urlComponents.host = loopbackConfig.ip - urlComponents.port = loopbackConfig.port + urlComponents.host = ip + urlComponents.port = port urlComponents.scheme = "http" urlComponents.path = "\(kLocalAPIPath)\(endpointPath)" urlComponents.queryItems = params @@ -293,7 +296,7 @@ public actor LocalAPIClient { private func parseAPIResponse(data: Data?, response: URLResponse?, - error: Error?) -> Result { + error: (any Error)?) -> Result { if let error { return .failure(error) @@ -322,7 +325,7 @@ public actor LocalAPIClient { bodyAsJSON: BodyT, headers: [String: String]? = nil, timeoutInterval: TimeInterval = 60, - resultTransformer: @escaping (_ result: Result) -> ResultT + resultTransformer: @escaping (_ result: Result) -> ResultT ) async -> ResultT { do { let encodedBody = try JSONEncoder().encode(bodyAsJSON) @@ -347,52 +350,52 @@ public actor LocalAPIClient { body: Data? = nil, headers: [String: String]? = nil, timeoutInterval: TimeInterval = 60, - resultTransformer: @escaping (_ result: Result) -> T) async -> T { - - var request: URLRequest - var sessionConfig: URLSessionConfiguration - do { - (request, sessionConfig) = try await self.basicAuthURLRequest(endpoint: endpoint, - path: path, - method: method, - headers: headers, - params: params) - - } catch { - return resultTransformer(.failure(error)) - } - - if let body { - request.httpBody = body - } + resultTransformer: @escaping (_ result: Result) -> T) async -> T { + + var request: URLRequest + var sessionConfig: URLSessionConfiguration + do { + (request, sessionConfig) = try await self.basicAuthURLRequest(endpoint: endpoint, + path: path, + method: method, + headers: headers, + params: params) + + } catch { + return resultTransformer(.failure(error)) + } - request.timeoutInterval = timeoutInterval + if let body { + request.httpBody = body + } - do { - let session = URLSession(configuration: sessionConfig) - let (data, response) = try await session.data(for: request) - switch self.parseAPIResponse(data: data, response: response, error: nil) { - case .success(let data): - return resultTransformer(.success(data)) - case .failure(let error): - logger?.log("LocalAPI request to \(path ?? "") failed with \(error)") + request.timeoutInterval = timeoutInterval + + do { + let session = URLSession(configuration: sessionConfig) + let (data, response) = try await session.data(for: request) + switch self.parseAPIResponse(data: data, response: response, error: nil) { + case .success(let data): + return resultTransformer(.success(data)) + case .failure(let error): + logger?.log("LocalAPI request to \(path ?? "") failed with \(error)") + return resultTransformer(.failure(error)) + } + } catch { return resultTransformer(.failure(error)) } - } catch { - return resultTransformer(.failure(error)) - } } // MARK: - Transformers - private func errorTransformer(result: Result) -> Error? { + private func errorTransformer(result: Result) -> (any Error)? { switch result { case .success: return nil case .failure(let error): return error } } - private func jsonDecodeTransformer(_ type: T.Type) -> (_ result: Result) -> Result { + private func jsonDecodeTransformer(_ type: T.Type) -> (_ result: Result) -> Result { return { result in switch result { case .success(let data): diff --git a/swift/TailscaleKit/LocalAPI/MessageProcessor.swift b/swift/TailscaleKit/LocalAPI/MessageProcessor.swift index 8eb2d54..8efaec1 100644 --- a/swift/TailscaleKit/LocalAPI/MessageProcessor.swift +++ b/swift/TailscaleKit/LocalAPI/MessageProcessor.swift @@ -16,7 +16,7 @@ let kProcessorQueuePollInterval: UInt64 = 100_000_000 // Nanos /// potential errors. public protocol MessageConsumer: Actor { func notify(_ notify: Ipn.Notify) - func error(_ error: Error) + func error(_ error: any Error) } /// MessageProcessor pulls queued Decodable messages from a MessageReader, deserializes them @@ -25,12 +25,12 @@ public class MessageProcessor: @unchecked Sendable { let consumer: any MessageConsumer let reader: MessageReader let workQueue = OperationQueue() - var logger: LogSink? + var logger: (any LogSink)? // A long running task to poll the queue - var pollTask: Task? + var pollTask: Task? - init(consumer: any MessageConsumer, logger: LogSink?) async { + init(consumer: any MessageConsumer, logger: (any LogSink)?) async { workQueue.maxConcurrentOperationCount = 1 workQueue.name = "io.tailscale.ipn.MessageProcessor.workQueue" @@ -44,7 +44,7 @@ public class MessageProcessor: @unchecked Sendable { reader.stop() } - func start(_ request: URLRequest, config: URLSessionConfiguration, errorHandler: (@Sendable (Error) -> Void)? = nil) { + func start(_ request: URLRequest, config: URLSessionConfiguration, errorHandler: (@Sendable (any Error) -> Void)? = nil) { workQueue.addOperation { [weak self] in guard let self = self else { return } logger?.log("Starting MessageProcessor for \(request.url?.absoluteString ?? "nil")") @@ -99,7 +99,7 @@ public class MessageProcessor: @unchecked Sendable { } } - func processError(_ error: Error) { + func processError(_ error: any Error) { Task { await consumer.error(error) } diff --git a/swift/TailscaleKit/LocalAPI/MessageReader.swift b/swift/TailscaleKit/LocalAPI/MessageReader.swift index 91c9f47..cea1b5b 100644 --- a/swift/TailscaleKit/LocalAPI/MessageReader.swift +++ b/swift/TailscaleKit/LocalAPI/MessageReader.swift @@ -28,7 +28,7 @@ final class MessageReader: NSObject, URLSessionDataDelegate, @unchecked Sendable var ipnWatchSession: URLSession? var dataTask: URLSessionDataTask? - var logger: LogSink? + var logger: (any LogSink)? /// FIFO queue for messages awaiting processing var pendingMessages: [Data] = [] @@ -39,9 +39,9 @@ final class MessageReader: NSObject, URLSessionDataDelegate, @unchecked Sendable /// restart the processor and queue with an .initialState flag. var congested = false - var errorHandler: (@Sendable (Error) -> Void)? + var errorHandler: (@Sendable (any Error) -> Void)? - init(logger: LogSink? = nil) { + init(logger: (any LogSink)? = nil) { self.logger = logger workQueue.maxConcurrentOperationCount = 1 workQueue.name = "io.tailscale.ipn.MessageReader.workQueue" @@ -52,7 +52,7 @@ final class MessageReader: NSObject, URLSessionDataDelegate, @unchecked Sendable workQueue.cancelAllOperations() } - func start(_ request: URLRequest, config: URLSessionConfiguration, errorHandler: @escaping @Sendable (Error) -> Void ) { + func start(_ request: URLRequest, config: URLSessionConfiguration, errorHandler: @escaping @Sendable (any Error) -> Void ) { workQueue.addOperation { [weak self] in guard let self = self else { return } @@ -95,7 +95,7 @@ final class MessageReader: NSObject, URLSessionDataDelegate, @unchecked Sendable func urlSession(_ session: URLSession, task: URLSessionTask, - didCompleteWithError error: Error?) { + didCompleteWithError error: (any Error)?) { if let error = error { let nsError = error as NSError // Ignore cancellation errors, those are deliberate. diff --git a/swift/TailscaleKit/OutgoingConnection.swift b/swift/TailscaleKit/OutgoingConnection.swift index a233f07..cc25c5f 100644 --- a/swift/TailscaleKit/OutgoingConnection.swift +++ b/swift/TailscaleKit/OutgoingConnection.swift @@ -37,7 +37,7 @@ public actor OutgoingConnection { private var address: String private var conn: TailscaleConnection = 0 - private let logger: LogSink + private let logger: any LogSink /// The state of the connection. Listen for transitions to determine /// if the connection may be used for send/receive operations. @@ -54,7 +54,7 @@ public actor OutgoingConnection { public init(tailscale: TailscaleHandle, to address: String, proto: NetProtocol, - logger: LogSink) async throws { + logger: any LogSink) async throws { self.logger = logger self.proto = proto diff --git a/swift/TailscaleKit/TailscaleNode.swift b/swift/TailscaleKit/TailscaleNode.swift index 9b751c5..8fcd136 100644 --- a/swift/TailscaleKit/TailscaleNode.swift +++ b/swift/TailscaleKit/TailscaleNode.swift @@ -51,7 +51,7 @@ public actor TailscaleNode { /// new IncomingConnections or OutgoingConnections public let tailscale: TailscaleHandle? - private let logger: LogSink? + private let logger: (any LogSink)? /// Instantiate a new TailscaleNode with the given configuration and /// and optional LogSink. If no LogSink is provided, logs will be @@ -61,7 +61,7 @@ public actor TailscaleNode { /// @See tailscale_start in Tailscale.h /// /// @throws TailscaleError on failure - public init(config: Configuration, logger: LogSink?) throws { + public init(config: Configuration, logger: (any LogSink)?) throws { self.logger = logger ?? BlackholeLogger() tailscale = tailscale_new() diff --git a/swift/TailscaleKit/URLSession+Tailscale.swift b/swift/TailscaleKit/URLSession+Tailscale.swift index 13efca9..8651bb8 100644 --- a/swift/TailscaleKit/URLSession+Tailscale.swift +++ b/swift/TailscaleKit/URLSession+Tailscale.swift @@ -5,7 +5,7 @@ import Foundation import Network -extension URLSessionConfiguration { +public extension URLSessionConfiguration { /// Adds the a ProxyConfiguration to a URLSessionConfiguration to /// proxy all requests through the given TailscaleNode. /// From 1e010beba21a2ab522da12e5a3aa3cc93a1b6b35 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Sat, 11 Jul 2026 18:56:39 -0500 Subject: [PATCH 07/10] fix ci --- .github/workflows/swift.yml | 2 +- swift/script/build-artifactbundle.sh | 10 ++- swift/script/fix-tstestcontrol-archive.sh | 100 ++++++++++++++++------ tstestcontrol/Makefile | 2 +- 4 files changed, 82 insertions(+), 32 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 78ba191..2551d70 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -38,7 +38,7 @@ jobs: - name: Install cross toolchains run: | apt-get update - apt-get install -y --no-install-recommends build-essential gcc-x86-64-linux-gnu gcc-aarch64-linux-gnu + apt-get install -y --no-install-recommends build-essential gcc-x86-64-linux-gnu gcc-aarch64-linux-gnu libc6-dev-amd64-cross libc6-dev-arm64-cross - name: Test run: cd swift && make test-spm diff --git a/swift/script/build-artifactbundle.sh b/swift/script/build-artifactbundle.sh index 0ea90d6..8474c7c 100755 --- a/swift/script/build-artifactbundle.sh +++ b/swift/script/build-artifactbundle.sh @@ -9,8 +9,12 @@ # # Linux triples are cross-compiled from any host given a real cross C # toolchain per target triple (on Debian/Ubuntu, `apt-get install -# gcc-x86-64-linux-gnu gcc-aarch64-linux-gnu` covers both). Override -# CC_ to point at a different compiler for a given triple. +# gcc-x86-64-linux-gnu gcc-aarch64-linux-gnu libc6-dev-amd64-cross +# libc6-dev-arm64-cross` covers both - the gcc packages alone are enough for +# whichever arch matches the host, but the other triple needs its libc6-dev-* +# -cross package too or cgo fails with "bits/libc-header-start.h: No such +# file or directory"). Override CC_ to point at a different +# compiler for a given triple. # # macOS triples can only be built when this script itself runs on macOS - # Go's cgo needs a real Mach-O-emitting C toolchain, which isn't available @@ -75,7 +79,7 @@ build_variant() { ( cd "$REPO_ROOT" CC="$cc" CGO_ENABLED=1 GOOS="$goos" GOARCH="$goarch" \ - go build -buildmode=c-archive -o "$variant_dir/libtailscale.a" . + go build -buildvcs=false -buildmode=c-archive -o "$variant_dir/libtailscale.a" . ) rm -f "$variant_dir/libtailscale.h" # go build also writes a header; we ship our own below diff --git a/swift/script/fix-tstestcontrol-archive.sh b/swift/script/fix-tstestcontrol-archive.sh index f209828..7613819 100755 --- a/swift/script/fix-tstestcontrol-archive.sh +++ b/swift/script/fix-tstestcontrol-archive.sh @@ -3,27 +3,43 @@ # SPDX-License-Identifier: BSD-3-Clause # # libtailscale.a and tstestcontrol/libtstestcontrol.a are two independently -# built Go c-archives. Each embeds its own copy of the cgo runtime glue -# (_cgo_topofstack, _cgo_panic, crosscall2) under the same fixed symbol -# names. Both ld.gold on Linux and Apple's linker reject them as duplicate -# symbols when flat-linked into one binary (which is exactly what SwiftPM -# does for every target, on every platform - Xcode only avoids this because -# its framework build never puts both go.o's in the same link). Forcing -# Linux's linker through with --allow-multiple-definition silently drops one -# archive's copy, corrupting that archive's stack-unwind info at runtime, so -# that's not an option either. +# built Go c-archives. Each embeds its own full copy of the cgo runtime glue +# (crosscall2, x_cgo_thread_start, x_cgo_init, etc.) under the same fixed +# symbol names, since that glue is plain C source in runtime/cgo with no +# per-package mangling. A handful of those names (_cgo_topofstack, +# _cgo_panic, crosscall2) are rejected outright as duplicate symbols by both +# ld.gold on Linux and Apple's linker when flat-linked into one binary +# (which is exactly what SwiftPM does for every target, on every platform - +# Xcode only avoids this because its framework build never puts both go.o's +# in the same link). Forcing Linux's linker through with +# --allow-multiple-definition isn't an option either: it silently drops one +# archive's copy, corrupting that archive's stack-unwind info at runtime. # -# This rewrites those three symbol names inside a *copy* of +# The rest of that duplicated glue (there are dozens more: x_cgo_init, +# x_cgo_thread_start, x_cgo_sys_thread_create, x_cgo_getstackbound, +# crosscall1, ...) doesn't fail the link at all - the linker just silently +# resolves each reference from whichever archive member happens to satisfy +# it first. That's just as dangerous as -allow-multiple-definition: it can +# splice one program's thread/stack bookkeeping into the other program's +# runtime, so the first time either program starts a fresh OS thread, it can +# crash deep in runtime.mstart0 with a corrupted stack pointer. So every +# overlapping global symbol needs renaming, not just the ones the linker +# happens to reject. +# +# Rather than hardcode that list (it depends on the Go version's +# runtime/cgo internals, not on anything in this repo), this script +# computes it: it diffs the defined global symbols of libtailscale.a +# (already built by build-artifactbundle.sh) against libtstestcontrol.a and +# renames every name that appears in both, inside a *copy* of # libtstestcontrol.a (consistently, across every member object, so its own -# internal linkage stays self-consistent) so the two archives no longer -# collide. Run this after building tstestcontrol/libtstestcontrol.a and -# before `swift build`/`swift test`. +# internal linkage stays self-consistent). Run this after building +# tstestcontrol/libtstestcontrol.a and before `swift build`/`swift test`. # # Mach-O object files (macOS) mangle every C symbol with an extra leading # underscore on disk (`crosscall2` -> `_crosscall2`, `_cgo_panic` -> -# `__cgo_panic`); ELF (Linux) doesn't. objcopy/llvm-objcopy operate on the -# raw on-disk name, not the source-level one, so the symbol list below is -# platform-dependent. +# `__cgo_panic`); ELF (Linux) doesn't. objcopy/llvm-objcopy and nm operate +# on the raw on-disk name, not the source-level one, so the symbol names +# collected below are platform-dependent. set -eu @@ -40,6 +56,15 @@ if [ ! -f "$SRC_ARCHIVE" ]; then exit 1 fi +# Any built libtailscale.a variant works as the reference: the glue symbol +# *names* below come from Go's runtime/cgo C source, which doesn't vary by +# GOOS/GOARCH, only their addresses do. +REF_LIBTAILSCALE=$(find "$SWIFT_DIR/build" -name libtailscale.a -print -quit 2>/dev/null || true) +if [ -z "$REF_LIBTAILSCALE" ]; then + echo "error: no built libtailscale.a found under $SWIFT_DIR/build (run 'make spm-setup' first)" >&2 + exit 1 +fi + # macOS doesn't ship objcopy on PATH by default. Xcode's command line tools are # built on LLVM but don't expose llvm-objcopy; a swift.org open-source toolchain # sometimes does under its own usr/bin, which `xcrun -f` can find even when it's @@ -62,18 +87,42 @@ if [ -z "$OBJCOPY" ]; then fi echo "using $OBJCOPY" -# See the Mach-O note above: on-disk symbol names need an extra leading `_` -# on Darwin that ELF doesn't have. -if [ "$(uname -s)" = "Darwin" ]; then - SYM_PREFIX="_" -else - SYM_PREFIX="" +NM="" +for candidate in nm llvm-nm gnm; do + if command -v "$candidate" >/dev/null 2>&1; then + NM="$candidate" + break + fi +done +if [ -z "$NM" ] && command -v xcrun >/dev/null 2>&1; then + NM=$(xcrun -f llvm-nm 2>/dev/null || true) +fi +if [ -z "$NM" ]; then + echo "error: no nm found" >&2 + exit 1 fi mkdir -p "$OUT_DIR" WORK_DIR=$(mktemp -d) trap 'rm -rf "$WORK_DIR"' EXIT +# Global (externally visible) *defined* symbol names in each archive - `-g +# --defined-only` skips both local symbols (which can't collide across +# archives) and undefined references (which aren't definitions to collide +# over). +"$NM" -g --defined-only "$REF_LIBTAILSCALE" 2>/dev/null | awk 'NF==3 {print $3}' | sort -u > "$WORK_DIR/libtailscale.syms" +"$NM" -g --defined-only "$SRC_ARCHIVE" 2>/dev/null | awk 'NF==3 {print $3}' | sort -u > "$WORK_DIR/libtstestcontrol.syms" +comm -12 "$WORK_DIR/libtailscale.syms" "$WORK_DIR/libtstestcontrol.syms" > "$WORK_DIR/shared.syms" + +SHARED_COUNT=$(wc -l < "$WORK_DIR/shared.syms") +echo "renaming $SHARED_COUNT symbol(s) shared between libtailscale.a and libtstestcontrol.a" + +REDEFINE_ARGS="" +while IFS= read -r sym; do + [ -z "$sym" ] && continue + REDEFINE_ARGS="$REDEFINE_ARGS --redefine-sym ${sym}=${sym}_tstestcontrol" +done < "$WORK_DIR/shared.syms" + cp "$SRC_ARCHIVE" "$WORK_DIR/libtstestcontrol.a" cd "$WORK_DIR" mkdir extract @@ -81,11 +130,8 @@ cd extract ar x ../libtstestcontrol.a for f in *.o; do - "$OBJCOPY" \ - --redefine-sym "${SYM_PREFIX}_cgo_topofstack=${SYM_PREFIX}_cgo_topofstack_tstestcontrol" \ - --redefine-sym "${SYM_PREFIX}_cgo_panic=${SYM_PREFIX}_cgo_panic_tstestcontrol" \ - --redefine-sym "${SYM_PREFIX}crosscall2=${SYM_PREFIX}crosscall2_tstestcontrol" \ - "$f" + # shellcheck disable=SC2086 + "$OBJCOPY" $REDEFINE_ARGS "$f" done rm -f "$OUT_ARCHIVE" diff --git a/tstestcontrol/Makefile b/tstestcontrol/Makefile index 7336c4a..e6f595c 100644 --- a/tstestcontrol/Makefile +++ b/tstestcontrol/Makefile @@ -6,7 +6,7 @@ all: libtstestcontrol.a libtstestcontrol.a: - go build -buildmode=c-archive -o $@ + go build -buildvcs=false -buildmode=c-archive -o $@ clean: rm -f libtstestcontrol.a From 9fac36327bfb3be932bc70a6dba7370730ded93d Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Sat, 11 Jul 2026 20:11:57 -0500 Subject: [PATCH 08/10] TimeInterval fix for Int32 --- swift/TailscaleKit/IncomingConnection.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/swift/TailscaleKit/IncomingConnection.swift b/swift/TailscaleKit/IncomingConnection.swift index 663f1ed..212bc4f 100644 --- a/swift/TailscaleKit/IncomingConnection.swift +++ b/swift/TailscaleKit/IncomingConnection.swift @@ -56,7 +56,7 @@ public actor IncomingConnection { /// Returns up to size bytes from the connection. Blocks until /// data is available - public func receive(maximumLength: Int = 4096, timeout: Int32) async throws -> Data { + public func receive(maximumLength: Int = 4096, timeout: TimeInterval) async throws -> Data { guard stateBroadcaster.value == .connected else { throw TailscaleError.connectionClosed } @@ -65,7 +65,7 @@ public actor IncomingConnection { } /// Reads a complete message from the connection - public func receiveMessage(timeout: Int32) async throws -> Data { + public func receiveMessage(timeout: TimeInterval) async throws -> Data { guard stateBroadcaster.value == .connected else { throw TailscaleError.connectionClosed } @@ -86,9 +86,13 @@ private actor SocketReader { self.conn = conn } - func read(timeout: Int32, len: Int) throws -> Data { + func read(timeout: TimeInterval, len: Int) throws -> Data { + guard timeout >= 0, timeout * 1000 <= Double(Int32.max) else { + throw TailscaleError.invalidTimeout + } + var p: pollfd = .init(fd: conn, events: Int16(POLLIN), revents: 0) - let res = poll(&p, 1, timeout) + let res = poll(&p, 1, Int32(timeout * 1000)) guard res > 0 else { throw TailscaleError.readFailed } @@ -104,7 +108,7 @@ private actor SocketReader { return Data(buffer[0.. Data { + func readAll(timeout: TimeInterval) throws -> Data { var data: Data = .init() while true { let read = try read(timeout: timeout, len: Self.maxBufferSize) From 030758376cf8ccb8d51bb40cacde998d7dce304b Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Sun, 12 Jul 2026 10:09:50 -0500 Subject: [PATCH 09/10] revert combine with ifdef --- swift/TailscaleKit/IncomingConnection.swift | 45 ++++++++++++++++++--- swift/TailscaleKit/Listener.swift | 33 +++++++++++++-- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/swift/TailscaleKit/IncomingConnection.swift b/swift/TailscaleKit/IncomingConnection.swift index 212bc4f..c62bb3a 100644 --- a/swift/TailscaleKit/IncomingConnection.swift +++ b/swift/TailscaleKit/IncomingConnection.swift @@ -7,6 +7,10 @@ import FoundationEssentials import Foundation #endif +#if canImport(Combine) +import Combine +#endif + #if canImport(Darwin) import Darwin #elseif canImport(Glibc) @@ -25,18 +29,29 @@ public actor IncomingConnection { public let remoteAddress: String? + #if canImport(Combine) + @Published var _state: ConnectionState = .idle + #else private var stateBroadcaster = StateBroadcaster(.idle) + #endif public func state() -> some AsyncSequence { + #if canImport(Combine) + $_state + .removeDuplicates() + .eraseToAnyPublisher() + .values + #else stateBroadcaster.subscribe() + #endif } init(conn: TailscaleConnection, remoteAddress: String?, logger: (any LogSink)? = nil) async { self.logger = logger self.conn = conn - stateBroadcaster.set(.connected) self.remoteAddress = remoteAddress - reader = SocketReader(conn: conn) + self.reader = SocketReader(conn: conn) + setConnectionState(.connected) } deinit { @@ -50,14 +65,13 @@ public actor IncomingConnection { _ = System.close(conn) conn = 0 } - stateBroadcaster.set(.closed) - stateBroadcaster.finish() + setConnectionState(.closed) } /// Returns up to size bytes from the connection. Blocks until /// data is available public func receive(maximumLength: Int = 4096, timeout: TimeInterval) async throws -> Data { - guard stateBroadcaster.value == .connected else { + guard connectionState == .connected else { throw TailscaleError.connectionClosed } @@ -66,12 +80,31 @@ public actor IncomingConnection { /// Reads a complete message from the connection public func receiveMessage(timeout: TimeInterval) async throws -> Data { - guard stateBroadcaster.value == .connected else { + guard connectionState == .connected else { throw TailscaleError.connectionClosed } return try await reader.readAll(timeout: timeout) } + + private var connectionState: ConnectionState { + #if canImport(Combine) + _state + #else + stateBroadcaster.value + #endif + } + + private func setConnectionState(_ state: ConnectionState) { + #if canImport(Combine) + _state = state + #else + stateBroadcaster.set(state) + if state == .closed { + stateBroadcaster.finish() + } + #endif + } } /// Serializes read operations from an IncomingConnection diff --git a/swift/TailscaleKit/Listener.swift b/swift/TailscaleKit/Listener.swift index 92acbd7..1955ee9 100644 --- a/swift/TailscaleKit/Listener.swift +++ b/swift/TailscaleKit/Listener.swift @@ -7,6 +7,10 @@ import Foundation import CTailscale #endif +#if canImport(Combine) +import Combine +#endif + /// A Listener is used to await incoming connections from another /// Tailnet node. public actor Listener { @@ -17,10 +21,21 @@ public actor Listener { private let logger: (any LogSink)? + #if canImport(Combine) + @Published var _state: ListenerState = .idle + #else private var stateBroadcaster = StateBroadcaster(.idle) + #endif public func state() -> some AsyncSequence { + #if canImport(Combine) + $_state + .removeDuplicates() + .eraseToAnyPublisher() + .values + #else stateBroadcaster.subscribe() + #endif } /// Initializes and readies a new listener @@ -41,13 +56,13 @@ public actor Listener { let res = tailscale_listen(tailscale, proto.rawValue, address, &listener) guard res == 0 else { - stateBroadcaster.set(.failed) + setConnectionState(.failed) let msg = tailscale.getErrorMessage() let err = TailscaleError.fromPosixErrCode(res, msg) logger?.log("Listener failed to initialize: \(msg) (\(err.localizedDescription))") throw err } - stateBroadcaster.set(.listening) + setConnectionState(.listening) } deinit { @@ -63,8 +78,7 @@ public actor Listener { _ = System.close(listener) listener = 0 } - stateBroadcaster.set(.closed) - stateBroadcaster.finish() + setConnectionState(.closed) } /// Blocks and awaits a new incoming connection @@ -126,4 +140,15 @@ public actor Listener { remoteAddress: remoteAddress, logger: logger) } + + private func setConnectionState(_ state: ListenerState) { + #if canImport(Combine) + _state = state + #else + stateBroadcaster.set(state) + if state == .closed { + stateBroadcaster.finish() + } + #endif + } } From fcd9885ef56f930948d0182bab462612f7a6f4ba Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Sun, 12 Jul 2026 10:12:30 -0500 Subject: [PATCH 10/10] import indents fix --- swift/TailscaleKit/IncomingConnection.swift | 6 +++--- swift/TailscaleKit/Listener.swift | 2 +- swift/TailscaleKit/LocalAPI/LocalAPIClient.swift | 2 +- swift/TailscaleKit/LocalAPI/MessageProcessor.swift | 2 +- swift/TailscaleKit/LocalAPI/MessageReader.swift | 2 +- swift/TailscaleKit/LogSink.swift | 6 +++--- swift/TailscaleKit/OutgoingConnection.swift | 2 +- swift/TailscaleKit/PlatformShims.swift | 6 +++--- swift/TailscaleKit/TailscaleError.swift | 8 ++++---- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/swift/TailscaleKit/IncomingConnection.swift b/swift/TailscaleKit/IncomingConnection.swift index c62bb3a..b376909 100644 --- a/swift/TailscaleKit/IncomingConnection.swift +++ b/swift/TailscaleKit/IncomingConnection.swift @@ -12,11 +12,11 @@ import Combine #endif #if canImport(Darwin) - import Darwin +import Darwin #elseif canImport(Glibc) - import Glibc +import Glibc #elseif canImport(Musl) - import Musl +import Musl #endif /// IncomingConnection is use to read incoming message from an inbound diff --git a/swift/TailscaleKit/Listener.swift b/swift/TailscaleKit/Listener.swift index 1955ee9..5e96b66 100644 --- a/swift/TailscaleKit/Listener.swift +++ b/swift/TailscaleKit/Listener.swift @@ -4,7 +4,7 @@ import Foundation #if canImport(CTailscale) - import CTailscale +import CTailscale #endif #if canImport(Combine) diff --git a/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift b/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift index 97b654b..945b782 100644 --- a/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift +++ b/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift @@ -8,7 +8,7 @@ import Foundation #endif #if canImport(FoundationNetworking) - import FoundationNetworking +import FoundationNetworking #endif let kLocalAPIPath = "/localapi/v0/" diff --git a/swift/TailscaleKit/LocalAPI/MessageProcessor.swift b/swift/TailscaleKit/LocalAPI/MessageProcessor.swift index 8efaec1..f8a7631 100644 --- a/swift/TailscaleKit/LocalAPI/MessageProcessor.swift +++ b/swift/TailscaleKit/LocalAPI/MessageProcessor.swift @@ -4,7 +4,7 @@ import Foundation #if canImport(FoundationNetworking) - import FoundationNetworking +import FoundationNetworking #endif let kJsonNewline = UInt8(ascii: "\n") diff --git a/swift/TailscaleKit/LocalAPI/MessageReader.swift b/swift/TailscaleKit/LocalAPI/MessageReader.swift index cea1b5b..3206acb 100644 --- a/swift/TailscaleKit/LocalAPI/MessageReader.swift +++ b/swift/TailscaleKit/LocalAPI/MessageReader.swift @@ -4,7 +4,7 @@ import Foundation #if canImport(FoundationNetworking) - import FoundationNetworking +import FoundationNetworking #endif enum MessageQueueError: Error { diff --git a/swift/TailscaleKit/LogSink.swift b/swift/TailscaleKit/LogSink.swift index f08b683..626fe66 100644 --- a/swift/TailscaleKit/LogSink.swift +++ b/swift/TailscaleKit/LogSink.swift @@ -4,11 +4,11 @@ import Foundation #if canImport(Darwin) - import Darwin +import Darwin #elseif canImport(Glibc) - import Glibc +import Glibc #elseif canImport(Musl) - import Musl +import Musl #endif /// A generic interface for sinking log messages from the Swift wrapper diff --git a/swift/TailscaleKit/OutgoingConnection.swift b/swift/TailscaleKit/OutgoingConnection.swift index cc25c5f..7d4b124 100644 --- a/swift/TailscaleKit/OutgoingConnection.swift +++ b/swift/TailscaleKit/OutgoingConnection.swift @@ -4,7 +4,7 @@ import Foundation #if canImport(CTailscale) - import CTailscale +import CTailscale #endif /// ConnectionState indicates the state of individual TSConnection instances diff --git a/swift/TailscaleKit/PlatformShims.swift b/swift/TailscaleKit/PlatformShims.swift index 67aa56c..e45ea88 100644 --- a/swift/TailscaleKit/PlatformShims.swift +++ b/swift/TailscaleKit/PlatformShims.swift @@ -2,11 +2,11 @@ // SPDX-License-Identifier: BSD-3-Clause #if canImport(Darwin) - import Darwin +import Darwin #elseif canImport(Glibc) - import Glibc +import Glibc #elseif canImport(Musl) - import Musl +import Musl #endif // Namespace for the platform libc calls. Actor types in this module declare diff --git a/swift/TailscaleKit/TailscaleError.swift b/swift/TailscaleKit/TailscaleError.swift index a2a5cfe..7d42bd6 100644 --- a/swift/TailscaleKit/TailscaleError.swift +++ b/swift/TailscaleKit/TailscaleError.swift @@ -8,15 +8,15 @@ import Foundation #endif #if canImport(CTailscale) - import CTailscale +import CTailscale #endif #if canImport(Darwin) - import Darwin +import Darwin #elseif canImport(Glibc) - import Glibc +import Glibc #elseif canImport(Musl) - import Musl +import Musl #endif public enum TailscaleError: Error {