diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1aae9f3..94deb38 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,13 @@ name: Build # macOS apps/macos universal .app → DMG + ZIP # Android apps/mobile release APK (installable, signed with the debug key) # iOS apps/mobile unsigned IPA, for sideloading with AltStore / SideStore / Sideloadly -# Pushing a v* tag also publishes a GitHub Release with all of them attached. +# A GitHub Release with the builds and a changelog is published when either: +# - a v* tag is pushed, or +# - the workflow is run manually (Actions → Build → Run workflow) with "release" ticked; +# it then creates the v tag on that commit itself. +# A version with a suffix (1.2.0-alpha.1, 1.2.0-beta.2) is published as a pre-release, for +# testing from a feature branch before it is merged. Every release builds all three apps; a +# manual run can tick "macos_only" to skip the mobile builds (e.g. a quick Mac test build). on: push: @@ -12,6 +18,19 @@ on: tags: ["v*"] pull_request: workflow_dispatch: + inputs: + release: + description: Publish a GitHub Release with the builds and changelog + type: boolean + default: false + version: + description: Release version without the v (1.2.0, or 1.2.0-alpha.1 for a pre-release) + type: string + default: "" + macos_only: + description: Build only the macOS app (skips Android and iOS) + type: boolean + default: false permissions: contents: write @@ -25,12 +44,30 @@ jobs: outputs: version: ${{ steps.v.outputs.version }} app-version: ${{ steps.v.outputs.app-version }} + prerelease: ${{ steps.v.outputs.prerelease }} + mobile: ${{ !inputs.macos_only }} steps: - id: v + env: + EVENT: ${{ github.event_name }} + RELEASE: ${{ inputs.release }} + INPUT_VERSION: ${{ inputs.version }} run: | + VERSION="" if [[ "$GITHUB_REF" == refs/tags/v* ]]; then - echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - echo "app-version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + VERSION="${GITHUB_REF_NAME#v}" + elif [[ "$EVENT" == "workflow_dispatch" && "$RELEASE" == "true" ]]; then + VERSION="$INPUT_VERSION" + fi + if [[ -n "$VERSION" ]]; then + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Version must look like 1.2.0 or 1.2.0-alpha.1 (got '$VERSION')" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # iOS needs a purely numeric version, so the app itself gets 1.2.0 for 1.2.0-alpha.1. + echo "app-version=${VERSION%%-*}" >> "$GITHUB_OUTPUT" + [[ "$VERSION" == *-* ]] && echo "prerelease=true" >> "$GITHUB_OUTPUT" || echo "prerelease=false" >> "$GITHUB_OUTPUT" else echo "version=0.0.0-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" # iOS requires a numeric version, so untagged builds keep package.json's. @@ -48,6 +85,41 @@ jobs: steps: - uses: actions/checkout@v4 + # A stand-in Android TV (Tests/fake_tv.py) for the end-to-end pairing test. + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Start fake Android TV + run: | + python -m pip install --quiet androidtvremote2 pyopenssl + python Tests/fake_tv.py --code-file "$RUNNER_TEMP/tv-code" --keys-file "$RUNNER_TEMP/tv-keys" \ + > "$RUNNER_TEMP/fake-tv.log" 2>&1 & + for i in $(seq 1 20); do nc -z 127.0.0.1 6466 && nc -z 127.0.0.1 6467 && exit 0; sleep 0.5; done + cat "$RUNNER_TEMP/fake-tv.log"; exit 1 + + - name: Run tests + env: + MIREMOTE_FAKE_TV_CODE: ${{ runner.temp }}/tv-code + MIREMOTE_FAKE_TV_KEYS: ${{ runner.temp }}/tv-keys + run: | + set +e + swift test 2>&1 | tee "$RUNNER_TEMP/swift-test.log" + status=${PIPESTATUS[0]} + if [[ $status != 0 ]]; then + # Surface failures as annotations, readable without the raw log. + enc() { python -c 'import sys; print(sys.stdin.read()[-6000:].replace("%","%25").replace("\r","").replace("\n","%0A"))'; } + echo "::error title=swift test failures::$(grep -E 'error:|failed|Timed out|XCTAssert|fatal' "$RUNNER_TEMP/swift-test.log" | head -40 | enc)" + echo "::error title=fake TV log::$(tail -60 "$RUNNER_TEMP/fake-tv.log" | enc)" + fi + enc() { python -c 'import sys; print(sys.stdin.read()[-6000:].replace("%","%25").replace("\r","").replace("\n","%0A"))'; } + echo "::notice title=swift test summary::$(grep -E "Test Case .*(passed|failed|skipped)|Executed [0-9]+ test" "$RUNNER_TEMP/swift-test.log" | tail -30 | enc)" + exit $status + + - name: Fake TV log + if: always() + run: cat "$RUNNER_TEMP/fake-tv.log" || true + - name: Build universal app run: UNIVERSAL=1 ./build-app.sh @@ -67,6 +139,7 @@ jobs: android: needs: version + if: needs.version.outputs.mobile == 'true' runs-on: ubuntu-latest defaults: run: @@ -128,6 +201,7 @@ jobs: ios: needs: version + if: needs.version.outputs.mobile == 'true' runs-on: macos-26 defaults: run: @@ -205,11 +279,16 @@ jobs: path: apps/mobile/out/* release: - if: startsWith(github.ref, 'refs/tags/v') + # Mobile jobs may be skipped (macos_only); everything that ran must have succeeded. + if: >- + !cancelled() && !failure() + && (startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.release)) needs: [version, macos, android, ios] runs-on: ubuntu-latest env: VERSION: ${{ needs.version.outputs.version }} + TAG: v${{ needs.version.outputs.version }} + PRERELEASE: ${{ needs.version.outputs.prerelease }} steps: - uses: actions/checkout@v4 with: @@ -220,24 +299,36 @@ jobs: path: out merge-multiple: true + # Uses the commit SHA (not the tag) so it also works on a manual run, before the tag exists. - name: Write release notes run: | - PREV=$(git describe --tags --abbrev=0 "$GITHUB_REF_NAME^" 2>/dev/null || true) - RANGE=${PREV:+$PREV..}$GITHUB_REF_NAME + PREV=$(git describe --tags --abbrev=0 "$GITHUB_SHA^" 2>/dev/null || true) + RANGE=${PREV:+$PREV..}$GITHUB_SHA { + if [[ "$PRERELEASE" == "true" ]]; then + echo "> [!WARNING]" + echo "> **Pre-release for testing.** It may have bugs. Grab the latest stable release if you just want a working remote." + echo + fi echo "## Download" echo echo "| Platform | File | How to install |" echo "|---|---|---|" echo "| macOS 14+ | \`Mi-Remote-$VERSION-macOS.dmg\` | Open it and drag **Mi Remote** to Applications |" - echo "| Android 7+ | \`Mi-Remote-$VERSION-android.apk\` | Open it on the phone and allow installing from this source |" - echo "| iPhone (iOS 16.4+) | \`Mi-Remote-$VERSION-ios-unsigned.ipa\` | Sideload with AltStore, SideStore or Sideloadly using your Apple ID |" + if [[ -f "out/Mi-Remote-$VERSION-android.apk" ]]; then + echo "| Android 7+ | \`Mi-Remote-$VERSION-android.apk\` | Open it on the phone and allow installing from this source |" + fi + if [[ -f "out/Mi-Remote-$VERSION-ios-unsigned.ipa" ]]; then + echo "| iPhone (iOS 16.4+) | \`Mi-Remote-$VERSION-ios-unsigned.ipa\` | Sideload with AltStore, SideStore or Sideloadly using your Apple ID |" + fi echo echo "## First launch" echo echo "- **macOS**: the app is not notarized. Open it once, then go to **System Settings → Privacy & Security** and click **Open Anyway**." + echo "- **macOS, after updating**: if the app asks to use its key in your keychain, click **Always Allow**. That key is what the TV paired with." echo "- **All platforms**: allow **Local Network** access when asked; the app needs it to find and reach the TV." - echo "- **iPhone**: apps sideloaded with a free Apple ID expire after 7 days; refresh them in AltStore/SideStore, or reinstall." + echo "- **Mi TVs without MiLink** (newer Android TV / Google TV models): the apps connect through the Android TV remote service instead. The first time, the TV shows a 6-character code; type it into the app." + [[ -f "out/Mi-Remote-$VERSION-ios-unsigned.ipa" ]] && echo "- **iPhone**: apps sideloaded with a free Apple ID expire after 7 days; refresh them in AltStore/SideStore, or reinstall." || true echo echo "See the [README](https://github.com/$GITHUB_REPOSITORY#install) for step-by-step instructions." echo @@ -246,9 +337,9 @@ jobs: git log --no-merges --pretty='- %s (%h)' "$RANGE" echo if [[ -n "$PREV" ]]; then - echo "**Full changelog**: https://github.com/$GITHUB_REPOSITORY/compare/$PREV...$GITHUB_REF_NAME" + echo "**Full changelog**: https://github.com/$GITHUB_REPOSITORY/compare/$PREV...$TAG" else - echo "**Full changelog**: https://github.com/$GITHUB_REPOSITORY/commits/$GITHUB_REF_NAME" + echo "**Full changelog**: https://github.com/$GITHUB_REPOSITORY/commits/$TAG" fi } > notes.md cat notes.md @@ -258,6 +349,11 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create "$GITHUB_REF_NAME" out/* \ + FLAGS=() + [[ "$PRERELEASE" == "true" ]] && FLAGS+=(--prerelease) + # --target creates the tag on this commit when it doesn't exist yet (manual run). + gh release create "$TAG" out/* \ + --target "$GITHUB_SHA" \ --title "Mi Remote $VERSION" \ - --notes-file notes.md + --notes-file notes.md \ + "${FLAGS[@]}" diff --git a/README.md b/README.md index 17b0b79..133d1d3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Mi TV Remote -Remote control apps for Xiaomi Mi TV (Android TV / PatchWall) on **macOS**, **Android** and **iOS**. They control the TV over Wi-Fi with no pairing and no ADB, using the TV's built-in MiLink remote service. +Remote control apps for Xiaomi Mi TV (Android TV / PatchWall) on **macOS**, **Android** and **iOS**. They control the TV over Wi-Fi with no ADB, using a remote service built into the TV: Xiaomi's **MiLink**, with no pairing needed, or, on models without it, Google's **Android TV remote service** after a one-time pairing code. -That service's protocol isn't documented, so it was **reverse engineered with [REA (Reverse Engineer Anything)](https://github.com/morluto/rea)** from the TV's own firmware (see [How it was reverse engineered](#how-it-was-reverse-engineered)). +MiLink's protocol isn't documented, so it was **reverse engineered with [REA (Reverse Engineer Anything)](https://github.com/morluto/rea)** from the TV's own firmware (see [How it was reverse engineered](#how-it-was-reverse-engineered)). ![Mi Remote opened from the Mac menu bar controlling a Mi TV](docs/demo.gif) @@ -22,13 +22,14 @@ That service's protocol isn't documented, so it was **reverse engineered with [R *The iOS app (left: remote and TV picker) and the Android app (right), in the simulator and emulator, connected to a mock MiLink TV.* -All three implement the same protocol and features: +All three implement the same protocols and features: -- **Full remote layout**: power, input source, recent apps, TV settings, D-pad and OK, back, home, menu, volume, mute, search, channel up and down, and media controls. +- **Works with more Mi TVs**: MiLink where the TV has it, otherwise the Android TV remote service, with a one-time pairing code shown on the TV. The apps pick the right one and remember it per TV. +- **Full remote layout**: power, input source, recent apps, TV settings, D-pad and OK, back, home, menu, volume, mute, search, channel up and down, and media controls. Over the Android TV remote service, input source and recent apps work differently (see [Compatibility](#compatibility)). - **Hold to repeat** on the D-pad, volume and channel keys. - **Type on the TV**: send text to search boxes and login fields. - **Auto-discovery** of Mi TVs on the network, or enter the IP address by hand. See [Compatibility](#compatibility) for supported models. -- **Auto-reconnect**, with heartbeats and a live latency readout. +- **Auto-reconnect**, with heartbeats, and a live latency readout over MiLink. - **macOS**: lives in the menu bar and has keyboard shortcuts ([list](apps/macos/README.md#keyboard-shortcuts)). - **Mobile**: haptic feedback, and the remote reconnects when you return to the app. @@ -37,10 +38,22 @@ All three implement the same protocol and features: | TV | Status | |---|---| | **Xiaomi Mi TV `MiTV-AXSO0`** (Android TV 9, PatchWall) | ✅ Tested with the macOS app. The mobile apps use the same protocol and have so far been tested against a mock TV | +| **Xiaomi Mi TV `MiTV-MOOR2`** (Android TV 11, no MiLink) | ✅ Tested with the macOS app, through the Android TV remote service ([#4](https://github.com/EndLess728/MiTV-Remote/issues/4)): pairing, navigation, volume, media keys and typing work. Input source opens Settings; recent apps isn't available | | Other Mi TVs on Android TV with PatchWall | Expected to work, not tested | -| Xiaomi TVs on Google TV | Unknown; may not include the MiLink service | +| Xiaomi TVs on Google TV | Expected to work through the Android TV remote service, not tested | -The apps don't depend on a specific model. They need the TV to run Xiaomi's **MiLink** service (`com.mitv.milinkservice`), which listens on **TCP port 6091**; it's the same service Xiaomi's own phone remote apps use. Any TV with that service should work. +The apps don't depend on a specific model. They talk to one of two services on the TV: + +- **MiLink** (`com.mitv.milinkservice`, **TCP port 6091**), the service Xiaomi's own phone remote apps use. No pairing needed. Supported by all apps. +- **Android TV Remote** (**TLS ports 6466 and 6467**), the service Google's TV remote app uses, present on most Android TV and Google TV models. Needs a one-time pairing: the TV shows a 6-character code, and you type it into the app. Supported by all apps. + +The apps try MiLink first and fall back to the Android TV remote service, and remember which one each TV uses. + +**Over the Android TV remote service**, the TV only acts on a limited set of keys, the same ones the Google TV app can send: + +- **Input source**: there's no input key, so the button opens the TV's **Settings**, where the inputs are. (Tried on the MOOR2: TV, HDMI 1–4, Guide and F1–F12 keys don't open the input menu.) +- **Recent apps**: not available; the button is greyed out. +- **macOS**: the pairing key is kept in your login keychain. After installing a new version, macOS may ask once for your password to let Mi Remote use it; choose **Always Allow** to keep the pairing. **Check your TV** (it must be on the same Wi-Fi as your computer): @@ -54,9 +67,9 @@ done Find the TV's IP under **Settings → Network → Wi-Fi** on the TV. -- **Port 6091 open**: the TV runs MiLink and Mi Remote should work. -- **Port 6091 closed** (and the TV is reachable): the TV doesn't run MiLink, so these apps can't control it yet. -- Ports **6095** (Xiaomi's older HTTP remote) and **6466** (Google's Android TV Remote service) aren't used by the apps today. Whether they're open tells us what a non-MiLink TV could support, so please include the full output when you open an issue. +- **Port 6091 open**: the TV runs MiLink and every Mi Remote app should work. +- **Port 6091 closed, 6466 open**: the TV has no MiLink but has the Android TV remote service, so the apps can control it after pairing. +- **Both closed** (and the TV is reachable): the apps can't control this TV yet. Port **6095** (Xiaomi's older HTTP remote) isn't used by the apps; whether it's open helps judge what such a TV could support, so please include the full output when you open an issue. If you try a model not listed here, please open an issue with the model (usually under **Settings → Device Preferences → About**), its Android TV version, the output above and whether it worked, so the table can grow. @@ -119,12 +132,14 @@ See [`apps/mobile/README.md`](apps/mobile/README.md) for the project layout and ### CI and releases -[`.github/workflows/build.yml`](.github/workflows/build.yml) builds all three apps on every push and pull request and attaches the DMG, APK and IPA to the workflow run. Pushing a version tag publishes a GitHub Release with all of them, plus a changelog: +[`.github/workflows/build.yml`](.github/workflows/build.yml) builds all three apps on every push and pull request and attaches the DMG, APK and IPA to the workflow run. The macOS job first runs the Swift tests, including an end-to-end test against a stand-in Android TV ([`apps/macos/Tests/fake_tv.py`](apps/macos/Tests/fake_tv.py)): TLS 1.2 only, client certificates required, and the real pairing protocol. Pushing a version tag publishes a GitHub Release with all of them, plus a changelog: ```sh git tag v1.1.0 && git push origin v1.1.0 ``` +Or, without a tag, run the workflow from **Actions → Build → Run workflow**, tick **release** and enter a version; it creates the tag itself. A version with a suffix, such as `1.2.0-alpha.1`, is published as a **pre-release**, so a feature branch can be tested before it's merged. Every release includes all three apps; for a quick Mac-only test build, tick **macos_only**. + No secrets are needed. The macOS app is ad-hoc signed, the APK uses the standard debug signing key, and the IPA is unsigned so that each person signs it with their own Apple ID. ## How it works @@ -156,12 +171,24 @@ Implementation notes: - **Text**: MiLink has no text packet, so typing maps each character to an Android key code (plus Shift) and sends it as a keystroke. - **Discovery**: Mi TVs advertise `_googlecast._tcp` with a TXT `md=MiTV-…` model, which the apps browse and resolve to an IPv4 address. +### Android TV Remote + +Mi TVs without MiLink usually still run Google's **Android TV Remote Service**, the one the Google TV app talks to (protocol v2). Both ports use **TLS 1.2** (the service is a Netty TLS 1.2 server; the apps pin 1.2, as Apple's TLS 1.3 handshake fails against it), and both require a client certificate. Messages are protobuf, each prefixed with its length as a varint. + +- **Pairing, port 6467.** The app sends a pairing request, then options and configuration (6-digit hex code, the app as input). The TV then shows a code. The secret is `SHA-256(client modulus, client exponent, TV modulus, TV exponent, last 2 bytes of the code)`. The code's first byte must equal the hash's first byte, which catches typos before anything is sent. Once the TV acks the secret, it trusts the app's certificate permanently. +- **Remote, port 6466.** The TV opens with a `RemoteConfigure` and the app answers with the features it wants; the TV pings every 5 seconds and the app echoes each ping. Keys are `RemoteKeyInject` messages with Android key codes, and text goes into the focused field with `RemoteImeBatchEdit`. Only some key codes are acted on (see [Compatibility](#compatibility)). If the TV drops the TLS session before configuring, the certificate isn't paired and the app starts pairing. The apps connect plain TCP first and only then TLS, because a rejected or stalled handshake can look like a reset or timeout, which would otherwise read as "nothing listening". +- **The client certificate.** Each app generates a 2048-bit RSA key and keeps it: in the keychain on iOS and macOS, in app storage on Android. The certificate is kept as a file next to the app's data and joined with the key into the TLS identity when connecting (on macOS a keychain label lookup of it came back empty, so the app sent no certificate and the TV refused to pair). The self-signed X.509 certificate is built by the app itself ([`src/atv/der.ts`](apps/mobile/src/atv/der.ts), [`ATVProtocol.swift`](apps/macos/Sources/MiRemote/ATVProtocol.swift)), because Apple's platforms have no API for creating certificates. The macOS app is ad-hoc signed, so after an update macOS may ask once whether it can use its keychain key; **Always Allow** keeps the pairing. + +The message layouts follow [androidtvremote2](https://github.com/tronikos/androidtvremote2), the library behind Home Assistant's Android TV Remote integration. The unit tests check against bytes and secrets that library produces. + Where it lives: | | macOS | Android / iOS | |---|---|---| -| Protocol and client | [`MiLinkClient.swift`](apps/macos/Sources/MiRemote/MiLinkClient.swift) | [`src/milink`](apps/mobile/src/milink) (TypeScript, unit tested) | -| TCP socket | Network framework | [`modules/milink`](apps/mobile/modules/milink): Swift (iOS) and Kotlin (Android) | +| MiLink protocol | [`RemoteClient.swift`](apps/macos/Sources/MiRemote/RemoteClient.swift) | [`src/milink`](apps/mobile/src/milink) (TypeScript, unit tested) | +| Android TV Remote protocol | [`ATVProtocol.swift`](apps/macos/Sources/MiRemote/ATVProtocol.swift) (unit tested) | [`src/atv`](apps/mobile/src/atv) (TypeScript, unit tested) | +| Connection, protocol choice, pairing | `RemoteClient.swift` | [`src/remote/client.ts`](apps/mobile/src/remote/client.ts) | +| TCP / TLS socket, client key | Network framework | [`modules/milink`](apps/mobile/modules/milink): Swift (iOS) and Kotlin (Android) | | Discovery | [`TVDiscovery.swift`](apps/macos/Sources/MiRemote/TVDiscovery.swift) | Same native module: `NWBrowser` (iOS) and `NsdManager` (Android) | ## How it was reverse engineered diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 95ececb..73c376b 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -9,6 +9,12 @@ let package = Package( name: "MiRemote", path: "Sources/MiRemote", swiftSettings: [.swiftLanguageMode(.v5)] - ) + ), + .testTarget( + name: "MiRemoteTests", + dependencies: ["MiRemote"], + path: "Tests/MiRemoteTests", + swiftSettings: [.swiftLanguageMode(.v5)] + ), ] ) diff --git a/apps/macos/README.md b/apps/macos/README.md index 26b19eb..a787865 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -29,15 +29,17 @@ UNIVERSAL=1 ./build-app.sh # arm64 + x86_64 VERSION=1.2.0 ./build-app.sh # stamp the version ``` -During development, `swift run` launches the app directly. +During development, `swift run` launches the app directly, and `swift test` runs the protocol tests. ## Source | File | Purpose | |---|---| -| `Sources/MiRemote/MiLinkClient.swift` | TCP connection, framing, key packets, heartbeat, reconnect, hold-to-repeat, typing | +| `Sources/MiRemote/RemoteClient.swift` | Connection, MiLink framing and key packets, heartbeat, reconnect, hold-to-repeat, typing; picks MiLink or Android TV Remote per TV and runs pairing | +| `Sources/MiRemote/ATVProtocol.swift` | Android TV Remote protocol: protobuf messages, pairing secret, self-signed certificate (DER) | +| `Sources/MiRemote/ClientIdentity.swift` | Client key and certificate in the login keychain, used for TLS | | `Sources/MiRemote/TVDiscovery.swift` | Bonjour discovery of Mi TVs | | `Sources/MiRemote/KeyCodes.swift` | Android key codes and the character-to-keystroke map | -| `Sources/MiRemote/RemoteView.swift` | SwiftUI remote UI, keyboard handling and TV picker | +| `Sources/MiRemote/RemoteView.swift` | SwiftUI remote UI, keyboard handling, TV picker and pairing sheet | | `Sources/MiRemote/MiRemoteApp.swift` | Menu bar status item and popover | | `scripts/make-icon.swift` | Renders `Resources/AppIcon.icns` | diff --git a/apps/macos/Sources/MiRemote/ATVProtocol.swift b/apps/macos/Sources/MiRemote/ATVProtocol.swift new file mode 100644 index 0000000..f01e3ba --- /dev/null +++ b/apps/macos/Sources/MiRemote/ATVProtocol.swift @@ -0,0 +1,425 @@ +import CryptoKit +import Foundation + +// Android TV Remote protocol v2, the one Google's TV remote apps use. Two TLS ports, both +// carrying varint-length-delimited protobuf messages, and both requiring a client certificate: +// +// 6467 pairing ("polo"): the TV shows a 6-digit hex code; proving we saw it makes the TV +// trust our certificate from then on. +// 6466 remote: key presses, text, pings. The TV drops the TLS session if the certificate +// isn't paired. +// +// Field numbers follow polo.proto and remotemessage.proto as used by androidtvremote2. This is +// a Swift port of the mobile app's src/atv; both are tested against the same fixtures. + +enum ATV { + static let remotePort: UInt16 = 6466 + static let pairingPort: UInt16 = 6467 + static let clientName = "Mi Remote" +} + +// MARK: - Protobuf + +enum Proto { + enum DecodeError: Error { case malformed } + + static func varint(_ value: Int64) -> [UInt8] { + // Negative int32 values are sign-extended to 64 bits, as protobuf does. + var v = UInt64(bitPattern: value) + var out: [UInt8] = [] + while v >= 0x80 { + out.append(UInt8(v & 0x7F) | 0x80) + v >>= 7 + } + out.append(UInt8(v)) + return out + } + + static func int(_ field: Int, _ value: Int64) -> [UInt8] { + varint(Int64(field << 3)) + varint(value) + } + + static func bytes(_ field: Int, _ data: [UInt8]) -> [UInt8] { + varint(Int64(field << 3 | 2)) + varint(Int64(data.count)) + data + } + + static func string(_ field: Int, _ text: String) -> [UInt8] { + bytes(field, Array(text.utf8)) + } + + /// Prefixes a message with its varint length, the framing both TV ports use. + static func delimited(_ message: [UInt8]) -> [UInt8] { + varint(Int64(message.count)) + message + } + + static func readVarint(_ bytes: [UInt8], _ index: inout Int) -> UInt64? { + var result: UInt64 = 0 + var shift: UInt64 = 0 + while index < bytes.count { + let byte = bytes[index] + index += 1 + if shift < 64 { result |= UInt64(byte & 0x7F) << shift } + if byte & 0x80 == 0 { return result } + shift += 7 + if shift >= 70 { return nil } + } + return nil + } + + /// Splits complete length-delimited messages off the front of `buffer`, leaving any partial + /// message in it for the next chunk. + static func split(_ buffer: inout [UInt8]) -> [[UInt8]] { + var messages: [[UInt8]] = [] + var offset = 0 + while offset < buffer.count { + var index = offset + guard let length = readVarint(buffer, &index), + length <= UInt64(buffer.count - index) else { break } + let end = index + Int(length) + messages.append(Array(buffer[index..> 3) + switch key & 7 { + case 0: + guard let value = Proto.readVarint(bytes, &index) else { throw DecodeError.malformed } + ints[field] = Int32(truncatingIfNeeded: value) + case 2: + guard let length = Proto.readVarint(bytes, &index), + length <= UInt64(bytes.count - index) else { throw DecodeError.malformed } + let end = index + Int(length) + blobs[field] = Array(bytes[index.. Bool { ints[field] != nil || blobs[field] != nil } + func int(_ field: Int, default fallback: Int32 = 0) -> Int32 { ints[field] ?? fallback } + func message(_ field: Int) -> Message? { blobs[field].flatMap { try? Message(decoding: $0) } } + func string(_ field: Int) -> String { blobs[field].map { String(decoding: $0, as: UTF8.self) } ?? "" } + } +} + +// MARK: - Pairing (polo) + +enum Pairing { + static let statusOK: Int32 = 200 + static let statusBadSecret: Int32 = 402 + + private static func outer(_ body: [UInt8]) -> [UInt8] { + Proto.int(1, 2) + Proto.int(2, Int64(statusOK)) + body + } + + /// Hexadecimal code, 6 symbols. + private static var hexEncoding: [UInt8] { Proto.int(1, 3) + Proto.int(2, 6) } + /// We type the code: the input role. + private static let roleInput: Int64 = 1 + + static func request(clientName: String) -> [UInt8] { + outer(Proto.bytes(10, Proto.string(1, "atvremote") + Proto.string(2, clientName))) + } + + static func options() -> [UInt8] { + outer(Proto.bytes(20, Proto.bytes(1, hexEncoding) + Proto.int(3, roleInput))) + } + + static func configuration() -> [UInt8] { + outer(Proto.bytes(30, Proto.bytes(1, hexEncoding) + Proto.int(2, roleInput))) + } + + static func secretMessage(_ secret: [UInt8]) -> [UInt8] { + outer(Proto.bytes(40, Proto.bytes(1, secret))) + } + + enum Reply: Equatable { case requestAck, options, configurationAck, secretAck, unknown } + + static func parse(_ bytes: [UInt8]) throws -> (status: Int32, reply: Reply) { + let message = try Proto.Message(decoding: bytes) + let status = message.int(2, default: statusOK) + if message.has(11) { return (status, .requestAck) } + if message.has(20) { return (status, .options) } + if message.has(31) { return (status, .configurationAck) } + if message.has(41) { return (status, .secretAck) } + return (status, .unknown) + } + + /// Uppercase hex, 6 characters, or nil if `input` can't be a code. + static func normalize(_ input: String) -> String? { + let code = input.filter { !$0.isWhitespace }.uppercased() + guard code.count == 6, code.allSatisfy({ $0.isHexDigit }) else { return nil } + return code + } + + /// SHA-256 over both public keys and the last 4 hex digits of the code. The first 2 hex + /// digits are a checksum that must equal the hash's first byte, which catches a mistyped + /// code before the TV sees it. Returns nil when it doesn't match. + static func secret(client: RSAPublicKey, server: RSAPublicKey, code: String) -> [UInt8]? { + guard let normalized = normalize(code) else { return nil } + let chars = Array(normalized) + let codeBytes = stride(from: 0, to: 6, by: 2).map { UInt8(String(chars[$0...$0 + 1]), radix: 16)! } + var input = client.modulus + client.exponent + server.modulus + server.exponent + input += codeBytes[1...] + let hash = Array(SHA256.hash(data: Data(input))) + return hash[0] == codeBytes[0] ? hash : nil + } +} + +// MARK: - Remote + +enum RemoteMessage { + /// Feature bits: ping, key, ime, power, volume, app link. + static let wantedFeatures: Int32 = 1 | 2 | 4 | 32 | 64 | 512 + + enum Direction: Int64 { case startLong = 1, endLong = 2, short = 3 } + + static func configure(features: Int32) -> [UInt8] { + let deviceInfo = Proto.string(1, ATV.clientName) + Proto.string(2, ATV.clientName) + + Proto.int(3, 1) + Proto.string(4, "1") + Proto.string(5, "atvremote") + Proto.string(6, "1.0.0") + return Proto.bytes(1, Proto.int(1, Int64(features)) + Proto.bytes(2, deviceInfo)) + } + + static func setActive(features: Int32) -> [UInt8] { + Proto.bytes(2, Proto.int(1, Int64(features))) + } + + static func pingResponse(_ value: Int32) -> [UInt8] { + Proto.bytes(9, Proto.int(1, Int64(value))) + } + + /// `code` is an Android KeyEvent keycode; the protocol's key enum uses the same numbers. + static func keyInject(code: Int32, direction: Direction) -> [UInt8] { + Proto.bytes(10, Proto.int(1, Int64(code)) + Proto.int(2, direction.rawValue)) + } + + /// Puts `text` into the focused text field on the TV, cursor after it. The counters echo the + /// TV's latest (0 before it has sent any), as the Google TV app and other clients do. + static func imeBatchEdit(imeCounter: Int32, fieldCounter: Int32, text: String) -> [UInt8] { + let cursor = Int64(text.unicodeScalars.count) + let imeObject = Proto.int(1, cursor) + Proto.int(2, cursor) + Proto.string(3, text) + let editInfo = Proto.int(1, 1) + Proto.bytes(2, imeObject) + return Proto.bytes(21, Proto.int(1, Int64(imeCounter)) + Proto.int(2, Int64(fieldCounter)) + Proto.bytes(3, editInfo)) + } + + enum Event: Equatable { + case configure(features: Int32, model: String) + case setActive + case ping(Int32) + case start(Bool) + case imeBatchEdit(imeCounter: Int32, fieldCounter: Int32) + /// A text field got focus (show request) or the focused app reported its field. + case imeField(fieldCounter: Int32) + case error + case other + } + + static func parse(_ bytes: [UInt8]) throws -> Event { + let message = try Proto.Message(decoding: bytes) + if let configure = message.message(1) { + return .configure(features: configure.int(1), model: configure.message(2)?.string(1) ?? "") + } + if message.has(2) { return .setActive } + if message.has(3) { return .error } + if let ping = message.message(8) { return .ping(ping.int(1)) } + if let keyInject = message.message(20), let field = keyInject.message(2) { + return .imeField(fieldCounter: field.int(1)) + } + if let show = message.message(22), let field = show.message(2) { + return .imeField(fieldCounter: field.int(1)) + } + if let batch = message.message(21) { + return .imeBatchEdit(imeCounter: batch.int(1), fieldCounter: batch.int(2)) + } + if let start = message.message(40) { return .start(start.int(1) != 0) } + return .other + } +} + +// MARK: - DER / X.509 + +struct RSAPublicKey: Equatable { + /// Unsigned big-endian, no leading zeros. + var modulus: [UInt8] + var exponent: [UInt8] +} + +/// Minimal DER: builds the self-signed client certificate around the keychain key (macOS has no +/// public API to create certificates) and reads RSA keys out of certificates. +enum DER { + enum ParseError: Error { case malformed, unsupported } + + private static let tagInteger: UInt8 = 0x02 + private static let tagBitString: UInt8 = 0x03 + private static let tagSequence: UInt8 = 0x30 + private static let tagVersion: UInt8 = 0xA0 + + static func tlv(_ tag: UInt8, _ content: [UInt8]) -> [UInt8] { + [tag] + length(content.count) + content + } + + private static func length(_ n: Int) -> [UInt8] { + if n < 0x80 { return [UInt8(n)] } + var out: [UInt8] = [] + var v = n + while v > 0 { + out.insert(UInt8(v & 0xFF), at: 0) + v >>= 8 + } + return [0x80 | UInt8(out.count)] + out + } + + static func sequence(_ items: [UInt8]...) -> [UInt8] { + tlv(tagSequence, items.flatMap { $0 }) + } + + /// INTEGER from unsigned big-endian bytes. + static func integer(_ magnitude: [UInt8]) -> [UInt8] { + let bytes = stripZeros(magnitude) + return tlv(tagInteger, (bytes.first ?? 0) & 0x80 != 0 ? [0] + bytes : bytes) + } + + static func oid(_ dotted: String) -> [UInt8] { + let parts = dotted.split(separator: ".").map { Int($0)! } + var out: [UInt8] = [UInt8(parts[0] * 40 + parts[1])] + for part in parts.dropFirst(2) { + var chunk: [UInt8] = [] + var v = part + repeat { + chunk.insert(UInt8(v & 0x7F) | (chunk.isEmpty ? 0 : 0x80), at: 0) + v >>= 7 + } while v > 0 + out += chunk + } + return tlv(0x06, out) + } + + private static func algorithm(_ dotted: String) -> [UInt8] { + sequence(oid(dotted), tlv(0x05, [])) + } + + private static let rsaEncryption = "1.2.840.113549.1.1.1" + private static let sha256WithRSA = "1.2.840.113549.1.1.11" + + private static func name(_ commonName: String) -> [UInt8] { + let cn = sequence(oid("2.5.4.3"), tlv(0x0C, Array(commonName.utf8))) + return sequence(tlv(0x31, cn)) + } + + /// UTCTime, YYMMDDHHMMSSZ (valid until 2049, which is all the certificate needs). + private static func utcTime(_ date: Date) -> [UInt8] { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + let text = String(format: "%02d%02d%02d%02d%02d%02dZ", + c.year! % 100, c.month!, c.day!, c.hour!, c.minute!, c.second!) + return tlv(0x17, Array(text.utf8)) + } + + static func subjectPublicKeyInfo(_ key: RSAPublicKey) -> [UInt8] { + let pkcs1 = sequence(integer(key.modulus), integer(key.exponent)) + return sequence(algorithm(rsaEncryption), tlv(tagBitString, [0] + pkcs1)) + } + + /// The to-be-signed part of a self-signed v3 certificate for `key`. + static func tbsCertificate(key: RSAPublicKey, commonName: String, serial: [UInt8], notBefore: Date) -> [UInt8] { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let notAfter = calendar.date(from: DateComponents(year: 2049, month: 12, day: 31, hour: 23, minute: 59, second: 59))! + let subject = name(commonName) + return sequence( + tlv(tagVersion, integer([2])), + integer(serial), + algorithm(sha256WithRSA), + subject, + sequence(utcTime(notBefore), utcTime(notAfter)), + subject, + subjectPublicKeyInfo(key) + ) + } + + /// Wraps a TBS certificate and its SHA256withRSA signature into the final certificate. + static func certificate(tbs: [UInt8], signature: [UInt8]) -> [UInt8] { + sequence(tbs, algorithm(sha256WithRSA), tlv(tagBitString, [0] + signature)) + } + + // MARK: Parsing + + private struct Node { + var tag: UInt8 + var content: [UInt8] + } + + private static func readNode(_ bytes: [UInt8], _ offset: Int) throws -> (Node, Int) { + guard offset + 2 <= bytes.count else { throw ParseError.malformed } + let tag = bytes[offset] + var len = Int(bytes[offset + 1]) + var start = offset + 2 + if len & 0x80 != 0 { + let count = len & 0x7F + guard count <= 4, start + count <= bytes.count else { throw ParseError.malformed } + len = 0 + for i in 0.. [Node] { + var nodes: [Node] = [] + var offset = 0 + while offset < content.count { + let (node, next) = try readNode(content, offset) + nodes.append(node) + offset = next + } + return nodes + } + + private static func stripZeros(_ bytes: [UInt8]) -> [UInt8] { + var i = 0 + while i < bytes.count - 1 && bytes[i] == 0 { i += 1 } + return Array(bytes[i...]) + } + + /// The RSA public key in an X.509 certificate, a SubjectPublicKeyInfo, or a PKCS#1 + /// RSAPublicKey (what the keychain exports). + static func rsaPublicKey(_ der: [UInt8]) throws -> RSAPublicKey { + let (top, _) = try readNode(der, 0) + guard top.tag == tagSequence else { throw ParseError.malformed } + let parts = try children(top.content) + if parts.count == 2, parts[0].tag == tagInteger, parts[1].tag == tagInteger { + return RSAPublicKey(modulus: stripZeros(parts[0].content), exponent: stripZeros(parts[1].content)) + } + if parts.count == 2, parts[0].tag == tagSequence, parts[1].tag == tagBitString { + return try rsaPublicKey(Array(parts[1].content.dropFirst())) + } + if parts.count == 3, parts[0].tag == tagSequence { + let tbs = try children(parts[0].content) + let index = tbs.first?.tag == tagVersion ? 6 : 5 + guard tbs.count > index else { throw ParseError.malformed } + return try rsaPublicKey(tlv(tbs[index].tag, tbs[index].content)) + } + throw ParseError.unsupported + } +} diff --git a/apps/macos/Sources/MiRemote/ClientIdentity.swift b/apps/macos/Sources/MiRemote/ClientIdentity.swift new file mode 100644 index 0000000..67949fe --- /dev/null +++ b/apps/macos/Sources/MiRemote/ClientIdentity.swift @@ -0,0 +1,138 @@ +import Foundation +import Security + +/// The RSA key and self-signed certificate Mi Remote presents to Android TVs. The TV trusts the +/// certificate once paired, so both persist: the private key in the login keychain, the +/// certificate (public data) in Application Support. `SecIdentityCreateWithCertificate` joins +/// them into the TLS identity. macOS can't create certificates, so `DER` builds it and this +/// signs it. +/// +/// (An earlier version stored the certificate in the keychain too and looked the identity up by +/// label; on the login keychain that lookup came back empty, so no client certificate was sent.) +/// +/// The app is ad-hoc signed, so after an update macOS may ask once whether Mi Remote may use +/// its own keychain key; "Always Allow" keeps the pairing. +enum ClientIdentity { + private static let keyLabel = "Mi Remote client key" + private static let keyTag = Data("local.miremote.client-key".utf8) + + enum Failure: LocalizedError { + case keychain(OSStatus) + case security(String) + + var errorDescription: String? { + switch self { + case .keychain(let status): + return (SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error \(status)" + case .security(let message): + return message + } + } + } + + /// The client key's public half, creating the key (and its certificate) on first use. + static func ensureCertificate() throws -> RSAPublicKey { + let key = try privateKey() + let publicKey = try DER.rsaPublicKey(try publicKeyData(key)) + if let der = certificate(), let certKey = try? DER.rsaPublicKey(der), certKey == publicKey { + return publicKey + } + var serial = (0..<8).map { _ in UInt8.random(in: 0...255) } + serial[0] = (serial[0] & 0x7F) | 0x01 + let tbs = DER.tbsCertificate(key: publicKey, commonName: ATV.clientName, serial: serial, notBefore: Date()) + let signature = try sign(Data(tbs), with: key) + try setCertificate(Data(DER.certificate(tbs: tbs, signature: [UInt8](signature)))) + return publicKey + } + + /// The TLS client identity: the stored certificate joined with its private key in the + /// keychain. Throws with the reason when that fails, so it shows up in the app. + static func identity() throws -> SecIdentity { + guard let der = certificate(), + let cert = SecCertificateCreateWithData(nil, Data(der) as CFData) else { + throw Failure.security("No client certificate yet") + } + var identity: SecIdentity? + let status = SecIdentityCreateWithCertificate(nil, cert, &identity) + guard status == errSecSuccess, let identity else { throw Failure.keychain(status) } + return identity + } + + // MARK: Storage + + private static var certificateURL: URL { + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Mi Remote", isDirectory: true) + return dir.appendingPathComponent("client-certificate.der") + } + + static func certificate() -> [UInt8]? { + (try? Data(contentsOf: certificateURL)).map { [UInt8]($0) } + } + + private static func setCertificate(_ der: Data) throws { + guard SecCertificateCreateWithData(nil, der as CFData) != nil else { + throw Failure.security("Not a valid certificate") + } + try FileManager.default.createDirectory(at: certificateURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try der.write(to: certificateURL, options: .atomic) + } + + private static func deleteCertificate() { + try? FileManager.default.removeItem(at: certificateURL) + } + + static func publicKeyData(_ key: SecKey) throws -> [UInt8] { + guard let pub = SecKeyCopyPublicKey(key) else { throw Failure.security("No public key") } + var error: Unmanaged? + guard let data = SecKeyCopyExternalRepresentation(pub, &error) as Data? else { + throw error!.takeRetainedValue() as Error + } + return [UInt8](data) + } + + private static func sign(_ data: Data, with key: SecKey) throws -> Data { + var error: Unmanaged? + guard let signature = SecKeyCreateSignature(key, .rsaSignatureMessagePKCS1v15SHA256, data as CFData, &error) as Data? else { + throw error!.takeRetainedValue() as Error + } + return signature + } + + static func privateKey() throws -> SecKey { + // Found by tag; by label too, for keys made before the tag was added. + let lookups: [(String, Any)] = [(kSecAttrApplicationTag as String, keyTag), (kSecAttrLabel as String, keyLabel)] + for (attribute, value) in lookups { + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrKeyClass as String: kSecAttrKeyClassPrivate, + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + attribute: value, + kSecReturnRef as String: true, + ] + var item: CFTypeRef? + if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item, + CFGetTypeID(item) == SecKeyGetTypeID() { + return (item as! SecKey) // swiftlint:disable:this force_cast + } + } + // A certificate for an older key would never match a new one. + deleteCertificate() + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits as String: 2048, + kSecAttrLabel as String: keyLabel, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrLabel as String: keyLabel, + kSecAttrApplicationTag as String: keyTag, + ], + ] + var error: Unmanaged? + guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { + throw error!.takeRetainedValue() as Error + } + return key + } +} diff --git a/apps/macos/Sources/MiRemote/MiLinkClient.swift b/apps/macos/Sources/MiRemote/MiLinkClient.swift deleted file mode 100644 index 4cdff0f..0000000 --- a/apps/macos/Sources/MiRemote/MiLinkClient.swift +++ /dev/null @@ -1,250 +0,0 @@ -import Foundation -import Network - -/// Client for the Xiaomi MiLink remote-control service (com.mitv.milinkservice, TCP 6091). -/// -/// Wire format (recovered from RCServerThread / SendKeyPacket / SendKeyData, all big-endian): -/// frame = type:u8 length:u16 payload[length] -/// type 2 = heartbeat (empty payload) -/// type 4 = send key; payload = code:u8 (1=send, 2=response) id:u32 len:u16 data[len] -/// key data = TLVs: 1 meta, 2 action, 3 keycode, 4 scancode, 5 repeat, 6 flags (i32), -/// 7 downTime, 8 eventTime (i64), 10 deviceId, 11 source (i32) -/// -/// downTime/eventTime MUST be 0: the TV's Instrumentation.sendKeySync() then stamps them with the -/// TV's own uptime clock. Host-clock timestamps land "in the future" on the TV and can wedge its -/// input dispatcher until that time arrives. -@MainActor -final class MiLinkClient: ObservableObject { - enum State: Equatable { - case disconnected - case connecting - case connected - case failed(String) - } - - static let port: UInt16 = 6091 - - @Published private(set) var state: State = .disconnected - @Published private(set) var lastAckMs: Int? - - private(set) var host: String = "" - private var connection: NWConnection? - private var buffer = Data() - private var nextId: UInt32 = 0 - private var heartbeat: Timer? - private var reconnectWork: DispatchWorkItem? - private var pendingSends: [UInt32: Date] = [:] - private var wantConnected = false - - // Hold-to-repeat - private var repeatTimer: Timer? - private var repeatCode: Int32 = 0 - private var repeatCount: Int32 = 0 - - // MARK: Connection - - func connect(to host: String) { - let host = host.trimmingCharacters(in: .whitespaces) - guard !host.isEmpty else { return } - disconnect() - self.host = host - wantConnected = true - open() - } - - func disconnect() { - wantConnected = false - reconnectWork?.cancel() - reconnectWork = nil - stopRepeat(sendUp: false) - teardown() - state = .disconnected - } - - private func open() { - guard let port = NWEndpoint.Port(rawValue: Self.port) else { return } - let tcp = NWProtocolTCP.Options() - tcp.noDelay = true - tcp.connectionTimeout = 4 - tcp.enableKeepalive = true - let conn = NWConnection(host: NWEndpoint.Host(host), port: port, using: NWParameters(tls: nil, tcp: tcp)) - connection = conn - state = .connecting - conn.stateUpdateHandler = { [weak self, weak conn] newState in - Task { @MainActor in - guard let self, let conn, conn === self.connection else { return } - self.handle(newState) - } - } - conn.start(queue: .main) - } - - private func handle(_ newState: NWConnection.State) { - switch newState { - case .ready: - state = .connected - buffer.removeAll() - receive() - heartbeat?.invalidate() - heartbeat = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in - Task { @MainActor in self?.write(Data([2, 0, 0])) } - } - case .failed(let error): - fail(error.localizedDescription) - case .waiting(let error): - fail(error.localizedDescription) - case .cancelled: - break - default: - break - } - } - - private func fail(_ message: String) { - teardown() - state = .failed(message) - scheduleReconnect() - } - - private func teardown() { - heartbeat?.invalidate() - heartbeat = nil - connection?.stateUpdateHandler = nil - connection?.cancel() - connection = nil - pendingSends.removeAll() - } - - private func scheduleReconnect() { - guard wantConnected else { return } - reconnectWork?.cancel() - let work = DispatchWorkItem { [weak self] in - Task { @MainActor in - guard let self, self.wantConnected, self.connection == nil else { return } - self.open() - } - } - reconnectWork = work - DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: work) - } - - private func receive() { - connection?.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in - Task { @MainActor in - guard let self else { return } - if let data, !data.isEmpty { - self.buffer.append(data) - self.drainFrames() - } - if isComplete || error != nil { - self.fail(error?.localizedDescription ?? "TV closed the connection") - } else { - self.receive() - } - } - } - } - - private func drainFrames() { - while buffer.count >= 3 { - let bytes = [UInt8](buffer.prefix(3)) - let length = Int(bytes[1]) << 8 | Int(bytes[2]) - guard buffer.count >= 3 + length else { return } - let payload = buffer.subdata(in: buffer.startIndex + 3 ..< buffer.startIndex + 3 + length) - buffer.removeSubrange(buffer.startIndex ..< buffer.startIndex + 3 + length) - if bytes[0] == 4, payload.count >= 7 { - let p = [UInt8](payload) - let id = UInt32(p[1]) << 24 | UInt32(p[2]) << 16 | UInt32(p[3]) << 8 | UInt32(p[4]) - if let sent = pendingSends.removeValue(forKey: id) { - lastAckMs = Int(Date().timeIntervalSince(sent) * 1000) - } - } - } - } - - private func write(_ data: Data) { - guard state == .connected, let connection else { return } - connection.send(content: data, completion: .contentProcessed { _ in }) - } - - // MARK: Keys - - private func keyPacket(code: Int32, action: Int32, meta: Int32 = 0, repeatCount: Int32 = 0) -> Data { - func i32(_ tag: UInt8, _ v: Int32) -> [UInt8] { - let u = UInt32(bitPattern: v) - return [tag, UInt8(u >> 24), UInt8(u >> 16 & 0xFF), UInt8(u >> 8 & 0xFF), UInt8(u & 0xFF)] - } - func i64(_ tag: UInt8, _ v: Int64) -> [UInt8] { - let u = UInt64(bitPattern: v) - return [tag] + (0..<8).map { UInt8(u >> (56 - 8 * UInt64($0)) & 0xFF) } - } - var data: [UInt8] = [] - data += i32(1, meta) - data += i32(2, action) // 0 = ACTION_DOWN, 1 = ACTION_UP - data += i32(3, code) - data += i32(4, 0) - data += i32(5, repeatCount) - data += i32(6, 0) - data += i64(7, 0) // downTime: let the TV stamp it - data += i64(8, 0) // eventTime: let the TV stamp it - data += i32(10, -1) // KeyCharacterMap.VIRTUAL_KEYBOARD - data += i32(11, 0x101) // InputDevice.SOURCE_KEYBOARD - - nextId &+= 1 - let id = nextId - pendingSends[id] = Date() - if pendingSends.count > 64 { pendingSends.removeAll() } - - let len = UInt16(data.count) - var body: [UInt8] = [1, UInt8(id >> 24), UInt8(id >> 16 & 0xFF), UInt8(id >> 8 & 0xFF), UInt8(id & 0xFF), - UInt8(len >> 8), UInt8(len & 0xFF)] - body += data - let total = UInt16(body.count) - return Data([4, UInt8(total >> 8), UInt8(total & 0xFF)] + body) - } - - /// Single press: DOWN followed by UP. - func press(_ key: AndroidKey) { press(code: key.rawValue) } - - func press(code: Int32, meta: Int32 = 0) { - write(keyPacket(code: code, action: 0, meta: meta)) - write(keyPacket(code: code, action: 1, meta: meta)) - } - - /// Begin a press-and-hold. The TV auto-releases a key 175 ms after the last DOWN, - /// so repeated DOWNs are sent every 100 ms until `endHold()`. - func beginHold(_ key: AndroidKey) { - stopRepeat(sendUp: true) - repeatCode = key.rawValue - repeatCount = 0 - write(keyPacket(code: repeatCode, action: 0)) - repeatTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - Task { @MainActor in - guard let self else { return } - self.repeatCount += 1 - self.write(self.keyPacket(code: self.repeatCode, action: 0, repeatCount: self.repeatCount)) - } - } - } - - func endHold() { stopRepeat(sendUp: true) } - - private func stopRepeat(sendUp: Bool) { - guard let timer = repeatTimer else { return } - timer.invalidate() - repeatTimer = nil - if sendUp { write(keyPacket(code: repeatCode, action: 1)) } - } - - /// Types text by sending one keystroke per character. Unsupported characters are skipped. - func type(_ text: String) { - var delay = 0.0 - for ch in text { - guard let stroke = androidKeystroke(for: ch) else { continue } - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.press(code: stroke.code, meta: stroke.meta) - } - delay += 0.04 - } - } -} diff --git a/apps/macos/Sources/MiRemote/RemoteClient.swift b/apps/macos/Sources/MiRemote/RemoteClient.swift new file mode 100644 index 0000000..d56213a --- /dev/null +++ b/apps/macos/Sources/MiRemote/RemoteClient.swift @@ -0,0 +1,694 @@ +import Foundation +import Network + +/// Client for the TV's remote-control service. Speaks one of two protocols, remembered per TV: +/// +/// - **MiLink** (TCP 6091), Xiaomi's own service on PatchWall TVs. No pairing. +/// - **Android TV Remote v2** (TLS 6466, pairing on 6467), the Google TV remote protocol, for +/// Mi TVs without MiLink. Needs a one-time code shown on the TV. See ATVProtocol.swift. +/// +/// For a TV it hasn't seen, it tries MiLink first and falls back to Android TV Remote when +/// nothing listens on 6091. +/// +/// MiLink wire format (recovered from RCServerThread / SendKeyPacket / SendKeyData, big-endian): +/// frame = type:u8 length:u16 payload[length] +/// type 2 = heartbeat (empty payload) +/// type 4 = send key; payload = code:u8 (1=send, 2=response) id:u32 len:u16 data[len] +/// key data = TLVs: 1 meta, 2 action, 3 keycode, 4 scancode, 5 repeat, 6 flags (i32), +/// 7 downTime, 8 eventTime (i64), 10 deviceId, 11 source (i32) +/// +/// downTime/eventTime MUST be 0: the TV's Instrumentation.sendKeySync() then stamps them with the +/// TV's own uptime clock. Host-clock timestamps land "in the future" on the TV and can wedge its +/// input dispatcher until that time arrives. +@MainActor +final class RemoteClient: ObservableObject { + enum State: Equatable { + case disconnected + case connecting + case connected + case failed(String) + /// Android TV Remote needs the code shown on the TV; see `pairingStep`. + case pairing + } + + enum TVProtocol: String { + case milink + case atv + } + + /// Asking the TV for a code, waiting for the user to type it, or waiting for the TV to accept it. + enum PairingStep: Equatable { + case starting + case enterCode + case verifying + } + + enum PairingError: Equatable { + case wrongCode + case rejected + case connection + case certificate + } + + static let milinkPort: UInt16 = 6091 + + @Published private(set) var state: State = .disconnected + @Published private(set) var lastAckMs: Int? + @Published private(set) var pairingStep: PairingStep? + @Published private(set) var pairingError: PairingError? + @Published private(set) var tvProtocol: TVProtocol? + /// The last connection error, with the port, for the status line and the pairing sheet. + @Published private(set) var lastError: String? + + private(set) var host: String = "" + private var wantConnected = false + private var reconnectWork: DispatchWorkItem? + + /// Which socket is open, so its events go to the right handler. + private enum Phase { case none, milink, atv, pairing } + private var phase: Phase = .none + private var connection: NWConnection? + /// Plain TCP connection made before a TLS one, to learn whether the port is open at all. + private var probe: NWConnection? + private var reachedPeer = false + private var peerCertificate: [UInt8]? + + // MiLink + private var milinkBuffer = Data() + private var nextId: UInt32 = 0 + private var heartbeat: Timer? + private var pendingSends: [UInt32: Date] = [:] + + // Android TV Remote + private var atvBuffer: [UInt8] = [] + private var atvReady = false + private var features = RemoteMessage.wantedFeatures + private var imeCounter: Int32? + private var imeFieldCounter: Int32 = 0 + private var justPaired = false + private var idleTimer: Timer? + private var clientKey: RSAPublicKey? + private var serverKey: RSAPublicKey? + + // Hold-to-repeat + private var repeatTimer: Timer? + private var repeatCode: Int32 = 0 + private var repeatCount: Int32 = 0 + + // MARK: Connection + + func connect(to host: String) { + let host = host.trimmingCharacters(in: .whitespaces) + guard !host.isEmpty else { return } + disconnect() + self.host = host + wantConnected = true + tvProtocol = Self.savedProtocol(for: host) + state = .connecting + open() + } + + func disconnect() { + wantConnected = false + reconnectWork?.cancel() + reconnectWork = nil + stopRepeat(sendUp: false) + closeSocket() + state = .disconnected + pairingStep = nil + pairingError = nil + } + + private func open() { + if tvProtocol == .atv { + openAtv() + } else { + openSocket(port: Self.milinkPort, tls: false, phase: .milink) + } + } + + private func openAtv() { + atvReady = false + imeCounter = nil + features = RemoteMessage.wantedFeatures + guard prepareCertificate() else { return } + openSocket(port: ATV.remotePort, tls: true, phase: .atv) + } + + /// Creates the client certificate on first use. False (with the state set) if that failed. + private func prepareCertificate() -> Bool { + do { + clientKey = try ClientIdentity.ensureCertificate() + return true + } catch { + closeSocket() + state = .pairing + pairingStep = .starting + pairingError = .certificate + return false + } + } + + private func scheduleReconnect() { + guard wantConnected else { return } + reconnectWork?.cancel() + let work = DispatchWorkItem { [weak self] in + Task { @MainActor in + guard let self, self.wantConnected, self.connection == nil, self.probe == nil else { return } + self.open() + } + } + reconnectWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: work) + } + + private func remember(_ proto: TVProtocol) { + tvProtocol = proto + var saved = UserDefaults.standard.dictionary(forKey: "tvProtocols") as? [String: String] ?? [:] + saved[host] = proto.rawValue + UserDefaults.standard.set(saved, forKey: "tvProtocols") + } + + private static func savedProtocol(for host: String) -> TVProtocol? { + let saved = UserDefaults.standard.dictionary(forKey: "tvProtocols") as? [String: String] + return saved?[host].flatMap(TVProtocol.init(rawValue:)) + } + + // MARK: Socket + + private func openSocket(port: UInt16, tls: Bool, phase: Phase) { + closeSocket() + guard let nwPort = NWEndpoint.Port(rawValue: port) else { return } + self.phase = phase + milinkBuffer.removeAll() + atvBuffer.removeAll() + reachedPeer = false + peerCertificate = nil + if state != .pairing { state = .connecting } + if tls { + probePort(nwPort) + } else { + startConnection(nwPort, tls: false) + } + } + + /// Connects plain TCP first and only then TLS, the way the Android app does. A TV that + /// rejects an unpaired certificate, or stalls the TLS handshake, can surface as a reset or a + /// timeout, which on its own reads like "nothing listens here". Knowing TCP got through + /// makes any later failure mean "the service is there", so pairing starts. + private func probePort(_ port: NWEndpoint.Port) { + let tcp = NWProtocolTCP.Options() + tcp.connectionTimeout = 4 + let conn = NWConnection(host: NWEndpoint.Host(host), port: port, using: NWParameters(tls: nil, tcp: tcp)) + probe = conn + conn.stateUpdateHandler = { [weak self, weak conn] newState in + Task { @MainActor in + guard let self, let conn, conn === self.probe else { return } + switch newState { + case .ready: + conn.stateUpdateHandler = nil + conn.cancel() + self.probe = nil + self.reachedPeer = true + self.startConnection(port, tls: true) + case .failed(let error), .waiting(let error): + conn.stateUpdateHandler = nil + conn.cancel() + self.probe = nil + self.socketFailed(error.localizedDescription, reachable: false) + default: + break + } + } + } + conn.start(queue: .main) + } + + private func startConnection(_ port: NWEndpoint.Port, tls: Bool) { + let tcp = NWProtocolTCP.Options() + tcp.noDelay = true + // TLS gets longer: on first use the keychain may ask before the client key can sign. + tcp.connectionTimeout = tls ? 10 : 4 + tcp.enableKeepalive = true + let conn = NWConnection(host: NWEndpoint.Host(host), port: port, + using: NWParameters(tls: tls ? tlsOptions() : nil, tcp: tcp)) + connection = conn + conn.stateUpdateHandler = { [weak self, weak conn] newState in + Task { @MainActor in + guard let self, let conn, conn === self.connection else { return } + self.handle(newState) + } + } + conn.start(queue: .main) + } + + /// Presents the client certificate and accepts the TV's self-signed one. The pairing secret + /// mixes in the TV's public key, which is what ties the session to the right TV. + private func tlsOptions() -> NWProtocolTLS.Options { + let options = NWProtocolTLS.Options() + let sec = options.securityProtocolOptions + // TLS 1.2 only. The TV's remote service (Netty) is a TLS 1.2 server, and Apple's TLS 1.3 + // handshake against it fails or stalls; native clients that work with Xiaomi TVs pin 1.2. + sec_protocol_options_set_min_tls_protocol_version(sec, .TLSv12) + sec_protocol_options_set_max_tls_protocol_version(sec, .TLSv12) + do { + if let secIdentity = sec_identity_create(try ClientIdentity.identity()) { + sec_protocol_options_set_local_identity(sec, secIdentity) + } + } catch { + // Without it the TV refuses the connection; say why instead of failing obscurely. + lastError = "Client certificate unavailable: \(error.localizedDescription)" + } + sec_protocol_options_set_verify_block(sec, { [weak self] _, trust, complete in + let secTrust = sec_trust_copy_ref(trust).takeRetainedValue() + var leaf: [UInt8]? + if let chain = SecTrustCopyCertificateChain(secTrust) as? [SecCertificate], let first = chain.first { + leaf = [UInt8](SecCertificateCopyData(first) as Data) + } + // Runs on the main queue (below), before the connection reports .ready. + MainActor.assumeIsolated { self?.peerCertificate = leaf } + complete(true) + }, .main) + return options + } + + private func closeSocket() { + probe?.stateUpdateHandler = nil + probe?.cancel() + probe = nil + heartbeat?.invalidate() + heartbeat = nil + idleTimer?.invalidate() + idleTimer = nil + connection?.stateUpdateHandler = nil + connection?.cancel() + connection = nil + pendingSends.removeAll() + phase = .none + } + + private func handle(_ newState: NWConnection.State) { + switch newState { + case .ready: + reachedPeer = true + switch phase { + case .milink: milinkConnected() + case .atv: atvConnected() + case .pairing: pairingConnected() + case .none: break + } + receive() + case .failed(let error), .waiting(let error): + socketFailed(error.localizedDescription, reachable: reachedPeer || peerWasReachable(error)) + default: + break + } + } + + /// `reachable`: TCP had connected, so something listens on that port. + private func socketFailed(_ message: String, reachable: Bool) { + let failedPhase = phase + let port: UInt16 = switch failedPhase { + case .milink: Self.milinkPort + case .atv: ATV.remotePort + case .pairing: ATV.pairingPort + case .none: 0 + } + let detail = port == 0 ? message : "\(port): \(message)" + lastError = detail + closeSocket() + stopRepeat(sendUp: false) + switch failedPhase { + case .milink: milinkFailed(detail, reachable: reachable) + case .atv: atvFailed(detail, reachable: reachable) + case .pairing: pairingFailed(detail, reachable: reachable) + case .none: break + } + } + + private func receive() { + guard let current = connection else { return } + current.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self, weak current] data, _, isComplete, error in + Task { @MainActor in + guard let self, let current, current === self.connection else { return } + if let data, !data.isEmpty { + switch self.phase { + case .milink: + self.milinkBuffer.append(data) + self.drainMiLinkFrames() + case .atv, .pairing: + self.atvBuffer += [UInt8](data) + for message in Proto.split(&self.atvBuffer) { + if self.phase == .atv { self.handleAtvMessage(message) } + else if self.phase == .pairing { self.handlePairingMessage(message) } + } + case .none: + break + } + } + guard current === self.connection else { return } + if isComplete || error != nil { + self.socketFailed(error?.localizedDescription ?? "TV closed the connection", reachable: true) + } else { + self.receive() + } + } + } + } + + private func sendDelimited(_ message: [UInt8]) { + connection?.send(content: Data(Proto.delimited(message)), completion: .contentProcessed { _ in }) + } + + // MARK: MiLink + + private func milinkConnected() { + state = .connected + lastError = nil + if tvProtocol != .milink { remember(.milink) } + heartbeat?.invalidate() + heartbeat = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in + Task { @MainActor in self?.write(Data([2, 0, 0])) } + } + } + + private func milinkFailed(_ message: String, reachable: Bool) { + if tvProtocol == nil && !reachable { + // Nothing on 6091: this TV may only have the Android TV Remote service. + openAtv() + return + } + state = .failed(message) + scheduleReconnect() + } + + private func drainMiLinkFrames() { + while milinkBuffer.count >= 3 { + let bytes = [UInt8](milinkBuffer.prefix(3)) + let length = Int(bytes[1]) << 8 | Int(bytes[2]) + guard milinkBuffer.count >= 3 + length else { return } + let start = milinkBuffer.startIndex + let payload = milinkBuffer.subdata(in: start + 3 ..< start + 3 + length) + milinkBuffer.removeSubrange(start ..< start + 3 + length) + if bytes[0] == 4, payload.count >= 7 { + let p = [UInt8](payload) + let id = UInt32(p[1]) << 24 | UInt32(p[2]) << 16 | UInt32(p[3]) << 8 | UInt32(p[4]) + if let sent = pendingSends.removeValue(forKey: id) { + lastAckMs = Int(Date().timeIntervalSince(sent) * 1000) + } + } + } + } + + private func write(_ data: Data) { + guard state == .connected, phase == .milink, let connection else { return } + connection.send(content: data, completion: .contentProcessed { _ in }) + } + + private func keyPacket(code: Int32, action: Int32, meta: Int32 = 0, repeatCount: Int32 = 0) -> Data { + func i32(_ tag: UInt8, _ v: Int32) -> [UInt8] { + let u = UInt32(bitPattern: v) + return [tag, UInt8(u >> 24), UInt8(u >> 16 & 0xFF), UInt8(u >> 8 & 0xFF), UInt8(u & 0xFF)] + } + func i64(_ tag: UInt8, _ v: Int64) -> [UInt8] { + let u = UInt64(bitPattern: v) + return [tag] + (0..<8).map { UInt8(u >> (56 - 8 * UInt64($0)) & 0xFF) } + } + var data: [UInt8] = [] + data += i32(1, meta) + data += i32(2, action) // 0 = ACTION_DOWN, 1 = ACTION_UP + data += i32(3, code) + data += i32(4, 0) + data += i32(5, repeatCount) + data += i32(6, 0) + data += i64(7, 0) // downTime: let the TV stamp it + data += i64(8, 0) // eventTime: let the TV stamp it + data += i32(10, -1) // KeyCharacterMap.VIRTUAL_KEYBOARD + data += i32(11, 0x101) // InputDevice.SOURCE_KEYBOARD + + nextId &+= 1 + let id = nextId + pendingSends[id] = Date() + if pendingSends.count > 64 { pendingSends.removeAll() } + + let len = UInt16(data.count) + var body: [UInt8] = [1, UInt8(id >> 24), UInt8(id >> 16 & 0xFF), UInt8(id >> 8 & 0xFF), UInt8(id & 0xFF), + UInt8(len >> 8), UInt8(len & 0xFF)] + body += data + let total = UInt16(body.count) + return Data([4, UInt8(total >> 8), UInt8(total & 0xFF)] + body) + } + + // MARK: Android TV Remote + + private func atvConnected() { + resetIdleTimer() + } + + private func atvFailed(_ message: String, reachable: Bool) { + if !atvReady && reachable { + // The service is there but dropped the TLS session: our certificate isn't paired (yet). + if justPaired { + justPaired = false + state = .pairing + pairingStep = .starting + pairingError = .rejected + return + } + startPairing() + return + } + state = .failed(message) + scheduleReconnect() + } + + /// The TV pings every 5 s; three missed pings means the connection is gone. + private func resetIdleTimer() { + idleTimer?.invalidate() + idleTimer = Timer.scheduledTimer(withTimeInterval: 16, repeats: false) { [weak self] _ in + Task { @MainActor in + guard let self, self.phase == .atv else { return } + self.socketFailed("No response from the TV", reachable: true) + } + } + } + + private func handleAtvMessage(_ bytes: [UInt8]) { + resetIdleTimer() + guard let event = try? RemoteMessage.parse(bytes) else { return } + switch event { + case .configure(let supported, _): + features = RemoteMessage.wantedFeatures & supported + sendDelimited(RemoteMessage.configure(features: features)) + markAtvReady() + case .setActive: + sendDelimited(RemoteMessage.setActive(features: features)) + case .ping(let value): + sendDelimited(RemoteMessage.pingResponse(value)) + case .start: + markAtvReady() + case .imeBatchEdit(let ime, let field): + imeCounter = ime + imeFieldCounter = field + case .imeField(let field): + imeFieldCounter = field + case .error, .other: + break + } + } + + private func markAtvReady() { + guard !atvReady else { return } + atvReady = true + justPaired = false + if tvProtocol != .atv { remember(.atv) } + state = .connected + lastAckMs = nil + lastError = nil + pairingStep = nil + pairingError = nil + } + + /// The Android TV Remote service ignores keys outside a supported set, including the + /// input-source and recent-apps keys (tested on a MiTV-MOOR2, #4). Input source opens + /// Settings instead, where the TV's inputs are; recent apps has no equivalent. + static func atvKey(for code: Int32) -> Int32? { + switch code { + case AndroidKey.tvInput.rawValue: return AndroidKey.settings.rawValue + case AndroidKey.appSwitch.rawValue: return nil + default: return code + } + } + + private func sendAtvKey(_ code: Int32) { + guard state == .connected, phase == .atv else { return } + sendDelimited(RemoteMessage.keyInject(code: code, direction: .short)) + } + + // MARK: Android TV pairing + + private func startPairing(error: PairingError? = nil) { + serverKey = nil + state = .pairing + pairingStep = .starting + pairingError = error + guard prepareCertificate() else { return } + openSocket(port: ATV.pairingPort, tls: true, phase: .pairing) + } + + /// Asks the TV for a new code, e.g. after a failed attempt. + func retryPairing() { + guard wantConnected, !host.isEmpty else { return } + reconnectWork?.cancel() + startPairing() + } + + /// Sends the code shown on the TV. False if it can't be right (checksum mismatch). + @discardableResult + func submitPairingCode(_ code: String) -> Bool { + guard phase == .pairing, pairingStep == .enterCode, let clientKey, let serverKey else { return false } + guard let secret = Pairing.secret(client: clientKey, server: serverKey, code: code) else { + pairingError = .wrongCode + return false + } + sendDelimited(Pairing.secretMessage(secret)) + pairingStep = .verifying + pairingError = nil + return true + } + + private func pairingConnected() { + guard let peerCertificate, let key = try? DER.rsaPublicKey(peerCertificate) else { + closeSocket() + pairingError = .connection + return + } + serverKey = key + sendDelimited(Pairing.request(clientName: ATV.clientName)) + } + + /// Stays on the pairing sheet with the error, rather than silently starting over. + private func pairingFailed(_ message: String, reachable: Bool) { + state = .pairing + pairingStep = .starting + pairingError = .connection + } + + private func handlePairingMessage(_ bytes: [UInt8]) { + guard let parsed = try? Pairing.parse(bytes) else { return } + let (status, reply) = parsed + guard status == Pairing.statusOK else { + closeSocket() + if status == Pairing.statusBadSecret { + startPairing(error: .wrongCode) + } else { + pairingError = .rejected + } + return + } + switch reply { + case .requestAck: + sendDelimited(Pairing.options()) + case .options: + sendDelimited(Pairing.configuration()) + case .configurationAck: + pairingStep = .enterCode + case .secretAck: + justPaired = true + remember(.atv) + pairingStep = nil + pairingError = nil + state = .connecting + openAtv() + case .unknown: + break + } + } + + // MARK: Keys + + /// Single press: DOWN followed by UP. + func press(_ key: AndroidKey) { press(code: key.rawValue) } + + func press(code: Int32, meta: Int32 = 0) { + if phase == .atv { + if let code = Self.atvKey(for: code) { sendAtvKey(code) } + } else { + write(keyPacket(code: code, action: 0, meta: meta)) + write(keyPacket(code: code, action: 1, meta: meta)) + } + } + + /// Begin a press-and-hold that repeats until `endHold()`. MiLink: the TV auto-releases a key + /// 175 ms after the last DOWN, so DOWN is resent every 100 ms. Android TV Remote: discrete + /// presses every 120 ms. + func beginHold(_ key: AndroidKey) { + stopRepeat(sendUp: true) + repeatCode = key.rawValue + repeatCount = 0 + if phase == .atv { + sendAtvKey(repeatCode) + repeatTimer = Timer.scheduledTimer(withTimeInterval: 0.12, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.sendAtvKey(self.repeatCode) + } + } + return + } + write(keyPacket(code: repeatCode, action: 0)) + repeatTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.repeatCount += 1 + self.write(self.keyPacket(code: self.repeatCode, action: 0, repeatCount: self.repeatCount)) + } + } + } + + func endHold() { stopRepeat(sendUp: true) } + + private func stopRepeat(sendUp: Bool) { + guard let timer = repeatTimer else { return } + timer.invalidate() + repeatTimer = nil + if sendUp && phase == .milink { write(keyPacket(code: repeatCode, action: 1)) } + } + + /// Types text on the TV. Android TV Remote sends it as an IME edit into the focused text + /// field (the TV ignores letter key events there). MiLink types one keystroke at a time and + /// skips characters with no Android key. + func type(_ text: String) { + guard !text.isEmpty else { return } + if phase == .atv { + guard state == .connected else { return } + sendDelimited(RemoteMessage.imeBatchEdit(imeCounter: imeCounter ?? 0, fieldCounter: imeFieldCounter, text: text)) + return + } + var delay = 0.0 + for ch in text { + guard let stroke = androidKeystroke(for: ch) else { continue } + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.press(code: stroke.code, meta: stroke.meta) + } + delay += 0.04 + } + } +} + +/// Whether a connection error means something is listening on the port. Network.framework +/// reports a TV that aborts the TLS handshake (an unpaired client certificate) as a TLS error or, +/// often, as a plain reset; only refusals, timeouts and missing routes mean nobody's there. +func peerWasReachable(_ error: NWError) -> Bool { + switch error { + case .tls: + return true + case .posix(let code): + let unreachable: Set = [ + .ECONNREFUSED, .ETIMEDOUT, .EHOSTUNREACH, .ENETUNREACH, .EHOSTDOWN, .ENETDOWN, .EADDRNOTAVAIL, + ] + return !unreachable.contains(code) + default: + return false + } +} diff --git a/apps/macos/Sources/MiRemote/RemoteView.swift b/apps/macos/Sources/MiRemote/RemoteView.swift index e7ae1e8..0de2cc1 100644 --- a/apps/macos/Sources/MiRemote/RemoteView.swift +++ b/apps/macos/Sources/MiRemote/RemoteView.swift @@ -16,7 +16,7 @@ private enum Palette { // MARK: - Root struct RemoteView: View { - @StateObject private var client = MiLinkClient() + @StateObject private var client = RemoteClient() @StateObject private var discovery = TVDiscovery() @AppStorage("tvHost") private var savedHost = "" @State private var showConnect = false @@ -89,6 +89,15 @@ struct RemoteView: View { .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Palette.bodyEdge)) .padding(14) } + } else if client.state == .pairing { + // Android TVs without MiLink need a one-time code shown on the TV. + ZStack { + Color.black.opacity(0.55) + PairSheet(client: client) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(Palette.body)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Palette.bodyEdge)) + .padding(14) + } } } } @@ -137,9 +146,17 @@ struct RemoteView: View { HStack { RoundKey(symbol: "power", tint: Palette.power, help: "Power (P)") { client.press(.power) } Spacer() - RoundKey(symbol: "rectangle.on.rectangle", help: "Input source") { client.press(.tvInput) } + RoundKey(symbol: "rectangle.on.rectangle", + help: client.tvProtocol == .atv ? "Input source (opens Settings: this TV has no input key over Wi-Fi)" : "Input source") { + client.press(.tvInput) + } Spacer() - RoundKey(symbol: "square.stack", help: "Recent apps") { client.press(.appSwitch) } + RoundKey(symbol: "square.stack", + help: client.tvProtocol == .atv ? "Recent apps (not available on this TV over Wi-Fi)" : "Recent apps") { + client.press(.appSwitch) + } + .disabled(client.tvProtocol == .atv) + .opacity(client.tvProtocol == .atv ? 0.35 : 1) Spacer() RoundKey(symbol: "gearshape.fill", help: "Settings") { client.press(.settings) } } @@ -213,6 +230,7 @@ struct RemoteView: View { case .connecting: return .yellow case .failed: return Palette.power case .disconnected: return .gray + case .pairing: return .yellow } } @@ -224,13 +242,14 @@ struct RemoteView: View { case .connecting: return "Connecting to \(client.host)…" case .failed(let msg): return "Retrying · \(msg)" case .disconnected: return "Not connected" + case .pairing: return "Pairing with \(client.host)…" } } // MARK: Keyboard private func handleKey(_ press: KeyPress) -> KeyPress.Result { - if textFocused || press.modifiers.contains(.command) { return .ignored } + if textFocused || press.modifiers.contains(.command) || client.state == .pairing { return .ignored } switch press.key { case .upArrow: client.press(.dpadUp) case .downArrow: client.press(.dpadDown) @@ -280,7 +299,7 @@ struct RoundKey: View { /// Tap = single press; press-and-hold (>350 ms) = auto-repeat until release. struct HoldKey: View { - @ObservedObject var client: MiLinkClient + @ObservedObject var client: RemoteClient var key: AndroidKey var symbol: String var size: CGFloat = 44 @@ -305,7 +324,7 @@ struct HoldKey: View { @MainActor func holdGesture(pressed: Binding, holding: Binding, task: Binding, - client: MiLinkClient, key: AndroidKey) -> some Gesture { + client: RemoteClient, key: AndroidKey) -> some Gesture { DragGesture(minimumDistance: 0) .onChanged { _ in guard !pressed.wrappedValue else { return } @@ -347,7 +366,7 @@ struct KeyStyle: ButtonStyle { // MARK: - D-pad struct DPad: View { - @ObservedObject var client: MiLinkClient + @ObservedObject var client: RemoteClient private let diameter: CGFloat = 200 var body: some View { @@ -378,7 +397,7 @@ struct DPad: View { } private struct DPadArrow: View { - @ObservedObject var client: MiLinkClient + @ObservedObject var client: RemoteClient var key: AndroidKey var symbol: String @State private var pressed = false @@ -400,7 +419,7 @@ private struct DPadArrow: View { // MARK: - Rocker struct Rocker: View { - @ObservedObject var client: MiLinkClient + @ObservedObject var client: RemoteClient var up: AndroidKey var down: AndroidKey var label: String @@ -422,7 +441,7 @@ struct Rocker: View { } private struct RockerHalf: View { - @ObservedObject var client: MiLinkClient + @ObservedObject var client: RemoteClient var key: AndroidKey var symbol: String @State private var pressed = false @@ -503,3 +522,118 @@ struct ConnectSheet: View { .padding(18) } } + +// MARK: - Pair sheet + +/// One-time pairing for TVs that only have Google's Android TV remote service: the TV shows a +/// 6-character code and the user types it here. Disappears once the TV is connected. +struct PairSheet: View { + @ObservedObject var client: RemoteClient + @State private var code = "" + @FocusState private var codeFocused: Bool + + private var verifying: Bool { client.pairingStep == .verifying } + private var canRetry: Bool { + client.pairingError == .rejected || client.pairingError == .connection || client.pairingError == .certificate + } + private var showsCodeField: Bool { + !canRetry && (client.pairingStep == .enterCode || client.pairingStep == .verifying) + } + private var valid: Bool { Pairing.normalize(code) != nil } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Pair with your TV") + .font(.headline) + Text("This TV (\(client.host)) doesn't have Xiaomi's MiLink service, so Mi Remote uses the Android TV remote service instead. That needs a one-time pairing.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if showsCodeField { + Text("Enter the 6-character code shown on your TV") + .font(.subheadline) + .foregroundStyle(.secondary) + HStack { + TextField("A1B2C3", text: $code) + .textFieldStyle(.roundedBorder) + .font(.system(size: 18, weight: .semibold, design: .monospaced)) + .focused($codeFocused) + .disabled(verifying) + .onSubmit(submit) + .onChange(of: code) { _, value in + let cleaned = String(value.uppercased().filter { !$0.isWhitespace }.prefix(6)) + if cleaned != value { code = cleaned } + } + Button(action: submit) { + if verifying { + ProgressView().controlSize(.small) + } else { + Text("Pair") + } + } + .keyboardShortcut(.defaultAction) + .disabled(!valid || verifying) + } + } else if !canRetry { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Asking the TV for a pairing code…") + .font(.callout) + } + } + + if let message = errorMessage { + Text(message) + .font(.callout) + .foregroundStyle(Palette.power) + .fixedSize(horizontal: false, vertical: true) + } + if client.pairingError != nil, let detail = client.lastError { + Text(detail) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + + if canRetry { + Button("Try again") { client.retryPairing() } + .keyboardShortcut(.defaultAction) + } + + Text("You only need to do this once per TV. If it keeps failing, on the TV open Settings → Apps → See all apps → Show system apps → Android TV Remote Service, and clear its storage.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Spacer() + Button("Cancel") { client.disconnect() } + .keyboardShortcut(.cancelAction) + } + } + .padding(18) + .onAppear { codeFocused = showsCodeField } + .onChange(of: client.pairingStep) { _, step in + if step == .starting { code = "" } + if step == .enterCode { codeFocused = true } + } + } + + private var errorMessage: String? { + switch client.pairingError { + case .wrongCode: return "That code didn't work. Enter the code now shown on the TV." + case .rejected: return "The TV didn't accept the pairing. Try again, and enter the code before it disappears from the TV." + case .connection: return "Lost the connection to the TV while pairing." + case .certificate: return "Couldn't create the pairing key in your keychain." + case nil: return nil + } + } + + private func submit() { + guard valid, !verifying else { return } + client.submitPairingCode(code) + } +} + diff --git a/apps/macos/Tests/MiRemoteTests/ATVProtocolTests.swift b/apps/macos/Tests/MiRemoteTests/ATVProtocolTests.swift new file mode 100644 index 0000000..a00c87f --- /dev/null +++ b/apps/macos/Tests/MiRemoteTests/ATVProtocolTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Network +import Security +import XCTest +@testable import MiRemote + +/// Same fixtures as the mobile app's src/atv tests: generated with androidtvremote2 (the Python +/// library Home Assistant uses) and its protobuf definitions. +final class ATVProtocolTests: XCTestCase { + private let clientCert = Data(base64Encoded: "MIICxDCCAaygAwIBAgICA+gwDQYJKoZIhvcNAQELBQAwETEPMA0GA1UEAwwGY2xpZW50MB4XDTI2MTAyMDAxNTIyNloXDTM2MTAxNzAxNTIyNlowETEPMA0GA1UEAwwGY2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwI5zpLK19vU172TlpQeRzqPtI6vS+aAbmrvjyKzHwN6iJYSGd3YP5mKnIvRWz4MO4YVgFQUQTG+gxB7OxTIeKjjyZBwTFMgD0n8pc7QCokAU9YxeQOqfJtiaVVO8G0D0a9p7hyd51iJ3J8Cn01QiY/R9Vu5EtfuzAw8BSr2buGI0cJpLGgskg+2yxysuo/GUCPWhoBjdj/Fi6+HJ0GsIrx40bAbfxfpmKtswO7ePPz93rYA99VamcUc771RgrYEuF2yenpdHKICxHOtvVzoOjNGC6E5vWohG1Fj3csvk7ON9m8OVdfoqyJPU9K7TUNiFGnlfo1bc9z5PfubYoZr1EwIDAQABoyYwJDAPBgNVHRMECDAGAQH/AgEAMBEGA1UdEQQKMAiCBmNsaWVudDANBgkqhkiG9w0BAQsFAAOCAQEAajHqzHW5Ujyw4CyXVhyK/G4k4w0o8QhIox/w5B1wnw1rcAt+/F4GHS2aQ11h1/gJtsC+YaqcbelxVvSSCqhvLDX7CWe8xoPCDX65HufvFNLQQEEUlJxkyegBe6n4tT22xJdTn6zTD65vYRZfSjKuvGXzpjRKZZFHSKd6aSGLtpqhVPqcR2Z/3YB9Su1VqEI/F2SWBCSejZUUD9f8X1f+aLPM6A77tOm5kz5M0CMFsIA+MqqTJn9cz+jbdkFKYkiWtBMAiGi+v9uVPdypQyHq88f7gfzBEVMko3I9/XPgUd353cKXUTei986incGaWHp7NAotNKE9yMEDIxxv5b3CwA==")! + private let serverCert = Data(base64Encoded: "MIICuDCCAaCgAwIBAgICA+gwDQYJKoZIhvcNAQELBQAwDTELMAkGA1UEAwwCdHYwHhcNMjYxMDIwMDE1MjI2WhcNMzYxMDE3MDE1MjI2WjANMQswCQYDVQQDDAJ0djCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKOqS1k8/QaU27Ah/BkgYO6xR0qM2tI1HbzsrPe/YEQHjT95yqYUcgVgQ74wPqUshrj4GoSfF4u9IF7iqMaYKv5sc/fVQhmR0ej8AJQa7OYWchzgP/fFl2I/A+/6epG7A+IwgMPwb+PEMdvZNsY6XuyqUEKg/AL8b/2/xXjCGCSG2vC1cuOTyBFwGmrSM9girl7ISea9kRx3KlF9oXG0ekUI7OZRPwkTRJTfkyk4oygYhR4KVW+eAc23Q9TIw1TyxHU8sz3v6wWlAC6WHXBESvOb+/bCdxXjZZiEUL0bGM9YfZFpLn/rKGDR/sX2B8EO5qd9XSn9FyJ/DCEyaZoYdDcCAwEAAaMiMCAwDwYDVR0TBAgwBgEB/wIBADANBgNVHREEBjAEggJ0djANBgkqhkiG9w0BAQsFAAOCAQEAX4P/t/4WtUhbiUXfnMrnTilNrvensSm5jxV95gMAbsF+8y/ikhkhdjb11gfs++68eBvpkOBnk7yeizK9W84QpKEUbMpcBTTbeBlf5RaPpsMGtYpaabAVKMe3VVFld+06oZj8QTEWYRC5FNEndvAYc9epHnRYQiT/lZBKbLFGRYaWzRefvVgr4lEPpzq44Q8vr1T8bakJXZM4FoGpjg+f+7X1uOIAIFVXpKbhOg9k1ZuKppSI8NZiaRM+3BXfJ1ek0ANG5V3q3VpmczXH7KrVgrbZJrrlUfgJf2V2eFee3aieiqeayWkn/dbslsZpLOZvEwuO531yglPfcziup268Dg==")! + private let pairingCode = "AA3F9A" + private let pairingSecretHex = "aa7fdc8dfcff4b9ddd943c85edcd6ba1469fe9795d6029fe03f55ede331c8ecc" + + private func hex(_ bytes: [UInt8]) -> String { bytes.map { String(format: "%02x", $0) }.joined() } + + func testVarints() { + XCTAssertEqual(Proto.varint(0), [0]) + XCTAssertEqual(Proto.varint(300), [0xAC, 0x02]) + XCTAssertEqual(Proto.varint(-1), [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01]) + } + + func testSplitsDelimitedMessagesAcrossChunks() { + let big = (0..<200).map { UInt8($0 & 0xFF) } + let stream = Proto.delimited([1, 2, 3]) + Proto.delimited(big) + var buffer = Array(stream.prefix(5)) + let first = Proto.split(&buffer) + XCTAssertEqual(first, [[1, 2, 3]]) + buffer += stream.dropFirst(5) + let second = Proto.split(&buffer) + XCTAssertEqual(second, [big]) + XCTAssertEqual(buffer, []) + } + + func testPairingMessagesMatchReference() { + XCTAssertEqual(Pairing.request(clientName: "Mi Remote"), [8, 2, 16, 200, 1, 82, 22, 10, 9, 97, 116, 118, 114, 101, 109, 111, 116, 101, 18, 9, 77, 105, 32, 82, 101, 109, 111, 116, 101]) + XCTAssertEqual(Pairing.options(), [8, 2, 16, 200, 1, 162, 1, 8, 10, 4, 8, 3, 16, 6, 24, 1]) + XCTAssertEqual(Pairing.configuration(), [8, 2, 16, 200, 1, 242, 1, 8, 10, 4, 8, 3, 16, 6, 16, 1]) + XCTAssertEqual(Pairing.secretMessage((0..<32).map { UInt8($0) }), [8, 2, 16, 200, 1, 194, 2, 34, 10, 32, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]) + } + + func testParsesPairingReplies() throws { + XCTAssertEqual(try Pairing.parse([8, 2, 16, 200, 1, 90, 4, 10, 2, 116, 118]).reply, .requestAck) + XCTAssertEqual(try Pairing.parse([8, 2, 16, 200, 1, 162, 1, 8, 10, 4, 8, 3, 16, 6, 24, 1]).reply, .options) + XCTAssertEqual(try Pairing.parse([8, 2, 16, 200, 1, 250, 1, 0]).reply, .configurationAck) + XCTAssertEqual(try Pairing.parse([8, 2, 16, 200, 1, 202, 2, 2, 10, 0]).reply, .secretAck) + XCTAssertEqual(try Pairing.parse([8, 2, 16, 146, 3]).status, Pairing.statusBadSecret) + } + + func testPairingSecretMatchesReference() throws { + let client = try DER.rsaPublicKey([UInt8](clientCert)) + let server = try DER.rsaPublicKey([UInt8](serverCert)) + XCTAssertEqual(Pairing.secret(client: client, server: server, code: pairingCode).map(hex), pairingSecretHex) + XCTAssertEqual(Pairing.secret(client: client, server: server, code: " aa3f9a ").map(hex), pairingSecretHex) + XCTAssertNil(Pairing.secret(client: client, server: server, code: "AB3F9A")) + XCTAssertNil(Pairing.secret(client: client, server: server, code: "AA3F9")) + } + + func testRemoteMessagesMatchReference() { + XCTAssertEqual(RemoteMessage.keyInject(code: 19, direction: .short), [82, 4, 8, 19, 16, 3]) + XCTAssertEqual(RemoteMessage.pingResponse(-5), [74, 11, 8, 251, 255, 255, 255, 255, 255, 255, 255, 255, 1]) + XCTAssertEqual(RemoteMessage.setActive(features: 615), [18, 3, 8, 231, 4]) + XCTAssertEqual(RemoteMessage.imeBatchEdit(imeCounter: 3, fieldCounter: 7, text: "héllo"), [170, 1, 22, 8, 3, 16, 7, 26, 16, 8, 1, 18, 12, 8, 5, 16, 5, 26, 6, 104, 195, 169, 108, 108, 111]) + XCTAssertEqual(RemoteMessage.configure(features: 615), [10, 50, 8, 231, 4, 18, 45, 10, 9, 77, 105, 32, 82, 101, 109, 111, 116, 101, 18, 9, 77, 105, 32, 82, 101, 109, 111, 116, 101, 24, 1, 34, 1, 49, 42, 9, 97, 116, 118, 114, 101, 109, 111, 116, 101, 50, 5, 49, 46, 48, 46, 48]) + XCTAssertEqual(RemoteMessage.wantedFeatures, 615) + } + + func testParsesRemoteMessages() throws { + XCTAssertEqual(try RemoteMessage.parse([66, 5, 8, 185, 96, 16, 1]), .ping(12345)) + XCTAssertEqual(try RemoteMessage.parse([178, 1, 12, 18, 10, 8, 4, 50, 6, 83, 101, 97, 114, 99, 104]), .imeField(fieldCounter: 4)) + XCTAssertEqual(try RemoteMessage.parse([162, 1, 19, 10, 13, 98, 11, 99, 111, 109, 46, 110, 101, 116, 102, 108, 105, 120, 18, 2, 8, 6]), .imeField(fieldCounter: 6)) + XCTAssertEqual(try RemoteMessage.parse([10, 25, 8, 255, 4, 18, 20, 10, 10, 77, 105, 84, 86, 45, 77, 79, 79, 82, 50, 18, 6, 88, 105, 97, 111, 109, 105]), .configure(features: 639, model: "MiTV-MOOR2")) + XCTAssertEqual(try RemoteMessage.parse([194, 2, 2, 8, 1]), .start(true)) + XCTAssertEqual(try RemoteMessage.parse([170, 1, 4, 8, 9, 16, 2]), .imeBatchEdit(imeCounter: 9, fieldCounter: 2)) + XCTAssertEqual(try RemoteMessage.parse([74, 11, 8, 251, 255, 255, 255, 255, 255, 255, 255, 255, 1]), .other) + } + + func testReadsRSAKeyFromCertificateAndPKCS1() throws { + let fromCert = try DER.rsaPublicKey([UInt8](clientCert)) + let cert = try XCTUnwrap(SecCertificateCreateWithData(nil, clientCert as CFData)) + let key = try XCTUnwrap(SecCertificateCopyKey(cert)) + let pkcs1 = try XCTUnwrap(SecKeyCopyExternalRepresentation(key, nil) as Data?) + XCTAssertEqual(try DER.rsaPublicKey([UInt8](pkcs1)), fromCert) + XCTAssertEqual(fromCert.exponent, [1, 0, 1]) + XCTAssertEqual(fromCert.modulus.count, 256) + } + + /// Builds a certificate the way ClientIdentity does, with an in-memory key, and checks that + /// Security.framework parses it and that its signature verifies. + func testBuildsAValidSelfSignedCertificate() throws { + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits as String: 2048, + ] + let privateKey = try XCTUnwrap(SecKeyCreateRandomKey(attributes as CFDictionary, nil)) + let publicKey = try XCTUnwrap(SecKeyCopyPublicKey(privateKey)) + let pkcs1 = try XCTUnwrap(SecKeyCopyExternalRepresentation(publicKey, nil) as Data?) + let rsa = try DER.rsaPublicKey([UInt8](pkcs1)) + + let tbs = DER.tbsCertificate(key: rsa, commonName: "Mi Remote", serial: [0x81, 2, 3, 4], notBefore: Date()) + let signature = try XCTUnwrap( + SecKeyCreateSignature(privateKey, .rsaSignatureMessagePKCS1v15SHA256, Data(tbs) as CFData, nil) as Data? + ) + let der = DER.certificate(tbs: tbs, signature: [UInt8](signature)) + + let cert = try XCTUnwrap(SecCertificateCreateWithData(nil, Data(der) as CFData)) + XCTAssertEqual(SecCertificateCopySubjectSummary(cert) as String?, "Mi Remote") + XCTAssertEqual(try DER.rsaPublicKey(der), rsa) + XCTAssertTrue(SecKeyVerifySignature(publicKey, .rsaSignatureMessagePKCS1v15SHA256, + Data(tbs) as CFData, signature as CFData, nil)) + } + + @MainActor + func testMapsKeysTheAndroidTVServiceIgnores() { + XCTAssertEqual(RemoteClient.atvKey(for: AndroidKey.tvInput.rawValue), AndroidKey.settings.rawValue) + XCTAssertNil(RemoteClient.atvKey(for: AndroidKey.appSwitch.rawValue)) + XCTAssertEqual(RemoteClient.atvKey(for: AndroidKey.home.rawValue), AndroidKey.home.rawValue) + } + + /// A TV rejecting an unpaired certificate often shows up as a reset, not a TLS error; that + /// must still count as "the service is there" so pairing starts. + func testClassifiesConnectionErrors() { + XCTAssertTrue(peerWasReachable(.posix(.ECONNRESET))) + XCTAssertTrue(peerWasReachable(.posix(.EPIPE))) + XCTAssertTrue(peerWasReachable(.tls(-9806))) + XCTAssertFalse(peerWasReachable(.posix(.ECONNREFUSED))) + XCTAssertFalse(peerWasReachable(.posix(.ETIMEDOUT))) + XCTAssertFalse(peerWasReachable(.posix(.EHOSTUNREACH))) + } +} diff --git a/apps/macos/Tests/MiRemoteTests/ClientIdentityTests.swift b/apps/macos/Tests/MiRemoteTests/ClientIdentityTests.swift new file mode 100644 index 0000000..78640bf --- /dev/null +++ b/apps/macos/Tests/MiRemoteTests/ClientIdentityTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Network +import Security +import XCTest +@testable import MiRemote + +/// The client key and certificate on a real (CI runner's) login keychain, step by step, so a +/// failure names the step that broke. +final class ClientIdentityTests: XCTestCase { + func testCreatesAStableCertificateAndATLSIdentity() throws { + let first = try ClientIdentity.ensureCertificate() + let der = try XCTUnwrap(ClientIdentity.certificate(), "certificate wasn't stored") + XCTAssertEqual(try DER.rsaPublicKey(der), first, "stored certificate is for another key") + + // A second call must reuse the key and certificate, not make new ones. + let second = try ClientIdentity.ensureCertificate() + XCTAssertEqual(second, first, "key changed between calls") + XCTAssertEqual(ClientIdentity.certificate(), der, "certificate was re-created") + + let key = try ClientIdentity.privateKey() + XCTAssertEqual(try DER.rsaPublicKey(try ClientIdentity.publicKeyData(key)), first) + + let identity = try ClientIdentity.identity() + var cert: SecCertificate? + XCTAssertEqual(SecIdentityCopyCertificate(identity, &cert), errSecSuccess) + XCTAssertEqual(cert.map { [UInt8](SecCertificateCopyData($0) as Data) }, der) + var identityKey: SecKey? + XCTAssertEqual(SecIdentityCopyPrivateKey(identity, &identityKey), errSecSuccess) + XCTAssertNotNil(sec_identity_create(identity), "Network.framework can't use the identity") + } +} diff --git a/apps/macos/Tests/MiRemoteTests/FakeTVTests.swift b/apps/macos/Tests/MiRemoteTests/FakeTVTests.swift new file mode 100644 index 0000000..aac2d49 --- /dev/null +++ b/apps/macos/Tests/MiRemoteTests/FakeTVTests.swift @@ -0,0 +1,81 @@ +import Foundation +import XCTest +@testable import MiRemote + +/// End to end against Tests/fake_tv.py, a stand-in Android TV (TLS 1.2 only, client +/// certificates required, unpaired certificates rejected on 6466). Exercises what the unit tests +/// can't: Network.framework TLS, the keychain identity, and the whole probe → pair → connect flow. +/// +/// Runs when MIREMOTE_FAKE_TV_CODE and MIREMOTE_FAKE_TV_KEYS point at the fake TV's files (CI +/// starts it); skipped otherwise. +final class FakeTVTests: XCTestCase { + struct Timeout: Error, CustomStringConvertible { + var description: String + } + + @MainActor + private func waitUntil(_ what: String, timeout: TimeInterval = 30, client: RemoteClient, + _ condition: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + if Date() > deadline { + throw Timeout(description: "Timed out waiting for \(what). state=\(client.state) " + + "pairingStep=\(String(describing: client.pairingStep)) " + + "pairingError=\(String(describing: client.pairingError)) " + + "lastError=\(client.lastError ?? "nil")") + } + try await Task.sleep(nanoseconds: 100_000_000) + } + } + + @MainActor + func testPairsWithAndControlsAnAndroidTV() async throws { + let env = ProcessInfo.processInfo.environment + guard let codeFile = env["MIREMOTE_FAKE_TV_CODE"], let keysFile = env["MIREMOTE_FAKE_TV_KEYS"] else { + throw XCTSkip("Fake TV not running (set MIREMOTE_FAKE_TV_CODE and MIREMOTE_FAKE_TV_KEYS)") + } + try? FileManager.default.removeItem(atPath: codeFile) + try? FileManager.default.removeItem(atPath: keysFile) + UserDefaults.standard.removeObject(forKey: "tvProtocols") + + let client = RemoteClient() + // Nothing listens on 6091 here, so this also covers the MiLink → Android TV fallback. + client.connect(to: "127.0.0.1") + + try await waitUntil("the TV to show a pairing code", client: client) { + client.pairingStep == .enterCode && FileManager.default.fileExists(atPath: codeFile) + } + let code = try String(contentsOfFile: codeFile, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertTrue(client.submitPairingCode(code), "client rejected the TV's code \(code)") + + try await waitUntil("the remote connection after pairing", client: client) { + client.state == .connected + } + XCTAssertEqual(client.tvProtocol, .atv) + + client.press(.dpadUp) + client.press(.home) + try await waitUntil("the TV to receive the keys", client: client) { + ((try? String(contentsOfFile: keysFile, encoding: .utf8)) ?? "").contains("3 3") + } + let keys = try String(contentsOfFile: keysFile, encoding: .utf8) + XCTAssertEqual(keys, "19 3\n3 3\n") + + // Text goes in as an IME edit, even before the TV has sent any IME counters. + client.type("Hello!") + try await waitUntil("the TV to receive typed text", client: client) { + ((try? String(contentsOfFile: keysFile, encoding: .utf8)) ?? "").contains("text 0 0 6 Hello!") + } + + // Reconnecting reuses the pairing: straight to connected, no new code. + client.disconnect() + try? FileManager.default.removeItem(atPath: codeFile) + client.connect(to: "127.0.0.1") + try await waitUntil("reconnecting with the saved pairing", client: client) { + client.state == .connected + } + XCTAssertFalse(FileManager.default.fileExists(atPath: codeFile), "TV was asked to pair again") + client.disconnect() + } +} diff --git a/apps/macos/Tests/fake_tv.py b/apps/macos/Tests/fake_tv.py new file mode 100644 index 0000000..9870465 --- /dev/null +++ b/apps/macos/Tests/fake_tv.py @@ -0,0 +1,258 @@ +"""A stand-in Android TV for testing the Mac app's Android TV Remote support end to end. + +Behaves like the TV's Android TV Remote Service, as far as the client can tell: + + 6467 pairing: TLS 1.2 with a client certificate. Runs the polo handshake, then writes the + 6-character code a real TV would show to --code-file and waits for the secret. + 6466 remote: TLS 1.2 that only completes for a paired client certificate (anything else gets + a TLS alert during the handshake). Sends RemoteConfigure, SetActive and RemoteStart, and + appends every key the client injects to --keys-file as " ", and every + IME text edit as "text ". + +Usage: python3 fake_tv.py --code-file PATH --keys-file PATH +Needs: pip install androidtvremote2 pyopenssl (for the protobuf definitions and a TLS server +that accepts self-signed client certificates). +""" + +from __future__ import annotations + +import argparse +import datetime +import hashlib +import os +import socket +import threading + +from androidtvremote2 import polo_pb2, remotemessage_pb2 +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from google.protobuf.internal.decoder import _DecodeVarint +from google.protobuf.internal.encoder import _VarintBytes +from OpenSSL import SSL, crypto + +PAIRED: set[bytes] = set() +LOCK = threading.Lock() + + +def log(*args: object) -> None: + print("[fake-tv]", *args, flush=True) + + +def make_identity() -> tuple[rsa.RSAPrivateKey, x509.Certificate]: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "FakeTV")]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(1) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=365)) + .sign(key, hashes.SHA256()) + ) + return key, cert + + +SERVER_KEY, SERVER_CERT = make_identity() + + +def der(cert: x509.Certificate) -> bytes: + return cert.public_bytes(serialization.Encoding.DER) + + +def context(require_paired: bool) -> SSL.Context: + ctx = SSL.Context(SSL.TLS_SERVER_METHOD) + # Like the real service (Netty): TLS 1.2 only. + ctx.set_min_proto_version(SSL.TLS1_2_VERSION) + ctx.set_max_proto_version(SSL.TLS1_2_VERSION) + ctx.use_certificate(crypto.X509.from_cryptography(SERVER_CERT)) + ctx.use_privatekey(crypto.PKey.from_cryptography_key(SERVER_KEY)) + + def verify(_conn, cert, _errnum, depth, _ok) -> bool: + if depth != 0: + return True + if not require_paired: + return True + with LOCK: + paired = der(cert.to_cryptography()) in PAIRED + if not paired: + log("remote: rejecting unpaired client certificate") + return paired + + ctx.set_verify(SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT, verify) + return ctx + + +class Session: + def __init__(self, conn: SSL.Connection) -> None: + self.conn = conn + self.buffer = b"" + + def send(self, message) -> None: + data = message.SerializeToString() + self.conn.sendall(_VarintBytes(len(data)) + data) + + def messages(self): + while True: + try: + chunk = self.conn.recv(4096) + except (SSL.ZeroReturnError, SSL.SysCallError, SSL.Error): + return + if not chunk: + return + self.buffer += chunk + while self.buffer: + try: + length, pos = _DecodeVarint(self.buffer, 0) + except IndexError: + break + if len(self.buffer) < pos + length: + break + yield self.buffer[pos : pos + length] + self.buffer = self.buffer[pos + length :] + + +def pairing_secret(client: x509.Certificate, code_tail: str) -> bytes: + c = client.public_key().public_numbers() + s = SERVER_CERT.public_key().public_numbers() + h = hashlib.sha256() + h.update(bytes.fromhex(f"{c.n:X}")) + h.update(bytes.fromhex(f"0{c.e:X}")) + h.update(bytes.fromhex(f"{s.n:X}")) + h.update(bytes.fromhex(f"0{s.e:X}")) + h.update(bytes.fromhex(code_tail)) + return h.digest() + + +def outer(status: int = 200) -> polo_pb2.OuterMessage: + msg = polo_pb2.OuterMessage() + msg.protocol_version = 2 + msg.status = status + return msg + + +def handle_pairing(conn: SSL.Connection, args: argparse.Namespace) -> None: + client = conn.get_peer_certificate().to_cryptography() + session = Session(conn) + expected = b"" + for raw in session.messages(): + msg = polo_pb2.OuterMessage() + msg.ParseFromString(raw) + reply = outer() + if msg.HasField("pairing_request"): + log("pairing: request from", msg.pairing_request.client_name) + reply.pairing_request_ack.server_name = "FakeTV" + elif msg.HasField("options"): + enc = reply.options.input_encodings.add() + enc.type = polo_pb2.Options.Encoding.ENCODING_TYPE_HEXADECIMAL + enc.symbol_length = 6 + reply.options.preferred_role = polo_pb2.Options.RoleType.ROLE_TYPE_INPUT + elif msg.HasField("configuration"): + tail = os.urandom(2).hex().upper() + expected = pairing_secret(client, tail) + code = f"{expected[0]:02X}{tail}" + with open(args.code_file, "w") as f: + f.write(code) + log("pairing: showing code", code) + reply.configuration_ack.SetInParent() + elif msg.HasField("secret"): + if expected and msg.secret.secret == expected: + with LOCK: + PAIRED.add(der(client)) + log("pairing: paired") + reply.secret_ack.secret = expected + else: + log("pairing: wrong secret") + reply = outer(402) + else: + log("pairing: unexpected message") + reply = outer(400) + session.send(reply) + + +def handle_remote(conn: SSL.Connection, args: argparse.Namespace) -> None: + session = Session(conn) + hello = remotemessage_pb2.RemoteMessage() + hello.remote_configure.code1 = 639 + hello.remote_configure.device_info.model = "FakeTV" + hello.remote_configure.device_info.vendor = "Test" + session.send(hello) + for raw in session.messages(): + msg = remotemessage_pb2.RemoteMessage() + msg.ParseFromString(raw) + if msg.HasField("remote_configure"): + log("remote: client configured with features", msg.remote_configure.code1) + out = remotemessage_pb2.RemoteMessage() + out.remote_set_active.active = 0 + session.send(out) + elif msg.HasField("remote_set_active"): + out = remotemessage_pb2.RemoteMessage() + out.remote_start.started = True + session.send(out) + ping = remotemessage_pb2.RemoteMessage() + ping.remote_ping_request.val1 = 7 + session.send(ping) + elif msg.HasField("remote_ping_response"): + log("remote: ping answered", msg.remote_ping_response.val1) + elif msg.HasField("remote_ime_batch_edit"): + edit = msg.remote_ime_batch_edit + for info in edit.edit_info: + field = info.text_field_status + line = f"text {edit.ime_counter} {edit.field_counter} {field.end} {field.value}" + log("remote:", line) + with open(args.keys_file, "a") as f: + f.write(line + "\n") + elif msg.HasField("remote_key_inject"): + key = msg.remote_key_inject + log("remote: key", key.key_code, key.direction) + with open(args.keys_file, "a") as f: + f.write(f"{key.key_code} {key.direction}\n") + + +def serve(port: int, require_paired: bool, handler, args: argparse.Namespace) -> None: + ctx = context(require_paired) + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", port)) + listener.listen(8) + log("listening on", port) + + def run(sock: socket.socket) -> None: + conn = SSL.Connection(ctx, sock) + conn.set_accept_state() + try: + conn.do_handshake() + except SSL.Error as exc: + log(f"{port}: handshake failed:", exc) + sock.close() + return + log(f"{port}: TLS {conn.get_protocol_version_name()} up") + try: + handler(conn, args) + finally: + try: + conn.shutdown() + except SSL.Error: + pass + sock.close() + + while True: + sock, _ = listener.accept() + threading.Thread(target=run, args=(sock,), daemon=True).start() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--code-file", required=True) + parser.add_argument("--keys-file", required=True) + args = parser.parse_args() + threading.Thread(target=serve, args=(6467, False, handle_pairing, args), daemon=True).start() + serve(6466, True, handle_remote, args) + + +if __name__ == "__main__": + main() diff --git a/apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/ClientIdentity.kt b/apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/ClientIdentity.kt new file mode 100644 index 0000000..c009cd9 --- /dev/null +++ b/apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/ClientIdentity.kt @@ -0,0 +1,108 @@ +package expo.modules.milink + +import android.content.Context +import android.util.Base64 +import java.io.ByteArrayInputStream +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509TrustManager + +/** + * The RSA key and self-signed certificate the app presents to Android TVs (Android TV Remote + * protocol). The TV trusts it once paired, so it must survive app restarts. The certificate is + * built in TypeScript (src/atv/der.ts), which asks this class to sign it. + * + * Kept in app-private preferences as a plain software key: it only identifies this phone to a + * TV on the LAN, and a software key works with every TLS stack Android ships. + */ +internal class ClientIdentity(context: Context) { + private val prefs = context.getSharedPreferences("expo.modules.milink.atv", Context.MODE_PRIVATE) + + @Synchronized + fun publicKey(): ByteArray { + ensureKey() + return decode(prefs.getString(PUBLIC_KEY, null)!!) + } + + @Synchronized + fun certificate(): ByteArray? = prefs.getString(CERTIFICATE, null)?.let(::decode) + + @Synchronized + fun setCertificate(der: ByteArray) { + // Throws if it isn't a parseable certificate, so a bad one is never stored. + CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(der)) + prefs.edit().putString(CERTIFICATE, encode(der)).commit() + } + + @Synchronized + fun sign(data: ByteArray): ByteArray { + ensureKey() + return Signature.getInstance("SHA256withRSA").run { + initSign(privateKey()) + update(data) + sign() + } + } + + /** TLS context that presents our certificate and accepts the TV's self-signed one. */ + @Synchronized + fun socketFactory(): SSLSocketFactory { + val der = certificate() ?: throw IllegalStateException("No client certificate yet") + val cert = CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(der)) as X509Certificate + val store = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + setKeyEntry("client", privateKey(), CharArray(0), arrayOf(cert)) + } + val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + .apply { init(store, CharArray(0)) } + .keyManagers + val context = SSLContext.getInstance("TLS") + context.init(keyManagers, arrayOf(AcceptTvCertificate), null) + return context.socketFactory + } + + private fun privateKey(): PrivateKey { + val bytes = decode(prefs.getString(PRIVATE_KEY, null)!!) + return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(bytes)) + } + + private fun ensureKey() { + if (prefs.contains(PRIVATE_KEY) && prefs.contains(PUBLIC_KEY)) return + val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + prefs.edit() + .putString(PRIVATE_KEY, encode(pair.private.encoded)) + .putString(PUBLIC_KEY, encode(pair.public.encoded)) + .remove(CERTIFICATE) + .commit() + } + + /** + * TVs present a self-signed certificate, so there is no CA to check it against. The pairing + * secret mixes in the TV's public key, which is what ties the session to the right TV. + */ + @Suppress("CustomX509TrustManager", "TrustAllX509TrustManager") + private object AcceptTvCertificate : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + + private companion object { + const val PRIVATE_KEY = "privateKey" + const val PUBLIC_KEY = "publicKey" + const val CERTIFICATE = "certificate" + + fun encode(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP) + fun decode(text: String): ByteArray = Base64.decode(text, Base64.NO_WRAP) + } +} diff --git a/apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/MiLinkModule.kt b/apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/MiLinkModule.kt index e824b3c..8e8daba 100644 --- a/apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/MiLinkModule.kt +++ b/apps/mobile/modules/milink/android/src/main/java/expo/modules/milink/MiLinkModule.kt @@ -13,10 +13,12 @@ import java.io.OutputStream import java.net.Inet4Address import java.net.InetSocketAddress import java.net.Socket +import javax.net.ssl.SSLSocket /** - * Raw TCP socket and Mi TV discovery for the MiLink remote protocol. - * Framing and key packets live in TypeScript (src/milink); this module only moves bytes. + * Raw TCP/TLS socket and Mi TV discovery for the remote protocols. + * Framing and packets live in TypeScript (src/milink, src/atv); this module only moves bytes, + * plus the client key and TLS that the Android TV Remote protocol needs. */ class MiLinkModule : Module() { // All socket and discovery state is touched only on this thread. @@ -24,6 +26,9 @@ class MiLinkModule : Module() { private val handler = Handler(thread.looper) private var socket: Socket? = null + private val identity by lazy { + ClientIdentity(appContext.reactContext ?: throw IllegalStateException("No React context")) + } private var output: OutputStream? = null private var generation = 0 @@ -38,8 +43,23 @@ class MiLinkModule : Module() { Events("onSocketState", "onSocketData", "onTvFound") - Function("connect") { host: String, port: Int -> - handler.post { open(host, port) } + Function("connect") { host: String, port: Int, tls: Boolean -> + handler.post { open(host, port, tls) } + } + + AsyncFunction("clientKey") { + val cert = identity.certificate() + val result = mutableMapOf("publicKey" to identity.publicKey().toIntList()) + if (cert != null) result["certificate"] = cert.toIntList() + result + } + + AsyncFunction("signWithClientKey") { data: List -> + identity.sign(data.toByteArray()).toIntList() + } + + AsyncFunction("setClientCertificate") { der: List -> + identity.setCertificate(der.toByteArray()) } Function("disconnect") { @@ -47,7 +67,7 @@ class MiLinkModule : Module() { } Function("send") { bytes: List -> - val data = ByteArray(bytes.size) { bytes[it].toByte() } + val data = bytes.toByteArray() handler.post { try { output?.write(data) @@ -77,31 +97,51 @@ class MiLinkModule : Module() { // region Socket - private fun open(host: String, port: Int) { + private fun open(host: String, port: Int, tls: Boolean) { close() val gen = ++generation emitState("connecting") // Connecting blocks, so do it on its own thread and hop back to `handler` with the result. Thread { - val s = Socket() + val plain = Socket() try { - s.tcpNoDelay = true - s.keepAlive = true - s.connect(InetSocketAddress(host, port), 4000) + plain.tcpNoDelay = true + plain.keepAlive = true + plain.connect(InetSocketAddress(host, port), 4000) } catch (e: Exception) { - try { s.close() } catch (_: Exception) {} - handler.post { if (gen == generation) emitState("failed", e.message ?: "Connection failed") } + try { plain.close() } catch (_: Exception) {} + handler.post { if (gen == generation) emitState("failed", e.message ?: "Connection failed", reachable = false) } return@Thread } + var s: Socket = plain + var peerCertificate: List? = null + if (tls) { + try { + val ssl = identity.socketFactory().createSocket(plain, host, port, true) as SSLSocket + s = ssl + // TLS 1.2 only, matching the TV's remote service (and the Apple clients). + ssl.enabledProtocols = arrayOf("TLSv1.2") + ssl.soTimeout = 6000 + ssl.startHandshake() + ssl.soTimeout = 0 + peerCertificate = ssl.session.peerCertificates.firstOrNull()?.encoded?.toIntList() + } catch (e: Exception) { + try { s.close() } catch (_: Exception) {} + // TCP got through, so the service exists; the TLS session itself was refused. + handler.post { if (gen == generation) emitState("failed", e.message ?: "TLS handshake failed", reachable = true) } + return@Thread + } + } + val connected = s handler.post { if (gen != generation) { - try { s.close() } catch (_: Exception) {} + try { connected.close() } catch (_: Exception) {} return@post } - socket = s - output = s.getOutputStream() - emitState("connected") - startReader(gen, s.getInputStream()) + socket = connected + output = connected.getOutputStream() + emitState("connected", peerCertificate = peerCertificate) + startReader(gen, connected.getInputStream()) } }.start() } @@ -128,7 +168,7 @@ class MiLinkModule : Module() { private fun fail(gen: Int, message: String) { if (gen != generation || socket == null) return teardown() - emitState("failed", message) + emitState("failed", message, reachable = true) } private fun close() { @@ -144,12 +184,23 @@ class MiLinkModule : Module() { output = null } - private fun emitState(state: String, error: String? = null) { + private fun emitState( + state: String, + error: String? = null, + reachable: Boolean? = null, + peerCertificate: List? = null, + ) { val body = mutableMapOf("state" to state) if (error != null) body["error"] = error + if (reachable != null) body["reachable"] = reachable + if (peerCertificate != null) body["peerCertificate"] = peerCertificate sendEvent("onSocketState", body) } + private fun ByteArray.toIntList(): List = map { it.toInt() and 0xFF } + + private fun List.toByteArray(): ByteArray = ByteArray(size) { this[it].toByte() } + // endregion // region Discovery diff --git a/apps/mobile/modules/milink/index.ts b/apps/mobile/modules/milink/index.ts index 2fd80e3..c862e85 100644 --- a/apps/mobile/modules/milink/index.ts +++ b/apps/mobile/modules/milink/index.ts @@ -13,21 +13,47 @@ export interface FoundTV { address: string; } +export interface SocketStateEvent { + state: SocketState; + error?: string; + /** On 'failed': whether TCP had connected, i.e. something is listening on that port. */ + reachable?: boolean; + /** On 'connected' over TLS: the server's certificate (DER). */ + peerCertificate?: number[]; +} + +export interface ClientKey { + /** RSA public key, DER: SubjectPublicKeyInfo on Android, PKCS#1 on iOS. */ + publicKey: number[]; + /** The stored self-signed certificate (DER), if one was set. */ + certificate?: number[]; +} + type MiLinkEvents = { - onSocketState(event: { state: SocketState; error?: string }): void; + onSocketState(event: SocketStateEvent): void; onSocketData(event: { bytes: number[] }): void; onTvFound(event: FoundTV): void; }; declare class MiLinkModule extends NativeModule { - /** Opens a TCP connection, replacing any existing one. Progress arrives as `onSocketState`. */ - connect(host: string, port: number): void; + /** + * Opens a TCP connection, replacing any existing one; with `tls`, a TLS session that presents + * the client certificate and accepts any server certificate. Progress arrives as + * `onSocketState`. + */ + connect(host: string, port: number, tls: boolean): void; disconnect(): void; /** Writes raw bytes (0–255) to the open connection. */ send(bytes: number[]): void; /** Browses the LAN for Mi TVs; each match arrives as `onTvFound`. */ startDiscovery(): void; stopDiscovery(): void; + /** The app's persistent RSA client key (created on first call) and its certificate. */ + clientKey(): Promise; + /** SHA256withRSA (PKCS#1 v1.5) signature with the client key. */ + signWithClientKey(data: number[]): Promise; + /** Stores the self-signed client certificate used for TLS. */ + setClientCertificate(der: number[]): Promise; } export default requireNativeModule('MiLink'); diff --git a/apps/mobile/modules/milink/ios/ClientIdentity.swift b/apps/mobile/modules/milink/ios/ClientIdentity.swift new file mode 100644 index 0000000..1eb310d --- /dev/null +++ b/apps/mobile/modules/milink/ios/ClientIdentity.swift @@ -0,0 +1,160 @@ +import Foundation +import Security + +/// The RSA key and self-signed certificate the app presents to Android TVs (Android TV Remote +/// protocol). The TV trusts it once paired, so both live in the keychain. iOS can't create +/// certificates, so the certificate is built in TypeScript (src/atv/der.ts), which asks this +/// type to sign it; the keychain pairs the stored key and certificate into a TLS identity. +enum ClientIdentity { + private static let keyTag = Data("expo.modules.milink.atv.key".utf8) + private static let certLabel = "expo.modules.milink.atv.certificate" + + enum Failure: LocalizedError { + case keychain(OSStatus) + case security(String) + + var errorDescription: String? { + switch self { + case .keychain(let status): + return (SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error \(status)" + case .security(let message): + return message + } + } + } + + /// PKCS#1 RSAPublicKey DER of the client key, creating the key on first use. + static func publicKey() throws -> Data { + let key = try privateKey() + guard let pub = SecKeyCopyPublicKey(key) else { throw Failure.security("No public key") } + var error: Unmanaged? + guard let data = SecKeyCopyExternalRepresentation(pub, &error) as Data? else { + throw error!.takeRetainedValue() as Error + } + return data + } + + /// The certificate is also kept as a file: the keychain is only needed for the identity, and + /// on macOS a label lookup of the stored certificate came back empty (#4), so the file is the + /// source of truth for "which certificate is ours". + private static var certificateURL: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("expo.modules.milink", isDirectory: true) + .appendingPathComponent("client-certificate.der") + } + + static func certificate() -> Data? { + if let data = try? Data(contentsOf: certificateURL) { return data } + let query: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: certLabel, + kSecReturnData as String: true, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil } + return item as? Data + } + + static func setCertificate(_ der: Data) throws { + guard SecCertificateCreateWithData(nil, der as CFData) != nil else { + throw Failure.security("Not a valid certificate") + } + try FileManager.default.createDirectory(at: certificateURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try der.write(to: certificateURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + try addToKeychain(der) + } + + /// Puts the certificate in the keychain, where it joins the private key as an identity. + private static func addToKeychain(_ der: Data) throws { + guard let cert = SecCertificateCreateWithData(nil, der as CFData) else { + throw Failure.security("Not a valid certificate") + } + SecItemDelete([ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: certLabel, + ] as CFDictionary) + let status = SecItemAdd([ + kSecClass as String: kSecClassCertificate, + kSecValueRef as String: cert, + kSecAttrLabel as String: certLabel, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] as CFDictionary, nil) + guard status == errSecSuccess || status == errSecDuplicateItem else { throw Failure.keychain(status) } + } + + /// SHA256withRSA (PKCS#1 v1.5) signature, used to self-sign the certificate. + static func sign(_ data: Data) throws -> Data { + let key = try privateKey() + var error: Unmanaged? + guard let signature = SecKeyCreateSignature( + key, .rsaSignatureMessagePKCS1v15SHA256, data as CFData, &error + ) as Data? else { + throw error!.takeRetainedValue() as Error + } + return signature + } + + /// The TLS identity: our certificate joined with its private key in the keychain. If the + /// keychain doesn't have the certificate (or lost it), it's added back once. Throws with the + /// reason rather than letting the app connect without a client certificate. + static func identity() throws -> SecIdentity { + guard let wanted = certificate() else { throw Failure.security("No client certificate yet") } + if let found = findIdentity(for: wanted) { return found } + try addToKeychain(wanted) + if let found = findIdentity(for: wanted) { return found } + throw Failure.security("The keychain didn't pair the client certificate with its key") + } + + private static func findIdentity(for wanted: Data) -> SecIdentity? { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnRef as String: true, + ] + var items: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &items) == errSecSuccess, + let identities = items as? [SecIdentity] else { return nil } + for identity in identities { + var cert: SecCertificate? + if SecIdentityCopyCertificate(identity, &cert) == errSecSuccess, let cert, + SecCertificateCopyData(cert) as Data == wanted { + return identity + } + } + return nil + } + + private static func privateKey() throws -> SecKey { + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: keyTag, + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecReturnRef as String: true, + ] + var item: CFTypeRef? + if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item { + return item as! SecKey // swiftlint:disable:this force_cast + } + // A certificate for an older key would never match the new one. + try? FileManager.default.removeItem(at: certificateURL) + SecItemDelete([ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: certLabel, + ] as CFDictionary) + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits as String: 2048, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: keyTag, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ], + ] + var error: Unmanaged? + guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { + throw error!.takeRetainedValue() as Error + } + return key + } +} diff --git a/apps/mobile/modules/milink/ios/MiLinkModule.swift b/apps/mobile/modules/milink/ios/MiLinkModule.swift index 500097b..44ec4b3 100644 --- a/apps/mobile/modules/milink/ios/MiLinkModule.swift +++ b/apps/mobile/modules/milink/ios/MiLinkModule.swift @@ -1,11 +1,17 @@ import ExpoModulesCore import Network -/// Raw TCP socket and Mi TV discovery for the MiLink remote protocol. -/// Framing and key packets live in TypeScript (src/milink); this module only moves bytes. +/// Raw TCP/TLS socket and Mi TV discovery for the remote protocols. +/// Framing and packets live in TypeScript (src/milink, src/atv); this module only moves bytes, +/// plus the client key and TLS that the Android TV Remote protocol needs. public class MiLinkModule: Module { private let queue = DispatchQueue(label: "expo.modules.milink") private var connection: NWConnection? + /// Plain TCP connection made before a TLS one, to learn whether the port is open at all. + private var probe: NWConnection? + /// Whether the current connection got past TCP (so a failure means the service exists). + private var reachedPeer = false + private var peerCertificate: Data? private var browser: NWBrowser? private var resolvers: [String: NWConnection] = [:] private var reported: Set = [] @@ -15,8 +21,25 @@ public class MiLinkModule: Module { Events("onSocketState", "onSocketData", "onTvFound") - Function("connect") { (host: String, port: Int) in - self.queue.async { self.open(host: host, port: port) } + Function("connect") { (host: String, port: Int, tls: Bool) in + self.queue.async { self.open(host: host, port: port, tls: tls) } + } + + AsyncFunction("clientKey") { () throws -> [String: Any] in + let publicKey = try ClientIdentity.publicKey() + var result: [String: Any] = ["publicKey": publicKey.map { Int($0) }] + if let cert = ClientIdentity.certificate() { + result["certificate"] = cert.map { Int($0) } + } + return result + } + + AsyncFunction("signWithClientKey") { (data: [Int]) throws -> [Int] in + try ClientIdentity.sign(Data(data.map { UInt8(truncatingIfNeeded: $0) })).map { Int($0) } + } + + AsyncFunction("setClientCertificate") { (der: [Int]) throws in + try ClientIdentity.setCertificate(Data(der.map { UInt8(truncatingIfNeeded: $0) })) } Function("disconnect") { @@ -48,21 +71,72 @@ public class MiLinkModule: Module { // MARK: - Socket - private func open(host: String, port: Int) { + private func open(host: String, port: Int, tls: Bool) { close() guard let nwPort = NWEndpoint.Port(rawValue: UInt16(clamping: port)) else { return } + reachedPeer = false + peerCertificate = nil + emitState("connecting") + if tls { + // Without a client certificate the TV refuses the session; say so instead of trying. + do { + _ = try ClientIdentity.identity() + } catch { + emitState("failed", error: "Client certificate unavailable: \(error.localizedDescription)", reachable: true) + return + } + probePort(host: host, port: nwPort) + } else { + startConnection(host: host, port: nwPort, tls: false) + } + } + + /// Connects plain TCP first and only then TLS, as the Android module does. A TV that rejects + /// an unpaired certificate, or stalls the TLS handshake, can surface as a reset or a timeout, + /// which on its own reads like "nothing listens here". + private func probePort(host: String, port: NWEndpoint.Port) { let tcp = NWProtocolTCP.Options() - tcp.noDelay = true tcp.connectionTimeout = 4 + let conn = NWConnection(host: NWEndpoint.Host(host), port: port, using: NWParameters(tls: nil, tcp: tcp)) + probe = conn + conn.stateUpdateHandler = { [weak self, weak conn] state in + guard let self, let conn, conn === self.probe else { return } + switch state { + case .ready: + conn.stateUpdateHandler = nil + conn.cancel() + self.probe = nil + self.reachedPeer = true + self.startConnection(host: host, port: port, tls: true) + case .waiting(let error), .failed(let error): + conn.stateUpdateHandler = nil + conn.cancel() + self.probe = nil + self.emitState("failed", error: error.localizedDescription, reachable: false) + default: + break + } + } + conn.start(queue: queue) + } + + private func startConnection(host: String, port nwPort: NWEndpoint.Port, tls: Bool) { + let tcp = NWProtocolTCP.Options() + tcp.noDelay = true + tcp.connectionTimeout = tls ? 10 : 4 tcp.enableKeepalive = true - let conn = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: NWParameters(tls: nil, tcp: tcp)) + let conn = NWConnection( + host: NWEndpoint.Host(host), + port: nwPort, + using: NWParameters(tls: tls ? tlsOptions() : nil, tcp: tcp) + ) connection = conn - emitState("connecting") conn.stateUpdateHandler = { [weak self, weak conn] state in guard let self, let conn, conn === self.connection else { return } switch state { case .ready: - self.emitState("connected") + self.reachedPeer = true + self.emitState("connected", peerCertificate: self.peerCertificate) self.receive(on: conn) case .waiting(let error), .failed(let error): // .waiting covers "no route" and a denied Local Network permission; report it as a @@ -70,7 +144,8 @@ public class MiLinkModule: Module { self.connection = nil conn.stateUpdateHandler = nil conn.cancel() - self.emitState("failed", error: error.localizedDescription) + let reachable = self.reachedPeer || peerWasReachable(error) + self.emitState("failed", error: error.localizedDescription, reachable: reachable) default: break } @@ -78,6 +153,28 @@ public class MiLinkModule: Module { conn.start(queue: queue) } + /// Presents the client certificate and accepts the TV's self-signed one. The pairing secret + /// mixes in the TV's public key, which is what ties the session to the right TV. + private func tlsOptions() -> NWProtocolTLS.Options { + let options = NWProtocolTLS.Options() + let sec = options.securityProtocolOptions + // TLS 1.2 only. The TV's remote service (Netty) is a TLS 1.2 server, and Apple's TLS 1.3 + // handshake against it fails or stalls; native clients that work with Xiaomi TVs pin 1.2. + sec_protocol_options_set_min_tls_protocol_version(sec, .TLSv12) + sec_protocol_options_set_max_tls_protocol_version(sec, .TLSv12) + if let identity = try? ClientIdentity.identity(), let secIdentity = sec_identity_create(identity) { + sec_protocol_options_set_local_identity(sec, secIdentity) + } + sec_protocol_options_set_verify_block(sec, { [weak self] _, trust, complete in + let secTrust = sec_trust_copy_ref(trust).takeRetainedValue() + if let chain = SecTrustCopyCertificateChain(secTrust) as? [SecCertificate], let leaf = chain.first { + self?.peerCertificate = SecCertificateCopyData(leaf) as Data + } + complete(true) + }, queue) + return options + } + private func receive(on conn: NWConnection) { conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self, weak conn] data, _, isComplete, error in guard let self, let conn, conn === self.connection else { return } @@ -88,7 +185,7 @@ public class MiLinkModule: Module { self.connection = nil conn.stateUpdateHandler = nil conn.cancel() - self.emitState("failed", error: error?.localizedDescription ?? "TV closed the connection") + self.emitState("failed", error: error?.localizedDescription ?? "TV closed the connection", reachable: true) } else { self.receive(on: conn) } @@ -96,6 +193,9 @@ public class MiLinkModule: Module { } private func close() { + probe?.stateUpdateHandler = nil + probe?.cancel() + probe = nil guard let conn = connection else { return } connection = nil conn.stateUpdateHandler = nil @@ -103,9 +203,11 @@ public class MiLinkModule: Module { emitState("disconnected") } - private func emitState(_ state: String, error: String? = nil) { + private func emitState(_ state: String, error: String? = nil, reachable: Bool? = nil, peerCertificate: Data? = nil) { var body: [String: Any] = ["state": state] if let error { body["error"] = error } + if let reachable { body["reachable"] = reachable } + if let peerCertificate { body["peerCertificate"] = peerCertificate.map { Int($0) } } sendEvent("onSocketState", body) } @@ -178,3 +280,20 @@ public class MiLinkModule: Module { conn.start(queue: queue) } } + +/// Whether a connection error means something is listening on the port. Network.framework +/// reports a TV that aborts the TLS handshake (an unpaired client certificate) as a TLS error or, +/// often, as a plain reset; only refusals, timeouts and missing routes mean nobody's there. +func peerWasReachable(_ error: NWError) -> Bool { + switch error { + case .tls: + return true + case .posix(let code): + let unreachable: Set = [ + .ECONNREFUSED, .ETIMEDOUT, .EHOSTUNREACH, .ENETUNREACH, .EHOSTDOWN, .ENETDOWN, .EADDRNOTAVAIL, + ] + return !unreachable.contains(code) + default: + return false + } +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 598d986..c3bede4 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -45,6 +45,18 @@ function RootNavigator(): React.JSX.Element { : { presentation: 'modal', animation: 'slide_from_bottom' } } /> + ); diff --git a/apps/mobile/src/app/connect.tsx b/apps/mobile/src/app/connect.tsx index f9f2fa4..0bdc53c 100644 --- a/apps/mobile/src/app/connect.tsx +++ b/apps/mobile/src/app/connect.tsx @@ -16,7 +16,7 @@ import MiLink, { type FoundTV } from 'milink'; import { tapFeedback } from '@/components/remote/useRemoteKey'; import { useTranslate } from '@/localization/utils'; -import { milink, useConnectionStore } from '@/milink/client'; +import { remote, useConnectionStore } from '@/remote/client'; import { useSettingsStore } from '@/store'; import { TextStyles } from '@/theme'; import { ms } from '@/utils'; @@ -73,7 +73,7 @@ export default function ConnectScreen(): React.JSX.Element { (host: string) => { tapFeedback(); setTvHost(host); - milink.connect(host); + remote.connect(host); router.back(); }, [setTvHost], @@ -189,7 +189,7 @@ export default function ConnectScreen(): React.JSX.Element { accessibilityRole="button" style={styles.disconnect} onPress={() => { - milink.disconnect(); + remote.disconnect(); router.back(); }} > diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 69cf460..39c32a6 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -4,14 +4,14 @@ import { KeyboardStickyView } from 'react-native-keyboard-controller'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons } from '@react-native-vector-icons/ionicons'; -import { router } from 'expo-router'; +import { router, usePathname } from 'expo-router'; import { DPad } from '@/components/remote/DPad'; import { RemoteKey } from '@/components/remote/RemoteKey'; import { Rocker } from '@/components/remote/Rocker'; import { tapFeedback } from '@/components/remote/useRemoteKey'; import { useTranslate } from '@/localization/utils'; -import { milink, useConnectionStore } from '@/milink/client'; +import { remote, useConnectionStore } from '@/remote/client'; import { useSettingsStore } from '@/store'; import { TextStyles } from '@/theme'; import { ms } from '@/utils'; @@ -28,6 +28,7 @@ function StatusHeader() { connecting: theme.colors.statusConnecting, connected: theme.colors.statusConnected, failed: theme.colors.statusFailed, + pairing: theme.colors.statusConnecting, }[status]; const subtitle = { @@ -38,6 +39,7 @@ function StatusHeader() { ? t('remote.connected', { host }) : t('remote.connected_latency', { host, ms: latencyMs }), failed: t('remote.failed', { host }), + pairing: t('remote.pairing', { host }), }[status]; return ( @@ -68,7 +70,7 @@ function TypeBar() { const send = () => { if (!text) return; - milink.type(text); + remote.type(text); setText(''); }; @@ -93,7 +95,7 @@ function TypeBar() { hitSlop={8} onPress={() => { tapFeedback(); - milink.press('del'); + remote.press('del'); }} > @@ -106,12 +108,21 @@ export default function RemoteScreen(): React.JSX.Element { const t = useTranslate(); const { width } = useWindowDimensions(); const insets = useSafeAreaInsets(); + const status = useConnectionStore((s) => s.status); + // The Android TV Remote service has no recent-apps key (see atvKey in the client). + const atv = useConnectionStore((s) => s.protocol === 'atv'); + const pathname = usePathname(); + + // Android TVs without MiLink need a one-time pairing code; ask for it when the client does. + useEffect(() => { + if (status === 'pairing' && pathname !== '/pair') router.push('/pair'); + }, [status, pathname]); // Connect to the saved TV, or ask the user to pick one on first launch. useEffect(() => { const { tvHost } = useSettingsStore.getState(); if (tvHost) { - milink.connect(tvHost); + remote.connect(tvHost); } else { router.push('/connect'); } @@ -120,8 +131,8 @@ export default function RemoteScreen(): React.JSX.Element { // Phones kill background sockets anyway; drop it cleanly and reconnect on return. useEffect(() => { const subscription = AppState.addEventListener('change', (state) => { - if (state === 'active') milink.resume(); - else if (state === 'background') milink.suspend(); + if (state === 'active') remote.resume(); + else if (state === 'background') remote.suspend(); }); return () => subscription.remove(); }, []); @@ -143,6 +154,7 @@ export default function RemoteScreen(): React.JSX.Element { diff --git a/apps/mobile/src/app/pair.tsx b/apps/mobile/src/app/pair.tsx new file mode 100644 index 0000000..757bfec --- /dev/null +++ b/apps/mobile/src/app/pair.tsx @@ -0,0 +1,241 @@ +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Platform, + Pressable, + ScrollView, + Text, + TextInput, + View, +} from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Ionicons } from '@react-native-vector-icons/ionicons'; +import { router } from 'expo-router'; + +import { normalizeCode } from '@/atv/messages'; +import { tapFeedback } from '@/components/remote/useRemoteKey'; +import { useTranslate } from '@/localization/utils'; +import { remote, useConnectionStore } from '@/remote/client'; +import { TextStyles } from '@/theme'; +import { ms } from '@/utils'; + +/** Code field and Pair button. Remounts whenever the TV shows a new code, which clears it. */ +function CodeEntry({ verifying }: { verifying: boolean }): React.JSX.Element { + const { theme } = useUnistyles(); + const t = useTranslate(); + const [code, setCode] = useState(''); + const valid = normalizeCode(code) !== null; + + const submit = () => { + if (!valid || verifying) return; + tapFeedback(); + remote.submitPairingCode(code); + }; + + return ( + <> + {t('pair.enter_code')} + + setCode(text.toUpperCase())} + onSubmitEditing={submit} + /> + styles.pairButton(valid && !verifying, pressed)} + onPress={submit} + > + {verifying ? ( + + ) : ( + {t('pair.pair')} + )} + + + + ); +} + +/** + * One-time pairing for TVs that only have Google's Android TV Remote service: the TV shows a + * 6-character code and the user types it here. Opened by the remote screen when the client + * reports `pairing`, and closes itself once the TV is connected. + */ +export default function PairScreen(): React.JSX.Element { + const { theme } = useUnistyles(); + const t = useTranslate(); + const status = useConnectionStore((s) => s.status); + const host = useConnectionStore((s) => s.host); + const step = useConnectionStore((s) => s.pairingStep); + const error = useConnectionStore((s) => s.pairingError); + const detail = useConnectionStore((s) => s.error); + + useEffect(() => { + if (status === 'connected') router.back(); + }, [status]); + + // Leaving mid-pairing (swipe down, back button) cancels it, so it doesn't reopen. + useEffect( + () => () => { + if (useConnectionStore.getState().status === 'pairing') remote.disconnect(); + }, + [], + ); + + const verifying = step === 'verifying'; + const canRetry = error === 'rejected' || error === 'connection' || status === 'failed'; + const waiting = !canRetry && (step === 'starting' || status === 'connecting' || !step); + + const errorText = error + ? { + wrongCode: t('pair.error_wrong_code'), + rejected: t('pair.error_rejected'), + connection: t('pair.error_connection'), + certificate: t('pair.error_certificate'), + }[error] + : status === 'failed' + ? t('pair.error_rejected') + : undefined; + + return ( + + + {t('pair.title')} + { + remote.disconnect(); + router.back(); + }} + > + + + + + {t('pair.intro', { host })} + + {waiting ? ( + + + {t('pair.requesting')} + + ) : null} + + {!waiting && !canRetry ? : null} + + {errorText ? {errorText} : null} + {errorText && detail ? ( + + {detail} + + ) : null} + + {canRetry ? ( + styles.pairButton(true, pressed)} + onPress={() => { + tapFeedback(); + remote.retryPairing(); + }} + > + {t('pair.retry')} + + ) : null} + + {t('pair.hint')} + + ); +} + +const styles = StyleSheet.create((theme, rt) => ({ + sheet: { + flex: 1, + backgroundColor: theme.colors.remoteBody, + }, + content: { + padding: ms(20), + // The Android modal is full screen and needs the status bar inset; the iOS sheet doesn't. + paddingTop: (Platform.OS === 'android' ? rt.insets.top : 0) + ms(20), + paddingBottom: rt.insets.bottom + ms(24), + gap: ms(14), + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: ms(4), + }, + close: { + width: ms(32), + height: ms(32), + borderRadius: ms(16), + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.remoteKey, + }, + label: { + color: theme.colors.remoteLabel, + }, + dim: { + color: theme.colors.remoteDim, + }, + white: { + color: theme.colors.white, + }, + error: { + color: theme.colors.statusFailed, + }, + hint: { + marginTop: ms(8), + }, + waitingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: ms(10), + paddingVertical: ms(8), + }, + codeRow: { + flexDirection: 'row', + gap: ms(10), + }, + codeInput: { + flex: 1, + height: ms(52), + paddingHorizontal: ms(14), + borderRadius: ms(12), + backgroundColor: theme.colors.remoteKey, + color: theme.colors.remoteLabel, + letterSpacing: 6, + textAlign: 'center', + }, + pairButton: (enabled: boolean, pressed: boolean) => ({ + height: ms(52), + minWidth: ms(88), + paddingHorizontal: ms(18), + borderRadius: ms(12), + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.accent, + opacity: !enabled ? 0.4 : pressed ? 0.8 : 1, + }), +})); diff --git a/apps/mobile/src/atv/__fixtures__/vectors.ts b/apps/mobile/src/atv/__fixtures__/vectors.ts new file mode 100644 index 0000000..216bf43 --- /dev/null +++ b/apps/mobile/src/atv/__fixtures__/vectors.ts @@ -0,0 +1,51 @@ +/** + * Fixtures generated with androidtvremote2 (the Python library Home Assistant uses) and its + * protobuf definitions, so these tests check against a known-working implementation. + */ + +// Two self-signed certificates from androidtvremote2's certificate_generator, and the pairing +// code / secret its pairing.py computes for them. +export const CLIENT_CERT = + 'MIICxDCCAaygAwIBAgICA+gwDQYJKoZIhvcNAQELBQAwETEPMA0GA1UEAwwGY2xpZW50MB4XDTI2MTAyMDAxNTIyNloXDTM2MTAxNzAxNTIyNlowETEPMA0GA1UEAwwGY2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwI5zpLK19vU172TlpQeRzqPtI6vS+aAbmrvjyKzHwN6iJYSGd3YP5mKnIvRWz4MO4YVgFQUQTG+gxB7OxTIeKjjyZBwTFMgD0n8pc7QCokAU9YxeQOqfJtiaVVO8G0D0a9p7hyd51iJ3J8Cn01QiY/R9Vu5EtfuzAw8BSr2buGI0cJpLGgskg+2yxysuo/GUCPWhoBjdj/Fi6+HJ0GsIrx40bAbfxfpmKtswO7ePPz93rYA99VamcUc771RgrYEuF2yenpdHKICxHOtvVzoOjNGC6E5vWohG1Fj3csvk7ON9m8OVdfoqyJPU9K7TUNiFGnlfo1bc9z5PfubYoZr1EwIDAQABoyYwJDAPBgNVHRMECDAGAQH/AgEAMBEGA1UdEQQKMAiCBmNsaWVudDANBgkqhkiG9w0BAQsFAAOCAQEAajHqzHW5Ujyw4CyXVhyK/G4k4w0o8QhIox/w5B1wnw1rcAt+/F4GHS2aQ11h1/gJtsC+YaqcbelxVvSSCqhvLDX7CWe8xoPCDX65HufvFNLQQEEUlJxkyegBe6n4tT22xJdTn6zTD65vYRZfSjKuvGXzpjRKZZFHSKd6aSGLtpqhVPqcR2Z/3YB9Su1VqEI/F2SWBCSejZUUD9f8X1f+aLPM6A77tOm5kz5M0CMFsIA+MqqTJn9cz+jbdkFKYkiWtBMAiGi+v9uVPdypQyHq88f7gfzBEVMko3I9/XPgUd353cKXUTei986incGaWHp7NAotNKE9yMEDIxxv5b3CwA=='; +export const SERVER_CERT = + 'MIICuDCCAaCgAwIBAgICA+gwDQYJKoZIhvcNAQELBQAwDTELMAkGA1UEAwwCdHYwHhcNMjYxMDIwMDE1MjI2WhcNMzYxMDE3MDE1MjI2WjANMQswCQYDVQQDDAJ0djCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKOqS1k8/QaU27Ah/BkgYO6xR0qM2tI1HbzsrPe/YEQHjT95yqYUcgVgQ74wPqUshrj4GoSfF4u9IF7iqMaYKv5sc/fVQhmR0ej8AJQa7OYWchzgP/fFl2I/A+/6epG7A+IwgMPwb+PEMdvZNsY6XuyqUEKg/AL8b/2/xXjCGCSG2vC1cuOTyBFwGmrSM9girl7ISea9kRx3KlF9oXG0ekUI7OZRPwkTRJTfkyk4oygYhR4KVW+eAc23Q9TIw1TyxHU8sz3v6wWlAC6WHXBESvOb+/bCdxXjZZiEUL0bGM9YfZFpLn/rKGDR/sX2B8EO5qd9XSn9FyJ/DCEyaZoYdDcCAwEAAaMiMCAwDwYDVR0TBAgwBgEB/wIBADANBgNVHREEBjAEggJ0djANBgkqhkiG9w0BAQsFAAOCAQEAX4P/t/4WtUhbiUXfnMrnTilNrvensSm5jxV95gMAbsF+8y/ikhkhdjb11gfs++68eBvpkOBnk7yeizK9W84QpKEUbMpcBTTbeBlf5RaPpsMGtYpaabAVKMe3VVFld+06oZj8QTEWYRC5FNEndvAYc9epHnRYQiT/lZBKbLFGRYaWzRefvVgr4lEPpzq44Q8vr1T8bakJXZM4FoGpjg+f+7X1uOIAIFVXpKbhOg9k1ZuKppSI8NZiaRM+3BXfJ1ek0ANG5V3q3VpmczXH7KrVgrbZJrrlUfgJf2V2eFee3aieiqeayWkn/dbslsZpLOZvEwuO531yglPfcziup268Dg=='; +export const PAIRING_CODE = 'AA3F9A'; +export const PAIRING_SECRET_HEX = + 'aa7fdc8dfcff4b9ddd943c85edcd6ba1469fe9795d6029fe03f55ede331c8ecc'; + +// Serialized with polo_pb2 / remotemessage_pb2. +export const PB = { + pairingRequest: [ + 8, 2, 16, 200, 1, 82, 22, 10, 9, 97, 116, 118, 114, 101, 109, 111, 116, 101, 18, 9, 77, 105, 32, + 82, 101, 109, 111, 116, 101, + ], + pairingOptions: [8, 2, 16, 200, 1, 162, 1, 8, 10, 4, 8, 3, 16, 6, 24, 1], + pairingConfiguration: [8, 2, 16, 200, 1, 242, 1, 8, 10, 4, 8, 3, 16, 6, 16, 1], + pairingSecret0to31: [ + 8, 2, 16, 200, 1, 194, 2, 34, 10, 32, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + ], + keyDpadUpShort: [82, 4, 8, 19, 16, 3], + pingResponseMinus5: [74, 11, 8, 251, 255, 255, 255, 255, 255, 255, 255, 255, 1], + setActive615: [18, 3, 8, 231, 4], + imeHello: [ + 170, 1, 22, 8, 3, 16, 7, 26, 16, 8, 1, 18, 12, 8, 5, 16, 5, 26, 6, 104, 195, 169, 108, 108, 111, + ], + configure615: [ + 10, 50, 8, 231, 4, 18, 45, 10, 9, 77, 105, 32, 82, 101, 109, 111, 116, 101, 18, 9, 77, 105, 32, + 82, 101, 109, 111, 116, 101, 24, 1, 34, 1, 49, 42, 9, 97, 116, 118, 114, 101, 109, 111, 116, + 101, 50, 5, 49, 46, 48, 46, 48, + ], + inPing12345: [66, 5, 8, 185, 96, 16, 1], + inConfigure: [ + 10, 25, 8, 255, 4, 18, 20, 10, 10, 77, 105, 84, 86, 45, 77, 79, 79, 82, 50, 18, 6, 88, 105, 97, + 111, 109, 105, + ], + inStart: [194, 2, 2, 8, 1], + inVolume: [146, 3, 4, 48, 100, 56, 12], + inImeBatch: [170, 1, 4, 8, 9, 16, 2], + inImeKey: [162, 1, 15, 10, 13, 98, 11, 99, 111, 109, 46, 110, 101, 116, 102, 108, 105, 120], + inRequestAck: [8, 2, 16, 200, 1, 90, 4, 10, 2, 116, 118], + inConfigurationAck: [8, 2, 16, 200, 1, 250, 1, 0], + inBadSecret: [8, 2, 16, 146, 3], +}; diff --git a/apps/mobile/src/atv/__tests__/atv.test.ts b/apps/mobile/src/atv/__tests__/atv.test.ts new file mode 100644 index 0000000..a05407d --- /dev/null +++ b/apps/mobile/src/atv/__tests__/atv.test.ts @@ -0,0 +1,183 @@ +import { createHash, generateKeyPairSync, sign, X509Certificate } from 'crypto'; + +import { + CLIENT_CERT, + PAIRING_CODE, + PAIRING_SECRET_HEX, + PB, + SERVER_CERT, +} from '../__fixtures__/vectors'; +import { certificate, rsaPublicKey, subjectPublicKeyInfo, tbsCertificate } from '../der'; +import { + Direction, + imeBatchEdit, + keyInject, + normalizeCode, + pairingConfiguration, + pairingOptions, + pairingRequest, + pairingSecret, + pairingSecretFor, + parsePairingMessage, + parseRemoteMessage, + pingResponse, + remoteConfigure, + remoteSetActive, + STATUS_BAD_SECRET, +} from '../messages'; +import { delimited, splitDelimited, varint } from '../protobuf'; +import { sha256 } from '../sha256'; + +const bytesOf = (b64: string) => Array.from(Buffer.from(b64, 'base64')); +const hex = (bytes: number[]) => Buffer.from(bytes).toString('hex'); + +describe('protobuf', () => { + it('encodes varints, including negative int32 as 10 bytes', () => { + expect(varint(0)).toEqual([0]); + expect(varint(300)).toEqual([0xac, 0x02]); + expect(varint(-1)).toEqual([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]); + }); + + it('splits length-delimited messages across chunk boundaries', () => { + const big = Array.from({ length: 200 }, (_, i) => i & 0xff); + const stream = [...delimited([1, 2, 3]), ...delimited(big)]; + const first = splitDelimited(stream.slice(0, 5)); + expect(first.messages).toEqual([[1, 2, 3]]); + const second = splitDelimited([...first.rest, ...stream.slice(5)]); + expect(second.messages).toEqual([big]); + expect(second.rest).toEqual([]); + }); + + it('keeps a partial varint length for the next chunk', () => { + const stream = delimited(Array.from({ length: 200 }, () => 7)); + const { messages, rest } = splitDelimited(stream.slice(0, 1)); + expect(messages).toEqual([]); + expect(rest).toEqual(stream.slice(0, 1)); + }); +}); + +describe('sha256', () => { + it('matches node crypto for several lengths', () => { + for (const len of [0, 3, 55, 56, 64, 300]) { + const data = Array.from({ length: len }, (_, i) => (i * 31) & 0xff); + expect(hex(sha256(data))).toBe(createHash('sha256').update(Buffer.from(data)).digest('hex')); + } + }); +}); + +describe('DER', () => { + it('reads the RSA key from a certificate, an SPKI and PKCS#1', () => { + const cert = new X509Certificate(Buffer.from(CLIENT_CERT, 'base64')); + const jwk = cert.publicKey.export({ format: 'jwk' }); + const modulus = Array.from(Buffer.from(jwk.n as string, 'base64url')); + const exponent = Array.from(Buffer.from(jwk.e as string, 'base64url')); + + expect(rsaPublicKey(bytesOf(CLIENT_CERT))).toEqual({ modulus, exponent }); + const spki = Array.from(cert.publicKey.export({ format: 'der', type: 'spki' })); + expect(rsaPublicKey(spki)).toEqual({ modulus, exponent }); + const pkcs1 = Array.from(cert.publicKey.export({ format: 'der', type: 'pkcs1' })); + expect(rsaPublicKey(pkcs1)).toEqual({ modulus, exponent }); + }); + + it('builds a self-signed certificate that OpenSSL accepts and verifies', () => { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = rsaPublicKey(Array.from(publicKey.export({ format: 'der', type: 'spki' }))); + expect(subjectPublicKeyInfo(key)).toEqual( + Array.from(publicKey.export({ format: 'der', type: 'spki' })), + ); + + const tbs = tbsCertificate(key, 'Mi Remote', [0x81, 2, 3, 4], new Date('2026-10-20T00:00:00Z')); + const signature = Array.from(sign('sha256', Buffer.from(tbs), privateKey)); + const der = certificate(tbs, signature); + + const cert = new X509Certificate(Buffer.from(der)); + expect(cert.subject).toBe('CN=Mi Remote'); + expect(cert.issuer).toBe('CN=Mi Remote'); + expect(cert.verify(publicKey)).toBe(true); + expect(new Date(cert.validTo).getUTCFullYear()).toBe(2049); + expect(rsaPublicKey(der)).toEqual(key); + }); +}); + +describe('pairing', () => { + const client = rsaPublicKey(bytesOf(CLIENT_CERT)); + const server = rsaPublicKey(bytesOf(SERVER_CERT)); + + it('computes the same secret as androidtvremote2', () => { + expect(hex(pairingSecretFor(client, server, PAIRING_CODE)!)).toBe(PAIRING_SECRET_HEX); + expect(hex(pairingSecretFor(client, server, ' aa3f9a ')!)).toBe(PAIRING_SECRET_HEX); + }); + + it('rejects a mistyped code using its checksum byte', () => { + expect(pairingSecretFor(client, server, 'AB3F9A')).toBeNull(); + expect(pairingSecretFor(client, server, 'AA3F9')).toBeNull(); + }); + + it('normalizes codes', () => { + expect(normalizeCode('ab 12 cd')).toBe('AB12CD'); + expect(normalizeCode('xyz123')).toBeNull(); + }); + + it('serializes pairing messages like the reference', () => { + expect(pairingRequest('Mi Remote')).toEqual(PB.pairingRequest); + expect(pairingOptions()).toEqual(PB.pairingOptions); + expect(pairingConfiguration()).toEqual(PB.pairingConfiguration); + expect(pairingSecret(Array.from({ length: 32 }, (_, i) => i))).toEqual(PB.pairingSecret0to31); + }); + + it('parses pairing replies', () => { + expect(parsePairingMessage(PB.inRequestAck)).toEqual({ status: 200, reply: 'requestAck' }); + expect(parsePairingMessage(PB.pairingOptions)).toEqual({ status: 200, reply: 'options' }); + expect(parsePairingMessage(PB.inConfigurationAck)).toEqual({ + status: 200, + reply: 'configurationAck', + }); + expect(parsePairingMessage(PB.inBadSecret).status).toBe(STATUS_BAD_SECRET); + }); +}); + +describe('remote messages', () => { + it('serializes outgoing messages like the reference', () => { + expect(keyInject(19, Direction.short)).toEqual(PB.keyDpadUpShort); + expect(pingResponse(-5)).toEqual(PB.pingResponseMinus5); + expect(remoteSetActive(615)).toEqual(PB.setActive615); + expect(imeBatchEdit(3, 7, 'héllo')).toEqual(PB.imeHello); + expect(remoteConfigure(615)).toEqual(PB.configure615); + }); + + it('parses incoming messages', () => { + expect(parseRemoteMessage(PB.inPing12345)).toEqual({ type: 'ping', value: 12345 }); + expect(parseRemoteMessage(PB.inConfigure)).toEqual({ + type: 'configure', + features: 639, + model: 'MiTV-MOOR2', + vendor: 'Xiaomi', + }); + expect(parseRemoteMessage(PB.inStart)).toEqual({ type: 'start', started: true }); + expect(parseRemoteMessage(PB.inVolume)).toEqual({ + type: 'volume', + level: 12, + max: 100, + muted: false, + }); + expect(parseRemoteMessage(PB.inImeBatch)).toEqual({ + type: 'imeBatchEdit', + imeCounter: 9, + fieldCounter: 2, + }); + expect(parseRemoteMessage(PB.inImeKey)).toEqual({ + type: 'imeKeyInject', + appPackage: 'com.netflix', + fieldCounter: undefined, + }); + expect(parseRemoteMessage(PB.pingResponseMinus5)).toEqual({ type: 'other' }); + expect( + parseRemoteMessage([178, 1, 12, 18, 10, 8, 4, 50, 6, 83, 101, 97, 114, 99, 104]), + ).toEqual({ type: 'imeShow', fieldCounter: 4 }); + expect( + parseRemoteMessage([ + 162, 1, 19, 10, 13, 98, 11, 99, 111, 109, 46, 110, 101, 116, 102, 108, 105, 120, 18, 2, 8, 6, + ]), + ).toEqual({ type: 'imeKeyInject', appPackage: 'com.netflix', fieldCounter: 6 }); + }); +}); diff --git a/apps/mobile/src/atv/der.ts b/apps/mobile/src/atv/der.ts new file mode 100644 index 0000000..72db70d --- /dev/null +++ b/apps/mobile/src/atv/der.ts @@ -0,0 +1,184 @@ +/** + * Minimal DER for the remote's client certificate: build a self-signed X.509 certificate around + * an RSA key the native side generated, and read RSA public keys out of certificates. + * iOS has no API to create certificates, so this lives here, shared by both platforms. + */ +import type { Bytes } from './protobuf'; + +const TAG_INTEGER = 0x02; +const TAG_BIT_STRING = 0x03; +const TAG_NULL = 0x05; +const TAG_OID = 0x06; +const TAG_UTF8 = 0x0c; +const TAG_UTC_TIME = 0x17; +const TAG_SEQUENCE = 0x30; +const TAG_SET = 0x31; +const TAG_VERSION = 0xa0; // [0] EXPLICIT + +function length(n: number): Bytes { + if (n < 0x80) return [n]; + const out: Bytes = []; + while (n > 0) { + out.unshift(n & 0xff); + n = Math.floor(n / 256); + } + return [0x80 | out.length, ...out]; +} + +export function tlv(tag: number, content: Bytes): Bytes { + return [tag, ...length(content.length), ...content]; +} + +export const sequence = (...items: Bytes[]): Bytes => tlv(TAG_SEQUENCE, items.flat()); + +/** INTEGER from unsigned big-endian bytes. */ +export function integer(magnitude: Bytes): Bytes { + const bytes = stripZeros(magnitude); + return tlv(TAG_INTEGER, bytes[0] & 0x80 ? [0, ...bytes] : bytes); +} + +export function oid(dotted: string): Bytes { + const parts = dotted.split('.').map(Number); + const out: Bytes = [parts[0] * 40 + parts[1]]; + for (const part of parts.slice(2)) { + const chunk: Bytes = []; + let v = part; + do { + chunk.unshift((v & 0x7f) | (chunk.length ? 0x80 : 0)); + v = Math.floor(v / 128); + } while (v > 0); + out.push(...chunk); + } + return tlv(TAG_OID, out); +} + +const OID_RSA = '1.2.840.113549.1.1.1'; +const OID_SHA256_RSA = '1.2.840.113549.1.1.11'; +const OID_COMMON_NAME = '2.5.4.3'; + +function algorithm(dotted: string): Bytes { + return sequence(oid(dotted), tlv(TAG_NULL, [])); +} + +function name(commonName: string): Bytes { + const cn = Array.from(commonName, (c) => c.charCodeAt(0) & 0x7f); + return sequence(tlv(TAG_SET, sequence(oid(OID_COMMON_NAME), tlv(TAG_UTF8, cn)))); +} + +/** UTCTime, YYMMDDHHMMSSZ. Only valid until 2049, which is all the certificate needs. */ +function utcTime(date: Date): Bytes { + const two = (n: number) => String(n).padStart(2, '0'); + const text = + two(date.getUTCFullYear() % 100) + + two(date.getUTCMonth() + 1) + + two(date.getUTCDate()) + + two(date.getUTCHours()) + + two(date.getUTCMinutes()) + + two(date.getUTCSeconds()) + + 'Z'; + return tlv( + TAG_UTC_TIME, + Array.from(text, (c) => c.charCodeAt(0)), + ); +} + +export interface RsaPublicKey { + /** Unsigned big-endian, no leading zeros. */ + modulus: Bytes; + exponent: Bytes; +} + +export function subjectPublicKeyInfo(key: RsaPublicKey): Bytes { + const pkcs1 = sequence(integer(key.modulus), integer(key.exponent)); + return sequence(algorithm(OID_RSA), tlv(TAG_BIT_STRING, [0, ...pkcs1])); +} + +/** The to-be-signed part of a self-signed v3 certificate for `key`. */ +export function tbsCertificate( + key: RsaPublicKey, + commonName: string, + serial: Bytes, + notBefore: Date, +): Bytes { + const subject = name(commonName); + return sequence( + tlv(TAG_VERSION, integer([2])), + integer(serial), + algorithm(OID_SHA256_RSA), + subject, + sequence(utcTime(notBefore), utcTime(new Date(Date.UTC(2049, 11, 31, 23, 59, 59)))), + subject, + subjectPublicKeyInfo(key), + ); +} + +/** Wraps a TBS certificate and its SHA256withRSA signature into the final certificate. */ +export function certificate(tbs: Bytes, signature: Bytes): Bytes { + return sequence(tbs, algorithm(OID_SHA256_RSA), tlv(TAG_BIT_STRING, [0, ...signature])); +} + +// ── Parsing ────────────────────────────────────────────────── + +interface Node { + tag: number; + content: Bytes; +} + +function readNode(bytes: Bytes, offset: number): { node: Node; next: number } { + const tag = bytes[offset]; + let len = bytes[offset + 1]; + let start = offset + 2; + if (len === undefined) throw new Error('Truncated DER'); + if (len & 0x80) { + const count = len & 0x7f; + len = 0; + for (let i = 0; i < count; i++) len = len * 256 + bytes[start + i]; + start += count; + } + if (start + len > bytes.length) throw new Error('Truncated DER'); + return { node: { tag, content: bytes.slice(start, start + len) }, next: start + len }; +} + +function children(content: Bytes): Node[] { + const out: Node[] = []; + let offset = 0; + while (offset < content.length) { + const { node, next } = readNode(content, offset); + out.push(node); + offset = next; + } + return out; +} + +function stripZeros(bytes: Bytes): Bytes { + let i = 0; + while (i < bytes.length - 1 && bytes[i] === 0) i++; + return bytes.slice(i); +} + +/** + * The RSA public key in an X.509 certificate, a SubjectPublicKeyInfo, or a PKCS#1 RSAPublicKey + * (what iOS exports). Throws for anything else, such as an EC key. + */ +export function rsaPublicKey(der: Bytes): RsaPublicKey { + const top = readNode(der, 0).node; + if (top.tag !== TAG_SEQUENCE) throw new Error('Not a DER sequence'); + const parts = children(top.content); + + // PKCS#1: SEQUENCE { INTEGER modulus, INTEGER exponent } + if (parts.length === 2 && parts[0].tag === TAG_INTEGER && parts[1].tag === TAG_INTEGER) { + return { modulus: stripZeros(parts[0].content), exponent: stripZeros(parts[1].content) }; + } + // SubjectPublicKeyInfo: SEQUENCE { AlgorithmIdentifier, BIT STRING { PKCS#1 } } + if (parts.length === 2 && parts[0].tag === TAG_SEQUENCE && parts[1].tag === TAG_BIT_STRING) { + return rsaPublicKey(parts[1].content.slice(1)); + } + // Certificate: SEQUENCE { tbsCertificate, signatureAlgorithm, signature } + if (parts.length === 3 && parts[0].tag === TAG_SEQUENCE) { + const tbs = children(parts[0].content); + const spki = tbs[tbs[0].tag === TAG_VERSION ? 6 : 5]; + if (!spki) throw new Error('Certificate has no public key'); + return rsaPublicKey(tlv(spki.tag, spki.content)); + } + throw new Error('Unrecognized public key format'); +} diff --git a/apps/mobile/src/atv/messages.ts b/apps/mobile/src/atv/messages.ts new file mode 100644 index 0000000..d0d2fee --- /dev/null +++ b/apps/mobile/src/atv/messages.ts @@ -0,0 +1,228 @@ +/** + * Android TV Remote protocol v2, the one Google's TV remote apps use. Two TLS ports, both + * carrying varint-length-delimited protobuf messages, and both requiring a client certificate: + * + * 6467 pairing ("polo"): the TV shows a 6-digit hex code; proving we saw it makes the TV + * trust our certificate from then on. + * 6466 remote: key presses, text, pings. The TV drops the TLS session if the certificate + * isn't paired. + * + * Field numbers follow polo.proto and remotemessage.proto as used by androidtvremote2. + */ +import type { RsaPublicKey } from './der'; +import { + type Bytes, + decode, + fieldBytes, + fieldMessage, + fieldString, + fieldVarint, + getInt, + getMessage, + getString, + has, +} from './protobuf'; +import { sha256 } from './sha256'; + +export const ATV_REMOTE_PORT = 6466; +export const ATV_PAIRING_PORT = 6467; + +// ── Pairing (polo) ─────────────────────────────────────────── + +const PROTOCOL_VERSION = 2; +export const STATUS_OK = 200; +export const STATUS_BAD_SECRET = 402; +const ENCODING_HEXADECIMAL = 3; +const ROLE_INPUT = 1; +const CODE_LENGTH = 6; + +function outer(body: Bytes): Bytes { + return [...fieldVarint(1, PROTOCOL_VERSION), ...fieldVarint(2, STATUS_OK), ...body]; +} + +const hexEncoding = (): Bytes => [ + ...fieldVarint(1, ENCODING_HEXADECIMAL), + ...fieldVarint(2, CODE_LENGTH), +]; + +export function pairingRequest(clientName: string): Bytes { + return outer(fieldMessage(10, [...fieldString(1, 'atvremote'), ...fieldString(2, clientName)])); +} + +export function pairingOptions(): Bytes { + return outer( + fieldMessage(20, [...fieldMessage(1, hexEncoding()), ...fieldVarint(3, ROLE_INPUT)]), + ); +} + +export function pairingConfiguration(): Bytes { + return outer( + fieldMessage(30, [...fieldMessage(1, hexEncoding()), ...fieldVarint(2, ROLE_INPUT)]), + ); +} + +export function pairingSecret(secret: Bytes): Bytes { + return outer(fieldMessage(40, fieldBytes(1, secret))); +} + +export type PairingReply = 'requestAck' | 'options' | 'configurationAck' | 'secretAck' | 'unknown'; + +export function parsePairingMessage(bytes: Bytes): { status: number; reply: PairingReply } { + const msg = decode(bytes); + const status = getInt(msg, 2, STATUS_OK); + let reply: PairingReply = 'unknown'; + if (has(msg, 11)) reply = 'requestAck'; + else if (has(msg, 20)) reply = 'options'; + else if (has(msg, 31)) reply = 'configurationAck'; + else if (has(msg, 41)) reply = 'secretAck'; + return { status, reply }; +} + +/** Normalizes what the user typed: uppercase hex, 6 characters, or null if it can't be a code. */ +export function normalizeCode(input: string): string | null { + const code = input.replace(/\s/g, '').toUpperCase(); + return /^[0-9A-F]{6}$/.test(code) ? code : null; +} + +/** + * The pairing secret: SHA-256 over both public keys and the last 4 hex digits of the code. + * The first 2 hex digits are a checksum: they must equal the hash's first byte, which catches + * a mistyped code before the TV sees it. Returns null when the checksum doesn't match. + */ +export function pairingSecretFor( + client: RsaPublicKey, + server: RsaPublicKey, + code: string, +): Bytes | null { + const normalized = normalizeCode(code); + if (!normalized) return null; + const codeBytes = [0, 2, 4].map((i) => parseInt(normalized.slice(i, i + 2), 16)); + const hash = sha256([ + ...client.modulus, + ...client.exponent, + ...server.modulus, + ...server.exponent, + ...codeBytes.slice(1), + ]); + return hash[0] === codeBytes[0] ? hash : null; +} + +// ── Remote ─────────────────────────────────────────────────── + +/** Feature bits exchanged in RemoteConfigure / RemoteSetActive. */ +export const Feature = { + ping: 1 << 0, + key: 1 << 1, + ime: 1 << 2, + power: 1 << 5, + volume: 1 << 6, + appLink: 1 << 9, +} as const; + +export const WANTED_FEATURES = + Feature.ping | Feature.key | Feature.ime | Feature.power | Feature.volume | Feature.appLink; + +export const Direction = { + startLong: 1, + endLong: 2, + short: 3, +} as const; + +export function remoteConfigure(features: number): Bytes { + const deviceInfo = [ + ...fieldString(1, 'Mi Remote'), + ...fieldString(2, 'Mi Remote'), + ...fieldVarint(3, 1), + ...fieldString(4, '1'), + ...fieldString(5, 'atvremote'), + ...fieldString(6, '1.0.0'), + ]; + return fieldMessage(1, [...fieldVarint(1, features), ...fieldMessage(2, deviceInfo)]); +} + +export function remoteSetActive(features: number): Bytes { + return fieldMessage(2, fieldVarint(1, features)); +} + +export function pingResponse(value: number): Bytes { + return fieldMessage(9, fieldVarint(1, value)); +} + +/** `code` is an Android KeyEvent keycode; the protocol's key enum uses the same numbers. */ +export function keyInject(code: number, direction: number): Bytes { + return fieldMessage(10, [...fieldVarint(1, code), ...fieldVarint(2, direction)]); +} + +/** + * Puts `text` into the focused text field on the TV, cursor after it. The counters echo the + * TV's latest (0 before it has sent any), as the Google TV app and other clients do. + */ +export function imeBatchEdit(imeCounter: number, fieldCounter: number, text: string): Bytes { + const cursor = Array.from(text).length; + const imeObject = [...fieldVarint(1, cursor), ...fieldVarint(2, cursor), ...fieldString(3, text)]; + const editInfo = [...fieldVarint(1, 1), ...fieldMessage(2, imeObject)]; + return fieldMessage(21, [ + ...fieldVarint(1, imeCounter), + ...fieldVarint(2, fieldCounter), + ...fieldMessage(3, editInfo), + ]); +} + +export type RemoteEvent = + | { type: 'configure'; features: number; model: string; vendor: string } + | { type: 'setActive' } + | { type: 'ping'; value: number } + | { type: 'start'; started: boolean } + | { type: 'imeBatchEdit'; imeCounter: number; fieldCounter: number } + | { type: 'imeKeyInject'; appPackage: string; fieldCounter?: number } + | { type: 'imeShow'; fieldCounter: number } + | { type: 'volume'; level: number; max: number; muted: boolean } + | { type: 'error' } + | { type: 'other' }; + +export function parseRemoteMessage(bytes: Bytes): RemoteEvent { + const msg = decode(bytes); + const configure = getMessage(msg, 1); + if (configure) { + const info = getMessage(configure, 2); + return { + type: 'configure', + features: getInt(configure, 1), + model: info ? getString(info, 1) : '', + vendor: info ? getString(info, 2) : '', + }; + } + if (has(msg, 2)) return { type: 'setActive' }; + if (has(msg, 3)) return { type: 'error' }; + const ping = getMessage(msg, 8); + if (ping) return { type: 'ping', value: getInt(ping, 1) }; + const imeKey = getMessage(msg, 20); + if (imeKey) { + const app = getMessage(imeKey, 1); + const field = getMessage(imeKey, 2); + return { + type: 'imeKeyInject', + appPackage: app ? getString(app, 12) : '', + fieldCounter: field ? getInt(field, 1) : undefined, + }; + } + const show = getMessage(msg, 22); + const showField = show ? getMessage(show, 2) : undefined; + if (showField) return { type: 'imeShow', fieldCounter: getInt(showField, 1) }; + const batch = getMessage(msg, 21); + if (batch) { + return { type: 'imeBatchEdit', imeCounter: getInt(batch, 1), fieldCounter: getInt(batch, 2) }; + } + const start = getMessage(msg, 40); + if (start) return { type: 'start', started: getInt(start, 1) !== 0 }; + const volume = getMessage(msg, 50); + if (volume) { + return { + type: 'volume', + level: getInt(volume, 7), + max: getInt(volume, 6), + muted: getInt(volume, 8) !== 0, + }; + } + return { type: 'other' }; +} diff --git a/apps/mobile/src/atv/protobuf.ts b/apps/mobile/src/atv/protobuf.ts new file mode 100644 index 0000000..6d0470c --- /dev/null +++ b/apps/mobile/src/atv/protobuf.ts @@ -0,0 +1,205 @@ +/** + * Just enough protobuf to speak the Android TV Remote protocol: varints, length-delimited + * fields and nested messages. Bytes are plain number arrays, matching the native socket API. + */ + +export type Bytes = number[]; + +/** Encodes a varint. Negative numbers are sign-extended to 64 bits, as protobuf does for int32. */ +export function varint(value: number): Bytes { + const out: Bytes = []; + if (value < 0) { + // Two's complement over 64 bits: low word is value >>> 0, high word is all ones. + let lo = value >>> 0; + let hi = 0xffffffff; + for (let i = 0; i < 9; i++) { + out.push((lo & 0x7f) | 0x80); + lo = ((lo >>> 7) | ((hi & 0x7f) << 25)) >>> 0; + hi >>>= 7; + } + out.push(1); + return out; + } + let v = Math.floor(value); + while (v >= 0x80) { + out.push((v % 0x80) | 0x80); + v = Math.floor(v / 0x80); + } + out.push(v); + return out; +} + +const WIRE_VARINT = 0; +const WIRE_FIXED64 = 1; +const WIRE_BYTES = 2; +const WIRE_FIXED32 = 5; + +export function fieldVarint(field: number, value: number): Bytes { + return [...varint((field << 3) | WIRE_VARINT), ...varint(value)]; +} + +export function fieldBytes(field: number, bytes: Bytes): Bytes { + return [...varint((field << 3) | WIRE_BYTES), ...varint(bytes.length), ...bytes]; +} + +export function fieldString(field: number, text: string): Bytes { + return fieldBytes(field, utf8(text)); +} + +/** A nested message is encoded exactly like a bytes field. */ +export const fieldMessage = fieldBytes; + +/** Prefixes a message with its varint length, the framing both TV ports use. */ +export function delimited(message: Bytes): Bytes { + return [...varint(message.length), ...message]; +} + +export function utf8(text: string): Bytes { + const out: Bytes = []; + for (const ch of text) { + let c = ch.codePointAt(0) ?? 0; + if (c < 0x80) { + out.push(c); + } else if (c < 0x800) { + out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + } else if (c < 0x10000) { + out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } else { + out.push( + 0xf0 | (c >> 18), + 0x80 | ((c >> 12) & 0x3f), + 0x80 | ((c >> 6) & 0x3f), + 0x80 | (c & 0x3f), + ); + } + } + return out; +} + +export function fromUtf8(bytes: Bytes): string { + let out = ''; + for (let i = 0; i < bytes.length; ) { + const b = bytes[i]; + let c: number; + if (b < 0x80) { + c = b; + i += 1; + } else if (b < 0xe0) { + c = ((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f); + i += 2; + } else if (b < 0xf0) { + c = ((b & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f); + i += 3; + } else { + c = + ((b & 0x07) << 18) | + ((bytes[i + 1] & 0x3f) << 12) | + ((bytes[i + 2] & 0x3f) << 6) | + (bytes[i + 3] & 0x3f); + i += 4; + } + out += String.fromCodePoint(c); + } + return out; +} + +/** Reads a varint at `offset`. `value` is the low 32 bits as a signed int32, which is what every + * field this app reads (int32, uint32 below 2^31, enum, bool) needs. */ +function readVarint(bytes: Bytes, offset: number): { value: number; next: number } | null { + let lo = 0; + let shift = 0; + let i = offset; + while (i < bytes.length) { + const b = bytes[i++]; + if (shift < 32) lo |= (b & 0x7f) << shift; + shift += 7; + if ((b & 0x80) === 0) return { value: lo | 0, next: i }; + if (shift >= 70) throw new Error('Malformed varint'); + } + return null; +} + +export type Field = { wire: 0; value: number } | { wire: 2; bytes: Bytes }; + +/** Decoded message: every occurrence of each field number, in order. */ +export type Message = Map; + +export function decode(bytes: Bytes): Message { + const fields: Message = new Map(); + let offset = 0; + while (offset < bytes.length) { + const key = readVarint(bytes, offset); + if (!key) throw new Error('Truncated field key'); + offset = key.next; + const field = key.value >>> 3; + const wire = key.value & 7; + let entry: Field | null = null; + if (wire === WIRE_VARINT) { + const v = readVarint(bytes, offset); + if (!v) throw new Error('Truncated varint'); + offset = v.next; + entry = { wire: 0, value: v.value }; + } else if (wire === WIRE_BYTES) { + const len = readVarint(bytes, offset); + if (!len || len.next + len.value > bytes.length) throw new Error('Truncated bytes field'); + entry = { wire: 2, bytes: bytes.slice(len.next, len.next + len.value) }; + offset = len.next + len.value; + } else if (wire === WIRE_FIXED64) { + offset += 8; + } else if (wire === WIRE_FIXED32) { + offset += 4; + } else { + throw new Error(`Unsupported wire type ${wire}`); + } + if (entry) { + const list = fields.get(field); + if (list) list.push(entry); + else fields.set(field, [entry]); + } + } + return fields; +} + +export function has(message: Message, field: number): boolean { + return message.has(field); +} + +/** Last value of a varint field, or `fallback` if absent. */ +export function getInt(message: Message, field: number, fallback = 0): number { + const list = message.get(field); + const last = list?.[list.length - 1]; + return last?.wire === 0 ? last.value : fallback; +} + +export function getBytes(message: Message, field: number): Bytes | undefined { + const list = message.get(field); + const last = list?.[list.length - 1]; + return last?.wire === 2 ? last.bytes : undefined; +} + +export function getMessage(message: Message, field: number): Message | undefined { + const bytes = getBytes(message, field); + return bytes ? decode(bytes) : undefined; +} + +export function getString(message: Message, field: number): string { + const bytes = getBytes(message, field); + return bytes ? fromUtf8(bytes) : ''; +} + +/** + * Splits complete length-delimited messages off the front of `buffer`. Returns the messages and + * whatever partial message remains, to be prepended to the next chunk. + */ +export function splitDelimited(buffer: Bytes): { messages: Bytes[]; rest: Bytes } { + const messages: Bytes[] = []; + let offset = 0; + while (offset < buffer.length) { + const len = readVarint(buffer, offset); + if (!len) break; + if (len.next + len.value > buffer.length) break; + messages.push(buffer.slice(len.next, len.next + len.value)); + offset = len.next + len.value; + } + return { messages, rest: buffer.slice(offset) }; +} diff --git a/apps/mobile/src/atv/sha256.ts b/apps/mobile/src/atv/sha256.ts new file mode 100644 index 0000000..2647453 --- /dev/null +++ b/apps/mobile/src/atv/sha256.ts @@ -0,0 +1,75 @@ +import type { Bytes } from './protobuf'; + +const K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + +/** SHA-256, synchronous, for the few hundred bytes hashed while pairing. */ +export function sha256(data: Bytes): Bytes { + const h = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]; + const bitLength = data.length * 8; + const padded = [...data, 0x80]; + while (padded.length % 64 !== 56) padded.push(0); + const hi = Math.floor(bitLength / 0x100000000); + const lo = bitLength >>> 0; + padded.push( + (hi >>> 24) & 0xff, + (hi >>> 16) & 0xff, + (hi >>> 8) & 0xff, + hi & 0xff, + (lo >>> 24) & 0xff, + (lo >>> 16) & 0xff, + (lo >>> 8) & 0xff, + lo & 0xff, + ); + + const w = new Array(64); + for (let block = 0; block < padded.length; block += 64) { + for (let i = 0; i < 16; i++) { + const j = block + i * 4; + w[i] = (padded[j] << 24) | (padded[j + 1] << 16) | (padded[j + 2] << 8) | padded[j + 3]; + } + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K[i] + w[i]) | 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) | 0; + hh = g; + g = f; + f = e; + e = (d + t1) | 0; + d = c; + c = b; + b = a; + a = (t1 + t2) | 0; + } + h[0] = (h[0] + a) | 0; + h[1] = (h[1] + b) | 0; + h[2] = (h[2] + c) | 0; + h[3] = (h[3] + d) | 0; + h[4] = (h[4] + e) | 0; + h[5] = (h[5] + f) | 0; + h[6] = (h[6] + g) | 0; + h[7] = (h[7] + hh) | 0; + } + return h.flatMap((v) => [(v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff]); +} diff --git a/apps/mobile/src/components/remote/RemoteKey.tsx b/apps/mobile/src/components/remote/RemoteKey.tsx index ba53713..92f769d 100644 --- a/apps/mobile/src/components/remote/RemoteKey.tsx +++ b/apps/mobile/src/components/remote/RemoteKey.tsx @@ -18,6 +18,8 @@ interface RemoteKeyProps { size?: number; variant?: Variant; style?: ViewStyle; + /** Dimmed and inert, for keys the connected TV doesn't support. */ + disabled?: boolean; } /** A round remote button that sends one Android key. */ @@ -29,6 +31,7 @@ export function RemoteKey({ size = ms(52), variant = 'default', style, + disabled = false, }: RemoteKeyProps): React.JSX.Element { const { theme } = useUnistyles(); const handlers = useRemoteKey(keyName, repeat); @@ -38,8 +41,14 @@ export function RemoteKey({ [styles.key(size, variant, pressed), style]} + style={({ pressed }) => [ + styles.key(size, variant, pressed), + disabled && styles.disabled, + style, + ]} {...handlers} > @@ -48,6 +57,9 @@ export function RemoteKey({ } const styles = StyleSheet.create((theme) => ({ + disabled: { + opacity: 0.35, + }, key: (size: number, variant: Variant, pressed: boolean) => { const fill = variant === 'accent' diff --git a/apps/mobile/src/components/remote/useRemoteKey.ts b/apps/mobile/src/components/remote/useRemoteKey.ts index 2f2e442..d708b3f 100644 --- a/apps/mobile/src/components/remote/useRemoteKey.ts +++ b/apps/mobile/src/components/remote/useRemoteKey.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useRef } from 'react'; import * as Haptics from 'expo-haptics'; -import { milink } from '@/milink/client'; import type { AndroidKeyName } from '@/milink/keys'; +import { remote } from '@/remote/client'; import { useSettingsStore } from '@/store'; /** How long a finger must stay down before a repeating key starts repeating. */ @@ -26,7 +26,7 @@ export function useRemoteKey(key: AndroidKeyName, repeat = false) { useEffect( () => () => { clearTimeout(holdTimer.current); - if (holding.current) milink.endHold(); + if (holding.current) remote.endHold(); }, [], ); @@ -37,7 +37,7 @@ export function useRemoteKey(key: AndroidKeyName, repeat = false) { holding.current = false; holdTimer.current = setTimeout(() => { holding.current = true; - milink.beginHold(key); + remote.beginHold(key); }, HOLD_DELAY_MS); }, [key, repeat]); @@ -45,9 +45,9 @@ export function useRemoteKey(key: AndroidKeyName, repeat = false) { clearTimeout(holdTimer.current); if (holding.current) { holding.current = false; - milink.endHold(); + remote.endHold(); } else { - milink.press(key); + remote.press(key); } }, [key]); diff --git a/apps/mobile/src/localization/translations/en.json b/apps/mobile/src/localization/translations/en.json index 15f6ccf..b409f4f 100644 --- a/apps/mobile/src/localization/translations/en.json +++ b/apps/mobile/src/localization/translations/en.json @@ -34,7 +34,8 @@ "next": "Next", "type_placeholder": "Type on TV…", "send_text": "Send text", - "delete": "Delete" + "delete": "Delete", + "pairing": "Pairing with {{host}}…" }, "connect": { "title": "Connect to Mi TV", @@ -59,5 +60,19 @@ "title": "Oops!", "message": "This screen doesn't exist.", "go_home": "Go to the remote" + }, + "pair": { + "title": "Pair with your TV", + "intro": "This TV ({{host}}) doesn't have Xiaomi's MiLink service, so Mi Remote uses the Android TV remote service instead. That needs a one-time pairing.", + "requesting": "Asking the TV for a pairing code…", + "enter_code": "Enter the 6-character code shown on your TV", + "pair": "Pair", + "retry": "Try again", + "cancel": "Cancel pairing", + "error_wrong_code": "That code didn't work. Enter the code now shown on the TV.", + "error_rejected": "The TV didn't accept the pairing. Try again, and enter the code before it disappears from the TV.", + "error_connection": "Lost the connection to the TV while pairing.", + "error_certificate": "Couldn't create the pairing key on this phone.", + "hint": "You only need to do this once per TV. If it keeps failing, on the TV open Settings → Apps → See all apps → Show system apps → Android TV Remote Service, and clear its storage." } } diff --git a/apps/mobile/src/milink/client.ts b/apps/mobile/src/milink/client.ts deleted file mode 100644 index d36810b..0000000 --- a/apps/mobile/src/milink/client.ts +++ /dev/null @@ -1,190 +0,0 @@ -import MiLink, { type SocketState } from 'milink'; -import { create } from 'zustand'; - -import { AndroidKey, type AndroidKeyName, keystrokeFor } from './keys'; -import { - ACTION_DOWN, - ACTION_UP, - HEARTBEAT, - keyFrame, - keyResponseId, - MILINK_PORT, - splitFrames, -} from './protocol'; - -export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'failed'; - -interface ConnectionState { - status: ConnectionStatus; - host: string; - error?: string; - /** Round trip of the last acknowledged key, in milliseconds. */ - latencyMs?: number; -} - -/** Live connection state for the UI. Not persisted. */ -export const useConnectionStore = create(() => ({ status: 'idle', host: '' })); - -const HEARTBEAT_MS = 5000; -const RECONNECT_MS = 2000; -/** The TV auto-releases a key 175 ms after the last DOWN, so held keys resend DOWN faster. */ -const REPEAT_MS = 100; -const TYPE_INTERVAL_MS = 40; - -/** - * Client for the Xiaomi MiLink remote-control service. Owns the native socket: connects, - * keeps it alive with heartbeats, reconnects after failures, and sends key events. - */ -class MiLinkClient { - private host = ''; - private wantConnected = false; - private buffer: number[] = []; - private nextId = 0; - private pending = new Map(); - private heartbeatTimer?: ReturnType; - private reconnectTimer?: ReturnType; - private repeatTimer?: ReturnType; - private repeatCode = 0; - private repeatCount = 0; - - constructor() { - MiLink.addListener('onSocketState', ({ state, error }) => this.handleState(state, error)); - MiLink.addListener('onSocketData', ({ bytes }) => this.handleData(bytes)); - } - - // ── Connection ───────────────────────────────────────────── - - connect(host: string): void { - const trimmed = host.trim(); - if (!trimmed) return; - this.disconnect(); - this.host = trimmed; - this.wantConnected = true; - useConnectionStore.setState({ host: trimmed, status: 'connecting', error: undefined }); - MiLink.connect(trimmed, MILINK_PORT); - } - - disconnect(): void { - this.wantConnected = false; - this.stopTimers(); - MiLink.disconnect(); - useConnectionStore.setState({ status: 'idle', error: undefined, latencyMs: undefined }); - } - - /** Drops the socket while the app is in the background; `resume()` reconnects. */ - suspend(): void { - if (!this.wantConnected) return; - this.stopTimers(); - MiLink.disconnect(); - useConnectionStore.setState({ status: 'idle', latencyMs: undefined }); - } - - resume(): void { - if (this.wantConnected && this.host) this.connect(this.host); - } - - get isConnected(): boolean { - return useConnectionStore.getState().status === 'connected'; - } - - private handleState(state: SocketState, error?: string): void { - if (state === 'connected') { - this.buffer = []; - this.pending.clear(); - clearInterval(this.heartbeatTimer); - this.heartbeatTimer = setInterval(() => this.write([...HEARTBEAT]), HEARTBEAT_MS); - useConnectionStore.setState({ status: 'connected', error: undefined }); - } else if (state === 'failed') { - clearInterval(this.heartbeatTimer); - this.endHold(false); - useConnectionStore.setState({ status: 'failed', error }); - this.scheduleReconnect(); - } else if (state === 'connecting') { - useConnectionStore.setState({ status: 'connecting' }); - } - // 'disconnected' only follows our own disconnect()/suspend(), which already set the state. - } - - private scheduleReconnect(): void { - clearTimeout(this.reconnectTimer); - if (!this.wantConnected) return; - this.reconnectTimer = setTimeout(() => { - if (this.wantConnected && !this.isConnected) MiLink.connect(this.host, MILINK_PORT); - }, RECONNECT_MS); - } - - private stopTimers(): void { - clearInterval(this.heartbeatTimer); - clearTimeout(this.reconnectTimer); - this.endHold(false); - } - - private handleData(bytes: number[]): void { - const { frames, rest } = splitFrames([...this.buffer, ...bytes]); - this.buffer = rest; - for (const frame of frames) { - const id = keyResponseId(frame); - const sentAt = id === null ? undefined : this.pending.get(id); - if (id !== null && sentAt !== undefined) { - this.pending.delete(id); - useConnectionStore.setState({ latencyMs: Date.now() - sentAt }); - } - } - } - - private write(bytes: number[]): void { - if (this.isConnected) MiLink.send(bytes); - } - - private sendKey(code: number, action: number, meta = 0, repeatCount = 0): void { - if (!this.isConnected) return; - this.nextId = (this.nextId + 1) >>> 0; - this.pending.set(this.nextId, Date.now()); - if (this.pending.size > 64) this.pending.clear(); - this.write(keyFrame(this.nextId, { code, action, meta, repeatCount })); - } - - // ── Keys ─────────────────────────────────────────────────── - - /** Single press: DOWN followed by UP. */ - press(key: AndroidKeyName): void { - this.pressCode(AndroidKey[key]); - } - - private pressCode(code: number, meta = 0): void { - this.sendKey(code, ACTION_DOWN, meta); - this.sendKey(code, ACTION_UP, meta); - } - - /** Begins a press-and-hold that repeats until `endHold()`. */ - beginHold(key: AndroidKeyName): void { - this.endHold(true); - this.repeatCode = AndroidKey[key]; - this.repeatCount = 0; - this.sendKey(this.repeatCode, ACTION_DOWN); - this.repeatTimer = setInterval(() => { - this.repeatCount += 1; - this.sendKey(this.repeatCode, ACTION_DOWN, 0, this.repeatCount); - }, REPEAT_MS); - } - - endHold(sendUp = true): void { - if (!this.repeatTimer) return; - clearInterval(this.repeatTimer); - this.repeatTimer = undefined; - if (sendUp) this.sendKey(this.repeatCode, ACTION_UP); - } - - /** Types text one keystroke at a time. Characters with no Android key are skipped. */ - type(text: string): void { - let delay = 0; - for (const ch of text) { - const stroke = keystrokeFor(ch); - if (!stroke) continue; - setTimeout(() => this.pressCode(stroke.code, stroke.meta), delay); - delay += TYPE_INTERVAL_MS; - } - } -} - -export const milink = new MiLinkClient(); diff --git a/apps/mobile/src/remote/__tests__/client.test.ts b/apps/mobile/src/remote/__tests__/client.test.ts new file mode 100644 index 0000000..de6b774 --- /dev/null +++ b/apps/mobile/src/remote/__tests__/client.test.ts @@ -0,0 +1,192 @@ +import { X509Certificate } from 'crypto'; + +import { CLIENT_CERT, PAIRING_CODE, PAIRING_SECRET_HEX, PB, SERVER_CERT } from '@/atv/__fixtures__/vectors'; +import { + ATV_PAIRING_PORT, + ATV_REMOTE_PORT, + Direction, + imeBatchEdit, + keyInject, + pairingConfiguration, + pairingOptions, + pairingRequest, + pingResponse, + remoteConfigure, + WANTED_FEATURES, +} from '@/atv/messages'; +import { delimited } from '@/atv/protobuf'; +import { MILINK_PORT } from '@/milink/protocol'; + +type Listener = (event: never) => void; + +// A scripted stand-in for the mockNative socket module. +const mockNative = { + listeners: {} as Record, + connect: jest.fn(), + disconnect: jest.fn(), + send: jest.fn(), + clientKey: jest.fn(), + signWithClientKey: jest.fn(), + setClientCertificate: jest.fn(), + addListener(name: string, listener: Listener) { + this.listeners[name] = listener; + return { remove: () => {} }; + }, + emitState(event: object) { + (this.listeners.onSocketState as (e: object) => void)(event); + }, + emitData(bytes: number[]) { + (this.listeners.onSocketData as (e: object) => void)({ bytes }); + }, +}; + +jest.mock('milink', () => ({ __esModule: true, default: mockNative })); + +const bytesOf = (b64: string) => Array.from(Buffer.from(b64, 'base64')); +const clientSpki = Array.from( + new X509Certificate(Buffer.from(CLIENT_CERT, 'base64')).publicKey.export({ + format: 'der', + type: 'spki', + }), +); + +/** Lets the client's awaited mockNative calls (client key, signing) settle. */ +const flush = () => new Promise((resolve) => setImmediate(resolve)); +const sentMessages = () => mockNative.send.mock.calls.map(([bytes]) => bytes as number[]); + +describe('RemoteClient', () => { + let remote: typeof import('../client').remote; + let useConnectionStore: typeof import('../client').useConnectionStore; + let useSettingsStore: typeof import('@/store').useSettingsStore; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + mockNative.clientKey.mockResolvedValue({ publicKey: clientSpki, certificate: bytesOf(CLIENT_CERT) }); + ({ remote, useConnectionStore } = require('../client')); + ({ useSettingsStore } = require('@/store')); + }); + + afterEach(() => remote.disconnect()); + + it('uses MiLink when port 6091 answers, and remembers it', () => { + remote.connect('192.168.1.10'); + expect(mockNative.connect).toHaveBeenLastCalledWith('192.168.1.10', MILINK_PORT, false); + mockNative.emitState({ state: 'connected' }); + expect(useConnectionStore.getState().status).toBe('connected'); + expect(useSettingsStore.getState().tvProtocols['192.168.1.10']).toBe('milink'); + }); + + it('pairs and connects over Android TV Remote when MiLink is absent', async () => { + remote.connect('192.168.1.54'); + expect(mockNative.connect).toHaveBeenLastCalledWith('192.168.1.54', MILINK_PORT, false); + + // Nothing on 6091 → try the Android TV Remote port. + mockNative.emitState({ state: 'failed', error: 'refused', reachable: false }); + await flush(); + expect(mockNative.connect).toHaveBeenLastCalledWith('192.168.1.54', ATV_REMOTE_PORT, true); + + // TLS refused before any message: certificate not paired → pairing port. + mockNative.emitState({ state: 'failed', error: 'certificate unknown', reachable: true }); + await flush(); + expect(useConnectionStore.getState()).toMatchObject({ + status: 'pairing', + pairingStep: 'starting', + }); + expect(mockNative.connect).toHaveBeenLastCalledWith('192.168.1.54', ATV_PAIRING_PORT, true); + + // Pairing handshake. + mockNative.send.mockClear(); + mockNative.emitState({ state: 'connected', peerCertificate: bytesOf(SERVER_CERT) }); + expect(sentMessages()).toEqual([delimited(pairingRequest('Mi Remote'))]); + mockNative.emitData(delimited(PB.inRequestAck)); + mockNative.emitData(delimited(PB.pairingOptions)); + expect(sentMessages().slice(1)).toEqual([ + delimited(pairingOptions()), + delimited(pairingConfiguration()), + ]); + mockNative.emitData(delimited(PB.inConfigurationAck)); + expect(useConnectionStore.getState().pairingStep).toBe('enterCode'); + + // A code with a bad checksum never reaches the TV. + mockNative.send.mockClear(); + expect(remote.submitPairingCode('000000')).toBe(false); + expect(mockNative.send).not.toHaveBeenCalled(); + expect(useConnectionStore.getState().pairingError).toBe('wrongCode'); + + // The right code sends the secret androidtvremote2 computes for these certificates. + expect(remote.submitPairingCode(PAIRING_CODE.toLowerCase())).toBe(true); + const secretMessage = sentMessages()[0]; + expect(Buffer.from(secretMessage.slice(-32)).toString('hex')).toBe(PAIRING_SECRET_HEX); + expect(useConnectionStore.getState().pairingStep).toBe('verifying'); + + // TV accepts → reconnect to the remote port. + mockNative.emitData(delimited([8, 2, 16, 200, 1, 202, 2, 2, 10, 0])); // secret_ack + await flush(); + expect(mockNative.connect).toHaveBeenLastCalledWith('192.168.1.54', ATV_REMOTE_PORT, true); + expect(useSettingsStore.getState().tvProtocols['192.168.1.54']).toBe('atv'); + + // Remote session: answer configure and pings, then send keys. + mockNative.send.mockClear(); + mockNative.emitState({ state: 'connected', peerCertificate: bytesOf(SERVER_CERT) }); + mockNative.emitData([...delimited(PB.inConfigure), ...delimited(PB.inPing12345)]); + expect(useConnectionStore.getState().status).toBe('connected'); + expect(sentMessages()).toEqual([ + delimited(remoteConfigure(WANTED_FEATURES & 639)), + delimited(pingResponse(12345)), + ]); + + mockNative.send.mockClear(); + remote.press('dpadUp'); + expect(sentMessages()).toEqual([delimited(keyInject(19, Direction.short))]); + + // Input source opens Settings; recent apps isn't sent (the service ignores both keys). + mockNative.send.mockClear(); + remote.press('tvInput'); + remote.press('appSwitch'); + expect(sentMessages()).toEqual([delimited(keyInject(176, Direction.short))]); + + // Text goes in as an IME edit even before the TV has sent IME counters. + mockNative.send.mockClear(); + remote.type('Hi'); + expect(sentMessages()).toEqual([delimited(imeBatchEdit(0, 0, 'Hi'))]); + }); + + it('keeps a failed pairing on screen with the error instead of starting over', async () => { + useSettingsStore.getState().setTvProtocol('192.168.1.54', 'atv'); + remote.connect('192.168.1.54'); + await flush(); + mockNative.emitState({ state: 'failed', error: 'handshake failure', reachable: true }); + await flush(); + expect(mockNative.connect).toHaveBeenLastCalledWith('192.168.1.54', ATV_PAIRING_PORT, true); + mockNative.connect.mockClear(); + mockNative.emitState({ state: 'failed', error: 'handshake failure', reachable: true }); + await flush(); + expect(useConnectionStore.getState()).toMatchObject({ + status: 'pairing', + pairingError: 'connection', + error: '6467: handshake failure', + }); + expect(mockNative.connect).not.toHaveBeenCalled(); + }); + + it('goes straight to Android TV Remote for a TV known to use it', async () => { + useSettingsStore.getState().setTvProtocol('192.168.1.54', 'atv'); + remote.connect('192.168.1.54'); + await flush(); + expect(mockNative.connect).toHaveBeenCalledTimes(1); + expect(mockNative.connect).toHaveBeenLastCalledWith('192.168.1.54', ATV_REMOTE_PORT, true); + }); + + it('creates and stores a self-signed certificate when none exists', async () => { + mockNative.clientKey.mockResolvedValue({ publicKey: clientSpki }); + mockNative.signWithClientKey.mockResolvedValue(Array.from({ length: 256 }, () => 1)); + remote.connect('192.168.1.54'); + mockNative.emitState({ state: 'failed', reachable: false }); + await flush(); + expect(mockNative.signWithClientKey).toHaveBeenCalledTimes(1); + expect(mockNative.setClientCertificate).toHaveBeenCalledTimes(1); + const der = mockNative.setClientCertificate.mock.calls[0][0] as number[]; + expect(new X509Certificate(Buffer.from(der)).subject).toBe('CN=Mi Remote'); + }); +}); diff --git a/apps/mobile/src/remote/client.ts b/apps/mobile/src/remote/client.ts new file mode 100644 index 0000000..8b95913 --- /dev/null +++ b/apps/mobile/src/remote/client.ts @@ -0,0 +1,604 @@ +import MiLink, { type SocketStateEvent } from 'milink'; +import { create } from 'zustand'; + +import { certificate, type RsaPublicKey, rsaPublicKey, tbsCertificate } from '@/atv/der'; +import { + ATV_PAIRING_PORT, + ATV_REMOTE_PORT, + Direction, + imeBatchEdit, + keyInject, + pairingConfiguration, + pairingOptions, + pairingRequest, + pairingSecret, + pairingSecretFor, + parsePairingMessage, + parseRemoteMessage, + pingResponse, + remoteConfigure, + remoteSetActive, + STATUS_BAD_SECRET, + STATUS_OK, + WANTED_FEATURES, +} from '@/atv/messages'; +import { type Bytes, delimited, splitDelimited } from '@/atv/protobuf'; +import { AndroidKey, type AndroidKeyName, keystrokeFor } from '@/milink/keys'; +import { + ACTION_DOWN, + ACTION_UP, + HEARTBEAT, + keyFrame, + keyResponseId, + MILINK_PORT, + splitFrames, +} from '@/milink/protocol'; +import { type TvProtocol, useSettingsStore } from '@/store'; + +export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'failed' | 'pairing'; + +/** Where an Android TV pairing stands: asking the TV for a code, waiting for the user to type + * it, or waiting for the TV to accept it. */ +export type PairingStep = 'starting' | 'enterCode' | 'verifying'; + +export type PairingError = 'wrongCode' | 'rejected' | 'connection' | 'certificate'; + +interface ConnectionState { + status: ConnectionStatus; + host: string; + error?: string; + /** Round trip of the last acknowledged key, in milliseconds (MiLink only). */ + latencyMs?: number; + protocol?: TvProtocol; + pairingStep?: PairingStep; + pairingError?: PairingError; +} + +/** Live connection state for the UI. Not persisted. */ +export const useConnectionStore = create(() => ({ status: 'idle', host: '' })); + +const setState = (state: Partial) => useConnectionStore.setState(state); + +const HEARTBEAT_MS = 5000; +const RECONNECT_MS = 2000; +/** MiLink: the TV auto-releases a key 175 ms after the last DOWN, so held keys resend DOWN faster. */ +const REPEAT_MS = 100; +/** Android TV Remote: held keys repeat as discrete presses. */ +const ATV_REPEAT_MS = 120; +const TYPE_INTERVAL_MS = 40; +/** The TV pings every 5 s; three missed pings means the connection is gone. */ +const ATV_IDLE_MS = 16000; +const CLIENT_NAME = 'Mi Remote'; + +/** Which socket the native module currently holds, so its events go to the right handler. */ +type Phase = 'none' | 'milink' | 'atv' | 'pairing'; + +/** + * Client for the TV's remote-control service. Owns the native socket and speaks one of two + * protocols, remembered per TV: + * + * - MiLink (TCP 6091), Xiaomi's own service on PatchWall TVs. No pairing. + * - Android TV Remote v2 (TLS 6466, pairing on 6467), the Google TV remote protocol, for Mi TVs + * without MiLink. Needs a one-time pairing code shown on the TV. + * + * For a TV it hasn't seen, it tries MiLink first and falls back to Android TV Remote when + * nothing listens on 6091. + */ +class RemoteClient { + private host = ''; + private wantConnected = false; + private protocol?: TvProtocol; + private phase: Phase = 'none'; + /** Bumped on every disconnect so async steps from an older attempt stop. */ + private attempt = 0; + private buffer: Bytes = []; + private heartbeatTimer?: ReturnType; + private reconnectTimer?: ReturnType; + private idleTimer?: ReturnType; + private repeatTimer?: ReturnType; + private repeatCode = 0; + private repeatCount = 0; + + // MiLink + private nextId = 0; + private pending = new Map(); + + // Android TV Remote + private atvReady = false; + private features = WANTED_FEATURES; + private imeCounter?: number; + private imeFieldCounter = 0; + private justPaired = false; + private clientKey?: RsaPublicKey; + private serverKey?: RsaPublicKey; + + constructor() { + MiLink.addListener('onSocketState', (event) => this.handleState(event)); + MiLink.addListener('onSocketData', ({ bytes }) => this.handleData(bytes)); + } + + // ── Connection ───────────────────────────────────────────── + + connect(host: string): void { + const trimmed = host.trim(); + if (!trimmed) return; + this.disconnect(); + this.host = trimmed; + this.wantConnected = true; + this.protocol = useSettingsStore.getState().tvProtocols[trimmed]; + setState({ + host: trimmed, + status: 'connecting', + error: undefined, + protocol: this.protocol, + pairingStep: undefined, + pairingError: undefined, + }); + this.open(); + } + + disconnect(): void { + this.wantConnected = false; + this.drop(); + setState({ + status: 'idle', + error: undefined, + latencyMs: undefined, + pairingStep: undefined, + pairingError: undefined, + }); + } + + /** Drops the socket while the app is in the background; `resume()` reconnects. */ + suspend(): void { + if (!this.wantConnected) return; + this.drop(); + setState({ status: 'idle', latencyMs: undefined, pairingStep: undefined }); + } + + resume(): void { + if (this.wantConnected && this.host) this.connect(this.host); + } + + get isConnected(): boolean { + return useConnectionStore.getState().status === 'connected'; + } + + private drop(): void { + this.attempt += 1; + this.stopTimers(); + this.phase = 'none'; + MiLink.disconnect(); + } + + private open(): void { + if (this.protocol === 'atv') void this.openAtv(); + else this.openMiLink(); + } + + private openMiLink(): void { + this.phase = 'milink'; + this.buffer = []; + MiLink.connect(this.host, MILINK_PORT, false); + } + + private openAtv(): Promise { + this.atvReady = false; + this.imeCounter = undefined; + this.features = WANTED_FEATURES; + return this.openTls(ATV_REMOTE_PORT, 'atv'); + } + + /** Opens a TLS socket once the client certificate exists. */ + private async openTls(port: number, phase: Phase): Promise { + // Silence the previous socket first, so its late events can't land in the new phase. + this.phase = 'none'; + MiLink.disconnect(); + const attempt = this.attempt; + try { + await this.ensureCertificate(); + } catch (e) { + if (attempt !== this.attempt) return; + setState({ status: 'failed', error: String(e), pairingError: 'certificate' }); + return; + } + if (attempt !== this.attempt || !this.wantConnected) return; + this.phase = phase; + this.buffer = []; + MiLink.connect(this.host, port, true); + } + + private scheduleReconnect(): void { + clearTimeout(this.reconnectTimer); + if (!this.wantConnected) return; + this.reconnectTimer = setTimeout(() => { + if (this.wantConnected && !this.isConnected) this.open(); + }, RECONNECT_MS); + } + + private stopTimers(): void { + clearInterval(this.heartbeatTimer); + clearTimeout(this.reconnectTimer); + clearTimeout(this.idleTimer); + this.endHold(false); + } + + private rememberProtocol(protocol: TvProtocol): void { + this.protocol = protocol; + useSettingsStore.getState().setTvProtocol(this.host, protocol); + setState({ protocol }); + } + + private handleState(event: SocketStateEvent): void { + if (event.state === 'disconnected') return; // only follows our own disconnect() + if (this.phase === 'milink') this.handleMiLinkState(event); + else if (this.phase === 'atv') this.handleAtvState(event); + else if (this.phase === 'pairing') this.handlePairingState(event); + } + + private handleData(bytes: number[]): void { + if (this.phase === 'milink') this.handleMiLinkData(bytes); + else if (this.phase === 'atv' || this.phase === 'pairing') { + let messages: Bytes[]; + try { + const split = splitDelimited([...this.buffer, ...bytes]); + messages = split.messages; + this.buffer = split.rest; + } catch { + this.buffer = []; + return; + } + for (const message of messages) { + if (this.phase === 'atv') this.handleAtvMessage(message); + else if (this.phase === 'pairing') this.handlePairingMessage(message); + } + } + } + + private sendDelimited(message: Bytes): void { + MiLink.send(delimited(message)); + } + + // ── MiLink ───────────────────────────────────────────────── + + private handleMiLinkState({ state, error, reachable }: SocketStateEvent): void { + if (state === 'connected') { + this.buffer = []; + this.pending.clear(); + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = setInterval(() => this.writeMiLink([...HEARTBEAT]), HEARTBEAT_MS); + if (this.protocol !== 'milink') this.rememberProtocol('milink'); + setState({ status: 'connected', error: undefined }); + } else if (state === 'failed') { + clearInterval(this.heartbeatTimer); + this.endHold(false); + if (!this.protocol && !reachable) { + // Nothing on 6091: this TV may only have the Android TV Remote service. + void this.openAtv(); + return; + } + setState({ status: 'failed', error }); + this.scheduleReconnect(); + } else if (state === 'connecting') { + setState({ status: 'connecting' }); + } + } + + private handleMiLinkData(bytes: number[]): void { + const { frames, rest } = splitFrames([...this.buffer, ...bytes]); + this.buffer = rest; + for (const frame of frames) { + const id = keyResponseId(frame); + const sentAt = id === null ? undefined : this.pending.get(id); + if (id !== null && sentAt !== undefined) { + this.pending.delete(id); + setState({ latencyMs: Date.now() - sentAt }); + } + } + } + + private writeMiLink(bytes: number[]): void { + if (this.isConnected && this.phase === 'milink') MiLink.send(bytes); + } + + private sendMiLinkKey(code: number, action: number, meta = 0, repeatCount = 0): void { + if (!this.isConnected || this.phase !== 'milink') return; + this.nextId = (this.nextId + 1) >>> 0; + this.pending.set(this.nextId, Date.now()); + if (this.pending.size > 64) this.pending.clear(); + this.writeMiLink(keyFrame(this.nextId, { code, action, meta, repeatCount })); + } + + // ── Android TV Remote ────────────────────────────────────── + + /** Makes sure the persistent client key has a self-signed certificate, creating one once. */ + private async ensureCertificate(): Promise { + const key = await MiLink.clientKey(); + const publicKey = rsaPublicKey(key.publicKey); + let valid = false; + if (key.certificate) { + try { + const certKey = rsaPublicKey(key.certificate); + valid = sameBytes(certKey.modulus, publicKey.modulus); + } catch { + valid = false; + } + } + if (!valid) { + const serial = Array.from({ length: 8 }, () => Math.floor(Math.random() * 256)); + serial[0] = (serial[0] & 0x7f) | 0x01; + const tbs = tbsCertificate(publicKey, CLIENT_NAME, serial, new Date()); + const signature = await MiLink.signWithClientKey(tbs); + await MiLink.setClientCertificate(certificate(tbs, signature)); + } + this.clientKey = publicKey; + } + + private handleAtvState({ state, error, reachable }: SocketStateEvent): void { + if (state === 'connecting') { + setState({ status: 'connecting' }); + return; + } + if (state === 'connected') { + this.buffer = []; + this.resetIdleTimer(); + return; + } + if (state !== 'failed') return; + clearTimeout(this.idleTimer); + this.endHold(false); + this.phase = 'none'; + + if (!this.atvReady && reachable) { + // The service is there but dropped the TLS session: our certificate isn't paired (yet). + if (this.justPaired) { + this.justPaired = false; + setState({ status: 'failed', error, pairingError: 'rejected' }); + return; + } + this.startPairing(); + return; + } + setState({ status: 'failed', error }); + this.scheduleReconnect(); + } + + private resetIdleTimer(): void { + clearTimeout(this.idleTimer); + this.idleTimer = setTimeout(() => { + if (this.phase !== 'atv') return; + MiLink.disconnect(); + this.handleAtvState({ state: 'failed', error: 'No response from the TV', reachable: true }); + }, ATV_IDLE_MS); + } + + private handleAtvMessage(bytes: Bytes): void { + this.resetIdleTimer(); + let event: ReturnType; + try { + event = parseRemoteMessage(bytes); + } catch { + return; + } + switch (event.type) { + case 'configure': + this.features = WANTED_FEATURES & event.features; + this.sendDelimited(remoteConfigure(this.features)); + this.markAtvReady(); + break; + case 'setActive': + this.sendDelimited(remoteSetActive(this.features)); + break; + case 'ping': + this.sendDelimited(pingResponse(event.value)); + break; + case 'start': + this.markAtvReady(); + break; + case 'imeBatchEdit': + this.imeCounter = event.imeCounter; + this.imeFieldCounter = event.fieldCounter; + break; + case 'imeShow': + this.imeFieldCounter = event.fieldCounter; + break; + case 'imeKeyInject': + if (event.fieldCounter !== undefined) this.imeFieldCounter = event.fieldCounter; + break; + default: + break; + } + } + + private markAtvReady(): void { + if (this.atvReady) return; + this.atvReady = true; + this.justPaired = false; + if (this.protocol !== 'atv') this.rememberProtocol('atv'); + setState({ + status: 'connected', + error: undefined, + latencyMs: undefined, + pairingStep: undefined, + pairingError: undefined, + }); + } + + private sendAtvKey(code: number): void { + if (!this.isConnected || this.phase !== 'atv') return; + this.sendDelimited(keyInject(code, Direction.short)); + } + + // ── Android TV pairing ───────────────────────────────────── + + private startPairing(error?: PairingError): void { + this.serverKey = undefined; + setState({ status: 'pairing', pairingStep: 'starting', pairingError: error }); + void this.openTls(ATV_PAIRING_PORT, 'pairing'); + } + + /** Asks the TV for a new code, e.g. after a failed attempt. */ + retryPairing(): void { + if (this.wantConnected && this.host) { + this.attempt += 1; + this.stopTimers(); + this.startPairing(); + } + } + + /** Sends the code shown on the TV. Returns false if it can't be right (checksum mismatch). */ + submitPairingCode(code: string): boolean { + const { pairingStep } = useConnectionStore.getState(); + if (this.phase !== 'pairing' || pairingStep !== 'enterCode') return false; + if (!this.clientKey || !this.serverKey) return false; + const secret = pairingSecretFor(this.clientKey, this.serverKey, code); + if (!secret) { + setState({ pairingError: 'wrongCode' }); + return false; + } + this.sendDelimited(pairingSecret(secret)); + setState({ pairingStep: 'verifying', pairingError: undefined }); + return true; + } + + private handlePairingState({ state, error, reachable, peerCertificate }: SocketStateEvent): void { + if (state === 'connected') { + try { + if (!peerCertificate) throw new Error('No TV certificate'); + this.serverKey = rsaPublicKey(peerCertificate); + } catch (e) { + this.phase = 'none'; + MiLink.disconnect(); + setState({ error: String(e), pairingError: 'connection' }); + return; + } + this.sendDelimited(pairingRequest(CLIENT_NAME)); + return; + } + if (state !== 'failed') return; + this.phase = 'none'; + // Stay on the pairing screen with the error (and a retry), rather than silently starting + // over, which hid why pairing failed (#4). + const detail = `${ATV_PAIRING_PORT}: ${error ?? (reachable ? 'connection lost' : 'unreachable')}`; + setState({ + status: 'pairing', + pairingStep: 'starting', + error: detail, + pairingError: 'connection', + }); + } + + private handlePairingMessage(bytes: Bytes): void { + let parsed: ReturnType; + try { + parsed = parsePairingMessage(bytes); + } catch { + return; + } + if (parsed.status !== STATUS_OK) { + this.phase = 'none'; + MiLink.disconnect(); + if (parsed.status === STATUS_BAD_SECRET) this.startPairing('wrongCode'); + else setState({ pairingError: 'rejected' }); + return; + } + switch (parsed.reply) { + case 'requestAck': + this.sendDelimited(pairingOptions()); + break; + case 'options': + this.sendDelimited(pairingConfiguration()); + break; + case 'configurationAck': + setState({ pairingStep: 'enterCode' }); + break; + case 'secretAck': + this.justPaired = true; + this.rememberProtocol('atv'); + setState({ status: 'connecting', pairingStep: undefined, pairingError: undefined }); + void this.openAtv(); + break; + default: + break; + } + } + + // ── Keys ─────────────────────────────────────────────────── + + /** Single press: DOWN followed by UP. */ + press(key: AndroidKeyName): void { + this.pressCode(AndroidKey[key]); + } + + private pressCode(code: number, meta = 0): void { + if (this.phase === 'atv') { + const mapped = atvKey(code); + if (mapped !== null) this.sendAtvKey(mapped); + } else { + this.sendMiLinkKey(code, ACTION_DOWN, meta); + this.sendMiLinkKey(code, ACTION_UP, meta); + } + } + + /** Begins a press-and-hold that repeats until `endHold()`. */ + beginHold(key: AndroidKeyName): void { + this.endHold(true); + this.repeatCode = AndroidKey[key]; + this.repeatCount = 0; + if (this.phase === 'atv') { + this.sendAtvKey(this.repeatCode); + this.repeatTimer = setInterval(() => this.sendAtvKey(this.repeatCode), ATV_REPEAT_MS); + return; + } + this.sendMiLinkKey(this.repeatCode, ACTION_DOWN); + this.repeatTimer = setInterval(() => { + this.repeatCount += 1; + this.sendMiLinkKey(this.repeatCode, ACTION_DOWN, 0, this.repeatCount); + }, REPEAT_MS); + } + + endHold(sendUp = true): void { + if (!this.repeatTimer) return; + clearInterval(this.repeatTimer); + this.repeatTimer = undefined; + if (sendUp && this.phase === 'milink') this.sendMiLinkKey(this.repeatCode, ACTION_UP); + } + + /** + * Types text on the TV. Android TV Remote sends it as an IME edit into the focused text field + * (the TV ignores letter key events there). MiLink types one keystroke at a time and skips + * characters with no Android key. + */ + type(text: string): void { + if (!text) return; + if (this.phase === 'atv') { + if (!this.isConnected) return; + this.sendDelimited(imeBatchEdit(this.imeCounter ?? 0, this.imeFieldCounter, text)); + return; + } + let delay = 0; + for (const ch of text) { + const stroke = keystrokeFor(ch); + if (!stroke) continue; + setTimeout(() => this.pressCode(stroke.code, stroke.meta), delay); + delay += TYPE_INTERVAL_MS; + } + } +} + +/** + * The Android TV Remote service ignores keys outside a supported set, including the input-source + * and recent-apps keys (tested on a MiTV-MOOR2, #4). Input source opens Settings instead, where + * the TV's inputs are; recent apps has no equivalent, so it returns null. + */ +export function atvKey(code: number): number | null { + if (code === AndroidKey.tvInput) return AndroidKey.settings; + if (code === AndroidKey.appSwitch) return null; + return code; +} + +function sameBytes(a: Bytes, b: Bytes): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +export const remote = new RemoteClient(); diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts index 57c449c..4826b18 100644 --- a/apps/mobile/src/store/index.ts +++ b/apps/mobile/src/store/index.ts @@ -1,5 +1,6 @@ import { useSettingsStore } from './useSettingsStore'; +export type { TvProtocol } from './useSettingsStore'; export { useSettingsStore } from './useSettingsStore'; /** diff --git a/apps/mobile/src/store/useSettingsStore.ts b/apps/mobile/src/store/useSettingsStore.ts index a59bcde..428b3a0 100644 --- a/apps/mobile/src/store/useSettingsStore.ts +++ b/apps/mobile/src/store/useSettingsStore.ts @@ -1,16 +1,30 @@ import { createPersistedStore } from './storage'; +/** Which remote protocol a TV speaks: Xiaomi MiLink, or Google's Android TV Remote. */ +export type TvProtocol = 'milink' | 'atv'; + interface SettingsState { /** IP address of the last TV the user connected to. Empty until one is chosen. */ tvHost: string; haptics: boolean; + /** Protocol each TV answered on, by IP address, so reconnects skip the probing. */ + tvProtocols: Record; setTvHost: (host: string) => void; setHaptics: (enabled: boolean) => void; + setTvProtocol: (host: string, protocol: TvProtocol | undefined) => void; } export const useSettingsStore = createPersistedStore('settings-storage', (set) => ({ tvHost: '', haptics: true, + tvProtocols: {}, setTvHost: (tvHost) => set({ tvHost }), setHaptics: (haptics) => set({ haptics }), + setTvProtocol: (host, protocol) => + set((state) => { + const tvProtocols = { ...state.tvProtocols }; + if (protocol) tvProtocols[host] = protocol; + else delete tvProtocols[host]; + return { tvProtocols }; + }), }));