joeytwiddle / Hibernate Idle Tabs

// ==UserScript==
// @name           Hibernate Idle Tabs
// @namespace      HIT
// @description    If a tab is unused for a long time, it switches to a light holding page until the tab is focused again.  This helps the browser to recover memory, and can speed up the re-opening of large sessions.
// @version        1.1.2
// @downstreamURL  http://userscripts.org/scripts/source/123252.user.js
// @include        *
// ==/UserScript==


/* +++ Config +++ */

var hibernateIfIdleForMoreThan = 4*60*60; // 4 hours
var restoreTime = 0.5; // in seconds

// We need an always-available basically blank HTML page we can navigate to for
// hibernation.  The userscript will run there and await re-activation.
// Userscripts do not run on about:blank in Firefox 6.0 or Chromium 2011, but a
// file:///... URL might work.

// I got desperate and aimed for a 404 on Google:
// This is not really satisfactory, since it provides an image and unneeded CSS!
var holdingPage = "http://neuralyte.org/~joey/hibernated_tab.html";

// If you want to use another holding page, put the old one here to keep your
// current/saved browser sessions working.
var oldHoldingPage = "http://www.google.com/hibernated_tab";

var passFaviconToHoldingPage = true;
var fadeHibernatedFavicons = true;

var forceHibernateWhenRunTwice = true;



/* +++ Documentation +++ */
//
// On a normal page, checks to see if the user goes idle.  (Mouse movements,
// key actions and page focus reset the idle timer.)  If the page is left idle
// past the timeout, then the window navigates to a lighter holding page,
// hopefully freeing up memory in the browser.
//
// On a holding page, if the user focuses the window, the window navigates back
// to the original page.  (This can be cancelled in Chrome by clicking on
// another tab, but not by paging to another tab with the keyboard!)
//
// I think single-click is also a cancellation now.
//
// In order for the tab of the holding page to present the same favicon as the
// original page, we must capture this image before leaving the original page,
// and pass it to the holding page as a CGI parameter.
//
// (A simpler alternative might be to aim for a 404 on the same domain and use
// that as the holding page.)



// BUG: Sometimes when un-hibernating, the webserver of the page we return to
// complains that the referrer URL header is too long!

// TODO: Some users may want the hibernated page to restore immediately when the tab is *refocused*, rather than waiting for a mouseover.

// TESTING: Expose a function to allow a bookmarklet to force-hibernate the current tab?

// CONSIDER: If we forget about fading the favicon, couldn't we simplify things by just sending the favicon URL rather than its image data?  I think I tested this, and although I could load the favicon into the document, I was not successful at getting it into the browser's title tab by adding a new <link rel="icon">.



/* +++ Main +++ */

var onHoldingPage = document.location.href.match(holdingPage+"?") != null;

// If you change holding page, this keeps the old one working for a while, for
// the sake of running browsers or saved sessions.
if (!onHoldingPage && oldHoldingPage) {
	onHoldingPage = document.location.href.match(oldHoldingPage+"?") != null;
}

