making progress

pull/134/head
Alex Kern 1 year ago
parent 6739d187e2
commit bebf6c319a
No known key found for this signature in database
GPG Key ID: EF051FACCACBEE25

@ -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.

@ -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<JSX.Element> {
return (
<div className="flex flex-col items-center space-y-5 py-10 max-w-2xl mx-auto">
<Spinner direction="down" />
<Spinner direction="up" />
<Wordmark />
<p>
<strong>404: Looks like this slice of FilePizza is missing!</strong>
</p>
<Link href="/" className="text-stone-500 hover:underline">
Serve up a fresh slice &raquo;
</Link>
<TitleText>404: Looks like this slice of FilePizza is missing!</TitleText>
<ReturnHome />
</div>
)
}

@ -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<DataConnection | null>(
null,
)
const [filesInfo, setFilesInfo] = useState<Array<{
fileName: string
size: number
type: string
}> | null>(null)
const processChunk = useRef<
((message: z.infer<typeof ChunkMessage>) => 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<string | null>(null)
useEffect(() => {
if (!shouldAttemptConnection) {
return
}
const conn = peer.connect(uploaderPeerID, {
reliable: true,
})
setDataConnection(conn)
const handleOpen = () => {
setOpen(true)
const request: z.infer<typeof Message> = {
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<Uint8Array>
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<Uint8Array>({
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<typeof Message> = {
type: MessageType.Start,
fileName: filesInfo[nextFileIndex].fileName,
offset: 0,
}
dataConnection.send(request)
nextFileIndex++
}
const processChunkFunc = (message: z.infer<typeof ChunkMessage>): 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<void> | 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<typeof Message> = {
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 (
<>
<TitleText>You downloaded {filesInfo.length} files.</TitleText>
<div className="flex flex-col space-y-5 w-full">
<UploadFileList files={filesInfo} />
<div className="w-full">
<ProgressBar value={bytesDownloaded} max={totalSize} />
</div>
</div>
</>
)
}
if (downloading && filesInfo) {
return (
<>
<TitleText>
You are about to start downloading {filesInfo.length} files.
</TitleText>
<div className="flex flex-col space-y-5 w-full">
<UploadFileList files={filesInfo} />
<div className="w-full">
<ProgressBar value={bytesDownloaded} max={totalSize} />
</div>
<StopButton onClick={handleStopDownload} isDownloading />
return (
<>
<TitleText>You downloaded {filesInfo.length} files.</TitleText>
<div className="flex flex-col space-y-5 w-full">
<UploadFileList files={filesInfo} />
<div className="w-full">
<ProgressBar value={bytesDownloaded} max={totalSize} />
</div>
</>
)
}
<ReturnHome />
</div>
</>
)
}
if (open && filesInfo) {
return (
<>
<TitleText>
You are about to start downloading {filesInfo.length} files.
</TitleText>
<div className="flex flex-col space-y-5 w-full">
<UploadFileList files={filesInfo} />
<DownloadButton onClick={handleStartDownload} />
export function DownloadInProgress({
filesInfo,
bytesDownloaded,
totalSize,
onStop,
}: {
filesInfo: FileInfo[]
bytesDownloaded: number
totalSize: number
onStop: () => void
}): JSX.Element {
return (
<>
<TitleText>
You are about to start downloading {filesInfo.length} files.
</TitleText>
<div className="flex flex-col space-y-5 w-full">
<UploadFileList files={filesInfo} />
<div className="w-full">
<ProgressBar value={bytesDownloaded} max={totalSize} />
</div>
</>
)
}
<StopButton onClick={onStop} isDownloading />
</div>
</>
)
}
if (open) {
return <Loading text="Listing uploaded files" />
}
export function ReadyToDownload({
filesInfo,
onStart,
}: {
filesInfo: FileInfo[]
onStart: () => void
}): JSX.Element {
return (
<>
<TitleText>
You are about to start downloading {filesInfo.length} files.
</TitleText>
<div className="flex flex-col space-y-5 w-full">
<UploadFileList files={filesInfo} />
<DownloadButton onClick={onStart} />
</div>
</>
)
}
// TODO(@kern): Connect immediately, then have server respond if password is needed.
if (shouldAttemptConnection) {
return <Loading text="Connecting to uploader" />
}
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 ? (
<TitleText>{errorMessage}</TitleText>
) : (
<TitleText>This download requires a password.</TitleText>
)}
<TitleText>
{errorMessage || 'This download requires a password.'}
</TitleText>
<div className="flex flex-col space-y-5 w-full">
<form
action="#"
method="post"
onSubmit={handleSubmitPassword}
onSubmit={handleSubmit}
className="w-full"
>
<div className="flex flex-col space-y-5 w-full">
@ -341,10 +123,67 @@ export default function Downloader({
isRequired
isInvalid={Boolean(errorMessage)}
/>
<UnlockButton onClick={handleSubmitPassword} />
<UnlockButton />
</div>
</form>
</div>
</>
)
}
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 (
<DownloadComplete
filesInfo={filesInfo}
bytesDownloaded={bytesDownloaded}
totalSize={totalSize}
/>
)
}
if (!isConnected) {
return <Loading text="Connecting to uploader..." />
}
if (isDownloading && filesInfo) {
return (
<DownloadInProgress
filesInfo={filesInfo}
bytesDownloaded={bytesDownloaded}
totalSize={totalSize}
onStop={stopDownload}
/>
)
}
if (filesInfo) {
return <ReadyToDownload filesInfo={filesInfo} onStart={startDownload} />
}
if (isPasswordRequired) {
return (
<PasswordEntry errorMessage={errorMessage} onSubmit={submitPassword} />
)
}
return <Loading text="Uh oh... Something went wrong." />
}

@ -0,0 +1,11 @@
import Link from 'next/link'
export default function ReturnHome(): JSX.Element {
return (
<div className="flex justify-center">
<Link href="/" className="text-stone-500 hover:underline">
Serve up a fresh slice &raquo;
</Link>
</div>
)
}

@ -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<DataConnection | null>(
null,
)
const [filesInfo, setFilesInfo] = useState<Array<{
fileName: string
size: number
type: string
}> | null>(null)
const processChunk = useRef<
((message: z.infer<typeof ChunkMessage>) => 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<string | null>(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<typeof Message>)
}
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<typeof Message>)
},
[dataConnection, password],
)
const startDownload = useCallback(() => {
if (!filesInfo || !dataConnection) return
setDownloading(true)
const fileStreamByPath: Record<
string,
{
stream: ReadableStream<Uint8Array>
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<Uint8Array>({
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<typeof Message>)
nextFileIndex++
}
processChunk.current = (message: z.infer<typeof ChunkMessage>) => {
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,
}
}

@ -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',

@ -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<typeof Message>

Loading…
Cancel
Save