diff --git a/package-lock.json b/package-lock.json index fd0d51d..3ca9bef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waterdrop", - "version": "0.1.18", + "version": "0.1.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waterdrop", - "version": "0.1.18", + "version": "0.1.21", "license": "SEE LICENSE IN LICENSE", "dependencies": { "busboy": "^1.6.0", diff --git a/package.json b/package.json index 2f35b33..847b2a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waterdrop", - "version": "0.1.18", + "version": "0.1.21", "description": "Private AirDrop-style file shelf for Tailscale networks.", "main": "src/main/main.js", "scripts": { diff --git a/src/renderer/public/manifest.webmanifest b/src/renderer/public/manifest.webmanifest index 2679479..c039d10 100644 --- a/src/renderer/public/manifest.webmanifest +++ b/src/renderer/public/manifest.webmanifest @@ -7,6 +7,38 @@ "display": "standalone", "background_color": "#08080a", "theme_color": "#08080a", + "share_target": { + "action": "./share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": [ + "image/*", + "video/*", + "audio/*", + "text/*", + "application/*", + "application/octet-stream", + ".7z", + ".csv", + ".gz", + ".json", + ".md", + ".rar", + ".tar", + ".xml", + ".zip" + ] + } + ] + } + }, "icons": [ { "src": "./icon.ico", diff --git a/src/renderer/public/waterdrop-sw.js b/src/renderer/public/waterdrop-sw.js index 77d101f..f79049a 100644 --- a/src/renderer/public/waterdrop-sw.js +++ b/src/renderer/public/waterdrop-sw.js @@ -3,6 +3,7 @@ const DB_VERSION = 1; const STORE_NAME = "uploads"; const SYNC_TAG = "waterdrop-upload-queue"; const WORKER_LOCK_MS = 2 * 60 * 1000; +const SHARE_TARGET_PATH = "share-target"; self.addEventListener("install", (event) => { event.waitUntil(self.skipWaiting()); @@ -21,6 +22,11 @@ self.addEventListener("sync", (event) => { ); }); +self.addEventListener("fetch", (event) => { + if (!isShareTargetRequest(event.request)) return; + event.respondWith(handleShareTarget(event.request)); +}); + self.addEventListener("backgroundfetchsuccess", (event) => { const id = event.registration.id; event.waitUntil( @@ -51,6 +57,7 @@ async function drainUploadQueue() { progress: Math.max(1, Number(record.progress || 0)), updatedAt: now, lockedUntil: now + WORKER_LOCK_MS, + syncStartedAt: record.status === "syncing" ? Number(record.syncStartedAt || now) : now, lastError: "", }; if (!await putUploadRecord(claimed)) continue; @@ -85,6 +92,115 @@ async function uploadRecord(record) { if (!response.ok) throw new Error(`HTTP ${response.status}`); } +async function handleShareTarget(request) { + const redirectUrl = new URL("./?shared=1", self.registration.scope); + try { + const queued = await queueShareTargetUploads(request); + redirectUrl.searchParams.set("shared", String(queued)); + if (queued > 0) { + if (self.registration.sync?.register) { + await self.registration.sync.register(SYNC_TAG).catch(() => {}); + } + await broadcast({ type: "WATERDROP_UPLOAD_QUEUED", count: queued, shared: true }).catch(() => {}); + } + } catch (err) { + console.error("Share target failed", err); + redirectUrl.searchParams.set("shared", "error"); + } + return Response.redirect(redirectUrl.href, 303); +} + +async function queueShareTargetUploads(request) { + const formData = await request.formData(); + const files = formData.getAll("files").filter(isFileLike); + if (!files.length) { + for (const value of formData.values()) { + if (isFileLike(value)) files.push(value); + } + } + + if (!files.length) { + const sharedText = buildSharedTextFile(formData); + if (sharedText) files.push(sharedText); + } + + let queued = 0; + for (const file of files) { + await queueSharedUpload(file); + queued += 1; + } + return queued; +} + +async function queueSharedUpload(file) { + const now = Date.now(); + const name = file.name || `shared-file-${now}`; + const record = { + id: crypto.randomUUID(), + name, + size: Number(file.size || 0), + mimeType: file.type || "application/octet-stream", + blob: file, + uploadUrl: new URL("api/files/raw", self.registration.scope).toString(), + status: "queued", + progress: 1, + attempts: 0, + createdAt: now, + updatedAt: now, + lockedUntil: 0, + lastError: "", + shared: true, + }; + if (!await putUploadRecord(record)) throw new Error(`Could not queue ${name}`); + return record; +} + +function buildSharedTextFile(formData) { + const title = cleanSharedText(formData.get("title")); + const text = cleanSharedText(formData.get("text")); + const url = cleanSharedText(formData.get("url")); + const lines = []; + if (title) lines.push(title); + if (url) lines.push(url); + if (text && text !== url) lines.push(text); + if (!lines.length) return null; + + const body = `${lines.join("\n\n")}\n`; + const blob = new Blob([body], { type: "text/plain" }); + const safeTitle = (title || "shared-link") + .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") + .replace(/\s+/g, " ") + .trim() + .slice(0, 80); + const name = `${safeTitle || "shared-link"}.txt`; + try { + return new File([blob], name, { type: "text/plain" }); + } catch { + blob.name = name; + return blob; + } +} + +function cleanSharedText(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function isShareTargetRequest(request) { + if (request.method !== "POST") return false; + const requestPath = new URL(request.url).pathname.replace(/\/+$/, ""); + const scopePath = new URL(self.registration.scope).pathname.replace(/\/+$/, ""); + return requestPath === `${scopePath}/${SHARE_TARGET_PATH}`; +} + +function isFileLike(value) { + return ( + value && + typeof value === "object" && + typeof value.arrayBuffer === "function" && + typeof value.size === "number" + ); +} + async function broadcast(message) { const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" }); clients.forEach((client) => client.postMessage(message)); @@ -154,6 +270,7 @@ async function getUploadRecord(id) { async function markUploadQueued(id, message, knownRecord = null) { const record = knownRecord || await getUploadRecord(id); if (!record) return; + if (!knownRecord && record.status !== "syncing") return; const queued = await putUploadRecord({ ...record, status: "queued", @@ -161,6 +278,7 @@ async function markUploadQueued(id, message, knownRecord = null) { attempts: Number(record.attempts || 0) + 1, updatedAt: Date.now(), lockedUntil: 0, + syncStartedAt: 0, lastError: message || "Upload paused", }); if (!queued) return; diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx index f57685c..64b8c32 100644 --- a/src/renderer/src/App.jsx +++ b/src/renderer/src/App.jsx @@ -21,6 +21,7 @@ import { Pencil, QrCode, RefreshCw, + Send, Server, Settings2, Shield, @@ -36,6 +37,7 @@ import logoUrl from "./logo.png"; import { PAGE_UPLOAD_LOCK_MS, STALE_UPLOAD_LOCK_MS, + STALLED_BACKGROUND_UPLOAD_MS, claimQueuedUpload, clearQueuedUpload, isUploadQueueSupported, @@ -78,6 +80,7 @@ const apiDefault = { rawUploadUrl: "api/files/raw", downloadUrl: (id) => `api/files/${id}/download`, previewUrl: (id) => `api/files/${id}/preview`, + cancelUpload: (id) => request(`api/uploads/${id}`, { method: "DELETE" }), createFolder: (name) => request("api/folders", { method: "POST", body: JSON.stringify({ name }) }), renameFolder: (id, name) => request(`api/folders/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }), clearFiles: () => request("api/files", { method: "DELETE" }), @@ -96,6 +99,7 @@ function buildApi(baseUrl = "") { rawUploadUrl: join("api/files/raw"), downloadUrl: (id) => join(`api/files/${id}/download`), previewUrl: (id) => join(`api/files/${id}/preview`), + cancelUpload: (id) => request(join(`api/uploads/${id}`), { method: "DELETE" }), createFolder: (name) => request(join("api/folders"), { method: "POST", body: JSON.stringify({ name }) }), renameFolder: (id, name) => request(join(`api/folders/${id}`), { method: "PATCH", body: JSON.stringify({ name }) }), clearFiles: () => request(join("api/files"), { method: "DELETE" }), @@ -124,6 +128,8 @@ export default function App() { const [pending, setPending] = useState([]); const [settings, setSettings] = useState({}); const [uploads, setUploads] = useState([]); + const [textDraft, setTextDraft] = useState(""); + const [textComposerOpen, setTextComposerOpen] = useState(false); const [createFolderUpload, setCreateFolderUpload] = useState(false); const [dragging, setDragging] = useState(false); const [busy, setBusy] = useState(true); @@ -215,7 +221,18 @@ export default function App() { notify(message, "warn"); }, [notify]); + const abortBackgroundFetchUpload = useCallback(async (id) => { + if (!id || isDesktop) return false; + const bgFetch = await serviceWorkerRegistrationRef.current?.backgroundFetch + ?.get(id) + .catch(() => null); + if (!bgFetch?.abort) return false; + await bgFetch.abort(); + return true; + }, [isDesktop]); + const startBackgroundFetchUpload = useCallback(async (record) => { + if (isDesktop) return false; const registration = serviceWorkerRegistrationRef.current; if (!registration?.backgroundFetch?.fetch) return false; try { @@ -242,7 +259,7 @@ export default function App() { await markQueuedUploadFailed(record.id, "Background fetch unavailable").catch(() => {}); return false; } - }, []); + }, [isDesktop]); const drainQueuedUploads = useCallback(async () => { if (drainingUploadsRef.current || !api.rawUploadUrl || !isUploadQueueSupported()) return; @@ -255,11 +272,18 @@ export default function App() { const queue = [...records]; const runNext = async () => { while (queue.length) { - const record = queue.shift(); - const activeBackgroundFetch = await serviceWorkerRegistrationRef.current?.backgroundFetch - ?.get(record.id) - .catch(() => null); - if (activeBackgroundFetch) continue; + let record = queue.shift(); + const activeBackgroundFetch = isDesktop + ? null + : await serviceWorkerRegistrationRef.current?.backgroundFetch + ?.get(record.id) + .catch(() => null); + if (activeBackgroundFetch) { + if (!isStalledBackgroundUpload(record)) continue; + await activeBackgroundFetch.abort?.().catch(() => {}); + const released = await releaseQueuedUpload(record.id, "Background upload stalled").catch(() => null); + record = released || { ...record, status: "queued", lockedUntil: 0 }; + } const forceClaim = record.status === "syncing"; if (forceClaim) { await releaseQueuedUpload(record.id, "Background fetch did not start").catch(() => {}); @@ -286,7 +310,7 @@ export default function App() { drainingUploadsRef.current = false; await refreshQueuedUploads(); } - }, [api, notifyUploadPaused, refreshFiles, refreshQueuedUploads, registerBackgroundUpload]); + }, [api, isDesktop, notifyUploadPaused, refreshFiles, refreshQueuedUploads, registerBackgroundUpload]); useEffect(() => { if (!("serviceWorker" in navigator) || !window.isSecureContext) { @@ -326,14 +350,18 @@ export default function App() { const onMessage = (event) => { const type = event.data?.type || ""; if (!type.startsWith("WATERDROP_UPLOAD_")) return; - refreshFiles({ silent: true }).then(refreshQueuedUploads); + refreshFiles({ silent: true }) + .then(refreshQueuedUploads) + .then(() => { + if (type === "WATERDROP_UPLOAD_QUEUED") drainQueuedUploads(); + }); }; navigator.serviceWorker.addEventListener("message", onMessage); return () => { active = false; navigator.serviceWorker.removeEventListener("message", onMessage); }; - }, [refreshFiles, refreshQueuedUploads, registerBackgroundUpload]); + }, [drainQueuedUploads, refreshFiles, refreshQueuedUploads, registerBackgroundUpload]); useEffect(() => { let mounted = true; @@ -498,7 +526,9 @@ export default function App() { nextExpiry: files.reduce((min, file) => Math.min(min, file.expiresAt || Infinity), Infinity), }; }, [files]); - const uploadModeTitle = uploadSupport.backgroundFetch + const uploadModeTitle = isDesktop + ? "Uploads run while WaterDrop is open." + : uploadSupport.backgroundFetch ? "Background uploads can continue through the browser task UI." : uploadSupport.backgroundSync ? "Queued uploads can retry in the background on this browser." @@ -612,11 +642,11 @@ export default function App() { if (dragDepth.current === 0) setDragging(false); } - async function uploadFiles(picked) { + async function uploadFiles(picked, { groupSelection = createFolderUpload } = {}) { const valid = picked.filter(Boolean); if (!valid.length) return; let targetFolder = null; - if (createFolderUpload) { + if (groupSelection) { try { const result = await api.createFolder(); targetFolder = result.folder; @@ -668,6 +698,19 @@ export default function App() { } } + async function sendTextDraft(event) { + event.preventDefault(); + if (!textDraft.trim()) return; + const file = createTextUploadFile(textDraft); + setTextDraft(""); + setTextComposerOpen(false); + await uploadFiles([file], { groupSelection: false }); + } + + function clearTextDraft() { + setTextDraft(""); + } + async function dismissUpload(upload) { if (!upload) return; if (upload.queued) { @@ -681,6 +724,30 @@ export default function App() { setUploads((items) => items.filter((item) => item.id !== upload.id)); } + async function cancelUpload(upload) { + if (!upload) return; + const name = upload.name || "this upload"; + if (!window.confirm(`Cancel upload "${name}"?`)) return; + let serverError = null; + try { + if (upload.queued) await clearQueuedUpload(upload.id); + await abortBackgroundFetchUpload(upload.id).catch(() => {}); + await api.cancelUpload?.(upload.id).catch((err) => { + serverError = err; + }); + setUploads((items) => items.filter((item) => item.id !== upload.id)); + notify( + serverError + ? "Upload cancelled locally. The server will ignore stale retries." + : "Upload cancelled", + serverError ? "warn" : "ok" + ); + await refreshFiles({ silent: true }); + } catch (err) { + notify(err.message || "Could not cancel upload", "warn"); + } + } + function uploadOneLegacy(file, targetFolder = null) { const id = crypto.randomUUID(); setUploads((items) => [ @@ -876,6 +943,17 @@ export default function App() { } } + async function copyFileText(file) { + if (!isTextFile(file)) return; + try { + const response = await fetch(api.previewUrl(file.id), { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + await copyText(await response.text(), "Text copied"); + } catch (err) { + notify(err.message || "Copy failed", "warn"); + } + } + return ( <>