diff --git a/web/src/App.tsx b/web/src/App.tsx index 3566487..f826711 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,13 @@ import { Routes, Route } from "react-router-dom"; import Home from "./pages/Home"; import JoinRoom from "./pages/JoinRoom"; +import Share from "./pages/Share"; export default function App() { return ( } /> + } /> } /> ); diff --git a/web/src/pages/Share.tsx b/web/src/pages/Share.tsx new file mode 100644 index 0000000..fc2b19d --- /dev/null +++ b/web/src/pages/Share.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState, useCallback } from "react"; +import { useSignaling } from "../hooks/useSignaling"; +import { useStore } from "../stores/useStore"; +import { useProfileStore } from "../stores/useProfileStore"; +import PeerList from "../components/PeerList"; +import ProfileSetup from "../components/ProfileSetup"; + +interface SharedData { + files: File[]; + text: string; +} + +/** Read shared files/text from the cache stashed by the service worker */ +async function readSharedData(): Promise { + const result: SharedData = { files: [], text: "" }; + + try { + const cache = await caches.open("share-target"); + + // Read metadata + const metaResponse = await cache.match("/share-target-meta"); + if (!metaResponse) return result; + + const meta = await metaResponse.json(); + result.text = meta.text || ""; + + // Read files + for (let i = 0; i < (meta.count || 0); i++) { + const fileResponse = await cache.match(`/share-target-file/${i}`); + if (!fileResponse) continue; + + const blob = await fileResponse.blob(); + const fileName = decodeURIComponent( + fileResponse.headers.get("X-File-Name") || `fichier-${i}`, + ); + result.files.push(new File([blob], fileName, { type: blob.type })); + } + + // Clean up cache + await caches.delete("share-target"); + } catch (err) { + console.error("[share] Failed to read shared data:", err); + } + + return result; +} + +export default function Share() { + const isSetUp = useProfileStore((s) => s.isSetUp); + + if (!isSetUp) { + return {}} />; + } + + return ; +} + +function ShareConnected() { + const { sendFiles, sendText } = useSignaling(); + const peers = useStore((s) => s.peers); + const setSelectedPeerId = useStore((s) => s.setSelectedPeerId); + + const [shared, setShared] = useState(null); + const [sent, setSent] = useState(false); + + useEffect(() => { + readSharedData().then(setShared); + }, []); + + const handlePeerSelect = useCallback( + (peerId: string) => { + if (!shared || sent) return; + + setSelectedPeerId(peerId); + + if (shared.files.length > 0) { + sendFiles(peerId, shared.files); + } + if (shared.text && shared.files.length === 0) { + sendText(peerId, shared.text); + } + + setSent(true); + }, + [shared, sent, sendFiles, sendText, setSelectedPeerId], + ); + + const fileCount = shared?.files.length || 0; + const hasText = !!shared?.text; + + return ( +
+
+ {/* Header */} +
+

+ AnyDrop +

+ + {!shared ? ( +

Chargement...

+ ) : sent ? ( +
+
βœ“
+

Envoi en cours

+ + Retour Γ  l'accueil + +
+ ) : ( + <> +

Envoyer Γ  quel appareil ?

+ + {/* What's being shared */} +
+ {fileCount > 0 && ( + + πŸ“Ž {fileCount} {fileCount > 1 ? "fichiers" : "fichier"} + + )} + {hasText && !fileCount && πŸ’¬ Texte} + {hasText && fileCount > 0 && + texte} +
+ + )} +
+ + {/* Peer list β€” tap to send immediately */} + {!sent && shared && ( +
+ {peers.length === 0 ? ( +
+
πŸ“‘
+

En attente d'appareils...

+

+ Ouvrez AnyDrop sur l'appareil destinataire. +

+
+ ) : ( +
+ +
+ )} +
+ )} + + {/* Footer */} + +
+
+ ); +} diff --git a/web/src/sw.ts b/web/src/sw.ts index ad4a247..563a254 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -21,6 +21,59 @@ self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()); }); +// ── Share Target handler ── +// iOS/Android share sheet sends a POST to /share with files as multipart/form-data. +// We intercept it, stash the files in a temporary cache, and redirect to the share page. + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + + if (url.pathname === "/share" && event.request.method === "POST") { + event.respondWith( + (async () => { + const formData = await event.request.formData(); + const files = formData.getAll("files") as File[]; + const text = formData.get("text") as string | null; + const sharedUrl = formData.get("url") as string | null; + + // Build the shared text (combine text + url if both present) + let sharedText = ""; + if (text) sharedText += text; + if (sharedUrl) sharedText += (sharedText ? "\n" : "") + sharedUrl; + + // Stash files in Cache Storage so the page can retrieve them + if (files.length > 0) { + const cache = await caches.open("share-target"); + // Store each file as a cache entry keyed by index + for (let i = 0; i < files.length; i++) { + const response = new Response(files[i], { + headers: { + "Content-Type": files[i].type || "application/octet-stream", + "X-File-Name": encodeURIComponent(files[i].name), + }, + }); + await cache.put(`/share-target-file/${i}`, response); + } + // Store the count + await cache.put( + "/share-target-meta", + new Response(JSON.stringify({ count: files.length, text: sharedText })), + ); + } else if (sharedText) { + const cache = await caches.open("share-target"); + await cache.put( + "/share-target-meta", + new Response(JSON.stringify({ count: 0, text: sharedText })), + ); + } + + // Redirect to the share page (GET) β€” the page will read from cache + return Response.redirect("/share", 303); + })(), + ); + } +}); + // ── Push notifications ── self.addEventListener("push", (event) => { @@ -56,13 +109,11 @@ self.addEventListener("notificationclick", (event) => { event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => { - // Focus existing tab if open for (const client of clients) { if (new URL(client.url).pathname === url && "focus" in client) { return client.focus(); } } - // Otherwise open new tab return self.clients.openWindow(url); }), ); diff --git a/web/vite.config.ts b/web/vite.config.ts index 09d708d..e46e313 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -41,6 +41,22 @@ export default defineConfig({ type: "image/png", }, ], + share_target: { + action: "/share", + method: "POST", + enctype: "multipart/form-data", + params: { + title: "title", + text: "text", + url: "url", + files: [ + { + name: "files", + accept: ["*/*"], + }, + ], + }, + } as any, }, devOptions: { enabled: false,