gecko-dev/devtools/client/jsonview/converter-child.js

339 строки
10 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const {Ci, Cu, CC} = require("chrome");
const ChromeUtils = require("ChromeUtils");
const Services = require("Services");
loader.lazyRequireGetter(this, "NetworkHelper",
"devtools/shared/webconsole/network-helper");
loader.lazyGetter(this, "debugJsModules", function() {
const {AppConstants} = require("resource://gre/modules/AppConstants.jsm");
return !!(AppConstants.DEBUG_JS_MODULES);
});
const BinaryInput = CC("@mozilla.org/binaryinputstream;1",
"nsIBinaryInputStream", "setInputStream");
const BufferStream = CC("@mozilla.org/io/arraybuffer-input-stream;1",
"nsIArrayBufferInputStream", "setData");
const kCSP = "default-src 'none' ; script-src resource:; ";
// Localization
loader.lazyGetter(this, "jsonViewStrings", () => {
return Services.strings.createBundle(
"chrome://devtools/locale/jsonview.properties");
});
/**
* This object detects 'application/vnd.mozilla.json.view' content type
* and converts it into a JSON Viewer application that allows simple
* JSON inspection.
*
* Inspired by JSON View: https://github.com/bhollis/jsonview/
*/
function Converter() {}
Converter.prototype = {
QueryInterface: ChromeUtils.generateQI([
Ci.nsIStreamConverter,
Ci.nsIStreamListener,
Ci.nsIRequestObserver,
]),
get wrappedJSObject() {
return this;
},
/**
* This component works as such:
* 1. asyncConvertData captures the listener
* 2. onStartRequest fires, initializes stuff, modifies the listener
* to match our output type
* 3. onDataAvailable decodes and inserts data into a text node
* 4. onStopRequest flushes data and spits back to the listener
* 5. convert does nothing, it's just the synchronous version
* of asyncConvertData
*/
convert: function(fromStream, fromType, toType, ctx) {
return fromStream;
},
asyncConvertData: function(fromType, toType, listener, ctx) {
this.listener = listener;
},
onDataAvailable: function(request, inputStream, offset, count) {
// Decode and insert data.
const buffer = new ArrayBuffer(count);
new BinaryInput(inputStream).readArrayBuffer(count, buffer);
this.decodeAndInsertBuffer(buffer);
},
onStartRequest: function(request) {
// Set the content type to HTML in order to parse the doctype, styles
// and scripts. The JSON will be manually inserted as text.
request.QueryInterface(Ci.nsIChannel);
request.contentType = "text/html";
const headers = getHttpHeaders(request);
// Enforce strict CSP:
try {
request.QueryInterface(Ci.nsIHttpChannel);
request.setResponseHeader("Content-Security-Policy", kCSP, false);
request.setResponseHeader("Content-Security-Policy-Report-Only", "", false);
} catch (ex) {
// If this is not an HTTP channel we can't and won't do anything.
}
// Don't honor the charset parameter and use UTF-8 (see bug 741776).
request.contentCharset = "UTF-8";
this.decoder = new TextDecoder("UTF-8");
// Changing the content type breaks saving functionality. Fix it.
fixSave(request);
// Because content might still have a reference to this window,
// force setting it to a null principal to avoid it being same-
// origin with (other) content.
request.loadInfo.resetPrincipalToInheritToNullPrincipal();
// Start the request.
this.listener.onStartRequest(request);
// Initialize stuff.
const win = NetworkHelper.getWindowForRequest(request);
this.data = exportData(win, headers);
insertJsonData(win, this.data.json);
win.addEventListener("contentMessage", onContentMessage, false, true);
keepThemeUpdated(win);
// Send the initial HTML code.
const buffer = new TextEncoder().encode(initialHTML(win.document)).buffer;
const stream = new BufferStream(buffer, 0, buffer.byteLength);
this.listener.onDataAvailable(request, stream, 0, stream.available());
},
onStopRequest: function(request, statusCode) {
// Flush data.
this.decodeAndInsertBuffer(new ArrayBuffer(0), true);
// Stop the request.
this.listener.onStopRequest(request, statusCode);
this.listener = null;
this.decoder = null;
this.data = null;
},
// Decodes an ArrayBuffer into a string and inserts it into the page.
decodeAndInsertBuffer: function(buffer, flush = false) {
// Decode the buffer into a string.
const data = this.decoder.decode(buffer, {stream: !flush});
// Using `appendData` instead of `textContent +=` is important to avoid
// repainting previous data.
this.data.json.appendData(data);
},
};
// Lets "save as" save the original JSON, not the viewer.
// To save with the proper extension we need the original content type,
// which has been replaced by application/vnd.mozilla.json.view
function fixSave(request) {
let match;
if (request instanceof Ci.nsIHttpChannel) {
try {
const header = request.getResponseHeader("Content-Type");
match = header.match(/^(application\/(?:[^;]+\+)?json)(?:;|$)/);
} catch (err) {
// Handled below
}
} else {
const uri = request.QueryInterface(Ci.nsIChannel).URI.spec;
match = uri.match(/^data:(application\/(?:[^;,]+\+)?json)[;,]/);
}
let originalType;
if (match) {
originalType = match[1];
} else {
originalType = "application/json";
}
request.QueryInterface(Ci.nsIWritablePropertyBag);
request.setProperty("contentType", originalType);
}
function getHttpHeaders(request) {
const headers = {
response: [],
request: [],
};
// The request doesn't have to be always nsIHttpChannel
// (e.g. in case of data: URLs)
if (request instanceof Ci.nsIHttpChannel) {
request.visitResponseHeaders({
visitHeader: function(name, value) {
headers.response.push({name: name, value: value});
},
});
request.visitRequestHeaders({
visitHeader: function(name, value) {
headers.request.push({name: name, value: value});
},
});
}
return headers;
}
// Exports variables that will be accessed by the non-privileged scripts.
function exportData(win, headers) {
const json = new win.Text();
const JSONView = Cu.cloneInto({
debugJsModules,
headers,
json,
readyState: "uninitialized",
Locale: {
$STR: key => {
try {
return jsonViewStrings.GetStringFromName(key);
} catch (err) {
console.error(err);
return undefined;
}
},
},
}, win, {
cloneFunctions: true,
wrapReflectors: true,
});
try {
Object.defineProperty(Cu.waiveXrays(win), "JSONView", {
value: JSONView,
configurable: true,
enumerable: true,
writable: true,
});
} catch (error) {
Cu.reportError(error);
}
return {json};
}
// Builds an HTML string that will be used to load stylesheets and scripts.
function initialHTML(doc) {
// Creates an element with the specified type, attributes and children.
function element(type, attributes = {}, children = []) {
const el = doc.createElement(type);
for (const [attr, value] of Object.entries(attributes)) {
el.setAttribute(attr, value);
}
el.append(...children);
return el;
}
let os;
const platform = Services.appinfo.OS;
if (platform.startsWith("WINNT")) {
os = "win";
} else if (platform.startsWith("Darwin")) {
os = "mac";
} else {
os = "linux";
}
const baseURI = "resource://devtools-client-jsonview/";
return "<!DOCTYPE html>\n" +
element("html", {
"platform": os,
"class": "theme-" + Services.prefs.getCharPref("devtools.theme"),
"dir": Services.locale.isAppLocaleRTL ? "rtl" : "ltr",
}, [
element("head", {}, [
element("meta", {
"http-equiv": "Content-Security-Policy",
content: kCSP,
}),
element("link", {
rel: "stylesheet",
type: "text/css",
href: baseURI + "css/main.css",
}),
]),
element("body", {}, [
element("div", {"id": "content"}, [
element("div", {"id": "json"}),
]),
element("script", {
src: baseURI + "lib/require.js",
"data-main": baseURI + "viewer-config.js",
}),
]),
]).outerHTML;
}
// We insert the received data into a text node, which should be appended into
// the #json element so that the JSON is still displayed even if JS is disabled.
// However, the HTML parser is not synchronous, so this function uses a mutation
// observer to detect the creation of the element. Then the text node is appended.
function insertJsonData(win, json) {
new win.MutationObserver(function(mutations, observer) {
for (const {target, addedNodes} of mutations) {
if (target.nodeType == 1 && target.id == "content") {
for (const node of addedNodes) {
if (node.nodeType == 1 && node.id == "json") {
observer.disconnect();
node.append(json);
return;
}
}
}
}
}).observe(win.document, {
childList: true,
subtree: true,
});
}
function keepThemeUpdated(win) {
const listener = function() {
const theme = Services.prefs.getCharPref("devtools.theme");
win.document.documentElement.className = "theme-" + theme;
};
Services.prefs.addObserver("devtools.theme", listener);
win.addEventListener("unload", function(event) {
Services.prefs.removeObserver("devtools.theme", listener);
win = null;
}, {once: true});
}
// Chrome <-> Content communication
function onContentMessage(e) {
// Do not handle events from different documents.
const win = this;
if (win != e.target) {
return;
}
const value = e.detail.value;
switch (e.detail.type) {
case "save":
Services.cpmm.sendAsyncMessage(
"devtools:jsonview:save", value);
}
}
function createInstance() {
return new Converter();
}
exports.JsonViewService = {
createInstance: createInstance,
};