From 2f3a5daafcd5986921b39465bccc099e62097ae8 Mon Sep 17 00:00:00 2001
From: Xi Xu
Date: Sun, 22 Mar 2026 13:50:36 +0800
Subject: [PATCH] feat: refine share flow and download handling
---
CODE_OF_CONDUCT.md | 2 +-
SECURITY.md | 11 +-
SUPPORT.md | 2 +-
apps/web/src/app/routes/NotFoundPage.tsx | 6 +-
apps/web/src/app/routes/SharePage.test.tsx | 11 +-
apps/web/src/app/routes/SharePage.tsx | 4 +-
.../features/history/HistoryBoard.test.tsx | 83 +------------
.../web/src/features/history/HistoryBoard.tsx | 47 +-------
.../src/features/receive/ReceiveTransfer.tsx | 46 ++------
.../web/src/features/share/ShareCard.test.tsx | 101 ++++++++++++++--
apps/web/src/features/share/ShareCard.tsx | 61 ++++++----
apps/web/src/lib/files/save.test.ts | 45 +++++++
apps/web/src/lib/files/save.ts | 110 +-----------------
apps/web/src/lib/seo/site.ts | 8 +-
skills/xdrop/SKILL.md | 4 -
tests/e2e/xdrop.spec.ts | 4 +-
16 files changed, 222 insertions(+), 323 deletions(-)
create mode 100644 apps/web/src/lib/files/save.test.ts
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 3eebb00..32b7161 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -51,7 +51,7 @@ an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project
-maintainer at [hi@xi-xu.me](mailto:hi@xi-xu.me). All complaints will be reviewed and investigated
+maintainer at [i@xi-xu.me](mailto:i@xi-xu.me). All complaints will be reviewed and investigated
promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any
diff --git a/SECURITY.md b/SECURITY.md
index 3b8df17..548d233 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,19 +1,10 @@
# Security Policy
-## Supported versions
-
-Xdrop is pre-`1.0`, so security fixes are only guaranteed on the latest development line.
-
-| Version or branch | Supported |
-| ----------------------------------------- | --------- |
-| `main` | Yes |
-| older commits, branches, and ad-hoc forks | No |
-
## Reporting a vulnerability
Please do not report security issues in public GitHub issues, discussions, or pull requests.
-Instead, email [hi@xi-xu.me](mailto:hi@xi-xu.me) with the subject line `Xdrop security report`.
+Instead, email [i@xi-xu.me](mailto:i@xi-xu.me) with the subject line `Xdrop security report`.
Include as much of the following as you can:
diff --git a/SUPPORT.md b/SUPPORT.md
index 435404d..71f54ef 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -43,7 +43,7 @@ Please share enough context for someone else to help:
Do not file public issues for vulnerabilities, exposed secrets, or anything that could put users at
risk.
-Follow [SECURITY.md](SECURITY.md) and email [hi@xi-xu.me](mailto:hi@xi-xu.me) instead.
+Follow [SECURITY.md](SECURITY.md) and email [i@xi-xu.me](mailto:i@xi-xu.me) instead.
## Before you open an issue
diff --git a/apps/web/src/app/routes/NotFoundPage.tsx b/apps/web/src/app/routes/NotFoundPage.tsx
index 8c4084f..6aa6319 100644
--- a/apps/web/src/app/routes/NotFoundPage.tsx
+++ b/apps/web/src/app/routes/NotFoundPage.tsx
@@ -19,7 +19,7 @@ export function NotFoundPage() {
This page was not found.
The address does not map to a page in Xdrop. If this came from a shared transfer, ask
- for the full URL, including the #k=... decryption fragment.
+ the sender to resend the complete share details.
@@ -47,8 +47,8 @@ export function NotFoundPage() {
Check the route family
- Shared downloads live under /t/:transferId. Sender history lives under{' '}
- /transfers.
+ Shared downloads and sender history use different pages. Return to a known page and
+ open the transfer again from there.
diff --git a/apps/web/src/app/routes/SharePage.test.tsx b/apps/web/src/app/routes/SharePage.test.tsx
index 466d2e1..eec4a32 100644
--- a/apps/web/src/app/routes/SharePage.test.tsx
+++ b/apps/web/src/app/routes/SharePage.test.tsx
@@ -4,12 +4,14 @@ import { describe, expect, it, vi } from 'vitest'
const { usePageMetadataMock, useTransfersMock } = vi.hoisted(() => ({
usePageMetadataMock: vi.fn(),
+ extendTransferMock: vi.fn(async () => {}),
useTransfersMock: vi.fn(() => ({
transfers: [
{
id: 't1',
},
],
+ extendTransfer: vi.fn(async () => {}),
})),
}))
@@ -46,9 +48,16 @@ describe('SharePage', () => {
expect(usePageMetadataMock).toHaveBeenCalledWith(
expect.objectContaining({
exposeUrl: false,
- title: 'Share the Full Link | Xdrop',
+ title: 'Share This Transfer | Xdrop',
}),
)
expect(screen.getByText('Share t1')).toBeInTheDocument()
+ expect(shareCardMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ transfer: { id: 't1' },
+ onExtendTransfer: expect.any(Function),
+ }),
+ undefined,
+ )
})
})
diff --git a/apps/web/src/app/routes/SharePage.tsx b/apps/web/src/app/routes/SharePage.tsx
index 85ebbfd..66d2b2a 100644
--- a/apps/web/src/app/routes/SharePage.tsx
+++ b/apps/web/src/app/routes/SharePage.tsx
@@ -8,7 +8,7 @@ import { usePageMetadata } from '@/lib/seo/usePageMetadata'
/** SharePage shows sender-side progress and sharing controls for one local transfer. */
export function SharePage() {
const { transferId } = useParams()
- const { transfers } = useTransfers()
+ const { transfers, extendTransfer } = useTransfers()
const transfer = transfers.find((item) => item.id === transferId)
usePageMetadata({
@@ -18,5 +18,5 @@ export function SharePage() {
exposeUrl: false,
})
- return
+ return
}
diff --git a/apps/web/src/features/history/HistoryBoard.test.tsx b/apps/web/src/features/history/HistoryBoard.test.tsx
index 0d148b1..5cf3755 100644
--- a/apps/web/src/features/history/HistoryBoard.test.tsx
+++ b/apps/web/src/features/history/HistoryBoard.test.tsx
@@ -4,17 +4,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LocalTransferRecord } from '@/lib/indexeddb/db'
-const { deleteTransferMock, extendTransferMock, transfersState } = vi.hoisted(() => ({
+const { deleteTransferMock, transfersState } = vi.hoisted(() => ({
transfersState: [] as LocalTransferRecord[],
deleteTransferMock: vi.fn(async () => {}),
- extendTransferMock: vi.fn(async () => {}),
}))
vi.mock('@/features/upload/TransferContext', () => ({
useTransfers: () => ({
transfers: transfersState,
deleteTransfer: deleteTransferMock,
- extendTransfer: extendTransferMock,
}),
}))
@@ -54,12 +52,6 @@ describe('HistoryBoard', () => {
vi.setSystemTime(new Date('2026-03-20T10:00:00.000Z'))
transfersState.splice(0, transfersState.length)
deleteTransferMock.mockClear()
- extendTransferMock.mockClear()
- Object.assign(navigator, {
- clipboard: {
- writeText: vi.fn(async () => {}),
- },
- })
})
afterEach(() => {
@@ -95,9 +87,8 @@ describe('HistoryBoard', () => {
expect(expiredCard).not.toBeNull()
expect(screen.getByText('Expired transfer')).toBeInTheDocument()
expect(
- within(expiredCard as HTMLElement).queryByRole('button', { name: /Copy link/i }),
+ screen.queryByRole('button', { name: /Set expiry to 1 week from now/i }),
).not.toBeInTheDocument()
- expect(screen.queryByRole('button', { name: /Restore for 1 week/i })).not.toBeInTheDocument()
})
it('does not show the empty state when only expired transfers exist', () => {
@@ -118,7 +109,7 @@ describe('HistoryBoard', () => {
expect(screen.getByText('No transfers on this device')).toBeInTheDocument()
})
- it('copies links, shows progress, and renders local warnings for active transfers', async () => {
+ it('shows progress and renders local warnings for active transfers', () => {
transfersState.splice(
0,
transfersState.length,
@@ -146,25 +137,6 @@ describe('HistoryBoard', () => {
expect(
within(uploadingCard as HTMLElement).getByRole('link', { name: 'Open share page' }),
).toHaveAttribute('href', '/share/uploading-1')
-
- fireEvent.click(within(uploadingCard as HTMLElement).getByRole('button', { name: 'Copy link' }))
-
- await act(async () => {
- await Promise.resolve()
- })
-
- expect(navigator.clipboard.writeText).toHaveBeenCalledWith('https://example.com/t/uploading-1')
- expect(
- within(uploadingCard as HTMLElement).getByRole('button', { name: 'Copied' }),
- ).toBeInTheDocument()
-
- await act(async () => {
- vi.advanceTimersByTime(1800)
- })
-
- expect(
- within(uploadingCard as HTMLElement).getByRole('button', { name: 'Copy link' }),
- ).toBeInTheDocument()
})
it('surfaces async action failures and clears stale errors before retrying', async () => {
@@ -173,33 +145,11 @@ describe('HistoryBoard', () => {
transfersState.length,
createTransfer('ready-1', 'Ready transfer', '2026-03-21T10:00:00.000Z'),
)
- extendTransferMock.mockRejectedValueOnce(new Error('Extend failed'))
deleteTransferMock.mockRejectedValueOnce('unexpected')
renderBoard()
- fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
-
- await act(async () => {
- await Promise.resolve()
- })
-
- expect(screen.getByText('Extend failed')).toBeInTheDocument()
- expect(extendTransferMock).toHaveBeenCalledWith('ready-1', expect.any(Number))
-
- extendTransferMock.mockResolvedValueOnce(undefined)
- fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
-
- await act(async () => {
- await Promise.resolve()
- })
-
- expect(screen.queryByText('Extend failed')).not.toBeInTheDocument()
-
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
- expect(
- screen.getByText(/Confirm delete to remove this transfer from this device/i),
- ).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Confirm delete' }))
await act(async () => {
@@ -209,31 +159,4 @@ describe('HistoryBoard', () => {
expect(screen.getByText('Could not update this transfer right now.')).toBeInTheDocument()
expect(deleteTransferMock).toHaveBeenCalledWith('ready-1')
})
-
- it('shows a scoped fallback when clipboard access is unavailable', async () => {
- Object.assign(navigator, {
- clipboard: {
- writeText: vi.fn(async () => {
- throw new Error('denied')
- }),
- },
- })
- transfersState.splice(
- 0,
- transfersState.length,
- createTransfer('ready-2', 'Ready transfer', '2026-03-21T10:00:00.000Z'),
- )
-
- renderBoard()
-
- fireEvent.click(screen.getByRole('button', { name: 'Copy link' }))
-
- await act(async () => {
- await Promise.resolve()
- })
-
- expect(
- screen.getByText(/Open the share page to copy the full link manually/i),
- ).toBeInTheDocument()
- })
})
diff --git a/apps/web/src/features/history/HistoryBoard.tsx b/apps/web/src/features/history/HistoryBoard.tsx
index 159d9ba..c5ef1bb 100644
--- a/apps/web/src/features/history/HistoryBoard.tsx
+++ b/apps/web/src/features/history/HistoryBoard.tsx
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
-import { MAX_EXPIRY_SECONDS, getExpiryOptionLabel } from '@xdrop/shared'
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/Button'
@@ -14,8 +13,7 @@ import { isExpiredTransfer } from '@/lib/transfers/expiry'
/** HistoryBoard is the sender-side dashboard for transfer status, copy, extend, and delete actions. */
export function HistoryBoard() {
- const { transfers, deleteTransfer, extendTransfer } = useTransfers()
- const [copiedTransferId, setCopiedTransferId] = useState()
+ const { transfers, deleteTransfer } = useTransfers()
const [expandedExpired, setExpandedExpired] = useState(false)
const [now, setNow] = useState(() => Date.now())
const [actionErrors, setActionErrors] = useState>({})
@@ -63,33 +61,6 @@ export function HistoryBoard() {
})
}
- /** copyLink keeps link-copy feedback local to the transfer the user interacted with. */
- const copyLink = async (transferId: string, shareUrl: string | undefined) => {
- if (!shareUrl) {
- return
- }
- clearActionError(transferId)
- try {
- await navigator.clipboard.writeText(shareUrl)
- setCopiedTransferId(transferId)
- window.setTimeout(() => setCopiedTransferId(undefined), 1800)
- } catch {
- setActionErrors((current) => ({
- ...current,
- [transferId]: 'Copy failed. Open the share page to copy the full link manually.',
- }))
- }
- }
-
- const handleExtend = async (transferId: string) => {
- clearActionError(transferId)
- try {
- await extendTransfer(transferId, MAX_EXPIRY_SECONDS)
- } catch (error) {
- setActionError(transferId, error)
- }
- }
-
const handleDelete = async (transferId: string) => {
clearActionError(transferId)
try {
@@ -187,21 +158,11 @@ export function HistoryBoard() {
) : null}
- {canManageTransfer
- ? 'Confirm delete to remove this transfer from this device.'
- : 'Confirm forget to remove this local record from this device.'}
+ Confirm forget to remove this local record from this device.
) : null}
{transfer.localManagementCleared ? (
diff --git a/apps/web/src/features/receive/ReceiveTransfer.tsx b/apps/web/src/features/receive/ReceiveTransfer.tsx
index 30f677d..2676505 100644
--- a/apps/web/src/features/receive/ReceiveTransfer.tsx
+++ b/apps/web/src/features/receive/ReceiveTransfer.tsx
@@ -12,10 +12,10 @@ import {
} from '@/lib/crypto/envelope'
import { parseLinkKey } from '@/lib/crypto/urlKey'
import { createDecryptedReadableStream, decryptFileToBlob } from '@/lib/download/decrypt'
-import { buildZipFromStreams, writeZipStream } from '@/lib/download/zip'
+import { buildZipFromStreams } from '@/lib/download/zip'
import { formatBytes } from '@/lib/files/formatBytes'
import { safeDownloadName, sanitizePath } from '@/lib/files/paths'
-import { isAbortError, openSaveWritable, saveBlob, supportsStreamingSave } from '@/lib/files/save'
+import { saveBlob } from '@/lib/files/save'
import { formatLocalDateTime } from '@/lib/i18n/formatDateTime'
import { PRIVATE_ROBOTS, RECEIVE_PAGE_DESCRIPTION, RECEIVE_PAGE_TITLE } from '@/lib/seo/site'
import { usePageMetadata } from '@/lib/seo/usePageMetadata'
@@ -94,7 +94,7 @@ export function ReceiveTransfer({ transferId }: Props) {
})()
}, [fragmentKey, transferId])
- /** downloadFile streams to disk when possible and falls back to an in-memory Blob otherwise. */
+ /** downloadFile decrypts to a Blob and hands it to the browser download manager. */
const downloadFile = async (file: ManifestFileEntry) => {
if (!rootKey) {
return
@@ -115,34 +115,18 @@ export function ReceiveTransfer({ transferId }: Props) {
setDownloadProgress(Math.round((completedBytes / Math.max(totalBytes, 1)) * 100)),
}
- if (supportsStreamingSave()) {
- const writable = await openSaveWritable(filename, {
- mimeType: file.mimeType || 'application/octet-stream',
- })
- if (writable) {
- await createDecryptedReadableStream(downloadArgs).pipeTo(writable)
- } else {
- const blob = await decryptFileToBlob(downloadArgs)
- saveBlob(blob, filename)
- }
- } else {
- const blob = await decryptFileToBlob(downloadArgs)
- saveBlob(blob, filename)
- }
+ const blob = await decryptFileToBlob(downloadArgs)
+ saveBlob(blob, filename)
setDownloadProgress(100)
} catch (caughtError) {
- if (isAbortError(caughtError)) {
- setDownloadProgress(0)
- return
- }
setDownloadError(caughtError instanceof Error ? caughtError.message : 'Download failed.')
} finally {
setActiveDownload((current) => (current === file.fileId ? null : current))
}
}
- /** downloadAll streams ZIP creation to disk when possible and avoids per-file buffering. */
+ /** downloadAll creates one ZIP Blob and hands it to the browser download manager. */
const downloadAll = async () => {
if (!manifest || !rootKey) {
return
@@ -176,25 +160,11 @@ export function ReceiveTransfer({ transferId }: Props) {
},
}))
- if (supportsStreamingSave()) {
- const writable = await openSaveWritable(zipName, { mimeType: 'application/zip' })
- if (writable) {
- await writeZipStream(createZipEntries(), writable)
- } else {
- const zip = await buildZipFromStreams(createZipEntries())
- saveBlob(zip, zipName)
- }
- } else {
- const zip = await buildZipFromStreams(createZipEntries())
- saveBlob(zip, zipName)
- }
+ const zip = await buildZipFromStreams(createZipEntries())
+ saveBlob(zip, zipName)
setDownloadProgress(100)
} catch (caughtError) {
- if (isAbortError(caughtError)) {
- setDownloadProgress(0)
- return
- }
setDownloadError(caughtError instanceof Error ? caughtError.message : 'Download failed.')
} finally {
setActiveDownload((current) => (current === 'zip' ? null : current))
diff --git a/apps/web/src/features/share/ShareCard.test.tsx b/apps/web/src/features/share/ShareCard.test.tsx
index a9f5d52..1f4aaf6 100644
--- a/apps/web/src/features/share/ShareCard.test.tsx
+++ b/apps/web/src/features/share/ShareCard.test.tsx
@@ -45,10 +45,13 @@ function createTransfer(
}
describe('ShareCard', () => {
+ const extendTransferMock = vi.fn(async () => {})
+
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-20T10:00:00.000Z'))
toDataUrlMock.mockClear()
+ extendTransferMock.mockClear()
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(async () => {}),
@@ -66,6 +69,7 @@ describe('ShareCard', () => {
,
)
@@ -74,10 +78,15 @@ describe('ShareCard', () => {
await Promise.resolve()
})
- expect(screen.getByRole('heading', { name: /Share the full link/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /Share this transfer/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Copy link/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Share link/i })).toBeInTheDocument()
- expect(screen.getByDisplayValue('https://example.com/t/active-1#k=test')).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: /Set expiry to 1 week from now/i }),
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByDisplayValue('https://example.com/t/active-1#k=test'),
+ ).not.toBeInTheDocument()
expect(screen.getByAltText('Transfer QR code')).toBeInTheDocument()
expect(toDataUrlMock).toHaveBeenCalledTimes(1)
})
@@ -119,6 +128,7 @@ describe('ShareCard', () => {
,
)
@@ -153,6 +163,7 @@ describe('ShareCard', () => {
,
)
@@ -180,6 +191,7 @@ describe('ShareCard', () => {
,
)
@@ -200,7 +212,7 @@ describe('ShareCard', () => {
expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument()
})
- it('shows a manual-copy fallback when clipboard access fails', async () => {
+ it('shows a retry-focused fallback when clipboard access fails', async () => {
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(async () => {
@@ -213,6 +225,7 @@ describe('ShareCard', () => {
,
)
@@ -227,8 +240,12 @@ describe('ShareCard', () => {
await Promise.resolve()
})
- expect(screen.getByText(/Select the full link below to copy it manually/i)).toBeInTheDocument()
- expect(screen.getByDisplayValue('https://example.com/t/active-6#k=test')).toBeInTheDocument()
+ expect(
+ screen.getByText(/Use your browser share tools or try again on this device/i),
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByDisplayValue('https://example.com/t/active-6#k=test'),
+ ).not.toBeInTheDocument()
})
it('ignores canceled share-sheet requests without surfacing an error', async () => {
@@ -241,6 +258,7 @@ describe('ShareCard', () => {
,
)
@@ -302,7 +320,7 @@ describe('ShareCard', () => {
const { rerender } = render(
-
+ ,
)
@@ -320,7 +338,7 @@ describe('ShareCard', () => {
rerender(
-
+ ,
)
await act(async () => {
@@ -330,7 +348,7 @@ describe('ShareCard', () => {
rerender(
-
+ ,
)
await act(async () => {
@@ -341,7 +359,7 @@ describe('ShareCard', () => {
rerender(
-
+ ,
)
await act(async () => {
@@ -350,6 +368,70 @@ describe('ShareCard', () => {
expect(screen.getByText('The link will work once the upload finishes.')).toBeInTheDocument()
})
+ it('extends expiry from the share page and surfaces failures locally', async () => {
+ extendTransferMock.mockRejectedValueOnce(new Error('Extend failed'))
+
+ const transfer = createTransfer('active-8', 'Extend transfer', '2026-03-21T10:00:00.000Z')
+ const { rerender } = render(
+
+
+ ,
+ )
+
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
+
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ expect(extendTransferMock).toHaveBeenCalledWith('active-8', 7 * 24 * 60 * 60)
+ expect(screen.getByText('Extend failed')).toBeInTheDocument()
+
+ extendTransferMock.mockResolvedValueOnce(undefined)
+ rerender(
+
+
+ ,
+ )
+
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
+
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ expect(screen.queryByText('Extend failed')).not.toBeInTheDocument()
+ })
+
+ it('hides the extend action when local management is unavailable', async () => {
+ render(
+
+
+ ,
+ )
+
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ expect(
+ screen.queryByRole('button', { name: /Set expiry to 1 week from now/i }),
+ ).not.toBeInTheDocument()
+ })
+
it('keeps the QR placeholder when QR generation fails', async () => {
toDataUrlMock.mockRejectedValueOnce(new Error('QR failed'))
@@ -357,6 +439,7 @@ describe('ShareCard', () => {
,
)
diff --git a/apps/web/src/features/share/ShareCard.tsx b/apps/web/src/features/share/ShareCard.tsx
index b75f232..98ffe77 100644
--- a/apps/web/src/features/share/ShareCard.tsx
+++ b/apps/web/src/features/share/ShareCard.tsx
@@ -1,3 +1,4 @@
+import { MAX_EXPIRY_SECONDS, getExpiryOptionLabel } from '@xdrop/shared'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import QRCode from 'qrcode'
@@ -14,17 +15,25 @@ import { isExpiredTransfer } from '@/lib/transfers/expiry'
type Props = {
transfer: LocalTransferRecord | undefined
+ onExtendTransfer?: (transferId: string, expiresInSeconds: number) => Promise
}
/** ShareCard shows sender-side sharing controls and current upload state for one transfer. */
-export function ShareCard({ transfer }: Props) {
+export function ShareCard({ transfer, onExtendTransfer }: Props) {
const [now, setNow] = useState(() => Date.now())
const [qrState, setQrState] = useState<{ dataUrl?: string; shareUrl?: string }>({})
const [copyState, setCopyState] = useState<'idle' | 'copied'>('idle')
+ const [isExtending, setIsExtending] = useState(false)
const [actionError, setActionError] = useState()
const progress = transfer ? (transfer.uploadedBytes / Math.max(transfer.totalBytes, 1)) * 100 : 0
const expired = transfer ? isExpiredTransfer(transfer, now) : false
const canShareLink = Boolean(transfer?.shareUrl) && !expired && transfer?.status !== 'deleted'
+ const canExtendTransfer =
+ Boolean(transfer?.manageToken) &&
+ !expired &&
+ transfer?.status !== 'deleted' &&
+ transfer?.localManagementCleared !== true &&
+ onExtendTransfer !== undefined
const qrShareUrl = canShareLink ? transfer?.shareUrl : undefined
const qrDataUrl = qrShareUrl && qrState.shareUrl === qrShareUrl ? qrState.dataUrl : undefined
@@ -80,7 +89,7 @@ export function ShareCard({ transfer }: Props) {
window.setTimeout(() => setCopyState('idle'), 1800)
} catch {
setCopyState('idle')
- setActionError('Copy failed. Select the full link below to copy it manually.')
+ setActionError('Copy failed. Use your browser share tools or try again on this device.')
}
}
@@ -110,6 +119,24 @@ export function ShareCard({ transfer }: Props) {
await copyLink()
}
+ const extendExpiry = async () => {
+ if (!transfer || !canExtendTransfer || !onExtendTransfer) {
+ return
+ }
+
+ try {
+ setActionError(undefined)
+ setIsExtending(true)
+ await onExtendTransfer(transfer.id, MAX_EXPIRY_SECONDS)
+ } catch (error) {
+ setActionError(
+ error instanceof Error ? error.message : 'Could not update this transfer right now.',
+ )
+ } finally {
+ setIsExtending(false)
+ }
+ }
+
if (!transfer) {
return (
Share
-
Share the full link.
+
Share this transfer.
{transfer.displayName}
The server stores ciphertext and operational metadata, not plaintext file names, file
@@ -208,23 +235,17 @@ export function ShareCard({ transfer }: Props) {
+ {canExtendTransfer ? (
+
+ ) : null}
Manage transfers
{actionError}
@@ -232,13 +253,13 @@ export function ShareCard({ transfer }: Props) {
) : null}
{transfer.localManagementCleared ? (
- Privacy mode removed local transfer controls after upload. Keep the share link safe.
- Extend and delete are no longer available here.
+ Privacy mode removed local transfer controls after upload. Keep your saved share access
+ safe. Extend and delete are no longer available here.
) : null}
- Keep the full link, including the #k= part. Without it, the files cannot be
- decrypted.
+ Keep the original share details intact. If the decryption key is missing, the files cannot
+ be opened.
diff --git a/apps/web/src/lib/files/save.test.ts b/apps/web/src/lib/files/save.test.ts
new file mode 100644
index 0000000..d292dce
--- /dev/null
+++ b/apps/web/src/lib/files/save.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { saveBlob } from './save'
+
+describe('saveBlob', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('uses the browser download manager instead of a file picker', () => {
+ const createObjectURLSpy = vi
+ .spyOn(URL, 'createObjectURL')
+ .mockReturnValue('blob:https://example.com/download')
+ const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+ const setTimeoutSpy = vi.spyOn(window, 'setTimeout')
+ const clickSpy = vi.fn()
+ const anchor = document.createElement('a')
+ anchor.click = clickSpy
+ const originalCreateElement = document.createElement.bind(document)
+
+ const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
+ if (tagName.toLowerCase() === 'a') {
+ return anchor
+ }
+
+ return originalCreateElement(tagName)
+ })
+
+ const blob = new Blob(['hello'], { type: 'text/plain' })
+ saveBlob(blob, 'hello.txt')
+
+ expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
+ expect(anchor.download).toBe('hello.txt')
+ expect(anchor.href).toBe('blob:https://example.com/download')
+ expect(clickSpy).toHaveBeenCalledTimes(1)
+ expect(setTimeoutSpy).toHaveBeenCalledOnce()
+
+ const revokeCallback = setTimeoutSpy.mock.calls[0]?.[0]
+ expect(revokeCallback).toBeTypeOf('function')
+ ;(revokeCallback as () => void)()
+ expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:https://example.com/download')
+
+ createElementSpy.mockRestore()
+ })
+})
diff --git a/apps/web/src/lib/files/save.ts b/apps/web/src/lib/files/save.ts
index e16d051..4e01033 100644
--- a/apps/web/src/lib/files/save.ts
+++ b/apps/web/src/lib/files/save.ts
@@ -1,114 +1,16 @@
/**
- * Download helpers that use the File System Access API when available and anchor downloads otherwise.
+ * Download helpers that hand files to the browser's normal download manager.
*/
-type FilePickerAcceptTypeLike = {
- description?: string
- accept: Record
-}
-
-type FileSystemWriteChunk =
- | BufferSource
- | Blob
- | string
- | {
- type: 'write'
- position?: number
- data: BufferSource | Blob | string
- }
- | {
- type: 'seek'
- position: number
- }
- | {
- type: 'truncate'
- size: number
- }
-
-export type FileSystemWritableLike = WritableStream & {
- write: (data: FileSystemWriteChunk) => Promise
- close: () => Promise
- abort: (reason?: unknown) => Promise
-}
-
-type FileSystemFileHandleLike = {
- createWritable: (options?: { keepExistingData?: boolean }) => Promise
-}
-
-type WindowWithSavePicker = Window & {
- showSaveFilePicker?: (options?: {
- suggestedName?: string
- types?: FilePickerAcceptTypeLike[]
- excludeAcceptAllOption?: boolean
- }) => Promise
-}
-
-/** supportsStreamingSave detects whether the browser can stream directly to a picked file. */
-export function supportsStreamingSave() {
- return (
- typeof window !== 'undefined' &&
- navigator.webdriver !== true &&
- typeof (window as WindowWithSavePicker).showSaveFilePicker === 'function'
- )
-}
-
-/** openSaveWritable opens a writable file handle for streaming downloads when supported. */
-export async function openSaveWritable(suggestedName: string, options?: { mimeType?: string }) {
- const picker = (window as WindowWithSavePicker).showSaveFilePicker
- if (!picker) {
- return null
- }
-
- const type = buildPickerType(suggestedName, options?.mimeType)
- const handle = await picker(
- type
- ? {
- suggestedName,
- types: [type],
- }
- : { suggestedName },
- )
-
- return handle.createWritable({ keepExistingData: false })
-}
-
-/** saveBlob falls back to a standard browser download using an object URL. */
+/** saveBlob triggers a standard browser download using an object URL. */
export function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
+
anchor.href = url
anchor.download = filename
+ anchor.rel = 'noopener'
anchor.click()
- URL.revokeObjectURL(url)
-}
-
-/** isAbortError normalizes user-cancelled file picker errors for callers. */
-export function isAbortError(error: unknown) {
- return error instanceof DOMException && error.name === 'AbortError'
-}
-
-function buildPickerType(
- suggestedName: string,
- mimeType?: string,
-): FilePickerAcceptTypeLike | undefined {
- const extension = extensionFromName(suggestedName)
- if (!mimeType || !extension) {
- return undefined
- }
-
- return {
- description: mimeType,
- accept: {
- [mimeType]: [extension],
- },
- }
-}
-
-function extensionFromName(filename: string) {
- const dot = filename.lastIndexOf('.')
- if (dot <= 0) {
- return undefined
- }
-
- return filename.slice(dot)
+
+ window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
diff --git a/apps/web/src/lib/seo/site.ts b/apps/web/src/lib/seo/site.ts
index b9f98a4..53cf3f8 100644
--- a/apps/web/src/lib/seo/site.ts
+++ b/apps/web/src/lib/seo/site.ts
@@ -15,15 +15,15 @@ export const DEFAULT_SEO_DESCRIPTION = PROJECT_ONE_LINER
export const HISTORY_PAGE_TITLE = 'Manage Transfers on This Device | Xdrop'
export const HISTORY_PAGE_DESCRIPTION =
'Manage encrypted transfers stored in this browser on this device. There is no account or cross-device history.'
-export const SHARE_PAGE_TITLE = 'Share the Full Link | Xdrop'
+export const SHARE_PAGE_TITLE = 'Share This Transfer | Xdrop'
export const SHARE_PAGE_DESCRIPTION =
- 'Review upload status and copy the full share link for a transfer staged in this browser on this device.'
+ 'Review upload status and share a transfer staged in this browser on this device without exposing its address on the page.'
export const RECEIVE_PAGE_TITLE = 'Download and Decrypt in the Browser | Xdrop'
export const RECEIVE_PAGE_DESCRIPTION =
- 'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.'
+ 'Download files from this transfer and decrypt them in the browser. The decryption key stays in the browser and never reaches the server.'
export const NOT_FOUND_PAGE_TITLE = 'Page Not Found | Xdrop'
export const NOT_FOUND_PAGE_DESCRIPTION =
- 'The address does not map to a page in Xdrop. If this came from a shared transfer, ask for the full URL, including the #k=... decryption fragment.'
+ 'The address does not map to a page in Xdrop. If this came from a shared transfer, ask the sender to resend the complete share details.'
export const DEFAULT_OG_IMAGE_PATH = '/brand-lockup-horizontal.png'
export const DEFAULT_OG_IMAGE_ALT = 'Xdrop horizontal brand lockup'
export const DEFAULT_LOGO_PATH = '/brand-symbol-512.png'
diff --git a/skills/xdrop/SKILL.md b/skills/xdrop/SKILL.md
index a89dcd1..67879eb 100644
--- a/skills/xdrop/SKILL.md
+++ b/skills/xdrop/SKILL.md
@@ -20,8 +20,6 @@ Environment requirements:
## Upload
-Run from the skill root:
-
```bash
bun scripts/upload.mjs --server [...]
```
@@ -49,8 +47,6 @@ If the user wants verification, upload a small temporary file and then confirm t
Require the full share link, including `#k=...`. Without the fragment key, the transfer cannot be decrypted.
-Run from the skill root:
-
```bash
bun scripts/download.mjs ""
```
diff --git a/tests/e2e/xdrop.spec.ts b/tests/e2e/xdrop.spec.ts
index 1ec7916..5424b96 100644
--- a/tests/e2e/xdrop.spec.ts
+++ b/tests/e2e/xdrop.spec.ts
@@ -212,7 +212,7 @@ async function createSingleFileTransfer(
})
await page.getByRole('button', { name: 'Start transfer' }).click()
await expect(page).toHaveURL(/\/share\//, { timeout: 60_000 })
- await expect(page.getByRole('heading', { name: 'Share the full link.' })).toBeVisible({
+ await expect(page.getByRole('heading', { name: 'Share this transfer.' })).toBeVisible({
timeout: 60_000,
})
await expect(page.locator('.status-badge').filter({ hasText: 'Ready' })).toBeVisible({
@@ -254,7 +254,7 @@ async function createFolderTransfer(
}, options.files)
await page.getByRole('button', { name: 'Start transfer' }).click()
await expect(page).toHaveURL(/\/share\//, { timeout: 60_000 })
- await expect(page.getByRole('heading', { name: 'Share the full link.' })).toBeVisible({
+ await expect(page.getByRole('heading', { name: 'Share this transfer.' })).toBeVisible({
timeout: 60_000,
})
await expect(page.locator('.status-badge').filter({ hasText: 'Ready' })).toBeVisible({