feat: cast flow
This commit is contained in:
parent
92a29ec096
commit
a600445e1b
|
@ -1,4 +1,8 @@
|
|||
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 = [
|
||||
'#87CEFA', // Light Blue
|
||||
|
@ -45,10 +49,105 @@ export default function PairingMode() {
|
|||
|
||||
const [digits, setDigits] = useState<string[]>([]);
|
||||
|
||||
const [publicKeyB64, setPublicKeyB64] = useState('');
|
||||
|
||||
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);
|
||||
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 (
|
||||
<>
|
||||
|
|
|
@ -15,8 +15,9 @@ export default function Slideshow() {
|
|||
const init = async () => {
|
||||
const collections = await syncCollections();
|
||||
|
||||
// get requested collection id from fragment (this is temporary and will be changed during cast)
|
||||
const requestedCollectionID = window.location.hash.slice(1);
|
||||
// get requested collection id from localStorage
|
||||
const requestedCollectionID =
|
||||
window.localStorage.getItem('targetCollectionId');
|
||||
|
||||
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_DESCRIPTION":"Route uploads through nearby servers",
|
||||
"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 React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Collection, CollectionSummary } from 'types/collection';
|
||||
import CollectionOptions from 'components/Collections/CollectionOptions';
|
||||
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 LinkIcon from '@mui/icons-material/Link';
|
||||
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
|
||||
import AlbumCastDialog from './CollectionOptions/AlbumCastDialog';
|
||||
|
||||
interface Iprops {
|
||||
activeCollection: Collection;
|
||||
|
@ -52,21 +53,32 @@ export default function CollectionInfoWithOptions({
|
|||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(true);
|
||||
|
||||
return (
|
||||
<CollectionInfoBarWrapper>
|
||||
<SpaceBetweenFlex>
|
||||
<CollectionInfo
|
||||
name={name}
|
||||
fileCount={fileCount}
|
||||
endIcon={<EndIcon type={type} />}
|
||||
/>
|
||||
{shouldShowOptions(type) && (
|
||||
<CollectionOptions
|
||||
{...props}
|
||||
collectionSummaryType={type}
|
||||
<>
|
||||
<CollectionInfoBarWrapper>
|
||||
<SpaceBetweenFlex>
|
||||
<CollectionInfo
|
||||
name={name}
|
||||
fileCount={fileCount}
|
||||
endIcon={<EndIcon type={type} />}
|
||||
/>
|
||||
)}
|
||||
</SpaceBetweenFlex>
|
||||
</CollectionInfoBarWrapper>
|
||||
{shouldShowOptions(type) && (
|
||||
<CollectionOptions
|
||||
{...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 React from 'react';
|
||||
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
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 VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined';
|
||||
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined';
|
||||
import TvIcon from '@mui/icons-material/Tv';
|
||||
|
||||
interface Iprops {
|
||||
isArchived: boolean;
|
||||
|
@ -123,6 +123,16 @@ export function AlbumCollectionOption({
|
|||
startIcon={<PeopleIcon />}>
|
||||
{t('SHARE_COLLECTION')}
|
||||
</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 React, { useContext, useRef, useState } from 'react';
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import * as CollectionAPI from 'services/collectionService';
|
||||
import * as TrashService from 'services/trashService';
|
||||
import {
|
||||
|
@ -45,6 +51,7 @@ interface CollectionOptionsProps {
|
|||
collectionSummaryType: CollectionSummaryType;
|
||||
showCollectionShareModal: () => void;
|
||||
setActiveCollectionID: (collectionID: number) => void;
|
||||
setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export enum CollectionActions {
|
||||
|
@ -67,6 +74,7 @@ export enum CollectionActions {
|
|||
UNPIN,
|
||||
HIDE,
|
||||
UNHIDE,
|
||||
SHOW_ALBUM_CAST_DIALOG,
|
||||
}
|
||||
|
||||
const CollectionOptions = (props: CollectionOptionsProps) => {
|
||||
|
@ -78,6 +86,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
showCollectionShareModal,
|
||||
setCollectionDownloadProgressAttributesCreator,
|
||||
isActiveCollectionDownloadInProgress,
|
||||
setShowAlbumCastDialog,
|
||||
} = props;
|
||||
|
||||
const { startLoading, finishLoading, setDialogMessage } =
|
||||
|
@ -98,7 +107,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
action: CollectionActions,
|
||||
loader = true
|
||||
) => {
|
||||
let callback;
|
||||
let callback: Function;
|
||||
switch (action) {
|
||||
case CollectionActions.SHOW_RENAME_DIALOG:
|
||||
callback = showRenameCollectionModal;
|
||||
|
@ -157,6 +166,9 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
case CollectionActions.UNHIDE:
|
||||
callback = unHideAlbum;
|
||||
break;
|
||||
case CollectionActions.SHOW_ALBUM_CAST_DIALOG:
|
||||
callback = showCastAlbumDialog;
|
||||
break;
|
||||
|
||||
default:
|
||||
logError(
|
||||
|
@ -167,7 +179,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
action;
|
||||
}
|
||||
}
|
||||
return async (...args) => {
|
||||
return async (...args: any) => {
|
||||
try {
|
||||
loader && startLoading();
|
||||
await callback(...args);
|
||||
|
@ -185,6 +197,10 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
};
|
||||
};
|
||||
|
||||
const showCastAlbumDialog = async () => {
|
||||
setShowAlbumCastDialog(true);
|
||||
};
|
||||
|
||||
const renameCollection = async (newName: string) => {
|
||||
if (activeCollection.name !== 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