diff --git a/e2e/transfer-performance.spec.js b/e2e/transfer-performance.spec.js
index a5d13b8..124c8e8 100644
--- a/e2e/transfer-performance.spec.js
+++ b/e2e/transfer-performance.spec.js
@@ -550,13 +550,23 @@ test('pairs two devices and establishes a real transfer performance baseline', a
expect(senderTransfers).toHaveLength(files.length);
expect(receiverTransfers).toHaveLength(files.length);
- await expect.poll(
- () => devices.receiver.downloadCount,
- {
- message: 'Receiver completion did not produce exactly one download per file.',
- timeout: 15_000
- }
- ).toBe(receiverDownloadsInitial + files.length);
+ await devices.receiver.page.waitForTimeout(250);
+ expect(devices.receiver.downloadCount).toBe(receiverDownloadsInitial);
+
+ const readyDownloads = devices.receiver.page.locator(
+ '#received-files-list .received-file-item.is-ready'
+ );
+ await expect(readyDownloads).toHaveCount(files.length);
+ await expect(readyDownloads.locator('.received-file-status'))
+ .toHaveText(Array(files.length).fill('Ready to download'));
+
+ let secondPendingHref = null;
+ if (files.length === 2) {
+ const pendingHrefs = await readyDownloads.locator('.received-file-download')
+ .evaluateAll((links) => links.map((link) => link.href));
+ expect(new Set(pendingHrefs).size).toBe(2);
+ secondPendingHref = pendingHrefs[1];
+ }
for (let index = 0; index < files.length; index += 1) {
const expectedSize = files[index].buffer.length;
@@ -605,6 +615,30 @@ test('pairs two devices and establishes a real transfer performance baseline', a
expect(receiverSnapshot.sequence).toBeGreaterThan(receiverTransfers[index - 1].sequence);
}
+ const receivedRow = devices.receiver.page.locator(
+ '#received-files-list .received-file-item.is-ready',
+ { hasText: files[index].name }
+ );
+ await expect(receivedRow).toHaveCount(1);
+ const downloadPromise = devices.receiver.page.waitForEvent('download');
+ await receivedRow.locator('.received-file-download').click();
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toBe(files[index].name);
+ await expect(devices.receiver.page.locator(
+ '#received-files-list .received-file-item.is-downloaded',
+ { hasText: files[index].name }
+ )).toContainText('Downloaded');
+ expect(devices.receiver.downloadCount).toBe(receiverDownloadsInitial + index + 1);
+
+ if (files.length === 2 && index === 0) {
+ const secondPendingDownload = devices.receiver.page.locator(
+ '#received-files-list .received-file-item.is-ready',
+ { hasText: files[1].name }
+ ).locator('.received-file-download');
+ await expect(secondPendingDownload).toBeVisible();
+ await expect(secondPendingDownload).toHaveAttribute('href', secondPendingHref);
+ }
+
reportIndex += 1;
await savePerformanceReport(
testInfo,
diff --git a/public/app.html b/public/app.html
index d04a04d..448fdd4 100644
--- a/public/app.html
+++ b/public/app.html
@@ -330,15 +330,27 @@
Transfer Completed!
0 MB
+
+
+
+
+
+
@@ -447,7 +459,11 @@ Transfer Completed!
btn_send_text: "Send Text",
history_title: "Session History",
transfer_completed: "Transfer Completed!",
- btn_download: "Download File",
+ received_files_title: "Received files",
+ ready_to_download: "Ready to download",
+ btn_download: "Download",
+ downloaded: "Downloaded",
+ download_pending_files: "Download pending files",
btn_transfer_another: "Transfer Another File",
queue_title: "Review files",
queue_waiting: "files ready",
@@ -595,7 +611,11 @@ Transfer Completed!
btn_send_text: "Enviar Texto",
history_title: "Historial de la Sesión",
transfer_completed: "¡Transferencia completada!",
- btn_download: "Descargar archivo",
+ received_files_title: "Archivos recibidos",
+ ready_to_download: "Listo para descargar",
+ btn_download: "Descargar",
+ downloaded: "Descargado",
+ download_pending_files: "Descargar archivos pendientes",
btn_transfer_another: "Transferir otro archivo",
queue_title: "Revisa los archivos",
queue_waiting: "archivos listos",
diff --git a/public/js/app.js b/public/js/app.js
index 5105397..159e2f2 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -150,38 +150,95 @@ function createPairingPrivacyFacade(helper) {
});
}
-function createReceivedBlobUrlLifecycle(downloadLink, urlApi) {
- let activeUrl = null;
-
- const clear = () => {
- if (!activeUrl) return false;
- const urlToRevoke = activeUrl;
- activeUrl = null;
- downloadLink.href = '#';
- downloadLink.removeAttribute('download');
+function createReceivedDownloadsManager(
+ urlApi,
+ scheduleRelease = globalThis.setTimeout,
+ releaseDelayMs = 1000
+) {
+ const downloads = new Map();
+ let nextId = 1;
+
+ const createSnapshot = (item) => Object.freeze({
+ id: item.id,
+ fileName: item.fileName,
+ size: item.size,
+ status: item.status,
+ url: item.url
+ });
+
+ const revokeUrl = (id) => {
+ const item = downloads.get(id);
+ if (!item || !item.url) return false;
+ const urlToRevoke = item.url;
+ item.url = null;
urlApi.revokeObjectURL(urlToRevoke);
return true;
};
+ const release = (id) => {
+ const revoked = revokeUrl(id);
+ downloads.delete(id);
+ return revoked;
+ };
+
const install = (blob, fileName) => {
- clear();
- const nextUrl = urlApi.createObjectURL(blob);
- activeUrl = nextUrl;
- downloadLink.href = nextUrl;
- downloadLink.setAttribute('download', fileName);
- return nextUrl;
+ const item = {
+ id: nextId,
+ fileName: String(fileName || ''),
+ size: Number(blob?.size || 0),
+ status: 'ready',
+ url: urlApi.createObjectURL(blob),
+ releaseScheduled: false
+ };
+ nextId += 1;
+ downloads.set(item.id, item);
+ return createSnapshot(item);
+ };
+
+ const startDownload = (id) => {
+ const item = downloads.get(id);
+ if (!item || item.status !== 'ready' || !item.url) return false;
+ item.status = 'downloaded';
+ if (!item.releaseScheduled) {
+ item.releaseScheduled = true;
+ scheduleRelease(() => revokeUrl(id), releaseDelayMs);
+ }
+ return true;
+ };
+
+ const clearAll = () => {
+ let released = 0;
+ for (const item of downloads.values()) {
+ if (item.url && revokeUrl(item.id)) released += 1;
+ }
+ downloads.clear();
+ return released;
};
+ const getItems = () => Object.freeze(
+ Array.from(downloads.values(), createSnapshot)
+ );
+
+ const getPendingItems = () => Object.freeze(
+ Array.from(downloads.values())
+ .filter((item) => item.status === 'ready' && Boolean(item.url))
+ .map(createSnapshot)
+ );
+
return Object.freeze({
- clear,
+ clearAll,
+ getItems,
+ getPendingItems,
install,
- getActiveUrl: () => activeUrl
+ release,
+ startDownload
});
}
-function clearReceivedBlobUrlOnPageHide(event, receivedBlobUrls) {
+function clearReceivedDownloadsOnPageHide(event, receivedDownloads) {
if (event.persisted === true) return false;
- return receivedBlobUrls.clear();
+ receivedDownloads.clearAll();
+ return true;
}
function isAutomaticReconnectAllowed(sessionState) {
@@ -317,9 +374,12 @@ document.addEventListener('DOMContentLoaded', () => {
const completedCard = document.getElementById('completed-card');
const completedFileName = document.getElementById('completed-file-name');
const completedFileSize = document.getElementById('completed-file-size');
- const btnDownload = document.getElementById('btn-download');
const btnResetTransfer = document.getElementById('btn-reset-transfer');
- const receivedBlobUrls = createReceivedBlobUrlLifecycle(btnDownload, URL);
+ const receivedFilesCard = document.getElementById('received-files-card');
+ const receivedFilesSummary = document.getElementById('received-files-summary');
+ const receivedFilesList = document.getElementById('received-files-list');
+ const btnReceiveAnother = document.getElementById('btn-receive-another');
+ const receivedDownloads = createReceivedDownloadsManager(URL);
// History Elements
const historyContainer = document.getElementById('history-container');
@@ -495,6 +555,87 @@ document.addEventListener('DOMContentLoaded', () => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
+ function renderReceivedFiles() {
+ const items = receivedDownloads.getItems();
+ const pendingItems = receivedDownloads.getPendingItems();
+ receivedFilesCard.classList.toggle('hidden', items.length === 0);
+ receivedFilesSummary.textContent = translate(
+ pendingItems.length > 0 ? 'download_pending_files' : 'downloaded'
+ );
+ receivedFilesList.replaceChildren();
+
+ items.forEach((item) => {
+ const row = document.createElement('div');
+ row.className = `received-file-item is-${item.status}`;
+ row.setAttribute('role', 'listitem');
+
+ const info = document.createElement('div');
+ info.className = 'received-file-info';
+
+ const name = document.createElement('span');
+ name.className = 'received-file-name';
+ name.textContent = item.fileName;
+ name.title = item.fileName;
+
+ const meta = document.createElement('div');
+ meta.className = 'received-file-meta';
+
+ const size = document.createElement('span');
+ size.textContent = formatBytes(item.size);
+
+ const separator = document.createElement('span');
+ separator.setAttribute('aria-hidden', 'true');
+ separator.textContent = '·';
+
+ const status = document.createElement('span');
+ status.className = 'received-file-status';
+ status.textContent = translate(item.status === 'ready' ? 'ready_to_download' : 'downloaded');
+
+ meta.appendChild(size);
+ meta.appendChild(separator);
+ meta.appendChild(status);
+ info.appendChild(name);
+ info.appendChild(meta);
+ row.appendChild(info);
+
+ if (item.status === 'ready' && item.url) {
+ const downloadLink = document.createElement('a');
+ downloadLink.className = 'btn btn-primary received-file-download';
+ downloadLink.href = item.url;
+ downloadLink.download = item.fileName;
+ downloadLink.textContent = translate('btn_download');
+ downloadLink.setAttribute('aria-label', `${translate('btn_download')}: ${item.fileName}`);
+ downloadLink.addEventListener('click', (event) => {
+ const fileCountBucket = receivedDownloads.getPendingItems().length === 1
+ ? 'one'
+ : 'multiple';
+ if (!receivedDownloads.startDownload(item.id)) {
+ event.preventDefault();
+ return;
+ }
+
+ downloadLink.setAttribute('aria-disabled', 'true');
+ status.textContent = translate('downloaded');
+ row.classList.remove('is-ready');
+ row.classList.add('is-downloaded');
+ trackFlowAnalytics('receiver_download_clicked', {
+ file_count_bucket: fileCountBucket
+ });
+ setTimeout(renderReceivedFiles, 0);
+ });
+ row.appendChild(downloadLink);
+ }
+
+ receivedFilesList.appendChild(row);
+ });
+ }
+
+ function clearReceivedDownloads() {
+ const released = receivedDownloads.clearAll();
+ renderReceivedFiles();
+ return released;
+ }
+
function switchView(viewName) {
if (viewName === 'setup') {
setupView.classList.remove('hidden');
@@ -1145,6 +1286,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!recovered) {
sessionRecoveryState = markManualActionDelivered(sessionRecoveryState);
webrtcManager.prepareForNewPairingSignals();
+ clearReceivedDownloads();
connectionEstablishedTracked = false;
trackFlowAnalytics('room_joined', {
role,
@@ -1231,6 +1373,7 @@ document.addEventListener('DOMContentLoaded', () => {
reconnectAttempts = clearedReconnect.attempts;
sessionRecoveryState = 'manual-reconnect';
p2pConnected = false;
+ clearReceivedDownloads();
switchView('setup');
};
@@ -1344,7 +1487,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
webrtcManager.onFileTransferStart = (fileName, totalBytes, isSending, options = {}) => {
- receivedBlobUrls.clear();
activeTransferMode = options.writeMode || 'send';
transferIsActive = true;
acquireTransferWakeLock();
@@ -1426,42 +1568,28 @@ document.addEventListener('DOMContentLoaded', () => {
updateOnboarding(4, { complete: true });
progressCard.classList.add('hidden');
networkDiagnostics.classList.add('hidden');
- if (sessionRecoveryState === 'signaling-disconnected' || sessionRecoveryState === 'recovering') {
- completedCard.classList.add('hidden');
- } else {
- completedCard.classList.remove('hidden');
- }
-
completedFileName.textContent = fileName;
if (options.savedToDisk) {
- receivedBlobUrls.clear();
+ completedCard.classList.toggle(
+ 'hidden',
+ sessionRecoveryState === 'signaling-disconnected' || sessionRecoveryState === 'recovering'
+ );
completedFileSize.textContent = translate('saved_to_disk');
- btnDownload.classList.add('hidden');
appendHistoryItem(fileName, translate('saved_to_disk'), 'received');
} else if (fileBlob) {
- // Received mode
const sizeStr = formatBytes(fileBlob.size);
- completedFileSize.textContent = sizeStr;
-
- receivedBlobUrls.install(fileBlob, fileName);
- btnDownload.classList.remove('hidden');
-
+ completedCard.classList.add('hidden');
+ receivedDownloads.install(fileBlob, fileName);
+ renderReceivedFiles();
appendHistoryItem(fileName, sizeStr, 'received');
-
- // Premium UX: Auto-trigger download
- try {
- btnDownload.click();
- } catch (err) {
- console.error('Auto download failed, user must click button manually', err);
- }
} else {
- // Sent mode
- receivedBlobUrls.clear();
const sizeStr = formatBytes(currentFileTransferSize);
+ completedCard.classList.toggle(
+ 'hidden',
+ sessionRecoveryState === 'signaling-disconnected' || sessionRecoveryState === 'recovering'
+ );
completedFileSize.textContent = translate('sent_success');
- btnDownload.classList.add('hidden');
-
appendHistoryItem(fileName, sizeStr, 'sent');
}
};
@@ -1493,7 +1621,6 @@ document.addEventListener('DOMContentLoaded', () => {
};
webrtcManager.onTransferError = (details = {}) => {
- receivedBlobUrls.clear();
activeTransferMode = 'idle';
transferIsActive = false;
releaseTransferWakeLock();
@@ -1514,7 +1641,6 @@ document.addEventListener('DOMContentLoaded', () => {
};
webrtcManager.onFileTransferCancelled = (fileName, isLocal) => {
- receivedBlobUrls.clear();
activeTransferMode = 'idle';
transferIsActive = false;
releaseTransferWakeLock();
@@ -1598,7 +1724,12 @@ document.addEventListener('DOMContentLoaded', () => {
completedCard.classList.add('hidden');
dropZone.classList.remove('hidden');
updateOnboarding(3);
- receivedBlobUrls.clear();
+ });
+
+ btnReceiveAnother.addEventListener('click', () => {
+ completedCard.classList.add('hidden');
+ dropZone.classList.remove('hidden');
+ updateOnboarding(3);
});
btnSendText.addEventListener('click', () => {
@@ -1679,7 +1810,7 @@ document.addEventListener('DOMContentLoaded', () => {
// --- APP RESET & CLEANUP ---
function resetApp({ preserveQueue = false } = {}) {
- receivedBlobUrls.clear();
+ clearReceivedDownloads();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
@@ -1773,13 +1904,16 @@ document.addEventListener('DOMContentLoaded', () => {
});
window.addEventListener('airdows:language-change', () => {
- renderQueue();
- updateConnectedStatus();
- updateOnboarding(onboardingStep, { complete: onboardingComplete });
+ queueMicrotask(() => {
+ renderQueue();
+ renderReceivedFiles();
+ updateConnectedStatus();
+ updateOnboarding(onboardingStep, { complete: onboardingComplete });
+ });
});
window.addEventListener('pagehide', (event) => {
- clearReceivedBlobUrlOnPageHide(event, receivedBlobUrls);
+ clearReceivedDownloadsOnPageHide(event, receivedDownloads);
});
document.addEventListener('visibilitychange', () => {
diff --git a/public/js/pairing-link-privacy.js b/public/js/pairing-link-privacy.js
index e219ce4..6dc213b 100644
--- a/public/js/pairing-link-privacy.js
+++ b/public/js/pairing-link-privacy.js
@@ -34,6 +34,7 @@
transfer_completed: ['direction', 'route', 'size_bucket', 'flow_version'],
transfer_failed: ['direction', 'route', 'failure_type', 'flow_version'],
transfer_cancelled: ['direction', 'initiated_by', 'flow_version'],
+ receiver_download_clicked: ['flow_version', 'file_count_bucket'],
route_selected: ['route']
});
diff --git a/public/style.css b/public/style.css
index 9fb92e4..1198613 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1537,6 +1537,102 @@ body {
font-size: 0.9rem;
}
+.received-files-card {
+ background: rgba(8, 17, 20, 0.74);
+ border: 1px solid rgba(56, 189, 248, 0.22);
+ border-radius: 18px;
+ margin-top: 18px;
+ padding: 22px;
+}
+
+.received-files-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.received-files-header h3 {
+ font-size: 1.05rem;
+ font-weight: 750;
+}
+
+.received-files-header p {
+ color: var(--text-secondary);
+ font-size: 0.8rem;
+ margin-top: 4px;
+}
+
+.received-files-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.received-file-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 14px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--border-light);
+ border-radius: 14px;
+ padding: 13px 14px;
+}
+
+.received-file-item.is-downloaded {
+ border-left: 3px solid var(--color-success);
+}
+
+.received-file-info {
+ min-width: 0;
+}
+
+.received-file-name {
+ color: var(--text-primary);
+ display: block;
+ font-size: 0.9rem;
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.received-file-meta {
+ color: var(--text-secondary);
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 0.76rem;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+.received-file-status {
+ color: #7dd3fc;
+ font-weight: 750;
+}
+
+.received-file-item.is-downloaded .received-file-status {
+ color: #6ee7b7;
+}
+
+.received-file-download {
+ min-height: 42px;
+ min-width: 116px;
+ padding: 9px 14px;
+}
+
+.received-file-download[aria-disabled="true"] {
+ opacity: 0.65;
+ pointer-events: none;
+}
+
+.received-files-card > .btn {
+ width: 100%;
+}
+
/* Toast Notifications */
.toast {
position: fixed;
@@ -1765,6 +1861,18 @@ body {
flex-direction: column;
}
+ .received-files-card {
+ padding: 17px;
+ }
+
+ .received-file-item {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .received-file-download {
+ width: 100%;
+ }
+
.diagnostic-grid {
grid-template-columns: 1fr;
}
diff --git a/test/pairing-link-privacy.test.js b/test/pairing-link-privacy.test.js
index 9770ac2..7d371c9 100644
--- a/test/pairing-link-privacy.test.js
+++ b/test/pairing-link-privacy.test.js
@@ -216,6 +216,19 @@ test('analytics properties exclude pairing and content identifiers', () => {
assert.deepEqual(properties, { role: 'receiver' });
assert.equal(JSON.stringify(properties).includes('1234'), false);
+
+ const downloadProperties = sanitizeAnalyticsProperties('receiver_download_clicked', {
+ flow_version: 'first-transfer-v2',
+ file_count_bucket: 'multiple',
+ fileName: 'private.pdf',
+ size: 987654,
+ mime: 'application/pdf',
+ blobUrl: 'blob:private'
+ });
+ assert.deepEqual(downloadProperties, {
+ flow_version: 'first-transfer-v2',
+ file_count_bucket: 'multiple'
+ });
});
test('every current analytics event preserves its safe properties', () => {
@@ -253,6 +266,9 @@ test('every current analytics event preserves its safe properties', () => {
},
transfer_cancelled: {
direction: 'receive', initiated_by: 'remote', flow_version: 'first-transfer-v2'
+ },
+ receiver_download_clicked: {
+ flow_version: 'first-transfer-v2', file_count_bucket: 'multiple'
}
};
diff --git a/test/received-blob-lifecycle.test.js b/test/received-blob-lifecycle.test.js
index 3044d26..72aaea3 100644
--- a/test/received-blob-lifecycle.test.js
+++ b/test/received-blob-lifecycle.test.js
@@ -7,23 +7,27 @@ const path = require('node:path');
const vm = require('node:vm');
const appPath = path.join(__dirname, '..', 'public', 'js', 'app.js');
+const webrtcPath = path.join(__dirname, '..', 'public', 'js', 'webrtc-manager.js');
+const htmlPath = path.join(__dirname, '..', 'public', 'app.html');
const appSource = fs.readFileSync(appPath, 'utf8');
+const webrtcSource = fs.readFileSync(webrtcPath, 'utf8');
+const htmlSource = fs.readFileSync(htmlPath, 'utf8');
-function loadLifecycleFactory() {
- const startAnchor = 'function createReceivedBlobUrlLifecycle';
+function loadManagerFactory() {
+ const startAnchor = 'function createReceivedDownloadsManager';
const endAnchor = '\nfunction isAutomaticReconnectAllowed';
const start = appSource.indexOf(startAnchor);
const end = appSource.indexOf(endAnchor, start);
assert.notEqual(start, -1, `Missing source anchor: ${startAnchor}`);
assert.notEqual(end, -1, `Missing source anchor: ${endAnchor}`);
- assert.ok(end > start, 'Received Blob lifecycle source anchors are out of order');
+ assert.ok(end > start, 'Received download manager source anchors are out of order');
const context = {};
vm.createContext(context);
vm.runInContext(
`${appSource.slice(start, end)}
-this.factory = createReceivedBlobUrlLifecycle;
-this.clearOnPageHide = clearReceivedBlobUrlOnPageHide;`,
+this.factory = createReceivedDownloadsManager;
+this.clearOnPageHide = clearReceivedDownloadsOnPageHide;`,
context
);
return {
@@ -32,21 +36,12 @@ this.clearOnPageHide = clearReceivedBlobUrlOnPageHide;`,
};
}
-const lifecycleHelpers = loadLifecycleFactory();
+const managerHelpers = loadManagerFactory();
function createHarness() {
const revoked = [];
const created = [];
- const link = {
- href: '#',
- attributes: new Map(),
- setAttribute(name, value) {
- this.attributes.set(name, value);
- },
- removeAttribute(name) {
- this.attributes.delete(name);
- }
- };
+ const scheduled = [];
const urlApi = {
createObjectURL(blob) {
const url = `blob:received-${created.length + 1}`;
@@ -57,127 +52,221 @@ function createHarness() {
revoked.push(url);
}
};
- const lifecycle = lifecycleHelpers.factory(link, urlApi);
- return { lifecycle, link, created, revoked };
+ const manager = managerHelpers.factory(urlApi, (callback, delay) => {
+ scheduled.push({ callback, delay });
+ });
+ const runScheduled = () => {
+ while (scheduled.length) scheduled.shift().callback();
+ };
+ return { manager, created, revoked, scheduled, runScheduled };
}
-test('two consecutive received files revoke the first URL before installing the second', () => {
- const { lifecycle, link, created, revoked } = createHarness();
- const firstUrl = lifecycle.install({ name: 'first' }, 'first.bin');
- const secondUrl = lifecycle.install({ name: 'second' }, 'second.bin');
+test('receiving a file does not start an automatic download', () => {
+ const { manager, created, revoked, scheduled } = createHarness();
- assert.deepEqual(revoked, [firstUrl]);
- assert.equal(created.length, 2);
- assert.equal(lifecycle.getActiveUrl(), secondUrl);
- assert.equal(link.href, secondUrl);
- assert.equal(link.attributes.get('download'), 'second.bin');
+ const item = manager.install({ size: 4 }, 'received.bin');
+
+ assert.equal(item.status, 'ready');
+ assert.equal(created.length, 1);
+ assert.deepEqual(revoked, []);
+ assert.equal(scheduled.length, 0);
+ assert.doesNotMatch(
+ appSource,
+ /btnDownload\.click\s*\(|downloadLink\.click\s*\(|receivedDownloads\.[\w]+\([^)]*\)\.click\s*\(/
+ );
});
-test('the newest received URL remains valid until explicit cleanup', () => {
- const { lifecycle, revoked } = createHarness();
- lifecycle.install({ name: 'first' }, 'first.bin');
- const newestUrl = lifecycle.install({ name: 'second' }, 'second.bin');
+test('one received file creates one downloadable Blob URL', () => {
+ const { manager, created } = createHarness();
+ const installed = manager.install({ size: 0 }, 'empty.bin');
+ const [pending] = manager.getPendingItems();
- assert.equal(revoked.includes(newestUrl), false);
- assert.equal(lifecycle.getActiveUrl(), newestUrl);
+ assert.equal(created.length, 1);
+ assert.equal(installed.url, 'blob:received-1');
+ assert.equal(installed.size, 0);
+ assert.deepEqual(pending, installed);
});
-test('application reset revokes the active received URL', () => {
- const { lifecycle, link, revoked } = createHarness();
- const url = lifecycle.install({}, 'reset.bin');
- lifecycle.clear();
+test('two files retain independent URLs in reception order', () => {
+ const { manager, created, revoked } = createHarness();
+ const first = manager.install({ size: 1 }, 'first.bin');
+ const second = manager.install({ size: 2 }, 'second.bin');
- assert.deepEqual(revoked, [url]);
- assert.equal(lifecycle.getActiveUrl(), null);
- assert.equal(link.href, '#');
- assert.match(appSource, /function resetApp\([^)]*\) \{\s*receivedBlobUrls\.clear\(\);/);
+ assert.equal(created.length, 2);
+ assert.notEqual(first.url, second.url);
+ assert.deepEqual(Array.from(manager.getItems(), (item) => item.fileName), ['first.bin', 'second.bin']);
+ assert.deepEqual(Array.from(manager.getPendingItems(), (item) => item.url), [first.url, second.url]);
+ assert.deepEqual(revoked, []);
});
-test('starting send mode revokes the prior received URL', () => {
- const { lifecycle, revoked } = createHarness();
- const url = lifecycle.install({}, 'received.bin');
- lifecycle.clear();
+test('installing a second file does not revoke the first URL', () => {
+ const { manager, revoked } = createHarness();
+ const first = manager.install({ size: 1 }, 'first.bin');
+ manager.install({ size: 2 }, 'second.bin');
- assert.deepEqual(revoked, [url]);
- assert.match(
- appSource,
- /webrtcManager\.onFileTransferStart = \([^)]*isSending[^)]*\) => \{\s*receivedBlobUrls\.clear\(\);/
- );
+ assert.equal(revoked.includes(first.url), false);
+ assert.equal(manager.getPendingItems()[0].url, first.url);
});
-test('repeated received URL cleanup is idempotent', () => {
- const { lifecycle, revoked } = createHarness();
- const url = lifecycle.install({}, 'once.bin');
+test('releasing one pending file removes only that file', () => {
+ const { manager, revoked } = createHarness();
+ const first = manager.install({ size: 1 }, 'first.bin');
+ const second = manager.install({ size: 2 }, 'second.bin');
- assert.equal(lifecycle.clear(), true);
- assert.equal(lifecycle.clear(), false);
- assert.equal(lifecycle.clear(), false);
- assert.deepEqual(revoked, [url]);
+ assert.equal(manager.release(first.id), true);
+ assert.deepEqual(revoked, [first.url]);
+ assert.deepEqual(Array.from(manager.getItems(), (item) => item.id), [second.id]);
+ assert.equal(manager.getPendingItems()[0].url, second.url);
});
-test('direct-to-disk completion cannot retain a previous Blob URL', () => {
- const { lifecycle, revoked } = createHarness();
- const url = lifecycle.install({}, 'memory.bin');
- lifecycle.clear();
+test('downloading one file revokes only its URL after the download starts', () => {
+ const { manager, revoked, scheduled, runScheduled } = createHarness();
+ const first = manager.install({ size: 1 }, 'first.bin');
+ const second = manager.install({ size: 2 }, 'second.bin');
- assert.deepEqual(revoked, [url]);
- assert.match(appSource, /if \(options\.savedToDisk\) \{\s*receivedBlobUrls\.clear\(\);/);
+ assert.equal(manager.startDownload(first.id), true);
+ assert.deepEqual(revoked, []);
+ assert.equal(scheduled.length, 1);
+ assert.equal(scheduled[0].delay, 1000);
+ assert.equal(manager.getItems()[0].status, 'downloaded');
+
+ runScheduled();
+
+ assert.deepEqual(revoked, [first.url]);
+ assert.equal(manager.getItems()[0].url, null);
+ assert.equal(manager.getPendingItems()[0].url, second.url);
});
-test('cancellation, failure, and manual reset use centralized cleanup', () => {
- assert.match(appSource, /webrtcManager\.onTransferError = \([^)]*\) => \{\s*receivedBlobUrls\.clear\(\);/);
- assert.match(appSource, /webrtcManager\.onFileTransferCancelled = \([^)]*\) => \{\s*receivedBlobUrls\.clear\(\);/);
- assert.match(appSource, /btnResetTransfer\.addEventListener\('click', \(\) => \{[\s\S]{0,250}receivedBlobUrls\.clear\(\);/);
+test('double click cannot start the same download twice', () => {
+ const { manager, revoked, scheduled, runScheduled } = createHarness();
+ const item = manager.install({ size: 3 }, 'once.bin');
+
+ assert.equal(manager.startDownload(item.id), true);
+ assert.equal(manager.startDownload(item.id), false);
+ assert.equal(scheduled.length, 1);
+ runScheduled();
+ assert.deepEqual(revoked, [item.url]);
});
-test('pagehide persisted in bfcache preserves the active received URL', () => {
- const { lifecycle, revoked, link } = createHarness();
- const url = lifecycle.install({}, 'received.bin');
+test('downloading the first file does not invalidate the second', () => {
+ const { manager, revoked, runScheduled } = createHarness();
+ const first = manager.install({ size: 1 }, 'first.bin');
+ const second = manager.install({ size: 2 }, 'second.bin');
- const cleared = lifecycleHelpers.clearOnPageHide({ persisted: true }, lifecycle);
+ manager.startDownload(first.id);
+ runScheduled();
- assert.equal(cleared, false);
- assert.deepEqual(revoked, []);
- assert.equal(lifecycle.getActiveUrl(), url);
- assert.equal(link.href, url);
+ assert.deepEqual(revoked, [first.url]);
+ assert.equal(manager.getPendingItems().length, 1);
+ assert.equal(manager.getPendingItems()[0].url, second.url);
+ assert.equal(manager.startDownload(second.id), true);
});
-test('pagehide outside bfcache revokes the active received URL', () => {
- const { lifecycle, revoked, link } = createHarness();
- const url = lifecycle.install({}, 'received.bin');
+test('pending item snapshots cannot mutate manager state', () => {
+ const { manager } = createHarness();
+ manager.install({ size: 5 }, 'immutable.bin');
+ const pending = manager.getPendingItems();
- const cleared = lifecycleHelpers.clearOnPageHide({ persisted: false }, lifecycle);
+ assert.equal(Object.isFrozen(pending), true);
+ assert.equal(Object.isFrozen(pending[0]), true);
+ assert.throws(() => pending.push({}), (error) => error?.name === 'TypeError');
+ assert.throws(
+ () => { pending[0].status = 'downloaded'; },
+ (error) => error?.name === 'TypeError'
+ );
+ assert.equal(manager.getPendingItems()[0].status, 'ready');
+});
- assert.equal(cleared, true);
- assert.deepEqual(revoked, [url]);
- assert.equal(lifecycle.getActiveUrl(), null);
- assert.equal(link.href, '#');
+test('full reset releases every received Blob URL', () => {
+ const { manager, revoked } = createHarness();
+ const first = manager.install({ size: 1 }, 'first.bin');
+ const second = manager.install({ size: 2 }, 'second.bin');
+
+ assert.equal(manager.clearAll(), 2);
+ assert.deepEqual(revoked, [first.url, second.url]);
+ assert.equal(manager.getItems().length, 0);
+ assert.match(appSource, /function resetApp\([^)]*\) \{\s*clearReceivedDownloads\(\);/);
+});
+
+test('non-recoverable disconnect reaches the full received-download reset', () => {
assert.match(
appSource,
- /window\.addEventListener\('pagehide', \(event\) => \{\s*clearReceivedBlobUrlOnPageHide\(event, receivedBlobUrls\);/
+ /socketManager\.onDisconnect = \([^)]*\) => \{[\s\S]{0,300}if \(recoverable && roomCode\)[\s\S]{0,160}resetApp\(\);/
);
+ assert.match(appSource, /function resetApp\([^)]*\) \{\s*clearReceivedDownloads\(\);/);
});
-test('pageshow after bfcache restoration keeps the download link usable', () => {
- const { lifecycle, revoked, link } = createHarness();
- const url = lifecycle.install({}, 'received.bin');
+test('normal pagehide releases all received URLs', () => {
+ const { manager, revoked } = createHarness();
+ const first = manager.install({ size: 1 }, 'first.bin');
+ const second = manager.install({ size: 2 }, 'second.bin');
+
+ assert.equal(managerHelpers.clearOnPageHide({ persisted: false }, manager), true);
+ assert.deepEqual(revoked, [first.url, second.url]);
+ assert.equal(manager.getItems().length, 0);
+ assert.match(
+ appSource,
+ /window\.addEventListener\('pagehide', \(event\) => \{\s*clearReceivedDownloadsOnPageHide\(event, receivedDownloads\);/
+ );
+});
- lifecycleHelpers.clearOnPageHide({ persisted: true }, lifecycle);
- const pageshowEvent = { persisted: true };
+test('bfcache pagehide preserves every received URL', () => {
+ const { manager, revoked } = createHarness();
+ const first = manager.install({ size: 1 }, 'first.bin');
+ const second = manager.install({ size: 2 }, 'second.bin');
- assert.equal(pageshowEvent.persisted, true);
+ assert.equal(managerHelpers.clearOnPageHide({ persisted: true }, manager), false);
assert.deepEqual(revoked, []);
- assert.equal(lifecycle.getActiveUrl(), url);
- assert.equal(link.href, url);
- assert.equal(link.attributes.get('download'), 'received.bin');
+ assert.deepEqual(
+ Array.from(manager.getPendingItems(), (item) => item.url),
+ [first.url, second.url]
+ );
+});
+
+test('direct-to-disk completion creates no Blob URL or download control', () => {
+ const completionStart = appSource.indexOf('webrtcManager.onFileTransferComplete');
+ const diskStart = appSource.indexOf('if (options.savedToDisk)', completionStart);
+ const memoryStart = appSource.indexOf('} else if (fileBlob)', diskStart);
+ const diskBranch = appSource.slice(diskStart, memoryStart);
+
+ assert.ok(completionStart >= 0 && diskStart > completionStart && memoryStart > diskStart);
+ assert.doesNotMatch(diskBranch, /createObjectURL|receivedDownloads\.install|btn_download/);
+ assert.match(diskBranch, /completedFileSize\.textContent = translate\('saved_to_disk'\)/);
+});
+
+test('delivery ACK remains before UI completion and independent from download clicks', () => {
+ const finalizeStart = webrtcSource.indexOf('async finalizeIncomingFile');
+ const finalizeEnd = webrtcSource.indexOf('\n async failIncomingTransfer', finalizeStart);
+ const finalizeSource = webrtcSource.slice(finalizeStart, finalizeEnd);
+ const ackIndex = finalizeSource.indexOf("type: 'transfer-ack'");
+ const completionIndex = finalizeSource.indexOf('this.onFileTransferComplete(fileBlob');
+
+ assert.ok(ackIndex >= 0, 'receiver finalization must emit transfer-ack');
+ assert.ok(completionIndex > ackIndex, 'ACK must precede the UI completion callback');
+ assert.doesNotMatch(finalizeSource, /startDownload|btnDownload|downloadLink/);
+ assert.doesNotMatch(appSource, /sendDeliveryControl\(\{\s*type: 'transfer-ack'/);
});
-test('cleanup after bfcache restoration remains idempotent', () => {
- const { lifecycle, revoked } = createHarness();
- const url = lifecycle.install({}, 'received.bin');
- lifecycleHelpers.clearOnPageHide({ persisted: true }, lifecycle);
+test('received-download interface includes complete English and Spanish copy', () => {
+ const requiredEnglish = [
+ 'Received files',
+ 'Ready to download',
+ 'Download',
+ 'Downloaded',
+ 'Download pending files',
+ 'Transfer Another File'
+ ];
+ const requiredSpanish = [
+ 'Archivos recibidos',
+ 'Listo para descargar',
+ 'Descargar',
+ 'Descargado',
+ 'Descargar archivos pendientes',
+ 'Transferir otro archivo'
+ ];
- assert.equal(lifecycle.clear(), true);
- assert.equal(lifecycle.clear(), false);
- assert.deepEqual(revoked, [url]);
+ for (const text of [...requiredEnglish, ...requiredSpanish]) {
+ assert.ok(htmlSource.includes(text), `Missing received-download translation: ${text}`);
+ }
+ assert.match(htmlSource, /id="received-files-list"[^>]*aria-live="polite"/);
});