ChatGPT解决这个技术问题 Extra ChatGPT

Detect when a browser receives a file download

I have a page that allows the user to download a dynamically-generated file. It takes a long time to generate, so I'd like to show a "waiting" indicator. The problem is, I can't figure out how to detect when the browser has received the file so that I can hide the indicator.

I'm requesting a hidden form, which POSTs to the server, and targets a hidden iframe for its results. This is, so I don't replace the entire browser window with the result. I listen for a "load" event on the iframe, hoping that it will fire when the download is complete.

I return a "Content-Disposition: attachment" header with the file, which causes the browser to show the "Save" dialog. But the browser doesn't fire a "load" event in the iframe.

One approach I tried is using a multi-part response. So it would send an empty HTML file, as well as the attached downloadable file.

For example:

Content-type: multipart/x-mixed-replace;boundary="abcde"

--abcde
Content-type: text/html

--abcde
Content-type: application/vnd.fdf
Content-Disposition: attachment; filename=foo.fdf

file-content
--abcde

This works in Firefox; it receives the empty HTML file, fires the "load" event, and then shows the "Save" dialog for the downloadable file. But it fails on Internet Explorer and Safari; Internet Explorer fires the "load" event, but it doesn't download the file, and Safari downloads the file (with the wrong name and content-type) and doesn't fire the "load" event.

A different approach might be to call to start the file creation, poll the server until it's ready, and then download the already-created file. But I'd rather avoid creating temporary files on the server.

What should I do?

No version of IE supports multipart/x-mixed-replace.
Thanks Eric -- that's good to know. I won't waste any more time with that approach.
Only reliable way seems to be server push notification (SignalR for ASP.NET folks).
bennadel.com/blog/… -- this is a simple solution
@mateen thanks dude! it really simple

D
David Passmore

One possible solution uses JavaScript on the client.

The client algorithm:

Generate a random unique token. Submit the download request, and include the token in a GET/POST field. Show the "waiting" indicator. Start a timer, and every second or so, look for a cookie named "fileDownloadToken" (or whatever you decide). If the cookie exists, and its value matches the token, hide the "waiting" indicator.

The server algorithm:

Look for the GET/POST field in the request. If it has a non-empty value, drop a cookie (e.g. "fileDownloadToken"), and set its value to the token's value.

Client source code (JavaScript):

function getCookie( name ) {
  var parts = document.cookie.split(name + "=");
  if (parts.length == 2) return parts.pop().split(";").shift();
}

function expireCookie( cName ) {
    document.cookie = 
        encodeURIComponent(cName) + "=deleted; expires=" + new Date( 0 ).toUTCString();
}

function setCursor( docStyle, buttonStyle ) {
    document.getElementById( "doc" ).style.cursor = docStyle;
    document.getElementById( "button-id" ).style.cursor = buttonStyle;
}

function setFormToken() {
    var downloadToken = new Date().getTime();
    document.getElementById( "downloadToken" ).value = downloadToken;
    return downloadToken;
}

var downloadTimer;
var attempts = 30;

// Prevents double-submits by waiting for a cookie from the server.
function blockResubmit() {
    var downloadToken = setFormToken();
    setCursor( "wait", "wait" );

    downloadTimer = window.setInterval( function() {
        var token = getCookie( "downloadToken" );

        if( (token == downloadToken) || (attempts == 0) ) {
            unblockSubmit();
        }

        attempts--;
    }, 1000 );
}

function unblockSubmit() {
  setCursor( "auto", "pointer" );
  window.clearInterval( downloadTimer );
  expireCookie( "downloadToken" );
  attempts = 30;
}

Example server code (PHP):

$TOKEN = "downloadToken";

// Sets a cookie so that when the download begins the browser can
// unblock the submit button (thus helping to prevent multiple clicks).
// The false parameter allows the cookie to be exposed to JavaScript.
$this->setCookieToken( $TOKEN, $_GET[ $TOKEN ], false );

$result = $this->sendFile();

Where:

public function setCookieToken(
    $cookieName, $cookieValue, $httpOnly = true, $secure = false ) {

    // See: http://stackoverflow.com/a/1459794/59087
    // See: http://shiflett.org/blog/2006/mar/server-name-versus-http-host
    // See: http://stackoverflow.com/a/3290474/59087
    setcookie(
        $cookieName,
        $cookieValue,
        2147483647,            // expires January 1, 2038
        "/",                   // your path
        $_SERVER["HTTP_HOST"], // your domain
        $secure,               // Use true over HTTPS
        $httpOnly              // Set true for $AUTH_COOKIE_NAME
    );
}

