feat: initial commit with full deployment setup
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 8s
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 8s
Includes React PWA frontend, WebSocket signaling server, shared types, K8s manifests, Gitea CI/CD workflow, nginx config, and Dockerfiles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
9d6e4da4ae
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
dist
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
.gitea
|
||||||
|
k8s
|
||||||
83
.gitea/workflows/deploy.yml
Normal file
83
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.arthurbarre.fr
|
||||||
|
IMAGE_SERVER: ordinarthur/anydrop-server
|
||||||
|
IMAGE_WEB: ordinarthur/anydrop-web
|
||||||
|
NAMESPACE: anydrop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ordinarthur
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push server
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: server/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_SERVER }}:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_SERVER }}:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Build and push web
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: web/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:${{ github.sha }}
|
||||||
|
build-args: |
|
||||||
|
VITE_WS_URL=wss://anydrop.arthurbarre.fr/ws
|
||||||
|
|
||||||
|
- name: Install kubectl
|
||||||
|
run: |
|
||||||
|
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||||
|
chmod +x kubectl
|
||||||
|
mv kubectl /usr/local/bin/
|
||||||
|
|
||||||
|
- name: Deploy to K3s
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.kube
|
||||||
|
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
|
||||||
|
chmod 600 ~/.kube/config
|
||||||
|
|
||||||
|
# Create namespace if needed
|
||||||
|
kubectl apply -f k8s/namespace.yml
|
||||||
|
|
||||||
|
# Create registry secret if needed
|
||||||
|
kubectl -n $NAMESPACE create secret docker-registry gitea-registry \
|
||||||
|
--docker-server=$REGISTRY \
|
||||||
|
--docker-username=ordinarthur \
|
||||||
|
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
# Apply manifests
|
||||||
|
kubectl apply -f k8s/server.yml
|
||||||
|
kubectl apply -f k8s/web.yml
|
||||||
|
|
||||||
|
# Force rollout with new images
|
||||||
|
kubectl -n $NAMESPACE set image deployment/anydrop-server \
|
||||||
|
anydrop-server=$REGISTRY/$IMAGE_SERVER:${{ github.sha }}
|
||||||
|
kubectl -n $NAMESPACE set image deployment/anydrop-web \
|
||||||
|
anydrop-web=$REGISTRY/$IMAGE_WEB:${{ github.sha }}
|
||||||
|
|
||||||
|
# Wait for rollout
|
||||||
|
kubectl -n $NAMESPACE rollout status deployment/anydrop-server --timeout=120s
|
||||||
|
kubectl -n $NAMESPACE rollout status deployment/anydrop-web --timeout=120s
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
124
CLAUDE.md
Normal file
124
CLAUDE.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# AnyDrop
|
||||||
|
|
||||||
|
> Partage de fichiers et de texte instantané, multi-OS, peer-to-peer, sans compte.
|
||||||
|
|
||||||
|
**Domaine de production :** `anydrop.arthurbarre.fr`
|
||||||
|
**Auteur :** Arthur Barré
|
||||||
|
**Status :** V1 — Web app (en cours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vision produit
|
||||||
|
|
||||||
|
AnyDrop est un "AirDrop universel" : il permet de partager fichiers, photos et texte instantanément entre **tous** les appareils (iOS, Android, macOS, Windows, Linux), quel que soit leur système d'exploitation, sans compte obligatoire et sans installation forcée.
|
||||||
|
|
||||||
|
Deux modes de partage :
|
||||||
|
|
||||||
|
1. **Mode réseau local** — Deux appareils sur le même Wi-Fi se voient automatiquement (détection par IP publique partagée).
|
||||||
|
2. **Mode lien public** — L'utilisateur génère une URL courte `anydrop.arthurbarre.fr/x7k` + QR code, partageable à n'importe qui, n'importe où (transfert P2P à travers internet via STUN/TURN).
|
||||||
|
|
||||||
|
Dans les deux cas, le transfert est **pair-à-pair** (WebRTC DataChannel), **chiffré** (DTLS), et **ne transite pas par le serveur**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture technique
|
||||||
|
|
||||||
|
### Stack V1
|
||||||
|
|
||||||
|
**Front web**
|
||||||
|
- React 18 + Vite + TypeScript
|
||||||
|
- TailwindCSS
|
||||||
|
- Zustand (state management)
|
||||||
|
- `simple-peer` (abstraction WebRTC)
|
||||||
|
- `qrcode.react` (génération QR code)
|
||||||
|
- PWA (installable sur mobile/desktop)
|
||||||
|
|
||||||
|
**Back signaling**
|
||||||
|
- Node.js + TypeScript
|
||||||
|
- `ws` (WebSocket)
|
||||||
|
- Rooms en mémoire (pas de DB en V1)
|
||||||
|
- Génération de codes courts (3-4 caractères, alphabet sans ambiguïté)
|
||||||
|
|
||||||
|
**Infra**
|
||||||
|
- Signaling : Railway / Fly.io
|
||||||
|
- Front : Vercel / Netlify
|
||||||
|
- STUN : `stun.l.google.com:19302` (gratuit)
|
||||||
|
- TURN : Open Relay (gratuit) → coturn auto-hébergé plus tard
|
||||||
|
|
||||||
|
### Pourquoi WebRTC ?
|
||||||
|
|
||||||
|
- Transferts **P2P** (rapides sur réseau local, 50-500 Mo/s)
|
||||||
|
- **Chiffrement natif obligatoire** (DTLS)
|
||||||
|
- Fonctionne dans tous les navigateurs modernes
|
||||||
|
- Pas de coût bande passante serveur pour les données
|
||||||
|
|
||||||
|
### Rôle du serveur de signaling
|
||||||
|
|
||||||
|
Le serveur **ne voit jamais les fichiers**. Il sert uniquement à :
|
||||||
|
1. Regrouper les pairs par "room" (IP publique ou code court)
|
||||||
|
2. Relayer les SDP offers/answers et ICE candidates entre les pairs
|
||||||
|
3. Notifier les connexions/déconnexions
|
||||||
|
|
||||||
|
Une fois la connexion WebRTC établie, le serveur est transparent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure du repo
|
||||||
|
|
||||||
|
```
|
||||||
|
anydrop/
|
||||||
|
├── CLAUDE.md # Ce fichier
|
||||||
|
├── docs/ # Documentation technique détaillée
|
||||||
|
│ ├── architecture.md
|
||||||
|
│ ├── webrtc-flow.md
|
||||||
|
│ ├── signaling-protocol.md
|
||||||
|
│ └── roadmap.md
|
||||||
|
├── web/ # React PWA (front utilisateur)
|
||||||
|
├── server/ # Node signaling WebSocket
|
||||||
|
├── shared/ # Types TypeScript partagés front/back
|
||||||
|
└── package.json # Monorepo (npm workspaces)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap (résumé)
|
||||||
|
|
||||||
|
- **Phase 1 — Web PWA** *(en cours)* : détection LAN, lien public + QR, transferts fichiers/texte
|
||||||
|
- **Phase 2 — Extensions** : Chrome + Firefox (menu contextuel "Envoyer via AnyDrop")
|
||||||
|
- **Phase 3 — Desktop** : Tauri (macOS + Windows + Linux, une base de code)
|
||||||
|
- **Phase 4 — Mobile** : iOS + Android (React Native ou Flutter, Share Sheet natif)
|
||||||
|
- **Phase 5 — Avancé** : chiffrement applicatif additionnel, transferts de dossiers, historique
|
||||||
|
|
||||||
|
Détails dans `docs/roadmap.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Langue du code** : anglais (variables, commentaires, commits)
|
||||||
|
- **Langue de la doc utilisateur** : français (site + UI)
|
||||||
|
- **Commits** : style conventional commits (`feat:`, `fix:`, `docs:`, `refactor:`…)
|
||||||
|
- **Pas de compte / pas de tracking** : valeur produit fondamentale, ne pas l'abandonner pour des "features"
|
||||||
|
- **Pas de stockage côté serveur** des contenus échangés (les rooms sont en RAM, volatiles)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commandes principales (à venir une fois scaffoldé)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # installe le monorepo
|
||||||
|
npm run dev # démarre front + signaling en parallèle
|
||||||
|
npm run dev:web # uniquement le front (Vite)
|
||||||
|
npm run dev:server # uniquement le signaling
|
||||||
|
npm run build # build production
|
||||||
|
npm run typecheck # vérification TS globale
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principes de développement
|
||||||
|
|
||||||
|
1. **Privacy by design** — Pas de logs de contenus, pas d'analytics intrusifs, codes de rooms éphémères.
|
||||||
|
2. **Zero-friction** — Ouvrir l'URL doit suffire. Pas de compte, pas d'email.
|
||||||
|
3. **Progressive enhancement** — Le web marche partout ; les apps natives ajoutent du confort, pas des features bloquantes.
|
||||||
|
4. **Un seul domaine, plusieurs clients** — Tous les clients (web, extensions, desktop, mobile) parlent au même signaling.
|
||||||
87
docs/architecture.md
Normal file
87
docs/architecture.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Architecture AnyDrop
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
AnyDrop repose sur trois briques :
|
||||||
|
|
||||||
|
1. **Client** (web, extension, desktop, mobile) — interface utilisateur + moteur WebRTC
|
||||||
|
2. **Serveur de signaling** — met en relation les pairs, ne voit jamais les fichiers
|
||||||
|
3. **Serveurs STUN/TURN** — aident à établir la connexion P2P à travers les NAT/firewalls
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ WebSocket ┌────────────────┐ WebSocket ┌──────────┐
|
||||||
|
│ Client A │ ────────────────────▶ │ Signaling │ ◀──────────────────── │ Client B │
|
||||||
|
└──────────┘ │ Server │ └──────────┘
|
||||||
|
│ └────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ═══════ WebRTC DataChannel (P2P chiffré DTLS) ═══════ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
(les fichiers transitent uniquement ici)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Le serveur de signaling
|
||||||
|
|
||||||
|
### Responsabilités
|
||||||
|
|
||||||
|
- Accepter les connexions WebSocket entrantes
|
||||||
|
- Attribuer un `peerId` unique et un `displayName` (nom animal aléatoire) à chaque client
|
||||||
|
- Placer le client dans la ou les rooms appropriées :
|
||||||
|
- Room **LAN** : hash de l'IP publique de la connexion
|
||||||
|
- Room **publique** : code court fourni dans l'URL (ex: `/x7k`)
|
||||||
|
- Relayer les messages de signaling (`offer`, `answer`, `ice-candidate`) entre pairs d'une même room
|
||||||
|
- Notifier `peer-joined` / `peer-left`
|
||||||
|
- Expirer les rooms publiques inactives (10 min)
|
||||||
|
|
||||||
|
### Ce que le serveur ne fait PAS
|
||||||
|
|
||||||
|
- ❌ Ne stocke aucun fichier ni contenu échangé
|
||||||
|
- ❌ Ne log pas les IPs dans des fichiers persistants
|
||||||
|
- ❌ N'a pas de base de données
|
||||||
|
- ❌ N'a pas d'authentification
|
||||||
|
|
||||||
|
## Les clients
|
||||||
|
|
||||||
|
Tous les clients (web, extensions, desktop, mobile) implémentent le **même protocole de signaling** et la même logique WebRTC. Le code `shared/` exporte les types de messages communs pour garantir la cohérence.
|
||||||
|
|
||||||
|
### Couche abstraction P2P
|
||||||
|
|
||||||
|
On utilise `simple-peer` pour masquer la complexité de l'API WebRTC native :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const peer = new SimplePeer({ initiator: true, trickle: true });
|
||||||
|
peer.on('signal', data => sendViaWebSocket(data)); // envoyer offer/ICE
|
||||||
|
peer.on('connect', () => sendFile(file)); // connexion établie
|
||||||
|
peer.on('data', chunk => reassemble(chunk)); // chunks reçus
|
||||||
|
```
|
||||||
|
|
||||||
|
### Découpage des fichiers
|
||||||
|
|
||||||
|
- Chunk size : **16 Ko** (compromis débit/mémoire)
|
||||||
|
- Header JSON initial : `{ name, size, mime, id }`
|
||||||
|
- Puis envoi séquentiel avec backpressure (`peer._channel.bufferedAmount < 1MB`)
|
||||||
|
- Fin de fichier : chunk spécial `{ eof: true, id }`
|
||||||
|
- Récepteur : accumule dans un `Blob[]` puis `new Blob(chunks, { type })`
|
||||||
|
|
||||||
|
## STUN et TURN
|
||||||
|
|
||||||
|
### STUN (Session Traversal Utilities for NAT)
|
||||||
|
|
||||||
|
Gratuit, quasi-gratuit en bande passante (quelques paquets). Sert uniquement à ce que le client découvre sa propre IP publique + port NAT, pour l'inclure dans les ICE candidates.
|
||||||
|
|
||||||
|
Serveurs utilisés : `stun.l.google.com:19302`, `stun1.l.google.com:19302`.
|
||||||
|
|
||||||
|
### TURN (Traversal Using Relays around NAT)
|
||||||
|
|
||||||
|
Fallback quand le P2P direct échoue (~10-20% des cas, surtout en entreprise / 4G). Le trafic est alors **relayé** par le serveur TURN → coût bande passante réel.
|
||||||
|
|
||||||
|
- V1 : Open Relay (gratuit, 500 Go/mois) — suffisant pour un lancement
|
||||||
|
- Plus tard : coturn auto-hébergé sur un VPS
|
||||||
|
|
||||||
|
## Déploiement cible
|
||||||
|
|
||||||
|
| Composant | Hébergement | URL |
|
||||||
|
|----------------|----------------------|-----------------------------------|
|
||||||
|
| Front web (PWA)| Vercel / Netlify | `anydrop.arthurbarre.fr` |
|
||||||
|
| Signaling | Railway / Fly.io | `ws.anydrop.arthurbarre.fr` |
|
||||||
|
| STUN | Google (gratuit) | `stun.l.google.com:19302` |
|
||||||
|
| TURN | Open Relay → coturn | `turn.anydrop.arthurbarre.fr` |
|
||||||
95
docs/roadmap.md
Normal file
95
docs/roadmap.md
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# Roadmap AnyDrop
|
||||||
|
|
||||||
|
## Phase 1 — Web PWA (V1, en cours)
|
||||||
|
|
||||||
|
**Objectif :** Produit utilisable depuis n'importe quel navigateur moderne, sur `anydrop.arthurbarre.fr`.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- [ ] Détection auto des pairs sur le même réseau (room LAN par IP publique)
|
||||||
|
- [ ] Envoi de fichiers (drag & drop, sélecteur)
|
||||||
|
- [ ] Envoi de plusieurs fichiers en une fois
|
||||||
|
- [ ] Envoi de texte / liens (modal)
|
||||||
|
- [ ] Aperçu image avant envoi
|
||||||
|
- [ ] Popup accepter/refuser côté récepteur
|
||||||
|
- [ ] Barre de progression par transfert
|
||||||
|
- [ ] Nom d'appareil animal auto-généré (pas de compte)
|
||||||
|
- [ ] Mode lien public `/xxx` avec code à 3 caractères
|
||||||
|
- [ ] Génération QR code pour lien public
|
||||||
|
- [ ] Expiration des rooms publiques (10 min d'inactivité)
|
||||||
|
- [ ] PWA (manifest + service worker, installable)
|
||||||
|
- [ ] Responsive mobile + desktop
|
||||||
|
- [ ] Dark mode
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
React + Vite + TS + Tailwind + Zustand + simple-peer + qrcode.react
|
||||||
|
Node + ws + TypeScript
|
||||||
|
|
||||||
|
### Livrable
|
||||||
|
Site fonctionnel déployé sur `anydrop.arthurbarre.fr` + signaling sur `ws.anydrop.arthurbarre.fr`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Extensions navigateur
|
||||||
|
|
||||||
|
**Objectif :** Partage en 1 clic depuis n'importe quelle page.
|
||||||
|
|
||||||
|
- Extension Chrome (Manifest V3)
|
||||||
|
- Extension Firefox (WebExtensions)
|
||||||
|
- Menu contextuel : "Envoyer via AnyDrop" sur page, image, lien, texte sélectionné
|
||||||
|
- Popup réutilisant la liste des pairs
|
||||||
|
- Réutilisation maximale du code `web/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Apps desktop (Tauri)
|
||||||
|
|
||||||
|
**Objectif :** Intégration OS native sans payer le prix d'Electron.
|
||||||
|
|
||||||
|
- Tauri (Rust + webview système)
|
||||||
|
- Targets : macOS (Intel + Apple Silicon), Windows 10+, Linux (AppImage + .deb)
|
||||||
|
- Features natives :
|
||||||
|
- Menu "Envoyer à AnyDrop" via Share Sheet (macOS) / Context Menu (Windows)
|
||||||
|
- Tray icon avec liste des pairs
|
||||||
|
- Dossier de réception configurable
|
||||||
|
- Notifications natives
|
||||||
|
- Démarrage auto (optionnel)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Apps mobiles
|
||||||
|
|
||||||
|
**Objectif :** Expérience native, intégrée au Share Sheet système.
|
||||||
|
|
||||||
|
- React Native (choix préféré pour réutiliser l'écosystème JS) ou Flutter
|
||||||
|
- iOS : Share Extension (partager depuis Photos, Safari, etc.)
|
||||||
|
- Android : Share Intent (partager depuis n'importe quelle app)
|
||||||
|
- Scan QR code intégré (caméra)
|
||||||
|
- Historique local (chiffré)
|
||||||
|
|
||||||
|
### Note Windows Phone
|
||||||
|
Windows Phone a été arrêté par Microsoft en 2019. Les rares appareils existants utiliseront la **version web** via Edge mobile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Fonctionnalités avancées
|
||||||
|
|
||||||
|
- Transfert de dossiers entiers (zip streaming)
|
||||||
|
- Chiffrement applicatif additionnel (SafetyNumber vérifiable)
|
||||||
|
- Reprise de transfert après coupure
|
||||||
|
- Historique chiffré local des transferts
|
||||||
|
- Favoris d'appareils (trust on first use)
|
||||||
|
- Multi-pair simultané (envoyer à plusieurs pairs en parallèle)
|
||||||
|
- Statistiques agrégées anonymes (opt-in)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hors-scope (pour l'instant)
|
||||||
|
|
||||||
|
- Comptes utilisateurs
|
||||||
|
- Stockage cloud des fichiers
|
||||||
|
- Messagerie persistante
|
||||||
|
- Collaboration temps réel sur documents
|
||||||
|
- Monétisation
|
||||||
|
|
||||||
|
Ces choix sont volontaires : AnyDrop doit rester **simple, rapide, privé**.
|
||||||
123
docs/signaling-protocol.md
Normal file
123
docs/signaling-protocol.md
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
# Protocole de signaling AnyDrop
|
||||||
|
|
||||||
|
Protocole simple en JSON via WebSocket. Endpoint : `wss://ws.anydrop.arthurbarre.fr`.
|
||||||
|
|
||||||
|
Tous les messages ont un champ `type`. Les types côté client et côté serveur sont définis dans `shared/protocol.ts`.
|
||||||
|
|
||||||
|
## Messages client → serveur
|
||||||
|
|
||||||
|
### `hello`
|
||||||
|
Envoyé immédiatement après connexion. Si `joinCode` est présent, le client rejoint la room publique correspondante. Sinon il est placé dans la room LAN basée sur son IP publique.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "hello",
|
||||||
|
"joinCode": "x7k" // optionnel
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `create-public-room`
|
||||||
|
Demande la création d'une room publique avec un code court.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "create-public-room" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `signal`
|
||||||
|
Relaie un message WebRTC (offer, answer, ICE candidate) vers un autre pair de la même room.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "signal",
|
||||||
|
"to": "b2",
|
||||||
|
"data": { /* SDP ou ICE */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `leave`
|
||||||
|
Le client annonce qu'il quitte (optionnel, sinon détecté par fermeture WebSocket).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "leave" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Messages serveur → client
|
||||||
|
|
||||||
|
### `welcome`
|
||||||
|
Réponse au `hello`. Fournit l'identité du client.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "welcome",
|
||||||
|
"peerId": "a1",
|
||||||
|
"displayName": "Renard Bleu",
|
||||||
|
"roomId": "lan:abc123",
|
||||||
|
"peers": [
|
||||||
|
{ "peerId": "b2", "displayName": "Tigre Rouge" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `public-room-created`
|
||||||
|
Réponse à `create-public-room`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "public-room-created",
|
||||||
|
"code": "x7k",
|
||||||
|
"url": "https://anydrop.arthurbarre.fr/x7k",
|
||||||
|
"expiresAt": "2026-04-14T18:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `peer-joined`
|
||||||
|
Un nouveau pair rejoint la room.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "peer-joined",
|
||||||
|
"peerId": "c3",
|
||||||
|
"displayName": "Ours Vert"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `peer-left`
|
||||||
|
Un pair a quitté.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "peer-left",
|
||||||
|
"peerId": "c3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `signal`
|
||||||
|
Relai d'un message WebRTC reçu d'un autre pair.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "signal",
|
||||||
|
"from": "a1",
|
||||||
|
"data": { /* SDP ou ICE */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `error`
|
||||||
|
Erreur générique.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"code": "room-not-found" | "room-expired" | "rate-limit",
|
||||||
|
"message": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Règles serveur
|
||||||
|
|
||||||
|
- Un client peut appartenir à **1 room LAN + 0 ou 1 room publique** simultanément.
|
||||||
|
- Un message `signal` n'est relayé que si `to` est dans au moins une des rooms du sender.
|
||||||
|
- Une room publique expire après **10 minutes sans nouveau message**.
|
||||||
|
- Le code court est généré dans l'alphabet `abcdefghjkmnpqrstuvwxyz23456789` (exclut `iloLIO01` pour lisibilité).
|
||||||
|
- Longueur par défaut : **3 caractères** ; si collision, essayer 5 fois puis passer à **4 caractères**.
|
||||||
|
- Rate limit : 10 connexions/minute par IP, 100 messages/minute par connexion.
|
||||||
146
docs/webrtc-flow.md
Normal file
146
docs/webrtc-flow.md
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
# Flux WebRTC détaillé
|
||||||
|
|
||||||
|
Ce document décrit pas-à-pas comment deux clients AnyDrop établissent une connexion P2P.
|
||||||
|
|
||||||
|
## Acteurs
|
||||||
|
|
||||||
|
- **Alice** — émetteur (veut envoyer un fichier)
|
||||||
|
- **Bob** — récepteur
|
||||||
|
- **Signaling** — serveur WebSocket AnyDrop
|
||||||
|
- **STUN** — `stun.l.google.com:19302`
|
||||||
|
- **TURN** — fallback si nécessaire
|
||||||
|
|
||||||
|
## Scénario 1 : Alice et Bob sur le même Wi-Fi
|
||||||
|
|
||||||
|
### 1. Connexion au signaling
|
||||||
|
|
||||||
|
```
|
||||||
|
Alice ──WS──▶ Signaling : { type: "hello" }
|
||||||
|
Signaling ──▶ Alice : { type: "welcome", peerId: "a1", displayName: "Renard Bleu" }
|
||||||
|
|
||||||
|
Bob ──WS──▶ Signaling : { type: "hello" }
|
||||||
|
Signaling ──▶ Bob : { type: "welcome", peerId: "b2", displayName: "Tigre Rouge" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Le serveur voit que les deux connexions proviennent de la **même IP publique** (la box d'Arthur) → il les place dans la room LAN `hash(IP)`.
|
||||||
|
|
||||||
|
### 2. Annonce mutuelle
|
||||||
|
|
||||||
|
```
|
||||||
|
Signaling ──▶ Alice : { type: "peer-joined", peerId: "b2", displayName: "Tigre Rouge" }
|
||||||
|
Signaling ──▶ Bob : { type: "peer-joined", peerId: "a1", displayName: "Renard Bleu" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque client affiche un pair cliquable dans l'UI.
|
||||||
|
|
||||||
|
### 3. Alice déclenche l'envoi
|
||||||
|
|
||||||
|
Alice drop un fichier sur "Tigre Rouge". Son client crée une `RTCPeerConnection` en mode initiator :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pc = new SimplePeer({ initiator: true, trickle: true });
|
||||||
|
pc.addStream(file); // simplifié
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Collecte des ICE candidates
|
||||||
|
|
||||||
|
Le navigateur d'Alice :
|
||||||
|
- Regarde ses interfaces réseau → trouve `192.168.1.42` → candidate **host**
|
||||||
|
- Interroge STUN → apprend son IP publique `82.x.x.x:54321` → candidate **srflx** (server reflexive)
|
||||||
|
- Si TURN configuré → réserve une allocation → candidate **relay**
|
||||||
|
|
||||||
|
### 5. Échange SDP via signaling
|
||||||
|
|
||||||
|
```
|
||||||
|
Alice ──▶ Signaling : { type: "signal", to: "b2", data: <SDP offer + ICE host> }
|
||||||
|
Signaling ──▶ Bob : { type: "signal", from: "a1", data: <SDP offer + ICE host> }
|
||||||
|
```
|
||||||
|
|
||||||
|
Bob reçoit l'offer, crée sa propre `RTCPeerConnection` (non-initiator) :
|
||||||
|
|
||||||
|
```
|
||||||
|
Bob ──▶ Signaling : { type: "signal", to: "a1", data: <SDP answer + ICE host> }
|
||||||
|
Signaling ──▶ Alice : { type: "signal", from: "b2", data: <SDP answer + ICE host> }
|
||||||
|
```
|
||||||
|
|
||||||
|
Les nouveaux ICE candidates découverts après coup sont envoyés par la même route (trickle ICE).
|
||||||
|
|
||||||
|
### 6. Connectivity checks
|
||||||
|
|
||||||
|
WebRTC teste toutes les paires de candidates (A.host × B.host, A.srflx × B.host, etc.) avec des paquets STUN binding request. Sur même LAN, **A.host ↔ B.host répond en premier** → ce chemin est sélectionné.
|
||||||
|
|
||||||
|
Durée typique : **500 ms à 2 s**.
|
||||||
|
|
||||||
|
### 7. DataChannel ouvert
|
||||||
|
|
||||||
|
```ts
|
||||||
|
peer.on('connect', () => {
|
||||||
|
peer.send(JSON.stringify({ name: "photo.jpg", size: 2458112, mime: "image/jpeg", id: "f1" }));
|
||||||
|
streamFileInChunks(file);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Le transfert se fait **directement via le Wi-Fi local** — débit proche du lien physique (WiFi 6 : 500 Mo/s possibles).
|
||||||
|
|
||||||
|
### 8. Fin
|
||||||
|
|
||||||
|
Chunk final `{ eof: true, id: "f1" }`, les deux pairs ferment la RTCPeerConnection ou la gardent ouverte pour un prochain envoi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scénario 2 : Alice partage un lien public à Charlie (autre réseau)
|
||||||
|
|
||||||
|
### 1. Alice génère un code
|
||||||
|
|
||||||
|
Alice clique sur "Partager publiquement". Son client demande au serveur :
|
||||||
|
|
||||||
|
```
|
||||||
|
Alice ──▶ Signaling : { type: "create-public-room" }
|
||||||
|
Signaling ──▶ Alice : { type: "public-room-created", code: "x7k", url: "https://anydrop.arthurbarre.fr/x7k" }
|
||||||
|
```
|
||||||
|
|
||||||
|
L'UI affiche le QR code + l'URL + le code en gros.
|
||||||
|
|
||||||
|
### 2. Charlie ouvre l'URL
|
||||||
|
|
||||||
|
Charlie sur son téléphone scanne le QR → son navigateur ouvre `/x7k`. Son client se connecte au signaling avec l'intention de rejoindre la room `x7k` :
|
||||||
|
|
||||||
|
```
|
||||||
|
Charlie ──▶ Signaling : { type: "hello", joinCode: "x7k" }
|
||||||
|
Signaling ──▶ Charlie : { type: "welcome", peerId: "c3", ... }
|
||||||
|
Signaling ──▶ Alice : { type: "peer-joined", peerId: "c3", displayName: "Ours Vert" }
|
||||||
|
Signaling ──▶ Charlie : { type: "peer-joined", peerId: "a1", displayName: "Renard Bleu" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Négociation WebRTC (comme au scénario 1)
|
||||||
|
|
||||||
|
Sauf que maintenant les candidates **host** (IPs locales) ne se joindront jamais (réseaux différents). Ce sont les candidates **srflx** (IP publique via STUN) qui vont s'apparier :
|
||||||
|
|
||||||
|
- NAT d'Alice fait un "hole punching" via STUN
|
||||||
|
- NAT de Charlie pareil
|
||||||
|
- Les paquets UDP se croisent et établissent le tunnel
|
||||||
|
|
||||||
|
Ça fonctionne dans **~80% des cas** avec juste STUN. Sinon → fallback TURN (relay).
|
||||||
|
|
||||||
|
### 4. Transfert
|
||||||
|
|
||||||
|
Même logique de chunks que scénario 1, mais débit limité par la bande passante internet (typiquement 10-100 Mo/s selon connexions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pourquoi le P2P parfois échoue (→ TURN)
|
||||||
|
|
||||||
|
- NAT symétrique (certaines box 4G, firewalls d'entreprise) : le port change à chaque destination → STUN impuissant
|
||||||
|
- Firewall corporate bloquant UDP sortant
|
||||||
|
- Dans ces cas, TURN relaie le trafic via un serveur intermédiaire (coût bande passante réel)
|
||||||
|
|
||||||
|
On détecte l'échec de connectivité via `iceConnectionState === "failed"` et on peut afficher un message à l'utilisateur ou retenter avec TURN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- **DTLS obligatoire** : WebRTC refuse d'ouvrir un DataChannel non chiffré
|
||||||
|
- Les clés DTLS sont négociées dans le SDP, échangées via le signaling
|
||||||
|
- Un attaquant sur le signaling pourrait théoriquement faire du MITM en modifiant les empreintes DTLS dans le SDP → pour une V2, on peut ajouter une vérification out-of-band (ex : afficher une "safety number" à comparer)
|
||||||
|
- Pour la V1, on s'appuie sur HTTPS/WSS pour sécuriser le signaling
|
||||||
4
k8s/namespace.yml
Normal file
4
k8s/namespace.yml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: anydrop
|
||||||
68
k8s/server.yml
Normal file
68
k8s/server.yml
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: anydrop-server-config
|
||||||
|
namespace: anydrop
|
||||||
|
data:
|
||||||
|
PORT: "3001"
|
||||||
|
BASE_URL: "https://anydrop.arthurbarre.fr"
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: anydrop-server
|
||||||
|
namespace: anydrop
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: anydrop-server
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: anydrop-server
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: anydrop-server
|
||||||
|
image: git.arthurbarre.fr/ordinarthur/anydrop-server:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 3001
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: anydrop-server-config
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3001
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3001
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: "50m"
|
||||||
|
limits:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "200m"
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: anydrop-server
|
||||||
|
namespace: anydrop
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app: anydrop-server
|
||||||
|
ports:
|
||||||
|
- port: 3001
|
||||||
|
targetPort: 3001
|
||||||
56
k8s/web.yml
Normal file
56
k8s/web.yml
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: anydrop-web
|
||||||
|
namespace: anydrop
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: anydrop-web
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: anydrop-web
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: anydrop-web
|
||||||
|
image: git.arthurbarre.fr/ordinarthur/anydrop-web:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "32Mi"
|
||||||
|
cpu: "50m"
|
||||||
|
limits:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: anydrop-web
|
||||||
|
namespace: anydrop
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector:
|
||||||
|
app: anydrop-web
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
nodePort: 30097
|
||||||
9384
package-lock.json
generated
Normal file
9384
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "anydrop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Universal file & text sharing — peer-to-peer, no account required",
|
||||||
|
"workspaces": [
|
||||||
|
"shared",
|
||||||
|
"server",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n server,web -c blue,green \"cd server && tsx watch src/index.ts\" \"cd web && vite\"",
|
||||||
|
"dev:web": "cd web && vite",
|
||||||
|
"dev:server": "cd server && tsx watch src/index.ts",
|
||||||
|
"build": "tsc -b shared && tsc -b server && cd web && vite build",
|
||||||
|
"typecheck": "tsc -b shared && tsc -b server && cd web && tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
server/Dockerfile
Normal file
32
server/Dockerfile
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json shared/
|
||||||
|
COPY server/package.json server/
|
||||||
|
COPY web/package.json web/
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
COPY shared/ shared/
|
||||||
|
COPY server/ server/
|
||||||
|
RUN npm run build -w shared && npm run build -w server
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json shared/
|
||||||
|
COPY server/package.json server/
|
||||||
|
COPY web/package.json web/
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY --from=build /app/shared/dist shared/dist
|
||||||
|
COPY --from=build /app/server/dist server/dist
|
||||||
|
|
||||||
|
ENV PORT=3001
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD ["node", "server/dist/index.js"]
|
||||||
23
server/package.json
Normal file
23
server/package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@anydrop/server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anydrop/shared": "*",
|
||||||
|
"nanoid": "^5.1.5",
|
||||||
|
"ws": "^8.18.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.15.3",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tsx": "^4.19.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
295
server/src/index.ts
Normal file
295
server/src/index.ts
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { networkInterfaces } from "node:os";
|
||||||
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import {
|
||||||
|
generateDisplayName,
|
||||||
|
type ClientMessage,
|
||||||
|
type ServerMessage,
|
||||||
|
type PeerInfo,
|
||||||
|
} from "@anydrop/shared";
|
||||||
|
import { RoomManager, type Client } from "./rooms.js";
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || "3001", 10);
|
||||||
|
const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
|
||||||
|
|
||||||
|
const RATE_LIMIT_CONNECTIONS_PER_IP = 10;
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||||
|
const RATE_LIMIT_MESSAGES = 100;
|
||||||
|
|
||||||
|
// Track connection rate per IP
|
||||||
|
const connectionCounts = new Map<string, { count: number; windowStart: number }>();
|
||||||
|
|
||||||
|
function hashIP(ip: string): string {
|
||||||
|
return createHash("sha256").update(ip).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip ::ffff: prefix from IPv4-mapped IPv6 addresses */
|
||||||
|
function normalizeIP(ip: string): string {
|
||||||
|
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if an IPv4 address is private (RFC 1918) or loopback */
|
||||||
|
function isPrivateIP(ip: string): boolean {
|
||||||
|
return (
|
||||||
|
ip.startsWith("10.") ||
|
||||||
|
ip.startsWith("192.168.") ||
|
||||||
|
ip.startsWith("127.") ||
|
||||||
|
/^172\.(1[6-9]|2\d|3[01])\./.test(ip)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopback(ip: string): boolean {
|
||||||
|
return ip === "::1" || ip === "127.0.0.1" || ip.startsWith("127.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect the server's own LAN subnet at startup (e.g. "192.168.1") */
|
||||||
|
function detectLanSubnet(): string | null {
|
||||||
|
const nets = networkInterfaces();
|
||||||
|
for (const ifaces of Object.values(nets)) {
|
||||||
|
if (!ifaces) continue;
|
||||||
|
for (const iface of ifaces) {
|
||||||
|
if (!iface.internal && iface.family === "IPv4" && isPrivateIP(iface.address)) {
|
||||||
|
const parts = iface.address.split(".");
|
||||||
|
return parts.slice(0, 3).join(".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverLanSubnet = detectLanSubnet();
|
||||||
|
console.log(`[init] detected LAN subnet: ${serverLanSubnet ?? "none"}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the LAN grouping key from an IP.
|
||||||
|
* - Loopback (::1, 127.x): use the server's own LAN subnet so localhost
|
||||||
|
* clients end up in the same room as LAN clients.
|
||||||
|
* - Private IPs: use the /24 subnet (e.g. "192.168.1") so all devices
|
||||||
|
* on the same WiFi share one LAN room.
|
||||||
|
* - Public IPs: use the full IP (devices behind the same NAT share one).
|
||||||
|
*/
|
||||||
|
function getLanGroupKey(rawIP: string): string {
|
||||||
|
const ip = normalizeIP(rawIP);
|
||||||
|
if (isLoopback(ip) || ip === "unknown") {
|
||||||
|
return serverLanSubnet ?? "localhost";
|
||||||
|
}
|
||||||
|
if (isPrivateIP(ip)) {
|
||||||
|
const parts = ip.split(".");
|
||||||
|
return parts.slice(0, 3).join(".");
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientIP(req: { headers: Record<string, string | string[] | undefined>; socket: { remoteAddress?: string } }): string {
|
||||||
|
const forwarded = req.headers["x-forwarded-for"];
|
||||||
|
if (forwarded) {
|
||||||
|
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0];
|
||||||
|
return first.trim();
|
||||||
|
}
|
||||||
|
return req.socket.remoteAddress || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(ws: WebSocket, msg: ServerMessage): void {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkConnectionRate(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = connectionCounts.get(ip);
|
||||||
|
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
connectionCounts.set(ip, { count: 1, windowStart: now });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
entry.count++;
|
||||||
|
return entry.count <= RATE_LIMIT_CONNECTIONS_PER_IP;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkMessageRate(client: Client): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - client.messageWindowStart > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
client.messageCount = 1;
|
||||||
|
client.messageWindowStart = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
client.messageCount++;
|
||||||
|
return client.messageCount <= RATE_LIMIT_MESSAGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP server (health check) ──
|
||||||
|
|
||||||
|
const httpServer = createServer((req, res) => {
|
||||||
|
if (req.url === "/health") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ status: "ok" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WebSocket server ──
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server: httpServer });
|
||||||
|
const roomManager = new RoomManager();
|
||||||
|
const clients = new Map<WebSocket, Client>();
|
||||||
|
|
||||||
|
wss.on("connection", (ws, req) => {
|
||||||
|
const ip = getClientIP(req as any);
|
||||||
|
const groupKey = getLanGroupKey(ip);
|
||||||
|
console.log(`[connect] raw IP: ${ip} → groupKey: ${groupKey} → room: lan:${hashIP(groupKey)}`);
|
||||||
|
|
||||||
|
if (!checkConnectionRate(ip)) {
|
||||||
|
send(ws, { type: "error", code: "rate-limit", message: "Too many connections" });
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerId = nanoid(8);
|
||||||
|
const displayName = generateDisplayName();
|
||||||
|
const lanRoomId = roomManager.getLanRoomId(hashIP(groupKey));
|
||||||
|
|
||||||
|
const client: Client = {
|
||||||
|
ws,
|
||||||
|
peerId,
|
||||||
|
displayName,
|
||||||
|
ip,
|
||||||
|
lanRoomId,
|
||||||
|
publicRoomId: null,
|
||||||
|
messageCount: 0,
|
||||||
|
messageWindowStart: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
clients.set(ws, client);
|
||||||
|
|
||||||
|
ws.on("message", (raw) => {
|
||||||
|
if (!checkMessageRate(client)) {
|
||||||
|
send(ws, { type: "error", code: "rate-limit", message: "Too many messages" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg: ClientMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case "hello":
|
||||||
|
handleHello(client, msg.joinCode);
|
||||||
|
break;
|
||||||
|
case "create-public-room":
|
||||||
|
handleCreatePublicRoom(client);
|
||||||
|
break;
|
||||||
|
case "signal":
|
||||||
|
handleSignal(client, msg.to, msg.data);
|
||||||
|
break;
|
||||||
|
case "leave":
|
||||||
|
handleLeave(client);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
handleLeave(client);
|
||||||
|
clients.delete(ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleHello(client: Client, joinCode?: string): void {
|
||||||
|
// Join LAN room
|
||||||
|
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
|
||||||
|
roomManager.addClientToRoom(lanRoom, client);
|
||||||
|
|
||||||
|
const peers: PeerInfo[] = [];
|
||||||
|
for (const peer of lanRoom.clients.values()) {
|
||||||
|
if (peer.peerId !== client.peerId) {
|
||||||
|
peers.push({ peerId: peer.peerId, displayName: peer.displayName });
|
||||||
|
// Notify existing peers
|
||||||
|
send(peer.ws, { type: "peer-joined", peerId: client.peerId, displayName: client.displayName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[hello] peer=${client.peerId} (${client.displayName}) room=${lanRoom.id} peers_in_room=${lanRoom.clients.size}`);
|
||||||
|
|
||||||
|
// Join public room if code provided
|
||||||
|
if (joinCode) {
|
||||||
|
const pubRoom = roomManager.getPublicRoomByCode(joinCode);
|
||||||
|
if (!pubRoom) {
|
||||||
|
send(client.ws, { type: "error", code: "room-not-found", message: `Room "${joinCode}" not found or expired` });
|
||||||
|
} else {
|
||||||
|
client.publicRoomId = pubRoom.id;
|
||||||
|
roomManager.addClientToRoom(pubRoom, client);
|
||||||
|
for (const peer of pubRoom.clients.values()) {
|
||||||
|
if (peer.peerId !== client.peerId) {
|
||||||
|
// Avoid duplicate if peer is already in LAN peers
|
||||||
|
if (!peers.find((p) => p.peerId === peer.peerId)) {
|
||||||
|
peers.push({ peerId: peer.peerId, displayName: peer.displayName });
|
||||||
|
}
|
||||||
|
send(peer.ws, { type: "peer-joined", peerId: client.peerId, displayName: client.displayName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send(client.ws, {
|
||||||
|
type: "welcome",
|
||||||
|
peerId: client.peerId,
|
||||||
|
displayName: client.displayName,
|
||||||
|
roomId: client.lanRoomId,
|
||||||
|
peers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreatePublicRoom(client: Client): void {
|
||||||
|
try {
|
||||||
|
const { code, roomId, expiresAt } = roomManager.createPublicRoom();
|
||||||
|
client.publicRoomId = roomId;
|
||||||
|
const room = roomManager.getPublicRoomByCode(code)!;
|
||||||
|
roomManager.addClientToRoom(room, client);
|
||||||
|
|
||||||
|
send(client.ws, {
|
||||||
|
type: "public-room-created",
|
||||||
|
code,
|
||||||
|
url: `${BASE_URL}/${code}`,
|
||||||
|
expiresAt: new Date(expiresAt).toISOString(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
send(client.ws, { type: "error", code: "rate-limit", message: "Failed to create room" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSignal(client: Client, to: string, data: unknown): void {
|
||||||
|
const target = roomManager.findPeerInClientRooms(client, to);
|
||||||
|
if (target) {
|
||||||
|
send(target.ws, { type: "signal", from: client.peerId, data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLeave(client: Client): void {
|
||||||
|
// Remove from LAN room
|
||||||
|
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
|
||||||
|
roomManager.removeClientFromRoom(lanRoom, client.peerId);
|
||||||
|
for (const peer of lanRoom.clients.values()) {
|
||||||
|
send(peer.ws, { type: "peer-left", peerId: client.peerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from public room
|
||||||
|
if (client.publicRoomId) {
|
||||||
|
const pubRoom = roomManager.getRoomById(client.publicRoomId);
|
||||||
|
if (pubRoom) {
|
||||||
|
roomManager.removeClientFromRoom(pubRoom, client.peerId);
|
||||||
|
for (const peer of pubRoom.clients.values()) {
|
||||||
|
send(peer.ws, { type: "peer-left", peerId: client.peerId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.publicRoomId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`AnyDrop signaling server running on port ${PORT}`);
|
||||||
|
});
|
||||||
171
server/src/rooms.ts
Normal file
171
server/src/rooms.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { WebSocket } from "ws";
|
||||||
|
import { customAlphabet } from "nanoid";
|
||||||
|
import {
|
||||||
|
SHORT_CODE_ALPHABET,
|
||||||
|
SHORT_CODE_LENGTH,
|
||||||
|
SHORT_CODE_MAX_LENGTH,
|
||||||
|
SHORT_CODE_MAX_RETRIES,
|
||||||
|
PUBLIC_ROOM_TTL_MS,
|
||||||
|
} from "@anydrop/shared";
|
||||||
|
|
||||||
|
const generateCode = customAlphabet(SHORT_CODE_ALPHABET, SHORT_CODE_LENGTH);
|
||||||
|
const generateLongCode = customAlphabet(SHORT_CODE_ALPHABET, SHORT_CODE_MAX_LENGTH);
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
ws: WebSocket;
|
||||||
|
peerId: string;
|
||||||
|
displayName: string;
|
||||||
|
ip: string;
|
||||||
|
lanRoomId: string;
|
||||||
|
publicRoomId: string | null;
|
||||||
|
messageCount: number;
|
||||||
|
messageWindowStart: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
type: "lan" | "public";
|
||||||
|
clients: Map<string, Client>;
|
||||||
|
code?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
lastActivity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomManager {
|
||||||
|
private rooms = new Map<string, Room>();
|
||||||
|
private publicCodeIndex = new Map<string, string>(); // code → roomId
|
||||||
|
private expirationTimer: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.expirationTimer = setInterval(() => this.cleanExpiredRooms(), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLanRoomId(ip: string): string {
|
||||||
|
return `lan:${ip}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreateLanRoom(ip: string): Room {
|
||||||
|
const id = this.getLanRoomId(ip);
|
||||||
|
let room = this.rooms.get(id);
|
||||||
|
if (!room) {
|
||||||
|
room = {
|
||||||
|
id,
|
||||||
|
type: "lan",
|
||||||
|
clients: new Map(),
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
};
|
||||||
|
this.rooms.set(id, room);
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
createPublicRoom(): { code: string; roomId: string; expiresAt: number } {
|
||||||
|
let code: string | null = null;
|
||||||
|
for (let i = 0; i < SHORT_CODE_MAX_RETRIES; i++) {
|
||||||
|
const candidate = generateCode();
|
||||||
|
if (!this.publicCodeIndex.has(candidate)) {
|
||||||
|
code = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!code) {
|
||||||
|
for (let i = 0; i < SHORT_CODE_MAX_RETRIES; i++) {
|
||||||
|
const candidate = generateLongCode();
|
||||||
|
if (!this.publicCodeIndex.has(candidate)) {
|
||||||
|
code = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!code) {
|
||||||
|
throw new Error("Failed to generate unique room code");
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = `pub:${code}`;
|
||||||
|
const expiresAt = Date.now() + PUBLIC_ROOM_TTL_MS;
|
||||||
|
const room: Room = {
|
||||||
|
id: roomId,
|
||||||
|
type: "public",
|
||||||
|
clients: new Map(),
|
||||||
|
code,
|
||||||
|
expiresAt,
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
};
|
||||||
|
this.rooms.set(roomId, room);
|
||||||
|
this.publicCodeIndex.set(code, roomId);
|
||||||
|
return { code, roomId, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
getPublicRoomByCode(code: string): Room | null {
|
||||||
|
const roomId = this.publicCodeIndex.get(code);
|
||||||
|
if (!roomId) return null;
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
if (room.expiresAt && Date.now() > room.expiresAt) {
|
||||||
|
this.deleteRoom(roomId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
addClientToRoom(room: Room, client: Client): void {
|
||||||
|
room.clients.set(client.peerId, client);
|
||||||
|
room.lastActivity = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeClientFromRoom(room: Room, peerId: string): void {
|
||||||
|
room.clients.delete(peerId);
|
||||||
|
if (room.clients.size === 0 && room.type === "lan") {
|
||||||
|
this.rooms.delete(room.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
touchRoom(room: Room): void {
|
||||||
|
room.lastActivity = Date.now();
|
||||||
|
if (room.expiresAt) {
|
||||||
|
room.expiresAt = Date.now() + PUBLIC_ROOM_TTL_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoomById(roomId: string): Room | null {
|
||||||
|
return this.rooms.get(roomId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
findPeerInClientRooms(client: Client, targetPeerId: string): Client | null {
|
||||||
|
const lanRoom = this.rooms.get(client.lanRoomId);
|
||||||
|
if (lanRoom?.clients.has(targetPeerId)) {
|
||||||
|
return lanRoom.clients.get(targetPeerId)!;
|
||||||
|
}
|
||||||
|
if (client.publicRoomId) {
|
||||||
|
const pubRoom = this.rooms.get(client.publicRoomId);
|
||||||
|
if (pubRoom?.clients.has(targetPeerId)) {
|
||||||
|
return pubRoom.clients.get(targetPeerId)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteRoom(roomId: string): void {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (room?.code) {
|
||||||
|
this.publicCodeIndex.delete(room.code);
|
||||||
|
}
|
||||||
|
this.rooms.delete(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanExpiredRooms(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, room] of this.rooms) {
|
||||||
|
if (room.expiresAt && now > room.expiresAt) {
|
||||||
|
for (const client of room.clients.values()) {
|
||||||
|
client.publicRoomId = null;
|
||||||
|
}
|
||||||
|
this.deleteRoom(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
clearInterval(this.expirationTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
server/tsconfig.json
Normal file
13
server/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../shared" }
|
||||||
|
]
|
||||||
|
}
|
||||||
1
server/tsconfig.tsbuildinfo
Normal file
1
server/tsconfig.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/index.ts","./src/rooms.ts"],"version":"5.8.3"}
|
||||||
19
shared/package.json
Normal file
19
shared/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@anydrop/shared",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc --watch"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
shared/src/index.ts
Normal file
2
shared/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./protocol.js";
|
||||||
|
export * from "./names.js";
|
||||||
21
shared/src/names.ts
Normal file
21
shared/src/names.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const ADJECTIVES = [
|
||||||
|
"Rouge", "Bleu", "Vert", "Doré", "Violet",
|
||||||
|
"Blanc", "Noir", "Rose", "Gris", "Orange",
|
||||||
|
"Rapide", "Calme", "Brave", "Agile", "Sage",
|
||||||
|
"Vif", "Fier", "Noble", "Grand", "Petit",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ANIMALS = [
|
||||||
|
"Renard", "Tigre", "Ours", "Loup", "Aigle",
|
||||||
|
"Dauphin", "Faucon", "Lynx", "Panda", "Lion",
|
||||||
|
"Chat", "Hibou", "Cerf", "Koala", "Phoque",
|
||||||
|
"Colibri", "Loutre", "Requin", "Corbeau", "Furet",
|
||||||
|
];
|
||||||
|
|
||||||
|
function pick<T>(arr: T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateDisplayName(): string {
|
||||||
|
return `${pick(ANIMALS)} ${pick(ADJECTIVES)}`;
|
||||||
|
}
|
||||||
137
shared/src/protocol.ts
Normal file
137
shared/src/protocol.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
// ── Client → Server messages ──
|
||||||
|
|
||||||
|
export interface HelloMessage {
|
||||||
|
type: "hello";
|
||||||
|
joinCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePublicRoomMessage {
|
||||||
|
type: "create-public-room";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalMessage {
|
||||||
|
type: "signal";
|
||||||
|
to: string;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveMessage {
|
||||||
|
type: "leave";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientMessage =
|
||||||
|
| HelloMessage
|
||||||
|
| CreatePublicRoomMessage
|
||||||
|
| SignalMessage
|
||||||
|
| LeaveMessage;
|
||||||
|
|
||||||
|
// ── Server → Client messages ──
|
||||||
|
|
||||||
|
export interface PeerInfo {
|
||||||
|
peerId: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WelcomeMessage {
|
||||||
|
type: "welcome";
|
||||||
|
peerId: string;
|
||||||
|
displayName: string;
|
||||||
|
roomId: string;
|
||||||
|
peers: PeerInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicRoomCreatedMessage {
|
||||||
|
type: "public-room-created";
|
||||||
|
code: string;
|
||||||
|
url: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerJoinedMessage {
|
||||||
|
type: "peer-joined";
|
||||||
|
peerId: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerLeftMessage {
|
||||||
|
type: "peer-left";
|
||||||
|
peerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalRelayMessage {
|
||||||
|
type: "signal";
|
||||||
|
from: string;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ErrorCode = "room-not-found" | "room-expired" | "rate-limit";
|
||||||
|
|
||||||
|
export interface ErrorMessage {
|
||||||
|
type: "error";
|
||||||
|
code: ErrorCode;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| WelcomeMessage
|
||||||
|
| PublicRoomCreatedMessage
|
||||||
|
| PeerJoinedMessage
|
||||||
|
| PeerLeftMessage
|
||||||
|
| SignalRelayMessage
|
||||||
|
| ErrorMessage;
|
||||||
|
|
||||||
|
// ── Data channel messages (peer-to-peer) ──
|
||||||
|
|
||||||
|
export interface FileMetaMessage {
|
||||||
|
type: "file-meta";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileEndMessage {
|
||||||
|
type: "file-end";
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextMessage {
|
||||||
|
type: "text";
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferRequestMessage {
|
||||||
|
type: "transfer-request";
|
||||||
|
files: { id: string; name: string; size: number; mime: string }[];
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferResponseMessage {
|
||||||
|
type: "transfer-response";
|
||||||
|
accepted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataChannelMessage =
|
||||||
|
| FileMetaMessage
|
||||||
|
| FileEndMessage
|
||||||
|
| TextMessage
|
||||||
|
| TransferRequestMessage
|
||||||
|
| TransferResponseMessage;
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
export const CHUNK_SIZE = 16 * 1024; // 16 KB
|
||||||
|
export const MAX_BUFFER = 1024 * 1024; // 1 MB backpressure threshold
|
||||||
|
|
||||||
|
export const SHORT_CODE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
|
||||||
|
export const SHORT_CODE_LENGTH = 3;
|
||||||
|
export const SHORT_CODE_MAX_LENGTH = 4;
|
||||||
|
export const SHORT_CODE_MAX_RETRIES = 5;
|
||||||
|
|
||||||
|
export const PUBLIC_ROOM_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
export const ICE_SERVERS: RTCIceServer[] = [
|
||||||
|
{ urls: "stun:stun.l.google.com:19302" },
|
||||||
|
{ urls: "stun:stun1.l.google.com:19302" },
|
||||||
|
];
|
||||||
9
shared/tsconfig.json
Normal file
9
shared/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
shared/tsconfig.tsbuildinfo
Normal file
1
shared/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
16
tsconfig.base.json
Normal file
16
tsconfig.base.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
25
web/Dockerfile
Normal file
25
web/Dockerfile
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG VITE_WS_URL
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json shared/
|
||||||
|
COPY server/package.json server/
|
||||||
|
COPY web/package.json web/
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
COPY shared/ shared/
|
||||||
|
COPY web/ web/
|
||||||
|
RUN npm run build -w shared && cd web && npx vite build
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/web/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
15
web/index.html
Normal file
15
web/index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="AnyDrop — Partage de fichiers instantané, peer-to-peer, sans compte" />
|
||||||
|
<meta name="theme-color" content="#6366f1" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>AnyDrop</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-white antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
web/nginx.conf
Normal file
24
web/nginx.conf
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA routing — fallback to index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket proxy to signaling server
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://anydrop-server:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_send_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
web/package.json
Normal file
35
web/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@anydrop/web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anydrop/shared": "*",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.5.1",
|
||||||
|
"simple-peer": "^9.11.1",
|
||||||
|
"zustand": "^5.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.20",
|
||||||
|
"@types/react-dom": "^18.3.6",
|
||||||
|
"@types/simple-peer": "^9.11.8",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^6.3.3",
|
||||||
|
"vite-plugin-node-polyfills": "^0.26.0",
|
||||||
|
"vite-plugin-pwa": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
5
web/public/favicon.svg
Normal file
5
web/public/favicon.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" rx="20" fill="#6366f1"/>
|
||||||
|
<path d="M50 20 L50 60 M35 45 L50 60 L65 45" stroke="white" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<line x1="25" y1="75" x2="75" y2="75" stroke="white" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 365 B |
12
web/src/App.tsx
Normal file
12
web/src/App.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import Home from "./pages/Home";
|
||||||
|
import JoinRoom from "./pages/JoinRoom";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/:code" element={<JoinRoom />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
web/src/components/DropZone.tsx
Normal file
101
web/src/components/DropZone.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface DropZoneProps {
|
||||||
|
onFilesSelected: (files: File[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DropZone({ onFilesSelected, disabled }: DropZoneProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter.current++;
|
||||||
|
if (e.dataTransfer.items.length > 0) {
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter.current--;
|
||||||
|
if (dragCounter.current === 0) {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
dragCounter.current = 0;
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
onFilesSelected(files);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onFilesSelected, disabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!disabled) {
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (files.length > 0) {
|
||||||
|
onFilesSelected(files);
|
||||||
|
}
|
||||||
|
// Reset to allow re-selecting the same file
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`
|
||||||
|
border-2 border-dashed rounded-2xl p-8
|
||||||
|
flex flex-col items-center justify-center gap-3
|
||||||
|
transition-all duration-200 cursor-pointer
|
||||||
|
min-h-[160px]
|
||||||
|
${isDragging
|
||||||
|
? "border-brand-400 bg-brand-500/10 scale-[1.02]"
|
||||||
|
: disabled
|
||||||
|
? "border-slate-700 bg-slate-900/50 cursor-not-allowed opacity-50"
|
||||||
|
: "border-slate-700 hover:border-slate-500 bg-slate-900/30 hover:bg-slate-900/50"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="text-4xl">{isDragging ? "📥" : "📁"}</div>
|
||||||
|
<p className="text-slate-400 text-sm text-center">
|
||||||
|
{disabled
|
||||||
|
? "Sélectionnez d'abord un appareil"
|
||||||
|
: isDragging
|
||||||
|
? "Déposez vos fichiers ici"
|
||||||
|
: "Glissez des fichiers ici ou cliquez pour sélectionner"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
web/src/components/PeerAvatar.tsx
Normal file
53
web/src/components/PeerAvatar.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
interface PeerAvatarProps {
|
||||||
|
displayName: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
isSelected?: boolean;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMAL_EMOJIS: Record<string, string> = {
|
||||||
|
Renard: "🦊", Tigre: "🐯", Ours: "🐻", Loup: "🐺", Aigle: "🦅",
|
||||||
|
Dauphin: "🐬", Faucon: "🦅", Lynx: "🐱", Panda: "🐼", Lion: "🦁",
|
||||||
|
Chat: "🐱", Hibou: "🦉", Cerf: "🦌", Koala: "🐨", Phoque: "🦭",
|
||||||
|
Colibri: "🐦", Loutre: "🦦", Requin: "🦈", Corbeau: "🐦⬛", Furet: "🐾",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEmoji(displayName: string): string {
|
||||||
|
const animal = displayName.split(" ")[0];
|
||||||
|
return ANIMAL_EMOJIS[animal] || "📱";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "w-12 h-12 text-xl",
|
||||||
|
md: "w-16 h-16 text-2xl",
|
||||||
|
lg: "w-20 h-20 text-3xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PeerAvatar({ displayName, onClick, isSelected, size = "md" }: PeerAvatarProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`
|
||||||
|
flex flex-col items-center gap-2 group cursor-pointer
|
||||||
|
transition-transform duration-200 hover:scale-105
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
${sizeClasses[size]}
|
||||||
|
rounded-full flex items-center justify-center
|
||||||
|
transition-all duration-200
|
||||||
|
${isSelected
|
||||||
|
? "bg-brand-500 ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950"
|
||||||
|
: "bg-slate-800 hover:bg-slate-700"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{getEmoji(displayName)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-300 group-hover:text-white transition-colors max-w-[80px] truncate">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
web/src/components/PeerList.tsx
Normal file
37
web/src/components/PeerList.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { useStore } from "../stores/useStore";
|
||||||
|
import PeerAvatar from "./PeerAvatar";
|
||||||
|
|
||||||
|
interface PeerListProps {
|
||||||
|
onPeerSelect: (peerId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeerList({ onPeerSelect }: PeerListProps) {
|
||||||
|
const peers = useStore((s) => s.peers);
|
||||||
|
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
||||||
|
|
||||||
|
if (peers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||||
|
<div className="text-5xl mb-4">📡</div>
|
||||||
|
<p className="text-lg font-medium">En attente d'appareils...</p>
|
||||||
|
<p className="text-sm mt-2 text-center max-w-xs">
|
||||||
|
Ouvrez AnyDrop sur un autre appareil connecté au même Wi-Fi,
|
||||||
|
ou partagez un lien public.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap justify-center gap-6 py-8">
|
||||||
|
{peers.map((peer) => (
|
||||||
|
<PeerAvatar
|
||||||
|
key={peer.peerId}
|
||||||
|
displayName={peer.displayName}
|
||||||
|
isSelected={selectedPeerId === peer.peerId}
|
||||||
|
onClick={() => onPeerSelect(peer.peerId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
web/src/components/PublicRoomPanel.tsx
Normal file
61
web/src/components/PublicRoomPanel.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
import { useStore } from "../stores/useStore";
|
||||||
|
|
||||||
|
interface PublicRoomPanelProps {
|
||||||
|
onCreateRoom: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicRoomPanel({ onCreateRoom }: PublicRoomPanelProps) {
|
||||||
|
const publicRoomCode = useStore((s) => s.publicRoomCode);
|
||||||
|
const publicRoomUrl = useStore((s) => s.publicRoomUrl);
|
||||||
|
|
||||||
|
if (!publicRoomCode || !publicRoomUrl) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onCreateRoom}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
||||||
|
border border-slate-700 hover:border-brand-500 rounded-xl
|
||||||
|
text-slate-300 hover:text-white transition-all
|
||||||
|
bg-slate-900/30 hover:bg-slate-900/50"
|
||||||
|
>
|
||||||
|
<span className="text-lg">🔗</span>
|
||||||
|
<span className="text-sm font-medium">Partager via lien public</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(publicRoomUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/50 border border-slate-700 rounded-2xl p-6 text-center space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
||||||
|
Lien public
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="bg-white p-3 rounded-xl">
|
||||||
|
<QRCodeSVG value={publicRoomUrl} size={160} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-3xl font-mono font-bold text-brand-400 tracking-widest">
|
||||||
|
{publicRoomCode.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="text-sm text-slate-400 hover:text-white transition-colors
|
||||||
|
underline underline-offset-4 decoration-slate-600"
|
||||||
|
>
|
||||||
|
{publicRoomUrl}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Partagez ce QR code ou ce lien pour recevoir des fichiers de n'importe qui.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
web/src/components/ReceiveDialog.tsx
Normal file
74
web/src/components/ReceiveDialog.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import type { IncomingRequest } from "../stores/useStore";
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} o`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReceiveDialogProps {
|
||||||
|
request: IncomingRequest;
|
||||||
|
onAccept: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReceiveDialog({ request, onAccept, onReject }: ReceiveDialogProps) {
|
||||||
|
const totalSize = request.files.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-2">
|
||||||
|
Transfert entrant
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mb-4">
|
||||||
|
<span className="text-brand-400 font-medium">{request.displayName}</span> veut vous envoyer :
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{request.files.length > 0 && (
|
||||||
|
<div className="bg-slate-800/50 rounded-xl p-3 mb-4 space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
{request.files.map((file) => (
|
||||||
|
<div key={file.id} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-white truncate mr-2">{file.name}</span>
|
||||||
|
<span className="text-slate-500 text-xs whitespace-nowrap">{formatSize(file.size)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{request.files.length > 1 && (
|
||||||
|
<div className="border-t border-slate-700 pt-2 flex justify-between text-xs text-slate-400">
|
||||||
|
<span>{request.files.length} fichiers</span>
|
||||||
|
<span>{formatSize(totalSize)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{request.text && (
|
||||||
|
<div className="bg-slate-800/50 rounded-xl p-3 mb-4">
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Texte :</p>
|
||||||
|
<p className="text-sm text-white whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
|
||||||
|
{request.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onReject}
|
||||||
|
className="flex-1 px-4 py-2.5 border border-slate-600 text-slate-300
|
||||||
|
hover:bg-slate-800 rounded-xl text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Refuser
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onAccept}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-brand-600 hover:bg-brand-500
|
||||||
|
text-white rounded-xl text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{request.text && request.files.length === 0 ? "Copier" : "Accepter"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
web/src/components/TextShareModal.tsx
Normal file
55
web/src/components/TextShareModal.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface TextShareModalProps {
|
||||||
|
onSend: (text: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TextShareModal({ onSend, onClose }: TextShareModalProps) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (text.trim()) {
|
||||||
|
onSend(text.trim());
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Envoyer du texte</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Tapez votre message, lien, ou texte..."
|
||||||
|
className="w-full h-32 bg-slate-800 border border-slate-600 rounded-xl p-3 text-white
|
||||||
|
placeholder-slate-500 resize-none focus:outline-none focus:ring-2
|
||||||
|
focus:ring-brand-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-3 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!text.trim()}
|
||||||
|
className="px-6 py-2 bg-brand-600 hover:bg-brand-500 disabled:opacity-50
|
||||||
|
disabled:cursor-not-allowed text-white text-sm font-medium
|
||||||
|
rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Envoyer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
web/src/components/TransferProgress.tsx
Normal file
68
web/src/components/TransferProgress.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useStore } from "../stores/useStore";
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} o`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TransferProgress() {
|
||||||
|
const transfers = useStore((s) => s.transfers);
|
||||||
|
const removeTransfer = useStore((s) => s.removeTransfer);
|
||||||
|
|
||||||
|
const activeTransfers = transfers.filter((t) => t.status !== "done" || Date.now() < Date.now()); // Show all for now
|
||||||
|
|
||||||
|
if (activeTransfers.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
||||||
|
Transferts
|
||||||
|
</h3>
|
||||||
|
{activeTransfers.map((transfer) => (
|
||||||
|
<div
|
||||||
|
key={transfer.id}
|
||||||
|
className="bg-slate-800/50 rounded-xl p-4 flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="text-2xl">
|
||||||
|
{transfer.direction === "send" ? "📤" : "📥"}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white truncate">{transfer.fileName}</p>
|
||||||
|
<p className="text-xs text-slate-500">{formatSize(transfer.fileSize)}</p>
|
||||||
|
{transfer.status === "transferring" && (
|
||||||
|
<div className="mt-2 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-brand-500 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${Math.round(transfer.progress * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{transfer.status === "pending" && (
|
||||||
|
<span className="text-xs text-yellow-400">En attente</span>
|
||||||
|
)}
|
||||||
|
{transfer.status === "transferring" && (
|
||||||
|
<span className="text-xs text-brand-400">
|
||||||
|
{Math.round(transfer.progress * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{transfer.status === "done" && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeTransfer(transfer.id)}
|
||||||
|
className="text-xs text-green-400 hover:text-green-300"
|
||||||
|
>
|
||||||
|
Terminé ✓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{transfer.status === "error" && (
|
||||||
|
<span className="text-xs text-red-400">Erreur</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
330
web/src/hooks/useSignaling.ts
Normal file
330
web/src/hooks/useSignaling.ts
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { ServerMessage, TransferRequestMessage, TransferResponseMessage } from "@anydrop/shared";
|
||||||
|
import { SignalingClient } from "../lib/signaling";
|
||||||
|
import { PeerManager } from "../lib/peerManager";
|
||||||
|
import {
|
||||||
|
sendFile,
|
||||||
|
createTransferRequest,
|
||||||
|
createTransferResponse,
|
||||||
|
createFileReceiver,
|
||||||
|
} from "../lib/fileTransfer";
|
||||||
|
import { useStore } from "../stores/useStore";
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, fileName: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSignaling(joinCode?: string) {
|
||||||
|
const signalingRef = useRef<SignalingClient | null>(null);
|
||||||
|
const peerManagerRef = useRef<PeerManager | null>(null);
|
||||||
|
const pendingFilesRef = useRef<Map<string, { files: File[]; text?: string }>>(new Map());
|
||||||
|
const fileReceiverRef = useRef<ReturnType<typeof createFileReceiver> | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setConnection,
|
||||||
|
setConnected,
|
||||||
|
addPeer,
|
||||||
|
removePeer,
|
||||||
|
setPeers,
|
||||||
|
setPublicRoom,
|
||||||
|
addTransfer,
|
||||||
|
updateTransfer,
|
||||||
|
setIncomingRequest,
|
||||||
|
setError,
|
||||||
|
} = useStore();
|
||||||
|
|
||||||
|
// Initialize file receiver
|
||||||
|
useEffect(() => {
|
||||||
|
fileReceiverRef.current = createFileReceiver({
|
||||||
|
onData: () => {},
|
||||||
|
onComplete: (fileId, blob, fileName) => {
|
||||||
|
updateTransfer(fileId, { progress: 1, status: "done" });
|
||||||
|
downloadBlob(blob, fileName);
|
||||||
|
},
|
||||||
|
onProgress: (fileId, progress) => {
|
||||||
|
updateTransfer(fileId, { progress, status: "transferring" });
|
||||||
|
},
|
||||||
|
onTransferRequest: (msg: TransferRequestMessage) => {
|
||||||
|
// Find the peer who sent this
|
||||||
|
const store = useStore.getState();
|
||||||
|
const fromPeer = store.peers.find(() => true); // We'll set this from data handler
|
||||||
|
setIncomingRequest({
|
||||||
|
peerId: "", // Will be set from the data handler
|
||||||
|
displayName: "",
|
||||||
|
files: msg.files,
|
||||||
|
text: msg.text,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onTransferResponse: (msg: TransferResponseMessage) => {
|
||||||
|
if (msg.accepted) {
|
||||||
|
// Start sending files
|
||||||
|
startSending();
|
||||||
|
} else {
|
||||||
|
// Clean up pending transfers
|
||||||
|
const store = useStore.getState();
|
||||||
|
for (const transfer of store.transfers) {
|
||||||
|
if (transfer.direction === "send" && transfer.status === "pending") {
|
||||||
|
updateTransfer(transfer.id, { status: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onText: (text) => {
|
||||||
|
// Show received text to user
|
||||||
|
const store = useStore.getState();
|
||||||
|
setIncomingRequest({
|
||||||
|
peerId: "",
|
||||||
|
displayName: "",
|
||||||
|
files: [],
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startSending = useCallback(() => {
|
||||||
|
const store = useStore.getState();
|
||||||
|
for (const [peerId, { files }] of pendingFilesRef.current) {
|
||||||
|
const pm = peerManagerRef.current;
|
||||||
|
if (!pm) continue;
|
||||||
|
const peer = pm.getPeer(peerId);
|
||||||
|
if (!peer) continue;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const transfer = store.transfers.find(
|
||||||
|
(t) => t.direction === "send" && t.fileName === file.name && t.status === "pending",
|
||||||
|
);
|
||||||
|
if (transfer) {
|
||||||
|
sendFile(peer, file, transfer.id, (progress) => {
|
||||||
|
updateTransfer(transfer.id, {
|
||||||
|
progress,
|
||||||
|
status: progress >= 1 ? "done" : "transferring",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingFilesRef.current.clear();
|
||||||
|
}, [updateTransfer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (msg: ServerMessage) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "welcome":
|
||||||
|
setConnection(msg.peerId, msg.displayName, msg.roomId);
|
||||||
|
setPeers(msg.peers);
|
||||||
|
break;
|
||||||
|
case "peer-joined":
|
||||||
|
addPeer({ peerId: msg.peerId, displayName: msg.displayName });
|
||||||
|
break;
|
||||||
|
case "peer-left":
|
||||||
|
removePeer(msg.peerId);
|
||||||
|
peerManagerRef.current?.closePeer(msg.peerId);
|
||||||
|
break;
|
||||||
|
case "signal":
|
||||||
|
peerManagerRef.current?.handleSignal(msg.from, msg.data);
|
||||||
|
break;
|
||||||
|
case "public-room-created":
|
||||||
|
setPublicRoom(msg.code, msg.url, msg.expiresAt);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
setError(msg.message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signaling = new SignalingClient(handleMessage, joinCode);
|
||||||
|
signalingRef.current = signaling;
|
||||||
|
|
||||||
|
const peerManager = new PeerManager(signaling, {
|
||||||
|
onConnect: (peerId) => {
|
||||||
|
console.log(`P2P connected with ${peerId}`);
|
||||||
|
},
|
||||||
|
onClose: (peerId) => {
|
||||||
|
console.log(`P2P disconnected from ${peerId}`);
|
||||||
|
},
|
||||||
|
onData: (peerId, data) => {
|
||||||
|
// Update incoming request with correct peerId
|
||||||
|
const store = useStore.getState();
|
||||||
|
const peerInfo = store.peers.find((p) => p.peerId === peerId);
|
||||||
|
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data);
|
||||||
|
if (msg.type === "transfer-request") {
|
||||||
|
setIncomingRequest({
|
||||||
|
peerId,
|
||||||
|
displayName: peerInfo?.displayName || peerId,
|
||||||
|
files: msg.files,
|
||||||
|
text: msg.text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "transfer-response") {
|
||||||
|
if (msg.accepted) {
|
||||||
|
startSending();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "text") {
|
||||||
|
setIncomingRequest({
|
||||||
|
peerId,
|
||||||
|
displayName: peerInfo?.displayName || peerId,
|
||||||
|
files: [],
|
||||||
|
text: msg.content,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileReceiverRef.current?.handleData(data);
|
||||||
|
},
|
||||||
|
onError: (peerId, err) => {
|
||||||
|
console.error(`P2P error with ${peerId}:`, err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
peerManagerRef.current = peerManager;
|
||||||
|
|
||||||
|
signaling.connect();
|
||||||
|
setConnected(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
signaling.disconnect();
|
||||||
|
peerManager.closeAll();
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
}, [joinCode]);
|
||||||
|
|
||||||
|
const sendFiles = useCallback((peerId: string, files: File[]) => {
|
||||||
|
const pm = peerManagerRef.current;
|
||||||
|
const signaling = signalingRef.current;
|
||||||
|
if (!pm || !signaling) return;
|
||||||
|
|
||||||
|
// Ensure P2P connection exists
|
||||||
|
let peer = pm.getPeer(peerId);
|
||||||
|
if (!peer) {
|
||||||
|
peer = pm.createPeer(peerId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = createTransferRequest(files);
|
||||||
|
|
||||||
|
// Add transfers to store
|
||||||
|
for (const fileMeta of request.files) {
|
||||||
|
const file = files.find((f) => f.name === fileMeta.name)!;
|
||||||
|
addTransfer({
|
||||||
|
id: fileMeta.id,
|
||||||
|
peerId,
|
||||||
|
fileName: fileMeta.name,
|
||||||
|
fileSize: fileMeta.size,
|
||||||
|
mime: fileMeta.mime,
|
||||||
|
direction: "send",
|
||||||
|
progress: 0,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store files for when accepted
|
||||||
|
pendingFilesRef.current.set(peerId, { files });
|
||||||
|
|
||||||
|
// Wait for connection then send request
|
||||||
|
const sendRequest = () => {
|
||||||
|
const p = pm.getPeer(peerId);
|
||||||
|
if (p && (p as any)._channel?.readyState === "open") {
|
||||||
|
p.send(JSON.stringify(request));
|
||||||
|
} else {
|
||||||
|
// Peer is connected but channel might not be ready
|
||||||
|
setTimeout(sendRequest, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (peer && (peer as any).connected) {
|
||||||
|
sendRequest();
|
||||||
|
} else {
|
||||||
|
peer.on("connect", sendRequest);
|
||||||
|
}
|
||||||
|
}, [addTransfer]);
|
||||||
|
|
||||||
|
const sendText = useCallback((peerId: string, text: string) => {
|
||||||
|
const pm = peerManagerRef.current;
|
||||||
|
if (!pm) return;
|
||||||
|
|
||||||
|
let peer = pm.getPeer(peerId);
|
||||||
|
if (!peer) {
|
||||||
|
peer = pm.createPeer(peerId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMsg = () => {
|
||||||
|
const p = pm.getPeer(peerId);
|
||||||
|
if (p) {
|
||||||
|
p.send(JSON.stringify({ type: "text", id: `t-${Date.now()}`, content: text }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (peer && (peer as any).connected) {
|
||||||
|
sendMsg();
|
||||||
|
} else {
|
||||||
|
peer.on("connect", sendMsg);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const acceptTransfer = useCallback((peerId: string) => {
|
||||||
|
const pm = peerManagerRef.current;
|
||||||
|
if (!pm) return;
|
||||||
|
|
||||||
|
const peer = pm.getPeer(peerId);
|
||||||
|
if (!peer) return;
|
||||||
|
|
||||||
|
// Add receive transfers
|
||||||
|
const store = useStore.getState();
|
||||||
|
const request = store.incomingRequest;
|
||||||
|
if (request) {
|
||||||
|
for (const file of request.files) {
|
||||||
|
addTransfer({
|
||||||
|
id: file.id,
|
||||||
|
peerId,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
mime: file.mime,
|
||||||
|
direction: "receive",
|
||||||
|
progress: 0,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peer.send(JSON.stringify(createTransferResponse(true)));
|
||||||
|
setIncomingRequest(null);
|
||||||
|
}, [addTransfer, setIncomingRequest]);
|
||||||
|
|
||||||
|
const rejectTransfer = useCallback((peerId: string) => {
|
||||||
|
const pm = peerManagerRef.current;
|
||||||
|
if (!pm) return;
|
||||||
|
|
||||||
|
const peer = pm.getPeer(peerId);
|
||||||
|
if (peer) {
|
||||||
|
peer.send(JSON.stringify(createTransferResponse(false)));
|
||||||
|
}
|
||||||
|
setIncomingRequest(null);
|
||||||
|
}, [setIncomingRequest]);
|
||||||
|
|
||||||
|
const createPublicRoom = useCallback(() => {
|
||||||
|
signalingRef.current?.send({ type: "create-public-room" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendFiles,
|
||||||
|
sendText,
|
||||||
|
acceptTransfer,
|
||||||
|
rejectTransfer,
|
||||||
|
createPublicRoom,
|
||||||
|
};
|
||||||
|
}
|
||||||
9
web/src/index.css
Normal file
9
web/src/index.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply min-h-screen;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
web/src/lib/fileTransfer.ts
Normal file
173
web/src/lib/fileTransfer.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import type SimplePeer from "simple-peer";
|
||||||
|
import {
|
||||||
|
CHUNK_SIZE,
|
||||||
|
MAX_BUFFER,
|
||||||
|
type FileMetaMessage,
|
||||||
|
type FileEndMessage,
|
||||||
|
type TransferRequestMessage,
|
||||||
|
type TransferResponseMessage,
|
||||||
|
type DataChannelMessage,
|
||||||
|
} from "@anydrop/shared";
|
||||||
|
|
||||||
|
export function createTransferRequest(
|
||||||
|
files: File[],
|
||||||
|
text?: string,
|
||||||
|
): TransferRequestMessage {
|
||||||
|
return {
|
||||||
|
type: "transfer-request",
|
||||||
|
files: files.map((f, i) => ({
|
||||||
|
id: `f${i}-${Date.now()}`,
|
||||||
|
name: f.name,
|
||||||
|
size: f.size,
|
||||||
|
mime: f.type || "application/octet-stream",
|
||||||
|
})),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTransferResponse(accepted: boolean): TransferResponseMessage {
|
||||||
|
return { type: "transfer-response", accepted };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendFile(
|
||||||
|
peer: SimplePeer.Instance,
|
||||||
|
file: File,
|
||||||
|
fileId: string,
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
// Send file metadata
|
||||||
|
const meta: FileMetaMessage = {
|
||||||
|
type: "file-meta",
|
||||||
|
id: fileId,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
mime: file.type || "application/octet-stream",
|
||||||
|
};
|
||||||
|
peer.send(JSON.stringify(meta));
|
||||||
|
|
||||||
|
// Stream file in chunks
|
||||||
|
let offset = 0;
|
||||||
|
const reader = file.stream().getReader();
|
||||||
|
|
||||||
|
let buffer = new Uint8Array(0);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
// Append to buffer
|
||||||
|
const newBuffer = new Uint8Array(buffer.length + value.length);
|
||||||
|
newBuffer.set(buffer);
|
||||||
|
newBuffer.set(value, buffer.length);
|
||||||
|
buffer = newBuffer;
|
||||||
|
|
||||||
|
// Send complete chunks from buffer
|
||||||
|
while (buffer.length >= CHUNK_SIZE) {
|
||||||
|
const chunk = buffer.slice(0, CHUNK_SIZE);
|
||||||
|
buffer = buffer.slice(CHUNK_SIZE);
|
||||||
|
|
||||||
|
// Backpressure: wait if buffer is full
|
||||||
|
await waitForDrain(peer);
|
||||||
|
|
||||||
|
peer.send(chunk);
|
||||||
|
offset += chunk.length;
|
||||||
|
onProgress(offset / file.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send remaining bytes
|
||||||
|
if (buffer.length > 0) {
|
||||||
|
await waitForDrain(peer);
|
||||||
|
peer.send(buffer);
|
||||||
|
offset += buffer.length;
|
||||||
|
onProgress(offset / file.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send end-of-file marker
|
||||||
|
const eof: FileEndMessage = { type: "file-end", id: fileId };
|
||||||
|
peer.send(JSON.stringify(eof));
|
||||||
|
onProgress(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForDrain(peer: SimplePeer.Instance): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const check = () => {
|
||||||
|
const channel = (peer as any)._channel as RTCDataChannel | undefined;
|
||||||
|
if (!channel || channel.bufferedAmount < MAX_BUFFER) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileReceiver {
|
||||||
|
onData: (data: Uint8Array | string) => void;
|
||||||
|
onComplete: (fileId: string, blob: Blob, fileName: string) => void;
|
||||||
|
onProgress: (fileId: string, progress: number) => void;
|
||||||
|
onTransferRequest: (msg: TransferRequestMessage) => void;
|
||||||
|
onTransferResponse: (msg: TransferResponseMessage) => void;
|
||||||
|
onText: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFileReceiver(callbacks: FileReceiver) {
|
||||||
|
const receiving = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; size: number; mime: string; chunks: Uint8Array[]; received: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleData(data: Uint8Array | string) {
|
||||||
|
// Try to parse as JSON control message
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
const msg: DataChannelMessage = JSON.parse(data);
|
||||||
|
switch (msg.type) {
|
||||||
|
case "file-meta":
|
||||||
|
receiving.set(msg.id, {
|
||||||
|
name: msg.name,
|
||||||
|
size: msg.size,
|
||||||
|
mime: msg.mime,
|
||||||
|
chunks: [],
|
||||||
|
received: 0,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "file-end": {
|
||||||
|
const file = receiving.get(msg.id);
|
||||||
|
if (file) {
|
||||||
|
const blob = new Blob(file.chunks, { type: file.mime });
|
||||||
|
callbacks.onComplete(msg.id, blob, file.name);
|
||||||
|
receiving.delete(msg.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "text":
|
||||||
|
callbacks.onText(msg.content);
|
||||||
|
break;
|
||||||
|
case "transfer-request":
|
||||||
|
callbacks.onTransferRequest(msg);
|
||||||
|
break;
|
||||||
|
case "transfer-response":
|
||||||
|
callbacks.onTransferResponse(msg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, ignore
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary data = file chunk
|
||||||
|
// Find which file we're receiving (first active one)
|
||||||
|
for (const [id, file] of receiving) {
|
||||||
|
const chunk = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
|
||||||
|
file.chunks.push(chunk);
|
||||||
|
file.received += chunk.length;
|
||||||
|
callbacks.onProgress(id, file.received / file.size);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
95
web/src/lib/peerManager.ts
Normal file
95
web/src/lib/peerManager.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import SimplePeer from "simple-peer";
|
||||||
|
import { ICE_SERVERS } from "@anydrop/shared";
|
||||||
|
import type { SignalingClient } from "./signaling";
|
||||||
|
|
||||||
|
export interface PeerConnectionCallbacks {
|
||||||
|
onConnect: (peerId: string) => void;
|
||||||
|
onClose: (peerId: string) => void;
|
||||||
|
onData: (peerId: string, data: Uint8Array | string) => void;
|
||||||
|
onError: (peerId: string, err: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PeerManager {
|
||||||
|
private peers = new Map<string, SimplePeer.Instance>();
|
||||||
|
private signaling: SignalingClient;
|
||||||
|
private callbacks: PeerConnectionCallbacks;
|
||||||
|
|
||||||
|
constructor(signaling: SignalingClient, callbacks: PeerConnectionCallbacks) {
|
||||||
|
this.signaling = signaling;
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
createPeer(peerId: string, initiator: boolean): SimplePeer.Instance {
|
||||||
|
// Close existing connection if any
|
||||||
|
this.closePeer(peerId);
|
||||||
|
|
||||||
|
const peer = new SimplePeer({
|
||||||
|
initiator,
|
||||||
|
trickle: true,
|
||||||
|
config: { iceServers: ICE_SERVERS },
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on("signal", (data) => {
|
||||||
|
this.signaling.send({ type: "signal", to: peerId, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on("connect", () => {
|
||||||
|
this.callbacks.onConnect(peerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on("data", (data: Uint8Array) => {
|
||||||
|
// Try to decode as string first (for JSON messages)
|
||||||
|
try {
|
||||||
|
const text = new TextDecoder().decode(data);
|
||||||
|
// Check if it looks like JSON
|
||||||
|
if (text.startsWith("{")) {
|
||||||
|
this.callbacks.onData(peerId, text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not text, treat as binary
|
||||||
|
}
|
||||||
|
this.callbacks.onData(peerId, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on("close", () => {
|
||||||
|
this.peers.delete(peerId);
|
||||||
|
this.callbacks.onClose(peerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on("error", (err) => {
|
||||||
|
this.callbacks.onError(peerId, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.peers.set(peerId, peer);
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSignal(fromPeerId: string, data: unknown): void {
|
||||||
|
let peer = this.peers.get(fromPeerId);
|
||||||
|
if (!peer) {
|
||||||
|
// Create a non-initiator peer for incoming connections
|
||||||
|
peer = this.createPeer(fromPeerId, false);
|
||||||
|
}
|
||||||
|
peer.signal(data as SimplePeer.SignalData);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeer(peerId: string): SimplePeer.Instance | undefined {
|
||||||
|
return this.peers.get(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
closePeer(peerId: string): void {
|
||||||
|
const peer = this.peers.get(peerId);
|
||||||
|
if (peer) {
|
||||||
|
peer.destroy();
|
||||||
|
this.peers.delete(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAll(): void {
|
||||||
|
for (const [id, peer] of this.peers) {
|
||||||
|
peer.destroy();
|
||||||
|
}
|
||||||
|
this.peers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
web/src/lib/signaling.ts
Normal file
68
web/src/lib/signaling.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import type { ClientMessage, ServerMessage } from "@anydrop/shared";
|
||||||
|
|
||||||
|
export type SignalingHandler = (msg: ServerMessage) => void;
|
||||||
|
|
||||||
|
const WS_URL = import.meta.env.VITE_WS_URL || `ws://${window.location.hostname}:3001`;
|
||||||
|
|
||||||
|
export class SignalingClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private handler: SignalingHandler;
|
||||||
|
private joinCode?: string;
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private intentionalClose = false;
|
||||||
|
|
||||||
|
constructor(handler: SignalingHandler, joinCode?: string) {
|
||||||
|
this.handler = handler;
|
||||||
|
this.joinCode = joinCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
this.intentionalClose = false;
|
||||||
|
this.ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.send({ type: "hello", joinCode: this.joinCode });
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg: ServerMessage = JSON.parse(event.data);
|
||||||
|
this.handler(msg);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
if (!this.intentionalClose) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
this.ws?.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
send(msg: ClientMessage): void {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.intentionalClose = true;
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
this.ws?.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.connect();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
web/src/main.tsx
Normal file
13
web/src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
149
web/src/pages/Home.tsx
Normal file
149
web/src/pages/Home.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useSignaling } from "../hooks/useSignaling";
|
||||||
|
import { useStore } from "../stores/useStore";
|
||||||
|
import PeerList from "../components/PeerList";
|
||||||
|
import DropZone from "../components/DropZone";
|
||||||
|
import TransferProgress from "../components/TransferProgress";
|
||||||
|
import TextShareModal from "../components/TextShareModal";
|
||||||
|
import ReceiveDialog from "../components/ReceiveDialog";
|
||||||
|
import PublicRoomPanel from "../components/PublicRoomPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom } =
|
||||||
|
useSignaling();
|
||||||
|
|
||||||
|
const displayName = useStore((s) => s.displayName);
|
||||||
|
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
||||||
|
const showTextModal = useStore((s) => s.showTextModal);
|
||||||
|
const incomingRequest = useStore((s) => s.incomingRequest);
|
||||||
|
const error = useStore((s) => s.error);
|
||||||
|
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
|
||||||
|
const setShowTextModal = useStore((s) => s.setShowTextModal);
|
||||||
|
const setError = useStore((s) => s.setError);
|
||||||
|
|
||||||
|
const handlePeerSelect = useCallback(
|
||||||
|
(peerId: string) => {
|
||||||
|
setSelectedPeerId(selectedPeerId === peerId ? null : peerId);
|
||||||
|
},
|
||||||
|
[selectedPeerId, setSelectedPeerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilesSelected = useCallback(
|
||||||
|
(files: File[]) => {
|
||||||
|
if (!selectedPeerId) return;
|
||||||
|
sendFiles(selectedPeerId, files);
|
||||||
|
},
|
||||||
|
[selectedPeerId, sendFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSendText = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (!selectedPeerId) return;
|
||||||
|
sendText(selectedPeerId, text);
|
||||||
|
},
|
||||||
|
[selectedPeerId, sendText],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAcceptTransfer = useCallback(() => {
|
||||||
|
if (incomingRequest) {
|
||||||
|
if (incomingRequest.text && incomingRequest.files.length === 0) {
|
||||||
|
navigator.clipboard?.writeText(incomingRequest.text).catch(() => {});
|
||||||
|
useStore.getState().setIncomingRequest(null);
|
||||||
|
} else {
|
||||||
|
acceptTransfer(incomingRequest.peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [incomingRequest, acceptTransfer]);
|
||||||
|
|
||||||
|
const handleRejectTransfer = useCallback(() => {
|
||||||
|
if (incomingRequest) {
|
||||||
|
rejectTransfer(incomingRequest.peerId);
|
||||||
|
}
|
||||||
|
}, [incomingRequest, rejectTransfer]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-1">
|
||||||
|
Any<span className="text-brand-400">Drop</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 text-sm">Partage instantané, sans compte</p>
|
||||||
|
{displayName && (
|
||||||
|
<p className="mt-3 text-sm text-slate-400">
|
||||||
|
Vous êtes <span className="text-brand-300 font-medium">{displayName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-sm text-red-400 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Peer list */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<PeerList onPeerSelect={handlePeerSelect} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Drop zone */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<DropZone onFilesSelected={handleFilesSelected} disabled={!selectedPeerId} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Text share button */}
|
||||||
|
{selectedPeerId && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTextModal(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
||||||
|
border border-slate-700 hover:border-brand-500 rounded-xl
|
||||||
|
text-slate-300 hover:text-white transition-all
|
||||||
|
bg-slate-900/30 hover:bg-slate-900/50"
|
||||||
|
>
|
||||||
|
<span className="text-lg">💬</span>
|
||||||
|
<span className="text-sm font-medium">Envoyer du texte</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transfer progress */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<TransferProgress />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Public room */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<PublicRoomPanel onCreateRoom={createPublicRoom} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="text-center text-xs text-slate-600 mt-12">
|
||||||
|
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showTextModal && selectedPeerId && (
|
||||||
|
<TextShareModal
|
||||||
|
onSend={handleSendText}
|
||||||
|
onClose={() => setShowTextModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{incomingRequest && (
|
||||||
|
<ReceiveDialog
|
||||||
|
request={incomingRequest}
|
||||||
|
onAccept={handleAcceptTransfer}
|
||||||
|
onReject={handleRejectTransfer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
web/src/pages/JoinRoom.tsx
Normal file
153
web/src/pages/JoinRoom.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useSignaling } from "../hooks/useSignaling";
|
||||||
|
import { useStore } from "../stores/useStore";
|
||||||
|
import PeerList from "../components/PeerList";
|
||||||
|
import DropZone from "../components/DropZone";
|
||||||
|
import TransferProgress from "../components/TransferProgress";
|
||||||
|
import TextShareModal from "../components/TextShareModal";
|
||||||
|
import ReceiveDialog from "../components/ReceiveDialog";
|
||||||
|
|
||||||
|
export default function JoinRoom() {
|
||||||
|
const { code } = useParams<{ code: string }>();
|
||||||
|
const { sendFiles, sendText, acceptTransfer, rejectTransfer } = useSignaling(code);
|
||||||
|
|
||||||
|
const displayName = useStore((s) => s.displayName);
|
||||||
|
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
||||||
|
const showTextModal = useStore((s) => s.showTextModal);
|
||||||
|
const incomingRequest = useStore((s) => s.incomingRequest);
|
||||||
|
const error = useStore((s) => s.error);
|
||||||
|
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
|
||||||
|
const setShowTextModal = useStore((s) => s.setShowTextModal);
|
||||||
|
const setError = useStore((s) => s.setError);
|
||||||
|
|
||||||
|
const handlePeerSelect = useCallback(
|
||||||
|
(peerId: string) => {
|
||||||
|
setSelectedPeerId(selectedPeerId === peerId ? null : peerId);
|
||||||
|
},
|
||||||
|
[selectedPeerId, setSelectedPeerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilesSelected = useCallback(
|
||||||
|
(files: File[]) => {
|
||||||
|
if (!selectedPeerId) return;
|
||||||
|
sendFiles(selectedPeerId, files);
|
||||||
|
},
|
||||||
|
[selectedPeerId, sendFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSendText = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (!selectedPeerId) return;
|
||||||
|
sendText(selectedPeerId, text);
|
||||||
|
},
|
||||||
|
[selectedPeerId, sendText],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAcceptTransfer = useCallback(() => {
|
||||||
|
if (incomingRequest) {
|
||||||
|
if (incomingRequest.text && incomingRequest.files.length === 0) {
|
||||||
|
navigator.clipboard.writeText(incomingRequest.text);
|
||||||
|
useStore.getState().setIncomingRequest(null);
|
||||||
|
} else {
|
||||||
|
acceptTransfer(incomingRequest.peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [incomingRequest, acceptTransfer]);
|
||||||
|
|
||||||
|
const handleRejectTransfer = useCallback(() => {
|
||||||
|
if (incomingRequest) {
|
||||||
|
rejectTransfer(incomingRequest.peerId);
|
||||||
|
}
|
||||||
|
}, [incomingRequest, rejectTransfer]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-1">
|
||||||
|
Any<span className="text-brand-400">Drop</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 text-sm">
|
||||||
|
Room <span className="text-brand-300 font-mono font-bold">{code?.toUpperCase()}</span>
|
||||||
|
</p>
|
||||||
|
{displayName && (
|
||||||
|
<p className="mt-3 text-sm text-slate-400">
|
||||||
|
Vous êtes <span className="text-brand-300 font-medium">{displayName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-sm text-red-400 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Peer list */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<PeerList onPeerSelect={handlePeerSelect} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Drop zone */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<DropZone onFilesSelected={handleFilesSelected} disabled={!selectedPeerId} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Text share button */}
|
||||||
|
{selectedPeerId && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTextModal(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
||||||
|
border border-slate-700 hover:border-brand-500 rounded-xl
|
||||||
|
text-slate-300 hover:text-white transition-all
|
||||||
|
bg-slate-900/30 hover:bg-slate-900/50"
|
||||||
|
>
|
||||||
|
<span className="text-lg">💬</span>
|
||||||
|
<span className="text-sm font-medium">Envoyer du texte</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transfer progress */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<TransferProgress />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Back to home */}
|
||||||
|
<section className="text-center">
|
||||||
|
<a href="/" className="text-sm text-slate-500 hover:text-slate-300 transition-colors">
|
||||||
|
← Retour à l'accueil
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="text-center text-xs text-slate-600 mt-12">
|
||||||
|
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showTextModal && selectedPeerId && (
|
||||||
|
<TextShareModal
|
||||||
|
onSend={handleSendText}
|
||||||
|
onClose={() => setShowTextModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{incomingRequest && (
|
||||||
|
<ReceiveDialog
|
||||||
|
request={incomingRequest}
|
||||||
|
onAccept={handleAcceptTransfer}
|
||||||
|
onReject={handleRejectTransfer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
web/src/stores/useStore.ts
Normal file
126
web/src/stores/useStore.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { PeerInfo } from "@anydrop/shared";
|
||||||
|
|
||||||
|
export interface TransferInfo {
|
||||||
|
id: string;
|
||||||
|
peerId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mime: string;
|
||||||
|
direction: "send" | "receive";
|
||||||
|
progress: number; // 0-1
|
||||||
|
status: "pending" | "transferring" | "done" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncomingRequest {
|
||||||
|
peerId: string;
|
||||||
|
displayName: string;
|
||||||
|
files: { id: string; name: string; size: number; mime: string }[];
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
// Connection
|
||||||
|
connected: boolean;
|
||||||
|
peerId: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
roomId: string | null;
|
||||||
|
|
||||||
|
// Peers
|
||||||
|
peers: PeerInfo[];
|
||||||
|
|
||||||
|
// Public room
|
||||||
|
publicRoomCode: string | null;
|
||||||
|
publicRoomUrl: string | null;
|
||||||
|
publicRoomExpiresAt: string | null;
|
||||||
|
|
||||||
|
// Transfers
|
||||||
|
transfers: TransferInfo[];
|
||||||
|
incomingRequest: IncomingRequest | null;
|
||||||
|
|
||||||
|
// UI
|
||||||
|
showTextModal: boolean;
|
||||||
|
selectedPeerId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setConnection: (peerId: string, displayName: string, roomId: string) => void;
|
||||||
|
setConnected: (connected: boolean) => void;
|
||||||
|
addPeer: (peer: PeerInfo) => void;
|
||||||
|
removePeer: (peerId: string) => void;
|
||||||
|
setPeers: (peers: PeerInfo[]) => void;
|
||||||
|
setPublicRoom: (code: string, url: string, expiresAt: string) => void;
|
||||||
|
clearPublicRoom: () => void;
|
||||||
|
addTransfer: (transfer: TransferInfo) => void;
|
||||||
|
updateTransfer: (id: string, updates: Partial<TransferInfo>) => void;
|
||||||
|
removeTransfer: (id: string) => void;
|
||||||
|
setIncomingRequest: (request: IncomingRequest | null) => void;
|
||||||
|
setShowTextModal: (show: boolean) => void;
|
||||||
|
setSelectedPeerId: (peerId: string | null) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
connected: false,
|
||||||
|
peerId: null,
|
||||||
|
displayName: null,
|
||||||
|
roomId: null,
|
||||||
|
peers: [],
|
||||||
|
publicRoomCode: null,
|
||||||
|
publicRoomUrl: null,
|
||||||
|
publicRoomExpiresAt: null,
|
||||||
|
transfers: [],
|
||||||
|
incomingRequest: null,
|
||||||
|
showTextModal: false,
|
||||||
|
selectedPeerId: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useStore = create<AppState>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
setConnection: (peerId, displayName, roomId) =>
|
||||||
|
set({ peerId, displayName, roomId, connected: true }),
|
||||||
|
|
||||||
|
setConnected: (connected) => set({ connected }),
|
||||||
|
|
||||||
|
addPeer: (peer) =>
|
||||||
|
set((s) => ({
|
||||||
|
peers: s.peers.some((p) => p.peerId === peer.peerId)
|
||||||
|
? s.peers
|
||||||
|
: [...s.peers, peer],
|
||||||
|
})),
|
||||||
|
|
||||||
|
removePeer: (peerId) =>
|
||||||
|
set((s) => ({ peers: s.peers.filter((p) => p.peerId !== peerId) })),
|
||||||
|
|
||||||
|
setPeers: (peers) => set({ peers }),
|
||||||
|
|
||||||
|
setPublicRoom: (code, url, expiresAt) =>
|
||||||
|
set({ publicRoomCode: code, publicRoomUrl: url, publicRoomExpiresAt: expiresAt }),
|
||||||
|
|
||||||
|
clearPublicRoom: () =>
|
||||||
|
set({ publicRoomCode: null, publicRoomUrl: null, publicRoomExpiresAt: null }),
|
||||||
|
|
||||||
|
addTransfer: (transfer) =>
|
||||||
|
set((s) => ({ transfers: [...s.transfers, transfer] })),
|
||||||
|
|
||||||
|
updateTransfer: (id, updates) =>
|
||||||
|
set((s) => ({
|
||||||
|
transfers: s.transfers.map((t) => (t.id === id ? { ...t, ...updates } : t)),
|
||||||
|
})),
|
||||||
|
|
||||||
|
removeTransfer: (id) =>
|
||||||
|
set((s) => ({ transfers: s.transfers.filter((t) => t.id !== id) })),
|
||||||
|
|
||||||
|
setIncomingRequest: (request) => set({ incomingRequest: request }),
|
||||||
|
|
||||||
|
setShowTextModal: (show) => set({ showTextModal: show }),
|
||||||
|
|
||||||
|
setSelectedPeerId: (peerId) => set({ selectedPeerId: peerId }),
|
||||||
|
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}));
|
||||||
24
web/tailwind.config.js
Normal file
24
web/tailwind.config.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: "#eef2ff",
|
||||||
|
100: "#e0e7ff",
|
||||||
|
200: "#c7d2fe",
|
||||||
|
300: "#a5b4fc",
|
||||||
|
400: "#818cf8",
|
||||||
|
500: "#6366f1",
|
||||||
|
600: "#4f46e5",
|
||||||
|
700: "#4338ca",
|
||||||
|
800: "#3730a3",
|
||||||
|
900: "#312e81",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
14
web/tsconfig.json
Normal file
14
web/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../shared" }
|
||||||
|
]
|
||||||
|
}
|
||||||
45
web/vite.config.ts
Normal file
45
web/vite.config.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
global: "globalThis",
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
nodePolyfills({
|
||||||
|
include: ["process", "events", "stream", "buffer", "util"],
|
||||||
|
}),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ["favicon.svg"],
|
||||||
|
manifest: {
|
||||||
|
name: "AnyDrop",
|
||||||
|
short_name: "AnyDrop",
|
||||||
|
description: "Partage de fichiers instantané, peer-to-peer, sans compte",
|
||||||
|
theme_color: "#6366f1",
|
||||||
|
background_color: "#0f172a",
|
||||||
|
display: "standalone",
|
||||||
|
start_url: "/",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "/icon-192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/icon-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user