diff --git a/.vscode/settings.json b/.vscode/settings.json
index 074e180a5..74739151b 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -13,5 +13,8 @@
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
- "typescript.enablePromptUseWorkspaceTsdk": true
+ "typescript.enablePromptUseWorkspaceTsdk": true,
+ "[typescript]": {
+ "editor.defaultFormatter": "vscode.typescript-language-features"
+ }
}
diff --git a/apps/cast/.eslintrc.js b/apps/cast/.eslintrc.js
new file mode 100644
index 000000000..60466d6e1
--- /dev/null
+++ b/apps/cast/.eslintrc.js
@@ -0,0 +1,13 @@
+module.exports = {
+ // When root is set to true, ESLint will stop looking for configuration files in parent directories.
+ // This is required here to ensure desktop picks the right eslint config, where this app is
+ // packaged as a submodule.
+ root: true,
+ extends: ['@ente/eslint-config'],
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ tsconfigRootDir: __dirname,
+ project: './tsconfig.json',
+ },
+ ignorePatterns: ['.eslintrc.js'],
+};
diff --git a/apps/cast/.gitignore b/apps/cast/.gitignore
new file mode 100644
index 000000000..fd3dbb571
--- /dev/null
+++ b/apps/cast/.gitignore
@@ -0,0 +1,36 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/apps/cast/README.md b/apps/cast/README.md
new file mode 100644
index 000000000..a75ac5248
--- /dev/null
+++ b/apps/cast/README.md
@@ -0,0 +1,40 @@
+This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
+
+[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
+
+The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
+
+This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
diff --git a/apps/cast/configUtil.js b/apps/cast/configUtil.js
new file mode 100644
index 000000000..795cf15dc
--- /dev/null
+++ b/apps/cast/configUtil.js
@@ -0,0 +1,53 @@
+const cp = require('child_process');
+const { getIsSentryEnabled } = require('./sentryConfigUtil');
+
+module.exports = {
+ WEB_SECURITY_HEADERS: {
+ 'Strict-Transport-Security': ' max-age=63072000',
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Download-Options': 'noopen',
+ 'X-Frame-Options': 'deny',
+ 'X-XSS-Protection': '1; mode=block',
+ 'Referrer-Policy': 'same-origin',
+ },
+
+ CSP_DIRECTIVES: {
+ // self is safe enough
+ 'default-src': "'self'",
+ // data to allow two factor qr code
+ 'img-src': "'self' blob: data: https://*.openstreetmap.org",
+ 'media-src': "'self' blob:",
+ 'manifest-src': "'self'",
+ 'style-src': "'self' 'unsafe-inline'",
+ 'font-src ': "'self'; script-src 'self' 'unsafe-eval' blob:",
+ 'connect-src':
+ "'self' https://*.ente.io http://localhost:8080 data: blob: https://ente-prod-eu.s3.eu-central-003.backblazeb2.com https://ente-prod-v3.s3.eu-central-2.wasabisys.com/ https://ente-staging-eu.s3.eu-central-003.backblazeb2.com/ ws://localhost:3000/",
+ 'base-uri ': "'self'",
+ // to allow worker
+ 'child-src': "'self' blob:",
+ 'object-src': "'none'",
+ 'frame-ancestors': " 'none'",
+ 'form-action': "'none'",
+ 'report-uri': ' https://csp-reporter.ente.io/local',
+ 'report-to': ' https://csp-reporter.ente.io/local',
+ 'script-src-elem': "'self' https://www.gstatic.com",
+ },
+
+ ALL_ROUTES: '/(.*)',
+
+ buildCSPHeader: (directives) => ({
+ 'Content-Security-Policy-Report-Only': Object.entries(
+ directives
+ ).reduce((acc, [key, value]) => acc + `${key} ${value};`, ''),
+ }),
+
+ convertToNextHeaderFormat: (headers) =>
+ Object.entries(headers).map(([key, value]) => ({ key, value })),
+
+ getGitSha: () =>
+ cp.execSync('git rev-parse --short HEAD', {
+ cwd: __dirname,
+ encoding: 'utf8',
+ }),
+ getIsSentryEnabled: getIsSentryEnabled,
+};
diff --git a/apps/cast/next.config.js b/apps/cast/next.config.js
new file mode 100644
index 000000000..d09487bad
--- /dev/null
+++ b/apps/cast/next.config.js
@@ -0,0 +1,3 @@
+const nextConfigBase = require('@ente/shared/next/next.config.base.js');
+
+module.exports = nextConfigBase;
diff --git a/apps/cast/package.json b/apps/cast/package.json
new file mode 100644
index 000000000..ef2d8b7fa
--- /dev/null
+++ b/apps/cast/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "cast",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "export": "next export"
+ },
+ "dependencies": {
+ "mime-types": "^2.1.35",
+ "jszip": "3.10.1"
+ },
+ "devDependencies": {
+ "sass": "^1.69.5"
+ }
+}
diff --git a/apps/cast/public/favicon.ico b/apps/cast/public/favicon.ico
new file mode 100644
index 000000000..4570eb8d9
Binary files /dev/null and b/apps/cast/public/favicon.ico differ
diff --git a/apps/cast/public/images/ente.svg b/apps/cast/public/images/ente.svg
new file mode 100644
index 000000000..33bd74256
--- /dev/null
+++ b/apps/cast/public/images/ente.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/cast/public/images/help-qrcode.webp b/apps/cast/public/images/help-qrcode.webp
new file mode 100644
index 000000000..79cd22c99
Binary files /dev/null and b/apps/cast/public/images/help-qrcode.webp differ
diff --git a/apps/cast/sentry.client.config.js b/apps/cast/sentry.client.config.js
new file mode 100644
index 000000000..f07284c49
--- /dev/null
+++ b/apps/cast/sentry.client.config.js
@@ -0,0 +1,2 @@
+import { setupSentry } from '@ente/shared/sentry/config/sentry.config.base';
+setupSentry('https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2');
diff --git a/apps/cast/sentry.properties b/apps/cast/sentry.properties
new file mode 100644
index 000000000..3bcfe48bc
--- /dev/null
+++ b/apps/cast/sentry.properties
@@ -0,0 +1,3 @@
+defaults.url=https://sentry.ente.io/
+defaults.org=ente
+defaults.project=photos-web
diff --git a/apps/cast/sentry.server.config.js b/apps/cast/sentry.server.config.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/cast/sentryConfigUtil.js b/apps/cast/sentryConfigUtil.js
new file mode 100644
index 000000000..84d4a7628
--- /dev/null
+++ b/apps/cast/sentryConfigUtil.js
@@ -0,0 +1,11 @@
+const ENV_DEVELOPMENT = 'development';
+
+module.exports.getIsSentryEnabled = () => {
+ if (process.env.NEXT_PUBLIC_SENTRY_ENV === ENV_DEVELOPMENT) {
+ return false;
+ } else if (process.env.NEXT_PUBLIC_DISABLE_SENTRY === 'true') {
+ return false;
+ } else {
+ return true;
+ }
+};
diff --git a/apps/cast/src/components/FilledCircleCheck/FilledCircleCheck.module.scss b/apps/cast/src/components/FilledCircleCheck/FilledCircleCheck.module.scss
new file mode 100644
index 000000000..535a2448a
--- /dev/null
+++ b/apps/cast/src/components/FilledCircleCheck/FilledCircleCheck.module.scss
@@ -0,0 +1,51 @@
+.circle {
+ width: 100px;
+ height: 100px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 50%;
+ overflow: hidden;
+
+ &.animate {
+ animation: scaleIn 0.3s ease-in-out forwards;
+ }
+}
+
+@keyframes scaleIn {
+ 0% {
+ transform: scale(0);
+ }
+ 50% {
+ transform: scale(1.1);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+.checkmark {
+ width: 100px;
+ height: 100px;
+
+ &__circle {
+ fill: green;
+ }
+
+ &__check {
+ transform-origin: 50% 50%;
+ stroke-dasharray: 48;
+ stroke-dashoffset: 48;
+ animation: strokeCheck 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
+ stroke: white;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
+}
+
+@keyframes strokeCheck {
+ 100% {
+ stroke-dashoffset: 0;
+ }
+}
diff --git a/apps/cast/src/components/FilledCircleCheck/index.tsx b/apps/cast/src/components/FilledCircleCheck/index.tsx
new file mode 100644
index 000000000..e70f1e336
--- /dev/null
+++ b/apps/cast/src/components/FilledCircleCheck/index.tsx
@@ -0,0 +1,34 @@
+import { useEffect, useState } from 'react';
+import styles from './FilledCircleCheck.module.scss'; // Import our CSS module
+
+const FilledCircleCheck = () => {
+ const [animate, setAnimate] = useState(false);
+
+ useEffect(() => {
+ setAnimate(true);
+ }, []);
+
+ return (
+
+ );
+};
+
+export default FilledCircleCheck;
diff --git a/apps/cast/src/components/LargeType.tsx b/apps/cast/src/components/LargeType.tsx
new file mode 100644
index 000000000..b2a2eb994
--- /dev/null
+++ b/apps/cast/src/components/LargeType.tsx
@@ -0,0 +1,62 @@
+const colourPool = [
+ '#87CEFA', // Light Blue
+ '#90EE90', // Light Green
+ '#F08080', // Light Coral
+ '#FFFFE0', // Light Yellow
+ '#FFB6C1', // Light Pink
+ '#E0FFFF', // Light Cyan
+ '#FAFAD2', // Light Goldenrod
+ '#87CEFA', // Light Sky Blue
+ '#D3D3D3', // Light Gray
+ '#B0C4DE', // Light Steel Blue
+ '#FFA07A', // Light Salmon
+ '#20B2AA', // Light Sea Green
+ '#778899', // Light Slate Gray
+ '#AFEEEE', // Light Turquoise
+ '#7A58C1', // Light Violet
+ '#FFA500', // Light Orange
+ '#A0522D', // Light Brown
+ '#9370DB', // Light Purple
+ '#008080', // Light Teal
+ '#808000', // Light Olive
+];
+
+export default function LargeType({ chars }: { chars: string[] }) {
+ return (
+
+ {chars.map((char, i) => (
+
+
+ {char}
+
+
+ {i + 1}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/cast/src/components/PairedSuccessfullyOverlay.tsx b/apps/cast/src/components/PairedSuccessfullyOverlay.tsx
new file mode 100644
index 000000000..517874b73
--- /dev/null
+++ b/apps/cast/src/components/PairedSuccessfullyOverlay.tsx
@@ -0,0 +1,42 @@
+import FilledCircleCheck from './FilledCircleCheck';
+
+export default function PairedSuccessfullyOverlay() {
+ return (
+
+
+
+
+ Pairing Complete
+
+
+ We're preparing your album.
+
This should only take a few seconds.
+
+
+
+ );
+}
diff --git a/apps/cast/src/components/Theatre/PhotoAuditorium.tsx b/apps/cast/src/components/Theatre/PhotoAuditorium.tsx
new file mode 100644
index 000000000..c137aec7f
--- /dev/null
+++ b/apps/cast/src/components/Theatre/PhotoAuditorium.tsx
@@ -0,0 +1,93 @@
+import { SlideshowContext } from 'pages/slideshow';
+import { useContext, useEffect, useState } from 'react';
+
+export default function PhotoAuditorium({
+ url,
+ nextSlideUrl,
+}: {
+ url: string;
+ nextSlideUrl: string;
+}) {
+ const { showNextSlide } = useContext(SlideshowContext);
+
+ const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false);
+ const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false);
+ const [prerenderTime, setPrerenderTime] = useState(null);
+
+ useEffect(() => {
+ let timeout: NodeJS.Timeout;
+ let timeout2: NodeJS.Timeout;
+
+ if (nextSlidePrerendered) {
+ const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0;
+ const delayTime = Math.max(5000 - elapsedTime, 0);
+
+ if (elapsedTime >= 5000) {
+ setShowPreloadedNextSlide(true);
+ } else {
+ timeout = setTimeout(() => {
+ setShowPreloadedNextSlide(true);
+ }, delayTime);
+ }
+
+ if (showNextSlide) {
+ timeout2 = setTimeout(() => {
+ showNextSlide();
+ setNextSlidePrerendered(false);
+ setPrerenderTime(null);
+ setShowPreloadedNextSlide(false);
+ }, delayTime);
+ }
+ }
+
+ return () => {
+ if (timeout) clearTimeout(timeout);
+ if (timeout2) clearTimeout(timeout2);
+ };
+ }, [nextSlidePrerendered, showNextSlide, prerenderTime]);
+
+ return (
+
+
+

+

{
+ setNextSlidePrerendered(true);
+ setPrerenderTime(Date.now());
+ }}
+ />
+
+
+ );
+}
diff --git a/apps/cast/src/components/Theatre/VideoAuditorium.tsx b/apps/cast/src/components/Theatre/VideoAuditorium.tsx
new file mode 100644
index 000000000..230d30312
--- /dev/null
+++ b/apps/cast/src/components/Theatre/VideoAuditorium.tsx
@@ -0,0 +1,53 @@
+import mime from 'mime-types';
+import { SlideshowContext } from 'pages/slideshow';
+import { useContext, useEffect, useRef } from 'react';
+
+export default function VideoAuditorium({
+ name,
+ url,
+}: {
+ name: string;
+ url: string;
+}) {
+ const { showNextSlide } = useContext(SlideshowContext);
+
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ attemptPlay();
+ }, [url, videoRef]);
+
+ const attemptPlay = async () => {
+ if (videoRef.current) {
+ try {
+ await videoRef.current.play();
+ } catch {
+ showNextSlide();
+ }
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/cast/src/components/Theatre/index.tsx b/apps/cast/src/components/Theatre/index.tsx
new file mode 100644
index 000000000..d2c8e6ab4
--- /dev/null
+++ b/apps/cast/src/components/Theatre/index.tsx
@@ -0,0 +1,30 @@
+import { FILE_TYPE } from 'constants/file';
+import PhotoAuditorium from './PhotoAuditorium';
+// import VideoAuditorium from './VideoAuditorium';
+
+interface fileProp {
+ fileName: string;
+ fileURL: string;
+ type: FILE_TYPE;
+}
+
+interface IProps {
+ file1: fileProp;
+ file2: fileProp;
+}
+
+export default function Theatre(props: IProps) {
+ switch (props.file1.type && props.file2.type) {
+ case FILE_TYPE.IMAGE:
+ return (
+
+ );
+ // case FILE_TYPE.VIDEO:
+ // return (
+ //
+ // );
+ }
+}
diff --git a/apps/cast/src/components/TimerBar.tsx b/apps/cast/src/components/TimerBar.tsx
new file mode 100644
index 000000000..0d0a07e6b
--- /dev/null
+++ b/apps/cast/src/components/TimerBar.tsx
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react';
+
+export default function TimerBar({ percentage }: { percentage: number }) {
+ const okColor = '#75C157';
+ const warningColor = '#FFC000';
+ const lateColor = '#FF0000';
+
+ const [backgroundColor, setBackgroundColor] = useState(okColor);
+
+ useEffect(() => {
+ if (percentage >= 40) {
+ setBackgroundColor(okColor);
+ } else if (percentage >= 20) {
+ setBackgroundColor(warningColor);
+ } else {
+ setBackgroundColor(lateColor);
+ }
+ }, [percentage]);
+
+ return (
+
+ );
+}
diff --git a/apps/cast/src/constants/api.ts b/apps/cast/src/constants/api.ts
new file mode 100644
index 000000000..17571f7f3
--- /dev/null
+++ b/apps/cast/src/constants/api.ts
@@ -0,0 +1 @@
+export const REQUEST_BATCH_SIZE = 1000;
diff --git a/apps/cast/src/constants/apps.ts b/apps/cast/src/constants/apps.ts
new file mode 100644
index 000000000..5d54d176c
--- /dev/null
+++ b/apps/cast/src/constants/apps.ts
@@ -0,0 +1,56 @@
+import { getAlbumsURL } from '@ente/shared/network/api';
+import { runningInBrowser } from '@ente/shared/platform';
+import { PAGES } from 'constants/pages';
+
+export enum APPS {
+ PHOTOS = 'PHOTOS',
+ AUTH = 'AUTH',
+ ALBUMS = 'ALBUMS',
+}
+
+export const ALLOWED_APP_PAGES = new Map([
+ [APPS.ALBUMS, [PAGES.SHARED_ALBUMS, PAGES.ROOT]],
+ [
+ APPS.AUTH,
+ [
+ PAGES.ROOT,
+ PAGES.LOGIN,
+ PAGES.SIGNUP,
+ PAGES.VERIFY,
+ PAGES.CREDENTIALS,
+ PAGES.RECOVER,
+ PAGES.CHANGE_PASSWORD,
+ PAGES.GENERATE,
+ PAGES.AUTH,
+ PAGES.TWO_FACTOR_VERIFY,
+ PAGES.TWO_FACTOR_RECOVER,
+ ],
+ ],
+]);
+
+export const CLIENT_PACKAGE_NAMES = new Map([
+ [APPS.ALBUMS, 'io.ente.albums.web'],
+ [APPS.PHOTOS, 'io.ente.photos.web'],
+ [APPS.AUTH, 'io.ente.auth.web'],
+]);
+
+export const getAppNameAndTitle = () => {
+ if (!runningInBrowser()) {
+ return {};
+ }
+ const currentURL = new URL(window.location.href);
+ const albumsURL = new URL(getAlbumsURL());
+ if (currentURL.origin === albumsURL.origin) {
+ return { name: APPS.ALBUMS, title: 'ente Photos' };
+ } else {
+ return { name: APPS.PHOTOS, title: 'ente Photos' };
+ }
+};
+
+export const getAppTitle = () => {
+ return getAppNameAndTitle().title;
+};
+
+export const getAppName = () => {
+ return getAppNameAndTitle().name;
+};
diff --git a/apps/cast/src/constants/cache.ts b/apps/cast/src/constants/cache.ts
new file mode 100644
index 000000000..80e0fecf1
--- /dev/null
+++ b/apps/cast/src/constants/cache.ts
@@ -0,0 +1,5 @@
+export enum CACHES {
+ THUMBS = 'thumbs',
+ FACE_CROPS = 'face-crops',
+ FILES = 'files',
+}
diff --git a/apps/cast/src/constants/collection.ts b/apps/cast/src/constants/collection.ts
new file mode 100644
index 000000000..a0c6a9037
--- /dev/null
+++ b/apps/cast/src/constants/collection.ts
@@ -0,0 +1,100 @@
+export const ARCHIVE_SECTION = -1;
+export const TRASH_SECTION = -2;
+export const DUMMY_UNCATEGORIZED_COLLECTION = -3;
+export const HIDDEN_ITEMS_SECTION = -4;
+export const ALL_SECTION = 0;
+export const DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME = 'Hidden';
+
+export enum CollectionType {
+ folder = 'folder',
+ favorites = 'favorites',
+ album = 'album',
+ uncategorized = 'uncategorized',
+}
+
+export enum CollectionSummaryType {
+ folder = 'folder',
+ favorites = 'favorites',
+ album = 'album',
+ archive = 'archive',
+ trash = 'trash',
+ uncategorized = 'uncategorized',
+ all = 'all',
+ outgoingShare = 'outgoingShare',
+ incomingShareViewer = 'incomingShareViewer',
+ incomingShareCollaborator = 'incomingShareCollaborator',
+ sharedOnlyViaLink = 'sharedOnlyViaLink',
+ archived = 'archived',
+ defaultHidden = 'defaultHidden',
+ hiddenItems = 'hiddenItems',
+ pinned = 'pinned',
+}
+export enum COLLECTION_LIST_SORT_BY {
+ NAME,
+ CREATION_TIME_ASCENDING,
+ UPDATION_TIME_DESCENDING,
+}
+
+export const COLLECTION_SHARE_DEFAULT_VALID_DURATION =
+ 10 * 24 * 60 * 60 * 1000 * 1000;
+export const COLLECTION_SHARE_DEFAULT_DEVICE_LIMIT = 4;
+
+export const COLLECTION_SORT_ORDER = new Map([
+ [CollectionSummaryType.all, 0],
+ [CollectionSummaryType.hiddenItems, 0],
+ [CollectionSummaryType.uncategorized, 1],
+ [CollectionSummaryType.favorites, 2],
+ [CollectionSummaryType.pinned, 3],
+ [CollectionSummaryType.album, 4],
+ [CollectionSummaryType.folder, 4],
+ [CollectionSummaryType.incomingShareViewer, 4],
+ [CollectionSummaryType.incomingShareCollaborator, 4],
+ [CollectionSummaryType.outgoingShare, 4],
+ [CollectionSummaryType.sharedOnlyViaLink, 4],
+ [CollectionSummaryType.archived, 4],
+ [CollectionSummaryType.archive, 5],
+ [CollectionSummaryType.trash, 6],
+ [CollectionSummaryType.defaultHidden, 7],
+]);
+
+export const SYSTEM_COLLECTION_TYPES = new Set([
+ CollectionSummaryType.all,
+ CollectionSummaryType.archive,
+ CollectionSummaryType.trash,
+ CollectionSummaryType.uncategorized,
+ CollectionSummaryType.hiddenItems,
+ CollectionSummaryType.defaultHidden,
+]);
+
+export const ADD_TO_NOT_ALLOWED_COLLECTION = new Set([
+ CollectionSummaryType.all,
+ CollectionSummaryType.archive,
+ CollectionSummaryType.incomingShareViewer,
+ CollectionSummaryType.trash,
+ CollectionSummaryType.uncategorized,
+ CollectionSummaryType.defaultHidden,
+ CollectionSummaryType.hiddenItems,
+]);
+
+export const MOVE_TO_NOT_ALLOWED_COLLECTION = new Set([
+ CollectionSummaryType.all,
+ CollectionSummaryType.archive,
+ CollectionSummaryType.incomingShareViewer,
+ CollectionSummaryType.incomingShareCollaborator,
+ CollectionSummaryType.trash,
+ CollectionSummaryType.uncategorized,
+ CollectionSummaryType.defaultHidden,
+ CollectionSummaryType.hiddenItems,
+]);
+
+export const OPTIONS_NOT_HAVING_COLLECTION_TYPES = new Set([
+ CollectionSummaryType.all,
+ CollectionSummaryType.archive,
+]);
+
+export const HIDE_FROM_COLLECTION_BAR_TYPES = new Set([
+ CollectionSummaryType.trash,
+ CollectionSummaryType.archive,
+ CollectionSummaryType.uncategorized,
+ CollectionSummaryType.defaultHidden,
+]);
diff --git a/apps/cast/src/constants/ffmpeg.ts b/apps/cast/src/constants/ffmpeg.ts
new file mode 100644
index 000000000..008b09784
--- /dev/null
+++ b/apps/cast/src/constants/ffmpeg.ts
@@ -0,0 +1,3 @@
+export const INPUT_PATH_PLACEHOLDER = 'INPUT';
+export const FFMPEG_PLACEHOLDER = 'FFMPEG';
+export const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
diff --git a/apps/cast/src/constants/file.ts b/apps/cast/src/constants/file.ts
new file mode 100644
index 000000000..c00ec9972
--- /dev/null
+++ b/apps/cast/src/constants/file.ts
@@ -0,0 +1,43 @@
+export const MIN_EDITED_CREATION_TIME = new Date(1800, 0, 1);
+export const MAX_EDITED_CREATION_TIME = new Date();
+
+export const MAX_EDITED_FILE_NAME_LENGTH = 100;
+export const MAX_CAPTION_SIZE = 5000;
+
+export const TYPE_HEIC = 'heic';
+export const TYPE_HEIF = 'heif';
+export const TYPE_JPEG = 'jpeg';
+export const TYPE_JPG = 'jpg';
+
+export enum FILE_TYPE {
+ IMAGE,
+ VIDEO,
+ LIVE_PHOTO,
+ OTHERS,
+}
+
+export const RAW_FORMATS = [
+ 'heic',
+ 'rw2',
+ 'tiff',
+ 'arw',
+ 'cr3',
+ 'cr2',
+ 'raf',
+ 'nef',
+ 'psd',
+ 'dng',
+ 'tif',
+];
+export const SUPPORTED_RAW_FORMATS = [
+ 'heic',
+ 'rw2',
+ 'tiff',
+ 'arw',
+ 'cr3',
+ 'cr2',
+ 'nef',
+ 'psd',
+ 'dng',
+ 'tif',
+];
diff --git a/apps/cast/src/constants/gallery.ts b/apps/cast/src/constants/gallery.ts
new file mode 100644
index 000000000..66b7759d6
--- /dev/null
+++ b/apps/cast/src/constants/gallery.ts
@@ -0,0 +1,15 @@
+export const GAP_BTW_TILES = 4;
+export const DATE_CONTAINER_HEIGHT = 48;
+export const SIZE_AND_COUNT_CONTAINER_HEIGHT = 72;
+export const IMAGE_CONTAINER_MAX_HEIGHT = 180;
+export const IMAGE_CONTAINER_MAX_WIDTH = 180;
+export const MIN_COLUMNS = 4;
+export const SPACE_BTW_DATES = 44;
+export const SPACE_BTW_DATES_TO_IMAGE_CONTAINER_WIDTH_RATIO = 0.244;
+
+export enum PLAN_PERIOD {
+ MONTH = 'month',
+ YEAR = 'year',
+}
+
+export const SYNC_INTERVAL_IN_MICROSECONDS = 1000 * 60 * 5; // 5 minutes
diff --git a/apps/cast/src/constants/pages.ts b/apps/cast/src/constants/pages.ts
new file mode 100644
index 000000000..85b9db018
--- /dev/null
+++ b/apps/cast/src/constants/pages.ts
@@ -0,0 +1,20 @@
+export enum PAGES {
+ CHANGE_EMAIL = '/change-email',
+ CHANGE_PASSWORD = '/change-password',
+ CREDENTIALS = '/credentials',
+ GALLERY = '/gallery',
+ GENERATE = '/generate',
+ LOGIN = '/login',
+ RECOVER = '/recover',
+ SIGNUP = '/signup',
+ TWO_FACTOR_SETUP = '/two-factor/setup',
+ TWO_FACTOR_VERIFY = '/two-factor/verify',
+ TWO_FACTOR_RECOVER = '/two-factor/recover',
+ VERIFY = '/verify',
+ ROOT = '/',
+ SHARED_ALBUMS = '/shared-albums',
+ // ML_DEBUG = '/ml-debug',
+ DEDUPLICATE = '/deduplicate',
+ // AUTH page is used to show (auth)enticator codes
+ AUTH = '/auth',
+}
diff --git a/apps/cast/src/constants/sentry.ts b/apps/cast/src/constants/sentry.ts
new file mode 100644
index 000000000..a8a05aff9
--- /dev/null
+++ b/apps/cast/src/constants/sentry.ts
@@ -0,0 +1,15 @@
+export const ENV_DEVELOPMENT = 'development';
+export const ENV_PRODUCTION = 'production';
+
+export const getSentryDSN = () =>
+ process.env.NEXT_PUBLIC_SENTRY_DSN ??
+ 'https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2';
+
+export const getSentryENV = () =>
+ process.env.NEXT_PUBLIC_SENTRY_ENV ?? ENV_PRODUCTION;
+
+export const getSentryRelease = () => process.env.SENTRY_RELEASE;
+
+export { getIsSentryEnabled } from '../../sentryConfigUtil';
+
+export const isDEVSentryENV = () => getSentryENV() === ENV_DEVELOPMENT;
diff --git a/apps/cast/src/constants/upload.ts b/apps/cast/src/constants/upload.ts
new file mode 100644
index 000000000..dccad9bae
--- /dev/null
+++ b/apps/cast/src/constants/upload.ts
@@ -0,0 +1,142 @@
+import { ENCRYPTION_CHUNK_SIZE } from '@ente/shared/crypto/constants';
+import { FILE_TYPE } from 'constants/file';
+import {
+ FileTypeInfo,
+ ImportSuggestion,
+ Location,
+ ParsedExtractedMetadata,
+} from 'types/upload';
+
+// list of format that were missed by type-detection for some files.
+export const WHITELISTED_FILE_FORMATS: FileTypeInfo[] = [
+ { fileType: FILE_TYPE.IMAGE, exactType: 'jpeg', mimeType: 'image/jpeg' },
+ { fileType: FILE_TYPE.IMAGE, exactType: 'jpg', mimeType: 'image/jpeg' },
+ { fileType: FILE_TYPE.VIDEO, exactType: 'webm', mimeType: 'video/webm' },
+ { fileType: FILE_TYPE.VIDEO, exactType: 'mod', mimeType: 'video/mpeg' },
+ { fileType: FILE_TYPE.VIDEO, exactType: 'mp4', mimeType: 'video/mp4' },
+ { fileType: FILE_TYPE.IMAGE, exactType: 'gif', mimeType: 'image/gif' },
+ { fileType: FILE_TYPE.VIDEO, exactType: 'dv', mimeType: 'video/x-dv' },
+ {
+ fileType: FILE_TYPE.VIDEO,
+ exactType: 'wmv',
+ mimeType: 'video/x-ms-asf',
+ },
+ {
+ fileType: FILE_TYPE.VIDEO,
+ exactType: 'hevc',
+ mimeType: 'video/hevc',
+ },
+ {
+ fileType: FILE_TYPE.IMAGE,
+ exactType: 'raf',
+ mimeType: 'image/x-fuji-raf',
+ },
+ {
+ fileType: FILE_TYPE.IMAGE,
+ exactType: 'orf',
+ mimeType: 'image/x-olympus-orf',
+ },
+
+ {
+ fileType: FILE_TYPE.IMAGE,
+ exactType: 'crw',
+ mimeType: 'image/x-canon-crw',
+ },
+];
+
+export const KNOWN_NON_MEDIA_FORMATS = ['xmp', 'html', 'txt'];
+
+export const EXIFLESS_FORMATS = ['gif', 'bmp'];
+
+// this is the chunk size of the un-encrypted file which is read and encrypted before uploading it as a single part.
+export const MULTIPART_PART_SIZE = 20 * 1024 * 1024;
+
+export const FILE_READER_CHUNK_SIZE = ENCRYPTION_CHUNK_SIZE;
+
+export const FILE_CHUNKS_COMBINED_FOR_A_UPLOAD_PART = Math.floor(
+ MULTIPART_PART_SIZE / FILE_READER_CHUNK_SIZE
+);
+
+export const RANDOM_PERCENTAGE_PROGRESS_FOR_PUT = () => 90 + 10 * Math.random();
+
+export const NULL_LOCATION: Location = { latitude: null, longitude: null };
+
+export enum UPLOAD_STAGES {
+ START,
+ READING_GOOGLE_METADATA_FILES,
+ EXTRACTING_METADATA,
+ UPLOADING,
+ CANCELLING,
+ FINISH,
+}
+
+export enum UPLOAD_STRATEGY {
+ SINGLE_COLLECTION,
+ COLLECTION_PER_FOLDER,
+}
+
+export enum UPLOAD_RESULT {
+ FAILED,
+ ALREADY_UPLOADED,
+ UNSUPPORTED,
+ BLOCKED,
+ TOO_LARGE,
+ LARGER_THAN_AVAILABLE_STORAGE,
+ UPLOADED,
+ UPLOADED_WITH_STATIC_THUMBNAIL,
+ ADDED_SYMLINK,
+}
+
+export enum PICKED_UPLOAD_TYPE {
+ FILES = 'files',
+ FOLDERS = 'folders',
+ ZIPS = 'zips',
+}
+
+export const MAX_FILE_SIZE_SUPPORTED = 4 * 1024 * 1024 * 1024; // 4 GB
+
+export const LIVE_PHOTO_ASSET_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB
+
+export const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = {
+ location: NULL_LOCATION,
+ creationTime: null,
+ width: null,
+ height: null,
+};
+
+export const A_SEC_IN_MICROSECONDS = 1e6;
+
+export const DEFAULT_IMPORT_SUGGESTION: ImportSuggestion = {
+ rootFolderName: '',
+ hasNestedFolders: false,
+ hasRootLevelFileWithFolder: false,
+};
+
+export const BLACK_THUMBNAIL_BASE64 =
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB' +
+ 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ' +
+ 'EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARC' +
+ 'ACWASwDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF' +
+ 'BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk' +
+ '6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztL' +
+ 'W2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAA' +
+ 'AAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVY' +
+ 'nLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImK' +
+ 'kpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oAD' +
+ 'AMBAAIRAxEAPwD/AD/6ACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
+ 'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
+ 'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAC' +
+ 'gAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
+ 'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
+ 'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
+ 'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
+ 'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
+ 'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA' +
+ 'KACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
+ 'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
+ 'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
+ 'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAK' +
+ 'ACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA' +
+ 'KACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
+ 'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
+ 'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD/9k=';
diff --git a/apps/cast/src/constants/urls.ts b/apps/cast/src/constants/urls.ts
new file mode 100644
index 000000000..291704e98
--- /dev/null
+++ b/apps/cast/src/constants/urls.ts
@@ -0,0 +1,19 @@
+export const ENTE_WEBSITE_LINK = 'https://ente.io';
+
+export const ML_BLOG_LINK = 'https://ente.io/blog/desktop-ml-beta';
+
+export const FACE_SEARCH_PRIVACY_POLICY_LINK =
+ 'https://ente.io/privacy#8-biometric-information-privacy-policy';
+
+export const SUPPORT_EMAIL = 'support@ente.io';
+
+export const APP_DOWNLOAD_URL = 'https://ente.io/download/desktop';
+
+export const FEEDBACK_EMAIL = 'feedback@ente.io';
+
+export const DELETE_ACCOUNT_EMAIL = 'account-deletion@ente.io';
+
+export const WEB_ROADMAP_URL = 'https://github.com/ente-io/photos-web/issues';
+
+export const DESKTOP_ROADMAP_URL =
+ 'https://github.com/ente-io/photos-desktop/issues';
diff --git a/apps/cast/src/pages/_app.tsx b/apps/cast/src/pages/_app.tsx
new file mode 100644
index 000000000..4d504197b
--- /dev/null
+++ b/apps/cast/src/pages/_app.tsx
@@ -0,0 +1,21 @@
+import type { AppProps } from 'next/app';
+import 'styles/global.css';
+import { ThemeProvider, CssBaseline } from '@mui/material';
+import { getTheme } from '@ente/shared/themes';
+import { THEME_COLOR } from '@ente/shared/themes/constants';
+import { APPS } from '@ente/shared/apps/constants';
+
+export default function App({ Component, pageProps }: AppProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/cast/src/pages/_document.tsx b/apps/cast/src/pages/_document.tsx
new file mode 100644
index 000000000..560edf99e
--- /dev/null
+++ b/apps/cast/src/pages/_document.tsx
@@ -0,0 +1,25 @@
+import { Html, Head, Main, NextScript } from 'next/document';
+
+export default function Document() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/cast/src/pages/index.tsx b/apps/cast/src/pages/index.tsx
new file mode 100644
index 000000000..30da9bf1a
--- /dev/null
+++ b/apps/cast/src/pages/index.tsx
@@ -0,0 +1,242 @@
+import EnteSpinner from '@ente/shared/components/EnteSpinner';
+import { boxSealOpen, toB64 } from '@ente/shared/crypto/internal/libsodium';
+import { useCastReceiver } from '@ente/shared/hooks/useCastReceiver';
+import { addLogLine } from '@ente/shared/logging';
+import castGateway from '@ente/shared/network/cast';
+import LargeType from 'components/LargeType';
+import _sodium from 'libsodium-wrappers';
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+import { storeCastData } from 'services/cast/castService';
+
+// Function to generate cryptographically secure digits
+const generateSecureData = (length: number): Uint8Array => {
+ const array = new Uint8Array(length);
+ window.crypto.getRandomValues(array);
+ // Modulo operation to ensure each byte is a single digit
+ for (let i = 0; i < length; i++) {
+ array[i] = array[i] % 10;
+ }
+ return array;
+};
+
+const convertDataToDecimalString = (data: Uint8Array): string => {
+ let decimalString = '';
+ for (let i = 0; i < data.length; i++) {
+ decimalString += data[i].toString(); // No need to pad, as each value is a single digit
+ }
+ return decimalString;
+};
+
+export default function PairingMode() {
+ const [digits, setDigits] = useState([]);
+ const [publicKeyB64, setPublicKeyB64] = useState('');
+ const [privateKeyB64, setPrivateKeyB64] = useState('');
+ const [codePending, setCodePending] = useState(true);
+ const [isCastReady, setIsCastReady] = useState(false);
+
+ const { cast } = useCastReceiver();
+
+ useEffect(() => {
+ init();
+ }, []);
+
+ useEffect(() => {
+ if (!cast) return;
+ if (isCastReady) return;
+ const context = cast.framework.CastReceiverContext.getInstance();
+
+ try {
+ const options = new cast.framework.CastReceiverOptions();
+ options.customNamespaces = Object.assign({});
+ options.customNamespaces['urn:x-cast:pair-request'] =
+ cast.framework.system.MessageType.JSON;
+
+ options.disableIdleTimeout = true;
+
+ context.addCustomMessageListener(
+ 'urn:x-cast:pair-request',
+ messageReceiveHandler
+ );
+ context.start(options);
+ } catch (e) {
+ addLogLine(e, 'failed to create cast context');
+ }
+ setIsCastReady(true);
+ return () => {
+ context.stop();
+ };
+ }, [cast, isCastReady]);
+
+ const messageReceiveHandler = (message: {
+ type: string;
+ senderId: string;
+ data: any;
+ }) => {
+ cast.framework.CastReceiverContext.getInstance().sendCustomMessage(
+ 'urn:x-cast:pair-request',
+ message.senderId,
+ {
+ code: digits.join(''),
+ }
+ );
+ };
+
+ const init = async () => {
+ const data = generateSecureData(6);
+ setDigits(convertDataToDecimalString(data).split(''));
+ const keypair = await generateKeyPair();
+ setPublicKeyB64(await toB64(keypair.publicKey));
+ setPrivateKeyB64(await toB64(keypair.privateKey));
+ };
+
+ 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(
+ `${digits.join('')}`
+ );
+ 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 {
+ await castGateway.registerDevice(
+ `${digits.join('')}`,
+ publicKeyB64
+ );
+ setCodePending(false);
+ } catch (e) {
+ // schedule re-try after 5 seconds
+ setTimeout(() => {
+ init();
+ }, 5000);
+ return;
+ }
+ };
+
+ const router = useRouter();
+
+ useEffect(() => {
+ if (digits.length < 1 || !publicKeyB64 || !privateKeyB64) return;
+
+ const interval = setInterval(async () => {
+ const data = await pollForCastData();
+ if (!data) return;
+ storeCastData(data);
+ await router.push('/slideshow');
+ }, 1000);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }, [digits, publicKeyB64, privateKeyB64, codePending]);
+
+ useEffect(() => {
+ if (!publicKeyB64) return;
+ advertisePublicKey(publicKeyB64);
+ }, [publicKeyB64]);
+
+ return (
+ <>
+
+
+

+
+ Enter this code on ente to pair this TV
+
+
+ {codePending ? (
+
+ ) : (
+ <>
+
+ >
+ )}
+
+
+ Visit{' '}
+
+ ente.io/cast
+ {' '}
+ for help
+
+
+

+
+
+
+ >
+ );
+}
diff --git a/apps/cast/src/pages/slideshow.tsx b/apps/cast/src/pages/slideshow.tsx
new file mode 100644
index 000000000..f38fef6fe
--- /dev/null
+++ b/apps/cast/src/pages/slideshow.tsx
@@ -0,0 +1,186 @@
+import { logError } from '@ente/shared/sentry';
+import PairedSuccessfullyOverlay from 'components/PairedSuccessfullyOverlay';
+import Theatre from 'components/Theatre';
+import { FILE_TYPE } from 'constants/file';
+import { useRouter } from 'next/router';
+import { createContext, useEffect, useState } from 'react';
+import {
+ getCastCollection,
+ getLocalFiles,
+ syncPublicFiles,
+} from 'services/cast/castService';
+import { Collection } from 'types/collection';
+import { EnteFile } from 'types/file';
+import { getPreviewableImage, isRawFileFromFileName } from 'utils/file';
+
+export const SlideshowContext = createContext<{
+ showNextSlide: () => void;
+}>(null);
+
+const renderableFileURLCache = new Map();
+
+export default function Slideshow() {
+ const [collectionFiles, setCollectionFiles] = useState([]);
+
+ const [currentFile, setCurrentFile] = useState(
+ undefined
+ );
+ const [nextFile, setNextFile] = useState(undefined);
+
+ const [loading, setLoading] = useState(true);
+ const [castToken, setCastToken] = useState('');
+ const [castCollection, setCastCollection] = useState<
+ Collection | undefined
+ >(undefined);
+
+ const syncCastFiles = async (token: string) => {
+ try {
+ const castToken = window.localStorage.getItem('castToken');
+ const requestedCollectionKey =
+ window.localStorage.getItem('collectionKey');
+ const collection = await getCastCollection(
+ castToken,
+ requestedCollectionKey
+ );
+ if (
+ castCollection === undefined ||
+ castCollection.updationTime !== collection.updationTime
+ ) {
+ setCastCollection(collection);
+ await syncPublicFiles(token, collection, () => { });
+ const files = await getLocalFiles(String(collection.id));
+ setCollectionFiles(
+ files.filter((file) => isFileEligibleForCast(file))
+ );
+ }
+ } catch (e) {
+ logError(e, 'error during sync');
+ router.push('/');
+ }
+ };
+
+ const init = async () => {
+ try {
+ const castToken = window.localStorage.getItem('castToken');
+ setCastToken(castToken);
+ } catch (e) {
+ logError(e, 'error during sync');
+ router.push('/');
+ }
+ };
+
+ useEffect(() => {
+ if (castToken) {
+ const intervalId = setInterval(() => {
+ syncCastFiles(castToken);
+ }, 5000);
+
+ return () => clearInterval(intervalId);
+ }
+ }, [castToken]);
+
+ const isFileEligibleForCast = (file: EnteFile) => {
+ const fileType = file.metadata.fileType;
+ if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO) {
+ return false;
+ }
+
+ const fileSizeLimit = 100 * 1024 * 1024;
+
+ if (file.info.fileSize > fileSizeLimit) {
+ return false;
+ }
+
+ const name = file.metadata.title;
+
+ if (fileType === FILE_TYPE.IMAGE) {
+ if (isRawFileFromFileName(name)) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ const router = useRouter();
+
+ useEffect(() => {
+ init();
+ }, []);
+
+ useEffect(() => {
+ if (collectionFiles.length < 1) return;
+ showNextSlide();
+ }, [collectionFiles]);
+
+ const showNextSlide = () => {
+ const currentIndex = collectionFiles.findIndex(
+ (file) => file.id === currentFile?.id
+ );
+
+ const nextIndex = (currentIndex + 1) % collectionFiles.length;
+ const nextNextIndex = (nextIndex + 1) % collectionFiles.length;
+
+ const nextFile = collectionFiles[nextIndex];
+ const nextNextFile = collectionFiles[nextNextIndex];
+
+ setCurrentFile(nextFile);
+ setNextFile(nextNextFile);
+ };
+
+ const [renderableFileURL, setRenderableFileURL] = useState('');
+
+ const getRenderableFileURL = async () => {
+ if (!currentFile) return;
+
+ const cacheValue = renderableFileURLCache.get(currentFile.id);
+ if (cacheValue) {
+ setRenderableFileURL(cacheValue);
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const blob = await getPreviewableImage(
+ currentFile as EnteFile,
+ castToken
+ );
+
+ const url = URL.createObjectURL(blob);
+
+ renderableFileURLCache.set(currentFile?.id, url);
+
+ setRenderableFileURL(url);
+ } catch (e) {
+ return;
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (currentFile) {
+ getRenderableFileURL();
+ }
+ }, [currentFile]);
+
+ return (
+ <>
+
+
+
+ {loading && }
+ >
+ );
+}
diff --git a/apps/cast/src/services/InMemoryStore.ts b/apps/cast/src/services/InMemoryStore.ts
new file mode 100644
index 000000000..82e0155a6
--- /dev/null
+++ b/apps/cast/src/services/InMemoryStore.ts
@@ -0,0 +1,32 @@
+export enum MS_KEYS {
+ OPT_OUT_OF_CRASH_REPORTS = 'optOutOfCrashReports',
+ SRP_CONFIGURE_IN_PROGRESS = 'srpConfigureInProgress',
+ REDIRECT_URL = 'redirectUrl',
+}
+
+type StoreType = Map, any>;
+
+class InMemoryStore {
+ private store: StoreType = new Map();
+
+ get(key: MS_KEYS) {
+ return this.store.get(key);
+ }
+
+ set(key: MS_KEYS, value: any) {
+ this.store.set(key, value);
+ }
+
+ delete(key: MS_KEYS) {
+ this.store.delete(key);
+ }
+
+ has(key: MS_KEYS) {
+ return this.store.has(key);
+ }
+ clear() {
+ this.store.clear();
+ }
+}
+
+export default new InMemoryStore();
diff --git a/apps/cast/src/services/cache/cacheStorageFactory.ts b/apps/cast/src/services/cache/cacheStorageFactory.ts
new file mode 100644
index 000000000..bb007977c
--- /dev/null
+++ b/apps/cast/src/services/cache/cacheStorageFactory.ts
@@ -0,0 +1,41 @@
+import { LimitedCacheStorage } from 'types/cache/index';
+// import { ElectronCacheStorage } from 'services/electron/cache';
+// import { runningInElectron, runningInWorker } from 'utils/common';
+// import { WorkerElectronCacheStorageService } from 'services/workerElectronCache/service';
+
+class cacheStorageFactory {
+ // workerElectronCacheStorageServiceInstance: WorkerElectronCacheStorageService;
+ getCacheStorage(): LimitedCacheStorage {
+ // if (runningInElectron()) {
+ // if (runningInWorker()) {
+ // if (!this.workerElectronCacheStorageServiceInstance) {
+ // // this.workerElectronCacheStorageServiceInstance =
+ // // new WorkerElectronCacheStorageService();
+ // }
+ // return this.workerElectronCacheStorageServiceInstance;
+ // } else {
+ // // return ElectronCacheStorage;
+ // }
+ // } else {
+ return transformBrowserCacheStorageToLimitedCacheStorage(caches);
+ // }
+ }
+}
+
+export const CacheStorageFactory = new cacheStorageFactory();
+
+function transformBrowserCacheStorageToLimitedCacheStorage(
+ caches: CacheStorage
+): LimitedCacheStorage {
+ return {
+ async open(cacheName) {
+ const cache = await caches.open(cacheName);
+ return {
+ match: cache.match.bind(cache),
+ put: cache.put.bind(cache),
+ delete: cache.delete.bind(cache),
+ };
+ },
+ delete: caches.delete.bind(caches),
+ };
+}
diff --git a/apps/cast/src/services/cache/cacheStorageService.ts b/apps/cast/src/services/cache/cacheStorageService.ts
new file mode 100644
index 000000000..87df2063b
--- /dev/null
+++ b/apps/cast/src/services/cache/cacheStorageService.ts
@@ -0,0 +1,33 @@
+import { logError } from '@ente/shared/sentry';
+import { CacheStorageFactory } from './cacheStorageFactory';
+
+const SecurityError = 'SecurityError';
+const INSECURE_OPERATION = 'The operation is insecure.';
+async function openCache(cacheName: string) {
+ try {
+ return await CacheStorageFactory.getCacheStorage().open(cacheName);
+ } catch (e) {
+ // ignoring insecure operation error, as it is thrown in incognito mode in firefox
+ if (e.name === SecurityError && e.message === INSECURE_OPERATION) {
+ // no-op
+ } else {
+ // log and ignore, we don't want to break the caller flow, when cache is not available
+ logError(e, 'openCache failed');
+ }
+ }
+}
+async function deleteCache(cacheName: string) {
+ try {
+ return await CacheStorageFactory.getCacheStorage().delete(cacheName);
+ } catch (e) {
+ // ignoring insecure operation error, as it is thrown in incognito mode in firefox
+ if (e.name === SecurityError && e.message === INSECURE_OPERATION) {
+ // no-op
+ } else {
+ // log and ignore, we don't want to break the caller flow, when cache is not available
+ logError(e, 'deleteCache failed');
+ }
+ }
+}
+
+export const CacheStorageService = { open: openCache, delete: deleteCache };
diff --git a/apps/cast/src/services/cast/castService.ts b/apps/cast/src/services/cast/castService.ts
new file mode 100644
index 000000000..755c7878a
--- /dev/null
+++ b/apps/cast/src/services/cast/castService.ts
@@ -0,0 +1,305 @@
+import { getEndpoint } from '@ente/shared/network/api';
+import localForage from '@ente/shared/storage/localForage';
+import HTTPService from '@ente/shared/network/HTTPService';
+import { logError } from '@ente/shared/sentry';
+import { CustomError, parseSharingErrorCodes } from '@ente/shared/error';
+import ComlinkCryptoWorker from '@ente/shared/crypto';
+
+import { Collection, CollectionPublicMagicMetadata } from 'types/collection';
+import { EncryptedEnteFile, EnteFile } from 'types/file';
+import { decryptFile, mergeMetadata, sortFiles } from 'utils/file';
+
+export interface SavedCollectionFiles {
+ collectionLocalID: string;
+ files: EnteFile[];
+}
+const ENDPOINT = getEndpoint();
+const COLLECTION_FILES_TABLE = 'collection-files';
+const COLLECTIONS_TABLE = 'collections';
+
+const getLastSyncKey = (collectionUID: string) => `${collectionUID}-time`;
+
+export const getLocalFiles = async (
+ collectionUID: string
+): Promise => {
+ const localSavedcollectionFiles =
+ (await localForage.getItem(
+ COLLECTION_FILES_TABLE
+ )) || [];
+ const matchedCollection = localSavedcollectionFiles.find(
+ (item) => item.collectionLocalID === collectionUID
+ );
+ return matchedCollection?.files || [];
+};
+
+const savecollectionFiles = async (
+ collectionUID: string,
+ files: EnteFile[]
+) => {
+ const collectionFiles =
+ (await localForage.getItem(
+ COLLECTION_FILES_TABLE
+ )) || [];
+ await localForage.setItem(
+ COLLECTION_FILES_TABLE,
+ dedupeCollectionFiles([
+ { collectionLocalID: collectionUID, files },
+ ...collectionFiles,
+ ])
+ );
+};
+
+export const getLocalCollections = async (collectionKey: string) => {
+ const localCollections =
+ (await localForage.getItem(COLLECTIONS_TABLE)) || [];
+ const collection =
+ localCollections.find(
+ (localSavedPublicCollection) =>
+ localSavedPublicCollection.key === collectionKey
+ ) || null;
+ return collection;
+};
+
+const saveCollection = async (collection: Collection) => {
+ const collections =
+ (await localForage.getItem(COLLECTIONS_TABLE)) ?? [];
+ await localForage.setItem(
+ COLLECTIONS_TABLE,
+ dedupeCollections([collection, ...collections])
+ );
+};
+
+const dedupeCollections = (collections: Collection[]) => {
+ const keySet = new Set([]);
+ return collections.filter((collection) => {
+ if (!keySet.has(collection.key)) {
+ keySet.add(collection.key);
+ return true;
+ } else {
+ return false;
+ }
+ });
+};
+
+const dedupeCollectionFiles = (collectionFiles: SavedCollectionFiles[]) => {
+ const keySet = new Set([]);
+ return collectionFiles.filter(({ collectionLocalID: collectionUID }) => {
+ if (!keySet.has(collectionUID)) {
+ keySet.add(collectionUID);
+ return true;
+ } else {
+ return false;
+ }
+ });
+};
+
+async function getSyncTime(collectionUID: string): Promise {
+ const lastSyncKey = getLastSyncKey(collectionUID);
+ const lastSyncTime = await localForage.getItem(lastSyncKey);
+ return lastSyncTime ?? 0;
+}
+
+const updateSyncTime = async (collectionUID: string, time: number) =>
+ await localForage.setItem(getLastSyncKey(collectionUID), time);
+
+export const syncPublicFiles = async (
+ token: string,
+ collection: Collection,
+ setPublicFiles: (files: EnteFile[]) => void
+) => {
+ try {
+ let files: EnteFile[] = [];
+ const sortAsc = collection?.pubMagicMetadata?.data.asc ?? false;
+ const collectionUID = String(collection.id);
+ const localFiles = await getLocalFiles(collectionUID);
+ files = [...files, ...localFiles];
+ try {
+ const lastSyncTime = await getSyncTime(collectionUID);
+ if (collection.updationTime === lastSyncTime) {
+ return sortFiles(files, sortAsc);
+ }
+ const fetchedFiles = await fetchFiles(
+ token,
+ collection,
+ lastSyncTime,
+ files,
+ setPublicFiles
+ );
+
+ files = [...files, ...fetchedFiles];
+ const latestVersionFiles = new Map();
+ files.forEach((file) => {
+ const uid = `${file.collectionID}-${file.id}`;
+ if (
+ !latestVersionFiles.has(uid) ||
+ latestVersionFiles.get(uid).updationTime < file.updationTime
+ ) {
+ latestVersionFiles.set(uid, file);
+ }
+ });
+ files = [];
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ for (const [_, file] of latestVersionFiles) {
+ if (file.isDeleted) {
+ continue;
+ }
+ files.push(file);
+ }
+ await savecollectionFiles(collectionUID, files);
+ await updateSyncTime(collectionUID, collection.updationTime);
+ setPublicFiles([...sortFiles(mergeMetadata(files), sortAsc)]);
+ } catch (e) {
+ const parsedError = parseSharingErrorCodes(e);
+ logError(e, 'failed to sync shared collection files');
+ if (parsedError.message === CustomError.TOKEN_EXPIRED) {
+ throw e;
+ }
+ }
+ return [...sortFiles(mergeMetadata(files), sortAsc)];
+ } catch (e) {
+ logError(e, 'failed to get local or sync shared collection files');
+ throw e;
+ }
+};
+
+const fetchFiles = async (
+ castToken: string,
+ collection: Collection,
+ sinceTime: number,
+ files: EnteFile[],
+ setPublicFiles: (files: EnteFile[]) => void
+): Promise => {
+ try {
+ let decryptedFiles: EnteFile[] = [];
+ let time = sinceTime;
+ let resp;
+ const sortAsc = collection?.pubMagicMetadata?.data.asc ?? false;
+ do {
+ if (!castToken) {
+ break;
+ }
+ resp = await HTTPService.get(
+ `${ENDPOINT}/cast/diff`,
+ {
+ sinceTime: time,
+ },
+ {
+ 'Cache-Control': 'no-cache',
+ 'X-Cast-Access-Token': castToken,
+ }
+ );
+ decryptedFiles = [
+ ...decryptedFiles,
+ ...(await Promise.all(
+ resp.data.diff.map(async (file: EncryptedEnteFile) => {
+ if (!file.isDeleted) {
+ return await decryptFile(file, collection.key);
+ } else {
+ return file;
+ }
+ }) as Promise[]
+ )),
+ ];
+
+ if (resp.data.diff.length) {
+ time = resp.data.diff.slice(-1)[0].updationTime;
+ }
+ setPublicFiles(
+ sortFiles(
+ mergeMetadata(
+ [...(files || []), ...decryptedFiles].filter(
+ (item) => !item.isDeleted
+ )
+ ),
+ sortAsc
+ )
+ );
+ } while (resp.data.hasMore);
+ return decryptedFiles;
+ } catch (e) {
+ logError(e, 'Get cast files failed');
+ throw e;
+ }
+};
+
+export const getCastCollection = async (
+ castToken: string,
+ collectionKey: string
+): Promise => {
+ try {
+ const resp = await HTTPService.get(`${ENDPOINT}/cast/info`, null, {
+ 'Cache-Control': 'no-cache',
+ 'X-Cast-Access-Token': castToken,
+ });
+ const fetchedCollection = resp.data.collection;
+
+ const cryptoWorker = await ComlinkCryptoWorker.getInstance();
+
+ const collectionName = (fetchedCollection.name =
+ fetchedCollection.name ||
+ (await cryptoWorker.decryptToUTF8(
+ fetchedCollection.encryptedName,
+ fetchedCollection.nameDecryptionNonce,
+ collectionKey
+ )));
+
+ let collectionPublicMagicMetadata: CollectionPublicMagicMetadata;
+ if (fetchedCollection.pubMagicMetadata?.data) {
+ collectionPublicMagicMetadata = {
+ ...fetchedCollection.pubMagicMetadata,
+ data: await cryptoWorker.decryptMetadata(
+ fetchedCollection.pubMagicMetadata.data,
+ fetchedCollection.pubMagicMetadata.header,
+ collectionKey
+ ),
+ };
+ }
+
+ const collection = {
+ ...fetchedCollection,
+ name: collectionName,
+ key: collectionKey,
+ pubMagicMetadata: collectionPublicMagicMetadata,
+ };
+ await saveCollection(collection);
+ return collection;
+ } catch (e) {
+ logError(e, 'failed to get cast collection');
+ throw e;
+ }
+};
+
+export const removeCollection = async (
+ collectionUID: string,
+ collectionKey: string
+) => {
+ const collections =
+ (await localForage.getItem(COLLECTIONS_TABLE)) || [];
+ await localForage.setItem(
+ COLLECTIONS_TABLE,
+ collections.filter((collection) => collection.key !== collectionKey)
+ );
+ await removeCollectionFiles(collectionUID);
+};
+
+export const removeCollectionFiles = async (collectionUID: string) => {
+ await localForage.removeItem(getLastSyncKey(collectionUID));
+ const collectionFiles =
+ (await localForage.getItem(
+ COLLECTION_FILES_TABLE
+ )) ?? [];
+ await localForage.setItem(
+ COLLECTION_FILES_TABLE,
+ collectionFiles.filter(
+ (collectionFiles) =>
+ collectionFiles.collectionLocalID !== collectionUID
+ )
+ );
+};
+
+export const storeCastData = (payloadObj: Object) => {
+ // iterate through all the keys in the payload object and set them in localStorage.
+ for (const key in payloadObj) {
+ window.localStorage.setItem(key, payloadObj[key]);
+ }
+};
diff --git a/apps/cast/src/services/castDownloadManager.ts b/apps/cast/src/services/castDownloadManager.ts
new file mode 100644
index 000000000..c1a786c76
--- /dev/null
+++ b/apps/cast/src/services/castDownloadManager.ts
@@ -0,0 +1,273 @@
+import {
+ generateStreamFromArrayBuffer,
+ getRenderableFileURL,
+ createTypedObjectURL,
+} from 'utils/file';
+import { EnteFile } from 'types/file';
+
+import { FILE_TYPE } from 'constants/file';
+import { CustomError } from '@ente/shared/error';
+import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
+import { CACHES } from 'constants/cache';
+import { CacheStorageService } from './cache/cacheStorageService';
+import { LimitedCache } from 'types/cache';
+import { getCastFileURL, getCastThumbnailURL } from '@ente/shared/network/api';
+import HTTPService from '@ente/shared/network/HTTPService';
+import { logError } from '@ente/shared/sentry';
+
+class CastDownloadManager {
+ private fileObjectURLPromise = new Map<
+ string,
+ Promise<{ original: string[]; converted: string[] }>
+ >();
+ private thumbnailObjectURLPromise = new Map>();
+
+ private fileDownloadProgress = new Map();
+
+ private progressUpdater: (value: Map) => void;
+
+ setProgressUpdater(progressUpdater: (value: Map) => void) {
+ this.progressUpdater = progressUpdater;
+ }
+
+ private async getThumbnailCache() {
+ try {
+ const thumbnailCache = await CacheStorageService.open(
+ CACHES.THUMBS
+ );
+ return thumbnailCache;
+ } catch (e) {
+ return null;
+ // ignore
+ }
+ }
+
+ public async getCachedThumbnail(
+ file: EnteFile,
+ thumbnailCache?: LimitedCache
+ ) {
+ try {
+ if (!thumbnailCache) {
+ thumbnailCache = await this.getThumbnailCache();
+ }
+ const cacheResp: Response = await thumbnailCache?.match(
+ file.id.toString()
+ );
+
+ if (cacheResp) {
+ return URL.createObjectURL(await cacheResp.blob());
+ }
+ return null;
+ } catch (e) {
+ logError(e, 'failed to get cached thumbnail');
+ throw e;
+ }
+ }
+
+ public async getThumbnail(file: EnteFile, castToken: string) {
+ try {
+ if (!this.thumbnailObjectURLPromise.has(file.id)) {
+ const downloadPromise = async () => {
+ const thumbnailCache = await this.getThumbnailCache();
+ const cachedThumb = await this.getCachedThumbnail(
+ file,
+ thumbnailCache
+ );
+ if (cachedThumb) {
+ return cachedThumb;
+ }
+
+ const thumb = await this.downloadThumb(castToken, file);
+ const thumbBlob = new Blob([thumb]);
+ try {
+ await thumbnailCache?.put(
+ file.id.toString(),
+ new Response(thumbBlob)
+ );
+ } catch (e) {
+ // TODO: handle storage full exception.
+ }
+ return URL.createObjectURL(thumbBlob);
+ };
+ this.thumbnailObjectURLPromise.set(file.id, downloadPromise());
+ }
+
+ return await this.thumbnailObjectURLPromise.get(file.id);
+ } catch (e) {
+ this.thumbnailObjectURLPromise.delete(file.id);
+ logError(e, 'get castDownloadManager preview Failed');
+ throw e;
+ }
+ }
+
+ private downloadThumb = async (castToken: string, file: EnteFile) => {
+ const resp = await HTTPService.get(
+ getCastThumbnailURL(file.id),
+ null,
+ {
+ 'X-Cast-Access-Token': castToken,
+ },
+ { responseType: 'arraybuffer' }
+ );
+ if (typeof resp.data === 'undefined') {
+ throw Error(CustomError.REQUEST_FAILED);
+ }
+ const cryptoWorker = await ComlinkCryptoWorker.getInstance();
+ const decrypted = await cryptoWorker.decryptThumbnail(
+ new Uint8Array(resp.data),
+ await cryptoWorker.fromB64(file.thumbnail.decryptionHeader),
+ file.key
+ );
+ return decrypted;
+ };
+
+ getFile = async (file: EnteFile, castToken: string, forPreview = false) => {
+ const fileKey = forPreview ? `${file.id}_preview` : `${file.id}`;
+ try {
+ const getFilePromise = async () => {
+ const fileStream = await this.downloadFile(castToken, file);
+ const fileBlob = await new Response(fileStream).blob();
+ if (forPreview) {
+ return await getRenderableFileURL(file, fileBlob);
+ } else {
+ const fileURL = await createTypedObjectURL(
+ fileBlob,
+ file.metadata.title
+ );
+ return { converted: [fileURL], original: [fileURL] };
+ }
+ };
+
+ if (!this.fileObjectURLPromise.get(fileKey)) {
+ this.fileObjectURLPromise.set(fileKey, getFilePromise());
+ }
+ const fileURLs = await this.fileObjectURLPromise.get(fileKey);
+ return fileURLs;
+ } catch (e) {
+ this.fileObjectURLPromise.delete(fileKey);
+ logError(e, 'castDownloadManager failed to get file');
+ throw e;
+ }
+ };
+
+ public async getCachedOriginalFile(file: EnteFile) {
+ return await this.fileObjectURLPromise.get(file.id.toString());
+ }
+
+ async downloadFile(castToken: string, file: EnteFile) {
+ const cryptoWorker = await ComlinkCryptoWorker.getInstance();
+ const onDownloadProgress = this.trackDownloadProgress(file.id);
+
+ if (
+ file.metadata.fileType === FILE_TYPE.IMAGE ||
+ file.metadata.fileType === FILE_TYPE.LIVE_PHOTO
+ ) {
+ const resp = await HTTPService.get(
+ getCastFileURL(file.id),
+ null,
+ {
+ 'X-Cast-Access-Token': castToken,
+ },
+ { responseType: 'arraybuffer' }
+ );
+ if (typeof resp.data === 'undefined') {
+ throw Error(CustomError.REQUEST_FAILED);
+ }
+ const decrypted = await cryptoWorker.decryptFile(
+ new Uint8Array(resp.data),
+ await cryptoWorker.fromB64(file.file.decryptionHeader),
+ file.key
+ );
+ return generateStreamFromArrayBuffer(decrypted);
+ }
+ const resp = await fetch(getCastFileURL(file.id), {
+ headers: {
+ 'X-Cast-Access-Token': castToken,
+ },
+ });
+ const reader = resp.body.getReader();
+
+ const contentLength = +resp.headers.get('Content-Length');
+ let downloadedBytes = 0;
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ const decryptionHeader = await cryptoWorker.fromB64(
+ file.file.decryptionHeader
+ );
+ const fileKey = await cryptoWorker.fromB64(file.key);
+ const { pullState, decryptionChunkSize } =
+ await cryptoWorker.initChunkDecryption(
+ decryptionHeader,
+ fileKey
+ );
+ let data = new Uint8Array();
+ // The following function handles each data chunk
+ function push() {
+ // "done" is a Boolean and value a "Uint8Array"
+ reader.read().then(async ({ done, value }) => {
+ // Is there more data to read?
+ if (!done) {
+ downloadedBytes += value.byteLength;
+ onDownloadProgress({
+ loaded: downloadedBytes,
+ total: contentLength,
+ });
+ const buffer = new Uint8Array(
+ data.byteLength + value.byteLength
+ );
+ buffer.set(new Uint8Array(data), 0);
+ buffer.set(new Uint8Array(value), data.byteLength);
+ if (buffer.length > decryptionChunkSize) {
+ const fileData = buffer.slice(
+ 0,
+ decryptionChunkSize
+ );
+ const { decryptedData } =
+ await cryptoWorker.decryptFileChunk(
+ fileData,
+ pullState
+ );
+ controller.enqueue(decryptedData);
+ data = buffer.slice(decryptionChunkSize);
+ } else {
+ data = buffer;
+ }
+ push();
+ } else {
+ if (data) {
+ const { decryptedData } =
+ await cryptoWorker.decryptFileChunk(
+ data,
+ pullState
+ );
+ controller.enqueue(decryptedData);
+ data = null;
+ }
+ controller.close();
+ }
+ });
+ }
+
+ push();
+ },
+ });
+ return stream;
+ }
+
+ trackDownloadProgress = (fileID: number) => {
+ return (event: { loaded: number; total: number }) => {
+ if (event.loaded === event.total) {
+ this.fileDownloadProgress.delete(fileID);
+ } else {
+ this.fileDownloadProgress.set(
+ fileID,
+ Math.round((event.loaded * 100) / event.total)
+ );
+ }
+ this.progressUpdater(new Map(this.fileDownloadProgress));
+ };
+ };
+}
+
+export default new CastDownloadManager();
diff --git a/apps/cast/src/services/events.ts b/apps/cast/src/services/events.ts
new file mode 100644
index 000000000..670ecac47
--- /dev/null
+++ b/apps/cast/src/services/events.ts
@@ -0,0 +1,12 @@
+import { EventEmitter } from 'eventemitter3';
+
+// When registering event handlers,
+// handle errors to avoid unhandled rejection or propagation to emit call
+
+export enum Events {
+ LOGOUT = 'logout',
+ FILE_UPLOADED = 'fileUploaded',
+ LOCAL_FILES_UPDATED = 'localFilesUpdated',
+}
+
+export const eventBus = new EventEmitter();
diff --git a/apps/cast/src/services/ffmpeg/ffmpegFactory.ts b/apps/cast/src/services/ffmpeg/ffmpegFactory.ts
new file mode 100644
index 000000000..eca7287c6
--- /dev/null
+++ b/apps/cast/src/services/ffmpeg/ffmpegFactory.ts
@@ -0,0 +1,29 @@
+// import isElectron from 'is-electron';
+// import { ElectronFFmpeg } from 'services/electron/ffmpeg';
+import { ElectronFile } from 'types/upload';
+import ComlinkFFmpegWorker from 'utils/comlink/ComlinkFFmpegWorker';
+
+export interface IFFmpeg {
+ run: (
+ cmd: string[],
+ inputFile: File | ElectronFile,
+ outputFilename: string,
+ dontTimeout?: boolean
+ ) => Promise;
+}
+
+class FFmpegFactory {
+ private client: IFFmpeg;
+ async getFFmpegClient() {
+ if (!this.client) {
+ // if (isElectron()) {
+ // this.client = new ElectronFFmpeg();
+ // } else {
+ this.client = await ComlinkFFmpegWorker.getInstance();
+ // }
+ }
+ return this.client;
+ }
+}
+
+export default new FFmpegFactory();
diff --git a/apps/cast/src/services/ffmpeg/ffmpegService.ts b/apps/cast/src/services/ffmpeg/ffmpegService.ts
new file mode 100644
index 000000000..4e6a6241f
--- /dev/null
+++ b/apps/cast/src/services/ffmpeg/ffmpegService.ts
@@ -0,0 +1,30 @@
+import {
+ FFMPEG_PLACEHOLDER,
+ INPUT_PATH_PLACEHOLDER,
+ OUTPUT_PATH_PLACEHOLDER,
+} from 'constants/ffmpeg';
+import { ElectronFile } from 'types/upload';
+import ffmpegFactory from './ffmpegFactory';
+import { logError } from '@ente/shared/sentry';
+
+export async function convertToMP4(file: File | ElectronFile) {
+ try {
+ const ffmpegClient = await ffmpegFactory.getFFmpegClient();
+ return await ffmpegClient.run(
+ [
+ FFMPEG_PLACEHOLDER,
+ '-i',
+ INPUT_PATH_PLACEHOLDER,
+ '-preset',
+ 'ultrafast',
+ OUTPUT_PATH_PLACEHOLDER,
+ ],
+ file,
+ 'output.mp4',
+ true
+ );
+ } catch (e) {
+ logError(e, 'ffmpeg convertToMP4 failed');
+ throw e;
+ }
+}
diff --git a/apps/cast/src/services/heicConversionService.ts b/apps/cast/src/services/heicConversionService.ts
new file mode 100644
index 000000000..00f49b7db
--- /dev/null
+++ b/apps/cast/src/services/heicConversionService.ts
@@ -0,0 +1,14 @@
+import { logError } from '@ente/shared/sentry';
+import WasmHEICConverterService from './wasmHeicConverter/wasmHEICConverterService';
+
+class HeicConversionService {
+ async convert(heicFileData: Blob): Promise {
+ try {
+ return await WasmHEICConverterService.convert(heicFileData);
+ } catch (e) {
+ logError(e, 'failed to convert heic file');
+ throw e;
+ }
+ }
+}
+export default new HeicConversionService();
diff --git a/apps/cast/src/services/livePhotoService.ts b/apps/cast/src/services/livePhotoService.ts
new file mode 100644
index 000000000..613d2861e
--- /dev/null
+++ b/apps/cast/src/services/livePhotoService.ts
@@ -0,0 +1,45 @@
+import JSZip from 'jszip';
+import { EnteFile } from 'types/file';
+import {
+ getFileExtensionWithDot,
+ getFileNameWithoutExtension,
+} from 'utils/file';
+
+class LivePhoto {
+ image: Uint8Array;
+ video: Uint8Array;
+ imageNameTitle: string;
+ videoNameTitle: string;
+}
+
+export const decodeLivePhoto = async (file: EnteFile, zipBlob: Blob) => {
+ const originalName = getFileNameWithoutExtension(file.metadata.title);
+ const zip = await JSZip.loadAsync(zipBlob, { createFolders: true });
+
+ const livePhoto = new LivePhoto();
+ for (const zipFilename in zip.files) {
+ if (zipFilename.startsWith('image')) {
+ livePhoto.imageNameTitle =
+ originalName + getFileExtensionWithDot(zipFilename);
+ livePhoto.image = await zip.files[zipFilename].async('uint8array');
+ } else if (zipFilename.startsWith('video')) {
+ livePhoto.videoNameTitle =
+ originalName + getFileExtensionWithDot(zipFilename);
+ livePhoto.video = await zip.files[zipFilename].async('uint8array');
+ }
+ }
+ return livePhoto;
+};
+
+export const encodeLivePhoto = async (livePhoto: LivePhoto) => {
+ const zip = new JSZip();
+ zip.file(
+ 'image' + getFileExtensionWithDot(livePhoto.imageNameTitle),
+ livePhoto.image
+ );
+ zip.file(
+ 'video' + getFileExtensionWithDot(livePhoto.videoNameTitle),
+ livePhoto.video
+ );
+ return await zip.generateAsync({ type: 'uint8array' });
+};
diff --git a/apps/cast/src/services/queueProcessor.ts b/apps/cast/src/services/queueProcessor.ts
new file mode 100644
index 000000000..97332aaee
--- /dev/null
+++ b/apps/cast/src/services/queueProcessor.ts
@@ -0,0 +1,86 @@
+import { CustomError } from '@ente/shared/error';
+
+interface RequestQueueItem {
+ request: (canceller?: RequestCanceller) => Promise;
+ successCallback: (response: any) => void;
+ failureCallback: (error: Error) => void;
+ isCanceled: { status: boolean };
+ canceller: { exec: () => void };
+}
+
+export enum PROCESSING_STRATEGY {
+ FIFO,
+ LIFO,
+}
+
+export interface RequestCanceller {
+ exec: () => void;
+}
+
+export interface CancellationStatus {
+ status: boolean;
+}
+
+export default class QueueProcessor {
+ private requestQueue: RequestQueueItem[] = [];
+
+ private requestInProcessing = 0;
+
+ constructor(
+ private maxParallelProcesses: number,
+ private processingStrategy = PROCESSING_STRATEGY.FIFO
+ ) {}
+
+ public queueUpRequest(
+ request: (canceller?: RequestCanceller) => Promise
+ ) {
+ const isCanceled: CancellationStatus = { status: false };
+ const canceller: RequestCanceller = {
+ exec: () => {
+ isCanceled.status = true;
+ },
+ };
+
+ const promise = new Promise((resolve, reject) => {
+ this.requestQueue.push({
+ request,
+ successCallback: resolve,
+ failureCallback: reject,
+ isCanceled,
+ canceller,
+ });
+ this.pollQueue();
+ });
+
+ return { promise, canceller };
+ }
+
+ private async pollQueue() {
+ if (this.requestInProcessing < this.maxParallelProcesses) {
+ this.requestInProcessing++;
+ this.processQueue();
+ }
+ }
+
+ private async processQueue() {
+ while (this.requestQueue.length > 0) {
+ const queueItem =
+ this.processingStrategy === PROCESSING_STRATEGY.LIFO
+ ? this.requestQueue.pop()
+ : this.requestQueue.shift();
+ let response = null;
+
+ if (queueItem.isCanceled.status) {
+ queueItem.failureCallback(Error(CustomError.REQUEST_CANCELLED));
+ } else {
+ try {
+ response = await queueItem.request(queueItem.canceller);
+ queueItem.successCallback(response);
+ } catch (e) {
+ queueItem.failureCallback(e);
+ }
+ }
+ }
+ this.requestInProcessing--;
+ }
+}
diff --git a/apps/cast/src/services/readerService.ts b/apps/cast/src/services/readerService.ts
new file mode 100644
index 000000000..765ea19e1
--- /dev/null
+++ b/apps/cast/src/services/readerService.ts
@@ -0,0 +1,93 @@
+import { logError } from '@ente/shared/sentry';
+import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
+import { ElectronFile } from 'types/upload';
+
+export async function getUint8ArrayView(
+ file: Blob | ElectronFile
+): Promise {
+ try {
+ return new Uint8Array(await file.arrayBuffer());
+ } catch (e) {
+ logError(e, 'reading file blob failed', {
+ fileSize: convertBytesToHumanReadable(file.size),
+ });
+ throw e;
+ }
+}
+
+export function getFileStream(file: File, chunkSize: number) {
+ const fileChunkReader = fileChunkReaderMaker(file, chunkSize);
+
+ const stream = new ReadableStream({
+ async pull(controller: ReadableStreamDefaultController) {
+ const chunk = await fileChunkReader.next();
+ if (chunk.done) {
+ controller.close();
+ } else {
+ controller.enqueue(chunk.value);
+ }
+ },
+ });
+ const chunkCount = Math.ceil(file.size / chunkSize);
+ return {
+ stream,
+ chunkCount,
+ };
+}
+
+export async function getElectronFileStream(
+ file: ElectronFile,
+ chunkSize: number
+) {
+ const chunkCount = Math.ceil(file.size / chunkSize);
+ return {
+ stream: await file.stream(),
+ chunkCount,
+ };
+}
+
+async function* fileChunkReaderMaker(file: File, chunkSize: number) {
+ let offset = 0;
+ while (offset < file.size) {
+ const blob = file.slice(offset, chunkSize + offset);
+ const fileChunk = await getUint8ArrayView(blob);
+ yield fileChunk;
+ offset += chunkSize;
+ }
+ return null;
+}
+
+// depreciated
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+async function getUint8ArrayViewOld(
+ reader: FileReader,
+ file: Blob
+): Promise {
+ return await new Promise((resolve, reject) => {
+ reader.onabort = () =>
+ reject(
+ Error(
+ `file reading was aborted, file size= ${convertBytesToHumanReadable(
+ file.size
+ )}`
+ )
+ );
+ reader.onerror = () =>
+ reject(
+ Error(
+ `file reading has failed, file size= ${convertBytesToHumanReadable(
+ file.size
+ )} , reason= ${reader.error}`
+ )
+ );
+ reader.onload = () => {
+ // Do whatever you want with the file contents
+ const result =
+ typeof reader.result === 'string'
+ ? new TextEncoder().encode(reader.result)
+ : new Uint8Array(reader.result);
+ resolve(result);
+ };
+ reader.readAsArrayBuffer(file);
+ });
+}
diff --git a/apps/cast/src/services/typeDetectionService.ts b/apps/cast/src/services/typeDetectionService.ts
new file mode 100644
index 000000000..49191ef20
--- /dev/null
+++ b/apps/cast/src/services/typeDetectionService.ts
@@ -0,0 +1,108 @@
+import { FILE_TYPE } from 'constants/file';
+import { ElectronFile, FileTypeInfo } from 'types/upload';
+import {
+ WHITELISTED_FILE_FORMATS,
+ KNOWN_NON_MEDIA_FORMATS,
+} from 'constants/upload';
+import { CustomError } from '@ente/shared/error';
+import { getFileExtension } from 'utils/file';
+import { logError } from '@ente/shared/sentry';
+import { getUint8ArrayView } from './readerService';
+import FileType, { FileTypeResult } from 'file-type';
+import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
+
+function getFileSize(file: File | ElectronFile) {
+ return file.size;
+}
+
+const TYPE_VIDEO = 'video';
+const TYPE_IMAGE = 'image';
+const CHUNK_SIZE_FOR_TYPE_DETECTION = 4100;
+
+export async function getFileType(
+ receivedFile: File | ElectronFile
+): Promise {
+ try {
+ let fileType: FILE_TYPE;
+ let typeResult: FileTypeResult;
+
+ if (receivedFile instanceof File) {
+ typeResult = await extractFileType(receivedFile);
+ } else {
+ typeResult = await extractElectronFileType(receivedFile);
+ }
+
+ const mimTypeParts: string[] = typeResult.mime?.split('/');
+
+ if (mimTypeParts?.length !== 2) {
+ throw Error(CustomError.INVALID_MIME_TYPE(typeResult.mime));
+ }
+ switch (mimTypeParts[0]) {
+ case TYPE_IMAGE:
+ fileType = FILE_TYPE.IMAGE;
+ break;
+ case TYPE_VIDEO:
+ fileType = FILE_TYPE.VIDEO;
+ break;
+ default:
+ throw Error(CustomError.NON_MEDIA_FILE);
+ }
+ return {
+ fileType,
+ exactType: typeResult.ext,
+ mimeType: typeResult.mime,
+ };
+ } catch (e) {
+ const fileFormat = getFileExtension(receivedFile.name);
+ const fileSize = convertBytesToHumanReadable(getFileSize(receivedFile));
+ const whiteListedFormat = WHITELISTED_FILE_FORMATS.find(
+ (a) => a.exactType === fileFormat
+ );
+ if (whiteListedFormat) {
+ return whiteListedFormat;
+ }
+ if (KNOWN_NON_MEDIA_FORMATS.includes(fileFormat)) {
+ throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
+ }
+ if (e.message === CustomError.NON_MEDIA_FILE) {
+ logError(e, 'unsupported file format', {
+ fileFormat,
+ fileSize,
+ });
+ throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
+ }
+ logError(e, 'type detection failed', {
+ fileFormat,
+ fileSize,
+ });
+ throw Error(CustomError.TYPE_DETECTION_FAILED(fileFormat));
+ }
+}
+
+async function extractFileType(file: File) {
+ const fileBlobChunk = file.slice(0, CHUNK_SIZE_FOR_TYPE_DETECTION);
+ const fileDataChunk = await getUint8ArrayView(fileBlobChunk);
+ return getFileTypeFromBuffer(fileDataChunk);
+}
+
+async function extractElectronFileType(file: ElectronFile) {
+ const stream = await file.stream();
+ const reader = stream.getReader();
+ const { value: fileDataChunk } = await reader.read();
+ await reader.cancel();
+ return getFileTypeFromBuffer(fileDataChunk);
+}
+
+async function getFileTypeFromBuffer(buffer: Uint8Array) {
+ const result = await FileType.fromBuffer(buffer);
+ if (!result?.mime) {
+ let logableInfo = '';
+ try {
+ logableInfo = `result: ${JSON.stringify(result)}`;
+ } catch (e) {
+ logableInfo = 'failed to stringify result';
+ }
+ throw Error(`mimetype missing from file type result - ${logableInfo}`);
+ }
+ return result;
+}
diff --git a/apps/cast/src/services/wasm/ffmpeg.ts b/apps/cast/src/services/wasm/ffmpeg.ts
new file mode 100644
index 000000000..bb27ed4db
--- /dev/null
+++ b/apps/cast/src/services/wasm/ffmpeg.ts
@@ -0,0 +1,116 @@
+import { promiseWithTimeout } from '@ente/shared/promise';
+import { createFFmpeg, FFmpeg } from 'ffmpeg-wasm';
+import QueueProcessor from 'services/queueProcessor';
+import { getUint8ArrayView } from 'services/readerService';
+import { logError } from '@ente/shared/sentry';
+import { generateTempName } from 'utils/temp';
+import { addLogLine } from '@ente/shared/logging';
+
+const INPUT_PATH_PLACEHOLDER = 'INPUT';
+const FFMPEG_PLACEHOLDER = 'FFMPEG';
+const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
+
+const FFMPEG_EXECUTION_WAIT_TIME = 30 * 1000;
+
+export class WasmFFmpeg {
+ private ffmpeg: FFmpeg;
+ private ready: Promise = null;
+ private ffmpegTaskQueue = new QueueProcessor(1);
+
+ constructor() {
+ this.ffmpeg = createFFmpeg({
+ corePath: '/js/ffmpeg/ffmpeg-core.js',
+ mt: false,
+ });
+
+ this.ready = this.init();
+ }
+
+ private async init() {
+ if (!this.ffmpeg.isLoaded()) {
+ await this.ffmpeg.load();
+ }
+ }
+
+ async run(
+ cmd: string[],
+ inputFile: File,
+ outputFileName: string,
+ dontTimeout = false
+ ) {
+ const response = this.ffmpegTaskQueue.queueUpRequest(() => {
+ if (dontTimeout) {
+ return this.execute(cmd, inputFile, outputFileName);
+ } else {
+ return promiseWithTimeout(
+ this.execute(cmd, inputFile, outputFileName),
+ FFMPEG_EXECUTION_WAIT_TIME
+ );
+ }
+ });
+ try {
+ return await response.promise;
+ } catch (e) {
+ logError(e, 'ffmpeg run failed');
+ throw e;
+ }
+ }
+
+ private async execute(
+ cmd: string[],
+ inputFile: File,
+ outputFileName: string
+ ) {
+ let tempInputFilePath: string;
+ let tempOutputFilePath: string;
+ try {
+ await this.ready;
+ const extension = getFileExtension(inputFile.name);
+ const tempNameSuffix = extension ? `input.${extension}` : 'input';
+ tempInputFilePath = `${generateTempName(10, tempNameSuffix)}`;
+ this.ffmpeg.FS(
+ 'writeFile',
+ tempInputFilePath,
+ await getUint8ArrayView(inputFile)
+ );
+ tempOutputFilePath = `${generateTempName(10, outputFileName)}`;
+
+ cmd = cmd.map((cmdPart) => {
+ if (cmdPart === FFMPEG_PLACEHOLDER) {
+ return '';
+ } else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
+ return tempInputFilePath;
+ } else if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
+ return tempOutputFilePath;
+ } else {
+ return cmdPart;
+ }
+ });
+ addLogLine(`${cmd}`);
+ await this.ffmpeg.run(...cmd);
+ return new File(
+ [this.ffmpeg.FS('readFile', tempOutputFilePath)],
+ outputFileName
+ );
+ } finally {
+ try {
+ this.ffmpeg.FS('unlink', tempInputFilePath);
+ } catch (e) {
+ logError(e, 'unlink input file failed');
+ }
+ try {
+ this.ffmpeg.FS('unlink', tempOutputFilePath);
+ } catch (e) {
+ logError(e, 'unlink output file failed');
+ }
+ }
+ }
+}
+
+function getFileExtension(filename: string) {
+ const lastDotPosition = filename.lastIndexOf('.');
+ if (lastDotPosition === -1) return null;
+ else {
+ return filename.slice(lastDotPosition + 1);
+ }
+}
diff --git a/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterClient.ts b/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterClient.ts
new file mode 100644
index 000000000..0ca183f22
--- /dev/null
+++ b/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterClient.ts
@@ -0,0 +1,13 @@
+import * as HeicConvert from 'heic-convert';
+import { getUint8ArrayView } from 'services/readerService';
+
+export async function convertHEIC(
+ fileBlob: Blob,
+ format: string
+): Promise {
+ const filedata = await getUint8ArrayView(fileBlob);
+ const result = await HeicConvert({ buffer: filedata, format });
+ const convertedFileData = new Uint8Array(result);
+ const convertedFileBlob = new Blob([convertedFileData]);
+ return convertedFileBlob;
+}
diff --git a/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterService.ts b/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterService.ts
new file mode 100644
index 000000000..a17884a9d
--- /dev/null
+++ b/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterService.ts
@@ -0,0 +1,114 @@
+import QueueProcessor from 'services/queueProcessor';
+import { retryAsyncFunction } from 'utils/network';
+import { DedicatedConvertWorker } from 'worker/convert.worker';
+import { ComlinkWorker } from 'utils/comlink/comlinkWorker';
+import { getDedicatedConvertWorker } from 'utils/comlink/ComlinkConvertWorker';
+import { logError } from '@ente/shared/sentry';
+import { addLogLine } from '@ente/shared/logging';
+import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
+import { CustomError } from '@ente/shared/error';
+
+const WORKER_POOL_SIZE = 2;
+const MAX_CONVERSION_IN_PARALLEL = 1;
+const WAIT_TIME_BEFORE_NEXT_ATTEMPT_IN_MICROSECONDS = [100, 100];
+const WAIT_TIME_IN_MICROSECONDS = 30 * 1000;
+const BREATH_TIME_IN_MICROSECONDS = 1000;
+const CONVERT_FORMAT = 'JPEG';
+
+class HEICConverter {
+ private convertProcessor = new QueueProcessor(
+ MAX_CONVERSION_IN_PARALLEL
+ );
+ private workerPool: ComlinkWorker[] = [];
+ private ready: Promise;
+
+ constructor() {
+ this.ready = this.init();
+ }
+ private async init() {
+ this.workerPool = [];
+ for (let i = 0; i < WORKER_POOL_SIZE; i++) {
+ this.workerPool.push(getDedicatedConvertWorker());
+ }
+ }
+ async convert(fileBlob: Blob): Promise {
+ await this.ready;
+ const response = this.convertProcessor.queueUpRequest(() =>
+ retryAsyncFunction(async () => {
+ const convertWorker = this.workerPool.shift();
+ const worker = await convertWorker.remote;
+ try {
+ const convertedHEIC = await new Promise(
+ (resolve, reject) => {
+ const main = async () => {
+ try {
+ const timeout = setTimeout(() => {
+ reject(Error('wait time exceeded'));
+ }, WAIT_TIME_IN_MICROSECONDS);
+ const startTime = Date.now();
+ const convertedHEIC =
+ await worker.convertHEIC(
+ fileBlob,
+ CONVERT_FORMAT
+ );
+ addLogLine(
+ `originalFileSize:${convertBytesToHumanReadable(
+ fileBlob?.size
+ )},convertedFileSize:${convertBytesToHumanReadable(
+ convertedHEIC?.size
+ )}, heic conversion time: ${
+ Date.now() - startTime
+ }ms `
+ );
+ clearTimeout(timeout);
+ resolve(convertedHEIC);
+ } catch (e) {
+ reject(e);
+ }
+ };
+ main();
+ }
+ );
+ if (!convertedHEIC || convertedHEIC?.size === 0) {
+ logError(
+ Error(`converted heic fileSize is Zero`),
+ 'converted heic fileSize is Zero',
+ {
+ originalFileSize: convertBytesToHumanReadable(
+ fileBlob?.size ?? 0
+ ),
+ convertedFileSize: convertBytesToHumanReadable(
+ convertedHEIC?.size ?? 0
+ ),
+ }
+ );
+ }
+ await new Promise((resolve) => {
+ setTimeout(
+ () => resolve(null),
+ BREATH_TIME_IN_MICROSECONDS
+ );
+ });
+ this.workerPool.push(convertWorker);
+ return convertedHEIC;
+ } catch (e) {
+ logError(e, 'heic conversion failed');
+ convertWorker.terminate();
+ this.workerPool.push(getDedicatedConvertWorker());
+ throw e;
+ }
+ }, WAIT_TIME_BEFORE_NEXT_ATTEMPT_IN_MICROSECONDS)
+ );
+ try {
+ return await response.promise;
+ } catch (e) {
+ if (e.message === CustomError.REQUEST_CANCELLED) {
+ // ignore
+ return null;
+ }
+ throw e;
+ }
+ }
+}
+
+export default new HEICConverter();
diff --git a/apps/cast/src/styles/global.css b/apps/cast/src/styles/global.css
new file mode 100644
index 000000000..d79f9f302
--- /dev/null
+++ b/apps/cast/src/styles/global.css
@@ -0,0 +1,3 @@
+#__next {
+ height: 100%;
+}
diff --git a/apps/cast/src/types/cache/index.ts b/apps/cast/src/types/cache/index.ts
new file mode 100644
index 000000000..2920ece10
--- /dev/null
+++ b/apps/cast/src/types/cache/index.ts
@@ -0,0 +1,20 @@
+export interface LimitedCacheStorage {
+ open: (cacheName: string) => Promise;
+ delete: (cacheName: string) => Promise;
+}
+
+export interface LimitedCache {
+ match: (key: string) => Promise;
+ put: (key: string, data: Response) => Promise;
+ delete: (key: string) => Promise;
+}
+
+export interface ProxiedLimitedCacheStorage {
+ open: (cacheName: string) => Promise;
+ delete: (cacheName: string) => Promise;
+}
+export interface ProxiedWorkerLimitedCache {
+ match: (key: string) => Promise;
+ put: (key: string, data: ArrayBuffer) => Promise;
+ delete: (key: string) => Promise;
+}
diff --git a/apps/cast/src/types/cast/index.ts b/apps/cast/src/types/cast/index.ts
new file mode 100644
index 000000000..f082e433e
--- /dev/null
+++ b/apps/cast/src/types/cast/index.ts
@@ -0,0 +1,5 @@
+export interface CastPayload {
+ collectionID: number;
+ collectionKey: string;
+ castToken: string;
+}
diff --git a/apps/cast/src/types/collection/index.ts b/apps/cast/src/types/collection/index.ts
new file mode 100644
index 000000000..489c0bdde
--- /dev/null
+++ b/apps/cast/src/types/collection/index.ts
@@ -0,0 +1,159 @@
+import { EnteFile } from 'types/file';
+import { CollectionSummaryType, CollectionType } from 'constants/collection';
+import {
+ EncryptedMagicMetadata,
+ MagicMetadataCore,
+ SUB_TYPE,
+ VISIBILITY_STATE,
+} from 'types/magicMetadata';
+
+export enum COLLECTION_ROLE {
+ VIEWER = 'VIEWER',
+ OWNER = 'OWNER',
+ COLLABORATOR = 'COLLABORATOR',
+ UNKNOWN = 'UNKNOWN',
+}
+
+export interface CollectionUser {
+ id: number;
+ email: string;
+ role: COLLECTION_ROLE;
+}
+
+export interface EncryptedCollection {
+ id: number;
+ owner: CollectionUser;
+ // collection name was unencrypted in the past, so we need to keep it as optional
+ name?: string;
+ encryptedKey: string;
+ keyDecryptionNonce: string;
+ encryptedName: string;
+ nameDecryptionNonce: string;
+ type: CollectionType;
+ attributes: collectionAttributes;
+ sharees: CollectionUser[];
+ publicURLs?: PublicURL[];
+ updationTime: number;
+ isDeleted: boolean;
+ magicMetadata: EncryptedMagicMetadata;
+ pubMagicMetadata: EncryptedMagicMetadata;
+ sharedMagicMetadata: EncryptedMagicMetadata;
+}
+
+export interface Collection
+ extends Omit<
+ EncryptedCollection,
+ | 'encryptedKey'
+ | 'keyDecryptionNonce'
+ | 'encryptedName'
+ | 'nameDecryptionNonce'
+ | 'magicMetadata'
+ | 'pubMagicMetadata'
+ | 'sharedMagicMetadata'
+ > {
+ key: string;
+ name: string;
+ magicMetadata: CollectionMagicMetadata;
+ pubMagicMetadata: CollectionPublicMagicMetadata;
+ sharedMagicMetadata: CollectionShareeMagicMetadata;
+}
+
+// define a method on Collection interface to return the sync key as collection.id-time
+// this is used to store the last sync time of a collection in local storage
+
+export interface PublicURL {
+ url: string;
+ deviceLimit: number;
+ validTill: number;
+ enableDownload: boolean;
+ enableCollect: boolean;
+ passwordEnabled: boolean;
+ nonce?: string;
+ opsLimit?: number;
+ memLimit?: number;
+}
+
+export interface UpdatePublicURL {
+ collectionID: number;
+ disablePassword?: boolean;
+ enableDownload?: boolean;
+ enableCollect?: boolean;
+ validTill?: number;
+ deviceLimit?: number;
+ passHash?: string;
+ nonce?: string;
+ opsLimit?: number;
+ memLimit?: number;
+}
+
+export interface CreatePublicAccessTokenRequest {
+ collectionID: number;
+ validTill?: number;
+ deviceLimit?: number;
+}
+
+export interface EncryptedFileKey {
+ id: number;
+ encryptedKey: string;
+ keyDecryptionNonce: string;
+}
+
+export interface AddToCollectionRequest {
+ collectionID: number;
+ files: EncryptedFileKey[];
+}
+
+export interface MoveToCollectionRequest {
+ fromCollectionID: number;
+ toCollectionID: number;
+ files: EncryptedFileKey[];
+}
+
+export interface collectionAttributes {
+ encryptedPath?: string;
+ pathDecryptionNonce?: string;
+}
+
+export type CollectionToFileMap = Map;
+
+export interface RemoveFromCollectionRequest {
+ collectionID: number;
+ fileIDs: number[];
+}
+
+export interface CollectionMagicMetadataProps {
+ visibility?: VISIBILITY_STATE;
+ subType?: SUB_TYPE;
+ order?: number;
+}
+
+export type CollectionMagicMetadata =
+ MagicMetadataCore;
+
+export interface CollectionShareeMetadataProps {
+ visibility?: VISIBILITY_STATE;
+}
+export type CollectionShareeMagicMetadata =
+ MagicMetadataCore;
+
+export interface CollectionPublicMagicMetadataProps {
+ asc?: boolean;
+ coverID?: number;
+}
+
+export type CollectionPublicMagicMetadata =
+ MagicMetadataCore;
+
+export interface CollectionSummary {
+ id: number;
+ name: string;
+ type: CollectionSummaryType;
+ coverFile: EnteFile;
+ latestFile: EnteFile;
+ fileCount: number;
+ updationTime: number;
+ order?: number;
+}
+
+export type CollectionSummaries = Map;
+export type CollectionFilesCount = Map;
diff --git a/apps/cast/src/types/file/index.ts b/apps/cast/src/types/file/index.ts
new file mode 100644
index 000000000..7c55a8af2
--- /dev/null
+++ b/apps/cast/src/types/file/index.ts
@@ -0,0 +1,103 @@
+import {
+ EncryptedMagicMetadata,
+ MagicMetadataCore,
+ VISIBILITY_STATE,
+} from 'types/magicMetadata';
+import { Metadata } from 'types/upload';
+
+export interface MetadataFileAttributes {
+ encryptedData: string;
+ decryptionHeader: string;
+}
+export interface S3FileAttributes {
+ objectKey: string;
+ decryptionHeader: string;
+}
+
+export interface FileInfo {
+ fileSize: number;
+ thumbSize: number;
+}
+
+export interface EncryptedEnteFile {
+ id: number;
+ collectionID: number;
+ ownerID: number;
+ file: S3FileAttributes;
+ thumbnail: S3FileAttributes;
+ metadata: MetadataFileAttributes;
+ info: FileInfo;
+ magicMetadata: EncryptedMagicMetadata;
+ pubMagicMetadata: EncryptedMagicMetadata;
+ encryptedKey: string;
+ keyDecryptionNonce: string;
+ isDeleted: boolean;
+ updationTime: number;
+}
+
+export interface EnteFile
+ extends Omit<
+ EncryptedEnteFile,
+ | 'metadata'
+ | 'pubMagicMetadata'
+ | 'magicMetadata'
+ | 'encryptedKey'
+ | 'keyDecryptionNonce'
+ > {
+ metadata: Metadata;
+ magicMetadata: FileMagicMetadata;
+ pubMagicMetadata: FilePublicMagicMetadata;
+ isTrashed?: boolean;
+ key: string;
+ src?: string;
+ msrc?: string;
+ html?: string;
+ w?: number;
+ h?: number;
+ title?: string;
+ deleteBy?: number;
+ isSourceLoaded?: boolean;
+ originalVideoURL?: string;
+ originalImageURL?: string;
+ dataIndex?: number;
+ conversionFailed?: boolean;
+ isConverted?: boolean;
+}
+
+export interface TrashRequest {
+ items: TrashRequestItems[];
+}
+
+export interface TrashRequestItems {
+ fileID: number;
+ collectionID: number;
+}
+
+export interface FileWithUpdatedMagicMetadata {
+ file: EnteFile;
+ updatedMagicMetadata: FileMagicMetadata;
+}
+
+export interface FileWithUpdatedPublicMagicMetadata {
+ file: EnteFile;
+ updatedPublicMagicMetadata: FilePublicMagicMetadata;
+}
+
+export interface FileMagicMetadataProps {
+ visibility?: VISIBILITY_STATE;
+ filePaths?: string[];
+}
+
+export type FileMagicMetadata = MagicMetadataCore;
+
+export interface FilePublicMagicMetadataProps {
+ editedTime?: number;
+ editedName?: string;
+ caption?: string;
+ uploaderName?: string;
+ w?: number;
+ h?: number;
+}
+
+export type FilePublicMagicMetadata =
+ MagicMetadataCore;
diff --git a/apps/cast/src/types/gallery/index.ts b/apps/cast/src/types/gallery/index.ts
new file mode 100644
index 000000000..927340b96
--- /dev/null
+++ b/apps/cast/src/types/gallery/index.ts
@@ -0,0 +1,57 @@
+// import { CollectionDownloadProgressAttributes } from 'components/Collections/CollectionDownloadProgress';
+// import { CollectionSelectorAttributes } from 'components/Collections/CollectionSelector';
+// import { TimeStampListItem } from 'components/PhotoList';
+import { User } from '@ente/shared/user/types';
+import { Collection } from 'types/collection';
+import { EnteFile } from 'types/file';
+
+export type SelectedState = {
+ [k: number]: boolean;
+ ownCount: number;
+ count: number;
+ collectionID: number;
+};
+export type SetFiles = React.Dispatch>;
+export type SetCollections = React.Dispatch>;
+export type SetLoading = React.Dispatch>;
+// export type SetCollectionSelectorAttributes = React.Dispatch<
+// React.SetStateAction
+// >;
+// export type SetCollectionDownloadProgressAttributes = React.Dispatch<
+// React.SetStateAction
+// >;
+
+export type MergedSourceURL = {
+ original: string;
+ converted: string;
+};
+export enum UploadTypeSelectorIntent {
+ normalUpload,
+ import,
+ collectPhotos,
+}
+export type GalleryContextType = {
+ thumbs: Map;
+ files: Map;
+ showPlanSelectorModal: () => void;
+ setActiveCollectionID: (collectionID: number) => void;
+ syncWithRemote: (force?: boolean, silent?: boolean) => Promise;
+ setBlockingLoad: (value: boolean) => void;
+ setIsInSearchMode: (value: boolean) => void;
+ // photoListHeader: TimeStampListItem;
+ openExportModal: () => void;
+ authenticateUser: (callback: () => void) => void;
+ user: User;
+ userIDToEmailMap: Map;
+ emailList: string[];
+ openHiddenSection: (callback?: () => void) => void;
+ isClipSearchResult: boolean;
+};
+
+export enum CollectionSelectorIntent {
+ upload,
+ add,
+ move,
+ restore,
+ unhide,
+}
diff --git a/apps/cast/src/types/magicMetadata/index.ts b/apps/cast/src/types/magicMetadata/index.ts
new file mode 100644
index 000000000..cc01eea84
--- /dev/null
+++ b/apps/cast/src/types/magicMetadata/index.ts
@@ -0,0 +1,29 @@
+export interface MagicMetadataCore {
+ version: number;
+ count: number;
+ header: string;
+ data: T;
+}
+
+export type EncryptedMagicMetadata = MagicMetadataCore;
+
+export enum VISIBILITY_STATE {
+ VISIBLE = 0,
+ ARCHIVED = 1,
+ HIDDEN = 2,
+}
+
+export enum SUB_TYPE {
+ DEFAULT = 0,
+ DEFAULT_HIDDEN = 1,
+ QUICK_LINK_COLLECTION = 2,
+}
+
+export interface BulkUpdateMagicMetadataRequest {
+ metadataList: UpdateMagicMetadataRequest[];
+}
+
+export interface UpdateMagicMetadataRequest {
+ id: number;
+ magicMetadata: EncryptedMagicMetadata;
+}
diff --git a/apps/cast/src/types/upload/index.ts b/apps/cast/src/types/upload/index.ts
new file mode 100644
index 000000000..192315ef1
--- /dev/null
+++ b/apps/cast/src/types/upload/index.ts
@@ -0,0 +1,170 @@
+import {
+ B64EncryptionResult,
+ LocalFileAttributes,
+} from '@ente/shared/crypto/types';
+import { FILE_TYPE } from 'constants/file';
+import { Collection } from 'types/collection';
+import {
+ MetadataFileAttributes,
+ S3FileAttributes,
+ FilePublicMagicMetadata,
+ FilePublicMagicMetadataProps,
+} from 'types/file';
+import { EncryptedMagicMetadata } from 'types/magicMetadata';
+
+export interface DataStream {
+ stream: ReadableStream;
+ chunkCount: number;
+}
+
+export function isDataStream(object: any): object is DataStream {
+ return 'stream' in object;
+}
+
+export type Logger = (message: string) => void;
+
+export interface Metadata {
+ title: string;
+ creationTime: number;
+ modificationTime: number;
+ latitude: number;
+ longitude: number;
+ fileType: FILE_TYPE;
+ hasStaticThumbnail?: boolean;
+ hash?: string;
+ imageHash?: string;
+ videoHash?: string;
+ localID?: number;
+ version?: number;
+ deviceFolder?: string;
+}
+
+export interface Location {
+ latitude: number;
+ longitude: number;
+}
+
+export interface ParsedMetadataJSON {
+ creationTime: number;
+ modificationTime: number;
+ latitude: number;
+ longitude: number;
+}
+
+export interface MultipartUploadURLs {
+ objectKey: string;
+ partURLs: string[];
+ completeURL: string;
+}
+
+export interface FileTypeInfo {
+ fileType: FILE_TYPE;
+ exactType: string;
+ mimeType?: string;
+ imageType?: string;
+ videoType?: string;
+}
+
+/*
+ * ElectronFile is a custom interface that is used to represent
+ * any file on disk as a File-like object in the Electron desktop app.
+ *
+ * This was added to support the auto-resuming of failed uploads
+ * which needed absolute paths to the files which the
+ * normal File interface does not provide.
+ */
+export interface ElectronFile {
+ name: string;
+ path: string;
+ size: number;
+ lastModified: number;
+ stream: () => Promise>;
+ blob: () => Promise;
+ arrayBuffer: () => Promise;
+}
+
+export interface UploadAsset {
+ isLivePhoto?: boolean;
+ file?: File | ElectronFile;
+ livePhotoAssets?: LivePhotoAssets;
+ isElectron?: boolean;
+}
+export interface LivePhotoAssets {
+ image: globalThis.File | ElectronFile;
+ video: globalThis.File | ElectronFile;
+}
+
+export interface FileWithCollection extends UploadAsset {
+ localID: number;
+ collection?: Collection;
+ collectionID?: number;
+}
+
+export type ParsedMetadataJSONMap = Map;
+
+export interface UploadURL {
+ url: string;
+ objectKey: string;
+}
+
+export interface FileInMemory {
+ filedata: Uint8Array | DataStream;
+ thumbnail: Uint8Array;
+ hasStaticThumbnail: boolean;
+}
+
+export interface FileWithMetadata
+ extends Omit {
+ metadata: Metadata;
+ localID: number;
+ pubMagicMetadata: FilePublicMagicMetadata;
+}
+
+export interface EncryptedFile {
+ file: ProcessedFile;
+ fileKey: B64EncryptionResult;
+}
+export interface ProcessedFile {
+ file: LocalFileAttributes;
+ thumbnail: LocalFileAttributes;
+ metadata: LocalFileAttributes;
+ pubMagicMetadata: EncryptedMagicMetadata;
+ localID: number;
+}
+export interface BackupedFile {
+ file: S3FileAttributes;
+ thumbnail: S3FileAttributes;
+ metadata: MetadataFileAttributes;
+ pubMagicMetadata: EncryptedMagicMetadata;
+}
+
+export interface UploadFile extends BackupedFile {
+ collectionID: number;
+ encryptedKey: string;
+ keyDecryptionNonce: string;
+}
+
+export interface ParsedExtractedMetadata {
+ location: Location;
+ creationTime: number;
+ width: number;
+ height: number;
+}
+
+// This is used to prompt the user the make upload strategy choice
+export interface ImportSuggestion {
+ rootFolderName: string;
+ hasNestedFolders: boolean;
+ hasRootLevelFileWithFolder: boolean;
+}
+
+export interface PublicUploadProps {
+ token: string;
+ passwordToken: string;
+ accessedThroughSharedURL: boolean;
+}
+
+export interface ExtractMetadataResult {
+ metadata: Metadata;
+ publicMagicMetadata: FilePublicMagicMetadataProps;
+}
diff --git a/apps/cast/src/types/upload/ui.ts b/apps/cast/src/types/upload/ui.ts
new file mode 100644
index 000000000..4283e30a1
--- /dev/null
+++ b/apps/cast/src/types/upload/ui.ts
@@ -0,0 +1,43 @@
+import { UPLOAD_RESULT, UPLOAD_STAGES } from 'constants/upload';
+
+export type FileID = number;
+export type FileName = string;
+
+export type PercentageUploaded = number;
+export type UploadFileNames = Map;
+
+export interface UploadCounter {
+ finished: number;
+ total: number;
+}
+
+export interface InProgressUpload {
+ localFileID: FileID;
+ progress: PercentageUploaded;
+}
+
+export interface FinishedUpload {
+ localFileID: FileID;
+ result: UPLOAD_RESULT;
+}
+
+export type InProgressUploads = Map;
+
+export type FinishedUploads = Map;
+
+export type SegregatedFinishedUploads = Map;
+
+export interface ProgressUpdater {
+ setPercentComplete: React.Dispatch>;
+ setUploadCounter: React.Dispatch>;
+ setUploadStage: React.Dispatch>;
+ setInProgressUploads: React.Dispatch<
+ React.SetStateAction
+ >;
+ setFinishedUploads: React.Dispatch<
+ React.SetStateAction
+ >;
+ setUploadFilenames: React.Dispatch>;
+ setHasLivePhotos: React.Dispatch>;
+ setUploadProgressView: React.Dispatch>;
+}
diff --git a/apps/cast/src/utils/collection/index.ts b/apps/cast/src/utils/collection/index.ts
new file mode 100644
index 000000000..54396ab8e
--- /dev/null
+++ b/apps/cast/src/utils/collection/index.ts
@@ -0,0 +1,147 @@
+import { COLLECTION_ROLE, Collection } from 'types/collection';
+import {
+ CollectionSummaryType,
+ CollectionType,
+ HIDE_FROM_COLLECTION_BAR_TYPES,
+ OPTIONS_NOT_HAVING_COLLECTION_TYPES,
+} from 'constants/collection';
+import { SUB_TYPE, VISIBILITY_STATE } from 'types/magicMetadata';
+import { User } from '@ente/shared/user/types';
+import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
+
+export enum COLLECTION_OPS_TYPE {
+ ADD,
+ MOVE,
+ REMOVE,
+ RESTORE,
+ UNHIDE,
+}
+
+export function getSelectedCollection(
+ collectionID: number,
+ collections: Collection[]
+) {
+ return collections.find((collection) => collection.id === collectionID);
+}
+
+export const shouldShowOptions = (type: CollectionSummaryType) => {
+ return !OPTIONS_NOT_HAVING_COLLECTION_TYPES.has(type);
+};
+export const showEmptyTrashQuickOption = (type: CollectionSummaryType) => {
+ return type === CollectionSummaryType.trash;
+};
+export const showDownloadQuickOption = (type: CollectionSummaryType) => {
+ return (
+ type === CollectionSummaryType.folder ||
+ type === CollectionSummaryType.favorites ||
+ type === CollectionSummaryType.album ||
+ type === CollectionSummaryType.uncategorized ||
+ type === CollectionSummaryType.hiddenItems ||
+ type === CollectionSummaryType.incomingShareViewer ||
+ type === CollectionSummaryType.incomingShareCollaborator ||
+ type === CollectionSummaryType.outgoingShare ||
+ type === CollectionSummaryType.sharedOnlyViaLink ||
+ type === CollectionSummaryType.archived ||
+ type === CollectionSummaryType.pinned
+ );
+};
+export const showShareQuickOption = (type: CollectionSummaryType) => {
+ return (
+ type === CollectionSummaryType.folder ||
+ type === CollectionSummaryType.album ||
+ type === CollectionSummaryType.outgoingShare ||
+ type === CollectionSummaryType.sharedOnlyViaLink ||
+ type === CollectionSummaryType.archived ||
+ type === CollectionSummaryType.incomingShareViewer ||
+ type === CollectionSummaryType.incomingShareCollaborator ||
+ type === CollectionSummaryType.pinned
+ );
+};
+export const shouldBeShownOnCollectionBar = (type: CollectionSummaryType) => {
+ return !HIDE_FROM_COLLECTION_BAR_TYPES.has(type);
+};
+
+export const getUserOwnedCollections = (collections: Collection[]) => {
+ const user: User = getData(LS_KEYS.USER);
+ if (!user?.id) {
+ throw Error('user missing');
+ }
+ return collections.filter((collection) => collection.owner.id === user.id);
+};
+
+export const isDefaultHiddenCollection = (collection: Collection) =>
+ collection.magicMetadata?.data.subType === SUB_TYPE.DEFAULT_HIDDEN;
+
+export const isHiddenCollection = (collection: Collection) =>
+ collection.magicMetadata?.data.visibility === VISIBILITY_STATE.HIDDEN;
+
+export const isQuickLinkCollection = (collection: Collection) =>
+ collection.magicMetadata?.data.subType === SUB_TYPE.QUICK_LINK_COLLECTION;
+
+export function isOutgoingShare(collection: Collection, user: User): boolean {
+ return collection.owner.id === user.id && collection.sharees?.length > 0;
+}
+
+export function isIncomingShare(collection: Collection, user: User) {
+ return collection.owner.id !== user.id;
+}
+
+export function isIncomingViewerShare(collection: Collection, user: User) {
+ const sharee = collection.sharees?.find((sharee) => sharee.id === user.id);
+ return sharee?.role === COLLECTION_ROLE.VIEWER;
+}
+
+export function isIncomingCollabShare(collection: Collection, user: User) {
+ const sharee = collection.sharees?.find((sharee) => sharee.id === user.id);
+ return sharee?.role === COLLECTION_ROLE.COLLABORATOR;
+}
+
+export function isSharedOnlyViaLink(collection: Collection) {
+ return collection.publicURLs?.length && !collection.sharees?.length;
+}
+
+export function isValidMoveTarget(
+ sourceCollectionID: number,
+ targetCollection: Collection,
+ user: User
+) {
+ return (
+ sourceCollectionID !== targetCollection.id &&
+ !isHiddenCollection(targetCollection) &&
+ !isQuickLinkCollection(targetCollection) &&
+ !isIncomingShare(targetCollection, user)
+ );
+}
+
+export function isValidReplacementAlbum(
+ collection: Collection,
+ user: User,
+ wantedCollectionName: string
+) {
+ return (
+ collection.name === wantedCollectionName &&
+ (collection.type === CollectionType.album ||
+ collection.type === CollectionType.folder) &&
+ !isHiddenCollection(collection) &&
+ !isQuickLinkCollection(collection) &&
+ !isIncomingShare(collection, user)
+ );
+}
+
+export function getCollectionNameMap(
+ collections: Collection[]
+): Map {
+ return new Map(
+ collections.map((collection) => [collection.id, collection.name])
+ );
+}
+
+export function getNonHiddenCollections(
+ collections: Collection[]
+): Collection[] {
+ return collections.filter((collection) => !isHiddenCollection(collection));
+}
+
+export function getHiddenCollections(collections: Collection[]): Collection[] {
+ return collections.filter((collection) => isHiddenCollection(collection));
+}
diff --git a/apps/cast/src/utils/comlink/ComlinkConvertWorker.ts b/apps/cast/src/utils/comlink/ComlinkConvertWorker.ts
new file mode 100644
index 000000000..563a6f0d6
--- /dev/null
+++ b/apps/cast/src/utils/comlink/ComlinkConvertWorker.ts
@@ -0,0 +1,30 @@
+import { Remote } from 'comlink';
+import { DedicatedConvertWorker } from 'worker/convert.worker';
+import { ComlinkWorker } from './comlinkWorker';
+import { runningInBrowser } from '@ente/shared/platform';
+
+class ComlinkConvertWorker {
+ private comlinkWorkerInstance: Remote;
+
+ async getInstance() {
+ if (!this.comlinkWorkerInstance) {
+ this.comlinkWorkerInstance = await getDedicatedConvertWorker()
+ .remote;
+ }
+ return this.comlinkWorkerInstance;
+ }
+}
+
+export const getDedicatedConvertWorker = () => {
+ if (runningInBrowser()) {
+ const cryptoComlinkWorker = new ComlinkWorker<
+ typeof DedicatedConvertWorker
+ >(
+ 'ente-convert-worker',
+ new Worker(new URL('worker/convert.worker.ts', import.meta.url))
+ );
+ return cryptoComlinkWorker;
+ }
+};
+
+export default new ComlinkConvertWorker();
diff --git a/apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts b/apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts
new file mode 100644
index 000000000..2410d469a
--- /dev/null
+++ b/apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts
@@ -0,0 +1,25 @@
+import { Remote } from 'comlink';
+import { DedicatedCryptoWorker } from 'worker/crypto.worker';
+import { ComlinkWorker } from './comlinkWorker';
+
+class ComlinkCryptoWorker {
+ private comlinkWorkerInstance: Promise>;
+
+ async getInstance() {
+ if (!this.comlinkWorkerInstance) {
+ const comlinkWorker = getDedicatedCryptoWorker();
+ this.comlinkWorkerInstance = comlinkWorker.remote;
+ }
+ return this.comlinkWorkerInstance;
+ }
+}
+
+export const getDedicatedCryptoWorker = () => {
+ const cryptoComlinkWorker = new ComlinkWorker(
+ 'ente-crypto-worker',
+ new Worker(new URL('worker/crypto.worker.ts', import.meta.url))
+ );
+ return cryptoComlinkWorker;
+};
+
+export default new ComlinkCryptoWorker();
diff --git a/apps/cast/src/utils/comlink/ComlinkFFmpegWorker.ts b/apps/cast/src/utils/comlink/ComlinkFFmpegWorker.ts
new file mode 100644
index 000000000..efc71bf99
--- /dev/null
+++ b/apps/cast/src/utils/comlink/ComlinkFFmpegWorker.ts
@@ -0,0 +1,25 @@
+import { Remote } from 'comlink';
+import { DedicatedFFmpegWorker } from 'worker/ffmpeg.worker';
+import { ComlinkWorker } from './comlinkWorker';
+
+class ComlinkFFmpegWorker {
+ private comlinkWorkerInstance: Promise>;
+
+ async getInstance() {
+ if (!this.comlinkWorkerInstance) {
+ const comlinkWorker = getDedicatedFFmpegWorker();
+ this.comlinkWorkerInstance = comlinkWorker.remote;
+ }
+ return this.comlinkWorkerInstance;
+ }
+}
+
+const getDedicatedFFmpegWorker = () => {
+ const cryptoComlinkWorker = new ComlinkWorker(
+ 'ente-ffmpeg-worker',
+ new Worker(new URL('worker/ffmpeg.worker.ts', import.meta.url))
+ );
+ return cryptoComlinkWorker;
+};
+
+export default new ComlinkFFmpegWorker();
diff --git a/apps/cast/src/utils/comlink/comlinkWorker.ts b/apps/cast/src/utils/comlink/comlinkWorker.ts
new file mode 100644
index 000000000..f566a3f90
--- /dev/null
+++ b/apps/cast/src/utils/comlink/comlinkWorker.ts
@@ -0,0 +1,27 @@
+import { addLocalLog } from '@ente/shared/logging';
+import { Remote, wrap } from 'comlink';
+// import { WorkerElectronCacheStorageClient } from 'services/workerElectronCache/client';
+
+export class ComlinkWorker InstanceType> {
+ public remote: Promise>>;
+ private worker: Worker;
+ private name: string;
+
+ constructor(name: string, worker: Worker) {
+ this.name = name;
+ this.worker = worker;
+
+ this.worker.onerror = (errorEvent) => {
+ console.error('Got error event from worker', errorEvent);
+ };
+ addLocalLog(() => `Initiated ${this.name}`);
+ const comlink = wrap(this.worker);
+ this.remote = new comlink() as Promise>>;
+ // expose(WorkerElectronCacheStorageClient, this.worker);
+ }
+
+ public terminate() {
+ this.worker.terminate();
+ addLocalLog(() => `Terminated ${this.name}`);
+ }
+}
diff --git a/apps/cast/src/utils/file/blob.ts b/apps/cast/src/utils/file/blob.ts
new file mode 100644
index 000000000..cb2e8c7a2
--- /dev/null
+++ b/apps/cast/src/utils/file/blob.ts
@@ -0,0 +1,15 @@
+export const readAsDataURL = (blob) =>
+ new Promise((resolve, reject) => {
+ const fileReader = new FileReader();
+ fileReader.onload = () => resolve(fileReader.result as string);
+ fileReader.onerror = () => reject(fileReader.error);
+ fileReader.readAsDataURL(blob);
+ });
+
+export const readAsText = (blob) =>
+ new Promise((resolve, reject) => {
+ const fileReader = new FileReader();
+ fileReader.onload = () => resolve(fileReader.result as string);
+ fileReader.onerror = () => reject(fileReader.error);
+ fileReader.readAsText(blob);
+ });
diff --git a/apps/cast/src/utils/file/index.ts b/apps/cast/src/utils/file/index.ts
new file mode 100644
index 000000000..c7818b36d
--- /dev/null
+++ b/apps/cast/src/utils/file/index.ts
@@ -0,0 +1,577 @@
+import { SelectedState } from 'types/gallery';
+import {
+ EnteFile,
+ EncryptedEnteFile,
+ FileMagicMetadata,
+ FilePublicMagicMetadata,
+} from 'types/file';
+import { decodeLivePhoto } from 'services/livePhotoService';
+import { getFileType } from 'services/typeDetectionService';
+import { logError } from '@ente/shared/sentry';
+import {
+ TYPE_HEIC,
+ TYPE_HEIF,
+ FILE_TYPE,
+ SUPPORTED_RAW_FORMATS,
+ RAW_FORMATS,
+} from 'constants/file';
+import CastDownloadManager from 'services/castDownloadManager';
+import heicConversionService from 'services/heicConversionService';
+import * as ffmpegService from 'services/ffmpeg/ffmpegService';
+import { isArchivedFile } from 'utils/magicMetadata';
+
+import { CustomError } from '@ente/shared/error';
+import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
+import isElectron from 'is-electron';
+import { isPlaybackPossible } from 'utils/photoFrame';
+import { FileTypeInfo } from 'types/upload';
+import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
+import { User } from '@ente/shared/user/types';
+import { addLogLine, addLocalLog } from '@ente/shared/logging';
+import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
+
+const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000;
+
+export enum FILE_OPS_TYPE {
+ DOWNLOAD,
+ FIX_TIME,
+ ARCHIVE,
+ UNARCHIVE,
+ HIDE,
+ TRASH,
+ DELETE_PERMANENTLY,
+}
+
+export function groupFilesBasedOnCollectionID(files: EnteFile[]) {
+ const collectionWiseFiles = new Map();
+ for (const file of files) {
+ if (!collectionWiseFiles.has(file.collectionID)) {
+ collectionWiseFiles.set(file.collectionID, []);
+ }
+ collectionWiseFiles.get(file.collectionID).push(file);
+ }
+ return collectionWiseFiles;
+}
+
+function getSelectedFileIds(selectedFiles: SelectedState) {
+ const filesIDs: number[] = [];
+ for (const [key, val] of Object.entries(selectedFiles)) {
+ if (typeof val === 'boolean' && val) {
+ filesIDs.push(Number(key));
+ }
+ }
+ return new Set(filesIDs);
+}
+export function getSelectedFiles(
+ selected: SelectedState,
+ files: EnteFile[]
+): EnteFile[] {
+ const selectedFilesIDs = getSelectedFileIds(selected);
+ return files.filter((file) => selectedFilesIDs.has(file.id));
+}
+
+export function sortFiles(files: EnteFile[], sortAsc = false) {
+ // sort based on the time of creation time of the file,
+ // for files with same creation time, sort based on the time of last modification
+ const factor = sortAsc ? -1 : 1;
+ return files.sort((a, b) => {
+ if (a.metadata.creationTime === b.metadata.creationTime) {
+ return (
+ factor *
+ (b.metadata.modificationTime - a.metadata.modificationTime)
+ );
+ }
+ return factor * (b.metadata.creationTime - a.metadata.creationTime);
+ });
+}
+
+export function sortTrashFiles(files: EnteFile[]) {
+ return files.sort((a, b) => {
+ if (a.deleteBy === b.deleteBy) {
+ if (a.metadata.creationTime === b.metadata.creationTime) {
+ return (
+ b.metadata.modificationTime - a.metadata.modificationTime
+ );
+ }
+ return b.metadata.creationTime - a.metadata.creationTime;
+ }
+ return a.deleteBy - b.deleteBy;
+ });
+}
+
+export async function decryptFile(
+ file: EncryptedEnteFile,
+ collectionKey: string
+): Promise {
+ try {
+ const worker = await ComlinkCryptoWorker.getInstance();
+ const {
+ encryptedKey,
+ keyDecryptionNonce,
+ metadata,
+ magicMetadata,
+ pubMagicMetadata,
+ ...restFileProps
+ } = file;
+ const fileKey = await worker.decryptB64(
+ encryptedKey,
+ keyDecryptionNonce,
+ collectionKey
+ );
+ const fileMetadata = await worker.decryptMetadata(
+ metadata.encryptedData,
+ metadata.decryptionHeader,
+ fileKey
+ );
+ let fileMagicMetadata: FileMagicMetadata;
+ let filePubMagicMetadata: FilePublicMagicMetadata;
+ if (magicMetadata?.data) {
+ fileMagicMetadata = {
+ ...file.magicMetadata,
+ data: await worker.decryptMetadata(
+ magicMetadata.data,
+ magicMetadata.header,
+ fileKey
+ ),
+ };
+ }
+ if (pubMagicMetadata?.data) {
+ filePubMagicMetadata = {
+ ...pubMagicMetadata,
+ data: await worker.decryptMetadata(
+ pubMagicMetadata.data,
+ pubMagicMetadata.header,
+ fileKey
+ ),
+ };
+ }
+ return {
+ ...restFileProps,
+ key: fileKey,
+ metadata: fileMetadata,
+ magicMetadata: fileMagicMetadata,
+ pubMagicMetadata: filePubMagicMetadata,
+ };
+ } catch (e) {
+ logError(e, 'file decryption failed');
+ throw e;
+ }
+}
+
+export function getFileNameWithoutExtension(filename: string) {
+ const lastDotPosition = filename.lastIndexOf('.');
+ if (lastDotPosition === -1) return filename;
+ else return filename.slice(0, lastDotPosition);
+}
+
+export function getFileExtensionWithDot(filename: string) {
+ const lastDotPosition = filename.lastIndexOf('.');
+ if (lastDotPosition === -1) return '';
+ else return filename.slice(lastDotPosition);
+}
+
+export function splitFilenameAndExtension(filename: string): [string, string] {
+ const lastDotPosition = filename.lastIndexOf('.');
+ if (lastDotPosition === -1) return [filename, null];
+ else
+ return [
+ filename.slice(0, lastDotPosition),
+ filename.slice(lastDotPosition + 1),
+ ];
+}
+
+export function getFileExtension(filename: string) {
+ return splitFilenameAndExtension(filename)[1]?.toLocaleLowerCase();
+}
+
+export function generateStreamFromArrayBuffer(data: Uint8Array) {
+ return new ReadableStream({
+ async start(controller: ReadableStreamDefaultController) {
+ controller.enqueue(data);
+ controller.close();
+ },
+ });
+}
+
+export async function getRenderableFileURL(file: EnteFile, fileBlob: Blob) {
+ switch (file.metadata.fileType) {
+ case FILE_TYPE.IMAGE: {
+ const convertedBlob = await getRenderableImage(
+ file.metadata.title,
+ fileBlob
+ );
+ const { originalURL, convertedURL } = getFileObjectURLs(
+ fileBlob,
+ convertedBlob
+ );
+ return {
+ converted: [convertedURL],
+ original: [originalURL],
+ };
+ }
+ case FILE_TYPE.LIVE_PHOTO: {
+ return await getRenderableLivePhotoURL(file, fileBlob);
+ }
+ case FILE_TYPE.VIDEO: {
+ const convertedBlob = await getPlayableVideo(
+ file.metadata.title,
+ fileBlob
+ );
+ const { originalURL, convertedURL } = getFileObjectURLs(
+ fileBlob,
+ convertedBlob
+ );
+ return {
+ converted: [convertedURL],
+ original: [originalURL],
+ };
+ }
+ default: {
+ const previewURL = await createTypedObjectURL(
+ fileBlob,
+ file.metadata.title
+ );
+ return {
+ converted: [previewURL],
+ original: [previewURL],
+ };
+ }
+ }
+}
+
+async function getRenderableLivePhotoURL(
+ file: EnteFile,
+ fileBlob: Blob
+): Promise<{ original: string[]; converted: string[] }> {
+ const livePhoto = await decodeLivePhoto(file, fileBlob);
+ const imageBlob = new Blob([livePhoto.image]);
+ const videoBlob = new Blob([livePhoto.video]);
+ const convertedImageBlob = await getRenderableImage(
+ livePhoto.imageNameTitle,
+ imageBlob
+ );
+ const convertedVideoBlob = await getPlayableVideo(
+ livePhoto.videoNameTitle,
+ videoBlob,
+ true
+ );
+ const { originalURL: originalImageURL, convertedURL: convertedImageURL } =
+ getFileObjectURLs(imageBlob, convertedImageBlob);
+
+ const { originalURL: originalVideoURL, convertedURL: convertedVideoURL } =
+ getFileObjectURLs(videoBlob, convertedVideoBlob);
+ return {
+ converted: [convertedImageURL, convertedVideoURL],
+ original: [originalImageURL, originalVideoURL],
+ };
+}
+
+export async function getPlayableVideo(
+ videoNameTitle: string,
+ videoBlob: Blob,
+ forceConvert = false
+) {
+ try {
+ const isPlayable = await isPlaybackPossible(
+ URL.createObjectURL(videoBlob)
+ );
+ if (isPlayable && !forceConvert) {
+ return videoBlob;
+ } else {
+ if (!forceConvert && !isElectron()) {
+ return null;
+ }
+ addLogLine(
+ 'video format not supported, converting it name:',
+ videoNameTitle
+ );
+ const mp4ConvertedVideo = await ffmpegService.convertToMP4(
+ new File([videoBlob], videoNameTitle)
+ );
+ addLogLine('video successfully converted', videoNameTitle);
+ return new Blob([await mp4ConvertedVideo.arrayBuffer()]);
+ }
+ } catch (e) {
+ addLogLine('video conversion failed', videoNameTitle);
+ logError(e, 'video conversion failed');
+ return null;
+ }
+}
+
+export async function getRenderableImage(fileName: string, imageBlob: Blob) {
+ let fileTypeInfo: FileTypeInfo;
+ try {
+ const tempFile = new File([imageBlob], fileName);
+ fileTypeInfo = await getFileType(tempFile);
+ addLocalLog(() => `file type info: ${JSON.stringify(fileTypeInfo)}`);
+ const { exactType } = fileTypeInfo;
+ let convertedImageBlob: Blob;
+ if (isRawFile(exactType)) {
+ try {
+ if (!isSupportedRawFormat(exactType)) {
+ throw Error(CustomError.UNSUPPORTED_RAW_FORMAT);
+ }
+
+ if (!isElectron()) {
+ throw Error(CustomError.NOT_AVAILABLE_ON_WEB);
+ }
+ addLogLine(
+ `RawConverter called for ${fileName}-${convertBytesToHumanReadable(
+ imageBlob.size
+ )}`
+ );
+ // convertedImageBlob = await imageProcessor.convertToJPEG(
+ // imageBlob,
+ // fileName
+ // );
+ addLogLine(`${fileName} successfully converted`);
+ } catch (e) {
+ try {
+ if (!isFileHEIC(exactType)) {
+ throw e;
+ }
+ addLogLine(
+ `HEICConverter called for ${fileName}-${convertBytesToHumanReadable(
+ imageBlob.size
+ )}`
+ );
+ convertedImageBlob = await heicConversionService.convert(
+ imageBlob
+ );
+ addLogLine(`${fileName} successfully converted`);
+ } catch (e) {
+ throw Error(CustomError.NON_PREVIEWABLE_FILE);
+ }
+ }
+ return convertedImageBlob;
+ } else {
+ return imageBlob;
+ }
+ } catch (e) {
+ logError(e, 'get Renderable Image failed', { fileTypeInfo });
+ return null;
+ }
+}
+
+export function isFileHEIC(exactType: string) {
+ return (
+ exactType.toLowerCase().endsWith(TYPE_HEIC) ||
+ exactType.toLowerCase().endsWith(TYPE_HEIF)
+ );
+}
+
+export function isRawFile(exactType: string) {
+ return RAW_FORMATS.includes(exactType.toLowerCase());
+}
+
+export function isRawFileFromFileName(fileName: string) {
+ for (const rawFormat of RAW_FORMATS) {
+ if (fileName.toLowerCase().endsWith(rawFormat)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+export function isSupportedRawFormat(exactType: string) {
+ return SUPPORTED_RAW_FORMATS.includes(exactType.toLowerCase());
+}
+
+export function mergeMetadata(files: EnteFile[]): EnteFile[] {
+ return files.map((file) => {
+ if (file.pubMagicMetadata?.data.editedTime) {
+ file.metadata.creationTime = file.pubMagicMetadata.data.editedTime;
+ }
+ if (file.pubMagicMetadata?.data.editedName) {
+ file.metadata.title = file.pubMagicMetadata.data.editedName;
+ }
+
+ return file;
+ });
+}
+
+export async function getFileFromURL(fileURL: string) {
+ const fileBlob = await (await fetch(fileURL)).blob();
+ const fileFile = new File([fileBlob], 'temp');
+ return fileFile;
+}
+
+export function getUniqueFiles(files: EnteFile[]) {
+ const idSet = new Set();
+ const uniqueFiles = files.filter((file) => {
+ if (!idSet.has(file.id)) {
+ idSet.add(file.id);
+ return true;
+ } else {
+ return false;
+ }
+ });
+
+ return uniqueFiles;
+}
+
+export const isImageOrVideo = (fileType: FILE_TYPE) =>
+ [FILE_TYPE.IMAGE, FILE_TYPE.VIDEO].includes(fileType);
+
+export const getArchivedFiles = (files: EnteFile[]) => {
+ return files.filter(isArchivedFile).map((file) => file.id);
+};
+
+export const createTypedObjectURL = async (blob: Blob, fileName: string) => {
+ const type = await getFileType(new File([blob], fileName));
+ return URL.createObjectURL(new Blob([blob], { type: type.mimeType }));
+};
+
+export const getUserOwnedFiles = (files: EnteFile[]) => {
+ const user: User = getData(LS_KEYS.USER);
+ if (!user?.id) {
+ throw Error('user missing');
+ }
+ return files.filter((file) => file.ownerID === user.id);
+};
+
+// doesn't work on firefox
+export const copyFileToClipboard = async (fileUrl: string) => {
+ const canvas = document.createElement('canvas');
+ const canvasCTX = canvas.getContext('2d');
+ const image = new Image();
+
+ const blobPromise = new Promise((resolve, reject) => {
+ let timeout: NodeJS.Timeout = null;
+ try {
+ image.setAttribute('src', fileUrl);
+ image.onload = () => {
+ canvas.width = image.width;
+ canvas.height = image.height;
+ canvasCTX.drawImage(image, 0, 0, image.width, image.height);
+ canvas.toBlob(
+ (blob) => {
+ resolve(blob);
+ },
+ 'image/png',
+ 1
+ );
+
+ clearTimeout(timeout);
+ };
+ } catch (e) {
+ void logError(e, 'failed to copy to clipboard');
+ reject(e);
+ } finally {
+ clearTimeout(timeout);
+ }
+ timeout = setTimeout(
+ () => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
+ WAIT_TIME_IMAGE_CONVERSION
+ );
+ });
+
+ const { ClipboardItem } = window;
+
+ await navigator.clipboard
+ .write([new ClipboardItem({ 'image/png': blobPromise })])
+ .catch((e) => logError(e, 'failed to copy to clipboard'));
+};
+
+export function getLatestVersionFiles(files: EnteFile[]) {
+ const latestVersionFiles = new Map();
+ files.forEach((file) => {
+ const uid = `${file.collectionID}-${file.id}`;
+ if (
+ !latestVersionFiles.has(uid) ||
+ latestVersionFiles.get(uid).updationTime < file.updationTime
+ ) {
+ latestVersionFiles.set(uid, file);
+ }
+ });
+ return Array.from(latestVersionFiles.values()).filter(
+ (file) => !file.isDeleted
+ );
+}
+
+export function getPersonalFiles(files: EnteFile[], user: User) {
+ if (!user?.id) {
+ throw Error('user missing');
+ }
+ return files.filter((file) => file.ownerID === user.id);
+}
+
+export function getIDBasedSortedFiles(files: EnteFile[]) {
+ return files.sort((a, b) => a.id - b.id);
+}
+
+export function constructFileToCollectionMap(files: EnteFile[]) {
+ const fileToCollectionsMap = new Map();
+ (files ?? []).forEach((file) => {
+ if (!fileToCollectionsMap.get(file.id)) {
+ fileToCollectionsMap.set(file.id, []);
+ }
+ fileToCollectionsMap.get(file.id).push(file.collectionID);
+ });
+ return fileToCollectionsMap;
+}
+
+export const shouldShowAvatar = (file: EnteFile, user: User) => {
+ if (!file || !user) {
+ return false;
+ }
+ // is Shared file
+ else if (file.ownerID !== user.id) {
+ return true;
+ }
+ // is public collected file
+ else if (
+ file.ownerID === user.id &&
+ file.pubMagicMetadata?.data?.uploaderName
+ ) {
+ return true;
+ } else {
+ return false;
+ }
+};
+
+export const getPreviewableImage = async (
+ file: EnteFile,
+ castToken: string
+): Promise => {
+ try {
+ let fileBlob: Blob;
+ const fileURL = await CastDownloadManager.getCachedOriginalFile(
+ file
+ )[0];
+ if (!fileURL) {
+ fileBlob = await new Response(
+ await CastDownloadManager.downloadFile(castToken, file)
+ ).blob();
+ } else {
+ fileBlob = await (await fetch(fileURL)).blob();
+ }
+ if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
+ const livePhoto = await decodeLivePhoto(file, fileBlob);
+ fileBlob = new Blob([livePhoto.image]);
+ }
+ const convertedBlob = await getRenderableImage(
+ file.metadata.title,
+ fileBlob
+ );
+ fileBlob = convertedBlob;
+ const fileType = await getFileType(
+ new File([fileBlob], file.metadata.title)
+ );
+
+ fileBlob = new Blob([fileBlob], { type: fileType.mimeType });
+ return fileBlob;
+ } catch (e) {
+ logError(e, 'failed to download file');
+ }
+};
+
+const getFileObjectURLs = (originalBlob: Blob, convertedBlob: Blob) => {
+ const originalURL = URL.createObjectURL(originalBlob);
+ const convertedURL = convertedBlob
+ ? convertedBlob === originalBlob
+ ? originalURL
+ : URL.createObjectURL(convertedBlob)
+ : null;
+ return { originalURL, convertedURL };
+};
diff --git a/apps/cast/src/utils/file/livePhoto.ts b/apps/cast/src/utils/file/livePhoto.ts
new file mode 100644
index 000000000..92fb311b1
--- /dev/null
+++ b/apps/cast/src/utils/file/livePhoto.ts
@@ -0,0 +1,42 @@
+import { FILE_TYPE } from 'constants/file';
+import { getFileExtension } from 'utils/file';
+
+const IMAGE_EXTENSIONS = [
+ 'heic',
+ 'heif',
+ 'jpeg',
+ 'jpg',
+ 'png',
+ 'gif',
+ 'bmp',
+ 'tiff',
+ 'webp',
+];
+
+const VIDEO_EXTENSIONS = [
+ 'mov',
+ 'mp4',
+ 'm4v',
+ 'avi',
+ 'wmv',
+ 'flv',
+ 'mkv',
+ 'webm',
+ '3gp',
+ '3g2',
+ 'avi',
+ 'ogv',
+ 'mpg',
+ 'mp',
+];
+
+export function getFileTypeFromExtensionForLivePhotoClustering(
+ filename: string
+) {
+ const extension = getFileExtension(filename)?.toLowerCase();
+ if (IMAGE_EXTENSIONS.includes(extension)) {
+ return FILE_TYPE.IMAGE;
+ } else if (VIDEO_EXTENSIONS.includes(extension)) {
+ return FILE_TYPE.VIDEO;
+ }
+}
diff --git a/apps/cast/src/utils/magicMetadata/index.ts b/apps/cast/src/utils/magicMetadata/index.ts
new file mode 100644
index 000000000..a650d0a82
--- /dev/null
+++ b/apps/cast/src/utils/magicMetadata/index.ts
@@ -0,0 +1,97 @@
+import { Collection } from 'types/collection';
+import { EnteFile } from 'types/file';
+import { MagicMetadataCore, VISIBILITY_STATE } from 'types/magicMetadata';
+import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
+
+export function isArchivedFile(item: EnteFile): boolean {
+ if (!item || !item.magicMetadata || !item.magicMetadata.data) {
+ return false;
+ }
+ return item.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED;
+}
+
+export function isArchivedCollection(item: Collection): boolean {
+ if (!item) {
+ return false;
+ }
+
+ if (item.magicMetadata && item.magicMetadata.data) {
+ return item.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED;
+ }
+
+ if (item.sharedMagicMetadata && item.sharedMagicMetadata.data) {
+ return (
+ item.sharedMagicMetadata.data.visibility ===
+ VISIBILITY_STATE.ARCHIVED
+ );
+ }
+ return false;
+}
+
+export function isPinnedCollection(item: Collection) {
+ if (
+ !item ||
+ !item.magicMetadata ||
+ !item.magicMetadata.data ||
+ typeof item.magicMetadata.data === 'string' ||
+ typeof item.magicMetadata.data.order === 'undefined'
+ ) {
+ return false;
+ }
+ return item.magicMetadata.data.order !== 0;
+}
+
+export async function updateMagicMetadata(
+ magicMetadataUpdates: T,
+ originalMagicMetadata?: MagicMetadataCore,
+ decryptionKey?: string
+): Promise> {
+ const cryptoWorker = await ComlinkCryptoWorker.getInstance();
+
+ if (!originalMagicMetadata) {
+ originalMagicMetadata = getNewMagicMetadata();
+ }
+
+ if (typeof originalMagicMetadata?.data === 'string') {
+ originalMagicMetadata.data = await cryptoWorker.decryptMetadata(
+ originalMagicMetadata.data,
+ originalMagicMetadata.header,
+ decryptionKey
+ );
+ }
+ // copies the existing magic metadata properties of the files and updates the visibility value
+ // The expected behavior while updating magic metadata is to let the existing property as it is and update/add the property you want
+ const magicMetadataProps: T = {
+ ...originalMagicMetadata.data,
+ ...magicMetadataUpdates,
+ };
+
+ const nonEmptyMagicMetadataProps =
+ getNonEmptyMagicMetadataProps(magicMetadataProps);
+
+ const magicMetadata = {
+ ...originalMagicMetadata,
+ data: nonEmptyMagicMetadataProps,
+ count: Object.keys(nonEmptyMagicMetadataProps).length,
+ };
+
+ return magicMetadata;
+}
+
+export const getNewMagicMetadata = (): MagicMetadataCore => {
+ return {
+ version: 1,
+ data: null,
+ header: null,
+ count: 0,
+ };
+};
+
+export const getNonEmptyMagicMetadataProps = (magicMetadataProps: T): T => {
+ return Object.fromEntries(
+ Object.entries(magicMetadataProps).filter(
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ ([_, v]) => v !== null && v !== undefined
+ )
+ ) as T;
+};
diff --git a/apps/cast/src/utils/network/index.ts b/apps/cast/src/utils/network/index.ts
new file mode 100644
index 000000000..b0e017dd3
--- /dev/null
+++ b/apps/cast/src/utils/network/index.ts
@@ -0,0 +1,28 @@
+import { sleep } from '@ente/shared/sleep';
+
+const waitTimeBeforeNextAttemptInMilliSeconds = [2000, 5000, 10000];
+
+export async function retryAsyncFunction(
+ request: (abort?: () => void) => Promise,
+ waitTimeBeforeNextTry?: number[]
+): Promise {
+ if (!waitTimeBeforeNextTry) {
+ waitTimeBeforeNextTry = waitTimeBeforeNextAttemptInMilliSeconds;
+ }
+
+ for (
+ let attemptNumber = 0;
+ attemptNumber <= waitTimeBeforeNextTry.length;
+ attemptNumber++
+ ) {
+ try {
+ const resp = await request();
+ return resp;
+ } catch (e) {
+ if (attemptNumber === waitTimeBeforeNextTry.length) {
+ throw e;
+ }
+ await sleep(waitTimeBeforeNextTry[attemptNumber]);
+ }
+ }
+}
diff --git a/apps/cast/src/utils/photoFrame/index.ts b/apps/cast/src/utils/photoFrame/index.ts
new file mode 100644
index 000000000..7637898f9
--- /dev/null
+++ b/apps/cast/src/utils/photoFrame/index.ts
@@ -0,0 +1,148 @@
+import { FILE_TYPE } from 'constants/file';
+import { EnteFile } from 'types/file';
+import { MergedSourceURL } from 'types/gallery';
+import { logError } from '@ente/shared/sentry';
+
+const WAIT_FOR_VIDEO_PLAYBACK = 1 * 1000;
+
+export async function isPlaybackPossible(url: string): Promise {
+ return await new Promise((resolve) => {
+ const t = setTimeout(() => {
+ resolve(false);
+ }, WAIT_FOR_VIDEO_PLAYBACK);
+
+ const video = document.createElement('video');
+ video.addEventListener('canplay', function () {
+ clearTimeout(t);
+ video.remove(); // Clean up the video element
+ // also check for duration > 0 to make sure it is not a broken video
+ if (video.duration > 0) {
+ resolve(true);
+ } else {
+ resolve(false);
+ }
+ });
+ video.addEventListener('error', function () {
+ clearTimeout(t);
+ video.remove();
+ resolve(false);
+ });
+
+ video.src = url;
+ });
+}
+
+export async function playVideo(livePhotoVideo, livePhotoImage) {
+ const videoPlaying = !livePhotoVideo.paused;
+ if (videoPlaying) return;
+ livePhotoVideo.style.opacity = 1;
+ livePhotoImage.style.opacity = 0;
+ livePhotoVideo.load();
+ livePhotoVideo.play().catch(() => {
+ pauseVideo(livePhotoVideo, livePhotoImage);
+ });
+}
+
+export async function pauseVideo(livePhotoVideo, livePhotoImage) {
+ const videoPlaying = !livePhotoVideo.paused;
+ if (!videoPlaying) return;
+ livePhotoVideo.pause();
+ livePhotoVideo.style.opacity = 0;
+ livePhotoImage.style.opacity = 1;
+}
+
+export function updateFileMsrcProps(file: EnteFile, url: string) {
+ file.msrc = url;
+ file.isSourceLoaded = false;
+ file.conversionFailed = false;
+ file.isConverted = false;
+ if (file.metadata.fileType === FILE_TYPE.IMAGE) {
+ file.src = url;
+ } else {
+ file.html = `
+
+

+
+ `;
+ }
+}
+
+export async function updateFileSrcProps(
+ file: EnteFile,
+ mergedURL: MergedSourceURL
+) {
+ const urls = {
+ original: mergedURL.original.split(','),
+ converted: mergedURL.converted.split(','),
+ };
+ let originalImageURL;
+ let originalVideoURL;
+ let convertedImageURL;
+ let convertedVideoURL;
+ let originalURL;
+ let isConverted;
+ let conversionFailed;
+ if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
+ [originalImageURL, originalVideoURL] = urls.original;
+ [convertedImageURL, convertedVideoURL] = urls.converted;
+ isConverted =
+ originalVideoURL !== convertedVideoURL ||
+ originalImageURL !== convertedImageURL;
+ conversionFailed = !convertedVideoURL || !convertedImageURL;
+ } else if (file.metadata.fileType === FILE_TYPE.VIDEO) {
+ [originalVideoURL] = urls.original;
+ [convertedVideoURL] = urls.converted;
+ isConverted = originalVideoURL !== convertedVideoURL;
+ conversionFailed = !convertedVideoURL;
+ } else if (file.metadata.fileType === FILE_TYPE.IMAGE) {
+ [originalImageURL] = urls.original;
+ [convertedImageURL] = urls.converted;
+ isConverted = originalImageURL !== convertedImageURL;
+ conversionFailed = !convertedImageURL;
+ } else {
+ [originalURL] = urls.original;
+ isConverted = false;
+ conversionFailed = false;
+ }
+
+ const isPlayable = !isConverted || (isConverted && !conversionFailed);
+
+ file.w = window.innerWidth;
+ file.h = window.innerHeight;
+ file.isSourceLoaded = true;
+ file.originalImageURL = originalImageURL;
+ file.originalVideoURL = originalVideoURL;
+ file.isConverted = isConverted;
+ file.conversionFailed = conversionFailed;
+
+ if (!isPlayable) {
+ return;
+ }
+
+ if (file.metadata.fileType === FILE_TYPE.VIDEO) {
+ file.html = `
+
+ `;
+ } else if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
+ file.html = `
+
+

+
+
+ `;
+ } else if (file.metadata.fileType === FILE_TYPE.IMAGE) {
+ file.src = convertedImageURL;
+ } else {
+ logError(
+ Error(`unknown file type - ${file.metadata.fileType}`),
+ 'Unknown file type'
+ );
+ file.src = originalURL;
+ }
+}
diff --git a/apps/cast/src/utils/temp/index.ts b/apps/cast/src/utils/temp/index.ts
new file mode 100644
index 000000000..589c66d55
--- /dev/null
+++ b/apps/cast/src/utils/temp/index.ts
@@ -0,0 +1,14 @@
+const CHARACTERS =
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+export function generateTempName(length: number, suffix: string) {
+ let tempName = '';
+
+ const charactersLength = CHARACTERS.length;
+ for (let i = 0; i < length; i++) {
+ tempName += CHARACTERS.charAt(
+ Math.floor(Math.random() * charactersLength)
+ );
+ }
+ return `${tempName}-${suffix}`;
+}
diff --git a/apps/cast/src/utils/time/format.ts b/apps/cast/src/utils/time/format.ts
new file mode 100644
index 000000000..59caefa80
--- /dev/null
+++ b/apps/cast/src/utils/time/format.ts
@@ -0,0 +1,78 @@
+import i18n, { t } from 'i18next';
+
+const dateTimeFullFormatter1 = new Intl.DateTimeFormat(i18n.language, {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+});
+
+const dateTimeFullFormatter2 = new Intl.DateTimeFormat(i18n.language, {
+ year: 'numeric',
+});
+const dateTimeShortFormatter = new Intl.DateTimeFormat(i18n.language, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+});
+
+const timeFormatter = new Intl.DateTimeFormat(i18n.language, {
+ timeStyle: 'short',
+});
+
+export function formatDateFull(date: number | Date) {
+ return [dateTimeFullFormatter1, dateTimeFullFormatter2]
+ .map((f) => f.format(date))
+ .join(' ');
+}
+
+export function formatDate(date: number | Date) {
+ const withinYear =
+ new Date().getFullYear() === new Date(date).getFullYear();
+ const dateTimeFormat2 = !withinYear ? dateTimeFullFormatter2 : null;
+ return [dateTimeFullFormatter1, dateTimeFormat2]
+ .filter((f) => !!f)
+ .map((f) => f.format(date))
+ .join(' ');
+}
+
+export function formatDateTimeShort(date: number | Date) {
+ return dateTimeShortFormatter.format(date);
+}
+
+export function formatTime(date: number | Date) {
+ return timeFormatter.format(date).toUpperCase();
+}
+
+export function formatDateTimeFull(dateTime: number | Date): string {
+ return [formatDateFull(dateTime), t('at'), formatTime(dateTime)].join(' ');
+}
+
+export function formatDateTime(dateTime: number | Date): string {
+ return [formatDate(dateTime), t('at'), formatTime(dateTime)].join(' ');
+}
+
+export function formatDateRelative(date: number) {
+ const units = {
+ year: 24 * 60 * 60 * 1000 * 365,
+ month: (24 * 60 * 60 * 1000 * 365) / 12,
+ day: 24 * 60 * 60 * 1000,
+ hour: 60 * 60 * 1000,
+ minute: 60 * 1000,
+ second: 1000,
+ };
+ const relativeDateFormat = new Intl.RelativeTimeFormat(i18n.language, {
+ localeMatcher: 'best fit',
+ numeric: 'always',
+ style: 'long',
+ });
+ const elapsed = date - Date.now(); // "Math.abs" accounts for both "past" & "future" scenarios
+
+ for (const u in units)
+ if (Math.abs(elapsed) > units[u] || u === 'second')
+ return relativeDateFormat.format(
+ Math.round(elapsed / units[u]),
+ u as Intl.RelativeTimeFormatUnit
+ );
+}
diff --git a/apps/cast/src/utils/time/index.ts b/apps/cast/src/utils/time/index.ts
new file mode 100644
index 000000000..11331a21b
--- /dev/null
+++ b/apps/cast/src/utils/time/index.ts
@@ -0,0 +1,136 @@
+export interface TimeDelta {
+ hours?: number;
+ days?: number;
+ months?: number;
+ years?: number;
+}
+
+interface DateComponent {
+ year: T;
+ month: T;
+ day: T;
+ hour: T;
+ minute: T;
+ second: T;
+}
+
+export function validateAndGetCreationUnixTimeInMicroSeconds(dateTime: Date) {
+ if (!dateTime || isNaN(dateTime.getTime())) {
+ return null;
+ }
+ const unixTime = dateTime.getTime() * 1000;
+ //ignoring dateTimeString = "0000:00:00 00:00:00"
+ if (unixTime === Date.UTC(0, 0, 0, 0, 0, 0, 0) || unixTime === 0) {
+ return null;
+ } else if (unixTime > Date.now() * 1000) {
+ return null;
+ } else {
+ return unixTime;
+ }
+}
+
+/*
+generates data component for date in format YYYYMMDD-HHMMSS
+ */
+export function parseDateFromFusedDateString(dateTime: string) {
+ const dateComponent: DateComponent = convertDateComponentToNumber({
+ year: dateTime.slice(0, 4),
+ month: dateTime.slice(4, 6),
+ day: dateTime.slice(6, 8),
+ hour: dateTime.slice(9, 11),
+ minute: dateTime.slice(11, 13),
+ second: dateTime.slice(13, 15),
+ });
+ return validateAndGetDateFromComponents(dateComponent);
+}
+
+/* sample date format = 2018-08-19 12:34:45
+ the date has six symbol separated number values
+ which we would extract and use to form the date
+ */
+export function tryToParseDateTime(dateTime: string): Date {
+ const dateComponent = getDateComponentsFromSymbolJoinedString(dateTime);
+ if (dateComponent.year?.length === 8 && dateComponent.month?.length === 6) {
+ // the filename has size 8 consecutive and then 6 consecutive digits
+ // high possibility that the it is a date in format YYYYMMDD-HHMMSS
+ const possibleDateTime = dateComponent.year + '-' + dateComponent.month;
+ return parseDateFromFusedDateString(possibleDateTime);
+ }
+ return validateAndGetDateFromComponents(
+ convertDateComponentToNumber(dateComponent)
+ );
+}
+
+function getDateComponentsFromSymbolJoinedString(
+ dateTime: string
+): DateComponent {
+ const [year, month, day, hour, minute, second] =
+ dateTime.match(/\d+/g) ?? [];
+
+ return { year, month, day, hour, minute, second };
+}
+
+function validateAndGetDateFromComponents(
+ dateComponent: DateComponent
+) {
+ let date = getDateFromComponents(dateComponent);
+ if (hasTimeValues(dateComponent) && !isTimePartValid(date, dateComponent)) {
+ // if the date has time values but they are not valid
+ // then we remove the time values and try to validate the date
+ date = getDateFromComponents(removeTimeValues(dateComponent));
+ }
+ if (!isDatePartValid(date, dateComponent)) {
+ return null;
+ }
+ return date;
+}
+
+function isTimePartValid(date: Date, dateComponent: DateComponent) {
+ return (
+ date.getHours() === dateComponent.hour &&
+ date.getMinutes() === dateComponent.minute &&
+ date.getSeconds() === dateComponent.second
+ );
+}
+
+function isDatePartValid(date: Date, dateComponent: DateComponent) {
+ return (
+ date.getFullYear() === dateComponent.year &&
+ date.getMonth() === dateComponent.month &&
+ date.getDate() === dateComponent.day
+ );
+}
+
+function convertDateComponentToNumber(
+ dateComponent: DateComponent
+): DateComponent {
+ return {
+ year: Number(dateComponent.year),
+ // https://stackoverflow.com/questions/2552483/why-does-the-month-argument-range-from-0-to-11-in-javascripts-date-constructor
+ month: Number(dateComponent.month) - 1,
+ day: Number(dateComponent.day),
+ hour: Number(dateComponent.hour),
+ minute: Number(dateComponent.minute),
+ second: Number(dateComponent.second),
+ };
+}
+
+function getDateFromComponents(dateComponent: DateComponent) {
+ const { year, month, day, hour, minute, second } = dateComponent;
+ if (hasTimeValues(dateComponent)) {
+ return new Date(year, month, day, hour, minute, second);
+ } else {
+ return new Date(year, month, day);
+ }
+}
+
+function hasTimeValues(dateComponent: DateComponent) {
+ const { hour, minute, second } = dateComponent;
+ return !isNaN(hour) && !isNaN(minute) && !isNaN(second);
+}
+
+function removeTimeValues(
+ dateComponent: DateComponent
+): DateComponent {
+ return { ...dateComponent, hour: 0, minute: 0, second: 0 };
+}
diff --git a/apps/cast/src/worker/convert.worker.ts b/apps/cast/src/worker/convert.worker.ts
new file mode 100644
index 000000000..8dae977a9
--- /dev/null
+++ b/apps/cast/src/worker/convert.worker.ts
@@ -0,0 +1,10 @@
+import * as Comlink from 'comlink';
+import { convertHEIC } from 'services/wasmHeicConverter/wasmHEICConverterClient';
+
+export class DedicatedConvertWorker {
+ async convertHEIC(fileBlob: Blob, format: string) {
+ return convertHEIC(fileBlob, format);
+ }
+}
+
+Comlink.expose(DedicatedConvertWorker, self);
diff --git a/apps/cast/src/worker/crypto.worker.ts b/apps/cast/src/worker/crypto.worker.ts
new file mode 100644
index 000000000..784c6e4ef
--- /dev/null
+++ b/apps/cast/src/worker/crypto.worker.ts
@@ -0,0 +1,215 @@
+import * as Comlink from 'comlink';
+import { StateAddress } from 'libsodium-wrappers';
+import * as libsodium from '@ente/shared/crypto/internal/libsodium';
+
+const textDecoder = new TextDecoder();
+const textEncoder = new TextEncoder();
+export class DedicatedCryptoWorker {
+ async decryptMetadata(
+ encryptedMetadata: string,
+ header: string,
+ key: string
+ ) {
+ const encodedMetadata = await libsodium.decryptChaChaOneShot(
+ await libsodium.fromB64(encryptedMetadata),
+ await libsodium.fromB64(header),
+ key
+ );
+ return JSON.parse(textDecoder.decode(encodedMetadata));
+ }
+
+ async decryptThumbnail(
+ fileData: Uint8Array,
+ header: Uint8Array,
+ key: string
+ ) {
+ return libsodium.decryptChaChaOneShot(fileData, header, key);
+ }
+
+ async decryptEmbedding(
+ encryptedEmbedding: string,
+ header: string,
+ key: string
+ ) {
+ const encodedEmbedding = await libsodium.decryptChaChaOneShot(
+ await libsodium.fromB64(encryptedEmbedding),
+ await libsodium.fromB64(header),
+ key
+ );
+ return Float32Array.from(
+ JSON.parse(textDecoder.decode(encodedEmbedding))
+ );
+ }
+
+ async decryptFile(fileData: Uint8Array, header: Uint8Array, key: string) {
+ return libsodium.decryptChaCha(fileData, header, key);
+ }
+
+ async encryptMetadata(metadata: Object, key: string) {
+ const encodedMetadata = textEncoder.encode(JSON.stringify(metadata));
+
+ const { file: encryptedMetadata } =
+ await libsodium.encryptChaChaOneShot(encodedMetadata, key);
+ const { encryptedData, ...other } = encryptedMetadata;
+ return {
+ file: {
+ encryptedData: await libsodium.toB64(encryptedData),
+ ...other,
+ },
+ key,
+ };
+ }
+
+ async encryptThumbnail(fileData: Uint8Array, key: string) {
+ return libsodium.encryptChaChaOneShot(fileData, key);
+ }
+
+ async encryptEmbedding(embedding: Float32Array, key: string) {
+ const encodedEmbedding = textEncoder.encode(
+ JSON.stringify(Array.from(embedding))
+ );
+ const { file: encryptEmbedding } = await libsodium.encryptChaChaOneShot(
+ encodedEmbedding,
+ key
+ );
+ const { encryptedData, ...other } = encryptEmbedding;
+ return {
+ file: {
+ encryptedData: await libsodium.toB64(encryptedData),
+ ...other,
+ },
+ key,
+ };
+ }
+
+ async encryptFile(fileData: Uint8Array) {
+ return libsodium.encryptChaCha(fileData);
+ }
+
+ async encryptFileChunk(
+ data: Uint8Array,
+ pushState: StateAddress,
+ isFinalChunk: boolean
+ ) {
+ return libsodium.encryptFileChunk(data, pushState, isFinalChunk);
+ }
+
+ async initChunkEncryption() {
+ return libsodium.initChunkEncryption();
+ }
+
+ async initChunkDecryption(header: Uint8Array, key: Uint8Array) {
+ return libsodium.initChunkDecryption(header, key);
+ }
+
+ async decryptFileChunk(fileData: Uint8Array, pullState: StateAddress) {
+ return libsodium.decryptFileChunk(fileData, pullState);
+ }
+
+ async initChunkHashing() {
+ return libsodium.initChunkHashing();
+ }
+
+ async hashFileChunk(hashState: StateAddress, chunk: Uint8Array) {
+ return libsodium.hashFileChunk(hashState, chunk);
+ }
+
+ async completeChunkHashing(hashState: StateAddress) {
+ return libsodium.completeChunkHashing(hashState);
+ }
+
+ async deriveKey(
+ passphrase: string,
+ salt: string,
+ opsLimit: number,
+ memLimit: number
+ ) {
+ return libsodium.deriveKey(passphrase, salt, opsLimit, memLimit);
+ }
+
+ async deriveSensitiveKey(passphrase: string, salt: string) {
+ return libsodium.deriveSensitiveKey(passphrase, salt);
+ }
+
+ async deriveInteractiveKey(passphrase: string, salt: string) {
+ return libsodium.deriveInteractiveKey(passphrase, salt);
+ }
+
+ async decryptB64(data: string, nonce: string, key: string) {
+ return libsodium.decryptB64(data, nonce, key);
+ }
+
+ async decryptToUTF8(data: string, nonce: string, key: string) {
+ return libsodium.decryptToUTF8(data, nonce, key);
+ }
+
+ async encryptToB64(data: string, key: string) {
+ return libsodium.encryptToB64(data, key);
+ }
+
+ async generateKeyAndEncryptToB64(data: string) {
+ return libsodium.generateKeyAndEncryptToB64(data);
+ }
+
+ async encryptUTF8(data: string, key: string) {
+ return libsodium.encryptUTF8(data, key);
+ }
+
+ async generateEncryptionKey() {
+ return libsodium.generateEncryptionKey();
+ }
+
+ async generateSaltToDeriveKey() {
+ return libsodium.generateSaltToDeriveKey();
+ }
+
+ async generateKeyPair() {
+ return libsodium.generateKeyPair();
+ }
+
+ async boxSealOpen(input: string, publicKey: string, secretKey: string) {
+ return libsodium.boxSealOpen(input, publicKey, secretKey);
+ }
+
+ async boxSeal(input: string, publicKey: string) {
+ return libsodium.boxSeal(input, publicKey);
+ }
+
+ async generateSubKey(
+ key: string,
+ subKeyLength: number,
+ subKeyID: number,
+ context: string
+ ) {
+ return libsodium.generateSubKey(key, subKeyLength, subKeyID, context);
+ }
+
+ async fromUTF8(string: string) {
+ return libsodium.fromUTF8(string);
+ }
+ async toUTF8(data: string) {
+ return libsodium.toUTF8(data);
+ }
+
+ async toB64(data: Uint8Array) {
+ return libsodium.toB64(data);
+ }
+
+ async toURLSafeB64(data: Uint8Array) {
+ return libsodium.toURLSafeB64(data);
+ }
+
+ async fromB64(string: string) {
+ return libsodium.fromB64(string);
+ }
+
+ async toHex(string: string) {
+ return libsodium.toHex(string);
+ }
+
+ async fromHex(string: string) {
+ return libsodium.fromHex(string);
+ }
+}
+
+Comlink.expose(DedicatedCryptoWorker, self);
diff --git a/apps/cast/src/worker/ffmpeg.worker.ts b/apps/cast/src/worker/ffmpeg.worker.ts
new file mode 100644
index 000000000..3c4f4c52e
--- /dev/null
+++ b/apps/cast/src/worker/ffmpeg.worker.ts
@@ -0,0 +1,15 @@
+import * as Comlink from 'comlink';
+import { WasmFFmpeg } from 'services/wasm/ffmpeg';
+
+export class DedicatedFFmpegWorker {
+ wasmFFmpeg: WasmFFmpeg;
+ constructor() {
+ this.wasmFFmpeg = new WasmFFmpeg();
+ }
+
+ run(cmd, inputFile, outputFileName, dontTimeout) {
+ return this.wasmFFmpeg.run(cmd, inputFile, outputFileName, dontTimeout);
+ }
+}
+
+Comlink.expose(DedicatedFFmpegWorker, self);
diff --git a/apps/cast/tsconfig.json b/apps/cast/tsconfig.json
new file mode 100644
index 000000000..cbdd32f74
--- /dev/null
+++ b/apps/cast/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "baseUrl": "./src",
+ "downlevelIteration": true,
+ "jsx": "preserve",
+ "jsxImportSource": "@emotion/react",
+ "lib": ["dom", "dom.iterable", "esnext", "webworker"],
+ "noImplicitAny": false,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "strictNullChecks": false,
+ "target": "es5",
+ "useUnknownInCatchVariables": false
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ "**/*.js",
+ "../../packages/shared/themes/mui-theme.d.ts",
+ "../../packages/accounts/**/*.tsx"
+ ],
+ "exclude": ["node_modules", "out", ".next", "thirdparty"]
+}
diff --git a/apps/photos/public/locales/en/translation.json b/apps/photos/public/locales/en/translation.json
index 48718e8e2..a6f46405d 100644
--- a/apps/photos/public/locales/en/translation.json
+++ b/apps/photos/public/locales/en/translation.json
@@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "Route uploads through nearby servers",
"MAGIC_SEARCH_STATUS": "Magic Search Status",
"INDEXED_ITEMS": "Indexed items",
+ "CAST_ALBUM_TO_TV": "Play album on TV",
+ "ENTER_CAST_PIN_CODE": "Enter the code you see on the TV below to pair this device.",
+ "PAIR_DEVICE_TO_TV": "Pair devices",
+ "TV_NOT_FOUND": "TV not found. Did you enter the PIN correctly?",
+ "AUTO_CAST_PAIR": "Auto Pair",
+ "AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "Auto Pair requires connecting to Google servers and only works with Chromecast supported devices. Google will not receive sensitive data, such as your photos.",
+ "PAIR_WITH_PIN": "Pair with PIN",
+ "CHOOSE_DEVICE_FROM_BROWSER": "Choose a cast-compatible device from the browser popup.",
+ "PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "Pair with PIN works for any large screen device you want to play your album on.",
+ "VISIT_CAST_ENTE_IO": "Visit cast.ente.io on the device you want to pair.",
+ "CAST_AUTO_PAIR_FAILED": "Chromecast Auto Pair failed. Please try again.",
"CACHE_DIRECTORY": "Cache folder",
"FREEHAND": "Freehand",
"APPLY_CROP": "Apply Crop",
diff --git a/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx b/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx
index 3ec310876..3a3511164 100644
--- a/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx
+++ b/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx
@@ -1,4 +1,5 @@
import { CollectionInfo } from './CollectionInfo';
+import React, { Dispatch, SetStateAction } from 'react';
import { Collection, CollectionSummary } from 'types/collection';
import CollectionOptions from 'components/Collections/CollectionOptions';
import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer';
@@ -20,6 +21,7 @@ interface Iprops {
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
isActiveCollectionDownloadInProgress: () => boolean;
setActiveCollectionID: (collectionID: number) => void;
+ setShowAlbumCastDialog: Dispatch>;
}
export default function CollectionInfoWithOptions({
@@ -49,6 +51,7 @@ export default function CollectionInfoWithOptions({
return <>>;
}
};
+
return (
diff --git a/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx b/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx
new file mode 100644
index 000000000..6219d32c4
--- /dev/null
+++ b/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx
@@ -0,0 +1,238 @@
+import { VerticallyCentered } from '@ente/shared/components/Container';
+import DialogBoxV2 from '@ente/shared/components/DialogBoxV2';
+import EnteButton from '@ente/shared/components/EnteButton';
+import EnteSpinner from '@ente/shared/components/EnteSpinner';
+import SingleInputForm, {
+ SingleInputFormProps,
+} from '@ente/shared/components/SingleInputForm';
+import { boxSeal } from '@ente/shared/crypto/internal/libsodium';
+import { loadSender } from '@ente/shared/hooks/useCastSender';
+import { addLogLine } from '@ente/shared/logging';
+import castGateway from '@ente/shared/network/cast';
+import { logError } from '@ente/shared/sentry';
+import { Typography } from '@mui/material';
+import { t } from 'i18next';
+import { useEffect, useState } from 'react';
+import { Collection } from 'types/collection';
+import { v4 as uuidv4 } from 'uuid';
+
+interface Props {
+ show: boolean;
+ onHide: () => void;
+ currentCollection: Collection;
+}
+
+enum AlbumCastError {
+ TV_NOT_FOUND = 'TV_NOT_FOUND',
+}
+
+declare global {
+ interface Window {
+ chrome: any;
+ }
+}
+
+export default function AlbumCastDialog(props: Props) {
+ const [view, setView] = useState<
+ 'choose' | 'auto' | 'pin' | 'auto-cast-error'
+ >('choose');
+
+ const [browserCanCast, setBrowserCanCast] = useState(false);
+ // Make API call on component mount
+ useEffect(() => {
+ castGateway.revokeAllTokens();
+
+ setBrowserCanCast(!!window.chrome);
+ }, []);
+
+ const onSubmit: SingleInputFormProps['callback'] = async (
+ value,
+ setFieldError
+ ) => {
+ try {
+ await doCast(value);
+ props.onHide();
+ } 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;
+ }
+
+ setFieldError(fieldError);
+ }
+ };
+
+ const doCast = async (pin: string) => {
+ // does the TV exist? have they advertised their existence?
+ const tvPublicKeyB64 = await castGateway.getPublicKey(pin);
+ if (!tvPublicKeyB64) {
+ throw new Error(AlbumCastError.TV_NOT_FOUND);
+ }
+ // generate random uuid string
+ const castToken = uuidv4();
+
+ // ok, they exist. let's give them the good stuff.
+ const payload = JSON.stringify({
+ castToken: castToken,
+ collectionID: props.currentCollection.id,
+ collectionKey: props.currentCollection.key,
+ });
+ const encryptedPayload = await boxSeal(btoa(payload), tvPublicKeyB64);
+
+ // hey TV, we acknowlege you!
+ await castGateway.publishCastPayload(
+ pin,
+ encryptedPayload,
+ props.currentCollection.id,
+ castToken
+ );
+ };
+
+ useEffect(() => {
+ if (view === 'auto') {
+ loadSender().then(async (sender) => {
+ const { cast } = sender;
+
+ const instance = await cast.framework.CastContext.getInstance();
+ try {
+ await instance.requestSession();
+ } catch (e) {
+ setView('auto-cast-error');
+ logError(e, 'Error requesting session');
+ return;
+ }
+ const session = instance.getCurrentSession();
+ session.addMessageListener(
+ 'urn:x-cast:pair-request',
+ (_, message) => {
+ const data = message;
+ const obj = JSON.parse(data);
+ const code = obj.code;
+
+ if (code) {
+ doCast(code)
+ .then(() => {
+ setView('choose');
+ props.onHide();
+ })
+ .catch((e) => {
+ setView('auto-cast-error');
+ logError(e, 'Error casting to TV');
+ });
+ }
+ }
+ );
+
+ session
+ .sendMessage('urn:x-cast:pair-request', {})
+ .then(() => {
+ addLogLine('Message sent successfully');
+ })
+ .catch((error) => {
+ logError(error, 'Error sending message');
+ });
+ });
+ }
+ }, [view]);
+
+ useEffect(() => {
+ if (props.show) {
+ castGateway.revokeAllTokens();
+ }
+ }, [props.show]);
+
+ return (
+
+ {view === 'choose' && (
+ <>
+ {browserCanCast && (
+ <>
+
+ {t(
+ 'AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE'
+ )}
+
+
+ {
+ setView('auto');
+ }}>
+ {t('AUTO_CAST_PAIR')}
+
+ >
+ )}
+
+ {t('PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE')}
+
+
+ {
+ setView('pin');
+ }}>
+ {t('PAIR_WITH_PIN')}
+
+ >
+ )}
+ {view === 'auto' && (
+
+
+ {t('CHOOSE_DEVICE_FROM_BROWSER')}
+ {
+ setView('choose');
+ }}>
+ {t('GO_BACK')}
+
+
+ )}
+ {view === 'auto-cast-error' && (
+
+ {t('CAST_AUTO_PAIR_FAILED')}
+ {
+ setView('choose');
+ }}>
+ {t('GO_BACK')}
+
+
+ )}
+ {view === 'pin' && (
+ <>
+ {t('VISIT_CAST_ENTE_IO')}
+ {t('ENTER_CAST_PIN_CODE')}
+
+ {
+ setView('choose');
+ }}>
+ {t('GO_BACK')}
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx b/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx
index d69940163..5c3f4c1dd 100644
--- a/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx
+++ b/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx
@@ -13,6 +13,7 @@ import PushPinOutlined from '@mui/icons-material/PushPinOutlined';
import { UnPinIcon } from 'components/icons/UnPinIcon';
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined';
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined';
+import TvIcon from '@mui/icons-material/Tv';
interface Iprops {
isArchived: boolean;
@@ -123,6 +124,14 @@ export function AlbumCollectionOption({
startIcon={}>
{t('SHARE_COLLECTION')}
+ }
+ onClick={handleCollectionAction(
+ CollectionActions.SHOW_ALBUM_CAST_DIALOG,
+ false
+ )}>
+ {t('CAST_ALBUM_TO_TV')}
+
>
);
}
diff --git a/apps/photos/src/components/Collections/CollectionOptions/index.tsx b/apps/photos/src/components/Collections/CollectionOptions/index.tsx
index 2016f5a7b..4b6b7a7cc 100644
--- a/apps/photos/src/components/Collections/CollectionOptions/index.tsx
+++ b/apps/photos/src/components/Collections/CollectionOptions/index.tsx
@@ -1,5 +1,11 @@
import { AlbumCollectionOption } from './AlbumCollectionOption';
-import React, { useContext, useRef, useState } from 'react';
+import React, {
+ Dispatch,
+ SetStateAction,
+ useContext,
+ useRef,
+ useState,
+} from 'react';
import * as CollectionAPI from 'services/collectionService';
import * as TrashService from 'services/trashService';
import {
@@ -43,6 +49,7 @@ interface CollectionOptionsProps {
collectionSummaryType: CollectionSummaryType;
showCollectionShareModal: () => void;
setActiveCollectionID: (collectionID: number) => void;
+ setShowAlbumCastDialog: Dispatch>;
}
export enum CollectionActions {
@@ -65,6 +72,7 @@ export enum CollectionActions {
UNPIN,
HIDE,
UNHIDE,
+ SHOW_ALBUM_CAST_DIALOG,
}
const CollectionOptions = (props: CollectionOptionsProps) => {
@@ -76,6 +84,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
showCollectionShareModal,
setFilesDownloadProgressAttributesCreator,
isActiveCollectionDownloadInProgress,
+ setShowAlbumCastDialog,
} = props;
const { startLoading, finishLoading, setDialogMessage } =
@@ -96,7 +105,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
action: CollectionActions,
loader = true
) => {
- let callback;
+ let callback: Function;
switch (action) {
case CollectionActions.SHOW_RENAME_DIALOG:
callback = showRenameCollectionModal;
@@ -155,6 +164,9 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
case CollectionActions.UNHIDE:
callback = unHideAlbum;
break;
+ case CollectionActions.SHOW_ALBUM_CAST_DIALOG:
+ callback = showCastAlbumDialog;
+ break;
default:
logError(
@@ -165,7 +177,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
action;
}
}
- return async (...args) => {
+ return async (...args: any) => {
try {
loader && startLoading();
await callback(...args);
@@ -183,6 +195,10 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
};
};
+ const showCastAlbumDialog = () => {
+ setShowAlbumCastDialog(true);
+ };
+
const renameCollection = async (newName: string) => {
if (activeCollection.name !== newName) {
await CollectionAPI.renameCollection(activeCollection, newName);
diff --git a/apps/photos/src/components/Collections/index.tsx b/apps/photos/src/components/Collections/index.tsx
index 8678be85b..67d919cd5 100644
--- a/apps/photos/src/components/Collections/index.tsx
+++ b/apps/photos/src/components/Collections/index.tsx
@@ -21,6 +21,7 @@ import {
isFilesDownloadCompleted,
} from '../FilesDownloadProgress';
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
+import AlbumCastDialog from './CollectionOptions/AlbumCastDialog';
interface Iprops {
activeCollection: Collection;
@@ -55,6 +56,8 @@ export default function Collections(props: Iprops) {
const [collectionShareModalView, setCollectionShareModalView] =
useState(false);
+ const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(false);
+
const [collectionListSortBy, setCollectionListSortBy] =
useLocalState(
LS_KEYS.COLLECTION_SORT_BY,
@@ -117,6 +120,7 @@ export default function Collections(props: Iprops) {
isActiveCollectionDownloadInProgress
}
setActiveCollectionID={setActiveCollectionID}
+ setShowAlbumCastDialog={setShowAlbumCastDialog}
/>
),
itemType: ITEM_TYPE.HEADER,
@@ -136,6 +140,7 @@ export default function Collections(props: Iprops) {
const closeAllCollections = () => setAllCollectionView(false);
const openAllCollections = () => setAllCollectionView(true);
const closeCollectionShare = () => setCollectionShareModalView(false);
+ const closeAlbumCastDialog = () => setShowAlbumCastDialog(false);
return (
<>
@@ -171,6 +176,11 @@ export default function Collections(props: Iprops) {
onClose={closeCollectionShare}
collection={activeCollection}
/>
+
>
);
}
diff --git a/package.json b/package.json
index 51aa8d1d0..ba6aad353 100644
--- a/package.json
+++ b/package.json
@@ -9,10 +9,13 @@
"scripts": {
"build:photos": "turbo run build --filter=photos",
"build:auth": "turbo run build --filter=auth",
+ "build:cast": "turbo run build --filter=cast",
"dev:auth": "turbo run dev --filter=auth",
"dev:photos": "turbo run dev --filter=photos",
+ "dev:cast": "turbo run dev --filter=cast",
"export:photos": "turbo run export --filter=photos",
"export:auth": "turbo run export --filter=auth",
+ "export:cast": "turbo run export --filter=cast",
"lint": "turbo run lint",
"albums": "turbo run albums",
"prepare": "husky install"
diff --git a/packages/shared/hooks/useCastReceiver.tsx b/packages/shared/hooks/useCastReceiver.tsx
new file mode 100644
index 000000000..1301939e1
--- /dev/null
+++ b/packages/shared/hooks/useCastReceiver.tsx
@@ -0,0 +1,44 @@
+declare const cast: any;
+
+import { useEffect, useState } from 'react';
+
+type Receiver = {
+ cast: typeof cast;
+};
+
+const load = (() => {
+ let promise: Promise | null = null;
+
+ return () => {
+ if (promise === null) {
+ promise = new Promise((resolve) => {
+ const script = document.createElement('script');
+ script.src =
+ 'https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js';
+
+ script.addEventListener('load', () => {
+ resolve({
+ cast,
+ });
+ });
+
+ document.body.appendChild(script);
+ });
+ }
+ return promise;
+ };
+})();
+
+export const useCastReceiver = () => {
+ const [receiver, setReceiver] = useState({
+ cast: null,
+ });
+
+ useEffect(() => {
+ load().then((receiver) => {
+ setReceiver(receiver);
+ });
+ });
+
+ return receiver;
+};
diff --git a/packages/shared/hooks/useCastSender.tsx b/packages/shared/hooks/useCastSender.tsx
new file mode 100644
index 000000000..7b9da6b42
--- /dev/null
+++ b/packages/shared/hooks/useCastSender.tsx
@@ -0,0 +1,63 @@
+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:
+ process.env.NEXT_PUBLIC_CAST_APP_ID,
+ 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/packages/shared/network/api.ts b/packages/shared/network/api.ts
index b079fa246..1fa6edf1b 100644
--- a/packages/shared/network/api.ts
+++ b/packages/shared/network/api.ts
@@ -22,6 +22,22 @@ export const getPublicCollectionFileURL = (id: number) => {
return `https://public-albums.ente.io/download/?fileID=${id}`;
};
+export const getCastFileURL = (id: number) => {
+ const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
+ if (isDevDeployment() && endpoint) {
+ return `${endpoint}/cast/files/download/${id}`;
+ }
+ return `https://cast-albums.ente.io/download/?fileID=${id}`;
+};
+
+export const getCastThumbnailURL = (id: number) => {
+ const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
+ if (isDevDeployment() && endpoint) {
+ return `${endpoint}/cast/files/preview/${id}`;
+ }
+ return `https://cast-albums.ente.io/preview/?fileID=${id}`;
+};
+
export const getThumbnailURL = (id: number) => {
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
if (isDevDeployment() && endpoint) {
diff --git a/packages/shared/network/cast.ts b/packages/shared/network/cast.ts
new file mode 100644
index 000000000..e3e37583f
--- /dev/null
+++ b/packages/shared/network/cast.ts
@@ -0,0 +1,89 @@
+import { ApiError } from '../error';
+import { logError } from '../sentry';
+import { getToken } from '../storage/localStorage/helpers';
+import HTTPService from './HTTPService';
+import { getEndpoint } from './api';
+
+class CastGateway {
+ constructor() {}
+
+ public async getCastData(code: string): Promise {
+ let resp;
+ try {
+ resp = await HTTPService.get(
+ `${getEndpoint()}/cast/cast-data/${code}`
+ );
+ } catch (e) {
+ logError(e, 'failed to getCastData');
+ throw e;
+ }
+ return resp.data.encCastData;
+ }
+
+ public async revokeAllTokens() {
+ try {
+ const token = getToken();
+ await HTTPService.delete(
+ getEndpoint() + '/cast/revoke-all-tokens/',
+ undefined,
+ undefined,
+ {
+ 'X-Auth-Token': token,
+ }
+ );
+ } catch (e) {
+ logError(e, 'removeAllTokens failed');
+ // swallow error
+ }
+ }
+
+ public async getPublicKey(code: string): Promise {
+ let resp;
+ try {
+ const token = getToken();
+ resp = await HTTPService.get(
+ `${getEndpoint()}/cast/device-info/${code}`,
+ undefined,
+ {
+ 'X-Auth-Token': token,
+ }
+ );
+ } catch (e) {
+ if (e instanceof ApiError && e.httpStatusCode === 404) {
+ return '';
+ }
+ logError(e, 'failed to getPublicKey');
+ throw e;
+ }
+ return resp.data.publicKey;
+ }
+
+ public async registerDevice(code: string, publicKey: string) {
+ await HTTPService.post(getEndpoint() + '/cast/device-info/', {
+ deviceCode: `${code}`,
+ publicKey: publicKey,
+ });
+ }
+
+ public async publishCastPayload(
+ code: string,
+ castPayload: string,
+ collectionID: number,
+ castToken: string
+ ) {
+ const token = getToken();
+ await HTTPService.post(
+ getEndpoint() + '/cast/cast-data/',
+ {
+ deviceCode: `${code}`,
+ encPayload: castPayload,
+ collectionID: collectionID,
+ castToken: castToken,
+ },
+ undefined,
+ { 'X-Auth-Token': token }
+ );
+ }
+}
+
+export default new CastGateway();
diff --git a/packages/shared/promise/index.ts b/packages/shared/promise/index.ts
new file mode 100644
index 000000000..849f18d94
--- /dev/null
+++ b/packages/shared/promise/index.ts
@@ -0,0 +1,23 @@
+import { CustomError } from '../error';
+
+export const promiseWithTimeout = async (
+ request: Promise,
+ timeout: number
+): Promise => {
+ const timeoutRef = { current: null };
+ const rejectOnTimeout = new Promise((_, reject) => {
+ timeoutRef.current = setTimeout(
+ () => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
+ timeout
+ );
+ });
+ const requestWithTimeOutCancellation = async () => {
+ const resp = await request;
+ clearTimeout(timeoutRef.current);
+ return resp;
+ };
+ return await Promise.race([
+ requestWithTimeOutCancellation(),
+ rejectOnTimeout,
+ ]);
+};
diff --git a/packages/shared/sleep/index.ts b/packages/shared/sleep/index.ts
new file mode 100644
index 000000000..39e64cb5f
--- /dev/null
+++ b/packages/shared/sleep/index.ts
@@ -0,0 +1,5 @@
+export async function sleep(time: number) {
+ await new Promise((resolve) => {
+ setTimeout(() => resolve(null), time);
+ });
+}
diff --git a/yarn.lock b/yarn.lock
index 63516b073..08f2bb358 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1170,6 +1170,14 @@ ansi-styles@^6.0.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
+anymatch@~3.1.2:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+ integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
+ dependencies:
+ normalize-path "^3.0.0"
+ picomatch "^2.0.4"
+
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
@@ -1309,6 +1317,11 @@ base-x@^4.0.0:
resolved "https://registry.yarnpkg.com/base-x/-/base-x-4.0.0.tgz#d0e3b7753450c73f8ad2389b5c018a4af7b2224a"
integrity sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==
+binary-extensions@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
+ integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+
bip39@^3.0.4:
version "3.0.4"
resolved "https://registry.npmjs.org/bip39/-/bip39-3.0.4.tgz"
@@ -1344,7 +1357,7 @@ brace-expansion@^2.0.1:
dependencies:
balanced-match "^1.0.0"
-braces@^3.0.2:
+braces@^3.0.2, braces@~3.0.2:
version "3.0.2"
resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@@ -1411,6 +1424,21 @@ chalk@^4.0.0, chalk@^4.1.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
+"chokidar@>=3.0.0 <4.0.0":
+ version "3.5.3"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
+ integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
+ dependencies:
+ anymatch "~3.1.2"
+ braces "~3.0.2"
+ glob-parent "~5.1.2"
+ is-binary-path "~2.1.0"
+ is-glob "~4.0.1"
+ normalize-path "~3.0.0"
+ readdirp "~3.6.0"
+ optionalDependencies:
+ fsevents "~2.3.2"
+
chrono-node@^2.2.6:
version "2.3.1"
resolved "https://registry.npmjs.org/chrono-node/-/chrono-node-2.3.1.tgz"
@@ -2336,7 +2364,7 @@ get-user-locale@^2.1.3:
"@types/lodash.memoize" "^4.1.7"
lodash.memoize "^4.1.1"
-glob-parent@^5.1.2:
+glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@@ -2606,6 +2634,11 @@ immediate@~3.0.5:
resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
+immutable@^4.0.0:
+ version "4.3.4"
+ resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.4.tgz#2e07b33837b4bb7662f288c244d1ced1ef65a78f"
+ integrity sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==
+
import-fresh@^3.0.0, import-fresh@^3.2.1:
version "3.3.0"
resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz"
@@ -2687,6 +2720,13 @@ is-bigint@^1.0.1:
dependencies:
has-bigints "^1.0.1"
+is-binary-path@~2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+ integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+ dependencies:
+ binary-extensions "^2.0.0"
+
is-boolean-object@^1.1.0:
version "1.1.2"
resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz"
@@ -2746,7 +2786,7 @@ is-fullwidth-code-point@^4.0.0:
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88"
integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==
-is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3:
+is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@@ -3177,7 +3217,7 @@ mime-db@1.52.0:
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
-mime-types@^2.1.12:
+mime-types@^2.1.12, mime-types@^2.1.35:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
@@ -3330,7 +3370,7 @@ node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@~2.6.1:
dependencies:
whatwg-url "^5.0.0"
-normalize-path@^3.0.0:
+normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
@@ -3584,7 +3624,7 @@ picocolors@^1.0.0:
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
-picomatch@^2.3.1:
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
@@ -3893,6 +3933,13 @@ readable-web-to-node-stream@^3.0.0:
dependencies:
readable-stream "^3.6.0"
+readdirp@~3.6.0:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+ integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+ dependencies:
+ picomatch "^2.2.1"
+
regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.7:
version "0.13.11"
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz"
@@ -4015,6 +4062,15 @@ sanitize-filename@^1.6.3:
dependencies:
truncate-utf8-bytes "^1.0.0"
+sass@^1.69.5:
+ version "1.69.5"
+ resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.5.tgz#23e18d1c757a35f2e52cc81871060b9ad653dfde"
+ integrity sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==
+ dependencies:
+ chokidar ">=3.0.0 <4.0.0"
+ immutable "^4.0.0"
+ source-map-js ">=0.6.2 <2.0.0"
+
sax@^1.2.4:
version "1.2.4"
resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz"
@@ -4138,7 +4194,7 @@ slice-ansi@^5.0.0:
ansi-styles "^6.0.0"
is-fullwidth-code-point "^4.0.0"
-source-map-js@^1.0.2:
+"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==