Awesome idea, I used it as a basic framework for this answer about downloading multiple files with jQuery/C#
A heads up for others: if document.cookies does not include the downloadToken, check the cookie path. In my case, I had to set the path to "/" on server side (e.g. cookie.setPath("/") in Java) even though path was blank by default. For some time I thought the issue was the special 'localhost' domain cookie handling (stackoverflow.com/questions/1134290/…) but that wasn't the issue in the end. May be that for others though so worth the read.
@bulltorious before going into more depth with your solution, I wonder if it will work with a cross domain file download request. Do you think that it will, or cookies restrictions will compromise it?
Brilliant - it wouldn't have occurred to me in 100 years that you could include cookies as part of a file download. Thank you!!
As others have pointed out, this solution only solves part of the problem, the waiting for the server to prepare the file time. The other part of the problem, which can be considerable depending on the size of the file and the connection speed, is how long it takes to actually get the whole file on the client. And that is not solved with this solution.
E
Echilon

A very simple (and lame) one line solution is to use the window.onblur() event to close the loading dialog. Of course, if it takes too long and the user decides to do something else (like reading emails) the loading dialog will close.


This is a simple approach which is ideal for getting rid of a loading overlay for a file download which was triggered using onbeforeunload Thank you.
This doesn't work in all browsers (some do not leave/blur the current window as part of the download workflow, e.g. Safari, some IE versions, etc).
Chrome and other such browsers auto-download the files where this condition will fail.
@Lucky that is only by default. It is entirely possible a user of Chrome will specify where downloads should be saved and hence see the dialog box
bad idea because you activate the blur on tabchange, or any action outside the window
P
Peter Mortensen

This solution is very simple, yet reliable. And it makes it possible to display real progress messages (and can be easily plugged in to existing processes):

The script that processes (my problem was: retrieving files via HTTP and deliver them as ZIP) writes the status to the session.

The status is polled and displayed every second. that’s all (OK, it’s not. You have to take care of a lot of details (for example, concurrent downloads), but it’s a good place to start ;-)).

The download page:

<a href="download.php?id=1" class="download">DOWNLOAD 1</a>
<a href="download.php?id=2" class="download">DOWNLOAD 2</a>

...

<div id="wait">
    Please wait...
    <div id="statusmessage"></div>
</div>

<script>

    // This is jQuery
    $('a.download').each(function()
    {
        $(this).click(
            function() {
                $('#statusmessage').html('prepare loading...');
                $('#wait').show();
                setTimeout('getstatus()', 1000);
            }
            );
        });
    });

    function getstatus() {
        $.ajax({
            url: "/getstatus.php",
            type: "POST",
            dataType: 'json',
            success: function(data) {
                $('#statusmessage').html(data.message);
                if(data.status == "pending")
                    setTimeout('getstatus()', 1000);
                else
                    $('#wait').hide();
                }
        });
    }
</script>

File getstatus.php

<?php
    session_start();
    echo json_encode($_SESSION['downloadstatus']);
?>

File download.php

<?php
    session_start();
    $processing = true;
    while($processing) {
        $_SESSION['downloadstatus'] = array("status" =>"pending", "message" => "Processing".$someinfo);
        session_write_close();
        $processing = do_what_has_2Bdone();
        session_start();
    }

    $_SESSION['downloadstatus'] = array("status" => "finished", "message" => "Done");
    // And spit the generated file to the browser
?>

but if the user has multiple windows or downloads open? also you get here a redundant call to the server
If you have multiple connections from one user they will be all waiting for other connections to end because session_start() locks session for user and prevents all other processes to access it.
you don't need to use .each() for event registrations. just say $('a.download').click()
Don't eval code inside setTimeout('getstatus()', 1000);. Use the fn directly: setTimeout(getstatus, 1000);
session_start(): Cannot start session when headers already sent
P
Peter Mortensen

The core problem is that the web browser does not have an event that fires when page navigation is cancelled but does have an event that fires when a page completes loading. Anything outside of a direct browser event is going to be a hack with pros and cons.

There are four known approaches to dealing with detecting when a browser download starts:

Call fetch(), retrieve the entire response, attach an a tag with a download attribute, and trigger a click event. Modern web browsers will then offer the user the option to save the already retrieved file. There are several downsides with this approach:

The entire data blob is stored in RAM, so if the file is large, it will consume that much RAM. For small files, this probably isn't a deal breaker.

The user has to wait for the entire file to download before they can save it. They also can't leave the page until it completes.

