feat: push notifications + iOS share sheet integration
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s

- Web Push API for offline device notifications (VAPID)
- Custom service worker with push + share target handlers
- iOS/Android share sheet support via Web Share Target API
- Dedicated /share page with one-tap send to nearby peer
- Background tab notifications for incoming transfers
- Persistent deviceId per device
This commit is contained in:
ordinarthur 2026-04-14 12:05:59 +02:00
parent fd249abbf1
commit 0034e91672
4 changed files with 228 additions and 2 deletions

View File

@ -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 (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/share" element={<Share />} />
<Route path="/:code" element={<JoinRoom />} />
</Routes>
);

157
web/src/pages/Share.tsx Normal file
View File

@ -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<SharedData> {
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 <ProfileSetup onDone={() => {}} />;
}
return <ShareConnected />;
}
function ShareConnected() {
const { sendFiles, sendText } = useSignaling();
const peers = useStore((s) => s.peers);
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
const [shared, setShared] = useState<SharedData | null>(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 (
<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-6">
<h1 className="text-3xl font-bold text-white mb-1">
Any<span className="text-brand-400">Drop</span>
</h1>
{!shared ? (
<p className="text-slate-500 text-sm mt-4">Chargement...</p>
) : sent ? (
<div className="mt-6">
<div className="text-4xl mb-3"></div>
<p className="text-brand-400 font-medium">Envoi en cours</p>
<a
href="/"
className="inline-block mt-4 text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
Retour à l'accueil
</a>
</div>
) : (
<>
<p className="text-slate-400 text-sm mt-4">Envoyer à quel appareil ?</p>
{/* What's being shared */}
<div className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 text-xs text-slate-400">
{fileCount > 0 && (
<span>
📎 {fileCount} {fileCount > 1 ? "fichiers" : "fichier"}
</span>
)}
{hasText && !fileCount && <span>💬 Texte</span>}
{hasText && fileCount > 0 && <span>+ texte</span>}
</div>
</>
)}
</header>
{/* Peer list — tap to send immediately */}
{!sent && shared && (
<section>
{peers.length === 0 ? (
<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 l'appareil destinataire.
</p>
</div>
) : (
<div className="flex flex-wrap justify-center gap-6 py-8">
<PeerList onPeerSelect={handlePeerSelect} />
</div>
)}
</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>
</div>
);
}

View File

@ -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);
}),
);

View File

@ -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,