function handleNormalPage() {

	whenIdleDo(hibernateIfIdleForMoreThan,hibernatePage);

	function hibernatePage() {

		var params = {
			title: document.title,
			url: document.location.href
		};

		function processFavicon(canvas) {
			document.body.appendChild(canvas);
			if (canvas) {
				try {
					if (fadeHibernatedFavicons) {
						makeCanvasMoreTransparent(canvas);
					}
					var faviconDataURL = canvas.toDataURL("image/png");
					params.favicon_data = faviconDataURL;
				} catch (e) {
					var extra = ( window != top ? " (running in frame or iframe)" : "" );
					console.error("[HIT] Got error"+extra+": "+e+" doc.loc="+document.location.href);
					// We get "Error: SECURITY_ERR: DOM Exception 18" (Chrome) if
					// the favicon is from a different host.
				}
			}
			reallyHibernatePage();
		}

		function reallyHibernatePage() {
			var queryString = buildQueryParameterString(params);
			document.location = holdingPage + "?" + queryString;
		}

		if (passFaviconToHoldingPage) {
			// I don't know how to grab the contents of the current favicon, so we
			// try to directly load a copy for ourselves.
			var url = document.location.href;
			var targetHost = url.replace(/.*:\/\//,'').replace(/\/.*/,'');
			loadFaviconIntoCanvas(targetHost,processFavicon);
		} else {
			reallyHibernatePage();
		}

	}

	function makeCanvasMoreTransparent(canvas) {
		var wid = canvas.width;
		var hei = canvas.height;
		var ctx = canvas.getContext("2d");
		var img = ctx.getImageData(0,0,wid,hei);
		var data = img.data;
		var len = 4*wid*hei;
		for (var ptr=0;ptr<len;ptr+=4) {
			data[ptr+3] /= 4;  // alpha channel
		}
		// May or may not be needed:
		ctx.putImageData(img,0,0);
	}



	if (forceHibernateWhenRunTwice) {
		if (window.hibernate_idle_tabs_loaded) {
			hibernatePage();
		}
		window.hibernate_idle_tabs_loaded = true;
	}


}

function handleHoldingPage() {

	var params = getQueryParameters();

	// setHibernateStatus("Holding page for " + params.title + "\n with URL: "+params.url);
	// var titleReport = params.title + " (Holding Page)";
	var titleReport = "(" + (params.title || params.url) + " :: Hibernated)";
	setWindowTitle(titleReport);

	var mainReport = titleReport;
	if (params.title) {
		/*
		statusElement.appendChild(document.createElement("P"));
		var div = document.createElement("tt");
		div.style.fontSize = "0.8em";
		div.appendChild(document.createTextNode(params.url));
		statusElement.appendChild(div);
		*/
		mainReport += "\n" + params.url;
	}

	setHibernateStatus(mainReport);

	try {
		var faviconDataURL = params.favicon_data;
		if (!faviconDataURL) {
			// If we do not have a favicon, it is preferable to present an empty/transparent favicon, rather than let the browser show the favicon of the holding page site!
			faviconDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAEklEQVQ4jWNgGAWjYBSMAggAAAQQAAF/TXiOAAAAAElFTkSuQmCC";
		}
		writeFaviconFromDataString(faviconDataURL);
	} catch (e) {
		console.error(""+e);
	}

	function restoreTab(evt) {
		var url = decodeURIComponent(params.url);
		setHibernateStatus("Returning to: "+url);
		document.location.replace(url);
		/*
		// Alternative; preserves "forward"
		window.history.back();  // TESTING!  With the fallback below, this seemed to work 90% of the time?
		// Sometimes it doesn't work.  So we fallback to old method:
		setTimeout(function(){
			setHibernateStatus("window.history.back() FAILED - setting document.location");
			setTimeout(function(){
				document.location.replace(url);   // I once saw this put ':'s when it should have put '%35's or whatever.  (That broke 'Up' bookmarklet.)
			},1000);
		},2500);
		*/
		evt.preventDefault();   // Accept responsibility for the double-click.
		return false;   // Prevent browser from doing anything else with it (e.g. selecting the word under the cursor).
	}

	checkForActivity();

	function checkForActivity() {

		var countdownTimer = null;

		// listen(document.body,'mousemove',startCountdown); // In Firefox this ignore mousemove on empty space (outside the document content), so trying window...
		listen(window,'mousemove',startCountdown); // Likewise for click below!
		// listen(document.body,'blur',clearCountdown); // Does not fire in Chrome?
		listen(window,'blur',clearCountdown); // For Chrome
		//listen(window,'mouseout',clearCountdown); // Firefox appears to be firing this when my mouse is still over the window, preventing navigation!  Let's just rely on 'blur' instead.
		// listen(document.body,'click',clearCountdown);
		listen(window,'click',clearCountdown);
		listen(window,'dblclick',restoreTab);

		function startCountdown(e) {
			if (countdownTimer != null) {
				// There is already a countdown running - do not start.
				return;
			}
			var togo = restoreTime*1000;
			function countDown() {
				setHibernateStatus(mainReport +
						'\n' + "Tab will restore in "+(togo/1000).toFixed(1)+" seconds." +
						'  ' + "Click or defocus to pause." +
						'  ' + "Or double click to restore now!"
						);
				if (togo <= 0) {
					restoreTab();
				} else {
					togo -= 1000;
					if (countdownTimer)
						clearTimeout(countdownTimer);
					countdownTimer = setTimeout(countDown,1000);
				}
			}
			countDown();
		}

		function clearCountdown(ev) {
			if (countdownTimer) {
				clearTimeout(countdownTimer);
			}
			countdownTimer = null;
			var evReport = "";
			if (ev) {
				evReport = " by "+ev.type+" on "+this;
			}
			var report = mainReport + '\n' + "Paused" + evReport + "";
			setHibernateStatus(report);
		}

	}

}

if (onHoldingPage) {
	handleHoldingPage();
} else {
	handleNormalPage();
}



/* +++ Library Functions +++ */

function listen(target,eventType,handler,capture) {
	target.addEventListener(eventType,handler,capture);
}

function ignore(target,eventType,handler,capture) {
	target.removeEventListener(eventType,handler,capture);
}

// Given an object, encode its properties and values into a URI-ready CGI query string.
function buildQueryParameterString(params) {
	return Object.keys(params).map( function(key) { return key+"="+encodeURIComponent(params[key]); } ).join("&");
}

// Returns an object whose keys and values match those of the CGI query string of the current document.
function getQueryParameters() {
	var queryString = document.location.search;
	var params = {};
	queryString.replace(/^\?/,'').split("&").map( function(s) {
		var part = s.split("=");
		var key = part[0];
		var value = decodeURIComponent(part[1]);
		params[key] = value;
	});
	return params;
}

function whenIdleDo(idleTimeoutSecs,activateIdleEvent) {

	var timer = null;
	var pageLastUsed = new Date().getTime();

	function setNotIdle() {
		pageLastUsed = new Date().getTime();
	}

	function checkForIdle() {
		var msSinceLastUsed = new Date().getTime() - pageLastUsed;
		if (msSinceLastUsed > idleTimeoutSecs * 1000) {
			activateIdleEvent();
		}
		setTimeout(checkForIdle,idleTimeoutSecs/5*1000);
	}

	setTimeout(checkForIdle,idleTimeoutSecs*1000);

	listen(document.body,'mousemove',setNotIdle);
	listen(document.body,'focus',setNotIdle);
	listen(document.body,'keydown',setNotIdle);

}



/* +++ Local Convenience Functions +++ */

var statusElement = null;
function checkStatusElement() {
	if (!statusElement) {
		while (document.body.firstChild) {
			document.body.removeChild(document.body.firstChild);
		}
		statusElement = document.createElement("div");
		document.body.insertBefore(statusElement,document.body.firstChild);
		statusElement.style.textAlign = "center";
	}
}

function setWindowTitle(msg) {
	msg = ""+msg;
	document.title = msg;
}

function setHibernateStatus(msg) {
	msg = ""+msg;
	checkStatusElement();
	statusElement.textContent = msg;
	statusElement.innerText   = msg;   // IE
	// Currently '\n' works in Chrome, but not in Firefox.
}






/* +++ Favicon, Canvas and DataURL Magic +++ */

function loadFaviconForHost(targetHost,callback) {

	// Try to load a favicon image for the given host, and pass it to callback.
	// Except: If there is a link with rel="icon" in the page, with host
	// matching the current page location, load that image file instead of
	// guessing the extension!

	var favicon = document.createElement('img');
	favicon.addEventListener('load',function() {
		callback(favicon);
	});

	var targetProtocol = document.location.protocol || "http:";

	// If there is a <link rel="icon" ...> in the current page, then I think that overrides the site-global favicon.
	// NOTE: This is not appropriate if a third party targetHost was requested, only if they really wanted the favicon for the current page!
	var foundLink = null;
	var linkElems = document.getElementsByTagName("link");
	for (var i=0;i<linkElems.length;i++) {
		var link = linkElems[i];
		if (link.rel === "icon" || link.rel === "shortcut icon") {
			// Since we can't read the image data of images from third-party hosts, we skip them and keep searching.
			if (link.host == document.location.host) {
				foundLink = link;
				break;
			}
		}
	}
	if (foundLink) {
		favicon.addEventListener('error',function(){ callback(favicon); });
		favicon.src = foundLink.href;   // Won't favicon.onload cause an additional callback to the one below?
		// NOTE: If we made the callback interface pass favicon as 'this' rather than an argument, then we wouldn't need to wrap it here (the argument may be evt).
		favicon = foundLink;
		callback(favicon);
		return;
	}

	var extsToTry = ["jpg","gif","png","ico"];   // iterated in reverse order
	function tryNextExtension() {
		var ext = extsToTry.pop();
		if (ext == null) {
			console.log("Ran out of extensions to try for "+targetHost+"/favicon.???");
			// We run the callback anyway!
			callback(null);
		} else {
			favicon.src = targetProtocol+"//"+targetHost+"/favicon."+ext;
		}
	}
	favicon.addEventListener('error',tryNextExtension);
	tryNextExtension();
	// When the favicon is working we can remove the canvas, but until then we may as well keep it visible!
}

function writeFaviconFromCanvas(canvas) {
	var faviconDataURL = canvas.toDataURL("image/png");
	// var faviconDataURL = canvas.toDataURL("image/x-icon;base64");
	// console.log("Got data URL: "+faviconDataURL.substring(0,10+"... (length "+faviconDataURL.length+")");
	writeFaviconFromDataString(faviconDataURL);
}

function writeFaviconFromDataString(faviconDataURL) {

	var d = document, h = document.getElementsByTagName('head')[0];

	// Create this favicon
	var ss = d.createElement('link');
	ss.rel = 'shortcut icon';
	ss.type = 'image/x-icon';
	ss.href = faviconDataURL;
	/*
	// Remove any existing favicons
	var links = h.getElementsByTagName('link');
	for (var i=0; i<links.length; i++) {
		if (links[i].href == ss.href) return;
		if (links[i].rel == "shortcut icon" || links[i].rel=="icon")
			h.removeChild(links[i]);
	}
	*/
	// Add this favicon to the head
	h.appendChild(ss);

	// Force browser to acknowledge
	// I saw this trick somewhere.  I don't know what browser requires it.  But I just left it in!
	var shim = document.createElement('iframe');
	shim.width = shim.height = 0;
	document.body.appendChild(shim);
	shim.src = "icon";
	document.body.removeChild(shim);

}

function loadFaviconIntoCanvas(targetHost,callback) {

	// console.log("Getting favicon for: "+targetHost);

	var canvas = document.createElement('canvas');
	var ctx = canvas.getContext('2d');

	loadFaviconForHost(targetHost,gotFavicon);

	function gotFavicon(favicon) {
		if (favicon) {
			// console.log("Got favicon from: "+favicon.src);
			canvas.width = favicon.width;
			canvas.height = favicon.height;
			ctx.drawImage( favicon, 0, 0 );
		}
		callback(canvas);
	}

}

/* This throws a security error from canvas.toDataURL(), I think because we are
trying to read something from a different domain than the script!
In Chrome: "SECURITY_ERR: DOM Exception 18"
*/
// loadFaviconIntoCanvas(document.location.host,writeFaviconFromCanvas);