feat: cast flow
This commit is contained in:
parent
92a29ec096
commit
a600445e1b
|
@ -1,4 +1,8 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import _sodium from 'libsodium-wrappers';
|
||||||
|
import { getKexValue, setKexValue } from 'services/kexService';
|
||||||
|
import { boxSealOpen, toB64 } from 'utils/crypto/libsodium';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
const colourPool = [
|
const colourPool = [
|
||||||
'#87CEFA', // Light Blue
|
'#87CEFA', // Light Blue
|
||||||
|
@ -45,10 +49,105 @@ export default function PairingMode() {
|
||||||
|
|
||||||
const [digits, setDigits] = useState<string[]>([]);
|
const [digits, setDigits] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [publicKeyB64, setPublicKeyB64] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
init();
|
||||||
|
}, 45 * 1000); // the kex API deletes keys every 60s, so we'll regenerate stuff prematurely
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
const data = generateSecureData(4);
|
const data = generateSecureData(4);
|
||||||
setDigits(convertDataToHex(data).split(''));
|
setDigits(convertDataToHex(data).split(''));
|
||||||
}, []);
|
|
||||||
|
const keypair = await generateKeyPair();
|
||||||
|
setPublicKeyB64(await toB64(keypair.publicKey));
|
||||||
|
setPrivateKeyB64(await toB64(keypair.privateKey));
|
||||||
|
};
|
||||||
|
|
||||||
|
const [privateKeyB64, setPrivateKeyB64] = useState('');
|
||||||
|
|
||||||
|
const generateKeyPair = async () => {
|
||||||
|
await _sodium.ready;
|
||||||
|
|
||||||
|
const keypair = _sodium.crypto_box_keypair();
|
||||||
|
|
||||||
|
return keypair;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollForCastData = async () => {
|
||||||
|
// 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 {
|
||||||
|
devicePayload = await getKexValue(`${digits.join('')}_payload`);
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedPayload = await boxSealOpen(
|
||||||
|
devicePayload,
|
||||||
|
publicKeyB64,
|
||||||
|
privateKeyB64
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptedPayloadObj = JSON.parse(decryptedPayload);
|
||||||
|
|
||||||
|
return decryptedPayloadObj;
|
||||||
|
};
|
||||||
|
|
||||||
|
const storePayloadLocally = (payloadObj: Object) => {
|
||||||
|
// iterate through all the keys in the payload object and set them in localStorage.
|
||||||
|
// if the key is "encryptionKey", store it in session storage instead.
|
||||||
|
for (const key in payloadObj) {
|
||||||
|
if (key === 'encryptionKey') {
|
||||||
|
window.sessionStorage.setItem(key, payloadObj[key]);
|
||||||
|
} else {
|
||||||
|
window.localStorage.setItem(key, payloadObj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const advertisePublicKey = async (publicKeyB64: string) => {
|
||||||
|
// hey client, we exist!
|
||||||
|
try {
|
||||||
|
await setKexValue(`${digits.join('')}_pubkey`, publicKeyB64);
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (digits.length < 1 || !publicKeyB64 || !privateKeyB64) return;
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
const data = await pollForCastData();
|
||||||
|
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
storePayloadLocally(data);
|
||||||
|
|
||||||
|
router.push('/slideshow');
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [digits, publicKeyB64, privateKeyB64]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!publicKeyB64) return;
|
||||||
|
|
||||||
|
advertisePublicKey(publicKeyB64);
|
||||||
|
}, [publicKeyB64]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -15,8 +15,9 @@ export default function Slideshow() {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
const collections = await syncCollections();
|
const collections = await syncCollections();
|
||||||
|
|
||||||
// get requested collection id from fragment (this is temporary and will be changed during cast)
|
// get requested collection id from localStorage
|
||||||
const requestedCollectionID = window.location.hash.slice(1);
|
const requestedCollectionID =
|
||||||
|
window.localStorage.getItem('targetCollectionId');
|
||||||
|
|
||||||
const files = await syncFiles('normal', collections, () => {});
|
const files = await syncFiles('normal', collections, () => {});
|
||||||
|
|
||||||
|
|
28
apps/cast/src/services/kexService.ts
Normal file
28
apps/cast/src/services/kexService.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
import HTTPService from './HTTPService';
|
||||||
|
|
||||||
|
export const getKexValue = async (key: string) => {
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await HTTPService.get(`/kex/get`, {
|
||||||
|
identifier: key,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to get kex value');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.data.wrappedKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setKexValue = async (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
await HTTPService.put('/kex/add', {
|
||||||
|
customIdentifier: key,
|
||||||
|
wrappedKey: value,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to set kex value');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
|
@ -620,5 +620,8 @@
|
||||||
"FASTER_UPLOAD":"Faster uploads",
|
"FASTER_UPLOAD":"Faster uploads",
|
||||||
"FASTER_UPLOAD_DESCRIPTION":"Route uploads through nearby servers",
|
"FASTER_UPLOAD_DESCRIPTION":"Route uploads through nearby servers",
|
||||||
"STATUS": "Status",
|
"STATUS": "Status",
|
||||||
"INDEXED_ITEMS": "Indexed items"
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { CollectionInfo } from './CollectionInfo';
|
import { CollectionInfo } from './CollectionInfo';
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Collection, CollectionSummary } from 'types/collection';
|
import { Collection, CollectionSummary } from 'types/collection';
|
||||||
import CollectionOptions from 'components/Collections/CollectionOptions';
|
import CollectionOptions from 'components/Collections/CollectionOptions';
|
||||||
import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer';
|
import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer';
|
||||||
|
@ -12,6 +12,7 @@ import ArchiveOutlined from '@mui/icons-material/ArchiveOutlined';
|
||||||
import PeopleIcon from '@mui/icons-material/People';
|
import PeopleIcon from '@mui/icons-material/People';
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
|
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
|
||||||
|
import AlbumCastDialog from './CollectionOptions/AlbumCastDialog';
|
||||||
|
|
||||||
interface Iprops {
|
interface Iprops {
|
||||||
activeCollection: Collection;
|
activeCollection: Collection;
|
||||||
|
@ -52,21 +53,32 @@ export default function CollectionInfoWithOptions({
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollectionInfoBarWrapper>
|
<>
|
||||||
<SpaceBetweenFlex>
|
<CollectionInfoBarWrapper>
|
||||||
<CollectionInfo
|
<SpaceBetweenFlex>
|
||||||
name={name}
|
<CollectionInfo
|
||||||
fileCount={fileCount}
|
name={name}
|
||||||
endIcon={<EndIcon type={type} />}
|
fileCount={fileCount}
|
||||||
/>
|
endIcon={<EndIcon type={type} />}
|
||||||
{shouldShowOptions(type) && (
|
|
||||||
<CollectionOptions
|
|
||||||
{...props}
|
|
||||||
collectionSummaryType={type}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{shouldShowOptions(type) && (
|
||||||
</SpaceBetweenFlex>
|
<CollectionOptions
|
||||||
</CollectionInfoBarWrapper>
|
{...props}
|
||||||
|
setShowAlbumCastDialog={setShowAlbumCastDialog}
|
||||||
|
collectionSummaryType={type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SpaceBetweenFlex>
|
||||||
|
</CollectionInfoBarWrapper>
|
||||||
|
<AlbumCastDialog
|
||||||
|
currentCollectionId={props.activeCollection.id}
|
||||||
|
show={showAlbumCastDialog}
|
||||||
|
onHide={() => setShowAlbumCastDialog(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Typography } from '@mui/material';
|
||||||
|
import DialogBoxV2 from 'components/DialogBoxV2';
|
||||||
|
import SingleInputForm, {
|
||||||
|
SingleInputFormProps,
|
||||||
|
} from 'components/SingleInputForm';
|
||||||
|
import { t } from 'i18next';
|
||||||
|
import { getKexValue, setKexValue } from 'services/kexService';
|
||||||
|
import { boxSeal } from 'utils/crypto/libsodium';
|
||||||
|
import { SESSION_KEYS, getKey } from 'utils/storage/sessionStorage';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean;
|
||||||
|
onHide: () => void;
|
||||||
|
currentCollectionId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlbumCastDialog(props: Props) {
|
||||||
|
const onSubmit: SingleInputFormProps['callback'] = async (
|
||||||
|
value,
|
||||||
|
setFieldError
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await doCast(value);
|
||||||
|
props.onHide();
|
||||||
|
} catch (e) {
|
||||||
|
setFieldError(t('UNKNOWN_ERROR'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doCast = async (pin: string) => {
|
||||||
|
// does the TV exist? have they advertised their existence?
|
||||||
|
const tvPublicKeyKexKey = `${pin}_pubkey`;
|
||||||
|
|
||||||
|
const tvPublicKeyB64 = await getKexValue(tvPublicKeyKexKey);
|
||||||
|
if (!tvPublicKeyB64) {
|
||||||
|
throw new Error('Failed to get TV public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ok, they exist. let's give them the good stuff.
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
...window.localStorage,
|
||||||
|
sessionKey: getKey(SESSION_KEYS.ENCRYPTION_KEY),
|
||||||
|
targetCollectionId: props.currentCollectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptedPayload = await boxSeal(payload, tvPublicKeyB64);
|
||||||
|
|
||||||
|
const encryptedPayloadForTvKexKey = `${pin}_payload`;
|
||||||
|
|
||||||
|
// hey TV, we acknowlege you!
|
||||||
|
await setKexValue(encryptedPayloadForTvKexKey, encryptedPayload);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogBoxV2
|
||||||
|
sx={{ zIndex: 1600 }}
|
||||||
|
open={props.show}
|
||||||
|
onClose={props.onHide}
|
||||||
|
attributes={{
|
||||||
|
title: t('CAST_ALBUM_TO_TV'),
|
||||||
|
}}>
|
||||||
|
<Typography>{t('ENTER_CAST_PIN_CODE')}</Typography>
|
||||||
|
<SingleInputForm
|
||||||
|
callback={onSubmit}
|
||||||
|
fieldType="text"
|
||||||
|
placeholder={'123456'}
|
||||||
|
buttonText={t('PAIR_DEVICE_TO_TV')}
|
||||||
|
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
|
||||||
|
/>
|
||||||
|
</DialogBoxV2>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,4 @@
|
||||||
import { OverflowMenuOption } from 'components/OverflowMenu/option';
|
import { OverflowMenuOption } from 'components/OverflowMenu/option';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import PeopleIcon from '@mui/icons-material/People';
|
import PeopleIcon from '@mui/icons-material/People';
|
||||||
|
@ -13,6 +12,7 @@ import PushPinOutlined from '@mui/icons-material/PushPinOutlined';
|
||||||
import { UnPinIcon } from 'components/icons/UnPinIcon';
|
import { UnPinIcon } from 'components/icons/UnPinIcon';
|
||||||
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined';
|
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined';
|
||||||
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined';
|
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined';
|
||||||
|
import TvIcon from '@mui/icons-material/Tv';
|
||||||
|
|
||||||
interface Iprops {
|
interface Iprops {
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
|
@ -123,6 +123,16 @@ export function AlbumCollectionOption({
|
||||||
startIcon={<PeopleIcon />}>
|
startIcon={<PeopleIcon />}>
|
||||||
{t('SHARE_COLLECTION')}
|
{t('SHARE_COLLECTION')}
|
||||||
</OverflowMenuOption>
|
</OverflowMenuOption>
|
||||||
|
<OverflowMenuOption
|
||||||
|
startIcon={<TvIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
handleCollectionAction(
|
||||||
|
CollectionActions.SHOW_ALBUM_CAST_DIALOG,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}}>
|
||||||
|
{t('CAST_ALBUM_TO_TV')}
|
||||||
|
</OverflowMenuOption>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
import { AlbumCollectionOption } from './AlbumCollectionOption';
|
import { AlbumCollectionOption } from './AlbumCollectionOption';
|
||||||
import React, { useContext, useRef, useState } from 'react';
|
import React, {
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
useContext,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import * as CollectionAPI from 'services/collectionService';
|
import * as CollectionAPI from 'services/collectionService';
|
||||||
import * as TrashService from 'services/trashService';
|
import * as TrashService from 'services/trashService';
|
||||||
import {
|
import {
|
||||||
|
@ -45,6 +51,7 @@ interface CollectionOptionsProps {
|
||||||
collectionSummaryType: CollectionSummaryType;
|
collectionSummaryType: CollectionSummaryType;
|
||||||
showCollectionShareModal: () => void;
|
showCollectionShareModal: () => void;
|
||||||
setActiveCollectionID: (collectionID: number) => void;
|
setActiveCollectionID: (collectionID: number) => void;
|
||||||
|
setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CollectionActions {
|
export enum CollectionActions {
|
||||||
|
@ -67,6 +74,7 @@ export enum CollectionActions {
|
||||||
UNPIN,
|
UNPIN,
|
||||||
HIDE,
|
HIDE,
|
||||||
UNHIDE,
|
UNHIDE,
|
||||||
|
SHOW_ALBUM_CAST_DIALOG,
|
||||||
}
|
}
|
||||||
|
|
||||||
const CollectionOptions = (props: CollectionOptionsProps) => {
|
const CollectionOptions = (props: CollectionOptionsProps) => {
|
||||||
|
@ -78,6 +86,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
||||||
showCollectionShareModal,
|
showCollectionShareModal,
|
||||||
setCollectionDownloadProgressAttributesCreator,
|
setCollectionDownloadProgressAttributesCreator,
|
||||||
isActiveCollectionDownloadInProgress,
|
isActiveCollectionDownloadInProgress,
|
||||||
|
setShowAlbumCastDialog,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { startLoading, finishLoading, setDialogMessage } =
|
const { startLoading, finishLoading, setDialogMessage } =
|
||||||
|
@ -98,7 +107,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
||||||
action: CollectionActions,
|
action: CollectionActions,
|
||||||
loader = true
|
loader = true
|
||||||
) => {
|
) => {
|
||||||
let callback;
|
let callback: Function;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case CollectionActions.SHOW_RENAME_DIALOG:
|
case CollectionActions.SHOW_RENAME_DIALOG:
|
||||||
callback = showRenameCollectionModal;
|
callback = showRenameCollectionModal;
|
||||||
|
@ -157,6 +166,9 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
||||||
case CollectionActions.UNHIDE:
|
case CollectionActions.UNHIDE:
|
||||||
callback = unHideAlbum;
|
callback = unHideAlbum;
|
||||||
break;
|
break;
|
||||||
|
case CollectionActions.SHOW_ALBUM_CAST_DIALOG:
|
||||||
|
callback = showCastAlbumDialog;
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logError(
|
logError(
|
||||||
|
@ -167,7 +179,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
||||||
action;
|
action;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return async (...args) => {
|
return async (...args: any) => {
|
||||||
try {
|
try {
|
||||||
loader && startLoading();
|
loader && startLoading();
|
||||||
await callback(...args);
|
await callback(...args);
|
||||||
|
@ -185,6 +197,10 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showCastAlbumDialog = async () => {
|
||||||
|
setShowAlbumCastDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
const renameCollection = async (newName: string) => {
|
const renameCollection = async (newName: string) => {
|
||||||
if (activeCollection.name !== newName) {
|
if (activeCollection.name !== newName) {
|
||||||
await CollectionAPI.renameCollection(activeCollection, newName);
|
await CollectionAPI.renameCollection(activeCollection, newName);
|
||||||
|
|
28
apps/photos/src/services/kexService.ts
Normal file
28
apps/photos/src/services/kexService.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
import HTTPService from './HTTPService';
|
||||||
|
|
||||||
|
export const getKexValue = async (key: string) => {
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await HTTPService.get(`/kex/get`, {
|
||||||
|
identifier: key,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to get kex value');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.data.wrappedKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setKexValue = async (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
await HTTPService.put('/kex/add', {
|
||||||
|
customIdentifier: key,
|
||||||
|
wrappedKey: value,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to set kex value');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in a new issue