/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ /* Copyright 2012 Mozilla Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; var EXPORTED_SYMBOLS = ['PdfStreamConverter']; const Cc = Components.classes; const Ci = Components.interfaces; const Cr = Components.results; const Cu = Components.utils; // True only if this is the version of pdf.js that is included with firefox. const MOZ_CENTRAL = PDFJSSCRIPT_MOZ_CENTRAL; const PDFJS_EVENT_ID = 'pdf.js.message'; const PDF_CONTENT_TYPE = 'application/pdf'; const PREF_PREFIX = 'PDFJSSCRIPT_PREF_PREFIX'; const PDF_VIEWER_WEB_PAGE = 'resource://pdf.js/web/viewer.html'; const MAX_DATABASE_LENGTH = 4096; const FIREFOX_ID = '{ec8030f7-c20a-464f-9b0e-13a3a9e97384}'; Cu.import('resource://gre/modules/XPCOMUtils.jsm'); Cu.import('resource://gre/modules/Services.jsm'); Cu.import('resource://gre/modules/NetUtil.jsm'); let appInfo = Cc['@mozilla.org/xre/app-info;1'] .getService(Ci.nsIXULAppInfo); let Svc = {}; XPCOMUtils.defineLazyServiceGetter(Svc, 'mime', '@mozilla.org/mime;1', 'nsIMIMEService'); let isInPrivateBrowsing; if (appInfo.ID === FIREFOX_ID) { let privateBrowsing = Cc['@mozilla.org/privatebrowsing;1'] .getService(Ci.nsIPrivateBrowsingService); isInPrivateBrowsing = function getInPrivateBrowsing() { return privateBrowsing.privateBrowsingEnabled; }; } else { isInPrivateBrowsing = function() { return false; }; } function getChromeWindow(domWindow) { var containingBrowser = domWindow.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebNavigation) .QueryInterface(Ci.nsIDocShell) .chromeEventHandler; return containingBrowser.ownerDocument.defaultView; } function getBoolPref(pref, def) { try { return Services.prefs.getBoolPref(pref); } catch (ex) { return def; } } function setStringPref(pref, value) { let str = Cc['@mozilla.org/supports-string;1'] .createInstance(Ci.nsISupportsString); str.data = value; Services.prefs.setComplexValue(pref, Ci.nsISupportsString, str); } function getStringPref(pref, def) { try { return Services.prefs.getComplexValue(pref, Ci.nsISupportsString).data; } catch (ex) { return def; } } function log(aMsg) { if (!getBoolPref(PREF_PREFIX + '.pdfBugEnabled', false)) return; let msg = 'PdfStreamConverter.js: ' + (aMsg.join ? aMsg.join('') : aMsg); Services.console.logStringMessage(msg); dump(msg + '\n'); } function getDOMWindow(aChannel) { var requestor = aChannel.notificationCallbacks ? aChannel.notificationCallbacks : aChannel.loadGroup.notificationCallbacks; var win = requestor.getInterface(Components.interfaces.nsIDOMWindow); return win; } function isEnabled() { if (MOZ_CENTRAL) { var disabled = getBoolPref(PREF_PREFIX + '.disabled', false); if (disabled) return false; // To also be considered enabled the "Preview in Firefox" option must be // selected in the Application preferences. var handlerInfo = Svc.mime .getFromTypeAndExtension('application/pdf', 'pdf'); return handlerInfo.alwaysAskBeforeHandling == false && handlerInfo.preferredAction == Ci.nsIHandlerInfo.handleInternally; } // Always returns true for the extension since enabling/disabling is handled // by the add-on manager. return true; } function getLocalizedStrings(path) { var stringBundle = Cc['@mozilla.org/intl/stringbundle;1']. getService(Ci.nsIStringBundleService). createBundle('chrome://pdf.js/locale/' + path); var map = {}; var enumerator = stringBundle.getSimpleEnumeration(); while (enumerator.hasMoreElements()) { var string = enumerator.getNext().QueryInterface(Ci.nsIPropertyElement); var key = string.key, property = 'textContent'; var i = key.lastIndexOf('.'); if (i >= 0) { property = key.substring(i + 1); key = key.substring(0, i); } if (!(key in map)) map[key] = {}; map[key][property] = string.value; } return map; } function getLocalizedString(strings, id, property) { property = property || 'textContent'; if (id in strings) return strings[id][property]; return id; } // PDF data storage function PdfDataListener(length) { this.length = length; // less than 0, if length is unknown this.data = new Uint8Array(length >= 0 ? length : 0x10000); this.loaded = 0; } PdfDataListener.prototype = { append: function PdfDataListener_append(chunk) { var willBeLoaded = this.loaded + chunk.length; if (this.length >= 0 && this.length < willBeLoaded) { this.length = -1; // reset the length, server is giving incorrect one } if (this.length < 0 && this.data.length < willBeLoaded) { // data length is unknown and new chunk will not fit in the existing // buffer, resizing the buffer by doubling the its last length var newLength = this.data.length; for (; newLength < willBeLoaded; newLength *= 2) {} var newData = new Uint8Array(newLength); newData.set(this.data); this.data = newData; } this.data.set(chunk, this.loaded); this.loaded = willBeLoaded; this.onprogress(this.loaded, this.length >= 0 ? this.length : void(0)); }, getData: function PdfDataListener_getData() { var data = this.data; if (this.loaded != data.length) data = data.subarray(0, this.loaded); delete this.data; // releasing temporary storage return data; }, finish: function PdfDataListener_finish() { this.isDataReady = true; if (this.oncompleteCallback) { this.oncompleteCallback(this.getData()); } }, error: function PdfDataListener_error(errorCode) { this.errorCode = errorCode; if (this.oncompleteCallback) { this.oncompleteCallback(null, errorCode); } }, onprogress: function() {}, set oncomplete(value) { this.oncompleteCallback = value; if (this.isDataReady) { value(this.getData()); } if (this.errorCode) { value(null, this.errorCode); } } }; // All the priviledged actions. function ChromeActions(domWindow, dataListener) { this.domWindow = domWindow; this.dataListener = dataListener; } ChromeActions.prototype = { download: function(data, sendResponse) { var originalUrl = data.originalUrl; // The data may not be downloaded so we need just retry getting the pdf with // the original url. var originalUri = NetUtil.newURI(data.originalUrl); var blobUri = data.blobUrl ? NetUtil.newURI(data.blobUrl) : originalUri; var extHelperAppSvc = Cc['@mozilla.org/uriloader/external-helper-app-service;1']. getService(Ci.nsIExternalHelperAppService); var frontWindow = Cc['@mozilla.org/embedcomp/window-watcher;1']. getService(Ci.nsIWindowWatcher).activeWindow; let docIsPrivate = false; try { docIsPrivate = this.domWindow .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebNavigation) .QueryInterface(Ci.nsILoadContext) .usePrivateBrowsing; } catch (x) { } let netChannel = NetUtil.newChannel(blobUri); if ('nsIPrivateBrowsingChannel' in Ci && netChannel instanceof Ci.nsIPrivateBrowsingChannel) { netChannel.setPrivate(docIsPrivate); } NetUtil.asyncFetch(netChannel, function(aInputStream, aResult) { if (!Components.isSuccessCode(aResult)) { if (sendResponse) sendResponse(true); return; } // Create a nsIInputStreamChannel so we can set the url on the channel // so the filename will be correct. let channel = Cc['@mozilla.org/network/input-stream-channel;1']. createInstance(Ci.nsIInputStreamChannel); channel.setURI(originalUri); channel.contentStream = aInputStream; channel.QueryInterface(Ci.nsIChannel); if ('nsIPrivateBrowsingChannel' in Ci && channel instanceof Ci.nsIPrivateBrowsingChannel) { channel.setPrivate(docIsPrivate); } var listener = { extListener: null, onStartRequest: function(aRequest, aContext) { this.extListener = extHelperAppSvc.doContent('application/pdf', aRequest, frontWindow, false); this.extListener.onStartRequest(aRequest, aContext); }, onStopRequest: function(aRequest, aContext, aStatusCode) { if (this.extListener) this.extListener.onStopRequest(aRequest, aContext, aStatusCode); // Notify the content code we're done downloading. if (sendResponse) sendResponse(false); }, onDataAvailable: function(aRequest, aContext, aInputStream, aOffset, aCount) { this.extListener.onDataAvailable(aRequest, aContext, aInputStream, aOffset, aCount); } }; channel.asyncOpen(listener, null); }); }, setDatabase: function(data) { if (isInPrivateBrowsing()) return; // Protect against something sending tons of data to setDatabase. if (data.length > MAX_DATABASE_LENGTH) return; setStringPref(PREF_PREFIX + '.database', data); }, getDatabase: function() { if (isInPrivateBrowsing()) return '{}'; return getStringPref(PREF_PREFIX + '.database', '{}'); }, getLocale: function() { return getStringPref('general.useragent.locale', 'en-US'); }, getLoadingType: function() { return this.dataListener ? 'passive' : 'active'; }, initPassiveLoading: function() { if (!this.dataListener) return false; var domWindow = this.domWindow; this.dataListener.onprogress = function ChromeActions_dataListenerProgress(loaded, total) { domWindow.postMessage({ pdfjsLoadAction: 'progress', loaded: loaded, total: total }, '*'); }; this.dataListener.oncomplete = function ChromeActions_dataListenerComplete(data, errorCode) { domWindow.postMessage({ pdfjsLoadAction: 'complete', data: data, errorCode: errorCode }, '*'); delete this.dataListener; }; return true; }, getStrings: function(data) { try { // Lazy initialization of localizedStrings if (!('localizedStrings' in this)) this.localizedStrings = getLocalizedStrings('viewer.properties'); var result = this.localizedStrings[data]; return JSON.stringify(result || null); } catch (e) { log('Unable to retrive localized strings: ' + e); return 'null'; } }, pdfBugEnabled: function() { return getBoolPref(PREF_PREFIX + '.pdfBugEnabled', false); }, supportsIntegratedFind: function() { // Integrated find is only supported when we're not in a frame and when the // new find events code exists. return this.domWindow.frameElement === null && getChromeWindow(this.domWindow).gFindBar && 'updateControlState' in getChromeWindow(this.domWindow).gFindBar; }, fallback: function(url, sendResponse) { var self = this; var domWindow = this.domWindow; var strings = getLocalizedStrings('chrome.properties'); var message = getLocalizedString(strings, 'unsupported_feature'); var notificationBox = null; // Multiple browser windows can be opened, finding one for notification box var windowsEnum = Services.wm .getZOrderDOMWindowEnumerator('navigator:browser', true); while (windowsEnum.hasMoreElements()) { var win = windowsEnum.getNext(); if (win.closed) continue; var browser = win.gBrowser.getBrowserForDocument(domWindow.top.document); if (browser) { // right window/browser is found, getting the notification box notificationBox = win.gBrowser.getNotificationBox(browser); break; } } if (!notificationBox) { log('Unable to get a notification box for the fallback message'); return; } // Flag so we don't call the response callback twice, since if the user // clicks open with different viewer both the button callback and // eventCallback will be called. var sentResponse = false; var buttons = [{ label: getLocalizedString(strings, 'open_with_different_viewer'), accessKey: getLocalizedString(strings, 'open_with_different_viewer', 'accessKey'), callback: function() { sentResponse = true; sendResponse(true); } }]; notificationBox.appendNotification(message, 'pdfjs-fallback', null, notificationBox.PRIORITY_WARNING_LOW, buttons, function eventsCallback(eventType) { // Currently there is only one event "removed" but if there are any other // added in the future we still only care about removed at the moment. if (eventType !== 'removed') return; // Don't send a response again if we already responded when the button was // clicked. if (!sentResponse) sendResponse(false); }); }, updateFindControlState: function(data) { if (!this.supportsIntegratedFind()) return; // Verify what we're sending to the findbar. var result = data.result; var findPrevious = data.findPrevious; var findPreviousType = typeof findPrevious; if ((typeof result !== 'number' || result < 0 || result > 3) || (findPreviousType !== 'undefined' && findPreviousType !== 'boolean')) { return; } getChromeWindow(this.domWindow).gFindBar .updateControlState(result, findPrevious); } }; // Event listener to trigger chrome privedged code. function RequestListener(actions) { this.actions = actions; } // Receive an event and synchronously or asynchronously responds. RequestListener.prototype.receive = function(event) { var message = event.target; var doc = message.ownerDocument; var action = message.getUserData('action'); var data = message.getUserData('data'); var sync = message.getUserData('sync'); var actions = this.actions; if (!(action in actions)) { log('Unknown action: ' + action); return; } if (sync) { var response = actions[action].call(this.actions, data); message.setUserData('response', response, null); } else { var response; if (!message.getUserData('callback')) { doc.documentElement.removeChild(message); response = null; } else { response = function sendResponse(response) { message.setUserData('response', response, null); var listener = doc.createEvent('HTMLEvents'); listener.initEvent('pdf.js.response', true, false); return message.dispatchEvent(listener); } } actions[action].call(this.actions, data, response); } }; // Forwards events from the eventElement to the contentWindow only if the // content window matches the currently selected browser window. function FindEventManager(eventElement, contentWindow, chromeWindow) { this.types = ['find', 'findagain', 'findhighlightallchange', 'findcasesensitivitychange']; this.chromeWindow = chromeWindow; this.contentWindow = contentWindow; this.eventElement = eventElement; } FindEventManager.prototype.bind = function() { var unload = function(e) { this.unbind(); this.contentWindow.removeEventListener(e.type, unload); }.bind(this); this.contentWindow.addEventListener('unload', unload); for (var i = 0; i < this.types.length; i++) { var type = this.types[i]; this.eventElement.addEventListener(type, this, true); } }; FindEventManager.prototype.handleEvent = function(e) { var chromeWindow = this.chromeWindow; var contentWindow = this.contentWindow; // Only forward the events if they are for our dom window. if (chromeWindow.gBrowser.selectedBrowser.contentWindow === contentWindow) { var detail = e.detail; detail.__exposedProps__ = { query: 'r', caseSensitive: 'r', highlightAll: 'r', findPrevious: 'r' }; var forward = contentWindow.document.createEvent('CustomEvent'); forward.initCustomEvent(e.type, true, true, detail); contentWindow.dispatchEvent(forward); e.preventDefault(); } }; FindEventManager.prototype.unbind = function() { for (var i = 0; i < this.types.length; i++) { var type = this.types[i]; this.eventElement.removeEventListener(type, this, true); } }; function PdfStreamConverter() { } PdfStreamConverter.prototype = { // properties required for XPCOM registration: classID: Components.ID('{PDFJSSCRIPT_STREAM_CONVERTER_ID}'), classDescription: 'pdf.js Component', contractID: '@mozilla.org/streamconv;1?from=application/pdf&to=*/*', QueryInterface: XPCOMUtils.generateQI([ Ci.nsISupports, Ci.nsIStreamConverter, Ci.nsIStreamListener, Ci.nsIRequestObserver ]), /* * This component works as such: * 1. asyncConvertData stores the listener * 2. onStartRequest creates a new channel, streams the viewer and cancels * the request so pdf.js can do the request * Since the request is cancelled onDataAvailable should not be called. The * onStopRequest does nothing. The convert function just returns the stream, * it's just the synchronous version of asyncConvertData. */ // nsIStreamConverter::convert convert: function(aFromStream, aFromType, aToType, aCtxt) { throw Cr.NS_ERROR_NOT_IMPLEMENTED; }, // nsIStreamConverter::asyncConvertData asyncConvertData: function(aFromType, aToType, aListener, aCtxt) { if (!isEnabled()) throw Cr.NS_ERROR_NOT_IMPLEMENTED; // Store the listener passed to us this.listener = aListener; }, // nsIStreamListener::onDataAvailable onDataAvailable: function(aRequest, aContext, aInputStream, aOffset, aCount) { if (!this.dataListener) { // Do nothing since all the data loading is handled by the viewer. return; } var binaryStream = this.binaryStream; binaryStream.setInputStream(aInputStream); this.dataListener.append(binaryStream.readByteArray(aCount)); }, // nsIRequestObserver::onStartRequest onStartRequest: function(aRequest, aContext) { // Setup the request so we can use it below. aRequest.QueryInterface(Ci.nsIChannel); // Creating storage for PDF data var contentLength = aRequest.contentLength; var dataListener = new PdfDataListener(contentLength); this.dataListener = dataListener; this.binaryStream = Cc['@mozilla.org/binaryinputstream;1'] .createInstance(Ci.nsIBinaryInputStream); // Change the content type so we don't get stuck in a loop. aRequest.contentType = 'text/html'; // Create a new channel that is viewer loaded as a resource. var ioService = Services.io; var channel = ioService.newChannel( PDF_VIEWER_WEB_PAGE, null, null); var listener = this.listener; // Proxy all the request observer calls, when it gets to onStopRequest // we can get the dom window. We also intentionally pass on the original // request(aRequest) below so we don't overwrite the original channel and // trigger an assertion. var proxy = { onStartRequest: function(request, context) { listener.onStartRequest(aRequest, context); }, onDataAvailable: function(request, context, inputStream, offset, count) { listener.onDataAvailable(aRequest, context, inputStream, offset, count); }, onStopRequest: function(request, context, statusCode) { // We get the DOM window here instead of before the request since it // may have changed during a redirect. var domWindow = getDOMWindow(channel); // Double check the url is still the correct one. if (domWindow.document.documentURIObject.equals(aRequest.URI)) { let actions = new ChromeActions(domWindow, dataListener); let requestListener = new RequestListener(actions); domWindow.addEventListener(PDFJS_EVENT_ID, function(event) { requestListener.receive(event); }, false, true); if (actions.supportsIntegratedFind()) { var chromeWindow = getChromeWindow(domWindow); var findEventManager = new FindEventManager(chromeWindow.gFindBar, domWindow, chromeWindow); findEventManager.bind(); } } else { log('Dom window url did not match request url.'); } listener.onStopRequest(aRequest, context, statusCode); } }; // Keep the URL the same so the browser sees it as the same. channel.originalURI = aRequest.URI; channel.loadGroup = aRequest.loadGroup; // We can use resource principal when data is fetched by the chrome // e.g. useful for NoScript var securityManager = Cc['@mozilla.org/scriptsecuritymanager;1'] .getService(Ci.nsIScriptSecurityManager); var uri = ioService.newURI(PDF_VIEWER_WEB_PAGE, null, null); // FF16 and below had getCodebasePrincipal, it was replaced by // getNoAppCodebasePrincipal (bug 758258). var resourcePrincipal = 'getNoAppCodebasePrincipal' in securityManager ? securityManager.getNoAppCodebasePrincipal(uri) : securityManager.getCodebasePrincipal(uri); aRequest.owner = resourcePrincipal; channel.asyncOpen(proxy, aContext); }, // nsIRequestObserver::onStopRequest onStopRequest: function(aRequest, aContext, aStatusCode) { if (!this.dataListener) { // Do nothing return; } if (Components.isSuccessCode(aStatusCode)) this.dataListener.finish(); else this.dataListener.error(aStatusCode); delete this.dataListener; delete this.binaryStream; } }; var NSGetFactory = XPCOMUtils.generateNSGetFactory([PdfStreamConverter]);