The built-in web browser file downloader is not used.

A cross-domain fetch will probably fail unless CORS headers are set.

Use an iframe + a server-side cookie. The iframe fires a load event if a page loads in the iframe instead of starting a download but it does not fire any events if the download starts. Setting a cookie with the web server can then be detected by JavaScript in a loop. There are several downsides with this approach:

The server and client have to work in concert. The server has to set a cookie. The client has to detect the cookie.

Cross-domain requests won't be able to set the cookie.

There are limits to how many cookies can be set per domain.

Can't send custom HTTP headers.

Use an iframe with URL redirection. The iframe starts a request and once the server has prepared the file, it dumps a HTML document that performs a meta refresh to a new URL, which triggers the download 1 second later. The load event on the iframe happens when the HTML document loads. There are several downsides with this approach:

The server has to maintain storage for the content being downloaded. Requires a cron job or similar to regularly clean up the directory.

The server has to dump out special HTML content when the file is ready.

The client has to guess as to when the iframe has actually made the second request to the server and when the download has actually started before removing the iframe from the DOM. This could be overcome by just leaving the iframe in the DOM.

Can't send custom HTTP headers.

Use an iframe + XHR. The iframe triggers the download request. As soon as the request is made via the iframe, an identical request via XHR is made. If the load event on the iframe fires, an error has occurred, abort the XHR request, and remove the iframe. If a XHR progress event fires, then downloading has probably started in the iframe, abort the XHR request, wait a few seconds, and then remove the iframe. This allows for larger files to be downloaded without relying on a server-side cookie. There are several downsides with this approach:

There are two separate requests made for the same information. The server can distinguish the XHR from the iframe by checking the incoming headers.

A cross-domain XHR request will probably fail unless CORS headers are set. However, the browser won't know if CORS is allowed or not until the server sends back the HTTP headers. If the server waits to send headers until the file data is ready, the XHR can roughly detect when the iframe has started to download even without CORS.

The client has to guess as to when the download has actually started to remove the iframe from the DOM. This could be overcome by just leaving the iframe in the DOM.

Can't send custom headers on the iframe.

Without an appropriate built-in web browser event, there aren't any perfect solutions here. However, one of the four methods above will likely be a better fit than the others depending on your use-case.

Whenever possible, stream responses to the client on the fly instead of generating everything first on the server and then sending the response. Various file formats can be streamed such as CSV, JSON, XML, ZIP, etc. It really depends on finding a library that supports streaming content. When streaming the response as soon as the request starts, detecting the start of the download won't matter as much because it will start almost right away.

Another option is to just output the download headers up front instead of waiting for all of the content to be generated first. Then generate the content and finally start sending to the client. The user's built-in downloader will patiently wait until the data starts arriving. The downside is that the underlying network connection could timeout waiting for data to start flowing (either on the client or server side).


excellent answer mate, thanks for listing all the downside of each solution clearly, very nicely put.
J
Jerzy Gebler

Based on Elmer's example, I've prepared my own solution. After clicking on an item with a defined "download" class, a custom message is displayed in the browser window. I used the focus trigger to hide the message. I've used the focus trigger to hide the message.

JavaScript

$(function(){$('.download').click(function() { ShowDownloadMessage(); }); })

function ShowDownloadMessage()
{
     $('#message-text').text('Your report is creating. Please wait...');
     $('#message').show();
     window.addEventListener('focus', HideDownloadMessage, false);
}

function HideDownloadMessage(){
    window.removeEventListener('focus', HideDownloadMessage, false);                   
    $('#message').hide();
}

HTML

<div id="message" style="display: none">
    <div id="message-screen-mask" class="ui-widget-overlay ui-front"></div>
    <div id="message-text" class="ui-dialog ui-widget ui-widget-content ui-corner-all ui-front ui-draggable ui-resizable waitmessage">please wait...</div>
</div>

Now you should implement any element to download:

<a class="download" href="file://www.ocelot.com.pl/prepare-report">Download report</a>

or

<input class="download" type="submit" value="Download" name="actionType">

After each download click you will see the message: Your report is creating. Please wait...


What if the user clicks the window?
this is exactly what I was looking for, thaks a lot!!
The hide() is not getting called in my case
My case is working on JSP and click to download csv. It works. Thanks.
The hide() part is not working in my case if I trigger the click event from JS code.
P
Peter Mortensen

I use the following to download blobs and revoke the object URL after the download. It works in Chrome and Firefox!

