diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..a0d02d4 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,6 @@ +- Use TypeScript. +- Use function syntax for defining React components. Define the prop types inline. +- If a value is exported, it should be exported on the same line as its definition. +- Always define the return type of a function or component. +- Use Tailwind CSS for styling. +- Don't use trailing semicolons. \ No newline at end of file diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 79d35e9..fe50730 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,6 +1,7 @@ -import Link from 'next/link' import Spinner from '../components/Spinner' import Wordmark from '../components/Wordmark' +import ReturnHome from '../components/ReturnHome' +import TitleText from '../components/TitleText' export const metadata = { title: 'FilePizza - 404: Slice Not Found', @@ -10,14 +11,10 @@ export const metadata = { export default async function NotFound(): Promise { return (
- + -

- 404: Looks like this slice of FilePizza is missing! -

- - Serve up a fresh slice » - + 404: Looks like this slice of FilePizza is missing! +
) } diff --git a/src/components/Downloader.tsx b/src/components/Downloader.tsx index b104371..75f8d33 100644 --- a/src/components/Downloader.tsx +++ b/src/components/Downloader.tsx @@ -1,22 +1,7 @@ 'use client' -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { useWebRTC } from './WebRTCProvider' -import { - browserName, - browserVersion, - osName, - osVersion, - mobileVendor, - mobileModel, -} from 'react-device-detect' -import { z } from 'zod' -import { ChunkMessage, decodeMessage, Message, MessageType } from '../messages' -import { - streamDownloadSingleFile, - streamDownloadMultipleFiles, -} from '../utils/download' -import { DataConnection } from 'peerjs' +import React, { useState, useCallback } from 'react' +import { useDownloader } from '../hooks/useDownloader' import PasswordField from './PasswordField' import UnlockButton from './UnlockButton' import Loading from './Loading' @@ -25,313 +10,110 @@ import DownloadButton from './DownloadButton' import StopButton from './StopButton' import ProgressBar from './ProgressBar' import TitleText from './TitleText' +import ReturnHome from './ReturnHome' -function cleanErrorMessage(errorMessage: string): string { - if (errorMessage.startsWith('Could not connect to peer')) { - return 'Could not connect to the uploader. Did they close their browser?' - } else { - return errorMessage - } -} - -function getZipFilename(): string { - return `filepizza-download-${Date.now()}.zip` +interface FileInfo { + fileName: string + size: number + type: string } -export default function Downloader({ - uploaderPeerID, +export function DownloadComplete({ + filesInfo, + bytesDownloaded, + totalSize, }: { - uploaderPeerID: string + filesInfo: FileInfo[] + bytesDownloaded: number + totalSize: number }): JSX.Element { - const peer = useWebRTC() - - const [password, setPassword] = useState('') - const [dataConnection, setDataConnection] = useState( - null, - ) - const [filesInfo, setFilesInfo] = useState | null>(null) - const processChunk = useRef< - ((message: z.infer) => void) | null - >(null) - const [shouldAttemptConnection, setShouldAttemptConnection] = useState(false) - const [open, setOpen] = useState(false) - const [downloading, setDownloading] = useState(false) - const [bytesDownloaded, setBytesDownloaded] = useState(0) - const [done, setDone] = useState(false) - const [errorMessage, setErrorMessage] = useState(null) - - useEffect(() => { - if (!shouldAttemptConnection) { - return - } - - const conn = peer.connect(uploaderPeerID, { - reliable: true, - }) - - setDataConnection(conn) - - const handleOpen = () => { - setOpen(true) - - const request: z.infer = { - type: MessageType.RequestInfo, - browserName: browserName, - browserVersion: browserVersion, - osName: osName, - osVersion: osVersion, - mobileVendor: mobileVendor, - mobileModel: mobileModel, - password, - } - - conn.send(request) - } - - const handleData = (data: unknown) => { - try { - const message = decodeMessage(data) - switch (message.type) { - case MessageType.Info: - setFilesInfo(message.files) - break - - case MessageType.Chunk: - if (processChunk.current) processChunk.current(message) - break - - case MessageType.Error: - console.error(message.error) - setErrorMessage(message.error) - conn.close() - break - } - } catch (err) { - console.error(err) - } - } - - const handleClose = () => { - setDataConnection(null) - setOpen(false) - setDownloading(false) - setShouldAttemptConnection(false) - } - - const handlePeerError = (err: Error) => { - console.error(err) - setErrorMessage(cleanErrorMessage(err.message)) - if (conn.open) { - conn.close() - } else { - handleClose() - } - } - - const handleConnectionError = (err: Error) => { - console.error(err) - setErrorMessage(cleanErrorMessage(err.message)) - if (conn.open) conn.close() - } - - conn.on('open', handleOpen) - conn.on('data', handleData) - conn.on('error', handleConnectionError) - conn.on('close', handleClose) - peer.on('error', handlePeerError) - - return () => { - if (conn.open) conn.close() - conn.off('open', handleOpen) - conn.off('data', handleData) - conn.off('error', handleConnectionError) - conn.off('close', handleClose) - peer.off('error', handlePeerError) - } - }, [peer, password, shouldAttemptConnection]) - - const handleSubmitPassword = useCallback((ev) => { - ev.preventDefault() - setShouldAttemptConnection(true) - }, []) - - const handleStartDownload = useCallback(() => { - if (!filesInfo || !dataConnection) return - - setDownloading(true) - - const fileStreamByPath: Record< - string, - { - stream: ReadableStream - enqueue: (chunk: Uint8Array) => void - close: () => void - } - > = {} - const fileStreams = filesInfo.map((info) => { - let enqueue: ((chunk: Uint8Array) => void) | null = null - let close: (() => void) | null = null - const stream = new ReadableStream({ - start(ctrl) { - enqueue = (chunk: Uint8Array) => ctrl.enqueue(chunk) - close = () => ctrl.close() - }, - }) - if (!enqueue || !close) { - throw new Error('Failed to initialize stream controllers') - } - fileStreamByPath[info.fileName] = { - stream, - enqueue, - close, - } - return stream - }) - - let nextFileIndex = 0 - const startNextFileOrFinish = (): void => { - if (nextFileIndex >= filesInfo.length) { - return - } - - const request: z.infer = { - type: MessageType.Start, - fileName: filesInfo[nextFileIndex].fileName, - offset: 0, - } - dataConnection.send(request) - nextFileIndex++ - } - - const processChunkFunc = (message: z.infer): void => { - const fileStream = fileStreamByPath[message.fileName] - if (!fileStream) { - console.error('no stream found for ' + message.fileName) - return - } - - setBytesDownloaded((bd) => bd + (message.bytes as ArrayBuffer).byteLength) - const uInt8 = new Uint8Array(message.bytes as ArrayBuffer) - fileStream.enqueue(uInt8) - if (message.final) { - fileStream.close() - startNextFileOrFinish() - } - } - processChunk.current = processChunkFunc - - const downloads = filesInfo.map((info, i) => ({ - name: info.fileName.replace(/^\//, ''), - size: info.size, - stream: () => fileStreams[i], - })) - - let downloadPromise: Promise | null = null - if (downloads.length > 1) { - const zipFilename = getZipFilename() - downloadPromise = streamDownloadMultipleFiles(downloads, zipFilename) - } else if (downloads.length === 1) { - downloadPromise = streamDownloadSingleFile( - downloads[0], - downloads[0].name, - ) - } else { - throw new Error('no files to download') - } - - downloadPromise - .then(() => { - const request: z.infer = { - type: MessageType.Done, - } - dataConnection.send(request) - setDone(true) - }) - .catch((err) => { - console.error(err) - }) - - startNextFileOrFinish() - }, [dataConnection, filesInfo]) - - const handleStopDownload = useCallback(() => { - // TODO(@kern): Implement me - }, []) - - const totalSize = filesInfo - ? filesInfo.reduce((acc, info) => acc + info.size, 0) - : 0 - - if (done && filesInfo) { - return ( - <> - You downloaded {filesInfo.length} files. -
- -
- -
-
- - ) - } - - if (downloading && filesInfo) { - return ( - <> - - You are about to start downloading {filesInfo.length} files. - -
- -
- -
- + return ( + <> + You downloaded {filesInfo.length} files. +
+ +
+
- - ) - } + +
+ + ) +} - if (open && filesInfo) { - return ( - <> - - You are about to start downloading {filesInfo.length} files. - -
- - +export function DownloadInProgress({ + filesInfo, + bytesDownloaded, + totalSize, + onStop, +}: { + filesInfo: FileInfo[] + bytesDownloaded: number + totalSize: number + onStop: () => void +}): JSX.Element { + return ( + <> + + You are about to start downloading {filesInfo.length} files. + +
+ +
+
- - ) - } + +
+ + ) +} - if (open) { - return - } +export function ReadyToDownload({ + filesInfo, + onStart, +}: { + filesInfo: FileInfo[] + onStart: () => void +}): JSX.Element { + return ( + <> + + You are about to start downloading {filesInfo.length} files. + +
+ + +
+ + ) +} - // TODO(@kern): Connect immediately, then have server respond if password is needed. - if (shouldAttemptConnection) { - return - } +export function PasswordEntry({ + onSubmit, + errorMessage, +}: { + onSubmit: (password: string) => void + errorMessage: string | null +}): JSX.Element { + const [password, setPassword] = useState('') + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + onSubmit(password) + }, + [onSubmit, password], + ) return ( <> - {errorMessage ? ( - {errorMessage} - ) : ( - This download requires a password. - )} + + {errorMessage || 'This download requires a password.'} +
@@ -341,10 +123,67 @@ export default function Downloader({ isRequired isInvalid={Boolean(errorMessage)} /> - +
) } + +export default function Downloader({ + uploaderPeerID, +}: { + uploaderPeerID: string +}): JSX.Element { + const { + filesInfo, + isConnected, + isPasswordRequired, + isDownloading, + isDone, + errorMessage, + submitPassword, + startDownload, + stopDownload, + totalSize, + bytesDownloaded, + } = useDownloader(uploaderPeerID) + + if (isDone && filesInfo) { + return ( + + ) + } + + if (!isConnected) { + return + } + + if (isDownloading && filesInfo) { + return ( + + ) + } + + if (filesInfo) { + return + } + + if (isPasswordRequired) { + return ( + + ) + } + + return +} diff --git a/src/components/ReturnHome.tsx b/src/components/ReturnHome.tsx new file mode 100644 index 0000000..72a081d --- /dev/null +++ b/src/components/ReturnHome.tsx @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default function ReturnHome(): JSX.Element { + return ( +
+ + Serve up a fresh slice » + +
+ ) +} diff --git a/src/hooks/useDownloader.ts b/src/hooks/useDownloader.ts new file mode 100644 index 0000000..0c0b1b2 --- /dev/null +++ b/src/hooks/useDownloader.ts @@ -0,0 +1,232 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { useWebRTC } from '../components/WebRTCProvider' +import { z } from 'zod' +import { ChunkMessage, decodeMessage, Message, MessageType } from '../messages' +import { DataConnection } from 'peerjs' +import { + streamDownloadSingleFile, + streamDownloadMultipleFiles, +} from '../utils/download' +import { + browserName, + browserVersion, + osName, + osVersion, + mobileVendor, + mobileModel, +} from 'react-device-detect' + +const cleanErrorMessage = (errorMessage: string): string => + errorMessage.startsWith('Could not connect to peer') + ? 'Could not connect to the uploader. Did they close their browser?' + : errorMessage + +const getZipFilename = (): string => `filepizza-download-${Date.now()}.zip` + +export function useDownloader(uploaderPeerID: string): { + filesInfo: Array<{ fileName: string; size: number; type: string }> | null + isConnected: boolean + isPasswordRequired: boolean + isDownloading: boolean + isDone: boolean + errorMessage: string | null + submitPassword: (password: string) => void + startDownload: () => void + stopDownload: () => void + totalSize: number + bytesDownloaded: number +} { + const peer = useWebRTC() + const [password, setPassword] = useState('') + const [dataConnection, setDataConnection] = useState( + null, + ) + const [filesInfo, setFilesInfo] = useState | null>(null) + const processChunk = useRef< + ((message: z.infer) => void) | null + >(null) + const [isConnected, setIsConnected] = useState(false) + const [isDownloading, setDownloading] = useState(false) + const [isDone, setDone] = useState(false) + const [bytesDownloaded, setBytesDownloaded] = useState(0) + const [errorMessage, setErrorMessage] = useState(null) + const [isPasswordRequired, setIsPasswordRequired] = useState(false) + + useEffect(() => { + const conn = peer.connect(uploaderPeerID, { reliable: true }) + setDataConnection(conn) + + const handleOpen = () => { + setIsConnected(true) + conn.send({ + type: MessageType.RequestInfo, + browserName, + browserVersion, + osName, + osVersion, + mobileVendor, + mobileModel, + } as z.infer) + } + + const handleData = (data: unknown) => { + try { + const message = decodeMessage(data) + switch (message.type) { + case MessageType.PasswordRequired: + setIsPasswordRequired(true) + break + case MessageType.Info: + setFilesInfo(message.files) + break + case MessageType.Chunk: + processChunk.current?.(message) + break + case MessageType.Error: + console.error(message.error) + setErrorMessage(message.error) + conn.close() + break + } + } catch (err) { + console.error(err) + } + } + + const handleClose = () => { + setDataConnection(null) + setIsConnected(false) + setDownloading(false) + } + + const handleError = (err: Error) => { + console.error(err) + setErrorMessage(cleanErrorMessage(err.message)) + if (conn.open) conn.close() + else handleClose() + } + + conn.on('open', handleOpen) + conn.on('data', handleData) + conn.on('error', handleError) + conn.on('close', handleClose) + peer.on('error', handleError) + + return () => { + if (conn.open) conn.close() + conn.off('open', handleOpen) + conn.off('data', handleData) + conn.off('error', handleError) + conn.off('close', handleClose) + peer.off('error', handleError) + } + }, [peer, password, uploaderPeerID]) + + const submitPassword = useCallback( + (pass: string) => { + if (!dataConnection) return + dataConnection.send({ + type: MessageType.UsePassword, + password: pass, + } as z.infer) + }, + [dataConnection, password], + ) + + const startDownload = useCallback(() => { + if (!filesInfo || !dataConnection) return + setDownloading(true) + + const fileStreamByPath: Record< + string, + { + stream: ReadableStream + enqueue: (chunk: Uint8Array) => void + close: () => void + } + > = {} + const fileStreams = filesInfo.map((info) => { + let enqueue: ((chunk: Uint8Array) => void) | null = null + let close: (() => void) | null = null + const stream = new ReadableStream({ + start(ctrl) { + enqueue = (chunk: Uint8Array) => ctrl.enqueue(chunk) + close = () => ctrl.close() + }, + }) + if (!enqueue || !close) + throw new Error('Failed to initialize stream controllers') + fileStreamByPath[info.fileName] = { stream, enqueue, close } + return stream + }) + + let nextFileIndex = 0 + const startNextFileOrFinish = () => { + if (nextFileIndex >= filesInfo.length) return + dataConnection.send({ + type: MessageType.Start, + fileName: filesInfo[nextFileIndex].fileName, + offset: 0, + } as z.infer) + nextFileIndex++ + } + + processChunk.current = (message: z.infer) => { + const fileStream = fileStreamByPath[message.fileName] + if (!fileStream) { + console.error('no stream found for ' + message.fileName) + return + } + setBytesDownloaded((bd) => bd + (message.bytes as ArrayBuffer).byteLength) + fileStream.enqueue(new Uint8Array(message.bytes as ArrayBuffer)) + if (message.final) { + fileStream.close() + startNextFileOrFinish() + } + } + + const downloads = filesInfo.map((info, i) => ({ + name: info.fileName.replace(/^\//, ''), + size: info.size, + stream: () => fileStreams[i], + })) + + const downloadPromise = + downloads.length > 1 + ? streamDownloadMultipleFiles(downloads, getZipFilename()) + : streamDownloadSingleFile(downloads[0], downloads[0].name) + + downloadPromise + .then(() => { + dataConnection.send({ type: MessageType.Done } as z.infer< + typeof Message + >) + setDone(true) + }) + .catch(console.error) + + startNextFileOrFinish() + }, [dataConnection, filesInfo]) + + const stopDownload = useCallback(() => { + // TODO(@kern): Implement me + }, []) + + return { + filesInfo, + isConnected, + isPasswordRequired, + isDownloading, + isDone, + errorMessage, + submitPassword, + startDownload, + stopDownload, + totalSize: filesInfo?.reduce((acc, info) => acc + info.size, 0) ?? 0, + bytesDownloaded, + } +} diff --git a/src/hooks/useUploaderConnections.ts b/src/hooks/useUploaderConnections.ts index 8abed65..27b944d 100644 --- a/src/hooks/useUploaderConnections.ts +++ b/src/hooks/useUploaderConnections.ts @@ -57,7 +57,8 @@ export function useUploaderConnections( const message = decodeMessage(data) switch (message.type) { case MessageType.RequestInfo: { - if (message.password !== password) { + if (password) { + // TODO(@kern): Check password const request: Message = { type: MessageType.Error, error: 'Invalid password', diff --git a/src/messages.ts b/src/messages.ts index 4c24852..826202a 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -7,6 +7,8 @@ export enum MessageType { Chunk = 'Chunk', Done = 'Done', Error = 'Error', + PasswordRequired = 'PasswordRequired', + UsePassword = 'UsePassword', } export const RequestInfoMessage = z.object({ @@ -17,7 +19,6 @@ export const RequestInfoMessage = z.object({ osVersion: z.string(), mobileVendor: z.string(), mobileModel: z.string(), - password: z.string(), }) export const InfoMessage = z.object({ @@ -54,6 +55,15 @@ export const ErrorMessage = z.object({ error: z.string(), }) +export const PasswordRequiredMessage = z.object({ + type: z.literal(MessageType.PasswordRequired), +}) + +export const UsePasswordMessage = z.object({ + type: z.literal(MessageType.UsePassword), + password: z.string(), +}) + export const Message = z.discriminatedUnion('type', [ RequestInfoMessage, InfoMessage, @@ -61,6 +71,8 @@ export const Message = z.discriminatedUnion('type', [ ChunkMessage, DoneMessage, ErrorMessage, + PasswordRequiredMessage, + UsePasswordMessage, ]) export type Message = z.infer