Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .devcontainer/swift/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions .devcontainer/swift/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "swift",
"build": {
"dockerfile": "Dockerfile",
"context": "../.."
},
"workspaceFolder": "/workspace",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind",
"customizations": {
"vscode": {
"extensions": [
"swiftlang.swift-vscode",
"golang.go"
],
"settings": {
"[swift]": {
"editor.formatOnSave": false
}
}
}
},
"postCreateCommand": "go version && swift --version"
}
24 changes: 22 additions & 2 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: swift
on: [push]

jobs:
build:
build-macos:

runs-on: macos-15
steps:
Expand All @@ -17,8 +17,28 @@ jobs:
- name: Test
run: cd swift && make test

- name: iOS
- name: iOS
run: cd swift && make ios-fat

- name: macos
run: cd swift && make macos

build-linux:

runs-on: ubuntu-latest
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 libc6-dev-amd64-cross libc6-dev-arm64-cross

- name: Test
run: cd swift && make test-spm
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ libtailscale.bundle
/sourcepkg/libtailscale.tar*

/vendor/

.build/
14 changes: 14 additions & 0 deletions swift/Examples/TailscaleKitCLI/Makefile
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions swift/Examples/TailscaleKitCLI/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions swift/Examples/TailscaleKitCLI/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// swift-tools-version:6.3
import PackageDescription

let package = Package(
name: "TailscaleKitCLI",
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"),
]
)
]
)
71 changes: 71 additions & 0 deletions swift/Examples/TailscaleKitCLI/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# TailscaleKitCLI

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

## Setup

From this directory, build the artifact bundle this package needs. Run this once:

```sh
$ make spm-setup
```

Generate and export an auth key at https://login.tailscale.com/admin/settings/keys.

```sh
$ export TS_AUTHKEY=tskey-auth-...
```

## Usage

```sh
$ swift run tsdemo
```

Example output:

```
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 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.
- `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`.
80 changes: 80 additions & 0 deletions swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

import ArgumentParser
import TailscaleKit

#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif

/// A minimal proof that TailscaleKit works via plain SwiftPM (including on Linux)
@main
struct TSDemo: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "tsdemo",
abstract: "Joins a tailnet and lists its devices, using TailscaleKit via SwiftPM.",
discussion: """
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.
"""
)

@Option(help: "Tailscale auth key. Defaults to the TS_AUTHKEY environment variable.")
var authKey: String?

@Option(help: "Control plane URL.")
var controlURL: String = kDefaultControlURL

@Flag(help: "Log tsnet's internal activity to stderr instead of discarding it.")
var verbose: Bool = false

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 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
}
}
}
Original file line number Diff line number Diff line change
@@ -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...."
Expand Down Expand Up @@ -75,7 +73,7 @@ class HelloViewModel: @unchecked Sendable {

@MainActor
func setMessage(_ message: String) {
self.message = message
self.message = message
}

func runRequest(_ dialer: Dialer) {
Expand Down
24 changes: 23 additions & 1 deletion swift/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -78,6 +80,26 @@ test: ## Run tests (macOS)
-destination 'platform=macOS,arch=arm64' \
CODE_SIGNING_ALLOWED=NO

.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 "::: 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
clean: ## Clean up build artifacts (including the libtailscale dependencies)
cd .. && make clean
Expand Down
37 changes: 37 additions & 0 deletions swift/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// 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",
platforms: [.macOS(.v15), .iOS(.v18)], // same as xcodeproj
products: [
.library(name: "TailscaleKit", targets: ["TailscaleKit"])
],
targets: [
// 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"],
swiftSettings: swiftSettings
),
.testTarget(
name: "TailscaleKitTests",
dependencies: ["TailscaleKit", "CTstestControl"],
path: "TailscaleKitXCTests",
swiftSettings: swiftSettings,
// Links against build/libtstestcontrol.a, a copy with its cgo runtime
// symbols renamed to avoid colliding with libtailscale.a's copy.
linkerSettings: [.unsafeFlags(["-L", "build"])]
),
]
)
Loading