function download(blob){
    var url = URL.createObjectURL(blob);
    console.log('create ' + url);

    window.addEventListener('focus', window_focus, false);
    function window_focus(){
        window.removeEventListener('focus', window_focus, false);
        URL.revokeObjectURL(url);
        console.log('revoke ' + url);
    }
    location.href = url;
}

After the file download dialog is closed, the window gets its focus back, so the focus event is triggered.


Still has the issue of switching window and returning which will cause the modal to hide.
Browsers like Chrome that download into the bottom tray never blur/refocus the window.
A
Art Geigel

If you're streaming a file that you're generating dynamically, and also have a realtime server-to-client messaging library implemented, you can alert your client pretty easily.

The server-to-client messaging library I like and recommend is Socket.io (via Node.js). After your server script is done generating the file that is being streamed for download your last line in that script can emit a message to Socket.io which sends a notification to the client. On the client, Socket.io listens for incoming messages emitted from the server and allows you to act on them. The benefit of using this method over others is that you are able to detect a "true" finish event after the streaming is done.

For example, you could show your busy indicator after a download link is clicked, stream your file, emit a message to Socket.io from the server in the last line of your streaming script, listen on the client for a notification, receive the notification and update your UI by hiding the busy indicator.

I realize most people reading answers to this question might not have this type of a setup, but I've used this exact solution to great effect in my own projects and it works wonderfully.

Socket.io is incredibly easy to install and use. See more: http://socket.io/


P
Peter Mortensen

I wrote a simple JavaScript class that implements a technique similar to the one described in bulltorious' answer. I hope it can be useful to someone here.

The GitHub project is called response-monitor.js.

By default it uses spin.js as the waiting indicator, but it also provides a set of callbacks for implementation of a custom indicator.

jQuery is supported, but not required.

Notable features

Simple integration

No dependencies

jQuery plug-in (optional)

Spin.js Integration (optional)

Configurable callbacks for monitoring events

Handles multiple simultaneous requests

Server-side error detection

Timeout detection

Cross browser

Example usage

HTML

<!-- The response monitor implementation -->
<script src="response-monitor.js"></script>

<!-- Optional jQuery plug-in -->
<script src="response-monitor.jquery.js"></script>

<a class="my_anchors" href="/report?criteria1=a&criteria2=b#30">Link 1 (Timeout: 30s)</a>
<a class="my_anchors" href="/report?criteria1=b&criteria2=d#10">Link 2 (Timeout: 10s)</a>

<form id="my_form" method="POST">
    <input type="text" name="criteria1">
    <input type="text" name="criteria2">
    <input type="submit" value="Download Report">
</form>

Client (plain JavaScript)

// Registering multiple anchors at once
var my_anchors = document.getElementsByClassName('my_anchors');
ResponseMonitor.register(my_anchors); // Clicking on the links initiates monitoring

// Registering a single form
var my_form = document.getElementById('my_form');
ResponseMonitor.register(my_form); // The submit event will be intercepted and monitored

Client (jQuery)

$('.my_anchors').ResponseMonitor();
$('#my_form').ResponseMonitor({timeout: 20});

Client with callbacks (jQuery)

// When options are defined, the default spin.js integration is bypassed
var options = {
    onRequest: function(token) {
        $('#cookie').html(token);
        $('#outcome').html('');
        $('#duration').html('');
    },
    onMonitor: function(countdown) {
        $('#duration').html(countdown);
    },
    onResponse: function(status) {
        $('#outcome').html(status==1 ? 'success' : 'failure');
    },
    onTimeout: function() {
        $('#outcome').html('timeout');
    }
};

// Monitor all anchors in the document
$('a').ResponseMonitor(options);

Server (PHP)

$cookiePrefix = 'response-monitor'; // Must match the one set on the client options
$tokenValue = $_GET[$cookiePrefix];
$cookieName = $cookiePrefix.'_'.$tokenValue; // Example: response-monitor_1419642741528

// This value is passed to the client through the ResponseMonitor.onResponse callback
$cookieValue = 1; // For example, "1" can interpret as success and "0" as failure

setcookie(
    $cookieName,
    $cookieValue,
    time() + 300,          // Expire in 5 minutes
    "/",
    $_SERVER["HTTP_HOST"],
    true,
    false
);

header('Content-Type: text/plain');
header("Content-Disposition: attachment; filename=\"Response.txt\"");

sleep(5); // Simulate whatever delays the response
print_r($_REQUEST); // Dump the request in the text file

For more examples, check the examples folder in the repository.


P
Peter Mortensen

I faced the same problem with that configuration:

Struts 1.2.9

jQuery 1.3.2.

jQuery UI 1.7.1.custom

Internet Explorer 11

Java 5

My solution with a cookie:

Client side:

When submitting your form, call your JavaScript function to hide your page and load your waiting spinner

function loadWaitingSpinner() {
    ... hide your page and show your spinner ...
}

Then, call a function that will check every 500 ms whether a cookie is coming from server.

function checkCookie() {
    var verif = setInterval(isWaitingCookie, 500, verif);
}

If the cookie is found, stop checking every 500 ms, expire the cookie and call your function to come back to your page and remove the waiting spinner (removeWaitingSpinner()). It is important to expire the cookie if you want to be able to download another file again!

function isWaitingCookie(verif) {
    var loadState = getCookie("waitingCookie");
    if (loadState == "done") {
        clearInterval(verif);
        document.cookie = "attenteCookie=done; expires=Tue, 31 Dec 1985 21:00:00 UTC;";
        removeWaitingSpinner();
    }
}

function getCookie(cookieName) {
    var name = cookieName + "=";
    var cookies = document.cookie
    var cs = cookies.split(';');
    for (var i = 0; i < cs.length; i++) {
        var c = cs[i];
        while(c.charAt(0) == ' ') {
            c = c.substring(1);
        }
        if (c.indexOf(name) == 0) {
            return c.substring(name.length, c.length);
        }
    }
    return "";
}

function removeWaitingSpinner() {
    ... come back to your page and remove your spinner ...
}

Server side:

At the end of your server process, add a cookie to the response. That cookie will be sent to the client when your file will be ready for download.

Cookie waitCookie = new Cookie("waitingCookie", "done");
response.addCookie(waitCookie);

P
Peter Mortensen

A solution from elsewhere that worked:

/**
 *  download file, show modal
 *
 * @param uri link
 * @param name file name
 */
function downloadURI(uri, name) {
// <------------------------------------------       Do something (show loading)
    fetch(uri)
        .then(resp => resp.blob())
        .then(blob => {
            const url = window.URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.style.display = 'none';
            a.href = url;
            // the filename you want
            a.download = name;
            document.body.appendChild(a);
            a.click();
            window.URL.revokeObjectURL(url);
            // <----------------------------------------  Detect here (hide loading)
            alert('File detected');
        })
        .catch(() => alert('An error sorry'));
}

You can use it:

downloadURI("www.linkToFile.com", "file.name");


Works but basically transforms data to Base64 on the memory before reconverting to binary and downloading. Not recommended for large files
How can I set the final downloaded filename to the filename which is fetched from url?
P
Peter Mortensen

