diff --git a/App/AppleTVDiscovery.swift b/App/AppleTVDiscovery.swift
new file mode 100644
index 0000000..3465819
--- /dev/null
+++ b/App/AppleTVDiscovery.swift
@@ -0,0 +1,94 @@
+import Foundation
+
+struct AppleTVDevice: Identifiable, Equatable {
+ let id: String
+ let name: String
+ let host: String
+ let port: Int
+}
+
+@MainActor
+final class AppleTVDiscovery: NSObject, ObservableObject {
+ @Published private(set) var devices: [AppleTVDevice] = []
+
+ private let browser = NetServiceBrowser()
+ private var services: [String: NetService] = [:]
+
+ override init() {
+ super.init()
+ browser.delegate = self
+ }
+
+ func start() {
+ guard services.isEmpty else { return }
+ browser.searchForServices(
+ ofType: "_remotepairing-manual-pairing._tcp.",
+ inDomain: "local.")
+ }
+
+ func stop() {
+ browser.stop()
+ for service in services.values {
+ service.stop()
+ }
+ services.removeAll()
+ devices.removeAll()
+ }
+
+ private func identifier(for service: NetService) -> String {
+ "\(service.name).\(service.type)\(service.domain)"
+ }
+
+ private func update(_ service: NetService) {
+ guard let host = service.hostName, service.port > 0 else { return }
+ let id = identifier(for: service)
+ let txt = service.txtRecordData().map(NetService.dictionary(fromTXTRecord:)) ?? [:]
+ let advertisedName = txt["name"].flatMap { String(data: $0, encoding: .utf8) }
+ let displayName = advertisedName.flatMap { $0.isEmpty ? nil : $0 } ?? service.name
+ let device = AppleTVDevice(
+ id: id,
+ name: displayName,
+ host: host,
+ port: service.port)
+ devices.removeAll { $0.id == id }
+ devices.append(device)
+ devices.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ }
+}
+
+extension AppleTVDiscovery: NetServiceBrowserDelegate {
+ nonisolated func netServiceBrowser(
+ _ browser: NetServiceBrowser,
+ didFind service: NetService,
+ moreComing: Bool
+ ) {
+ Task { @MainActor in
+ let id = identifier(for: service)
+ services[id] = service
+ service.delegate = self
+ service.resolve(withTimeout: 6)
+ }
+ }
+
+ nonisolated func netServiceBrowser(
+ _ browser: NetServiceBrowser,
+ didRemove service: NetService,
+ moreComing: Bool
+ ) {
+ Task { @MainActor in
+ let id = identifier(for: service)
+ services.removeValue(forKey: id)
+ devices.removeAll { $0.id == id }
+ }
+ }
+}
+
+extension AppleTVDiscovery: NetServiceDelegate {
+ nonisolated func netServiceDidResolveAddress(_ sender: NetService) {
+ Task { @MainActor in update(sender) }
+ }
+
+ nonisolated func netService(_ sender: NetService, didUpdateTXTRecord data: Data) {
+ Task { @MainActor in update(sender) }
+ }
+}
diff --git a/App/ContentView.swift b/App/ContentView.swift
index 69e7581..99ba71f 100644
--- a/App/ContentView.swift
+++ b/App/ContentView.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct ContentView: View {
@StateObject private var controller = PairingController.shared
+ @State private var appleTVPin = ""
var body: some View {
VStack(spacing: 28) {
@@ -29,7 +30,17 @@ struct ContentView: View {
Button {
controller.start()
} label: {
- Text("Pair")
+ Text("Pair iPhone or iPad")
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.glass)
+ .controlSize(.large)
+
+ Button {
+ controller.browseForAppleTVs()
+ } label: {
+ Text("Pair Apple TV")
.font(.headline)
.frame(maxWidth: .infinity)
}
@@ -78,6 +89,73 @@ struct ContentView: View {
ProgressView()
}
+ case .browsingAppleTV:
+ VStack(spacing: 16) {
+ Text("Choose an Apple TV")
+ .font(.headline)
+ Text("On Apple TV, open **Settings › Remotes and Devices › Remote App and Devices**.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+
+ if controller.appleTVs.isEmpty {
+ ProgressView("Looking for Apple TVs…")
+ } else {
+ ForEach(controller.appleTVs) { device in
+ Button {
+ appleTVPin = ""
+ controller.pairAppleTV(device)
+ } label: {
+ Label(device.name, systemImage: "appletv")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.glass)
+ .controlSize(.large)
+ }
+ }
+
+ Button("Cancel") { controller.cancelAppleTVPairing() }
+ .buttonStyle(.glass)
+ }
+
+ case .enteringAppleTVPin(let device):
+ VStack(spacing: 16) {
+ Text("Pair with \(device.name)")
+ .font(.headline)
+ Text("Enter the six-digit code shown on your Apple TV.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ TextField("000000", text: $appleTVPin)
+ .keyboardType(.numberPad)
+ .textContentType(.oneTimeCode)
+ .multilineTextAlignment(.center)
+ .font(.system(size: 32, weight: .bold, design: .rounded))
+ .monospacedDigit()
+ .onChange(of: appleTVPin) { _, value in
+ appleTVPin = String(value.filter(\.isNumber).prefix(6))
+ }
+ .padding()
+ .glassEffect(.regular, in: .rect(cornerRadius: 16))
+ Button("Pair") {
+ controller.submitAppleTVPin(appleTVPin)
+ }
+ .buttonStyle(.glass)
+ .controlSize(.large)
+ .disabled(appleTVPin.count != 6)
+ Button("Cancel") { controller.cancelAppleTVPairing() }
+ .buttonStyle(.glass)
+ }
+
+ case .pairingAppleTV(let name):
+ VStack(spacing: 16) {
+ ProgressView()
+ Text("Pairing with \(name)…")
+ .foregroundStyle(.secondary)
+ Button("Cancel") { controller.cancelAppleTVPairing() }
+ .buttonStyle(.glass)
+ }
+
case .success(let device):
VStack(spacing: 12) {
Label("Paired", systemImage: "checkmark.seal.fill")
diff --git a/App/Info.plist b/App/Info.plist
index 6bd4712..b80dba6 100644
--- a/App/Info.plist
+++ b/App/Info.plist
@@ -36,10 +36,11 @@
UIInterfaceOrientationLandscapeRight
NSLocalNetworkUsageDescription
- StikPair advertises a pairing service so this device can pair with it.
+ StikPair uses your local network to discover devices and complete pairing.
NSBonjourServices
_remotepairing-pairable-host._tcp
+ _remotepairing-manual-pairing._tcp
_stikpairprobe._tcp
diff --git a/App/PairingController.swift b/App/PairingController.swift
index 95a773d..2e0fb83 100644
--- a/App/PairingController.swift
+++ b/App/PairingController.swift
@@ -1,4 +1,5 @@
import BackgroundTasks
+import Combine
import Foundation
import StikPairFFI
import UserNotifications
@@ -13,6 +14,9 @@ final class PairingController: ObservableObject {
case idle
case waiting
case showPin(String)
+ case browsingAppleTV
+ case enteringAppleTVPin(AppleTVDevice)
+ case pairingAppleTV(String)
case success(PairedDevice)
case failed(String)
}
@@ -25,6 +29,7 @@ final class PairingController: ObservableObject {
}
@Published var phase: Phase = .idle
+ @Published private(set) var appleTVs: [AppleTVDevice] = []
@Published var keepAliveAudio: Bool = UserDefaults.standard.bool(forKey: "keepAlive.audio") {
didSet { UserDefaults.standard.set(keepAliveAudio, forKey: "keepAlive.audio") }
@@ -39,18 +44,29 @@ final class PairingController: ObservableObject {
private var netService: NetService?
private let localNetwork = LocalNetworkAuthorization()
private let keepAlive = KeepAlive()
+ private let appleTVDiscovery = AppleTVDiscovery()
+ private var discoverySubscription: AnyCancellable?
+ private var appleTVSession: OpaquePointer?
+ private var activeAppleTV: AppleTVDevice?
+ private var appleTVCancelled = false
private var bgTask: BGContinuedProcessingTask?
private var pairingStarted = false
private var taskFinished = false
var isRunning: Bool {
+ if pairingStarted { return true }
switch phase {
- case .waiting, .showPin: return true
+ case .waiting, .showPin, .enteringAppleTVPin, .pairingAppleTV: return true
default: return false
}
}
+ private init() {
+ discoverySubscription = appleTVDiscovery.$devices
+ .sink { [weak self] devices in self?.appleTVs = devices }
+ }
+
nonisolated func registerBackgroundTask() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: PairingController.taskIdentifier,
@@ -82,8 +98,97 @@ final class PairingController: ObservableObject {
}
}
+ func browseForAppleTVs() {
+ guard !isRunning else { return }
+ phase = .browsingAppleTV
+ Task {
+ let authorized = await localNetwork.request()
+ guard case .browsingAppleTV = phase else { return }
+ guard authorized else {
+ phase = .failed("Local Network permission is required. Enable it in Settings › StikPair › Local Network, then try again.")
+ return
+ }
+ appleTVDiscovery.start()
+ }
+ }
+
+ func pairAppleTV(_ device: AppleTVDevice) {
+ guard !pairingStarted, let session = stikpair_apple_tv_session_new() else { return }
+ appleTVDiscovery.stop()
+ appleTVSession = session
+ activeAppleTV = device
+ appleTVCancelled = false
+ pairingStarted = true
+ phase = .pairingAppleTV(device.name)
+
+ let name = hostName
+ let outPath = Self.pairingFilePath()
+ let sessionBits = UInt(bitPattern: session)
+ let ctxBits = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque())
+ DispatchQueue.global(qos: .userInitiated).async {
+ guard let session = OpaquePointer(bitPattern: sessionBits) else { return }
+ let ctx = UnsafeMutableRawPointer(bitPattern: ctxBits)
+ var result = StikPairResult()
+ let rc = device.host.withCString { hostC in
+ name.withCString { nameC in
+ outPath.withCString { outC in
+ stikpair_apple_tv_session_run(
+ session, hostC, UInt16(device.port), nameC, outC,
+ appleTVPinCallback, ctx, &result)
+ }
+ }
+ }
+
+ let outcome: Phase
+ if rc == 0 {
+ outcome = .success(PairedDevice(
+ name: cString(result.device_name),
+ model: cString(result.device_model),
+ udid: cString(result.device_udid),
+ pairingFilePath: cString(result.pairing_file_path)))
+ } else {
+ let message = cString(result.error)
+ outcome = .failed(message.isEmpty ? "Apple TV pairing failed (code \(rc))" : message)
+ }
+ stikpair_result_free(&result)
+
+ DispatchQueue.main.async {
+ let cancelled = self.appleTVCancelled
+ if self.appleTVSession == session {
+ self.appleTVSession = nil
+ }
+ stikpair_apple_tv_session_free(session)
+ self.activeAppleTV = nil
+ self.pairingStarted = false
+ guard !cancelled else { return }
+ self.phase = outcome
+ if case .success = outcome {
+ self.postReturnNotification()
+ }
+ }
+ }
+ }
+
+ func submitAppleTVPin(_ pin: String) {
+ guard pin.count == 6, pin.allSatisfy(\.isNumber), let session = appleTVSession else { return }
+ let rc = pin.withCString { stikpair_apple_tv_session_submit_pin(session, $0) }
+ if rc == 0, let device = activeAppleTV {
+ phase = .pairingAppleTV(device.name)
+ }
+ }
+
+ func cancelAppleTVPairing() {
+ appleTVCancelled = true
+ if let session = appleTVSession {
+ stikpair_apple_tv_session_cancel(session)
+ }
+ appleTVDiscovery.stop()
+ phase = .idle
+ }
+
func reset() {
guard !isRunning else { return }
+ appleTVDiscovery.stop()
phase = .idle
}
@@ -200,6 +305,11 @@ final class PairingController: ObservableObject {
}
}
+ fileprivate func presentAppleTVPin() {
+ guard !appleTVCancelled, let device = activeAppleTV else { return }
+ phase = .enteringAppleTVPin(device)
+ }
+
private func stopAdvertising() {
netService?.stop()
netService = nil
@@ -253,6 +363,14 @@ private let pinCallback: StikPairPinCb = { pin, ctx in
}
}
+private let appleTVPinCallback: StikPairAppleTvPinCb = { ctx in
+ guard let ctx = ctx else { return }
+ let controller = Unmanaged.fromOpaque(ctx).takeUnretainedValue()
+ DispatchQueue.main.async {
+ controller.presentAppleTVPin()
+ }
+}
+
private func cString(_ ptr: UnsafeMutablePointer?) -> String {
guard let ptr = ptr else { return "" }
return String(cString: ptr)
diff --git a/README.md b/README.md
index 47c0107..9a78781 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# StikPair
-A simple SwiftUI iOS app that creates a pairing file **on-device**, using iOS 27+ wireless pairing. Powered by [idevice](https://github.com/jkcoxson/idevice).
+A simple SwiftUI iOS app that creates a pairing file **on-device**, using iOS 27+ or tvOS 11+ wireless pairing. Powered by [idevice](https://github.com/jkcoxson/idevice).
## Requirements
@@ -20,12 +20,21 @@ Set your signing team and run on a device.
## Use
-1. Tap **Pair** and grant the Local Network prompt.
+### iPhone or iPad
+
+1. Tap **Pair iPhone or iPad** and grant the Local Network prompt.
2. On the device: **Settings › Privacy & Security › Developer Mode**, scroll down,
tap **Pair with StikPair**, and enter the PIN shown in the Live Activity.
3. When the "Pairing complete" notification arrives, return to the app and tap
**Export Pairing File**.
+### Apple TV
+
+1. On Apple TV, open **Settings › Remotes and Devices › Remote App and Devices**.
+2. Tap **Pair Apple TV** in StikPair and select the Apple TV.
+3. Enter the six-digit code shown on the Apple TV.
+4. When pairing completes, tap **Export Pairing File**.
+
## License
MIT, **non-commercial**. Free for personal/non-commercial use; for commercial use contact StephenDev0@outlook.com. See [LICENSE](LICENSE).
diff --git a/rust/include/stikpair.h b/rust/include/stikpair.h
index 0e2de1e..78a7f82 100644
--- a/rust/include/stikpair.h
+++ b/rust/include/stikpair.h
@@ -18,6 +18,9 @@ typedef void (*StikPairReadyCb)(void *ctx,
size_t txt_count);
typedef void (*StikPairPinCb)(const char *pin, void *ctx);
+typedef void (*StikPairAppleTvPinCb)(void *ctx);
+
+typedef struct StikPairAppleTvSession StikPairAppleTvSession;
typedef struct {
char *error;
@@ -38,6 +41,23 @@ int32_t stikpair_run_host(const char *bind_addr,
void *ctx,
StikPairResult *out);
+StikPairAppleTvSession *stikpair_apple_tv_session_new(void);
+
+int32_t stikpair_apple_tv_session_run(StikPairAppleTvSession *session,
+ const char *host,
+ uint16_t port,
+ const char *name,
+ const char *out_path,
+ StikPairAppleTvPinCb pin_cb,
+ void *ctx,
+ StikPairResult *out);
+
+int32_t stikpair_apple_tv_session_submit_pin(StikPairAppleTvSession *session,
+ const char *pin);
+
+void stikpair_apple_tv_session_cancel(StikPairAppleTvSession *session);
+void stikpair_apple_tv_session_free(StikPairAppleTvSession *session);
+
void stikpair_result_free(StikPairResult *r);
#ifdef __cplusplus
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
index 01d1cde..b0c9670 100644
--- a/rust/src/lib.rs
+++ b/rust/src/lib.rs
@@ -8,11 +8,12 @@
use std::ffi::{c_char, c_void, CStr, CString};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::ptr;
+use std::sync::{mpsc, Arc, Mutex};
use idevice::remote_pairing::{
- PairableHost, PairableHostInfo, RpPairingFile, RpPairingSocket,
+ PairableHost, PairableHostInfo, RemotePairingClient, RpPairingFile, RpPairingSocket,
};
-use tokio::net::TcpListener;
+use tokio::net::{TcpListener, TcpStream};
pub type StikPairReadyCb = Option<
extern "C" fn(
@@ -26,6 +27,12 @@ pub type StikPairReadyCb = Option<
>;
pub type StikPairPinCb = Option;
+pub type StikPairAppleTvPinCb = Option;
+
+#[repr(C)]
+pub struct StikPairAppleTvSession {
+ pin_sender: Mutex