diff --git a/web/apps/cast/src/services/pair.ts b/web/apps/cast/src/services/pair.ts new file mode 100644 index 000000000..02c1da6d6 --- /dev/null +++ b/web/apps/cast/src/services/pair.ts @@ -0,0 +1,103 @@ +import log from "@/next/log"; +import { toB64 } from "@ente/shared/crypto/internal/libsodium"; +import castGateway from "@ente/shared/network/cast"; +import { wait } from "@ente/shared/utils"; +import _sodium from "libsodium-wrappers"; +import { type Cast } from "../utils/useCastReceiver"; + +/** + * Listen for pairing requests using the given {@link cast} instance. + * + * [Note: Cast protocol] + * + * The Chromecast Framework (represented here by our handle to the Chromecast + * Web SDK, {@link cast}) itself is used for only the initial handshake, none of + * the data, even encrypted passes over it thereafter. + * + * The entire protocol is quite simple. + * + * 1. We (the receiver) generate a public/private keypair. and register the + * public part of it with museum. + * + * 2. Museum gives us a pairing "code" in lieu. + * + * 3. Listen for incoming messages over the Chromecast connection. + * + * 4. The client (our Web or mobile app) will connect using the "sender" + * Chromecast SDK. This will result in a bi-directional channel between us + * ("receiver") and the Ente client app ("sender"). + * + * 5. Thereafter, if at any time the sender disconnects, close the Chromecast + * context. This effectively shuts us down, causing the entire page to get + * reloaded. + * + * 6. After connecting, the sender sends an (empty) message. We reply by sending + * them a message containing the pairing code. This exchange is the only data + * that traverses over the Chromecast connection. + * + * 5. If at anytime the + * + * + * + * in our custom // "urn:x-cast:pair-request" namespace. over Chromecast + protocol is minimal: + * + * 1. Client (Web or mobile) sends an (empty) message in our custom // + "urn:x-cast:pair-request" namespace. + // + // 2. We reply with the device code. +*/ +export const listenForPairingRequest = async (cast: Cast) => { + // Generate keypair + const keypair = await generateKeyPair(); + const publicKeyB64 = await toB64(keypair.publicKey); + const privateKeyB64 = await toB64(keypair.privateKey); + + // Register keypair with museum to get a pairing code. + let code: string; + do { + try { + code = await castGateway.registerDevice(publicKeyB64); + } catch (e) { + log.error("Failed to register public key with server", e); + // Schedule retry after 10 seconds. + await wait(10000); + } + } while (code === undefined); + + // Listen for incoming messages sent via the Chromecast SDK + const context = cast.framework.CastReceiverContext.getInstance(); + const namespace = "urn:x-cast:pair-request"; + + const options = new cast.framework.CastReceiverOptions(); + // TODO(MR): Are any of these options required? + options.maxInactivity = 3600; + options.customNamespaces = Object.assign({}); + options.customNamespaces[namespace] = + cast.framework.system.MessageType.JSON; + options.disableIdleTimeout = true; + + // Reply with the code that we have if anyone asks over chromecast. + const incomingMessageListener = ({ senderId }: { senderId: string }) => + context.sendCustomMessage(namespace, senderId, { code }); + + context.addCustomMessageListener( + namespace, + // We need to cast, the `senderId` is present in the message we get but + // not present in the TypeScript type. + incomingMessageListener as unknown as SystemEventHandler, + ); + + // Shutdown ourselves if the "sender" disconnects. + context.addEventListener( + cast.framework.system.EventType.SENDER_DISCONNECTED, + () => context.stop(), + ); + + context.start(options); +}; + +const generateKeyPair = async () => { + await _sodium.ready; + return _sodium.crypto_box_keypair(); +}; diff --git a/web/apps/cast/src/utils/useCastReceiver.tsx b/web/apps/cast/src/utils/useCastReceiver.tsx index a2313fdd0..8cd958ec4 100644 --- a/web/apps/cast/src/utils/useCastReceiver.tsx +++ b/web/apps/cast/src/utils/useCastReceiver.tsx @@ -1,6 +1,8 @@ /// import { useEffect, useState } from "react"; +export type Cast = typeof cast; + /** * Load the Chromecast Web Receiver SDK and return a reference to the `cast` * global object that the SDK attaches to the window. @@ -8,7 +10,7 @@ import { useEffect, useState } from "react"; * https://developers.google.com/cast/docs/web_receiver/basic */ export const useCastReceiver = () => { - const [receiver, setReceiver] = useState(); + const [receiver, setReceiver] = useState(); useEffect(() => { const script = document.createElement("script");