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 (
<>
@@ -1062,6 +1140,54 @@ export default function App() {
{createFolderUpload ? "Drop files into a new folder" : "Drop files"}
+
+