From 589a1aece8034b15d5364e4b1411839092a97002 Mon Sep 17 00:00:00 2001 From: Stanislaw Adaszewski Date: Mon, 17 Feb 2020 12:17:24 +0100 Subject: [PATCH] Attempt to download inline but doesn't seem to work. --- frontend/rollup.config.js | 2 +- frontend/src/js/page/wb-app.js | 2 +- frontend/src/js/page/wb-download-page.js | 18 +- frontend/src/js/thirdparty/StreamSaver.js | 296 ++++++++++++++++++++++ 4 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 frontend/src/js/thirdparty/StreamSaver.js diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js index 1ac0c99..a0a4200 100755 --- a/frontend/rollup.config.js +++ b/frontend/rollup.config.js @@ -49,7 +49,7 @@ export default { 'node_modules/js-yaml/dist/js-yaml.min.js': 'dist/js/js-yaml.min.js', 'node_modules/streamsaver/mitm.html': 'dist/mitm.html', 'node_modules/streamsaver/sw.js': 'dist/sw.js', - 'node_modules/streamsaver/StreamSaver.js': 'dist/js/StreamSaver.js', + 'src/js/thirdparty/StreamSaver.js': 'dist/js/StreamSaver.js', 'node_modules/web-streams-polyfill/dist/ponyfill.js': 'dist/js/web-streams-polyfill/ponyfill.js', verbose: true }), diff --git a/frontend/src/js/page/wb-app.js b/frontend/src/js/page/wb-app.js index 3abdcd8..a086525 100644 --- a/frontend/src/js/page/wb-app.js +++ b/frontend/src/js/page/wb-app.js @@ -93,7 +93,7 @@ class WBApp extends Component { - + ); } diff --git a/frontend/src/js/page/wb-download-page.js b/frontend/src/js/page/wb-download-page.js index 8660d64..4b1e748 100644 --- a/frontend/src/js/page/wb-download-page.js +++ b/frontend/src/js/page/wb-download-page.js @@ -1,9 +1,21 @@ import { h, Component } from 'preact'; import makeArvadosRequest from 'make-arvados-request'; +function contentTypeFromFilename(name) { + let ext = name.split('.'); + ext = ext[ext.length - 1].toUpperCase(); + if (ext === 'TXT') + return 'text/plain; charset=utf-8'; + if (ext === 'JPG' || ext === 'JPEG') + return 'image/jpeg'; + if (ext === 'PNG') + return 'image/png'; + return 'application/octet-stream; charset=utf-8'; +} + class WBDownloadPage extends Component { componentDidMount() { - const { app, blocksBlobUrl } = this.props; + const { app, blocksBlobUrl, inline } = this.props; const { arvHost, arvToken } = app.state; let prom = new Promise((accept, reject) => { @@ -39,7 +51,9 @@ class WBDownloadPage extends Component { [ _, _, name, file ] = JSON.parse(text); fileStream = streamSaver.createWriteStream(name, { - size: file[1] + size: file[1], + inline: inline, + contentType: contentTypeFromFilename(name) }); writer = fileStream.getWriter(); diff --git a/frontend/src/js/thirdparty/StreamSaver.js b/frontend/src/js/thirdparty/StreamSaver.js new file mode 100644 index 0000000..0a596be --- /dev/null +++ b/frontend/src/js/thirdparty/StreamSaver.js @@ -0,0 +1,296 @@ +/* global chrome location ReadableStream define MessageChannel TransformStream */ + +;((name, definition) => { + typeof module !== 'undefined' + ? module.exports = definition() + : typeof define === 'function' && typeof define.amd === 'object' + ? define(definition) + : this[name] = definition() +})('streamSaver', () => { + 'use strict' + + let mitmTransporter = null + let supportsTransferable = false + const test = fn => { try { fn() } catch (e) {} } + const ponyfill = window.WebStreamsPolyfill || {} + const isSecureContext = window.isSecureContext + let useBlobFallback = /constructor/i.test(window.HTMLElement) || !!window.safari + const downloadStrategy = isSecureContext || 'MozAppearance' in document.documentElement.style + ? 'iframe' + : 'navigate' + + const streamSaver = { + createWriteStream, + WritableStream: window.WritableStream || ponyfill.WritableStream, + supported: true, + version: { full: '2.0.0', major: 2, minor: 0, dot: 0 }, + mitm: 'https://jimmywarting.github.io/StreamSaver.js/mitm.html?version=2.0.0' + } + + /** + * create a hidden iframe and append it to the DOM (body) + * + * @param {string} src page to load + * @return {HTMLIFrameElement} page to load + */ + function makeIframe (src) { + if (!src) throw new Error('meh') + const iframe = document.createElement('iframe') + iframe.hidden = true + iframe.src = src + iframe.loaded = false + iframe.name = 'iframe' + iframe.isIframe = true + iframe.postMessage = (...args) => iframe.contentWindow.postMessage(...args) + iframe.addEventListener('load', () => { + iframe.loaded = true + }, { once: true }) + document.body.appendChild(iframe) + return iframe + } + + /** + * create a popup that simulates the basic things + * of what a iframe can do + * + * @param {string} src page to load + * @return {object} iframe like object + */ + function makePopup (src) { + const options = 'width=200,height=100' + const delegate = document.createDocumentFragment() + const popup = { + frame: window.open(src, 'popup', options), + loaded: false, + isIframe: false, + isPopup: true, + remove () { popup.frame.close() }, + addEventListener (...args) { delegate.addEventListener(...args) }, + dispatchEvent (...args) { delegate.dispatchEvent(...args) }, + removeEventListener (...args) { delegate.removeEventListener(...args) }, + postMessage (...args) { popup.frame.postMessage(...args) } + } + + const onReady = evt => { + if (evt.source === popup.frame) { + popup.loaded = true + window.removeEventListener('message', onReady) + popup.dispatchEvent(new Event('load')) + } + } + + window.addEventListener('message', onReady) + + return popup + } + + try { + // We can't look for service worker since it may still work on http + new Response(new ReadableStream()) + if (isSecureContext && !('serviceWorker' in navigator)) { + useBlobFallback = true + } + } catch (err) { + useBlobFallback = true + } + + test(() => { + // Transfariable stream was first enabled in chrome v73 behind a flag + const { readable } = new TransformStream() + const mc = new MessageChannel() + mc.port1.postMessage(readable, [readable]) + mc.port1.close() + mc.port2.close() + supportsTransferable = true + // Freeze TransformStream object (can only work with native) + Object.defineProperty(streamSaver, 'TransformStream', { + configurable: false, + writable: false, + value: TransformStream + }) + }) + + function loadTransporter () { + if (!mitmTransporter) { + mitmTransporter = isSecureContext + ? makeIframe(streamSaver.mitm) + : makePopup(streamSaver.mitm) + } + } + + /** + * @param {string} filename filename that should be used + * @param {object} options [description] + * @param {number} size depricated + * @return {WritableStream} + */ + function createWriteStream (filename, options, size) { + let opts = { + size: null, + pathname: null, + writableStrategy: undefined, + readableStrategy: undefined + } + + // normalize arguments + if (Number.isFinite(options)) { + [ size, options ] = [ options, size ] + console.warn('[StreamSaver] Depricated pass an object as 2nd argument when creating a write stream') + opts.size = size + opts.writableStrategy = options + } else if (options && options.highWaterMark) { + console.warn('[StreamSaver] Depricated pass an object as 2nd argument when creating a write stream') + opts.size = size + opts.writableStrategy = options + } else { + opts = options || {} + } + if (!useBlobFallback) { + loadTransporter() + + var bytesWritten = 0 // by StreamSaver.js (not the service worker) + var downloadUrl = null + var channel = new MessageChannel() + + // Make filename RFC5987 compatible + filename = encodeURIComponent(filename.replace(/\//g, ':')) + .replace(/['()]/g, escape) + .replace(/\*/g, '%2A') + + const response = { + transferringReadable: supportsTransferable, + pathname: opts.pathname || Math.random().toString().slice(-6) + '/' + filename, + headers: { + 'Content-Type': (options.contentType ? options.contentType : + 'application/octet-stream; charset=utf-8'), + 'Content-Disposition': (options.inline ? 'inline' : + ("attachment; filename*=UTF-8''" + filename)) + } + } + + if (opts.size) { + response.headers['Content-Length'] = opts.size + } + + const args = [ response, '*', [ channel.port2 ] ] + + if (supportsTransferable) { + const transformer = downloadStrategy === 'iframe' ? undefined : { + // This transformer & flush method is only used by insecure context. + transform (chunk, controller) { + bytesWritten += chunk.length + controller.enqueue(chunk) + + if (downloadUrl) { + location.href = downloadUrl + downloadUrl = null + } + }, + flush () { + if (downloadUrl) { + location.href = downloadUrl + } + } + } + var ts = new streamSaver.TransformStream( + transformer, + opts.writableStrategy, + opts.readableStrategy + ) + const readableStream = ts.readable + + channel.port1.postMessage({ readableStream }, [ readableStream ]) + } + + channel.port1.onmessage = evt => { + // Service worker sent us a link that we should open. + if (evt.data.download) { + // Special treatment for popup... + if (downloadStrategy === 'navigate') { + mitmTransporter.remove() + mitmTransporter = null + if (bytesWritten) { + location.href = evt.data.download + } else { + downloadUrl = evt.data.download + } + } else { + if (mitmTransporter.isPopup) { + mitmTransporter.remove() + // Special case for firefox, they can keep sw alive with fetch + if (downloadStrategy === 'iframe') { + makeIframe(streamSaver.mitm) + } + } + + // We never remove this iframes b/c it can interrupt saving + makeIframe(evt.data.download) + } + } + } + + if (mitmTransporter.loaded) { + mitmTransporter.postMessage(...args) + } else { + mitmTransporter.addEventListener('load', () => { + mitmTransporter.postMessage(...args) + }, { once: true }) + } + } + + let chunks = [] + + return (!useBlobFallback && ts && ts.writable) || new streamSaver.WritableStream({ + write (chunk) { + if (useBlobFallback) { + // Safari... The new IE6 + // https://github.com/jimmywarting/StreamSaver.js/issues/69 + // + // even doe it has everything it fails to download anything + // that comes from the service worker..! + chunks.push(chunk) + return + } + + // is called when a new chunk of data is ready to be written + // to the underlying sink. It can return a promise to signal + // success or failure of the write operation. The stream + // implementation guarantees that this method will be called + // only after previous writes have succeeded, and never after + // close or abort is called. + + // TODO: Kind of important that service worker respond back when + // it has been written. Otherwise we can't handle backpressure + // EDIT: Transfarable streams solvs this... + channel.port1.postMessage(chunk) + bytesWritten += chunk.length + + if (downloadUrl) { + location.href = downloadUrl + downloadUrl = null + } + }, + close () { + if (useBlobFallback) { + const blob = new Blob(chunks, { type: 'application/octet-stream; charset=utf-8' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + link.click() + } else { + channel.port1.postMessage('end') + } + }, + abort () { + chunks = [] + channel.port1.postMessage('abort') + channel.port1.onmessage = null + channel.port1.close() + channel.port2.close() + channel = null + } + }, opts.writableStrategy) + } + + return streamSaver +})