Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/components/ToolGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { tools } from '@/registry/tools';
import { categories, categoryColors } from '@/registry/categories';
import { categories, categoryColors, categoryNotes } from '@/registry/categories';

/**
* Static tool grid grouped by category. Rendered without a client directive so
Expand All @@ -25,6 +25,9 @@ export function ToolGrid() {
({categoryTools.filter(tool => !tool.desktopOnly).length})
</span>
</div>
{categoryNotes[category] && (
<p className="mb-4 max-w-3xl text-sm text-muted-foreground">{categoryNotes[category]}</p>
)}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{categoryTools.map(tool => {
const Icon = tool.icon;
Expand Down
78 changes: 68 additions & 10 deletions src/hooks/useFileTransfer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { connectSignal, type SignalClient } from '@/tools/webrtc/signal-client';
import { createPeer, type PeerHandles } from '@/tools/webrtc/peer';
import { createManualConnection, type ManualConnection } from '@/tools/webrtc/manual';
import {
CHUNK_SIZE,
chunkCount,
Expand All @@ -27,8 +28,10 @@ const HIGH_WATER = 8 * 1024 * 1024; // pause sending above this bufferedAmount
export function useFileTransfer() {
const signalRef = useRef<SignalClient | null>(null);
const peerRef = useRef<PeerHandles | null>(null);
const manualRef = useRef<ManualConnection | null>(null);
const channelRef = useRef<RTCDataChannel | null>(null);
const modeRef = useRef<TransferMode>('send');
const iceRef = useRef<RTCIceServer[] | undefined>(undefined);
const recvRef = useRef<{ meta: TransferMeta | null; chunks: ArrayBuffer[]; received: number }>({
meta: null,
chunks: [],
Expand All @@ -44,13 +47,22 @@ export function useFileTransfer() {
const cleanup = useCallback(() => {
channelRef.current?.close();
peerRef.current?.close();
manualRef.current?.close();
signalRef.current?.close();
channelRef.current = null;
peerRef.current = null;
manualRef.current = null;
signalRef.current = null;
recvRef.current = { meta: null, chunks: [], received: 0 };
}, []);

const handleState = useCallback((state: RTCPeerConnectionState) => {
if (state === 'failed' || state === 'disconnected') {
setError('Could not connect — the network may be too restrictive (no relay server).');
setStatus('error');
}
}, []);

const handleRecv = useCallback((data: string | ArrayBuffer) => {
const r = recvRef.current;
if (typeof data === 'string') {
Expand Down Expand Up @@ -92,20 +104,18 @@ export function useFileTransfer() {
const signal = signalRef.current;
peerRef.current = createPeer({
initiator,
iceServers: iceRef.current,
sendSignal: msg => signal.send(msg),
onState: state => {
if (state === 'failed' || state === 'disconnected') {
setError('Could not connect — the network may be too restrictive (no relay server).');
setStatus('error');
}
},
onState: handleState,
onChannel: wireChannel,
});
}, [wireChannel]);
}, [wireChannel, handleState]);

const connect = useCallback((mode: TransferMode, roomId: string) => {
// --- Automatic signaling (via our server) ---
const connect = useCallback((mode: TransferMode, roomId: string, iceServers?: RTCIceServer[]) => {
cleanup();
modeRef.current = mode;
iceRef.current = iceServers;
setError('');
setProgress(0);
setReceivedBlob(null);
Expand All @@ -120,7 +130,7 @@ export function useFileTransfer() {
else setStatus('waiting');
break;
case 'peer-joined':
setupPeer(true); // host starts the offer
setupPeer(true);
break;
case 'peer-left':
setError('The other device disconnected.');
Expand All @@ -141,6 +151,41 @@ export function useFileTransfer() {
});
}, [cleanup, setupPeer]);

// --- Manual signaling (serverless copy-paste) ---
const manualCreateOffer = useCallback(async (iceServers?: RTCIceServer[]): Promise<string> => {
cleanup();
modeRef.current = 'send';
setError('');
setProgress(0);
setReceivedBlob(null);
setIncoming(null);
setStatus('connecting');
const conn = createManualConnection({ initiator: true, iceServers, onState: handleState, onChannel: wireChannel });
manualRef.current = conn;
const code = await conn.createOfferCode();
setStatus('waiting');
return code;
}, [cleanup, wireChannel, handleState]);

const manualAcceptAnswer = useCallback(async (answerCode: string): Promise<void> => {
await manualRef.current?.acceptAnswer(answerCode);
}, []);

const manualAcceptOffer = useCallback(async (offerCode: string, iceServers?: RTCIceServer[]): Promise<string> => {
cleanup();
modeRef.current = 'receive';
setError('');
setProgress(0);
setReceivedBlob(null);
setIncoming(null);
setStatus('connecting');
const conn = createManualConnection({ initiator: false, iceServers, onState: handleState, onChannel: wireChannel });
manualRef.current = conn;
const answer = await conn.acceptOfferReturnAnswer(offerCode);
setStatus('waiting');
return answer;
}, [cleanup, wireChannel, handleState]);

const sendFile = useCallback(async (file: File) => {
const channel = channelRef.current;
if (!channel || channel.readyState !== 'open') return;
Expand Down Expand Up @@ -177,5 +222,18 @@ export function useFileTransfer() {

useEffect(() => () => cleanup(), [cleanup]);

return { status, error, progress, incoming, receivedBlob, connect, sendFile, reset, CHUNK_SIZE };
return {
status,
error,
progress,
incoming,
receivedBlob,
connect,
manualCreateOffer,
manualAcceptAnswer,
manualAcceptOffer,
sendFile,
reset,
CHUNK_SIZE,
};
}
Loading
Loading