Skip to content
Open
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
103 changes: 41 additions & 62 deletions web-app/package-lock.json

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

4 changes: 1 addition & 3 deletions web-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@
"clsx": "^2.1.1",
"native-file-system-adapter": "^3.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"streamsaver": "^2.0.6"
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.0",
Expand All @@ -31,7 +30,6 @@
"@rollup/plugin-inject": "^5.0.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/streamsaver": "^2.0.5",
"@types/wicg-file-system-access": "^2023.10.5",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/ui": "^3.0.7",
Expand Down
19 changes: 3 additions & 16 deletions web-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,11 @@
import { clsx } from 'clsx';
import { useState, useEffect, type ChangeEvent } from 'react';
import streamsaver from 'streamsaver';
import { showSaveFilePicker } from 'native-file-system-adapter';
import './App.css';
import { type Chunker, type Source, OpenTDF } from '@opentdf/sdk';
import { type SessionInformation, OidcClient } from './session.js';
import { c } from './config.js';

async function toFile(
stream: ReadableStream<Uint8Array>,
filepath = 'download.tdf',
options?: StreamPipeOptions
): Promise<void> {
const fileStream = streamsaver.createWriteStream(filepath, {
writableStrategy: { highWaterMark: 1 },
readableStrategy: { highWaterMark: 1 },
});

return stream.pipeTo(fileStream, options);
}
import { downloadReadableStream } from './utils/download-readable-stream';

function decryptedFileName(encryptedFileName: string): string {
// Groups: 1 file 'name' bit
Expand Down Expand Up @@ -393,7 +380,7 @@ function App() {
try {
switch (sinkType) {
case 'file':
await toFile(cipherTextWithProgress, downloadName, { signal: sc.signal });
await downloadReadableStream(cipherTextWithProgress, downloadName, { signal: sc.signal });
break;
case 'fsapi':
if (!f) {
Expand Down Expand Up @@ -468,7 +455,7 @@ function App() {
.pipeThrough(progressTransformers.writer);
switch (sinkType) {
case 'file':
await toFile(plainTextStream, dfn, { signal: sc.signal });
await downloadReadableStream(plainTextStream, dfn, { signal: sc.signal });
break;
case 'fsapi':
if (!f) {
Expand Down
115 changes: 115 additions & 0 deletions web-app/src/utils/download-readable-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Downloads a ReadableStream as a file using a service worker stream (streamsaver-like) or a Blob fallback.
* Efficient for large files and supports aborting via AbortSignal in options.
*/
export async function downloadReadableStream(
stream: ReadableStream<Uint8Array>,
filename = 'download.tdf',
options?: { signal?: AbortSignal }
): Promise<void> {
if (typeof navigator !== 'undefined' && typeof navigator?.serviceWorker !== 'undefined') {
const swUrl = '/sw.js';
try {
await navigator.serviceWorker.register(swUrl, { scope: '/' });
await navigator.serviceWorker.ready;
} catch (e) {
console.log('Downloading service worker registration failed:', e);
return fallbackDownload(stream, filename, options);
}

const channel = new MessageChannel();
const downloadId = Math.random().toString(36).slice(2);
navigator.serviceWorker.controller?.postMessage(
{
downloadId,
filename,
port: channel.port2,
},
[channel.port2]
);

// Pipe the stream to the service worker via the channel
if (typeof stream.pipeTo === 'function') {
const writer = channel.port1;
const reader = stream.getReader();
function readAndSend() {
reader.read().then(({ done, value }) => {
if (done) {
writer.postMessage({ done: true });
writer.close();
return;
}
writer.postMessage({ chunk: value });
readAndSend();
});
}
readAndSend();
}

// Hidden iframe triggers the browser download event
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = `/stream-saver-download?downloadId=${downloadId}`;
document.body.appendChild(iframe);
setTimeout(() => {
document.body.removeChild(iframe);
}, 2000); // Clean up after 2 seconds
} else {
fallbackDownload(stream, filename, options);
}
}

// Fallback: collect stream into a Blob and trigger download via anchor
async function fallbackDownload(
stream: ReadableStream<Uint8Array>,
filename: string,
options?: { signal?: AbortSignal }
) {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let done = false;
let aborted = false;
const signal = options?.signal;
let abortHandler: (() => void) | undefined;

if (signal) {
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
abortHandler = () => {
aborted = true;
reader.cancel();
};
signal.addEventListener('abort', abortHandler);
}
try {
while (!done) {
if (aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const { value, done: streamDone } = await reader.read();
if (value) {
chunks.push(value);
}
done = streamDone;
}
} finally {
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
}
if (aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const blob = new Blob(chunks);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
}
Loading
Loading