Merge branch 'main' into accounts-passkeys
This commit is contained in:
commit
5d7d166976
127 changed files with 6078 additions and 368 deletions
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
@ -13,5 +13,8 @@
|
|||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
}
|
||||
}
|
||||
|
|
9
Development.md
Normal file
9
Development.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
## Notes for Developers
|
||||
|
||||
### Local production builds
|
||||
|
||||
```sh
|
||||
yarn export:photos
|
||||
cd apps/
|
||||
python3 -m http.server
|
||||
```
|
|
@ -1,6 +1,3 @@
|
|||
import { setupSentry } from '@ente/shared/sentry/config/sentry.config.base';
|
||||
import { initSentry } from '@ente/shared/sentry/config/sentry.config.base';
|
||||
|
||||
const DEFAULT_SENTRY_DSN =
|
||||
'https://e2ccc39d811640b49602323774220955@sentry.ente.io/13';
|
||||
|
||||
setupSentry(DEFAULT_SENTRY_DSN);
|
||||
initSentry('https://ad075e4713480307bb8bc0811547c65e@sentry.ente.io/8');
|
||||
|
|
|
@ -5,7 +5,7 @@ import { t } from 'i18next';
|
|||
import { useRouter } from 'next/router';
|
||||
import { Overlay } from '@ente/shared/components/Container';
|
||||
import EnteSpinner from '@ente/shared/components/EnteSpinner';
|
||||
import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
|
||||
import { LS_KEYS } from '@ente/shared/storage/localStorage';
|
||||
import HTTPService from '@ente/shared/network/HTTPService';
|
||||
import Head from 'next/head';
|
||||
import LoadingBar from 'react-top-loading-bar';
|
||||
|
@ -17,8 +17,10 @@ import {
|
|||
DialogBoxAttributesV2,
|
||||
SetDialogBoxAttributesV2,
|
||||
} from '@ente/shared/components/DialogBoxV2/types';
|
||||
import { addLogLine } from '@ente/shared/logging';
|
||||
import { clearLogsIfLocalStorageLimitExceeded } from '@ente/shared/logging/web';
|
||||
import {
|
||||
clearLogsIfLocalStorageLimitExceeded,
|
||||
logStartupMessage,
|
||||
} from '@ente/shared/logging/web';
|
||||
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import {
|
||||
|
@ -31,8 +33,6 @@ import createEmotionCache from '@ente/shared/themes/createEmotionCache';
|
|||
import { THEME_COLOR } from '@ente/shared/themes/constants';
|
||||
import { SetTheme } from '@ente/shared/themes/types';
|
||||
import { setupI18n } from '@ente/shared/i18n';
|
||||
import { getSentryUserID } from '@ente/shared/sentry/utils';
|
||||
import { User } from '@ente/shared/user/types';
|
||||
import { useLocalState } from '@ente/shared/hooks/useLocalState';
|
||||
import { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
|
||||
import { getTheme } from '@ente/shared/themes';
|
||||
|
@ -87,12 +87,7 @@ export default function App(props: EnteAppProps) {
|
|||
});
|
||||
// setup logging
|
||||
clearLogsIfLocalStorageLimitExceeded();
|
||||
const main = async () => {
|
||||
addLogLine(`userID: ${(getData(LS_KEYS.USER) as User)?.id}`);
|
||||
addLogLine(`sentryID: ${await getSentryUserID()}`);
|
||||
addLogLine(`sentry release ID: ${process.env.SENTRY_RELEASE}`);
|
||||
};
|
||||
main();
|
||||
logStartupMessage();
|
||||
}, []);
|
||||
|
||||
const setUserOnline = () => setOffline(false);
|
||||
|
|
13
apps/cast/.eslintrc.js
Normal file
13
apps/cast/.eslintrc.js
Normal file
|
@ -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'],
|
||||
};
|
36
apps/cast/.gitignore
vendored
Normal file
36
apps/cast/.gitignore
vendored
Normal file
|
@ -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
|
40
apps/cast/README.md
Normal file
40
apps/cast/README.md
Normal file
|
@ -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.
|
53
apps/cast/configUtil.js
Normal file
53
apps/cast/configUtil.js
Normal file
|
@ -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,
|
||||
};
|
3
apps/cast/next.config.js
Normal file
3
apps/cast/next.config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
const nextConfigBase = require('@ente/shared/next/next.config.base.js');
|
||||
|
||||
module.exports = nextConfigBase;
|
19
apps/cast/package.json
Normal file
19
apps/cast/package.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
BIN
apps/cast/public/favicon.ico
Normal file
BIN
apps/cast/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
3
apps/cast/public/images/ente.svg
Normal file
3
apps/cast/public/images/ente.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="43" height="13" viewBox="0 0 43 13" fill="#fff" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.102 12.144C4.998 12.144 4.026 11.928 3.186 11.496C2.358 11.064 1.716 10.476 1.26 9.732C0.804 8.976 0.576 8.118 0.576 7.158C0.576 6.186 0.798 5.328 1.242 4.584C1.698 3.828 2.316 3.24 3.096 2.82C3.876 2.388 4.758 2.172 5.742 2.172C6.69 2.172 7.542 2.376 8.298 2.784C9.066 3.18 9.672 3.756 10.116 4.512C10.56 5.256 10.782 6.15 10.782 7.194C10.782 7.302 10.776 7.428 10.764 7.572C10.752 7.704 10.74 7.83 10.728 7.95H2.862V6.312H9.252L8.172 6.798C8.172 6.294 8.07 5.856 7.866 5.484C7.662 5.112 7.38 4.824 7.02 4.62C6.66 4.404 6.24 4.296 5.76 4.296C5.28 4.296 4.854 4.404 4.482 4.62C4.122 4.824 3.84 5.118 3.636 5.502C3.432 5.874 3.33 6.318 3.33 6.834V7.266C3.33 7.794 3.444 8.262 3.672 8.67C3.912 9.066 4.242 9.372 4.662 9.588C5.094 9.792 5.598 9.894 6.174 9.894C6.69 9.894 7.14 9.816 7.524 9.66C7.92 9.504 8.28 9.27 8.604 8.958L10.098 10.578C9.654 11.082 9.096 11.472 8.424 11.748C7.752 12.012 6.978 12.144 6.102 12.144ZM18.5375 2.172C19.3055 2.172 19.9895 2.328 20.5895 2.64C21.2015 2.94 21.6815 3.408 22.0295 4.044C22.3775 4.668 22.5515 5.472 22.5515 6.456V12H19.7435V6.888C19.7435 6.108 19.5695 5.532 19.2215 5.16C18.8855 4.788 18.4055 4.602 17.7815 4.602C17.3375 4.602 16.9355 4.698 16.5755 4.89C16.2275 5.07 15.9515 5.352 15.7475 5.736C15.5555 6.12 15.4595 6.612 15.4595 7.212V12H12.6515V2.316H15.3335V4.998L14.8295 4.188C15.1775 3.54 15.6755 3.042 16.3235 2.694C16.9715 2.346 17.7095 2.172 18.5375 2.172ZM29.0568 12.144C27.9168 12.144 27.0288 11.856 26.3928 11.28C25.7568 10.692 25.4388 9.822 25.4388 8.67V0.174H28.2468V8.634C28.2468 9.042 28.3548 9.36 28.5708 9.588C28.7868 9.804 29.0808 9.912 29.4528 9.912C29.8968 9.912 30.2748 9.792 30.5868 9.552L31.3428 11.532C31.0548 11.736 30.7068 11.892 30.2988 12C29.9028 12.096 29.4888 12.144 29.0568 12.144ZM23.9448 4.692V2.532H30.6588V4.692H23.9448ZM37.4262 12.144C36.3222 12.144 35.3502 11.928 34.5102 11.496C33.6822 11.064 33.0402 10.476 32.5842 9.732C32.1282 8.976 31.9002 8.118 31.9002 7.158C31.9002 6.186 32.1222 5.328 32.5662 4.584C33.0222 3.828 33.6402 3.24 34.4202 2.82C35.2002 2.388 36.0822 2.172 37.0662 2.172C38.0142 2.172 38.8662 2.376 39.6222 2.784C40.3902 3.18 40.9962 3.756 41.4402 4.512C41.8842 5.256 42.1062 6.15 42.1062 7.194C42.1062 7.302 42.1002 7.428 42.0882 7.572C42.0762 7.704 42.0642 7.83 42.0522 7.95H34.1862V6.312H40.5762L39.4962 6.798C39.4962 6.294 39.3942 5.856 39.1902 5.484C38.9862 5.112 38.7042 4.824 38.3442 4.62C37.9842 4.404 37.5642 4.296 37.0842 4.296C36.6042 4.296 36.1782 4.404 35.8062 4.62C35.4462 4.824 35.1642 5.118 34.9602 5.502C34.7562 5.874 34.6542 6.318 34.6542 6.834V7.266C34.6542 7.794 34.7682 8.262 34.9962 8.67C35.2362 9.066 35.5662 9.372 35.9862 9.588C36.4182 9.792 36.9222 9.894 37.4982 9.894C38.0142 9.894 38.4642 9.816 38.8482 9.66C39.2442 9.504 39.6042 9.27 39.9282 8.958L41.4222 10.578C40.9782 11.082 40.4202 11.472 39.7482 11.748C39.0762 12.012 38.3022 12.144 37.4262 12.144Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 2.9 KiB |
BIN
apps/cast/public/images/help-qrcode.webp
Normal file
BIN
apps/cast/public/images/help-qrcode.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 164 B |
2
apps/cast/sentry.client.config.js
Normal file
2
apps/cast/sentry.client.config.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
import { setupSentry } from '@ente/shared/sentry/config/sentry.config.base';
|
||||
setupSentry('https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2');
|
3
apps/cast/sentry.properties
Normal file
3
apps/cast/sentry.properties
Normal file
|
@ -0,0 +1,3 @@
|
|||
defaults.url=https://sentry.ente.io/
|
||||
defaults.org=ente
|
||||
defaults.project=photos-web
|
0
apps/cast/sentry.server.config.js
Normal file
0
apps/cast/sentry.server.config.js
Normal file
11
apps/cast/sentryConfigUtil.js
Normal file
11
apps/cast/sentryConfigUtil.js
Normal file
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
34
apps/cast/src/components/FilledCircleCheck/index.tsx
Normal file
34
apps/cast/src/components/FilledCircleCheck/index.tsx
Normal file
|
@ -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 (
|
||||
<div className={`${styles.circle} ${animate ? styles.animate : ''}`}>
|
||||
<svg
|
||||
className={styles.checkmark}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 52 52">
|
||||
<circle
|
||||
className={styles.checkmark__circle}
|
||||
cx="26"
|
||||
cy="26"
|
||||
r="25"
|
||||
fill="green"
|
||||
/>
|
||||
<path
|
||||
className={styles.checkmark__check}
|
||||
fill="none"
|
||||
d="M14.1 27.2l7.1 7.2 16.7-16.8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilledCircleCheck;
|
62
apps/cast/src/components/LargeType.tsx
Normal file
62
apps/cast/src/components/LargeType.tsx
Normal file
|
@ -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 (
|
||||
<table
|
||||
style={{
|
||||
fontSize: '4rem',
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'monospace',
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{chars.map((char, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '0.5rem',
|
||||
// alternating background
|
||||
backgroundColor: i % 2 === 0 ? '#2e2e2e' : '#5e5e5e',
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
color: colourPool[i % colourPool.length],
|
||||
lineHeight: 1.2,
|
||||
}}>
|
||||
{char}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '1rem',
|
||||
}}>
|
||||
{i + 1}
|
||||
</span>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
42
apps/cast/src/components/PairedSuccessfullyOverlay.tsx
Normal file
42
apps/cast/src/components/PairedSuccessfullyOverlay.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import FilledCircleCheck from './FilledCircleCheck';
|
||||
|
||||
export default function PairedSuccessfullyOverlay() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
backgroundColor: 'black',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<FilledCircleCheck />
|
||||
<h2
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
}}>
|
||||
Pairing Complete
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
lineHeight: '1.5rem',
|
||||
}}>
|
||||
We're preparing your album.
|
||||
<br /> This should only take a few seconds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
93
apps/cast/src/components/Theatre/PhotoAuditorium.tsx
Normal file
93
apps/cast/src/components/Theatre/PhotoAuditorium.tsx
Normal file
|
@ -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<number | null>(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 (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundBlendMode: 'multiply',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}>
|
||||
<img
|
||||
src={url}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
display: showPreloadedNextSlide ? 'none' : 'block',
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={nextSlideUrl}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
display: showPreloadedNextSlide ? 'block' : 'none',
|
||||
}}
|
||||
onLoad={() => {
|
||||
setNextSlidePrerendered(true);
|
||||
setPrerenderTime(Date.now());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
53
apps/cast/src/components/Theatre/VideoAuditorium.tsx
Normal file
53
apps/cast/src/components/Theatre/VideoAuditorium.tsx
Normal file
|
@ -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<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
attemptPlay();
|
||||
}, [url, videoRef]);
|
||||
|
||||
const attemptPlay = async () => {
|
||||
if (videoRef.current) {
|
||||
try {
|
||||
await videoRef.current.play();
|
||||
} catch {
|
||||
showNextSlide();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
controls
|
||||
style={{
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
}}
|
||||
onError={showNextSlide}
|
||||
onEnded={showNextSlide}>
|
||||
<source src={url} type={mime.lookup(name)} />
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
30
apps/cast/src/components/Theatre/index.tsx
Normal file
30
apps/cast/src/components/Theatre/index.tsx
Normal file
|
@ -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 (
|
||||
<PhotoAuditorium
|
||||
url={props.file1.fileURL}
|
||||
nextSlideUrl={props.file2.fileURL}
|
||||
/>
|
||||
);
|
||||
// case FILE_TYPE.VIDEO:
|
||||
// return (
|
||||
// <VideoAuditorium name={props.fileName} url={props.fileURL} />
|
||||
// );
|
||||
}
|
||||
}
|
30
apps/cast/src/components/TimerBar.tsx
Normal file
30
apps/cast/src/components/TimerBar.tsx
Normal file
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
width: `${percentage}%`, // Set the width based on the time left
|
||||
height: '10px', // Same as the border thickness
|
||||
backgroundColor, // The color of the moving border
|
||||
transition: 'width 1s linear', // Smooth transition for the width change
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
1
apps/cast/src/constants/api.ts
Normal file
1
apps/cast/src/constants/api.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const REQUEST_BATCH_SIZE = 1000;
|
56
apps/cast/src/constants/apps.ts
Normal file
56
apps/cast/src/constants/apps.ts
Normal file
|
@ -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;
|
||||
};
|
5
apps/cast/src/constants/cache.ts
Normal file
5
apps/cast/src/constants/cache.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export enum CACHES {
|
||||
THUMBS = 'thumbs',
|
||||
FACE_CROPS = 'face-crops',
|
||||
FILES = 'files',
|
||||
}
|
100
apps/cast/src/constants/collection.ts
Normal file
100
apps/cast/src/constants/collection.ts
Normal file
|
@ -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,
|
||||
]);
|
3
apps/cast/src/constants/ffmpeg.ts
Normal file
3
apps/cast/src/constants/ffmpeg.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const INPUT_PATH_PLACEHOLDER = 'INPUT';
|
||||
export const FFMPEG_PLACEHOLDER = 'FFMPEG';
|
||||
export const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
|
43
apps/cast/src/constants/file.ts
Normal file
43
apps/cast/src/constants/file.ts
Normal file
|
@ -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',
|
||||
];
|
15
apps/cast/src/constants/gallery.ts
Normal file
15
apps/cast/src/constants/gallery.ts
Normal file
|
@ -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
|
20
apps/cast/src/constants/pages.ts
Normal file
20
apps/cast/src/constants/pages.ts
Normal file
|
@ -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',
|
||||
}
|
15
apps/cast/src/constants/sentry.ts
Normal file
15
apps/cast/src/constants/sentry.ts
Normal file
|
@ -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;
|
142
apps/cast/src/constants/upload.ts
Normal file
142
apps/cast/src/constants/upload.ts
Normal file
|
@ -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=';
|
19
apps/cast/src/constants/urls.ts
Normal file
19
apps/cast/src/constants/urls.ts
Normal file
|
@ -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';
|
21
apps/cast/src/pages/_app.tsx
Normal file
21
apps/cast/src/pages/_app.tsx
Normal file
|
@ -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 (
|
||||
<ThemeProvider theme={getTheme(THEME_COLOR.DARK, APPS.PHOTOS)}>
|
||||
<CssBaseline enableColorScheme />
|
||||
|
||||
<main
|
||||
style={{
|
||||
display: 'contents',
|
||||
}}>
|
||||
<Component {...pageProps} />
|
||||
</main>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
25
apps/cast/src/pages/_document.tsx
Normal file
25
apps/cast/src/pages/_document.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Html, Head, Main, NextScript } from 'next/document';
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html
|
||||
lang="en"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}>
|
||||
<Head />
|
||||
<body
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
}}>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
242
apps/cast/src/pages/index.tsx
Normal file
242
apps/cast/src/pages/index.tsx
Normal file
|
@ -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<string[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<img width={150} src="/images/ente.svg" />
|
||||
<h1
|
||||
style={{
|
||||
fontWeight: 'normal',
|
||||
}}>
|
||||
Enter this code on <b>ente</b> to pair this TV
|
||||
</h1>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '10px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{codePending ? (
|
||||
<EnteSpinner />
|
||||
) : (
|
||||
<>
|
||||
<LargeType chars={digits} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '1.2rem',
|
||||
}}>
|
||||
Visit{' '}
|
||||
<a
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: '#87CEFA',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
href="https://ente.io/cast"
|
||||
target="_blank">
|
||||
ente.io/cast
|
||||
</a>{' '}
|
||||
for help
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
backgroundColor: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '10px',
|
||||
borderRadius: '10px',
|
||||
}}>
|
||||
<img src="/images/help-qrcode.webp" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
186
apps/cast/src/pages/slideshow.tsx
Normal file
186
apps/cast/src/pages/slideshow.tsx
Normal file
|
@ -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<number, string>();
|
||||
|
||||
export default function Slideshow() {
|
||||
const [collectionFiles, setCollectionFiles] = useState<EnteFile[]>([]);
|
||||
|
||||
const [currentFile, setCurrentFile] = useState<EnteFile | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [nextFile, setNextFile] = useState<EnteFile | undefined>(undefined);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [castToken, setCastToken] = useState<string>('');
|
||||
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<string>('');
|
||||
|
||||
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 (
|
||||
<>
|
||||
<SlideshowContext.Provider value={{ showNextSlide }}>
|
||||
<Theatre
|
||||
file1={{
|
||||
fileName: currentFile?.metadata.title,
|
||||
fileURL: renderableFileURL,
|
||||
type: currentFile?.metadata.fileType,
|
||||
}}
|
||||
file2={{
|
||||
fileName: nextFile?.metadata.title,
|
||||
fileURL: renderableFileURL,
|
||||
type: nextFile?.metadata.fileType,
|
||||
}}
|
||||
/>
|
||||
</SlideshowContext.Provider>
|
||||
{loading && <PairedSuccessfullyOverlay />}
|
||||
</>
|
||||
);
|
||||
}
|
32
apps/cast/src/services/InMemoryStore.ts
Normal file
32
apps/cast/src/services/InMemoryStore.ts
Normal file
|
@ -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<Partial<MS_KEYS>, 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();
|
41
apps/cast/src/services/cache/cacheStorageFactory.ts
vendored
Normal file
41
apps/cast/src/services/cache/cacheStorageFactory.ts
vendored
Normal file
|
@ -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),
|
||||
};
|
||||
}
|
33
apps/cast/src/services/cache/cacheStorageService.ts
vendored
Normal file
33
apps/cast/src/services/cache/cacheStorageService.ts
vendored
Normal file
|
@ -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 };
|
305
apps/cast/src/services/cast/castService.ts
Normal file
305
apps/cast/src/services/cast/castService.ts
Normal file
|
@ -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<EnteFile[]> => {
|
||||
const localSavedcollectionFiles =
|
||||
(await localForage.getItem<SavedCollectionFiles[]>(
|
||||
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<SavedCollectionFiles[]>(
|
||||
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<Collection[]>(COLLECTIONS_TABLE)) || [];
|
||||
const collection =
|
||||
localCollections.find(
|
||||
(localSavedPublicCollection) =>
|
||||
localSavedPublicCollection.key === collectionKey
|
||||
) || null;
|
||||
return collection;
|
||||
};
|
||||
|
||||
const saveCollection = async (collection: Collection) => {
|
||||
const collections =
|
||||
(await localForage.getItem<Collection[]>(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<number> {
|
||||
const lastSyncKey = getLastSyncKey(collectionUID);
|
||||
const lastSyncTime = await localForage.getItem<number>(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<string, EnteFile>();
|
||||
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<EnteFile[]> => {
|
||||
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<EnteFile>[]
|
||||
)),
|
||||
];
|
||||
|
||||
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<Collection> => {
|
||||
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<Collection[]>(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<SavedCollectionFiles[]>(
|
||||
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]);
|
||||
}
|
||||
};
|
273
apps/cast/src/services/castDownloadManager.ts
Normal file
273
apps/cast/src/services/castDownloadManager.ts
Normal file
|
@ -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<number, Promise<string>>();
|
||||
|
||||
private fileDownloadProgress = new Map<number, number>();
|
||||
|
||||
private progressUpdater: (value: Map<number, number>) => void;
|
||||
|
||||
setProgressUpdater(progressUpdater: (value: Map<number, number>) => 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();
|
12
apps/cast/src/services/events.ts
Normal file
12
apps/cast/src/services/events.ts
Normal file
|
@ -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<Events>();
|
29
apps/cast/src/services/ffmpeg/ffmpegFactory.ts
Normal file
29
apps/cast/src/services/ffmpeg/ffmpegFactory.ts
Normal file
|
@ -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<File | ElectronFile>;
|
||||
}
|
||||
|
||||
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();
|
30
apps/cast/src/services/ffmpeg/ffmpegService.ts
Normal file
30
apps/cast/src/services/ffmpeg/ffmpegService.ts
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
14
apps/cast/src/services/heicConversionService.ts
Normal file
14
apps/cast/src/services/heicConversionService.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { logError } from '@ente/shared/sentry';
|
||||
import WasmHEICConverterService from './wasmHeicConverter/wasmHEICConverterService';
|
||||
|
||||
class HeicConversionService {
|
||||
async convert(heicFileData: Blob): Promise<Blob> {
|
||||
try {
|
||||
return await WasmHEICConverterService.convert(heicFileData);
|
||||
} catch (e) {
|
||||
logError(e, 'failed to convert heic file');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
export default new HeicConversionService();
|
45
apps/cast/src/services/livePhotoService.ts
Normal file
45
apps/cast/src/services/livePhotoService.ts
Normal file
|
@ -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' });
|
||||
};
|
86
apps/cast/src/services/queueProcessor.ts
Normal file
86
apps/cast/src/services/queueProcessor.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { CustomError } from '@ente/shared/error';
|
||||
|
||||
interface RequestQueueItem {
|
||||
request: (canceller?: RequestCanceller) => Promise<any>;
|
||||
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<T> {
|
||||
private requestQueue: RequestQueueItem[] = [];
|
||||
|
||||
private requestInProcessing = 0;
|
||||
|
||||
constructor(
|
||||
private maxParallelProcesses: number,
|
||||
private processingStrategy = PROCESSING_STRATEGY.FIFO
|
||||
) {}
|
||||
|
||||
public queueUpRequest(
|
||||
request: (canceller?: RequestCanceller) => Promise<T>
|
||||
) {
|
||||
const isCanceled: CancellationStatus = { status: false };
|
||||
const canceller: RequestCanceller = {
|
||||
exec: () => {
|
||||
isCanceled.status = true;
|
||||
},
|
||||
};
|
||||
|
||||
const promise = new Promise<T>((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--;
|
||||
}
|
||||
}
|
93
apps/cast/src/services/readerService.ts
Normal file
93
apps/cast/src/services/readerService.ts
Normal file
|
@ -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<Uint8Array> {
|
||||
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<Uint8Array>({
|
||||
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<Uint8Array> {
|
||||
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);
|
||||
});
|
||||
}
|
108
apps/cast/src/services/typeDetectionService.ts
Normal file
108
apps/cast/src/services/typeDetectionService.ts
Normal file
|
@ -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<FileTypeInfo> {
|
||||
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;
|
||||
}
|
116
apps/cast/src/services/wasm/ffmpeg.ts
Normal file
116
apps/cast/src/services/wasm/ffmpeg.ts
Normal file
|
@ -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<void> = null;
|
||||
private ffmpegTaskQueue = new QueueProcessor<File>(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<File>(
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import * as HeicConvert from 'heic-convert';
|
||||
import { getUint8ArrayView } from 'services/readerService';
|
||||
|
||||
export async function convertHEIC(
|
||||
fileBlob: Blob,
|
||||
format: string
|
||||
): Promise<Blob> {
|
||||
const filedata = await getUint8ArrayView(fileBlob);
|
||||
const result = await HeicConvert({ buffer: filedata, format });
|
||||
const convertedFileData = new Uint8Array(result);
|
||||
const convertedFileBlob = new Blob([convertedFileData]);
|
||||
return convertedFileBlob;
|
||||
}
|
|
@ -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<Blob>(
|
||||
MAX_CONVERSION_IN_PARALLEL
|
||||
);
|
||||
private workerPool: ComlinkWorker<typeof DedicatedConvertWorker>[] = [];
|
||||
private ready: Promise<void>;
|
||||
|
||||
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<Blob> {
|
||||
await this.ready;
|
||||
const response = this.convertProcessor.queueUpRequest(() =>
|
||||
retryAsyncFunction<Blob>(async () => {
|
||||
const convertWorker = this.workerPool.shift();
|
||||
const worker = await convertWorker.remote;
|
||||
try {
|
||||
const convertedHEIC = await new Promise<Blob>(
|
||||
(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();
|
3
apps/cast/src/styles/global.css
Normal file
3
apps/cast/src/styles/global.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
#__next {
|
||||
height: 100%;
|
||||
}
|
20
apps/cast/src/types/cache/index.ts
vendored
Normal file
20
apps/cast/src/types/cache/index.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
export interface LimitedCacheStorage {
|
||||
open: (cacheName: string) => Promise<LimitedCache>;
|
||||
delete: (cacheName: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface LimitedCache {
|
||||
match: (key: string) => Promise<Response>;
|
||||
put: (key: string, data: Response) => Promise<void>;
|
||||
delete: (key: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ProxiedLimitedCacheStorage {
|
||||
open: (cacheName: string) => Promise<ProxiedWorkerLimitedCache>;
|
||||
delete: (cacheName: string) => Promise<boolean>;
|
||||
}
|
||||
export interface ProxiedWorkerLimitedCache {
|
||||
match: (key: string) => Promise<ArrayBuffer>;
|
||||
put: (key: string, data: ArrayBuffer) => Promise<void>;
|
||||
delete: (key: string) => Promise<boolean>;
|
||||
}
|
5
apps/cast/src/types/cast/index.ts
Normal file
5
apps/cast/src/types/cast/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface CastPayload {
|
||||
collectionID: number;
|
||||
collectionKey: string;
|
||||
castToken: string;
|
||||
}
|
159
apps/cast/src/types/collection/index.ts
Normal file
159
apps/cast/src/types/collection/index.ts
Normal file
|
@ -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<number, EnteFile>;
|
||||
|
||||
export interface RemoveFromCollectionRequest {
|
||||
collectionID: number;
|
||||
fileIDs: number[];
|
||||
}
|
||||
|
||||
export interface CollectionMagicMetadataProps {
|
||||
visibility?: VISIBILITY_STATE;
|
||||
subType?: SUB_TYPE;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type CollectionMagicMetadata =
|
||||
MagicMetadataCore<CollectionMagicMetadataProps>;
|
||||
|
||||
export interface CollectionShareeMetadataProps {
|
||||
visibility?: VISIBILITY_STATE;
|
||||
}
|
||||
export type CollectionShareeMagicMetadata =
|
||||
MagicMetadataCore<CollectionShareeMetadataProps>;
|
||||
|
||||
export interface CollectionPublicMagicMetadataProps {
|
||||
asc?: boolean;
|
||||
coverID?: number;
|
||||
}
|
||||
|
||||
export type CollectionPublicMagicMetadata =
|
||||
MagicMetadataCore<CollectionPublicMagicMetadataProps>;
|
||||
|
||||
export interface CollectionSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
type: CollectionSummaryType;
|
||||
coverFile: EnteFile;
|
||||
latestFile: EnteFile;
|
||||
fileCount: number;
|
||||
updationTime: number;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type CollectionSummaries = Map<number, CollectionSummary>;
|
||||
export type CollectionFilesCount = Map<number, number>;
|
103
apps/cast/src/types/file/index.ts
Normal file
103
apps/cast/src/types/file/index.ts
Normal file
|
@ -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<FileMagicMetadataProps>;
|
||||
|
||||
export interface FilePublicMagicMetadataProps {
|
||||
editedTime?: number;
|
||||
editedName?: string;
|
||||
caption?: string;
|
||||
uploaderName?: string;
|
||||
w?: number;
|
||||
h?: number;
|
||||
}
|
||||
|
||||
export type FilePublicMagicMetadata =
|
||||
MagicMetadataCore<FilePublicMagicMetadataProps>;
|
57
apps/cast/src/types/gallery/index.ts
Normal file
57
apps/cast/src/types/gallery/index.ts
Normal file
|
@ -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<React.SetStateAction<EnteFile[]>>;
|
||||
export type SetCollections = React.Dispatch<React.SetStateAction<Collection[]>>;
|
||||
export type SetLoading = React.Dispatch<React.SetStateAction<boolean>>;
|
||||
// export type SetCollectionSelectorAttributes = React.Dispatch<
|
||||
// React.SetStateAction<CollectionSelectorAttributes>
|
||||
// >;
|
||||
// export type SetCollectionDownloadProgressAttributes = React.Dispatch<
|
||||
// React.SetStateAction<CollectionDownloadProgressAttributes>
|
||||
// >;
|
||||
|
||||
export type MergedSourceURL = {
|
||||
original: string;
|
||||
converted: string;
|
||||
};
|
||||
export enum UploadTypeSelectorIntent {
|
||||
normalUpload,
|
||||
import,
|
||||
collectPhotos,
|
||||
}
|
||||
export type GalleryContextType = {
|
||||
thumbs: Map<number, string>;
|
||||
files: Map<number, MergedSourceURL>;
|
||||
showPlanSelectorModal: () => void;
|
||||
setActiveCollectionID: (collectionID: number) => void;
|
||||
syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;
|
||||
setBlockingLoad: (value: boolean) => void;
|
||||
setIsInSearchMode: (value: boolean) => void;
|
||||
// photoListHeader: TimeStampListItem;
|
||||
openExportModal: () => void;
|
||||
authenticateUser: (callback: () => void) => void;
|
||||
user: User;
|
||||
userIDToEmailMap: Map<number, string>;
|
||||
emailList: string[];
|
||||
openHiddenSection: (callback?: () => void) => void;
|
||||
isClipSearchResult: boolean;
|
||||
};
|
||||
|
||||
export enum CollectionSelectorIntent {
|
||||
upload,
|
||||
add,
|
||||
move,
|
||||
restore,
|
||||
unhide,
|
||||
}
|
29
apps/cast/src/types/magicMetadata/index.ts
Normal file
29
apps/cast/src/types/magicMetadata/index.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
export interface MagicMetadataCore<T> {
|
||||
version: number;
|
||||
count: number;
|
||||
header: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export type EncryptedMagicMetadata = MagicMetadataCore<string>;
|
||||
|
||||
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;
|
||||
}
|
170
apps/cast/src/types/upload/index.ts
Normal file
170
apps/cast/src/types/upload/index.ts
Normal file
|
@ -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<Uint8Array>;
|
||||
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<ReadableStream<Uint8Array>>;
|
||||
blob: () => Promise<Blob>;
|
||||
arrayBuffer: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
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<string, ParsedMetadataJSON>;
|
||||
|
||||
export interface UploadURL {
|
||||
url: string;
|
||||
objectKey: string;
|
||||
}
|
||||
|
||||
export interface FileInMemory {
|
||||
filedata: Uint8Array | DataStream;
|
||||
thumbnail: Uint8Array;
|
||||
hasStaticThumbnail: boolean;
|
||||
}
|
||||
|
||||
export interface FileWithMetadata
|
||||
extends Omit<FileInMemory, 'hasStaticThumbnail'> {
|
||||
metadata: Metadata;
|
||||
localID: number;
|
||||
pubMagicMetadata: FilePublicMagicMetadata;
|
||||
}
|
||||
|
||||
export interface EncryptedFile {
|
||||
file: ProcessedFile;
|
||||
fileKey: B64EncryptionResult;
|
||||
}
|
||||
export interface ProcessedFile {
|
||||
file: LocalFileAttributes<Uint8Array | DataStream>;
|
||||
thumbnail: LocalFileAttributes<Uint8Array>;
|
||||
metadata: LocalFileAttributes<string>;
|
||||
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;
|
||||
}
|
43
apps/cast/src/types/upload/ui.ts
Normal file
43
apps/cast/src/types/upload/ui.ts
Normal file
|
@ -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<FileID, FileName>;
|
||||
|
||||
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<FileID, PercentageUploaded>;
|
||||
|
||||
export type FinishedUploads = Map<FileID, UPLOAD_RESULT>;
|
||||
|
||||
export type SegregatedFinishedUploads = Map<UPLOAD_RESULT, FileID[]>;
|
||||
|
||||
export interface ProgressUpdater {
|
||||
setPercentComplete: React.Dispatch<React.SetStateAction<number>>;
|
||||
setUploadCounter: React.Dispatch<React.SetStateAction<UploadCounter>>;
|
||||
setUploadStage: React.Dispatch<React.SetStateAction<UPLOAD_STAGES>>;
|
||||
setInProgressUploads: React.Dispatch<
|
||||
React.SetStateAction<InProgressUpload[]>
|
||||
>;
|
||||
setFinishedUploads: React.Dispatch<
|
||||
React.SetStateAction<SegregatedFinishedUploads>
|
||||
>;
|
||||
setUploadFilenames: React.Dispatch<React.SetStateAction<UploadFileNames>>;
|
||||
setHasLivePhotos: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setUploadProgressView: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
147
apps/cast/src/utils/collection/index.ts
Normal file
147
apps/cast/src/utils/collection/index.ts
Normal file
|
@ -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<number, string> {
|
||||
return new Map<number, string>(
|
||||
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));
|
||||
}
|
30
apps/cast/src/utils/comlink/ComlinkConvertWorker.ts
Normal file
30
apps/cast/src/utils/comlink/ComlinkConvertWorker.ts
Normal file
|
@ -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<DedicatedConvertWorker>;
|
||||
|
||||
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();
|
25
apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts
Normal file
25
apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Remote } from 'comlink';
|
||||
import { DedicatedCryptoWorker } from 'worker/crypto.worker';
|
||||
import { ComlinkWorker } from './comlinkWorker';
|
||||
|
||||
class ComlinkCryptoWorker {
|
||||
private comlinkWorkerInstance: Promise<Remote<DedicatedCryptoWorker>>;
|
||||
|
||||
async getInstance() {
|
||||
if (!this.comlinkWorkerInstance) {
|
||||
const comlinkWorker = getDedicatedCryptoWorker();
|
||||
this.comlinkWorkerInstance = comlinkWorker.remote;
|
||||
}
|
||||
return this.comlinkWorkerInstance;
|
||||
}
|
||||
}
|
||||
|
||||
export const getDedicatedCryptoWorker = () => {
|
||||
const cryptoComlinkWorker = new ComlinkWorker<typeof DedicatedCryptoWorker>(
|
||||
'ente-crypto-worker',
|
||||
new Worker(new URL('worker/crypto.worker.ts', import.meta.url))
|
||||
);
|
||||
return cryptoComlinkWorker;
|
||||
};
|
||||
|
||||
export default new ComlinkCryptoWorker();
|
25
apps/cast/src/utils/comlink/ComlinkFFmpegWorker.ts
Normal file
25
apps/cast/src/utils/comlink/ComlinkFFmpegWorker.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Remote } from 'comlink';
|
||||
import { DedicatedFFmpegWorker } from 'worker/ffmpeg.worker';
|
||||
import { ComlinkWorker } from './comlinkWorker';
|
||||
|
||||
class ComlinkFFmpegWorker {
|
||||
private comlinkWorkerInstance: Promise<Remote<DedicatedFFmpegWorker>>;
|
||||
|
||||
async getInstance() {
|
||||
if (!this.comlinkWorkerInstance) {
|
||||
const comlinkWorker = getDedicatedFFmpegWorker();
|
||||
this.comlinkWorkerInstance = comlinkWorker.remote;
|
||||
}
|
||||
return this.comlinkWorkerInstance;
|
||||
}
|
||||
}
|
||||
|
||||
const getDedicatedFFmpegWorker = () => {
|
||||
const cryptoComlinkWorker = new ComlinkWorker<typeof DedicatedFFmpegWorker>(
|
||||
'ente-ffmpeg-worker',
|
||||
new Worker(new URL('worker/ffmpeg.worker.ts', import.meta.url))
|
||||
);
|
||||
return cryptoComlinkWorker;
|
||||
};
|
||||
|
||||
export default new ComlinkFFmpegWorker();
|
27
apps/cast/src/utils/comlink/comlinkWorker.ts
Normal file
27
apps/cast/src/utils/comlink/comlinkWorker.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { addLocalLog } from '@ente/shared/logging';
|
||||
import { Remote, wrap } from 'comlink';
|
||||
// import { WorkerElectronCacheStorageClient } from 'services/workerElectronCache/client';
|
||||
|
||||
export class ComlinkWorker<T extends new () => InstanceType<T>> {
|
||||
public remote: Promise<Remote<InstanceType<T>>>;
|
||||
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<T>(this.worker);
|
||||
this.remote = new comlink() as Promise<Remote<InstanceType<T>>>;
|
||||
// expose(WorkerElectronCacheStorageClient, this.worker);
|
||||
}
|
||||
|
||||
public terminate() {
|
||||
this.worker.terminate();
|
||||
addLocalLog(() => `Terminated ${this.name}`);
|
||||
}
|
||||
}
|
15
apps/cast/src/utils/file/blob.ts
Normal file
15
apps/cast/src/utils/file/blob.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export const readAsDataURL = (blob) =>
|
||||
new Promise<string>((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<string>((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = () => resolve(fileReader.result as string);
|
||||
fileReader.onerror = () => reject(fileReader.error);
|
||||
fileReader.readAsText(blob);
|
||||
});
|
577
apps/cast/src/utils/file/index.ts
Normal file
577
apps/cast/src/utils/file/index.ts
Normal file
|
@ -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<number, EnteFile[]>();
|
||||
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<EnteFile> {
|
||||
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<number>();
|
||||
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<Blob>((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<string, EnteFile>();
|
||||
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<number, number[]>();
|
||||
(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<Blob> => {
|
||||
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 };
|
||||
};
|
42
apps/cast/src/utils/file/livePhoto.ts
Normal file
42
apps/cast/src/utils/file/livePhoto.ts
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
97
apps/cast/src/utils/magicMetadata/index.ts
Normal file
97
apps/cast/src/utils/magicMetadata/index.ts
Normal file
|
@ -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<T>(
|
||||
magicMetadataUpdates: T,
|
||||
originalMagicMetadata?: MagicMetadataCore<T>,
|
||||
decryptionKey?: string
|
||||
): Promise<MagicMetadataCore<T>> {
|
||||
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||
|
||||
if (!originalMagicMetadata) {
|
||||
originalMagicMetadata = getNewMagicMetadata<T>();
|
||||
}
|
||||
|
||||
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 = <T>(): MagicMetadataCore<T> => {
|
||||
return {
|
||||
version: 1,
|
||||
data: null,
|
||||
header: null,
|
||||
count: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const getNonEmptyMagicMetadataProps = <T>(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;
|
||||
};
|
28
apps/cast/src/utils/network/index.ts
Normal file
28
apps/cast/src/utils/network/index.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { sleep } from '@ente/shared/sleep';
|
||||
|
||||
const waitTimeBeforeNextAttemptInMilliSeconds = [2000, 5000, 10000];
|
||||
|
||||
export async function retryAsyncFunction<T>(
|
||||
request: (abort?: () => void) => Promise<T>,
|
||||
waitTimeBeforeNextTry?: number[]
|
||||
): Promise<T> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
148
apps/cast/src/utils/photoFrame/index.ts
Normal file
148
apps/cast/src/utils/photoFrame/index.ts
Normal file
|
@ -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<boolean> {
|
||||
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 = `
|
||||
<div class = 'pswp-item-container'>
|
||||
<img src="${url}"/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
<video controls onContextMenu="return false;">
|
||||
<source src="${convertedVideoURL}" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
`;
|
||||
} else if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
|
||||
file.html = `
|
||||
<div class = 'pswp-item-container'>
|
||||
<img id = "live-photo-image-${file.id}" src="${convertedImageURL}" onContextMenu="return false;"/>
|
||||
<video id = "live-photo-video-${file.id}" loop muted onContextMenu="return false;">
|
||||
<source src="${convertedVideoURL}" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
`;
|
||||
} 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;
|
||||
}
|
||||
}
|
14
apps/cast/src/utils/temp/index.ts
Normal file
14
apps/cast/src/utils/temp/index.ts
Normal file
|
@ -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}`;
|
||||
}
|
78
apps/cast/src/utils/time/format.ts
Normal file
78
apps/cast/src/utils/time/format.ts
Normal file
|
@ -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
|
||||
);
|
||||
}
|
136
apps/cast/src/utils/time/index.ts
Normal file
136
apps/cast/src/utils/time/index.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
export interface TimeDelta {
|
||||
hours?: number;
|
||||
days?: number;
|
||||
months?: number;
|
||||
years?: number;
|
||||
}
|
||||
|
||||
interface DateComponent<T = number> {
|
||||
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<number> = 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<string> {
|
||||
const [year, month, day, hour, minute, second] =
|
||||
dateTime.match(/\d+/g) ?? [];
|
||||
|
||||
return { year, month, day, hour, minute, second };
|
||||
}
|
||||
|
||||
function validateAndGetDateFromComponents(
|
||||
dateComponent: DateComponent<number>
|
||||
) {
|
||||
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<number>) {
|
||||
return (
|
||||
date.getHours() === dateComponent.hour &&
|
||||
date.getMinutes() === dateComponent.minute &&
|
||||
date.getSeconds() === dateComponent.second
|
||||
);
|
||||
}
|
||||
|
||||
function isDatePartValid(date: Date, dateComponent: DateComponent<number>) {
|
||||
return (
|
||||
date.getFullYear() === dateComponent.year &&
|
||||
date.getMonth() === dateComponent.month &&
|
||||
date.getDate() === dateComponent.day
|
||||
);
|
||||
}
|
||||
|
||||
function convertDateComponentToNumber(
|
||||
dateComponent: DateComponent<string>
|
||||
): DateComponent<number> {
|
||||
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<number>) {
|
||||
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<number>) {
|
||||
const { hour, minute, second } = dateComponent;
|
||||
return !isNaN(hour) && !isNaN(minute) && !isNaN(second);
|
||||
}
|
||||
|
||||
function removeTimeValues(
|
||||
dateComponent: DateComponent<number>
|
||||
): DateComponent<number> {
|
||||
return { ...dateComponent, hour: 0, minute: 0, second: 0 };
|
||||
}
|
10
apps/cast/src/worker/convert.worker.ts
Normal file
10
apps/cast/src/worker/convert.worker.ts
Normal file
|
@ -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);
|
215
apps/cast/src/worker/crypto.worker.ts
Normal file
215
apps/cast/src/worker/crypto.worker.ts
Normal file
|
@ -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);
|
15
apps/cast/src/worker/ffmpeg.worker.ts
Normal file
15
apps/cast/src/worker/ffmpeg.worker.ts
Normal file
|
@ -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);
|
25
apps/cast/tsconfig.json
Normal file
25
apps/cast/tsconfig.json
Normal file
|
@ -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"]
|
||||
}
|
|
@ -1,46 +1,41 @@
|
|||
# Sample configuration file
|
||||
#
|
||||
# All variables are commented out by default. Copy paste this into a new file
|
||||
# called `.env.local`, or create a new file with that name and add the
|
||||
# environment variables you need into it. That `.env.local` file will be
|
||||
# .gitignored, so you can freely customize how the app runs in your local setup.
|
||||
# called `.env.local` (or create a new file with that name) and add the
|
||||
# environment variables you want to apply during development. `.env.local` is
|
||||
# gitignored, so you can freely customize it for your local setup.
|
||||
#
|
||||
# - `.env.local` is picked up by next when NODE_ENV is development
|
||||
# `.env.local` is picked up by Next.js when NODE_ENV is 'development' (it is
|
||||
# 'production' by default, but gets set to 'development' when we run `next dev`)
|
||||
#
|
||||
# - `.env` is picked up always
|
||||
#
|
||||
# You don't necessarily need to use these files, these variables can be provided
|
||||
# as environment variables when running yarn dev too. e.g.
|
||||
# Alternatively, these variables can be provided as environment variables, say:
|
||||
#
|
||||
# NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:3000 yarn dev:photos
|
||||
#
|
||||
# Variables prefixed with NEXT_PUBLIC_ (in our case, all of them) are made
|
||||
# available when next runs our code in the browser. For more details, see
|
||||
# Variables prefixed with NEXT_PUBLIC_ are made available when Next.js runs our
|
||||
# code in the browser (Behind the scenes, Next.js just hardcodes occurrences of
|
||||
# `process.env.NEXT_PUBLIC_FOO` with the value of the `NEXT_PUBLIC_FOO` env var
|
||||
# when the bundle is built). See
|
||||
# https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables
|
||||
#
|
||||
# By default, the app is configured to connect to the production instance etc.
|
||||
# This is usually a good default, for example someone might want to run the
|
||||
# client locally but still use their actual ente account.
|
||||
# A development build behaves differently in some aspects:
|
||||
#
|
||||
# Even though it connects to the production instances, when invoked with `yarn
|
||||
# dev:*`, next will behave as if NODE_ENV was set to 'development' (otherwise
|
||||
# this is assumed to be 'production'). There are some other cases too when we
|
||||
# assume we're in a dev environment (e.g. the NEXT_PUBLIC_APP_ENV env var below.
|
||||
# For the full list of rules that decide what counts as a dev build, see the
|
||||
# `isDevDeployment` function).
|
||||
# - Logs go to the browser console (in addition to the log file)
|
||||
# - There is some additional logging
|
||||
# - Sentry is not initialized
|
||||
# - ... (search for isDevBuild to see all impacts)
|
||||
#
|
||||
# We have some development related conveniences tied to the dev build:
|
||||
# 1. Logs go to the browser console instead of the log file
|
||||
# 2. Sentry crash reporting etc is disabled
|
||||
#
|
||||
# The variables below thus serve as ways to customize which API instance we
|
||||
# connect to for various purposes. These variables are only honoured when we're
|
||||
# in a development environment.
|
||||
# Note that even in development build, the app still connects to the production
|
||||
# APIs by default (can be customized using the env vars below). This is usually
|
||||
# a good default, for example a customer cloning this repository want to build
|
||||
# and run the client from source but still use their actual Ente account.
|
||||
|
||||
# The ente API endpoint
|
||||
# NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:3000
|
||||
# The Ente API endpoint
|
||||
#
|
||||
# NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:3000
|
||||
|
||||
# The ente API endpoint for payments related functionality
|
||||
# The Ente API endpoint for payments related functionality
|
||||
#
|
||||
# NEXT_PUBLIC_ENTE_PAYMENT_ENDPOINT = http://localhost:3001
|
||||
|
||||
# The URL for the shared albums deployment
|
||||
|
@ -49,6 +44,7 @@
|
|||
# deploying, we add an a CNAME alias from "albums.ente.io" -> "/shared-album".
|
||||
#
|
||||
# Enhancement: Consider splitting this into a separate app/ in this repository.
|
||||
#
|
||||
# NEXT_PUBLIC_ENTE_ALBUM_ENDPOINT = http://localhost:3002
|
||||
|
||||
# The URL of the family plans web app deployment
|
||||
|
@ -58,41 +54,21 @@
|
|||
# these pages.
|
||||
#
|
||||
# Enhancement: Consider moving that into the app/ folder in this repository.
|
||||
#
|
||||
# NEXT_PUBLIC_ENTE_FAMILY_PORTAL_ENDPOINT = http://localhost:3003
|
||||
|
||||
# This in not useful when running locally. It is used to provide us a way to
|
||||
# mark certain deployments as "staging" by setting environment variables in the
|
||||
# CI job that deploys them on a remote server. See the `isDevDeployment`
|
||||
# function.
|
||||
# Set this to "true" to disable the upload of files via Cloudflare Workers.
|
||||
#
|
||||
# By default, the photos web app gets deployed to "web.ente.io".
|
||||
# NEXT_PUBLIC_ENTE_WEB_ENDPOINT = http://localhost:3000
|
||||
|
||||
# Set this to true to disable reporting of crashes to Sentry.
|
||||
# These workers were introduced as a way of make file uploads faster:
|
||||
# https://ente.io/blog/tech/making-uploads-faster/
|
||||
#
|
||||
# Crash reporting is disabled if the user has opted out. This provides another
|
||||
# way to disable crash reporting, say for local test branches.
|
||||
# NEXT_PUBLIC_DISABLE_SENTRY=true
|
||||
|
||||
# Set this to disable the upload of files via CF Workers
|
||||
# By default, that's the route we take. However, during development it can be
|
||||
# convenient to turn this flag on to directly upload to the S3-compatible URLs
|
||||
# returned by the ente API.
|
||||
#
|
||||
# CF workers provide us with a way of make the file uploads faster. The why and
|
||||
# how is explained here: https://ente.io/blog/tech/making-uploads-faster/
|
||||
#
|
||||
# By default, that's the route we take. This flag can be set to true to disable
|
||||
# that route, and instead directly upload to the S3-compatible URLs provided by
|
||||
# our API server.
|
||||
#
|
||||
# Note the double negative.
|
||||
# NEXT_PUBLIC_DISABLE_CF_UPLOAD_PROXY = true
|
||||
|
||||
# This is an alternative to run as a development build.
|
||||
#
|
||||
# You likely don't need this if you're running on your machine, because when
|
||||
# invoked with `next dev` (as is the case for `yarn dev:photos` etc), next will
|
||||
# behave as if NODE_ENV was set to 'development'.
|
||||
# NEXT_PUBLIC_APP_ENV = development
|
||||
# NEXT_PUBLIC_ENTE_DIRECT_UPLOAD = true
|
||||
|
||||
# The path of the JSON file which contains the expected results of our
|
||||
# integration tests. See `upload.test.ts` for more details.
|
||||
# NEXT_PUBLIC_EXPECTED_JSON_PATH = /path/to/dataset/expected.json
|
||||
#
|
||||
# NEXT_PUBLIC_ENTE_TEST_EXPECTED_JSON_PATH = /path/to/dataset/expected.json
|
||||
|
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
|
|
|
@ -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",
|
||||
"PASSKEYS": "Passkeys",
|
||||
"FREEHAND": "Freehand",
|
||||
|
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
|
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
|
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
|
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "Router les chargements vers les serveurs à proximité",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "Éléments indexés",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
"KEY_GENERATION_IN_PROGRESS_MESSAGE": "Generazione delle chiavi di crittografia...",
|
||||
"PASSPHRASE_HINT": "Password",
|
||||
"CONFIRM_PASSPHRASE": "Conferma la password",
|
||||
"REFERRAL_CODE_HINT": "",
|
||||
"REFERRAL_CODE_HINT": "Come hai conosciuto Ente? (opzionale)",
|
||||
"REFERRAL_INFO": "",
|
||||
"PASSPHRASE_MATCH_ERROR": "Le password non corrispondono",
|
||||
"CONSOLE_WARNING_STOP": "STOP!",
|
||||
|
@ -179,7 +179,7 @@
|
|||
"SUBSCRIPTION_CANCEL_SUCCESS": "Abbonamento annullato con successo",
|
||||
"REACTIVATE_SUBSCRIPTION": "Riattiva abbonamento",
|
||||
"REACTIVATE_SUBSCRIPTION_MESSAGE": "Una volta riattivato, ti verrà addebitato il valore di {{date, dateTime}}",
|
||||
"SUBSCRIPTION_ACTIVATE_SUCCESS": "",
|
||||
"SUBSCRIPTION_ACTIVATE_SUCCESS": "Iscrizione attivata con successo ",
|
||||
"SUBSCRIPTION_ACTIVATE_FAILED": "",
|
||||
"SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "Grazie",
|
||||
"CANCEL_SUBSCRIPTION_ON_MOBILE": "Annulla abbonamento mobile",
|
||||
|
@ -196,13 +196,13 @@
|
|||
"SHARE": "Condividi",
|
||||
"SHARE_COLLECTION": "Condividi album",
|
||||
"SHAREES": "Condividi con",
|
||||
"SHARE_WITH_SELF": "",
|
||||
"ALREADY_SHARED": "",
|
||||
"SHARING_BAD_REQUEST_ERROR": "",
|
||||
"SHARING_DISABLED_FOR_FREE_ACCOUNTS": "",
|
||||
"SHARE_WITH_SELF": "Ops, non puoi condividere a te stesso",
|
||||
"ALREADY_SHARED": "Ops, lo stai già condividendo con {{email}}",
|
||||
"SHARING_BAD_REQUEST_ERROR": "Condividere gli album non è consentito",
|
||||
"SHARING_DISABLED_FOR_FREE_ACCOUNTS": "La condivisione è disabilitata per gli account free",
|
||||
"DOWNLOAD_COLLECTION": "Scarica album",
|
||||
"DOWNLOAD_COLLECTION_MESSAGE": "",
|
||||
"CREATE_ALBUM_FAILED": "",
|
||||
"DOWNLOAD_COLLECTION_MESSAGE": "<p>Sei sicuro di volere scaricare l'album interamente?</p><p>Tutti i file saranno messi in coda per il download</p>",
|
||||
"CREATE_ALBUM_FAILED": "Operazione di creazione dell'album fallita, per favore riprova",
|
||||
"SEARCH": "Ricerca",
|
||||
"SEARCH_RESULTS": "Risultati della ricerca",
|
||||
"NO_RESULTS": "",
|
||||
|
@ -210,21 +210,21 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "Album",
|
||||
"LOCATION": "Posizione",
|
||||
"CITY": "",
|
||||
"CITY": "Posizione",
|
||||
"DATE": "Data",
|
||||
"FILE_NAME": "Nome file",
|
||||
"THING": "",
|
||||
"THING": "Contenuto",
|
||||
"FILE_CAPTION": "Descrizione",
|
||||
"FILE_TYPE": "",
|
||||
"FILE_TYPE": "Tipo del file",
|
||||
"CLIP": ""
|
||||
},
|
||||
"photos_count_zero": "",
|
||||
"photos_count_zero": "Nessuna memoria",
|
||||
"photos_count_one": "",
|
||||
"photos_count_other": "",
|
||||
"TERMS_AND_CONDITIONS": "",
|
||||
"ADD_TO_COLLECTION": "Aggiungi all'album",
|
||||
"SELECTED": "",
|
||||
"VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD": "",
|
||||
"VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD": "Questo video non può essere riprodotto nel tuo browser",
|
||||
"PEOPLE": "Persone",
|
||||
"INDEXING_SCHEDULED": "",
|
||||
"ANALYZING_PHOTOS": "",
|
||||
|
@ -233,17 +233,17 @@
|
|||
"UNIDENTIFIED_FACES": "volti non identificati",
|
||||
"OBJECTS": "",
|
||||
"TEXT": "testo",
|
||||
"INFO": "",
|
||||
"INFO": "Info ",
|
||||
"INFO_OPTION": "",
|
||||
"FILE_NAME": "Nome file",
|
||||
"CAPTION_PLACEHOLDER": "Aggiungi una descrizione",
|
||||
"LOCATION": "Posizione",
|
||||
"SHOW_ON_MAP": "",
|
||||
"MAP": "",
|
||||
"MAP_SETTINGS": "",
|
||||
"ENABLE_MAPS": "",
|
||||
"ENABLE_MAP": "",
|
||||
"DISABLE_MAPS": "",
|
||||
"SHOW_ON_MAP": "Guarda su OpenStreetMap",
|
||||
"MAP": "Mappa",
|
||||
"MAP_SETTINGS": "Impostazioni Mappa",
|
||||
"ENABLE_MAPS": "Attivare Mappa?",
|
||||
"ENABLE_MAP": "Attivare mappa",
|
||||
"DISABLE_MAPS": "Disattivare Mappa?",
|
||||
"ENABLE_MAP_DESCRIPTION": "",
|
||||
"DISABLE_MAP_DESCRIPTION": "",
|
||||
"DISABLE_MAP": "",
|
||||
|
@ -262,7 +262,7 @@
|
|||
"ENABLE": "Attiva",
|
||||
"LOST_DEVICE": "",
|
||||
"INCORRECT_CODE": "Codice errato",
|
||||
"TWO_FACTOR_INFO": "",
|
||||
"TWO_FACTOR_INFO": "Aggiungi un ulteriore livello di sicurezza richiedendo più informazioni rispetto a email e password per eseguire l'accesso al tuo account",
|
||||
"DISABLE_TWO_FACTOR_LABEL": "",
|
||||
"UPDATE_TWO_FACTOR_LABEL": "",
|
||||
"DISABLE": "",
|
||||
|
@ -287,63 +287,63 @@
|
|||
"SKIPPED_VIDEOS_INFO": "",
|
||||
"LIVE_PHOTOS_DETECTED": "",
|
||||
"RETRY_FAILED": "",
|
||||
"FAILED_UPLOADS": "",
|
||||
"SKIPPED_FILES": "",
|
||||
"FAILED_UPLOADS": "Caricamento fallito ",
|
||||
"SKIPPED_FILES": "Ignora caricamenti",
|
||||
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "",
|
||||
"UNSUPPORTED_FILES": "",
|
||||
"SUCCESSFUL_UPLOADS": "",
|
||||
"SUCCESSFUL_UPLOADS": "Caricamenti eseguiti con successo",
|
||||
"SKIPPED_INFO": "",
|
||||
"UNSUPPORTED_INFO": "",
|
||||
"BLOCKED_UPLOADS": "",
|
||||
"SKIPPED_VIDEOS": "",
|
||||
"INPROGRESS_METADATA_EXTRACTION": "",
|
||||
"INPROGRESS_UPLOADS": "",
|
||||
"TOO_LARGE_UPLOADS": "",
|
||||
"LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "",
|
||||
"LARGER_THAN_AVAILABLE_STORAGE_INFO": "",
|
||||
"TOO_LARGE_INFO": "",
|
||||
"SKIPPED_VIDEOS": "Video saltati",
|
||||
"INPROGRESS_METADATA_EXTRACTION": "In corso",
|
||||
"INPROGRESS_UPLOADS": "Caricamenti in corso",
|
||||
"TOO_LARGE_UPLOADS": "File pesanti",
|
||||
"LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "Spazio insufficiente",
|
||||
"LARGER_THAN_AVAILABLE_STORAGE_INFO": "Questi file non sono stati caricati perché supererebbero la capacità massima del tuo piano di spazio d'archiviazione",
|
||||
"TOO_LARGE_INFO": "Questi file non sono stati caricati perché superano il nostro limite di pesantezza di un file",
|
||||
"THUMBNAIL_GENERATION_FAILED_INFO": "",
|
||||
"UPLOAD_TO_COLLECTION": "",
|
||||
"UNCATEGORIZED": "",
|
||||
"ARCHIVE": "Archivio",
|
||||
"FAVORITES": "",
|
||||
"ARCHIVE_COLLECTION": "",
|
||||
"FAVORITES": "Preferiti",
|
||||
"ARCHIVE_COLLECTION": "Album archiviato",
|
||||
"ARCHIVE_SECTION_NAME": "Archivio",
|
||||
"ALL_SECTION_NAME": "",
|
||||
"ALL_SECTION_NAME": "Tutto",
|
||||
"MOVE_TO_COLLECTION": "Sposta nell'album",
|
||||
"UNARCHIVE": "",
|
||||
"UNARCHIVE_COLLECTION": "",
|
||||
"HIDE_COLLECTION": "",
|
||||
"UNHIDE_COLLECTION": "",
|
||||
"MOVE": "",
|
||||
"UNARCHIVE": "Rimuovi dall'archivio",
|
||||
"UNARCHIVE_COLLECTION": "Rimuovi album dall'archivio",
|
||||
"HIDE_COLLECTION": "Nascondi album",
|
||||
"UNHIDE_COLLECTION": "Rimuovi album dai nascosti",
|
||||
"MOVE": "Sposta",
|
||||
"ADD": "Aggiungi",
|
||||
"REMOVE": "",
|
||||
"REMOVE": "Rimuovi",
|
||||
"YES_REMOVE": "Sì, rimuovi",
|
||||
"REMOVE_FROM_COLLECTION": "Rimuovi dall'album",
|
||||
"TRASH": "Cestino",
|
||||
"MOVE_TO_TRASH": "Sposta nel cestino",
|
||||
"TRASH_FILES_MESSAGE": "",
|
||||
"TRASH_FILE_MESSAGE": "",
|
||||
"DELETE_PERMANENTLY": "",
|
||||
"RESTORE": "",
|
||||
"RESTORE_TO_COLLECTION": "",
|
||||
"EMPTY_TRASH": "",
|
||||
"EMPTY_TRASH_TITLE": "",
|
||||
"EMPTY_TRASH_MESSAGE": "",
|
||||
"LEAVE_SHARED_ALBUM": "",
|
||||
"LEAVE_ALBUM": "",
|
||||
"LEAVE_SHARED_ALBUM_TITLE": "",
|
||||
"TRASH_FILES_MESSAGE": "Gli elementi selezionati verranno eliminati da tutti gli album e spostati nel cestino.",
|
||||
"TRASH_FILE_MESSAGE": "Il file verrà eliminato da tutti gli album e spostato nel cestino.",
|
||||
"DELETE_PERMANENTLY": "Elimina definitivamente",
|
||||
"RESTORE": "Ripristina",
|
||||
"RESTORE_TO_COLLECTION": "Ripristina nell'album",
|
||||
"EMPTY_TRASH": "Svuota il cestino",
|
||||
"EMPTY_TRASH_TITLE": "Vuoi svuotare il cestino?",
|
||||
"EMPTY_TRASH_MESSAGE": "I file selezionati verranno eliminati definitivamente dal tuo account ente.",
|
||||
"LEAVE_SHARED_ALBUM": "Sì, esci",
|
||||
"LEAVE_ALBUM": "Abbandona l'album",
|
||||
"LEAVE_SHARED_ALBUM_TITLE": "Abbandonare l'album condiviso?",
|
||||
"LEAVE_SHARED_ALBUM_MESSAGE": "",
|
||||
"NOT_FILE_OWNER": "",
|
||||
"CONFIRM_SELF_REMOVE_MESSAGE": "",
|
||||
"CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "",
|
||||
"SORT_BY_CREATION_TIME_ASCENDING": "",
|
||||
"SORT_BY_UPDATION_TIME_DESCENDING": "",
|
||||
"SORT_BY_CREATION_TIME_ASCENDING": "Meno recente",
|
||||
"SORT_BY_UPDATION_TIME_DESCENDING": "Ultimo aggiornamento",
|
||||
"SORT_BY_NAME": "Nome",
|
||||
"COMPRESS_THUMBNAILS": "",
|
||||
"THUMBNAIL_REPLACED": "",
|
||||
"FIX_THUMBNAIL": "",
|
||||
"FIX_THUMBNAIL_LATER": "",
|
||||
"COMPRESS_THUMBNAILS": "Comprimi miniature",
|
||||
"THUMBNAIL_REPLACED": "Miniature compresse",
|
||||
"FIX_THUMBNAIL": "Comprimi",
|
||||
"FIX_THUMBNAIL_LATER": "Comprimi più tardi",
|
||||
"REPLACE_THUMBNAIL_NOT_STARTED": "",
|
||||
"REPLACE_THUMBNAIL_COMPLETED": "",
|
||||
"REPLACE_THUMBNAIL_NOOP": "",
|
||||
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
|
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "Uploaden door nabije servers",
|
||||
"MAGIC_SEARCH_STATUS": "Magische Zoekfunctie Status",
|
||||
"INDEXED_ITEMS": "Geïndexeerde bestanden",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "Cache map",
|
||||
"FREEHAND": "Losse hand",
|
||||
"APPLY_CROP": "Bijsnijden toepassen",
|
||||
|
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
|
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
|
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
|
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "通过附近的服务器路由上传",
|
||||
"MAGIC_SEARCH_STATUS": "魔法搜索状态",
|
||||
"INDEXED_ITEMS": "索引项目",
|
||||
"CAST_ALBUM_TO_TV": "",
|
||||
"ENTER_CAST_PIN_CODE": "",
|
||||
"PAIR_DEVICE_TO_TV": "",
|
||||
"TV_NOT_FOUND": "",
|
||||
"AUTO_CAST_PAIR": "",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
|
||||
"PAIR_WITH_PIN": "",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
|
||||
"VISIT_CAST_ENTE_IO": "",
|
||||
"CAST_AUTO_PAIR_FAILED": "",
|
||||
"CACHE_DIRECTORY": "缓存文件夹",
|
||||
"FREEHAND": "手画",
|
||||
"APPLY_CROP": "应用裁剪",
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { setupSentry } from '@ente/shared/sentry/config/sentry.config.base';
|
||||
import { initSentry } from '@ente/shared/sentry/config/sentry.config.base';
|
||||
|
||||
const DEFAULT_SENTRY_DSN =
|
||||
'https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2';
|
||||
setupSentry(DEFAULT_SENTRY_DSN);
|
||||
initSentry('https://cbed7333f2810fbbdb692dcd76d8ca1a@sentry.ente.io/2');
|
||||
|
|
0
apps/photos/sentry.edge.config.ts
Normal file
0
apps/photos/sentry.edge.config.ts
Normal file
|
@ -1,3 +1,6 @@
|
|||
defaults.url=https://sentry.ente.io/
|
||||
defaults.org=ente
|
||||
defaults.project=photos-web
|
||||
# This file is used by the SentryWebpackPlugin to upload sourcemaps (if the
|
||||
# SENTRY_AUTH_TOKEN environment variable is defined).
|
||||
|
||||
defaults.url = https://sentry.ente.io/
|
||||
defaults.org = ente
|
||||
defaults.project = photos-web
|
||||
|
|
|
@ -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<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function CollectionInfoWithOptions({
|
||||
|
@ -49,6 +51,7 @@ export default function CollectionInfoWithOptions({
|
|||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CollectionInfoBarWrapper>
|
||||
<SpaceBetweenFlex>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue