mirror of https://github.com/kern/filepizza
making progress
parent
6739d187e2
commit
bebf6c319a
@ -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.
|
||||
@ -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 »
|
||||
</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,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue