gecko-dev/toolkit/components/extensions/NativeMessaging.jsm
Kris Maglione 11b59ae527 Bug 1298810: Pass Port object to listeners on native messaging ports. r=rpl
MozReview-Commit-ID: 1saUOB1jyE1

--HG--
extra : rebase_source : 8831300c38bcca9283c25049c463d2b6cf41380d
extra : histedit_source : 3171045999ce6619f5e77af328c90ab3f8fbf994
2016-08-29 13:12:16 -07:00

444 lines
15 KiB
JavaScript

/* 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";
this.EXPORTED_SYMBOLS = ["HostManifestManager", "NativeApp"];
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
const {EventEmitter} = Cu.import("resource://devtools/shared/event-emitter.js", {});
XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
"resource://gre/modules/AppConstants.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
"resource://gre/modules/AsyncShutdown.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionUtils",
"resource://gre/modules/ExtensionUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
"resource://gre/modules/Schemas.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Services",
"resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Subprocess",
"resource://gre/modules/Subprocess.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "clearTimeout",
"resource://gre/modules/Timer.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "setTimeout",
"resource://gre/modules/Timer.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "WindowsRegistry",
"resource://gre/modules/WindowsRegistry.jsm");
const HOST_MANIFEST_SCHEMA = "chrome://extensions/content/schemas/native_host_manifest.json";
const VALID_APPLICATION = /^\w+(\.\w+)*$/;
// For a graceful shutdown (i.e., when the extension is unloaded or when it
// explicitly calls disconnect() on a native port), how long we give the native
// application to exit before we start trying to kill it. (in milliseconds)
const GRACEFUL_SHUTDOWN_TIME = 3000;
// Hard limits on maximum message size that can be read/written
// These are defined in the native messaging documentation, note that
// the write limit is imposed by the "wire protocol" in which message
// boundaries are defined by preceding each message with its length as
// 4-byte unsigned integer so this is the largest value that can be
// represented. Good luck generating a serialized message that large,
// the practical write limit is likely to be dictated by available memory.
const MAX_READ = 1024 * 1024;
const MAX_WRITE = 0xffffffff;
// Preferences that can lower the message size limits above,
// used for testing the limits.
const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes";
const PREF_MAX_WRITE = "webextensions.native-messaging.max-output-message-bytes";
const REGPATH = "Software\\Mozilla\\NativeMessagingHosts";
this.HostManifestManager = {
_initializePromise: null,
_lookup: null,
init() {
if (!this._initializePromise) {
let platform = AppConstants.platform;
if (platform == "win") {
this._lookup = this._winLookup;
} else if (platform == "macosx" || platform == "linux") {
let dirs = [
Services.dirsvc.get("XREUserNativeMessaging", Ci.nsIFile).path,
Services.dirsvc.get("XRESysNativeMessaging", Ci.nsIFile).path,
];
this._lookup = (application, context) => this._tryPaths(application, dirs, context);
} else {
throw new Error(`Native messaging is not supported on ${AppConstants.platform}`);
}
this._initializePromise = Schemas.load(HOST_MANIFEST_SCHEMA);
}
return this._initializePromise;
},
_winLookup(application, context) {
const REGISTRY = Ci.nsIWindowsRegKey;
let regPath = `${REGPATH}\\${application}`;
let path = WindowsRegistry.readRegKey(REGISTRY.ROOT_KEY_CURRENT_USER,
regPath, "", REGISTRY.WOW64_64);
if (!path) {
path = WindowsRegistry.readRegKey(Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
regPath, "", REGISTRY.WOW64_64);
}
if (!path) {
return null;
}
return this._tryPath(path, application, context)
.then(manifest => manifest ? {path, manifest} : null);
},
_tryPath(path, application, context) {
return Promise.resolve()
.then(() => OS.File.read(path, {encoding: "utf-8"}))
.then(data => {
let manifest;
try {
manifest = JSON.parse(data);
} catch (ex) {
let msg = `Error parsing native host manifest ${path}: ${ex.message}`;
Cu.reportError(msg);
return null;
}
let normalized = Schemas.normalize(manifest, "manifest.NativeHostManifest", context);
if (normalized.error) {
Cu.reportError(normalized.error);
return null;
}
manifest = normalized.value;
if (manifest.name != application) {
let msg = `Native host manifest ${path} has name property ${manifest.name} (expected ${application})`;
Cu.reportError(msg);
return null;
}
return normalized.value;
}).catch(ex => {
if (ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
return null;
}
throw ex;
});
},
_tryPaths: Task.async(function* (application, dirs, context) {
for (let dir of dirs) {
let path = OS.Path.join(dir, `${application}.json`);
let manifest = yield this._tryPath(path, application, context);
if (manifest) {
return {path, manifest};
}
}
return null;
}),
/**
* Search for a valid native host manifest for the given application name.
* The directories searched and rules for manifest validation are all
* detailed in the native messaging documentation.
*
* @param {string} application The name of the applciation to search for.
* @param {object} context A context object as expected by Schemas.normalize.
* @returns {object} The contents of the validated manifest, or null if
* no valid manifest can be found for this application.
*/
lookupApplication(application, context) {
if (!VALID_APPLICATION.test(application)) {
throw new Error(`Invalid application "${application}"`);
}
return this.init().then(() => this._lookup(application, context));
},
};
this.NativeApp = class extends EventEmitter {
constructor(extension, context, application) {
super();
this.context = context;
this.name = application;
// We want a close() notification when the window is destroyed.
this.context.callOnClose(this);
this.encoder = new TextEncoder();
this.proc = null;
this.readPromise = null;
this.sendQueue = [];
this.writePromise = null;
this.sentDisconnect = false;
// Grab these once at startup
XPCOMUtils.defineLazyPreferenceGetter(this, "maxRead", PREF_MAX_READ, MAX_READ);
XPCOMUtils.defineLazyPreferenceGetter(this, "maxWrite", PREF_MAX_WRITE, MAX_WRITE);
this.startupPromise = HostManifestManager.lookupApplication(application, context)
.then(hostInfo => {
if (!hostInfo) {
throw new Error(`No such native application ${application}`);
}
if (!hostInfo.manifest.allowed_extensions.includes(extension.id)) {
throw new Error(`This extension does not have permission to use native application ${application}`);
}
let command = hostInfo.manifest.path;
if (AppConstants.platform == "win") {
// OS.Path.join() ignores anything before the last absolute path
// it sees, so if command is already absolute, it remains unchanged
// here. If it is relative, we get the proper absolute path here.
command = OS.Path.join(OS.Path.dirname(hostInfo.path), command);
}
let subprocessOpts = {
command: command,
arguments: [hostInfo.path],
workdir: OS.Path.dirname(command),
stderr: "pipe",
};
return Subprocess.call(subprocessOpts);
}).then(proc => {
this.startupPromise = null;
this.proc = proc;
this._startRead();
this._startWrite();
this._startStderrRead();
}).catch(err => {
this.startupPromise = null;
Cu.reportError(err instanceof Error ? err : err.message);
this._cleanup(err);
});
}
// A port is definitely "alive" if this.proc is non-null. But we have
// to provide a live port object immediately when connecting so we also
// need to consider a port alive if proc is null but the startupPromise
// is still pending.
get _isDisconnected() {
return (!this.proc && !this.startupPromise);
}
_startRead() {
if (this.readPromise) {
throw new Error("Entered _startRead() while readPromise is non-null");
}
this.readPromise = this.proc.stdout.readUint32()
.then(len => {
if (len > this.maxRead) {
throw new Error(`Native application tried to send a message of ${len} bytes, which exceeds the limit of ${this.maxRead} bytes.`);
}
return this.proc.stdout.readJSON(len);
}).then(msg => {
this.emit("message", msg);
this.readPromise = null;
this._startRead();
}).catch(err => {
if (err.errorCode != Subprocess.ERROR_END_OF_FILE) {
Cu.reportError(err instanceof Error ? err : err.message);
}
this._cleanup(err);
});
}
_startWrite() {
if (this.sendQueue.length == 0) {
return;
}
if (this.writePromise) {
throw new Error("Entered _startWrite() while writePromise is non-null");
}
let buffer = this.sendQueue.shift();
let uintArray = Uint32Array.of(buffer.byteLength);
this.writePromise = Promise.all([
this.proc.stdin.write(uintArray.buffer),
this.proc.stdin.write(buffer),
]).then(() => {
this.writePromise = null;
this._startWrite();
}).catch(err => {
Cu.reportError(err.message);
this._cleanup(err);
});
}
_startStderrRead() {
let proc = this.proc;
let app = this.name;
Task.spawn(function* () {
let partial = "";
while (true) {
let data = yield proc.stderr.readString();
if (data.length == 0) {
// We have hit EOF, just stop reading
if (partial) {
Services.console.logStringMessage(`stderr output from native app ${app}: ${partial}`);
}
break;
}
let lines = data.split(/\r?\n/);
lines[0] = partial + lines[0];
partial = lines.pop();
for (let line of lines) {
Services.console.logStringMessage(`stderr output from native app ${app}: ${line}`);
}
}
});
}
send(msg) {
if (this._isDisconnected) {
throw new this.context.cloneScope.Error("Attempt to postMessage on disconnected port");
}
let json;
try {
json = this.context.jsonStringify(msg);
} catch (err) {
throw new this.context.cloneScope.Error(err.message);
}
let buffer = this.encoder.encode(json).buffer;
if (buffer.byteLength > this.maxWrite) {
throw new this.context.cloneScope.Error("Write too big");
}
this.sendQueue.push(buffer);
if (!this.startupPromise && !this.writePromise) {
this._startWrite();
}
}
// Shut down the native application and also signal to the extension
// that the connect has been disconnected.
_cleanup(err) {
this.context.forgetOnClose(this);
let doCleanup = () => {
// Set a timer to kill the process gracefully after one timeout
// interval and kill it forcefully after two intervals.
let timer = setTimeout(() => {
this.proc.kill(GRACEFUL_SHUTDOWN_TIME);
}, GRACEFUL_SHUTDOWN_TIME);
let promise = Promise.all([
this.proc.stdin.close()
.catch(err => {
if (err.errorCode != Subprocess.ERROR_END_OF_FILE) {
throw err;
}
}),
this.proc.wait(),
]).then(() => {
this.proc = null;
clearTimeout(timer);
});
AsyncShutdown.profileBeforeChange.addBlocker(
`Native Messaging: Wait for application ${this.name} to exit`,
promise);
promise.then(() => {
AsyncShutdown.profileBeforeChange.removeBlocker(promise);
});
return promise;
};
if (this.proc) {
doCleanup();
} else if (this.startupPromise) {
this.startupPromise.then(doCleanup);
}
if (!this.sentDisconnect) {
this.sentDisconnect = true;
this.emit("disconnect", err);
}
}
// Called from Context when the extension is shut down.
close() {
this._cleanup();
}
portAPI() {
let port = {
name: this.name,
disconnect: () => {
if (this._isDisconnected) {
throw new this.context.cloneScope.Error("Attempt to disconnect an already disconnected port");
}
this._cleanup();
},
postMessage: msg => {
this.send(msg);
},
onDisconnect: new ExtensionUtils.SingletonEventManager(this.context, "native.onDisconnect", fire => {
let listener = what => {
this.context.runSafeWithoutClone(fire, port);
};
this.on("disconnect", listener);
return () => {
this.off("disconnect", listener);
};
}).api(),
onMessage: new ExtensionUtils.SingletonEventManager(this.context, "native.onMessage", fire => {
let listener = (what, msg) => {
msg = Cu.cloneInto(msg, this.context.cloneScope);
this.context.runSafeWithoutClone(fire, msg, port);
};
this.on("message", listener);
return () => {
this.off("message", listener);
};
}).api(),
};
port = Cu.cloneInto(port, this.context.cloneScope, {cloneFunctions: true});
return port;
}
sendMessage(msg) {
let responsePromise = new Promise((resolve, reject) => {
this.on("message", (what, msg) => { resolve(msg); });
this.on("disconnect", (what, err) => { reject(err); });
});
let result = this.startupPromise.then(() => {
this.send(msg);
return responsePromise;
});
result.then(() => {
this._cleanup();
}, () => {
// Prevent the response promise from being reported as an
// unchecked rejection if the startup promise fails.
responsePromise.catch(() => {});
this._cleanup();
});
return result;
}
};