From d1dc7e64f0ceef25250aad1d868b8f22d1920b66 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Mon, 6 Jul 2026 15:31:41 -0300 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20atualiza=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 +- package.json | 2 +- src/renderer/public/manifest.webmanifest | 32 +++++++ src/renderer/public/waterdrop-sw.js | 115 +++++++++++++++++++++++ src/renderer/src/App.jsx | 8 +- src/server/dropServer.js | 40 +++++++- test/dropServer.test.js | 31 ++++++ 7 files changed, 223 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index fd0d51d..3780140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waterdrop", - "version": "0.1.18", + "version": "0.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waterdrop", - "version": "0.1.18", + "version": "0.1.19", "license": "SEE LICENSE IN LICENSE", "dependencies": { "busboy": "^1.6.0", diff --git a/package.json b/package.json index 2f35b33..bec0d6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waterdrop", - "version": "0.1.18", + "version": "0.1.19", "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..3d8ab5f 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( @@ -85,6 +91,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); + 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 }); + } + redirectUrl.searchParams.set("shared", String(queued)); + } 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)); diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx index f57685c..8b2d4ce 100644 --- a/src/renderer/src/App.jsx +++ b/src/renderer/src/App.jsx @@ -326,14 +326,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; diff --git a/src/server/dropServer.js b/src/server/dropServer.js index dafb84d..1519efc 100644 --- a/src/server/dropServer.js +++ b/src/server/dropServer.js @@ -687,6 +687,11 @@ async function handleRequest({ req, res, store, rendererDir, getPort, getHttpsPo return; } + if (pathname === "/share-target") { + await handleShareTarget({ req, res, store, redirectTo: "/" }); + return; + } + if (pathname.startsWith("/api/")) { await handleApi({ req, res, url, relative: pathname, store, getPort, getHttpsPort, eventClients }); return; @@ -694,6 +699,10 @@ async function handleRequest({ req, res, store, rendererDir, getPort, getHttpsPo if (pathname.startsWith(`${BASE_PATH}/`)) { const relative = pathname.slice(BASE_PATH.length); + if (relative === "/share-target") { + await handleShareTarget({ req, res, store, redirectTo: `${BASE_PATH}/` }); + return; + } if (relative.startsWith("/api/")) { await handleApi({ req, res, url, relative, store, getPort, getHttpsPort, eventClients }); return; @@ -702,7 +711,14 @@ async function handleRequest({ req, res, store, rendererDir, getPort, getHttpsPo return; } - if (pathname === "/" || pathname === "/index.html" || pathname === "/icon.ico" || pathname.startsWith("/assets/")) { + if ( + pathname === "/" || + pathname === "/index.html" || + pathname === "/icon.ico" || + pathname === "/manifest.webmanifest" || + pathname === "/waterdrop-sw.js" || + pathname.startsWith("/assets/") + ) { await serveStatic({ req, res, pathname, rendererDir }); return; } @@ -969,7 +985,19 @@ async function handleChunkUpload(req, res, store, { id, originalName, mimeType, } } -async function handleUpload(req, res, store) { +async function handleShareTarget({ req, res, store, redirectTo }) { + if (req.method === "GET" || req.method === "HEAD") { + redirect(res, redirectTo); + return; + } + if (req.method !== "POST") { + sendJson(res, 405, { error: "Method not allowed" }); + return; + } + await handleUpload(req, res, store, { redirectTo }); +} + +async function handleUpload(req, res, store, { redirectTo = "" } = {}) { const contentType = req.headers["content-type"] || ""; if (!contentType.includes("multipart/form-data")) { sendJson(res, 415, { error: "Expected multipart/form-data" }); @@ -1054,6 +1082,10 @@ async function handleUpload(req, res, store) { try { const files = await Promise.all(uploadPromises); + if (redirectTo) { + redirect(res, redirectTo, 303); + return; + } sendJson(res, 201, { files }); } catch (err) { await Promise.allSettled(tempPaths.map((tempPath) => removeIfExists(tempPath))); @@ -1599,8 +1631,8 @@ function corsHeaders() { }; } -function redirect(res, location) { - res.writeHead(308, { Location: location }); +function redirect(res, location, status = 308) { + res.writeHead(status, { Location: location }); res.end(); } diff --git a/test/dropServer.test.js b/test/dropServer.test.js index 86d1c6e..0b00065 100644 --- a/test/dropServer.test.js +++ b/test/dropServer.test.js @@ -167,6 +167,37 @@ test("uploads announce a placeholder before committing", async () => { } }); +test("web share target uploads shared files and returns to the app", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "waterdrop-share-target-test-")); + const rendererDir = path.join(root, "renderer"); + const dataDir = path.join(root, "data"); + const downloads = path.join(root, "downloads"); + await fs.mkdir(rendererDir, { recursive: true }); + await fs.writeFile(path.join(rendererDir, "index.html"), "WaterDrop"); + + const server = await createDropServer({ dataDir, defaultDownloadDir: downloads, rendererDir, port: 48040 }); + try { + const form = new FormData(); + form.append("files", new Blob(["shared"], { type: "text/plain" }), "shared.txt"); + + const upload = await fetch(new URL("share-target", server.localUrl), { + method: "POST", + body: form, + redirect: "manual", + }); + assert.equal(upload.status, 303); + assert.equal(upload.headers.get("location"), "/drop/"); + + const listed = await (await fetch(new URL("api/files", server.localUrl))).json(); + assert.equal(listed.files.length, 1); + assert.equal(listed.files[0].name, "shared.txt"); + assert.equal(listed.files[0].size, 6); + } finally { + await server.close(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + test("pending upload placeholder clears once the file lands", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "waterdrop-pending-test-")); const rendererDir = path.join(root, "renderer"); From 3e70045fd34a0ff2f0a29cc0921ec6a50f0a74e8 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Mon, 6 Jul 2026 15:41:59 -0300 Subject: [PATCH 2/7] fix share target review feedback --- package-lock.json | 4 ++-- package.json | 2 +- src/server/dropServer.js | 22 +++++++++++++++++++-- test/dropServer.test.js | 42 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3780140..fd0d51d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waterdrop", - "version": "0.1.19", + "version": "0.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waterdrop", - "version": "0.1.19", + "version": "0.1.18", "license": "SEE LICENSE IN LICENSE", "dependencies": { "busboy": "^1.6.0", diff --git a/package.json b/package.json index bec0d6b..2f35b33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waterdrop", - "version": "0.1.19", + "version": "0.1.18", "description": "Private AirDrop-style file shelf for Tailscale networks.", "main": "src/main/main.js", "scripts": { diff --git a/src/server/dropServer.js b/src/server/dropServer.js index 1519efc..b55cffb 100644 --- a/src/server/dropServer.js +++ b/src/server/dropServer.js @@ -987,19 +987,33 @@ async function handleChunkUpload(req, res, store, { id, originalName, mimeType, async function handleShareTarget({ req, res, store, redirectTo }) { if (req.method === "GET" || req.method === "HEAD") { - redirect(res, redirectTo); + redirect(res, redirectTo, 302); return; } if (req.method !== "POST") { sendJson(res, 405, { error: "Method not allowed" }); return; } - await handleUpload(req, res, store, { redirectTo }); + try { + await handleUpload(req, res, store, { redirectTo }); + } catch (err) { + if (err?.code === "UPLOAD_ABORTED") throw err; + console.error("Share target upload failed", err); + if (!res.headersSent) { + redirect(res, redirectTo, 303); + return; + } + throw err; + } } async function handleUpload(req, res, store, { redirectTo = "" } = {}) { const contentType = req.headers["content-type"] || ""; if (!contentType.includes("multipart/form-data")) { + if (redirectTo) { + redirect(res, redirectTo, 303); + return; + } sendJson(res, 415, { error: "Expected multipart/form-data" }); return; } @@ -1090,6 +1104,10 @@ async function handleUpload(req, res, store, { redirectTo = "" } = {}) { } catch (err) { await Promise.allSettled(tempPaths.map((tempPath) => removeIfExists(tempPath))); pendingIds.forEach((id) => store.removePendingUpload(id, "upload-failed")); + if (redirectTo) { + redirect(res, redirectTo, 303); + return; + } sendJson(res, 500, { error: `Upload failed: ${err.message}` }); } } diff --git a/test/dropServer.test.js b/test/dropServer.test.js index 0b00065..9892fa6 100644 --- a/test/dropServer.test.js +++ b/test/dropServer.test.js @@ -177,6 +177,14 @@ test("web share target uploads shared files and returns to the app", async () => const server = await createDropServer({ dataDir, defaultDownloadDir: downloads, rendererDir, port: 48040 }); try { + const nav = await fetch(new URL("share-target", server.localUrl), { redirect: "manual" }); + assert.equal(nav.status, 302); + assert.equal(nav.headers.get("location"), "/drop/"); + + const head = await fetch(new URL("share-target", server.localUrl), { method: "HEAD", redirect: "manual" }); + assert.equal(head.status, 302); + assert.equal(head.headers.get("location"), "/drop/"); + const form = new FormData(); form.append("files", new Blob(["shared"], { type: "text/plain" }), "shared.txt"); @@ -198,6 +206,40 @@ test("web share target uploads shared files and returns to the app", async () => } }); +test("web share target redirects back to the app when storage rejects an upload", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "waterdrop-share-target-error-test-")); + const rendererDir = path.join(root, "renderer"); + const dataDir = path.join(root, "data"); + const downloads = path.join(root, "downloads"); + await fs.mkdir(rendererDir, { recursive: true }); + await fs.writeFile(path.join(rendererDir, "index.html"), "WaterDrop"); + + const server = await createDropServer({ dataDir, defaultDownloadDir: downloads, rendererDir, port: 48045 }); + try { + server.store.addFromTemp = async () => { + throw new Error("disk full"); + }; + + const form = new FormData(); + form.append("files", new Blob(["shared"], { type: "text/plain" }), "shared.txt"); + + const upload = await fetch(new URL("share-target", server.localUrl), { + method: "POST", + body: form, + redirect: "manual", + }); + assert.equal(upload.status, 303); + assert.equal(upload.headers.get("location"), "/drop/"); + + const listed = await (await fetch(new URL("api/files", server.localUrl))).json(); + assert.equal(listed.files.length, 0); + assert.equal(listed.pending.length, 0); + } finally { + await server.close(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + test("pending upload placeholder clears once the file lands", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "waterdrop-pending-test-")); const rendererDir = path.join(root, "renderer"); From 59ccfe16e5de9eb5f459796c9d253871fea1f4d1 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Mon, 6 Jul 2026 15:47:55 -0300 Subject: [PATCH 3/7] fix text share server fallback --- src/server/dropServer.js | 54 ++++++++++++++++++++++++++++++++++++++++ test/dropServer.test.js | 36 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/server/dropServer.js b/src/server/dropServer.js index b55cffb..97115cb 100644 --- a/src/server/dropServer.js +++ b/src/server/dropServer.js @@ -1027,6 +1027,7 @@ async function handleUpload(req, res, store, { redirectTo = "" } = {}) { const uploadPromises = []; const tempPaths = []; const pendingIds = []; + const fields = new Map(); busboy.on("file", (_fieldName, fileStream, info) => { const originalName = info.filename || "unnamed-file"; @@ -1066,6 +1067,10 @@ async function handleUpload(req, res, store, { redirectTo = "" } = {}) { uploadPromises.push(promise); }); + busboy.on("field", (name, value) => { + if (!fields.has(name)) fields.set(name, value); + }); + let aborted = false; busboy.on("error", (err) => { if (!aborted) console.error("Upload parser failed", err); @@ -1094,6 +1099,29 @@ async function handleUpload(req, res, store, { redirectTo = "" } = {}) { throw err; } + if (redirectTo && uploadPromises.length === 0) { + const sharedText = buildSharedTextUpload(fields); + if (sharedText) { + const id = crypto.randomUUID(); + const tempPath = path.join(store.tmpDir, `${id}.upload`); + tempPaths.push(tempPath); + pendingIds.push(id); + store.addPendingUpload({ id, name: sharedText.name, size: sharedText.body.length }); + uploadPromises.push( + fsp.writeFile(tempPath, sharedText.body).then(() => + store.addFromTemp({ + id, + tempPath, + originalName: sharedText.name, + mimeType: "text/plain", + size: sharedText.body.length, + sha256: crypto.createHash("sha256").update(sharedText.body).digest("hex"), + }) + ) + ); + } + } + try { const files = await Promise.all(uploadPromises); if (redirectTo) { @@ -1112,6 +1140,32 @@ async function handleUpload(req, res, store, { redirectTo = "" } = {}) { } } +function buildSharedTextUpload(fields) { + const title = cleanSharedText(fields.get("title")); + const text = cleanSharedText(fields.get("text")); + const url = cleanSharedText(fields.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 = Buffer.from(`${lines.join("\n\n")}\n`, "utf8"); + const safeTitle = (title || "shared-link") + .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") + .replace(/\s+/g, " ") + .trim() + .slice(0, 80); + return { + body, + name: `${safeTitle || "shared-link"}.txt`, + }; +} + +function cleanSharedText(value) { + return typeof value === "string" ? value.trim() : ""; +} + async function handleDownload(req, res, store, id) { const file = store.get(id); if (file?.kind === "folder") { diff --git a/test/dropServer.test.js b/test/dropServer.test.js index 9892fa6..04176ac 100644 --- a/test/dropServer.test.js +++ b/test/dropServer.test.js @@ -240,6 +240,42 @@ test("web share target redirects back to the app when storage rejects an upload" } }); +test("web share target preserves text-only shares in the server fallback", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "waterdrop-share-target-text-test-")); + const rendererDir = path.join(root, "renderer"); + const dataDir = path.join(root, "data"); + const downloads = path.join(root, "downloads"); + await fs.mkdir(rendererDir, { recursive: true }); + await fs.writeFile(path.join(rendererDir, "index.html"), "WaterDrop"); + + const server = await createDropServer({ dataDir, defaultDownloadDir: downloads, rendererDir, port: 48046 }); + try { + const form = new FormData(); + form.append("title", "Example: Link"); + form.append("url", "https://example.com/page"); + form.append("text", "A useful reference"); + + const upload = await fetch(new URL("share-target", server.localUrl), { + method: "POST", + body: form, + redirect: "manual", + }); + assert.equal(upload.status, 303); + assert.equal(upload.headers.get("location"), "/drop/"); + + const listed = await (await fetch(new URL("api/files", server.localUrl))).json(); + assert.equal(listed.files.length, 1); + assert.equal(listed.files[0].name, "Example_ Link.txt"); + assert.equal(listed.files[0].mimeType, "text/plain"); + + const download = await fetch(new URL(`api/files/${listed.files[0].id}/download`, server.localUrl)); + assert.equal(await download.text(), "Example: Link\n\nhttps://example.com/page\n\nA useful reference\n"); + } finally { + await server.close(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + test("pending upload placeholder clears once the file lands", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "waterdrop-pending-test-")); const rendererDir = path.join(root, "renderer"); From 5c8ba9861aac15b7ae3ade380809b1942dccbab4 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Mon, 6 Jul 2026 15:55:04 -0300 Subject: [PATCH 4/7] fix share target broadcast redirect --- src/renderer/public/waterdrop-sw.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/public/waterdrop-sw.js b/src/renderer/public/waterdrop-sw.js index 3d8ab5f..619bd4e 100644 --- a/src/renderer/public/waterdrop-sw.js +++ b/src/renderer/public/waterdrop-sw.js @@ -95,13 +95,13 @@ 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 }); + await broadcast({ type: "WATERDROP_UPLOAD_QUEUED", count: queued, shared: true }).catch(() => {}); } - redirectUrl.searchParams.set("shared", String(queued)); } catch (err) { console.error("Share target failed", err); redirectUrl.searchParams.set("shared", "error"); From 1d6d2d8e1240bcf70388115e6b8d442f5b619140 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Mon, 6 Jul 2026 16:21:17 -0300 Subject: [PATCH 5/7] add text sharing copy flow --- package-lock.json | 4 +- package.json | 2 +- src/renderer/src/App.jsx | 144 +++++++++++++++++++++++++++++++----- src/renderer/src/styles.css | 47 ++++++++++++ 4 files changed, 174 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index fd0d51d..3780140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waterdrop", - "version": "0.1.18", + "version": "0.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waterdrop", - "version": "0.1.18", + "version": "0.1.19", "license": "SEE LICENSE IN LICENSE", "dependencies": { "busboy": "^1.6.0", diff --git a/package.json b/package.json index 2f35b33..bec0d6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waterdrop", - "version": "0.1.18", + "version": "0.1.19", "description": "Private AirDrop-style file shelf for Tailscale networks.", "main": "src/main/main.js", "scripts": { diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx index 8b2d4ce..1ddd2bb 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, @@ -124,6 +125,7 @@ export default function App() { const [pending, setPending] = useState([]); const [settings, setSettings] = useState({}); const [uploads, setUploads] = useState([]); + const [textDraft, setTextDraft] = useState(""); const [createFolderUpload, setCreateFolderUpload] = useState(false); const [dragging, setDragging] = useState(false); const [busy, setBusy] = useState(true); @@ -616,11 +618,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; @@ -672,6 +674,18 @@ export default function App() { } } + async function sendTextDraft(event) { + event.preventDefault(); + if (!textDraft.trim()) return; + const file = createTextUploadFile(textDraft); + setTextDraft(""); + await uploadFiles([file], { groupSelection: false }); + } + + function clearTextDraft() { + setTextDraft(""); + } + async function dismissUpload(upload) { if (!upload) return; if (upload.queued) { @@ -880,6 +894,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 ( <>