I had a real struggle with this exact problem, but I found a viable solution using iframes (I know, I know. It's terrible, but it works for a simple problem that I had.)

I had an HTML page that launched a separate PHP script that generated the file and then downloaded it. On the HTML page, I used the following jQuery code in the html header (you'll need to include a jQuery library as well):

<script>
    $(function(){
        var iframe = $("<iframe>", {name: 'iframe', id: 'iframe',}).appendTo("body").hide();
        $('#click').on('click', function(){
            $('#iframe').attr('src', 'your_download_script.php');
        });
        $('iframe').load(function(){
            $('#iframe').attr('src', 'your_download_script.php?download=yes'); <!-- On first iframe load, run script again but download file instead -->
            $('#iframe').unbind(); <!-- Unbinds the iframe. Helps prevent against infinite recursion if the script returns valid html (such as echoing out exceptions) -->
        });
    });
</script>

In file your_download_script.php, have the following:

function downloadFile($file_path) {
    if (file_exists($file_path)) {
        header('Content-Description: File Transfer');
        header('Content-Type: text/csv');
        header('Content-Disposition: attachment; filename=' . basename($file_path));
        header('Expires: 0');
        header('Cache-Control: must-revalidate');
        header('Pragma: public');
        header('Content-Length: ' . filesize($file_path));
        ob_clean();
        flush();
        readfile($file_path);
        exit();
    }
}

$_SESSION['your_file'] = path_to_file; // This is just how I chose to store the filepath

if (isset($_REQUEST['download']) && $_REQUEST['download'] == 'yes') {
    downloadFile($_SESSION['your_file']);
} else {
    // Execute logic to create the file
}

To break this down, jQuery first launches your PHP script in an iframe. The iframe is loaded once the file is generated. Then jQuery launches the script again with a request variable telling the script to download the file.

The reason that you can't do the download and file generation all in one go is due to the php header() function. If you use header(), you're changing the script to something other than a web page and jQuery will never recognize the download script as being 'loaded'. I know this may not necessarily be detecting when a browser receives a file, but your issue sounded similar to mine.


g
gahooa

When the user triggers the generation of the file, you could simply assign a unique ID to that "download", and send the user to a page which refreshes (or checks with AJAX) every few seconds. Once the file is finished, save it under that same unique ID and...

If the file is ready, do the download.

If the file is not ready, show the progress.

Then you can skip the whole iframe/waiting/browserwindow mess, yet have a really elegant solution.


That sounds like the temporary-file approach I mentioned above. I might do something like this if it turns out my idea is impossible, but I was hoping to avoid it.
b
bluish

If you don't want to generate and store the file on the server, are you willing to store the status, e.g. file-in-progress, file-complete? Your "waiting" page could poll the server to know when the file generation is complete. You wouldn't know for sure that the browser started the download but you'd have some confidence.


P
Peter Mortensen

In my experience, there are two ways to handle this:

Set a short-lived cookie on the download, and have JavaScript continually check for its existence. Only real issue is getting the cookie lifetime right - too short and the JavaScript can miss it, too long and it might cancel the download screens for other downloads. Using JavaScript to remove the cookie upon discovery usually fixes this. Download the file using fetch/XHR. Not only do you know exactly when the file download finishes, if you use XHR you can use progress events to show a progress bar! Save the resulting blob with msSaveBlob in Internet Explorer or Edge and a download link (like this one) in Firefox and Chrome. The problem with this method is that iOS Safari doesn't seem to handle downloading blobs right - you can convert the blob into a data URL with a FileReader and open that in a new window, but that's opening the file, not saving it.


P
Peter Mortensen

I just had this exact same problem. My solution was to use temporary files since I was generating a bunch of temporary files already. The form is submitted with:

var microBox = {
    show : function(content) {
        $(document.body).append('<div id="microBox_overlay"></div><div id="microBox_window"><div id="microBox_frame"><div id="microBox">' +
        content + '</div></div></div>');
        return $('#microBox_overlay');
    },

    close : function() {
        $('#microBox_overlay').remove();
        $('#microBox_window').remove();
    }
};

$.fn.bgForm = function(content, callback) {
    // Create an iframe as target of form submit
    var id = 'bgForm' + (new Date().getTime());
    var $iframe = $('<iframe id="' + id + '" name="' + id + '" style="display: none;" src="about:blank"></iframe>')
        .appendTo(document.body);
    var $form = this;
    // Submittal to an iframe target prevents page refresh
    $form.attr('target', id);
    // The first load event is called when about:blank is loaded
    $iframe.one('load', function() {
        // Attach listener to load events that occur after successful form submittal
        $iframe.load(function() {
            microBox.close();
            if (typeof(callback) == 'function') {
                var iframe = $iframe[0];
                var doc = iframe.contentWindow.document;
                var data = doc.body.innerHTML;
                callback(data);
            }
        });
    });

    this.submit(function() {
        microBox.show(content);
    });

    return this;
};

$('#myForm').bgForm('Please wait...');

At the end of the script that generates the file I have:

header('Refresh: 0;url=fetch.php?token=' . $token);
echo '<html></html>';

This will cause the load event on the iframe to be fired. Then the wait message is closed and the file download will then start. It was tested on Internet Explorer 7 and Firefox.


P
Peter Mortensen

If you have downloaded a file, which is saved, as opposed to being in the document, there isn't any way to determine when the download is complete, since it is not in the scope of the current document, but a separate process in the browser.


I should clarify -- I"m not too concerned with when the download completes. If I can just identify when the download starts, that would be enough.
P
Peter Mortensen

The question is to have a ‘waiting’ indicator while a file is generated and then return to normal once the file is downloading. The way I like to do this is using a hidden iFrame and hook the frame’s onload event to let my page know when download starts.

But onload does not fire in Internet Explorer for file downloads (like with the attachment header token). Polling the server works, but I dislike the extra complexity. So here is what I do:

Target the hidden iFrame as usual.

Generate the content. Cache it with an absolute timeout in 2 minutes.

Send a JavaScript redirect back to the calling client, essentially calling the generator page a second time. Note: this will cause the onload event to fire in Internet Explorer because it's acting like a regular page.

Remove the content from the cache and send it to the client.

Disclaimer: Don’t do this on a busy site, because the caching could add up. But really, if your sites are that busy, the long running process will starve you of threads anyway.

Here is what the code-behind looks like, which is all you really need.

public partial class Download : System.Web.UI.Page
{
    protected System.Web.UI.HtmlControls.HtmlControl Body;

    protected void Page_Load( object sender, EventArgs e )
    {
        byte[ ] data;
        string reportKey = Session.SessionID + "_Report";

        // Check is this page request to generate the content
        //    or return the content (data query string defined)
        if ( Request.QueryString[ "data" ] != null )
        {
            // Get the data and remove the cache
            data = Cache[ reportKey ] as byte[ ];
            Cache.Remove( reportKey );

            if ( data == null )
                // send the user some information
                Response.Write( "Javascript to tell user there was a problem." );
            else
            {
                Response.CacheControl = "no-cache";
                Response.AppendHeader( "Pragma", "no-cache" );
                Response.Buffer = true;

                Response.AppendHeader( "content-disposition", "attachment; filename=Report.pdf" );
                Response.AppendHeader( "content-size", data.Length.ToString( ) );
                Response.BinaryWrite( data );
            }
            Response.End();
        }
        else
        {
            // Generate the data here. I am loading a file just for an example
            using ( System.IO.FileStream stream = new System.IO.FileStream( @"C:\1.pdf", System.IO.FileMode.Open ) )
                using ( System.IO.BinaryReader reader = new System.IO.BinaryReader( stream ) )
                {
                    data = new byte[ reader.BaseStream.Length ];
                    reader.Read( data, 0, data.Length );
                }

            // Store the content for retrieval
            Cache.Insert( reportKey, data, null, DateTime.Now.AddMinutes( 5 ), TimeSpan.Zero );

            // This is the key bit that tells the frame to reload this page
            //   and start downloading the content. NOTE: Url has a query string
            //   value, so that the content isn't generated again.
            Body.Attributes.Add("onload", "window.location = 'binary.aspx?data=t'");
        }
    }

P
Peter Mortensen

A quick solution if you only want to display a message or a loader GIF image until the download dialog is displayed is to put the message in a hidden container and when you click on the button that generate the file to be downloaded, you make the container visible.

Then use jQuery or JavaScript to catch the focusout event of the button to hide the container that contain the message.


P
Peter Mortensen

If XMLHttpRequest with a blob is not an option then you can open your file in a new window and check if any elements get populated in that window body with interval.

var form = document.getElementById("frmDownlaod"); form.setAttribute("action", "downoad/url"); form.setAttribute("target", "downlaod"); var exportwindow = window.open("", "downlaod", "width=800,height=600,resizable=yes"); form.submit(); var responseInterval = setInterval(function() { var winBody = exportwindow.document.body if(winBody.hasChildNodes()) // Or 'downoad/url' === exportwindow.document.location.href { clearInterval(responseInterval); // Do your work. // If there is an error page configured in your application // for failed requests, check for those DOM elements. } }, 1000) // Better if you specify the maximum number of intervals


P
Peter Mortensen

This Java/Spring example detects the end of a download, at which point it hides the "Loading..." indicator.

Approach: On the JavaScript side, set a cookie with a maximum expiration age of 2 minutes, and poll every second for cookie expiration. Then the server-side overrides this cookie with an earlier expiration age -- the completion of the server process. As soon as the cookie expiration is detected in the JavaScript polling, "Loading..." is hidden.

JavaScript Side

function buttonClick() { // Suppose this is the handler for the button that starts
    $("#loadingProgressOverlay").show();  // Show loading animation
    startDownloadChecker("loadingProgressOverlay", 120);
    // Here you launch the download URL...
    window.location.href = "myapp.com/myapp/download";
}

// This JavaScript function detects the end of a download.
// It does timed polling for a non-expired Cookie, initially set on the
// client-side with a default max age of 2 min.,
// but then overridden on the server-side with an *earlier* expiration age
// (the completion of the server operation) and sent in the response.
// Either the JavaScript timer detects the expired cookie earlier than 2 min.
// (coming from the server), or the initial JavaScript-created cookie expires after 2 min.
function startDownloadChecker(imageId, timeout) {

    var cookieName = "ServerProcessCompleteChecker";  // Name of the cookie which is set and later overridden on the server
    var downloadTimer = 0;  // Reference to the timer object

    // The cookie is initially set on the client-side with a specified default timeout age (2 min. in our application)
    // It will be overridden on the server side with a new (earlier) expiration age (the completion of the server operation),
    // or auto-expire after 2 min.
    setCookie(cookieName, 0, timeout);

    // Set a timer to check for the cookie every second
    downloadTimer = window.setInterval(function () {

        var cookie = getCookie(cookieName);

        // If cookie expired (NOTE: this is equivalent to cookie "doesn't exist"), then clear "Loading..." and stop polling
        if ((typeof cookie === 'undefined')) {
            $("#" + imageId).hide();
            window.clearInterval(downloadTimer);
        }

    }, 1000); // Every second
}

// These are helper JavaScript functions for setting and retrieving a Cookie
function setCookie(name, value, expiresInSeconds) {
    var exdate = new Date();
    exdate.setTime(exdate.getTime() + expiresInSeconds * 1000);
    var c_value = escape(value) + ((expiresInSeconds == null) ? "" : "; expires=" + exdate.toUTCString());
    document.cookie = name + "=" + c_value + '; path=/';
}

function getCookie(name) {
    var parts = document.cookie.split(name + "=");
    if (parts.length == 2 ) {
        return parts.pop().split(";").shift();
    }
}

Java/Spring Server Side

    @RequestMapping("/download")
    public String download(HttpServletRequest request, HttpServletResponse response) throws Exception {
        //... Some logic for downloading, returning a result ...

        // Create a Cookie that will override the JavaScript-created
        // Max-Age-2min Cookie with an earlier expiration (same name)
        Cookie myCookie = new Cookie("ServerProcessCompleteChecker", "-1");
        myCookie.setMaxAge(0); // This is immediate expiration, but can also
                               // add +3 seconds for any flushing concerns
        myCookie.setPath("/");
        response.addCookie(myCookie);
        //... -- presumably the download is writing to the Output Stream...
        return null;
}

The cookie is created by the JS script but it's not updated by the controller, it maintains the original value (0), how can I update the cookie value without refreshing the page ?
That's strange - can you ensure the name is exactly correct? It will overwrite the cookie if the name matches. Let me know
The original value is not 0. The original value set in JS is 2 min. The NEW value that the server is supposed to modify with is 0.
Also, are you doing this: myCookie.setPath("/"); response.addCookie(myCookie);
I figured out (for some reason), that I should add cookies before doing response.getOutputStream(); (getting response output stream to append download files), it was not taken into account when I did it after that step
P
Peter Mortensen

PrimeFaces uses cookie polling, too.

monitorDownload():

    monitorDownload: function(start, complete, monitorKey) {
        if(this.cookiesEnabled()) {
            if(start) {
                start();
            }

            var cookieName = monitorKey ? 'primefaces.download_' + monitorKey : 'primefaces.download';
            window.downloadMonitor = setInterval(function() {
                var downloadComplete = PrimeFaces.getCookie(cookieName);

                if(downloadComplete === 'true') {
                    if(complete) {
                        complete();
                    }
                    clearInterval(window.downloadMonitor);
                    PrimeFaces.setCookie(cookieName, null);
                }
            }, 1000);
        }
    },

P
Peter Mortensen

I have updated the below reference code. Add a proper download URL link and try this out.

XMLHttpRequest: progress event - Live_example - code sample

Return to post

Reference: XMLHttpRequest: progress event, Live example


P
Peter Mortensen

You can rely on the browser's cache and trigger a second download of the same file when the file is loaded to the cache.

$('#link').click(function(e) {
    e.preventDefault();

    var url = $(this).attr('href');
    var request = new XMLHttpRequest();
    request.responseType = "blob";
    request.open("GET", url);

    var self = this;
    request.onreadystatechange = function () {
        if (request.readyState === 4) {
            var file = $(self).data('file');
            var anchor = document.createElement('a');
            anchor.download = file;
            console.log(file);
            console.log(request);
            anchor.href = window.URL.createObjectURL(request.response);
            anchor.click();
            console.log('Completed. Download window popped up.');
        }
    };
    request.send();
});

P
Peter Mortensen

Create an iframe when a button/link is clicked and append this to body.

$('<iframe />')
    .attr('src', url)
    .attr('id', 'iframe_download_report')
    .hide()
    .appendTo('body');

Create an iframe with a delay and delete it after download.

var triggerDelay =   100;
var cleaningDelay =  20000;
var that = this;
setTimeout(function() {
    var frame = $('<iframe style="width:1px; height:1px;" class="multi-download-frame"></iframe>');
    frame.attr('src', url + "?" + "Content-Disposition: attachment ; filename=" + that.model.get('fileName'));
    $(ev.target).after(frame);
    setTimeout(function() {
        frame.remove();
    }, cleaningDelay);
}, triggerDelay);

This is lacking information and it doesn't solve the "when to hide loading" issue.