diff --git a/web/apps/cast/src/pages/index.tsx b/web/apps/cast/src/pages/index.tsx index 595aed40f..88b7b5582 100644 --- a/web/apps/cast/src/pages/index.tsx +++ b/web/apps/cast/src/pages/index.tsx @@ -1,164 +1,65 @@ +import log from "@/next/log"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import LargeType from "components/LargeType"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; -import { pair, register, type Registration } from "services/cast"; +import { advertiseCode, getCastData, register } from "services/cast"; import { storeCastData } from "services/cast/castService"; import { useCastReceiver } from "../utils/useCastReceiver"; export default function PairingMode() { - const [registration, setRegistration] = useState< - Registration | undefined - >(); + const [publicKeyB64, setPublicKeyB64] = useState(); + const [privateKeyB64, setPrivateKeyB64] = useState(); + const [pairingCode, setPairingCode] = useState(); + + // The returned cast object is a reference to a global instance and can be + // used in a useEffect dependency list. const cast = useCastReceiver(); + const router = useRouter(); - // useEffect(() => { - // init(); - // }, []); - - // const init = async () => { - // try { - // const keypair = await generateKeyPair(); - // setPublicKeyB64(await toB64(keypair.publicKey)); - // setPrivateKeyB64(await toB64(keypair.privateKey)); - // } catch (e) { - // log.error("failed to generate keypair", e); - // throw e; - // } - // }; - - // useEffect(() => { - // if (!cast) { - // return; - // } - // if (isCastReady) { - // return; - // } - // const context = cast.framework.CastReceiverContext.getInstance(); - - // try { - // const options = new cast.framework.CastReceiverOptions(); - // options.maxInactivity = 3600; - // options.customNamespaces = Object.assign({}); - // options.customNamespaces["urn:x-cast:pair-request"] = - // cast.framework.system.MessageType.JSON; - - // options.disableIdleTimeout = true; - // context.set; - - // context.addCustomMessageListener( - // "urn:x-cast:pair-request", - // messageReceiveHandler, - // ); - - // // listen to close request and stop the context - // context.addEventListener( - // cast.framework.system.EventType.SENDER_DISCONNECTED, - // // eslint-disable-next-line @typescript-eslint/no-unused-vars - // (_) => { - // context.stop(); - // }, - // ); - // context.start(options); - // setIsCastReady(true); - // } catch (e) { - // log.error("failed to create cast context", e); - // } - - // return () => { - // // context.stop(); - // }; - // }, [cast]); - - // const messageReceiveHandler = (message: { - // type: string; - // senderId: string; - // data: any; - // }) => { - // try { - // cast.framework.CastReceiverContext.getInstance().sendCustomMessage( - // "urn:x-cast:pair-request", - // message.senderId, - // { - // code: deviceCode, - // }, - // ); - // } catch (e) { - // log.error("failed to send message", e); - // } - // }; - - // const generateKeyPair = async () => { - // await _sodium.ready; - // const keypair = _sodium.crypto_box_keypair(); - // return keypair; - // }; - - // const pollForCastData = async () => { - // if (codePending) { - // return; - // } - // // see if we were acknowledged on the client. - // // the client will send us the encrypted payload using our public key that we advertised. - // // then, we can decrypt this and store all the necessary info locally so we can play the collection slideshow. - // let devicePayload = ""; - // try { - // const encDastData = await castGateway.getCastData(`${deviceCode}`); - // if (!encDastData) return; - // devicePayload = encDastData; - // } catch (e) { - // setCodePending(true); - // init(); - // return; - // } - - // const decryptedPayload = await boxSealOpen( - // devicePayload, - // publicKeyB64, - // privateKeyB64, - // ); - - // const decryptedPayloadObj = JSON.parse(atob(decryptedPayload)); - - // return decryptedPayloadObj; - // }; - - // const advertisePublicKey = async (publicKeyB64: string) => { - // // hey client, we exist! - // try { - // const codeValue = await castGateway.registerDevice(publicKeyB64); - // setDeviceCode(codeValue); - // setCodePending(false); - // } catch (e) { - // // schedule re-try after 5 seconds - // setTimeout(() => { - // init(); - // }, 5000); - // return; - // } - // }; + const init = () => { + register().then((r) => { + setPublicKeyB64(r.publicKeyB64); + setPrivateKeyB64(r.privateKeyB64); + setPairingCode(r.pairingCode); + }); + }; useEffect(() => { - register().then((r) => setRegistration(r)); + init(); }, []); useEffect(() => { - if (!cast || !registration) return; + if (cast) advertiseCode(cast, () => pairingCode); + }, [cast]); - pair(cast, registration).then((data) => { + const pollTick = async () => { + const registration = { publicKeyB64, privateKeyB64, pairingCode }; + try { + const data = await getCastData(registration); + if (!data) { + // No one has connected yet + return; + } + + log.info("Pairing complete"); storeCastData(data); - router.push("/slideshow"); - }); - }, [cast, registration]); + await router.push("/slideshow"); + } catch (e) { + console.log("Failed to get cast data", e); + // Start again from the beginning + setPairingCode(undefined); + init(); + } + }; - // console.log([cast, registration]); - // useEffect(() => { - // if (!publicKeyB64) return; - // advertisePublicKey(publicKeyB64); - // }, [publicKeyB64]); + useEffect(() => { + if (!publicKeyB64 || !privateKeyB64 || !pairingCode) return; - const { pairingCode } = registration ?? {}; + const interval = setInterval(pollTick, 2000); + return () => clearInterval(interval); + }, [publicKeyB64, privateKeyB64, pairingCode]); return ( <> diff --git a/web/apps/cast/src/services/cast.ts b/web/apps/cast/src/services/cast.ts index 6ee9347e2..db41e707e 100644 --- a/web/apps/cast/src/services/cast.ts +++ b/web/apps/cast/src/services/cast.ts @@ -1,5 +1,5 @@ -/* eslint has already fixed this warning, we don't have the latest version yet - https://github.com/eslint/eslint/pull/18286 */ +// eslint has already fixed this warning, we don't have the latest version yet +// https://github.com/eslint/eslint/pull/18286 /* eslint-disable no-constant-condition */ import log from "@/next/log"; @@ -30,21 +30,19 @@ export interface Registration { * * The pairing happens in two phases: * - * Phase 1 + * Phase 1 - {@link register} * * 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. Show this on the screen. * - * Phase 2 + * Phase 2 - {@link advertiseCode} * * There are two ways the client can connect - either by sending us a blank * message over the Chromecast protocol (to which we'll reply with the pairing * code), or by the user manually entering the pairing code on their screen. * - * So there are two parallel processes. - * * 3. Listen for incoming messages over the Chromecast connection. * * 4. The client (our Web or mobile app) will connect using the "sender" @@ -60,11 +58,12 @@ export interface Registration { * that traverses over the Chromecast connection. * * Once the client gets the pairing code (via Chromecast or manual entry), - * they'll let museum know. So + * they'll let museum know. So in parallel with Phase 2, we perform Phase 3. * - * 7. In parallel, keep polling museum to ask it if anyone has claimed that code - * we vended out and used that to send us an payload encrypted using our - * public key. + * Phase 3 - {@link getCastData} in a setInterval. + * + * 7. Keep polling museum to ask it if anyone has claimed that code we vended + * out and used that to send us an payload encrypted using our public key. * * 8. When that happens, decrypt that data with our private key, and return this * payload. It is a JSON object that contains the data we need to initiate a @@ -73,9 +72,11 @@ export interface Registration { * Phase 1 (Steps 1 and 2) are done by the {@link register} function, which * returns a {@link Registration}. * - * At this time we start showing the pairing code on the UI, and proceed with - * the remaining steps (Phase 2) using the {@link pair} function that returns - * the data we need to start the slideshow. + * At this time we start showing the pairing code on the UI, and start phase 2, + * {@link advertiseCode} to vend out the pairing code to Chromecast connections. + * + * In parallel, we start Phase 3, calling {@link getCastData} in a loop. Once we + * get a response, we decrypt it to get the data we need to start the slideshow. */ export const register = async (): Promise => { // Generate keypair. @@ -100,15 +101,16 @@ export const register = async (): Promise => { }; /** - * Listen for pairing requests using the given {@link cast} instance for - * connections for {@link registration}. Phase 2 of the pairing protocol. + * Listen for incoming messages on the given {@link cast} receiver, replying to + * each of them with a pairing code obtained using the given {@link pairingCode} + * callback. Phase 2 of the pairing protocol. * - * On successful pairing, return the payload (JSON) sent by the sender who - * connected to us. See: [Note: Pairing protocol] + * See: [Note: Pairing protocol]. */ -export const pair = async (cast: Cast, registration: Registration) => { - const { pairingCode, publicKeyB64, privateKeyB64 } = registration; - +export const advertiseCode = ( + cast: Cast, + pairingCode: () => string | undefined, +) => { // Prepare the Chromecast "context". const context = cast.framework.CastReceiverContext.getInstance(); const namespace = "urn:x-cast:pair-request"; @@ -121,9 +123,18 @@ export const pair = async (cast: Cast, registration: Registration) => { 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: pairingCode }); + // Reply with the code that we have if anyone asks over Chromecast. + const incomingMessageListener = ({ senderId }: { senderId: string }) => { + const code = pairingCode(); + if (!code) { + log.warn( + "Ignoring incoming Chromecast message because we do not yet have a pairing code", + ); + return; + } + + context.sendCustomMessage(namespace, senderId, { code }); + }; context.addCustomMessageListener( namespace, @@ -141,26 +152,25 @@ export const pair = async (cast: Cast, registration: Registration) => { // Start listening for Chromecast connections. context.start(options); +}; - // Start polling museum. - let encryptedCastData: string | undefined | null; - while (true) { - // The client will send us the encrypted payload using our public key - // that we registered with museum. Then, we can decrypt this using the - // private key of the pair and return the plaintext payload, which'll be - // a JSON object containing the data we need to start a slideshow for - // some collection. - try { - encryptedCastData = await castGateway.getCastData(pairingCode); - } catch (e) { - log.error("Failed to get cast data from server", e); - } - if (encryptedCastData) break; - // Nobody's claimed the code yet (or there was some error). Poll again - // after 2 seconds. - await wait(2000); - } +/** + * Ask museum if anyone has sent a (encrypted) payload corresponding to the + * given pairing code. If so, decrypt it using our private key and return the + * JSON payload. Phase 3 of the pairing protocol. + * + * See: [Note: Pairing protocol]. + */ +export const getCastData = async (registration: Registration) => { + const { pairingCode, publicKeyB64, privateKeyB64 } = registration; + // The client will send us the encrypted payload using our public key that + // we registered with museum. + const encryptedCastData = await castGateway.getCastData(pairingCode); + + // Decrypt it using the private key of the pair and return the plaintext + // payload, which'll be a JSON object containing the data we need to start a + // slideshow for some collection. const decryptedCastData = await boxSealOpen( encryptedCastData, publicKeyB64,