From 98801be6458d376590f7ef16fcc96835658a1e21 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 06:02:08 +0000 Subject: [PATCH] feat: add iOS Fire TV remote app (SwiftUI + native ADB client) Adds a self-contained SwiftUI iPhone app under ios/FireTVRemote that controls a Fire TV over the local network. It implements the ADB wire protocol and RSA auth handshake in pure Swift (no dependencies), so the TV trusts the phone after a one-time on-screen prompt. - ADB transport over Network framework with CNXN/AUTH/OPEN/WRTE/OKAY/CLSE - RSA key persisted in Keychain; Android adb public-key encoding - Remote UI: D-pad, navigation, transport, volume/power, app launchers, on-TV text entry - Manage multiple Fire TVs by IP; setup/help screen included Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019gGkDiRA1DmjCAVmTTUVs8 --- .../FireTVRemote.xcodeproj/project.pbxproj | 238 ++++++++++++++++++ .../FireTVRemote/ADB/ADBConnection.swift | 209 +++++++++++++++ .../FireTVRemote/ADB/ADBKey.swift | 155 ++++++++++++ .../FireTVRemote/ADB/ADBProtocol.swift | 74 ++++++ .../FireTVRemote/ADB/AndroidPubkey.swift | 62 +++++ .../FireTVRemote/ADB/BigUInt.swift | 94 +++++++ .../AccentColor.colorset/Contents.json | 20 ++ .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../FireTVRemote/ContentView.swift | 56 +++++ .../Controllers/DeviceStore.swift | 52 ++++ .../Controllers/FireTVController.swift | 73 ++++++ .../FireTVRemote/FireTVRemoteApp.swift | 15 ++ .../FireTVRemote/Models/Device.swift | 16 ++ .../FireTVRemote/Models/RemoteKey.swift | 49 ++++ .../FireTVRemote/Views/AddDeviceView.swift | 57 +++++ .../FireTVRemote/Views/DeviceListView.swift | 50 ++++ .../FireTVRemote/Views/DpadView.swift | 59 +++++ .../FireTVRemote/Views/HelpView.swift | 46 ++++ .../FireTVRemote/Views/RemoteView.swift | 159 ++++++++++++ ios/FireTVRemote/README.md | 76 ++++++ 21 files changed, 1579 insertions(+) create mode 100644 ios/FireTVRemote/FireTVRemote.xcodeproj/project.pbxproj create mode 100644 ios/FireTVRemote/FireTVRemote/ADB/ADBConnection.swift create mode 100644 ios/FireTVRemote/FireTVRemote/ADB/ADBKey.swift create mode 100644 ios/FireTVRemote/FireTVRemote/ADB/ADBProtocol.swift create mode 100644 ios/FireTVRemote/FireTVRemote/ADB/AndroidPubkey.swift create mode 100644 ios/FireTVRemote/FireTVRemote/ADB/BigUInt.swift create mode 100644 ios/FireTVRemote/FireTVRemote/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/FireTVRemote/FireTVRemote/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/FireTVRemote/FireTVRemote/Assets.xcassets/Contents.json create mode 100644 ios/FireTVRemote/FireTVRemote/ContentView.swift create mode 100644 ios/FireTVRemote/FireTVRemote/Controllers/DeviceStore.swift create mode 100644 ios/FireTVRemote/FireTVRemote/Controllers/FireTVController.swift create mode 100644 ios/FireTVRemote/FireTVRemote/FireTVRemoteApp.swift create mode 100644 ios/FireTVRemote/FireTVRemote/Models/Device.swift create mode 100644 ios/FireTVRemote/FireTVRemote/Models/RemoteKey.swift create mode 100644 ios/FireTVRemote/FireTVRemote/Views/AddDeviceView.swift create mode 100644 ios/FireTVRemote/FireTVRemote/Views/DeviceListView.swift create mode 100644 ios/FireTVRemote/FireTVRemote/Views/DpadView.swift create mode 100644 ios/FireTVRemote/FireTVRemote/Views/HelpView.swift create mode 100644 ios/FireTVRemote/FireTVRemote/Views/RemoteView.swift create mode 100644 ios/FireTVRemote/README.md diff --git a/ios/FireTVRemote/FireTVRemote.xcodeproj/project.pbxproj b/ios/FireTVRemote/FireTVRemote.xcodeproj/project.pbxproj new file mode 100644 index 0000000..44e0419 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote.xcodeproj/project.pbxproj @@ -0,0 +1,238 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 1A2B3C4D5E6F000000000005 /* FireTVRemote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FireTVRemote.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 1A2B3C4D5E6F000000000006 /* FireTVRemote */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = FireTVRemote; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1A2B3C4D5E6F000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1A2B3C4D5E6F000000000002 = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F000000000006 /* FireTVRemote */, + 1A2B3C4D5E6F000000000003 /* Products */, + ); + sourceTree = ""; + }; + 1A2B3C4D5E6F000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F000000000005 /* FireTVRemote.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1A2B3C4D5E6F000000000004 /* FireTVRemote */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A2B3C4D5E6F00000000000B /* Build configuration list for PBXNativeTarget "FireTVRemote" */; + buildPhases = ( + 1A2B3C4D5E6F000000000007 /* Sources */, + 1A2B3C4D5E6F000000000008 /* Frameworks */, + 1A2B3C4D5E6F000000000009 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 1A2B3C4D5E6F000000000006 /* FireTVRemote */, + ); + name = FireTVRemote; + productName = FireTVRemote; + productReference = 1A2B3C4D5E6F000000000005 /* FireTVRemote.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1A2B3C4D5E6F000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + 1A2B3C4D5E6F000000000004 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 1A2B3C4D5E6F00000000000A /* Build configuration list for PBXProject "FireTVRemote" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 1A2B3C4D5E6F000000000002; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 1A2B3C4D5E6F000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1A2B3C4D5E6F000000000004 /* FireTVRemote */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1A2B3C4D5E6F000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1A2B3C4D5E6F000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1A2B3C4D5E6F00000000000C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 1A2B3C4D5E6F00000000000D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + MTL_ENABLE_DEBUG_INFO = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 1A2B3C4D5E6F00000000000E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "FireTV Remote connects to your Fire TV over your local Wi-Fi network to send remote commands."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.firetvremote.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1A2B3C4D5E6F00000000000F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "FireTV Remote connects to your Fire TV over your local Wi-Fi network to send remote commands."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.firetvremote.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1A2B3C4D5E6F00000000000A /* Build configuration list for PBXProject "FireTVRemote" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A2B3C4D5E6F00000000000C /* Debug */, + 1A2B3C4D5E6F00000000000D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A2B3C4D5E6F00000000000B /* Build configuration list for PBXNativeTarget "FireTVRemote" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A2B3C4D5E6F00000000000E /* Debug */, + 1A2B3C4D5E6F00000000000F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1A2B3C4D5E6F000000000001 /* Project object */; +} diff --git a/ios/FireTVRemote/FireTVRemote/ADB/ADBConnection.swift b/ios/FireTVRemote/FireTVRemote/ADB/ADBConnection.swift new file mode 100644 index 0000000..700da85 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/ADB/ADBConnection.swift @@ -0,0 +1,209 @@ +import Foundation +import Network + +enum ADBConnectionError: Error, LocalizedError { + case notReady(String) + case connectionClosed + case handshakeFailed + case authRejected + case timeout + case unexpected(String) + + var errorDescription: String? { + switch self { + case .notReady(let m): return "Could not reach the Fire TV: \(m)" + case .connectionClosed: return "The Fire TV closed the connection." + case .handshakeFailed: return "The Fire TV did not complete the ADB handshake." + case .authRejected: return "The Fire TV rejected authentication. Accept the on-screen prompt and try again." + case .timeout: return "Timed out talking to the Fire TV." + case .unexpected(let m): return m + } + } +} + +/// One authenticated ADB session to a Fire TV. An `actor` so that the single +/// underlying TCP stream is never used concurrently. +actor ADBConnection { + private let host: NWEndpoint.Host + private let port: NWEndpoint.Port + private let queue = DispatchQueue(label: "adb.connection") + private var connection: NWConnection? + private var nextLocalId: UInt32 = 1 + private var connectTask: Task? + private(set) var isConnected = false + + init(host: String, port: UInt16 = 5555) { + self.host = NWEndpoint.Host(host) + self.port = NWEndpoint.Port(rawValue: port) ?? 5555 + } + + // MARK: Lifecycle + + /// Connects and authenticates. Concurrent callers (e.g. rapid button taps + /// before the first connection settles) share a single in-flight attempt. + func connect() async throws { + if isConnected { return } + if let connectTask { return try await connectTask.value } + let task = Task { try await performConnect() } + connectTask = task + defer { connectTask = nil } + try await task.value + } + + private func performConnect() async throws { + let conn = NWConnection(host: host, port: port, using: .tcp) + connection = conn + try await startTransport(conn) + try await performHandshake() + isConnected = true + } + + func close() { + connectTask?.cancel() + connectTask = nil + connection?.cancel() + connection = nil + isConnected = false + } + + private func startTransport(_ conn: NWConnection) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + var resumed = false + conn.stateUpdateHandler = { state in + guard !resumed else { return } + switch state { + case .ready: + resumed = true + cont.resume() + case .failed(let error), .waiting(let error): + resumed = true + cont.resume(throwing: ADBConnectionError.notReady(error.localizedDescription)) + case .cancelled: + resumed = true + cont.resume(throwing: ADBConnectionError.connectionClosed) + default: + break + } + } + conn.start(queue: queue) + } + } + + // MARK: Handshake + + private func performHandshake() async throws { + let identity = Data("host::features=shell_v2,cmd\0".utf8) + try await send(ADBMessage(.connect, arg0: 0x0100_0001, arg1: 256 * 1024, payload: identity)) + + var sentSignature = false + var sentPublicKey = false + + for _ in 0..<8 { + let msg = try await receiveMessage() + switch msg.command { + case .connect: + return // authenticated + case .auth where msg.arg0 == ADBAuth.token.rawValue: + if !sentSignature { + let signature = try ADBKey.shared.sign(token: msg.payload) + try await send(ADBMessage(.auth, arg0: ADBAuth.signature.rawValue, payload: signature)) + sentSignature = true + } else if !sentPublicKey { + let pubkey = try ADBKey.shared.androidPublicKey() + try await send(ADBMessage(.auth, arg0: ADBAuth.rsaPublicKey.rawValue, payload: pubkey)) + sentPublicKey = true + } else { + throw ADBConnectionError.authRejected + } + default: + continue + } + } + throw ADBConnectionError.handshakeFailed + } + + // MARK: Shell + + /// Runs a shell command and returns its combined output. Buttons are + /// fire-and-forget but we still drain the stream so the next command starts + /// from a clean state. + @discardableResult + func shell(_ command: String) async throws -> String { + if !isConnected { try await connect() } + let localId = nextLocalId + nextLocalId &+= 1 + if nextLocalId == 0 { nextLocalId = 1 } + + try await send(ADBMessage(.open, arg0: localId, payload: Data("shell:\(command)\0".utf8))) + + var remoteId: UInt32 = 0 + var output = Data() + for _ in 0..<256 { + let msg = try await receiveMessage() + guard msg.arg1 == localId || msg.command == .okay else { continue } + switch msg.command { + case .okay: + remoteId = msg.arg0 + case .write: + remoteId = msg.arg0 + output.append(msg.payload) + try await send(ADBMessage(.okay, arg0: localId, arg1: remoteId)) + case .close: + try await send(ADBMessage(.close, arg0: localId, arg1: remoteId)) + return String(decoding: output, as: UTF8.self) + default: + continue + } + } + return String(decoding: output, as: UTF8.self) + } + + // MARK: Transport primitives + + private func send(_ message: ADBMessage) async throws { + guard let conn = connection else { throw ADBConnectionError.connectionClosed } + let data = message.encoded() + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + conn.send(content: data, completion: .contentProcessed { error in + if let error { cont.resume(throwing: error) } else { cont.resume() } + }) + } + } + + private func receiveMessage() async throws -> ADBMessage { + let header = try await receiveExact(24) + guard let (rawCmd, arg0, arg1, length) = ADBMessage.parseHeader(header) else { + throw ADBConnectionError.unexpected("Malformed ADB header.") + } + let payload = length > 0 ? try await receiveExact(Int(length)) : Data() + guard let command = ADBCommand(rawValue: rawCmd) else { + // Unknown command: surface as a benign empty OKAY so the loop continues. + return ADBMessage(.okay, arg0: arg0, arg1: arg1) + } + return ADBMessage(command, arg0: arg0, arg1: arg1, payload: payload) + } + + /// Reads exactly `count` bytes, looping until satisfied or the stream ends. + private func receiveExact(_ count: Int) async throws -> Data { + guard let conn = connection else { throw ADBConnectionError.connectionClosed } + var buffer = Data() + while buffer.count < count { + let remaining = count - buffer.count + let chunk: Data = try await withCheckedThrowingContinuation { cont in + conn.receive(minimumIncompleteLength: 1, maximumLength: remaining) { data, _, isComplete, error in + if let error { + cont.resume(throwing: error) + } else if let data, !data.isEmpty { + cont.resume(returning: data) + } else if isComplete { + cont.resume(throwing: ADBConnectionError.connectionClosed) + } else { + cont.resume(returning: Data()) + } + } + } + buffer.append(chunk) + } + return buffer + } +} diff --git a/ios/FireTVRemote/FireTVRemote/ADB/ADBKey.swift b/ios/FireTVRemote/FireTVRemote/ADB/ADBKey.swift new file mode 100644 index 0000000..1983c79 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/ADB/ADBKey.swift @@ -0,0 +1,155 @@ +import Foundation +import Security + +/// Manages the persistent RSA key pair used to authenticate with Fire TV's +/// `adbd`. The key is generated once and stored in the iOS Keychain, so the +/// "Allow USB debugging?" prompt on the TV only has to be accepted a single +/// time per device. +enum ADBKeyError: Error, LocalizedError { + case keyGenerationFailed(String) + case signingFailed(String) + case publicKeyExportFailed(String) + case badPublicKeyEncoding + + var errorDescription: String? { + switch self { + case .keyGenerationFailed(let m): return "Could not create signing key: \(m)" + case .signingFailed(let m): return "Could not sign auth token: \(m)" + case .publicKeyExportFailed(let m): return "Could not export public key: \(m)" + case .badPublicKeyEncoding: return "Public key had an unexpected format." + } + } +} + +final class ADBKey { + static let shared = ADBKey() + + private let tag = "com.firetvremote.adb.key".data(using: .utf8)! + private let modulusBits = 2048 + private lazy var privateKey: SecKey = (try? loadOrCreateKey()) ?? { fatalError("ADB key unavailable") }() + + private init() {} + + // MARK: Token signing (ADB AUTH type 2) + + /// Signs a 20-byte ADB auth token. `adbd` treats the token as a SHA-1 + /// digest and verifies an RSA PKCS#1 v1.5 signature over it, which is + /// exactly what `.rsaSignatureDigestPKCS1v15SHA1` produces. + func sign(token: Data) throws -> Data { + var error: Unmanaged? + guard let sig = SecKeyCreateSignature( + privateKey, + .rsaSignatureDigestPKCS1v15SHA1, + token as CFData, + &error + ) else { + throw ADBKeyError.signingFailed(Self.message(error)) + } + return sig as Data + } + + // MARK: Public key (ADB AUTH type 3) + + /// Returns the public key encoded in Android's `adb` format: + /// `base64(struct RSAPublicKey) + " name\0"`. + func androidPublicKey(name: String = "firetv-remote@ios") throws -> Data { + guard let pub = SecKeyCopyPublicKey(privateKey) else { + throw ADBKeyError.publicKeyExportFailed("no public key") + } + var error: Unmanaged? + guard let der = SecKeyCopyExternalRepresentation(pub, &error) as Data? else { + throw ADBKeyError.publicKeyExportFailed(Self.message(error)) + } + let (modulus, exponent) = try Self.parsePKCS1(der) + let blob = try AndroidPubkey.encode(modulusBE: modulus, exponent: exponent) + var out = Data(blob.base64EncodedString().utf8) + out.append(Data(" \(name)".utf8)) + out.append(0) // adbd expects a trailing NUL + return out + } + + // MARK: Keychain + + private func loadOrCreateKey() throws -> SecKey { + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: tag, + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecReturnRef as String: true + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecSuccess, let item { return item as! SecKey } + + let attrs: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits as String: modulusBits, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: tag + ] + ] + var error: Unmanaged? + guard let key = SecKeyCreateRandomKey(attrs as CFDictionary, &error) else { + throw ADBKeyError.keyGenerationFailed(Self.message(error)) + } + return key + } + + // MARK: DER parsing + + /// Parses an RSA PKCS#1 `RSAPublicKey` DER blob into (modulus, exponent), + /// both big-endian with leading zero bytes stripped. + private static func parsePKCS1(_ der: Data) throws -> ([UInt8], [UInt8]) { + var p = DERParser(Array(der)) + try p.expect(0x30) // SEQUENCE + _ = try p.readLength() + try p.expect(0x02) // INTEGER modulus + let modulus = try p.readBytes(try p.readLength()) + try p.expect(0x02) // INTEGER exponent + let exponent = try p.readBytes(try p.readLength()) + return (stripLeadingZeros(modulus), stripLeadingZeros(exponent)) + } + + private static func stripLeadingZeros(_ b: [UInt8]) -> [UInt8] { + var i = 0 + while i < b.count - 1 && b[i] == 0 { i += 1 } + return Array(b[i...]) + } + + private static func message(_ error: Unmanaged?) -> String { + guard let e = error?.takeRetainedValue() else { return "unknown error" } + return (e as Error).localizedDescription + } +} + +/// Tiny DER cursor supporting just the tags/lengths we need. +private struct DERParser { + let bytes: [UInt8] + var index = 0 + init(_ bytes: [UInt8]) { self.bytes = bytes } + + mutating func expect(_ tag: UInt8) throws { + guard index < bytes.count, bytes[index] == tag else { + throw ADBKeyError.badPublicKeyEncoding + } + index += 1 + } + + mutating func readLength() throws -> Int { + guard index < bytes.count else { throw ADBKeyError.badPublicKeyEncoding } + let first = bytes[index]; index += 1 + if first & 0x80 == 0 { return Int(first) } + let count = Int(first & 0x7f) + guard count > 0, index + count <= bytes.count else { throw ADBKeyError.badPublicKeyEncoding } + var len = 0 + for _ in 0.. [UInt8] { + guard index + n <= bytes.count else { throw ADBKeyError.badPublicKeyEncoding } + defer { index += n } + return Array(bytes[index.. Data { + var data = Data(capacity: 24 + payload.count) + Self.appendLE32(&data, command.rawValue) + Self.appendLE32(&data, arg0) + Self.appendLE32(&data, arg1) + Self.appendLE32(&data, UInt32(payload.count)) + Self.appendLE32(&data, Self.checksum(payload)) + Self.appendLE32(&data, command.rawValue ^ 0xffff_ffff) + data.append(payload) + return data + } + + /// Parsed view of a 24-byte header: (command, arg0, arg1, payloadLength). + static func parseHeader(_ header: Data) -> (UInt32, UInt32, UInt32, UInt32)? { + guard header.count == 24 else { return nil } + let bytes = [UInt8](header) + func le32(_ offset: Int) -> UInt32 { + UInt32(bytes[offset]) + | UInt32(bytes[offset + 1]) << 8 + | UInt32(bytes[offset + 2]) << 16 + | UInt32(bytes[offset + 3]) << 24 + } + return (le32(0), le32(4), le32(8), le32(12)) + } + + /// ADB's payload checksum is the simple sum of the bytes (mod 2^32). + static func checksum(_ payload: Data) -> UInt32 { + var sum: UInt32 = 0 + for b in payload { sum = sum &+ UInt32(b) } + return sum + } + + private static func appendLE32(_ data: inout Data, _ value: UInt32) { + data.append(UInt8(value & 0xff)) + data.append(UInt8((value >> 8) & 0xff)) + data.append(UInt8((value >> 16) & 0xff)) + data.append(UInt8((value >> 24) & 0xff)) + } +} diff --git a/ios/FireTVRemote/FireTVRemote/ADB/AndroidPubkey.swift b/ios/FireTVRemote/FireTVRemote/ADB/AndroidPubkey.swift new file mode 100644 index 0000000..04d1c44 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/ADB/AndroidPubkey.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Encodes an RSA public key into the binary `struct RSAPublicKey` layout that +/// Android's `adb` uses (see platform/system/core libcrypto_utils +/// `android_pubkey.c`). The struct is little-endian: +/// +/// ``` +/// uint32_t modulus_size_words; // 64 for a 2048-bit key +/// uint32_t n0inv; // -1 / n[0] mod 2^32 +/// uint8_t modulus[256]; // little-endian +/// uint8_t rr[256]; // R^2 mod n, little-endian, R = 2^2048 +/// uint32_t exponent; // typically 65537 +/// ``` +enum AndroidPubkey { + static let modulusSizeBytes = 256 + static let modulusSizeWords: UInt32 = 64 // 256 / 4 + + static func encode(modulusBE: [UInt8], exponent: [UInt8]) throws -> Data { + guard modulusBE.count == modulusSizeBytes else { + throw ADBKeyError.badPublicKeyEncoding // only 2048-bit keys supported + } + let n = BigUInt(modulusBE) + + // n0inv = -1 / n[0] mod 2^32 + let n0 = UInt32(modulusBE[modulusSizeBytes - 4]) << 24 + | UInt32(modulusBE[modulusSizeBytes - 3]) << 16 + | UInt32(modulusBE[modulusSizeBytes - 2]) << 8 + | UInt32(modulusBE[modulusSizeBytes - 1]) + let n0inv = 0 &- inverseMod2_32(n0) + + // rr = R^2 mod n, where R = 2^(256*8) = 2^2048, so R^2 = 2^4096. + let rr = BigUInt.twoPowMod(exponent: modulusSizeBytes * 8 * 2, modulus: n) + + var data = Data() + appendLE32(&data, modulusSizeWords) + appendLE32(&data, n0inv) + data.append(contentsOf: modulusBE.reversed()) // little-endian + data.append(contentsOf: rr.bytes(paddedTo: modulusSizeBytes).reversed()) + appendLE32(&data, exponentValue(exponent)) + return data + } + + /// Modular inverse of an odd 32-bit value modulo 2^32 via Newton iteration. + private static func inverseMod2_32(_ x: UInt32) -> UInt32 { + var y = x // correct modulo 2^3 + for _ in 0..<5 { y = y &* (2 &- x &* y) } // doubles correct bits each step + return y + } + + private static func exponentValue(_ be: [UInt8]) -> UInt32 { + var v: UInt32 = 0 + for b in be.suffix(4) { v = (v << 8) | UInt32(b) } + return v + } + + private static func appendLE32(_ data: inout Data, _ value: UInt32) { + data.append(UInt8(value & 0xff)) + data.append(UInt8((value >> 8) & 0xff)) + data.append(UInt8((value >> 16) & 0xff)) + data.append(UInt8((value >> 24) & 0xff)) + } +} diff --git a/ios/FireTVRemote/FireTVRemote/ADB/BigUInt.swift b/ios/FireTVRemote/FireTVRemote/ADB/BigUInt.swift new file mode 100644 index 0000000..174e964 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/ADB/BigUInt.swift @@ -0,0 +1,94 @@ +import Foundation + +/// A minimal, fixed-purpose big unsigned integer used only for the math needed +/// to encode an RSA public key into Android's `adb` public-key format. +/// +/// It stores bytes big-endian and supports just enough operations (compare, +/// shift-left-by-one, subtract) to compute `R^2 mod n` by repeated doubling. +/// This is not a general-purpose bignum and is not performance critical — it +/// runs a few thousand byte-array operations once per connection setup. +struct BigUInt { + /// Big-endian magnitude with no leading zero bytes (except the value 0, + /// which is represented as an empty array). + private(set) var bytes: [UInt8] + + init(_ bytes: [UInt8]) { + self.bytes = BigUInt.trim(bytes) + } + + init(_ value: UInt) { + var v = value + var out: [UInt8] = [] + while v > 0 { + out.insert(UInt8(v & 0xff), at: 0) + v >>= 8 + } + self.bytes = out + } + + private static func trim(_ b: [UInt8]) -> [UInt8] { + var i = 0 + while i < b.count && b[i] == 0 { i += 1 } + return Array(b[i...]) // empty when all zero + } + + /// Returns -1, 0, or 1 for self <, ==, > other. + func compare(_ other: BigUInt) -> Int { + if bytes.count != other.bytes.count { + return bytes.count < other.bytes.count ? -1 : 1 + } + for (a, b) in zip(bytes, other.bytes) where a != b { + return a < b ? -1 : 1 + } + return 0 + } + + /// self <<= 1 + mutating func shiftLeftOne() { + var carry: UInt8 = 0 + var out = [UInt8](repeating: 0, count: bytes.count) + for i in stride(from: bytes.count - 1, through: 0, by: -1) { + let v = (UInt16(bytes[i]) << 1) | UInt16(carry) + out[i] = UInt8(v & 0xff) + carry = UInt8(v >> 8) + } + if carry != 0 { out.insert(carry, at: 0) } + bytes = BigUInt.trim(out) + } + + /// self -= other, requires self >= other. + mutating func subtract(_ other: BigUInt) { + var result = [UInt8](repeating: 0, count: bytes.count) + var borrow: Int = 0 + let n = bytes.count + let m = other.bytes.count + for i in 0.. BigUInt { + var r = BigUInt(1) + for _ in 0..= 0 { + r.subtract(modulus) + } + } + return r + } + + /// Big-endian bytes left-padded (or trimmed) to an exact length. + func bytes(paddedTo length: Int) -> [UInt8] { + if bytes.count >= length { + return Array(bytes.suffix(length)) + } + return [UInt8](repeating: 0, count: length - bytes.count) + bytes + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/FireTVRemote/FireTVRemote/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..89a7ebd --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.918", + "green" : "0.522", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/FireTVRemote/FireTVRemote/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Assets.xcassets/Contents.json b/ios/FireTVRemote/FireTVRemote/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/FireTVRemote/FireTVRemote/ContentView.swift b/ios/FireTVRemote/FireTVRemote/ContentView.swift new file mode 100644 index 0000000..7d0b4ab --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/ContentView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject private var store: DeviceStore + @EnvironmentObject private var controller: FireTVController + + var body: some View { + NavigationStack { + Group { + if store.devices.isEmpty { + EmptyStateView() + } else { + RemoteView() + } + } + .navigationTitle("Fire TV Remote") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + NavigationLink { + DeviceListView() + } label: { + Image(systemName: "tv.badge.wifi") + } + } + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + HelpView() + } label: { + Image(systemName: "questionmark.circle") + } + } + } + } + .onAppear { controller.use(store.selected) } + .onChange(of: store.selectedID) { _, _ in controller.use(store.selected) } + } +} + +private struct EmptyStateView: View { + var body: some View { + ContentUnavailableView { + Label("No Fire TV yet", systemImage: "tv.slash") + } description: { + Text("Add your Fire TV's IP address to start controlling it.") + } actions: { + NavigationLink { + AddDeviceView() + } label: { + Text("Add Fire TV") + .fontWeight(.semibold) + } + .buttonStyle(.borderedProminent) + } + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Controllers/DeviceStore.swift b/ios/FireTVRemote/FireTVRemote/Controllers/DeviceStore.swift new file mode 100644 index 0000000..8ff1261 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Controllers/DeviceStore.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Persists the list of saved Fire TVs and the currently selected one in +/// `UserDefaults`. +@MainActor +final class DeviceStore: ObservableObject { + @Published var devices: [Device] = [] { didSet { save() } } + @Published var selectedID: UUID? { didSet { defaults.set(selectedID?.uuidString, forKey: selectedKey) } } + + private let defaults = UserDefaults.standard + private let devicesKey = "saved_devices" + private let selectedKey = "selected_device" + + init() { + if let data = defaults.data(forKey: devicesKey), + let decoded = try? JSONDecoder().decode([Device].self, from: data) { + devices = decoded + } + if let raw = defaults.string(forKey: selectedKey) { + selectedID = UUID(uuidString: raw) + } + if selectedID == nil { selectedID = devices.first?.id } + } + + var selected: Device? { + devices.first { $0.id == selectedID } ?? devices.first + } + + func add(_ device: Device) { + devices.append(device) + if selectedID == nil { selectedID = device.id } + } + + func update(_ device: Device) { + guard let index = devices.firstIndex(where: { $0.id == device.id }) else { return } + devices[index] = device + } + + func remove(at offsets: IndexSet) { + let removed = offsets.map { devices[$0].id } + devices.remove(atOffsets: offsets) + if let selectedID, removed.contains(selectedID) { + self.selectedID = devices.first?.id + } + } + + private func save() { + if let data = try? JSONEncoder().encode(devices) { + defaults.set(data, forKey: devicesKey) + } + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Controllers/FireTVController.swift b/ios/FireTVRemote/FireTVRemote/Controllers/FireTVController.swift new file mode 100644 index 0000000..7e5eb4f --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Controllers/FireTVController.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftUI + +/// Drives a single Fire TV: owns the ADB connection, exposes connection state to +/// the UI, and turns remote button taps into shell commands. +@MainActor +final class FireTVController: ObservableObject { + enum Status: Equatable { + case idle + case connecting + case connected + case failed(String) + } + + @Published private(set) var status: Status = .idle + + private var connection: ADBConnection? + private var device: Device? + + /// Points the controller at a device. If it changed, tears down any existing + /// connection so the next action reconnects to the new host. + func use(_ device: Device?) { + guard self.device != device else { return } + let conn = connection + Task { await conn?.close() } + connection = nil + self.device = device + status = .idle + } + + func connect() async { + guard let device else { status = .failed("No Fire TV selected."); return } + status = .connecting + let conn = connection ?? ADBConnection(host: device.host, port: device.port) + connection = conn + do { + try await conn.connect() + status = .connected + } catch { + status = .failed(error.localizedDescription) + } + } + + func press(_ key: RemoteKey) { + run(key.command) + } + + func launch(_ app: FireTVApp) { + run(app.launchCommand) + } + + func sendText(_ text: String) { + let escaped = text.replacingOccurrences(of: " ", with: "%s") + .replacingOccurrences(of: "'", with: "") + run("input text '\(escaped)'") + } + + /// Runs a shell command, connecting first if needed and surfacing failures. + private func run(_ command: String) { + guard let device else { status = .failed("No Fire TV selected."); return } + let conn = connection ?? ADBConnection(host: device.host, port: device.port) + connection = conn + Task { + do { + if status != .connected { status = .connecting } + try await conn.shell(command) + status = .connected + } catch { + status = .failed(error.localizedDescription) + } + } + } +} diff --git a/ios/FireTVRemote/FireTVRemote/FireTVRemoteApp.swift b/ios/FireTVRemote/FireTVRemote/FireTVRemoteApp.swift new file mode 100644 index 0000000..3728b1b --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/FireTVRemoteApp.swift @@ -0,0 +1,15 @@ +import SwiftUI + +@main +struct FireTVRemoteApp: App { + @StateObject private var store = DeviceStore() + @StateObject private var controller = FireTVController() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(store) + .environmentObject(controller) + } + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Models/Device.swift b/ios/FireTVRemote/FireTVRemote/Models/Device.swift new file mode 100644 index 0000000..4e9bb76 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Models/Device.swift @@ -0,0 +1,16 @@ +import Foundation + +/// A saved Fire TV on the user's network. +struct Device: Identifiable, Codable, Hashable { + var id: UUID + var name: String + var host: String + var port: UInt16 + + init(id: UUID = UUID(), name: String, host: String, port: UInt16 = 5555) { + self.id = id + self.name = name + self.host = host + self.port = port + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Models/RemoteKey.swift b/ios/FireTVRemote/FireTVRemote/Models/RemoteKey.swift new file mode 100644 index 0000000..eea5b92 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Models/RemoteKey.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Android key codes used by `input keyevent`. These cover the standard Fire TV +/// remote layout. See Android's `KeyEvent` for the full list. +enum RemoteKey: Int { + case up = 19 + case down = 20 + case left = 21 + case right = 22 + case select = 23 // DPAD_CENTER + case back = 4 + case home = 3 + case menu = 82 + case playPause = 85 // MEDIA_PLAY_PAUSE + case rewind = 89 + case fastForward = 90 + case next = 87 + case previous = 88 + case volumeUp = 24 + case volumeDown = 25 + case mute = 164 + case power = 26 // toggles sleep/wake + case wake = 224 + + /// The shell command that delivers this key press. + var command: String { "input keyevent \(rawValue)" } +} + +/// A launchable Fire TV app, started via the `monkey` launcher so we don't need +/// to know the exact launch activity. +struct FireTVApp: Identifiable, Hashable { + var id: String { package } + let name: String + let package: String + let systemImage: String + + var launchCommand: String { + "monkey -p \(package) -c android.intent.category.LAUNCHER 1" + } + + static let presets: [FireTVApp] = [ + FireTVApp(name: "Netflix", package: "com.netflix.ninja", systemImage: "play.tv"), + FireTVApp(name: "YouTube", package: "com.amazon.firetv.youtube", systemImage: "play.rectangle"), + FireTVApp(name: "Prime Video", package: "com.amazon.avod", systemImage: "film"), + FireTVApp(name: "Disney+", package: "com.disney.disneyplus", systemImage: "sparkles.tv"), + FireTVApp(name: "Hulu", package: "com.hulu.plus", systemImage: "tv"), + FireTVApp(name: "Spotify", package: "com.spotify.tv.android", systemImage: "music.note") + ] +} diff --git a/ios/FireTVRemote/FireTVRemote/Views/AddDeviceView.swift b/ios/FireTVRemote/FireTVRemote/Views/AddDeviceView.swift new file mode 100644 index 0000000..64b3e00 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Views/AddDeviceView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct AddDeviceView: View { + @EnvironmentObject private var store: DeviceStore + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var host = "" + @State private var port = "5555" + + var body: some View { + Form { + Section("Fire TV") { + TextField("Name (e.g. Living Room)", text: $name) + TextField("IP address (e.g. 192.168.1.42)", text: $host) + .keyboardType(.numbersAndPunctuation) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + TextField("Port", text: $port) + .keyboardType(.numberPad) + } + + Section { + Text("Find the IP on your Fire TV under Settings ▸ My Fire TV ▸ About ▸ Network. Make sure ADB debugging is turned on (see the Help screen).") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Add Fire TV") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(!isValid) + } + } + } + + private var isValid: Bool { + !host.trimmingCharacters(in: .whitespaces).isEmpty && UInt16(port) != nil + } + + private func save() { + let trimmedName = name.trimmingCharacters(in: .whitespaces) + let device = Device( + name: trimmedName.isEmpty ? "Fire TV" : trimmedName, + host: host.trimmingCharacters(in: .whitespaces), + port: UInt16(port) ?? 5555 + ) + store.add(device) + store.selectedID = device.id + dismiss() + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Views/DeviceListView.swift b/ios/FireTVRemote/FireTVRemote/Views/DeviceListView.swift new file mode 100644 index 0000000..ccefd2a --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Views/DeviceListView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct DeviceListView: View { + @EnvironmentObject private var store: DeviceStore + @EnvironmentObject private var controller: FireTVController + @State private var showAdd = false + + var body: some View { + List { + Section { + ForEach(store.devices) { device in + Button { + store.selectedID = device.id + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(device.name) + .foregroundStyle(.primary) + Text("\(device.host):\(String(device.port))") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if device.id == store.selectedID { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.tint) + } + } + } + } + .onDelete { store.remove(at: $0) } + } footer: { + Text("Tap a Fire TV to make it active.") + } + } + .navigationTitle("My Fire TVs") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAdd = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showAdd) { + NavigationStack { AddDeviceView() } + } + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Views/DpadView.swift b/ios/FireTVRemote/FireTVRemote/Views/DpadView.swift new file mode 100644 index 0000000..c73a848 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Views/DpadView.swift @@ -0,0 +1,59 @@ +import SwiftUI +import UIKit + +/// The circular directional pad with a center "select" button. +struct DpadView: View { + let onPress: (RemoteKey) -> Void + + var body: some View { + ZStack { + Circle() + .fill(Color(.secondarySystemBackground)) + .overlay(Circle().stroke(Color(.systemGray4), lineWidth: 1)) + + VStack { + arrow(.up, "chevron.up") + Spacer() + arrow(.down, "chevron.down") + } + .padding(.vertical, 18) + + HStack { + arrow(.left, "chevron.left") + Spacer() + arrow(.right, "chevron.right") + } + .padding(.horizontal, 18) + + Button { + press(.select) + } label: { + Text("OK") + .font(.headline.weight(.bold)) + .frame(width: 92, height: 92) + .background(Circle().fill(Color.accentColor)) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + } + .frame(width: 260, height: 260) + } + + private func arrow(_ key: RemoteKey, _ symbol: String) -> some View { + Button { + press(key) + } label: { + Image(systemName: symbol) + .font(.title2.weight(.semibold)) + .frame(width: 56, height: 56) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + } + + private func press(_ key: RemoteKey) { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onPress(key) + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Views/HelpView.swift b/ios/FireTVRemote/FireTVRemote/Views/HelpView.swift new file mode 100644 index 0000000..a260cfa --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Views/HelpView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct HelpView: View { + var body: some View { + List { + Section("1 · Enable ADB on the Fire TV") { + step("Open Settings ▸ My Fire TV ▸ About and click the device name (or \"Build\") 7 times to unlock Developer Options.") + step("Go back to Settings ▸ My Fire TV ▸ Developer Options and turn ON \"ADB debugging\".") + step("On newer Fire OS the toggle may be under Settings ▸ Device & Software ▸ Developer Options.") + } + + Section("2 · Find the IP address") { + step("Settings ▸ My Fire TV ▸ About ▸ Network shows the IP (e.g. 192.168.1.42).") + step("Add it on the previous screen. The port is 5555 unless you changed it.") + } + + Section("3 · First connection") { + step("The first time you press a button, the Fire TV shows \"Allow USB debugging?\". Check \"Always allow from this computer\" and select OK.") + step("After that it stays trusted — this app reuses the same key.") + } + + Section("Troubleshooting") { + step("Both devices must be on the same Wi-Fi / LAN.") + step("If it can't connect, confirm ADB debugging is still on and the IP hasn't changed (set a DHCP reservation on your router to keep it fixed).") + step("If auth keeps failing, toggle ADB debugging off and on, then try again to re-trigger the prompt.") + } + + Section { + Text("This app talks to the Fire TV over ADB on your local network only. Nothing leaves your home network and no Amazon account is required.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Help & Setup") + .navigationBarTitleDisplayMode(.inline) + } + + private func step(_ text: String) -> some View { + Label { + Text(text) + } icon: { + Image(systemName: "checkmark.circle") + .foregroundStyle(.tint) + } + } +} diff --git a/ios/FireTVRemote/FireTVRemote/Views/RemoteView.swift b/ios/FireTVRemote/FireTVRemote/Views/RemoteView.swift new file mode 100644 index 0000000..466caa9 --- /dev/null +++ b/ios/FireTVRemote/FireTVRemote/Views/RemoteView.swift @@ -0,0 +1,159 @@ +import SwiftUI +import UIKit + +/// The main remote screen: status, D-pad, transport/navigation buttons, volume, +/// and app shortcuts. +struct RemoteView: View { + @EnvironmentObject private var store: DeviceStore + @EnvironmentObject private var controller: FireTVController + @State private var showKeyboard = false + @State private var textToSend = "" + + var body: some View { + ScrollView { + VStack(spacing: 28) { + statusBar + + DpadView(onPress: controller.press) + + navigationRow + transportRow + volumeRow + + appShortcuts + } + .padding() + } + .safeAreaInset(edge: .bottom) { keyboardBar } + .alert("Send text", isPresented: $showKeyboard) { + TextField("Type here", text: $textToSend) + Button("Send") { + controller.sendText(textToSend) + textToSend = "" + } + Button("Cancel", role: .cancel) { textToSend = "" } + } message: { + Text("Types into the focused field on the Fire TV.") + } + } + + // MARK: Sections + + private var statusBar: some View { + HStack(spacing: 8) { + Circle().fill(statusColor).frame(width: 10, height: 10) + Text(statusText) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + if let name = store.selected?.name { + Text(name).font(.subheadline.weight(.medium)) + } + } + .padding(.horizontal, 4) + } + + private var navigationRow: some View { + HStack(spacing: 16) { + circleButton("chevron.backward", label: "Back") { controller.press(.back) } + circleButton("house.fill", label: "Home") { controller.press(.home) } + circleButton("line.3.horizontal", label: "Menu") { controller.press(.menu) } + } + } + + private var transportRow: some View { + HStack(spacing: 16) { + circleButton("backward.fill", label: "Rewind") { controller.press(.rewind) } + circleButton("playpause.fill", label: "Play") { controller.press(.playPause) } + circleButton("forward.fill", label: "Forward") { controller.press(.fastForward) } + } + } + + private var volumeRow: some View { + HStack(spacing: 16) { + circleButton("speaker.wave.1.fill", label: "Vol −") { controller.press(.volumeDown) } + circleButton("speaker.slash.fill", label: "Mute") { controller.press(.mute) } + circleButton("speaker.wave.3.fill", label: "Vol +") { controller.press(.volumeUp) } + circleButton("power", label: "Power") { controller.press(.power) } + } + } + + private var appShortcuts: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Apps") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 12) { + ForEach(FireTVApp.presets) { app in + Button { + controller.launch(app) + } label: { + VStack(spacing: 6) { + Image(systemName: app.systemImage) + .font(.title2) + Text(app.name) + .font(.caption) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(.plain) + } + } + } + } + + private var keyboardBar: some View { + Button { + showKeyboard = true + } label: { + Label("Type on TV", systemImage: "keyboard") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal) + .padding(.bottom, 6) + } + + // MARK: Helpers + + private func circleButton(_ symbol: String, label: String, action: @escaping () -> Void) -> some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + action() + } label: { + VStack(spacing: 6) { + Image(systemName: symbol) + .font(.title3) + .frame(width: 60, height: 60) + .background(Circle().fill(Color(.secondarySystemBackground))) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + } + + private var statusColor: Color { + switch controller.status { + case .connected: return .green + case .connecting: return .orange + case .failed: return .red + case .idle: return .gray + } + } + + private var statusText: String { + switch controller.status { + case .idle: return "Ready" + case .connecting: return "Connecting…" + case .connected: return "Connected" + case .failed(let message): return message + } + } +} diff --git a/ios/FireTVRemote/README.md b/ios/FireTVRemote/README.md new file mode 100644 index 0000000..2ea85e4 --- /dev/null +++ b/ios/FireTVRemote/README.md @@ -0,0 +1,76 @@ +# FireTV Remote (iOS) + +A native SwiftUI iPhone app that controls an Amazon Fire TV over your home +Wi-Fi network. It speaks the **ADB** (Android Debug Bridge) protocol directly — +no Amazon account, no cloud, nothing leaves your LAN. + +## Features + +- D-pad with center **OK** button +- Back / Home / Menu +- Play-Pause, Rewind, Fast-Forward +- Volume up/down, Mute, Power (sleep/wake) +- One-tap launch for Netflix, YouTube, Prime Video, Disney+, Hulu, Spotify +- "Type on TV" text entry for search boxes +- Save multiple Fire TVs and switch between them +- Automatic, persistent ADB key — you accept the on-screen prompt only once + +## Requirements + +- A Mac with **Xcode 16 or newer** to build and install onto your iPhone +- iPhone running **iOS 17+** +- iPhone and Fire TV on the **same Wi-Fi / LAN** + +> You need a Mac to get any app onto an iPhone (free Apple ID works for personal +> sideloading; the app will need to be re-signed every 7 days with a free +> account, or yearly with a paid Apple Developer account). + +## Build & install + +1. Open `ios/FireTVRemote/FireTVRemote.xcodeproj` in Xcode. +2. Select the **FireTVRemote** target ▸ **Signing & Capabilities** and choose + your Team (your Apple ID). Change the bundle identifier if Xcode reports it's + taken (e.g. `com.yourname.firetvremote`). +3. Plug in your iPhone (or use a paired wireless device), select it as the run + destination, and press **Run** (⌘R). +4. On the iPhone, trust the developer profile if prompted: + Settings ▸ General ▸ VPN & Device Management. + +## Set up the Fire TV (one time) + +1. **Enable Developer Options:** Settings ▸ My Fire TV ▸ About ▸ click the + device name (or "Build") **7 times**. +2. **Turn on ADB debugging:** Settings ▸ My Fire TV ▸ Developer Options ▸ + **ADB debugging = ON**. (On newer Fire OS this may live under + Settings ▸ Device & Software ▸ Developer Options.) +3. **Find the IP:** Settings ▸ My Fire TV ▸ About ▸ Network. +4. In the app, tap the TV icon ▸ **+**, enter a name and the IP (port `5555`). +5. The first button press triggers an **"Allow USB debugging?"** dialog on the + TV — check *"Always allow from this computer"* and select **OK**. + +Tip: set a DHCP reservation on your router so the Fire TV keeps the same IP. + +## How it works + +The app is a small, dependency-free ADB client: + +| File | Responsibility | +|------|----------------| +| `ADB/ADBProtocol.swift` | Wire-format message encode/decode (CNXN, AUTH, OPEN, WRTE, OKAY, CLSE) | +| `ADB/ADBConnection.swift` | TCP transport (Network framework), handshake, shell streams | +| `ADB/ADBKey.swift` | RSA key persisted in the Keychain; token signing + public-key export | +| `ADB/AndroidPubkey.swift` | Encodes the public key into Android's `adb` key format | +| `ADB/BigUInt.swift` | Minimal big-int math for the `R² mod n` term that encoding needs | +| `Controllers/FireTVController.swift` | Turns button taps into `input keyevent …` shell commands | +| `Controllers/DeviceStore.swift` | Persists saved devices in `UserDefaults` | +| `Views/*` | SwiftUI remote, device list, add-device, and help screens | + +Buttons map to Android key codes (e.g. `input keyevent 23` for select); apps are +launched with `monkey -p -c android.intent.category.LAUNCHER 1`. + +## Limitations + +- Targets classic **ADB over TCP on port 5555**. Fire OS "Wireless debugging" + (ADB-over-TLS pairing) is not implemented. +- Power is a sleep/wake toggle, matching the physical remote. +- Discovery is by manual IP entry (most reliable across router setups).