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