summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/NativeMessaging.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/extensions/NativeMessaging.sys.mjs396
1 files changed, 396 insertions, 0 deletions
diff --git a/toolkit/components/extensions/NativeMessaging.sys.mjs b/toolkit/components/extensions/NativeMessaging.sys.mjs
new file mode 100644
index 0000000000..20924f169d
--- /dev/null
+++ b/toolkit/components/extensions/NativeMessaging.sys.mjs
@@ -0,0 +1,396 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ NativeManifests: "resource://gre/modules/NativeManifests.sys.mjs",
+ Subprocess: "resource://gre/modules/Subprocess.sys.mjs",
+});
+
+const { ExtensionError, promiseTimeout } = ExtensionUtils;
+
+// 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";
+
+export var NativeApp = class extends EventEmitter {
+ /**
+ * @param {BaseContext} context The context that initiated the native app.
+ * @param {string} application The identifier of the native app.
+ */
+ constructor(context, application) {
+ super();
+
+ this.context = context;
+ this.name = application;
+
+ // We want a close() notification when the window is destroyed.
+ this.context.callOnClose(this);
+
+ this.proc = null;
+ this.readPromise = null;
+ this.sendQueue = [];
+ this.writePromise = null;
+ this.cleanupStarted = false;
+
+ this.startupPromise = lazy.NativeManifests.lookupManifest(
+ "stdio",
+ application,
+ context
+ )
+ .then(hostInfo => {
+ // Report a generic error to not leak information about whether a native
+ // application is installed to addons that do not have the right permission.
+ if (!hostInfo) {
+ throw new ExtensionError(`No such native application ${application}`);
+ }
+
+ let command = hostInfo.manifest.path;
+ if (AppConstants.platform == "win") {
+ // Normalize in case the extension used / instead of \.
+ command = command.replaceAll("/", "\\");
+
+ if (!PathUtils.isAbsolute(command)) {
+ // Note: hostInfo.path is an absolute path to the manifest.
+ const parentPath = PathUtils.parent(
+ hostInfo.path.replaceAll("/", "\\")
+ );
+ // PathUtils.joinRelative cannot be used because it throws for "..".
+ // but command is allowed to contain ".." to traverse the directory.
+ command = `${parentPath}\\${command}`;
+ }
+ } else if (!PathUtils.isAbsolute(command)) {
+ // Only windows supports relative paths.
+ throw new Error(
+ "NativeApp requires absolute path to command on this platform"
+ );
+ }
+
+ let subprocessOpts = {
+ command: command,
+ arguments: [hostInfo.path, context.extension.id],
+ workdir: PathUtils.parent(command),
+ stderr: "pipe",
+ disclaim: true,
+ };
+
+ return lazy.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);
+ });
+ }
+
+ /**
+ * Open a connection to a native messaging host.
+ *
+ * @param {number} portId A unique internal ID that identifies the port.
+ * @param {NativeMessenger} port Parent NativeMessenger used to send messages.
+ * @returns {ParentPort}
+ */
+ onConnect(portId, port) {
+ // eslint-disable-next-line
+ this.on("message", (_, message) => {
+ port.sendPortMessage(
+ portId,
+ new StructuredCloneHolder(
+ `NativeMessaging/onConnect/${this.name}`,
+ null,
+ message
+ )
+ );
+ });
+ this.once("disconnect", (_, error) => {
+ port.sendPortDisconnect(portId, error && new ClonedErrorHolder(error));
+ });
+ return {
+ onPortMessage: holder => this.send(holder),
+ onPortDisconnect: () => this.close(),
+ };
+ }
+
+ /**
+ * @param {BaseContext} context The scope from where `message` originates.
+ * @param {*} message A message from the extension, meant for a native app.
+ * @returns {ArrayBuffer} An ArrayBuffer that can be sent to the native app.
+ */
+ static encodeMessage(context, message) {
+ message = context.jsonStringify(message);
+ let buffer = new TextEncoder().encode(message).buffer;
+ if (buffer.byteLength > NativeApp.maxWrite) {
+ throw new context.Error("Write too big");
+ }
+ return buffer;
+ }
+
+ // 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 > NativeApp.maxRead) {
+ throw new ExtensionError(
+ `Native application tried to send a message of ${len} bytes, which exceeds the limit of ${NativeApp.maxRead} bytes.`
+ );
+ }
+ return this.proc.stdout.readJSON(len);
+ })
+ .then(msg => {
+ this.emit("message", msg);
+ this.readPromise = null;
+ this._startRead();
+ })
+ .catch(err => {
+ if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) {
+ Cu.reportError(err instanceof Error ? err : err.message);
+ }
+ this._cleanup(err);
+ });
+ }
+
+ _startWrite() {
+ if (!this.sendQueue.length) {
+ 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;
+ (async function () {
+ let partial = "";
+ while (true) {
+ let data = await proc.stderr.readString();
+ if (!data.length) {
+ // 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(holder) {
+ if (this._isDisconnected) {
+ throw new ExtensionError("Attempt to postMessage on disconnected port");
+ }
+ let msg = holder.deserialize(globalThis);
+ if (Cu.getClassName(msg, true) != "ArrayBuffer") {
+ // This error cannot be triggered by extensions; it indicates an error in
+ // our implementation.
+ throw new Error(
+ "The message to the native messaging host is not an ArrayBuffer"
+ );
+ }
+
+ let buffer = msg;
+
+ if (buffer.byteLength > NativeApp.maxWrite) {
+ throw new ExtensionError("Write too big");
+ }
+
+ this.sendQueue.push(buffer);
+ if (!this.startupPromise && !this.writePromise) {
+ this._startWrite();
+ }
+ }
+
+ // Shut down the native application and (by default) signal to the extension
+ // that the connect has been disconnected.
+ async _cleanup(err, fromExtension = false) {
+ if (this.cleanupStarted) {
+ return;
+ }
+ this.cleanupStarted = true;
+ this.context.forgetOnClose(this);
+
+ if (!fromExtension) {
+ if (err && err.errorCode == lazy.Subprocess.ERROR_END_OF_FILE) {
+ err = null;
+ }
+ this.emit("disconnect", err);
+ }
+
+ await this.startupPromise;
+
+ if (!this.proc) {
+ // Failed to initialize proc in the constructor.
+ return;
+ }
+
+ // To prevent an uncooperative process from blocking shutdown, we take the
+ // following actions, and wait for GRACEFUL_SHUTDOWN_TIME in between.
+ //
+ // 1. Allow exit by closing the stdin pipe.
+ // 2. Allow exit by a kill signal.
+ // 3. Allow exit by forced kill signal.
+ // 4. Give up and unblock shutdown despite the process still being alive.
+
+ // Close the stdin stream and allow the process to exit on its own.
+ // proc.wait() below will resolve once the process has exited gracefully.
+ this.proc.stdin.close().catch(err => {
+ if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) {
+ Cu.reportError(err);
+ }
+ });
+ let exitPromise = Promise.race([
+ // 1. Allow the process to exit on its own after closing stdin.
+ this.proc.wait().then(() => {
+ this.proc = null;
+ }),
+ promiseTimeout(GRACEFUL_SHUTDOWN_TIME).then(() => {
+ if (this.proc) {
+ // 2. Kill the process gracefully. 3. Force kill after a timeout.
+ this.proc.kill(GRACEFUL_SHUTDOWN_TIME);
+
+ // 4. If the process is still alive after a kill + timeout followed
+ // by a forced kill + timeout, give up and just resolve exitPromise.
+ //
+ // Note that waiting for just one interval is not enough, because the
+ // `proc.kill()` is asynchronous, so we need to wait a bit after the
+ // kill signal has been sent.
+ return promiseTimeout(2 * GRACEFUL_SHUTDOWN_TIME);
+ }
+ }),
+ ]);
+
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ `Native Messaging: Wait for application ${this.name} to exit`,
+ exitPromise
+ );
+ }
+
+ // Called when the Context or Port is closed.
+ close() {
+ this._cleanup(null, true);
+ }
+
+ sendMessage(holder) {
+ let responsePromise = new Promise((resolve, reject) => {
+ this.once("message", (what, msg) => {
+ resolve(msg);
+ });
+ this.once("disconnect", (what, err) => {
+ reject(err);
+ });
+ });
+
+ let result = this.startupPromise.then(() => {
+ // Skip .send() if _cleanup() has been called already;
+ // otherwise the error passed to _cleanup/"disconnect" would be hidden by the
+ // "Attempt to postMessage on disconnected port" error from this.send().
+ if (!this.cleanupStarted) {
+ this.send(holder);
+ }
+ 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;
+ }
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ NativeApp,
+ "maxRead",
+ PREF_MAX_READ,
+ MAX_READ
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ NativeApp,
+ "maxWrite",
+ PREF_MAX_WRITE,
+ MAX_WRITE
+);