Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
32 changes: 32 additions & 0 deletions src/renderer/public/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +26 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 application/octet-stream is already covered by the application/* wildcard that precedes it, making this entry redundant. Browsers process the accept list by matching against each entry, so the duplicate is harmless but unnecessary clutter.

Suggested change
"application/*",
"application/octet-stream",
"application/*",
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/renderer/public/manifest.webmanifest
Line: 26-27

Comment:
`application/octet-stream` is already covered by the `application/*` wildcard that precedes it, making this entry redundant. Browsers process the accept list by matching against each entry, so the duplicate is harmless but unnecessary clutter.

```suggestion
            "application/*",
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

".7z",
".csv",
".gz",
".json",
".md",
".rar",
".tar",
".xml",
".zip"
]
}
]
}
},
"icons": [
{
"src": "./icon.ico",
Expand Down
118 changes: 118 additions & 0 deletions src/renderer/public/waterdrop-sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -154,13 +270,15 @@ 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",
progress: 0,
attempts: Number(record.attempts || 0) + 1,
updatedAt: Date.now(),
lockedUntil: 0,
syncStartedAt: 0,
lastError: message || "Upload paused",
});
if (!queued) return;
Expand Down
Loading
Loading