diff --git a/web/apps/cast/src/pages/index.tsx b/web/apps/cast/src/pages/index.tsx index ad031f863b..2a49e3d43f 100644 --- a/web/apps/cast/src/pages/index.tsx +++ b/web/apps/cast/src/pages/index.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; import { readCastData, storeCastData } from "services/cast-data"; import { getCastData, register } from "services/pair"; -import { advertiseOnChromecast } from "../services/chromecast"; +import { advertiseOnChromecast } from "../services/chromecast-receiver"; export default function Index() { const [publicKeyB64, setPublicKeyB64] = useState(); diff --git a/web/apps/cast/src/pages/slideshow.tsx b/web/apps/cast/src/pages/slideshow.tsx index 7dba6c8fd5..1d206867c4 100644 --- a/web/apps/cast/src/pages/slideshow.tsx +++ b/web/apps/cast/src/pages/slideshow.tsx @@ -5,7 +5,7 @@ import { FilledCircleCheck } from "components/FilledCircleCheck"; import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; import { readCastData } from "services/cast-data"; -import { isChromecast } from "services/chromecast"; +import { isChromecast } from "services/chromecast-receiver"; import { imageURLGenerator } from "services/render"; export default function Slideshow() { diff --git a/web/apps/cast/src/services/chromecast.ts b/web/apps/cast/src/services/chromecast-receiver.ts similarity index 100% rename from web/apps/cast/src/services/chromecast.ts rename to web/apps/cast/src/services/chromecast-receiver.ts diff --git a/web/apps/cast/src/services/render.ts b/web/apps/cast/src/services/render.ts index b19e41bfe1..21c9925a03 100644 --- a/web/apps/cast/src/services/render.ts +++ b/web/apps/cast/src/services/render.ts @@ -28,7 +28,7 @@ import HTTPService from "@ente/shared/network/HTTPService"; import type { AxiosResponse } from "axios"; import type { CastData } from "services/cast-data"; import { detectMediaMIMEType } from "services/detect-type"; -import { isChromecast } from "./chromecast"; +import { isChromecast } from "./chromecast-receiver"; /** * An async generator function that loops through all the files in the diff --git a/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx b/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx index 2683cc3ee0..138c3042b4 100644 --- a/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx +++ b/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx @@ -1,52 +1,47 @@ import { boxSeal } from "@/base/crypto/libsodium"; import log from "@/base/log"; import type { Collection } from "@/media/collection"; -import { VerticallyCentered } from "@ente/shared/components/Container"; +import { loadCast } from "@/new/photos/utils/chromecast-sender"; import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; -import EnteButton from "@ente/shared/components/EnteButton"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import SingleInputForm, { type SingleInputFormProps, } from "@ente/shared/components/SingleInputForm"; import castGateway from "@ente/shared/network/cast"; -import { Link, Typography } from "@mui/material"; +import { Button, Link, Stack, Typography } from "@mui/material"; import { t } from "i18next"; import { useEffect, useState } from "react"; import { Trans } from "react-i18next"; import { v4 as uuidv4 } from "uuid"; -import { loadSender } from "../../utils/useCastSender"; -interface Props { - show: boolean; - onHide: () => void; - currentCollection: Collection; +interface AlbumCastDialogProps { + /** If `true`, the dialog is shown. */ + open: boolean; + /** Callback fired when the dialog wants to be closed. */ + onClose: () => void; + /** The collection that we want to cast. */ + collection: Collection; } -enum AlbumCastError { - TV_NOT_FOUND = "tv_not_found", -} - -declare global { - interface Window { - chrome: any; - } -} - -export default function AlbumCastDialog({ - show, - onHide, - currentCollection, -}: Props) { +/** + * A dialog that shows various options that the user has for casting an album. + */ +export const AlbumCastDialog: React.FC = ({ + open, + onClose, + collection, +}) => { const [view, setView] = useState< "choose" | "auto" | "pin" | "auto-cast-error" >("choose"); const [browserCanCast, setBrowserCanCast] = useState(false); - // Make API call on component mount + + // Make API call to clear all previous sessions on component mount. useEffect(() => { castGateway.revokeAllTokens(); - setBrowserCanCast(!!window.chrome); + setBrowserCanCast(typeof window["chrome"] !== "undefined"); }, []); const onSubmit: SingleInputFormProps["callback"] = async ( @@ -55,55 +50,47 @@ export default function AlbumCastDialog({ ) => { try { await doCast(value.trim()); - onHide(); + onClose(); } catch (e) { - const error = e as Error; - let fieldError: string; - switch (error.message) { - case AlbumCastError.TV_NOT_FOUND: - fieldError = t("tv_not_found"); - break; - default: - fieldError = t("UNKNOWN_ERROR"); - break; + if (e instanceof Error && e.message == "tv-not-found") { + setFieldError(t("tv_not_found")); + } else { + setFieldError(t("UNKNOWN_ERROR")); } - - setFieldError(fieldError); } }; const doCast = async (pin: string) => { - // does the TV exist? have they advertised their existence? + // Does the TV exist? have they advertised their existence? const tvPublicKeyB64 = await castGateway.getPublicKey(pin); if (!tvPublicKeyB64) { - throw new Error(AlbumCastError.TV_NOT_FOUND); + throw new Error("tv-not-found"); } - // generate random uuid string + + // Generate random id. const castToken = uuidv4(); - // ok, they exist. let's give them the good stuff. + // Ok, they exist. let's give them the good stuff. const payload = JSON.stringify({ castToken: castToken, - collectionID: currentCollection.id, - collectionKey: currentCollection.key, + collectionID: collection.id, + collectionKey: collection.key, }); const encryptedPayload = await boxSeal(btoa(payload), tvPublicKeyB64); - // hey TV, we acknowlege you! + // Hey TV, we acknowlege you! await castGateway.publishCastPayload( pin, encryptedPayload, - currentCollection.id, + collection.id, castToken, ); }; useEffect(() => { if (view === "auto") { - loadSender().then(async (sender) => { - const { cast } = sender; - - const instance = await cast.framework.CastContext.getInstance(); + loadCast().then(async (cast) => { + const instance = cast.framework.CastContext.getInstance(); try { await instance.requestSession(); } catch (e) { @@ -123,105 +110,80 @@ export default function AlbumCastDialog({ doCast(code) .then(() => { setView("choose"); - onHide(); + onClose(); }) .catch((e) => { - setView("auto-cast-error"); log.error("Error casting to TV", e); + setView("auto-cast-error"); }); } }, ); - const collectionID = currentCollection.id; + const collectionID = collection.id; session .sendMessage("urn:x-cast:pair-request", { collectionID }) .then(() => { - log.debug(() => "Message sent successfully"); - }) - .catch((e) => { - log.error("Error sending message", e); + log.debug(() => "urn:x-cast:pair-request sent"); }); }); } }, [view]); useEffect(() => { - if (show) { - castGateway.revokeAllTokens(); - } - }, [show]); + if (open) castGateway.revokeAllTokens(); + }, [open]); return ( - {view === "choose" && ( - <> + {view == "choose" && ( + {browserCanCast && ( - <> + {t("cast_auto_pair_description")} - { - setView("auto"); - }} - > + + )} - - {t("pair_with_pin_description")} - - - { - setView("pin"); - }} - > - {t("pair_with_pin")} - - + + + {t("pair_with_pin_description")} + + + + )} - {view === "auto" && ( - - + {view == "auto" && ( + +
+ +
{t("choose_device_from_browser")} - { - setView("choose"); - }} - > + +
)} - {view === "auto-cast-error" && ( - + {view == "auto-cast-error" && ( + {t("cast_auto_pair_failed")} - { - setView("choose"); - }} - > + + )} - {view === "pin" && ( + {view == "pin" && ( <> - { - setView("choose"); - }} - > + )}
); -} +}; diff --git a/web/apps/photos/src/components/Collections/index.tsx b/web/apps/photos/src/components/Collections/index.tsx index b81e9fa642..c838c7373e 100644 --- a/web/apps/photos/src/components/Collections/index.tsx +++ b/web/apps/photos/src/components/Collections/index.tsx @@ -24,7 +24,7 @@ import { isFilesDownloadCancelled, isFilesDownloadCompleted, } from "../FilesDownloadProgress"; -import AlbumCastDialog from "./AlbumCastDialog"; +import { AlbumCastDialog } from "./AlbumCastDialog"; /** * Specifies what the bar is displaying currently. @@ -68,11 +68,11 @@ export const Collections: React.FC = ({ filesDownloadProgressAttributesList, setFilesDownloadProgressAttributesCreator, }) => { - const [allCollectionView, setAllCollectionView] = useState(false); - const [collectionShareModalView, setCollectionShareModalView] = + const [openAllCollectionDialog, setOpenAllCollectionDialog] = useState(false); - - const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(false); + const [openCollectionShareView, setOpenCollectionShareView] = + useState(false); + const [openAlbumCastDialog, setOpenAlbumCastDialog] = useState(false); const [collectionListSortBy, setCollectionListSortBy] = useLocalState( @@ -128,7 +128,7 @@ export const Collections: React.FC = ({ activeCollection={activeCollection} setCollectionNamerAttributes={setCollectionNamerAttributes} showCollectionShareModal={() => - setCollectionShareModalView(true) + setOpenCollectionShareView(true) } setFilesDownloadProgressAttributesCreator={ setFilesDownloadProgressAttributesCreator @@ -137,7 +137,7 @@ export const Collections: React.FC = ({ isActiveCollectionDownloadInProgress } setActiveCollectionID={setActiveCollectionID} - setShowAlbumCastDialog={setShowAlbumCastDialog} + setShowAlbumCastDialog={setOpenAlbumCastDialog} /> ), itemType: ITEM_TYPE.HEADER, @@ -157,11 +157,6 @@ export const Collections: React.FC = ({ return <>; } - const closeAllCollections = () => setAllCollectionView(false); - const openAllCollections = () => setAllCollectionView(true); - const closeCollectionShare = () => setCollectionShareModalView(false); - const closeAlbumCastDialog = () => setShowAlbumCastDialog(false); - return ( <> = ({ collectionListSortBy, setCollectionListSortBy, }} - onShowAllCollections={openAllCollections} + onShowAllCollections={() => setOpenAllCollectionDialog(true)} collectionSummaries={sortedCollectionSummaries.filter((x) => shouldBeShownOnCollectionBar(x.type), )} /> setOpenAllCollectionDialog(false)} collectionSummaries={sortedCollectionSummaries.filter( (x) => !isSystemCollection(x.type), )} @@ -193,19 +188,18 @@ export const Collections: React.FC = ({ collectionListSortBy={collectionListSortBy} isInHiddenSection={mode == "hidden-albums"} /> - setOpenCollectionShareView(false)} collection={activeCollection} /> setOpenAlbumCastDialog(false)} + collection={activeCollection} /> ); diff --git a/web/apps/photos/src/utils/useCastSender.tsx b/web/apps/photos/src/utils/useCastSender.tsx deleted file mode 100644 index 0f3c6a316b..0000000000 --- a/web/apps/photos/src/utils/useCastSender.tsx +++ /dev/null @@ -1,62 +0,0 @@ -declare const chrome: any; -declare const cast: any; - -declare global { - interface Window { - __onGCastApiAvailable: (isAvailable: boolean) => void; - } -} - -import { useEffect, useState } from "react"; - -type Sender = { - chrome: typeof chrome; - cast: typeof cast; -}; - -export const loadSender = (() => { - let promise: Promise | null = null; - - return () => { - if (promise === null) { - promise = new Promise((resolve) => { - const script = document.createElement("script"); - script.src = - "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"; - window.__onGCastApiAvailable = (isAvailable) => { - if (isAvailable) { - cast.framework.CastContext.getInstance().setOptions({ - receiverApplicationId: "F5BCEC64", - autoJoinPolicy: - chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, - }); - - resolve({ - chrome, - cast, - }); - } - }; - document.body.appendChild(script); - }); - } - return promise; - }; -})(); - -export const useCastSender = () => { - const [sender, setSender] = useState( - { - chrome: null, - cast: null, - }, - ); - - useEffect(() => { - loadSender().then((sender) => { - setSender(sender); - }); - }, []); - - return sender; -}; diff --git a/web/docs/webauthn-passkeys.md b/web/docs/webauthn-passkeys.md index 853a31145e..47b1559629 100644 --- a/web/docs/webauthn-passkeys.md +++ b/web/docs/webauthn-passkeys.md @@ -115,10 +115,10 @@ func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { ##### Response body (JSON) -| Key | Type | Value | -| --------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| options | object | The credential creation options that will be provided to the browser. | -| sessionID | string (uuidv4) | The identifier the server uses to persist metadata about the registration ceremony, like the user ID and challenge to prevent replay attacks. | +| Key | Type | Value | +| --------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| options | object | The credential creation options that will be provided to the browser. | +| sessionID | string (uuid) | The identifier the server uses to persist metadata about the registration ceremony, like the user ID and challenge to prevent replay attacks. | ```json { diff --git a/web/packages/base/crypto/libsodium.ts b/web/packages/base/crypto/libsodium.ts index 225b5efe72..ff470b1c9e 100644 --- a/web/packages/base/crypto/libsodium.ts +++ b/web/packages/base/crypto/libsodium.ts @@ -682,6 +682,12 @@ export async function boxSealOpen( ); } +/** + * Encrypt the given {@link input} using the given {@link publicKey}. + * + * This function performs asymmetric (public-key) encryption. To decrypt the + * result, use {@link boxSealOpen}. + */ export async function boxSeal(input: string, publicKey: string) { await sodium.ready; return await toB64( diff --git a/web/packages/new/photos/utils/chromecast-sender.ts b/web/packages/new/photos/utils/chromecast-sender.ts new file mode 100644 index 0000000000..8b5f4e93df --- /dev/null +++ b/web/packages/new/photos/utils/chromecast-sender.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +/** + * The types for the sender are already available as + * "@types/chromecast-caf-sender", however installing them breaks the types for + * the cast receiver in apps/cast. Vice-versa, having those types for the + * receiver ("@types/chromecast-caf-receiver") conflicts with the types that we + * add for the sender. + * + * As a workaround, this file includes the handpicked interface from + * "@types/chromecast-caf-sender" for only the parts that we use. + */ + +declare global { + interface Window { + cast: typeof cast; + __onGCastApiAvailable(available: boolean, reason?: string): void; + } +} + +declare namespace chrome.cast { + /** + * @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.AutoJoinPolicy + */ + export enum AutoJoinPolicy { + ORIGIN_SCOPED = "origin_scoped", + } +} + +/** + * Cast Application Framework + * @see https://developers.google.com/cast/docs/reference/chrome/cast.framework + */ +declare namespace cast.framework { + interface CastOptions { + autoJoinPolicy: chrome.cast.AutoJoinPolicy; + receiverApplicationId?: string | undefined; + } + + class CastContext { + static getInstance(): CastContext; + setOptions(options: CastOptions): void; + requestSession(): Promise; + getCurrentSession(): CastSession | null; + } + + class CastSession { + sendMessage(namespace: string, data: unknown): Promise; + addMessageListener( + namespace: string, + listener: (namespace: string, message: string) => void, + ): void; + } +} + +/** + * Load the Chromecast script, resolving with the global `cast` object. + */ +export const loadCast = (() => { + let promise: Promise | undefined; + + return () => { + if (promise === undefined) { + promise = new Promise((resolve) => { + const script = document.createElement("script"); + script.src = + "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"; + window.__onGCastApiAvailable = (isAvailable) => { + if (isAvailable) { + cast.framework.CastContext.getInstance().setOptions({ + receiverApplicationId: "F5BCEC64", + autoJoinPolicy: + chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, + }); + + resolve(cast); + } + }; + document.body.appendChild(script); + }); + } + return promise; + }; +})();