From 33d7d3f60fc9454d99e16ce866024af81881f72e Mon Sep 17 00:00:00 2001 From: CelloSerenity <195480169+CelloSerenity@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:28:11 -0600 Subject: [PATCH 1/2] feat: add Apple TV pairing support --- App/AppleTVDiscovery.swift | 94 ++++++++++++++++++ App/ContentView.swift | 80 +++++++++++++++- App/Info.plist | 3 +- App/PairingController.swift | 120 ++++++++++++++++++++++- README.md | 13 ++- rust/include/stikpair.h | 20 ++++ rust/src/lib.rs | 185 +++++++++++++++++++++++++++++++++++- 7 files changed, 506 insertions(+), 9 deletions(-) create mode 100644 App/AppleTVDiscovery.swift 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..129a79d 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+ wireless pairing or Apple TV manual 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>>, +} #[repr(C)] pub struct StikPairResult { @@ -92,7 +99,11 @@ pub unsafe extern "C" fn stikpair_run_host( let name = opt_str(name, "StikPair"); let model = opt_str(model, "Mac17,7"); let out_path = opt_str(out_path, "rp_pairing_file.plist"); - let cbs = Callbacks { ready: ready_cb, pin: pin_cb, ctx }; + let cbs = Callbacks { + ready: ready_cb, + pin: pin_cb, + ctx, + }; let rt = match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -121,6 +132,114 @@ pub unsafe extern "C" fn stikpair_run_host( } } +#[no_mangle] +pub extern "C" fn stikpair_apple_tv_session_new() -> *mut StikPairAppleTvSession { + Box::into_raw(Box::new(StikPairAppleTvSession { + pin_sender: Mutex::new(None), + })) +} + +#[no_mangle] +pub unsafe extern "C" fn stikpair_apple_tv_session_run( + session: *mut StikPairAppleTvSession, + host: *const c_char, + port: u16, + name: *const c_char, + out_path: *const c_char, + pin_cb: StikPairAppleTvPinCb, + ctx: *mut c_void, + out: *mut StikPairResult, +) -> i32 { + if session.is_null() || out.is_null() { + return 2; + } + *out = StikPairResult::empty(); + + let host = opt_str(host, ""); + let name = opt_str(name, "StikPair"); + let out_path = opt_str(out_path, "rp_pairing_file.plist"); + if host.is_empty() { + (*out).error = cstr("invalid Apple TV address"); + return 1; + } + + let (pin_sender, pin_receiver) = mpsc::channel(); + *(*session).pin_sender.lock().unwrap() = Some(pin_sender); + let callbacks = AppleTvCallbacks { pin: pin_cb, ctx }; + + let rt = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + (*out).error = cstr(format!("failed to start runtime: {e}")); + return 1; + } + }; + + let result = rt.block_on(pair_apple_tv( + host, + port, + name, + out_path, + pin_receiver, + callbacks, + )); + *(*session).pin_sender.lock().unwrap() = None; + + match result { + Ok(res) => { + (*out).device_name = cstr(res.name); + (*out).device_model = cstr(res.model); + (*out).device_udid = cstr(res.udid); + (*out).pairing_file_path = cstr(res.path); + (*out).host_alt_irk_hex = cstr(res.host_alt_irk_hex); + 0 + } + Err(e) => { + (*out).error = cstr(e); + 1 + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn stikpair_apple_tv_session_submit_pin( + session: *mut StikPairAppleTvSession, + pin: *const c_char, +) -> i32 { + if session.is_null() { + return 2; + } + let pin = opt_str(pin, ""); + if pin.len() != 6 || !pin.bytes().all(|byte| byte.is_ascii_digit()) { + return 1; + } + let Some(sender) = (*session).pin_sender.lock().unwrap().take() else { + return 2; + }; + if sender.send(pin).is_ok() { + 0 + } else { + 2 + } +} + +#[no_mangle] +pub unsafe extern "C" fn stikpair_apple_tv_session_cancel(session: *mut StikPairAppleTvSession) { + if !session.is_null() { + (*session).pin_sender.lock().unwrap().take(); + } +} + +#[no_mangle] +pub unsafe extern "C" fn stikpair_apple_tv_session_free(session: *mut StikPairAppleTvSession) { + if !session.is_null() { + drop(Box::from_raw(session)); + } +} + struct Paired { name: String, model: String, @@ -193,6 +312,62 @@ async fn run( }) } +async fn pair_apple_tv( + host: String, + port: u16, + name: String, + out_path: String, + pin_receiver: mpsc::Receiver, + callbacks: AppleTvCallbacks, +) -> Result { + let mut pairing_file = RpPairingFile::generate(&name); + let stream = TcpStream::connect((host.as_str(), port)) + .await + .map_err(|e| format!("failed to connect to Apple TV: {e}"))?; + let mut client = RemotePairingClient::new(RpPairingSocket::new(stream), &name); + let pin_receiver = Arc::new(Mutex::new(pin_receiver)); + client + .connect(&mut pairing_file, || { + let pin_receiver = pin_receiver.clone(); + async move { + if let Some(callback) = callbacks.pin { + callback(callbacks.ctx); + } + pin_receiver + .lock() + .ok() + .and_then(|receiver| receiver.recv().ok()) + .unwrap_or_default() + } + }) + .await + .map_err(|e| format!("Apple TV pairing failed: {e}"))?; + + let peer = client + .paired_peer_device() + .map_err(|e| format!("failed to read Apple TV details: {e}"))?; + let paired = Paired { + name: peer.name.clone(), + model: peer.model.clone(), + udid: peer.remotepairing_udid.clone(), + path: out_path.clone(), + host_alt_irk_hex: String::new(), + }; + pairing_file + .write_to_file(&out_path) + .await + .map_err(|e| format!("failed to write pairing file: {e}"))?; + Ok(paired) +} + +#[derive(Clone, Copy)] +struct AppleTvCallbacks { + pin: StikPairAppleTvPinCb, + ctx: *mut c_void, +} + +unsafe impl Send for AppleTvCallbacks {} + fn hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { @@ -214,7 +389,9 @@ fn emit_ready(cbs: &Callbacks, service_id: &str, port: u16, host_info: &Pairable let key_ptrs: Vec<*const c_char> = keys.iter().map(|s| s.as_ptr()).collect(); let val_ptrs: Vec<*const c_char> = vals.iter().map(|s| s.as_ptr()).collect(); - let Ok(id_c) = CString::new(service_id) else { return }; + let Ok(id_c) = CString::new(service_id) else { + return; + }; cb( cbs.ctx, id_c.as_ptr(), From ee2ad0c7b24bfb515e2527e0e7ba9bd06a23e368 Mon Sep 17 00:00:00 2001 From: CelloSerenity <195480169+CelloSerenity@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:10:19 -0600 Subject: [PATCH 2/2] Update iOS version requirement to 27+ and tvOS to 11+ --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 129a79d..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 or Apple TV manual 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