diff --git a/.github/workflows/auth-lint.yml b/.github/workflows/auth-lint.yml index 6504e0646..63d644c2e 100644 --- a/.github/workflows/auth-lint.yml +++ b/.github/workflows/auth-lint.yml @@ -3,7 +3,7 @@ name: "Lint (auth)" on: # Run on every push to a branch other than main that changes auth/ push: - branches-ignore: [main, "deploy/**"] + branches-ignore: [main, "deploy/**", "deploy-f/**"] paths: - "auth/**" - ".github/workflows/auth-lint.yml" diff --git a/.github/workflows/desktop-lint.yml b/.github/workflows/desktop-lint.yml index 0b8263f3d..0c1929e6a 100644 --- a/.github/workflows/desktop-lint.yml +++ b/.github/workflows/desktop-lint.yml @@ -3,7 +3,7 @@ name: "Lint (desktop)" on: # Run on every push to a branch other than main that changes desktop/ push: - branches-ignore: [main, "deploy/**"] + branches-ignore: [main, "deploy/**", "deploy-f/**"] paths: - "desktop/**" - ".github/workflows/desktop-lint.yml" diff --git a/.github/workflows/docs-verify-build.yml b/.github/workflows/docs-verify-build.yml index a57f71c86..5d31ff837 100644 --- a/.github/workflows/docs-verify-build.yml +++ b/.github/workflows/docs-verify-build.yml @@ -6,7 +6,7 @@ name: "Verify build (docs)" on: # Run on every push to a branch other than main that changes docs/ push: - branches-ignore: [main, "deploy/**"] + branches-ignore: [main, "deploy/**", "deploy-f/**"] paths: - "docs/**" - ".github/workflows/docs-verify-build.yml" diff --git a/.github/workflows/mobile-lint.yml b/.github/workflows/mobile-lint.yml index 57b2ca4db..8abc6f0c7 100644 --- a/.github/workflows/mobile-lint.yml +++ b/.github/workflows/mobile-lint.yml @@ -3,7 +3,7 @@ name: "Lint (mobile)" on: # Run on every push to a branch other than main that changes mobile/ push: - branches-ignore: [main, f-droid, "deploy/**"] + branches-ignore: [main, f-droid, "deploy/**", "deploy-f/**"] paths: - "mobile/**" - ".github/workflows/mobile-lint.yml" diff --git a/.github/workflows/server-lint.yml b/.github/workflows/server-lint.yml index d25f2adcc..30038b3b9 100644 --- a/.github/workflows/server-lint.yml +++ b/.github/workflows/server-lint.yml @@ -3,7 +3,7 @@ name: "Lint (server)" on: # Run on every push to a branch other than main that changes server/ push: - branches-ignore: [main, "deploy/**"] + branches-ignore: [main, "deploy/**", "deploy-f/**"] paths: - "server/**" - ".github/workflows/server-lint.yml" diff --git a/.github/workflows/web-lint.yml b/.github/workflows/web-lint.yml index 0dc11aa0e..baf2a98ab 100644 --- a/.github/workflows/web-lint.yml +++ b/.github/workflows/web-lint.yml @@ -3,7 +3,7 @@ name: "Lint (web)" on: # Run on every push to a branch other than main that changes web/ push: - branches-ignore: [main, "deploy/**"] + branches-ignore: [main, "deploy/**", "deploy-f/**"] paths: - "web/**" - ".github/workflows/web-lint.yml" diff --git a/web/apps/cast/src/pages/index.tsx b/web/apps/cast/src/pages/index.tsx index bc0f6253d..175d75bbc 100644 --- a/web/apps/cast/src/pages/index.tsx +++ b/web/apps/cast/src/pages/index.tsx @@ -12,25 +12,28 @@ export default function Index() { const [privateKeyB64, setPrivateKeyB64] = useState(); const [pairingCode, setPairingCode] = useState(); + // Keep a boolean flag to ensure that Cast Receiver starts only once even if + // pairing codes change. + const [haveInitializedCast, setHaveInitializedCast] = useState(false); + const router = useRouter(); useEffect(() => { - init(); - }, []); - - const init = () => { - register().then((r) => { - setPublicKeyB64(r.publicKeyB64); - setPrivateKeyB64(r.privateKeyB64); - setPairingCode(r.pairingCode); - }); - }; - - useEffect(() => { - castReceiverLoadingIfNeeded().then((cast) => - advertiseCode(cast, () => pairingCode), - ); - }, []); + if (!pairingCode) { + register().then((r) => { + setPublicKeyB64(r.publicKeyB64); + setPrivateKeyB64(r.privateKeyB64); + setPairingCode(r.pairingCode); + }); + } else { + if (!haveInitializedCast) { + castReceiverLoadingIfNeeded().then((cast) => { + setHaveInitializedCast(true); + advertiseCode(cast, () => pairingCode); + }); + } + } + }, [pairingCode]); useEffect(() => { if (!publicKeyB64 || !privateKeyB64 || !pairingCode) return; @@ -52,10 +55,12 @@ export default function Index() { storeCastData(data); await router.push("/slideshow"); } catch (e) { - log.error("Failed to get cast data", e); - // Start again from the beginning. + // The pairing code becomes invalid after an hour, which will cause + // `getCastData` to fail. There might be other reasons this might + // fail too, but in all such cases, it is a reasonable idea to start + // again from the beginning. + log.warn("Failed to get cast data", e); setPairingCode(undefined); - init(); } }; diff --git a/web/apps/cast/src/pages/slideshow.tsx b/web/apps/cast/src/pages/slideshow.tsx index bd3339b42..98426a857 100644 --- a/web/apps/cast/src/pages/slideshow.tsx +++ b/web/apps/cast/src/pages/slideshow.tsx @@ -46,6 +46,8 @@ export default function Slideshow() { }; }, []); + console.log("Rendering slideshow", { loading, imageURL, nextImageURL }); + if (loading) return ; return ; diff --git a/web/apps/cast/src/services/cast.ts b/web/apps/cast/src/services/cast.ts index 8ead8962a..8cb99c241 100644 --- a/web/apps/cast/src/services/cast.ts +++ b/web/apps/cast/src/services/cast.ts @@ -84,26 +84,17 @@ export const renderableImageURLs = async function* (castData: CastData) { const { collectionKey, castToken } = castData; /** - * We have a sliding window of four URLs, with the `urls[1]` being the one - * that is the one currently being shown in the slideshow. - * - * At each step, we shift the window towards the right by shifting out the - * leftmost (oldest) `urls[0]`, and adding a new one at the end. - * - * We can revoke url[0] when we shift it out because we know it is not being - * used anymore. - * - * We need to special case the first two renders to avoid revoking the - * initial URLs that are displayed the first two times. This results in a - * memory leak of the very first objectURL that we display. + * Keep a FIFO queue of the URLs that we've vended out recently so that we + * can revoke those that are not being shown anymore. */ - const urls: string[] = [""]; - let i = 0; + const previousURLs: string[] = []; - /** - * Number of milliseconds to keep the slide on the screen. - */ + /** The URL pair that we will yield */ + const urls: string[] = []; + + /** Number of milliseconds to keep the slide on the screen. */ const slideDuration = 10000; /* 10 s */ + /** * Time when we last yielded. * @@ -137,16 +128,33 @@ export const renderableImageURLs = async function* (castData: CastData) { continue; } - if (urls.length < 4) continue; + // Need at least a pair. + // + // There are two scenarios: + // + // - First run: urls will initially be empty, so gobble two. + // + // - Subsequently, urls will have the "next" / "preloaded" URL left + // over from the last time. We'll promote that to being the one + // that'll get displayed, and preload another one. + if (urls.length < 2) continue; - const oldestURL = urls.shift(); - if (oldestURL && i !== 1) URL.revokeObjectURL(oldestURL); - i += 1; + // The last element of previousURLs is the URL that is currently + // being shown on screen. + // + // The last to last element is the one that was shown prior to that, + // and now can be safely revoked. + if (previousURLs.length > 1) + URL.revokeObjectURL(previousURLs.shift()); - const urlPair: RenderableImageURLPair = [ - ensure(urls[0]), - ensure(urls[1]), - ]; + // The URL that'll now get displayed on screen. + const url = ensure(urls.shift()); + // The URL that we're preloading for next time around. + const nextURL = ensure(urls[0]); + + previousURLs.push(url); + + const urlPair: RenderableImageURLPair = [url, nextURL]; const elapsedTime = Date.now() - lastYieldTime; if (elapsedTime > 0 && elapsedTime < slideDuration) @@ -299,6 +307,8 @@ const downloadFile = async (castToken: string, file: EnteFile) => { throw new Error("Can only cast images and live photos"); const url = getCastFileURL(file.id); + // TODO(MR): Remove if usused eventually + // const url = getCastThumbnailURL(file.id); const resp = await HTTPService.get( url, null, @@ -313,6 +323,8 @@ const downloadFile = async (castToken: string, file: EnteFile) => { const decrypted = await cryptoWorker.decryptFile( new Uint8Array(resp.data), await cryptoWorker.fromB64(file.file.decryptionHeader), + // TODO(MR): Remove if usused eventually + // await cryptoWorker.fromB64(file.thumbnail.decryptionHeader), file.key, ); return new Response(decrypted).blob(); diff --git a/web/apps/cast/src/services/pair.ts b/web/apps/cast/src/services/pair.ts index 66f9feddd..0364cf491 100644 --- a/web/apps/cast/src/services/pair.ts +++ b/web/apps/cast/src/services/pair.ts @@ -116,24 +116,68 @@ export const advertiseCode = ( const namespace = "urn:x-cast:pair-request"; const options = new cast.framework.CastReceiverOptions(); - // Do not automatically close the connection when the sender disconnects. - options.maxInactivity = 3600; /* 1 hour */ + // We don't use the media features of the Cast SDK. + options.skipPlayersLoad = true; // TODO:Is this required? The docs say "(The default type of a message bus // is JSON; if not provided here)." options.customNamespaces = Object.assign({}); options.customNamespaces[namespace] = cast.framework.system.MessageType.JSON; - // TODO: This looks like the only one needed, but a comment with the reason - // might be good. + // Do not stop the casting if the receiver is unreachable. A user should be + // able to start a cast on their phone and then put it away, leaving the + // cast running on their big screen. options.disableIdleTimeout = true; + // The collection ID with which we paired. If we get another connection + // request for a different collection ID, restart the app to allow them to + // reconnect using a freshly generated pairing code. + // + // If the request does not have a collectionID, forego this check. + let pairedCollectionID: string | undefined; + + type ListenerProps = { + senderId: string; + data: unknown; + }; + // Reply with the code that we have if anyone asks over Chromecast. - const incomingMessageListener = ({ senderId }: { senderId: string }) => { + const incomingMessageListener = ({ senderId, data }: ListenerProps) => { + const restart = (reason: string) => { + log.error(`Restarting app because ${reason}`); + // context.stop will close the tab but it'll get reopened again + // immediately since the client app will reconnect in the scenarios + // where we're calling this function. + context.stop(); + }; + + const collectionID = + data && + typeof data == "object" && + typeof data["collectionID"] == "string" + ? data["collectionID"] + : undefined; + + if (pairedCollectionID && pairedCollectionID != collectionID) { + restart(`incoming request for a new collection ${collectionID}`); + return; + } + + pairedCollectionID = collectionID; + const code = pairingCode(); if (!code) { - log.warn( - "Ignoring incoming Chromecast message because we do not yet have a pairing code", - ); + // Our caller waits until it has a pairing code before it calls + // `advertiseCode`, but there is still an edge case where we can + // find ourselves without a pairing code: + // + // 1. The current pairing code expires. We start the process to get + // a new one. + // + // 2. But before that happens, someone connects. + // + // The window where this can happen is short, so if we do find + // ourselves in this scenario, + restart("we got a pairing request when refreshing pairing codes"); return; } @@ -147,8 +191,10 @@ export const advertiseCode = ( incomingMessageListener as unknown as SystemEventHandler, ); - // Shutdown ourselves if the sender disconnects. - // TODO(MR): I assume the page reloads on shutdown. Is that correct? + // Close the (chromecast) tab if the sender disconnects. + // + // Chromecast does a "shutdown" of our cast app when we call `context.stop`. + // This translates into it closing the tab where it is showing our app. context.addEventListener( cast.framework.system.EventType.SENDER_DISCONNECTED, () => context.stop(),