Bug 1871127 - Add tsconfig, basic types, and fix or ignore remaining type errors...
[gecko.git] / toolkit / components / extensions / NativeMessaging.sys.mjs
blobdcd8fe780789255167a003bdc286e1197a609847
1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
2 /* vim: set sts=2 sw=2 et tw=80: */
3 /* This Source Code Form is subject to the terms of the Mozilla Public
4  * License, v. 2.0. If a copy of the MPL was not distributed with this
5  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
8 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
9 import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
10 import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
12 const lazy = {};
14 ChromeUtils.defineESModuleGetters(lazy, {
15   AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
16   NativeManifests: "resource://gre/modules/NativeManifests.sys.mjs",
17   Subprocess: "resource://gre/modules/Subprocess.sys.mjs",
18 });
20 const { ExtensionError, promiseTimeout } = ExtensionUtils;
22 // For a graceful shutdown (i.e., when the extension is unloaded or when it
23 // explicitly calls disconnect() on a native port), how long we give the native
24 // application to exit before we start trying to kill it.  (in milliseconds)
25 const GRACEFUL_SHUTDOWN_TIME = 3000;
27 // Hard limits on maximum message size that can be read/written
28 // These are defined in the native messaging documentation, note that
29 // the write limit is imposed by the "wire protocol" in which message
30 // boundaries are defined by preceding each message with its length as
31 // 4-byte unsigned integer so this is the largest value that can be
32 // represented.  Good luck generating a serialized message that large,
33 // the practical write limit is likely to be dictated by available memory.
34 const MAX_READ = 1024 * 1024;
35 const MAX_WRITE = 0xffffffff;
37 // Preferences that can lower the message size limits above,
38 // used for testing the limits.
39 const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes";
40 const PREF_MAX_WRITE =
41   "webextensions.native-messaging.max-output-message-bytes";
43 XPCOMUtils.defineLazyPreferenceGetter(lazy, "maxRead", PREF_MAX_READ, MAX_READ);
44 XPCOMUtils.defineLazyPreferenceGetter(
45   lazy,
46   "maxWrite",
47   PREF_MAX_WRITE,
48   MAX_WRITE
51 export class NativeApp extends EventEmitter {
52   /**
53    * @param {BaseContext} context The context that initiated the native app.
54    * @param {string} application The identifier of the native app.
55    */
56   constructor(context, application) {
57     super();
59     this.context = context;
60     this.name = application;
62     // We want a close() notification when the window is destroyed.
63     this.context.callOnClose(this);
65     this.proc = null;
66     this.readPromise = null;
67     this.sendQueue = [];
68     this.writePromise = null;
69     this.cleanupStarted = false;
71     this.startupPromise = lazy.NativeManifests.lookupManifest(
72       "stdio",
73       application,
74       context
75     )
76       .then(hostInfo => {
77         // Report a generic error to not leak information about whether a native
78         // application is installed to addons that do not have the right permission.
79         if (!hostInfo) {
80           throw new ExtensionError(`No such native application ${application}`);
81         }
83         let command = hostInfo.manifest.path;
84         if (AppConstants.platform == "win") {
85           // Normalize in case the extension used / instead of \.
86           command = command.replaceAll("/", "\\");
88           if (!PathUtils.isAbsolute(command)) {
89             // Note: hostInfo.path is an absolute path to the manifest.
90             const parentPath = PathUtils.parent(
91               hostInfo.path.replaceAll("/", "\\")
92             );
93             // PathUtils.joinRelative cannot be used because it throws for "..".
94             // but command is allowed to contain ".." to traverse the directory.
95             command = `${parentPath}\\${command}`;
96           }
97         } else if (!PathUtils.isAbsolute(command)) {
98           // Only windows supports relative paths.
99           throw new Error(
100             "NativeApp requires absolute path to command on this platform"
101           );
102         }
104         let subprocessOpts = {
105           command: command,
106           arguments: [hostInfo.path, context.extension.id],
107           workdir: PathUtils.parent(command),
108           stderr: "pipe",
109           disclaim: true,
110         };
112         return lazy.Subprocess.call(subprocessOpts);
113       })
114       .then(proc => {
115         this.startupPromise = null;
116         this.proc = proc;
117         this._startRead();
118         this._startWrite();
119         this._startStderrRead();
120       })
121       .catch(err => {
122         this.startupPromise = null;
123         Cu.reportError(err instanceof Error ? err : err.message);
124         this._cleanup(err);
125       });
126   }
128   /**
129    * Open a connection to a native messaging host.
130    *
131    * @param {number} portId A unique internal ID that identifies the port.
132    * @param {import("ExtensionParent.sys.mjs").NativeMessenger} port Parent NativeMessenger used to send messages.
133    * @returns {import("ExtensionParent.sys.mjs").ParentPort}
134    */
135   onConnect(portId, port) {
136     // eslint-disable-next-line
137     this.on("message", (_, message) => {
138       port.sendPortMessage(
139         portId,
140         new StructuredCloneHolder(
141           `NativeMessaging/onConnect/${this.name}`,
142           null,
143           message
144         )
145       );
146     });
147     this.once("disconnect", (_, error) => {
148       port.sendPortDisconnect(portId, error && new ClonedErrorHolder(error));
149     });
150     return {
151       onPortMessage: holder => this.send(holder),
152       onPortDisconnect: () => this.close(),
153     };
154   }
156   /**
157    * @param {BaseContext} context The scope from where `message` originates.
158    * @param {*} message A message from the extension, meant for a native app.
159    * @returns {ArrayBuffer} An ArrayBuffer that can be sent to the native app.
160    */
161   static encodeMessage(context, message) {
162     message = context.jsonStringify(message);
163     let buffer = new TextEncoder().encode(message).buffer;
164     if (buffer.byteLength > lazy.maxWrite) {
165       throw new context.Error("Write too big");
166     }
167     return buffer;
168   }
170   // A port is definitely "alive" if this.proc is non-null.  But we have
171   // to provide a live port object immediately when connecting so we also
172   // need to consider a port alive if proc is null but the startupPromise
173   // is still pending.
174   get _isDisconnected() {
175     return !this.proc && !this.startupPromise;
176   }
178   _startRead() {
179     if (this.readPromise) {
180       throw new Error("Entered _startRead() while readPromise is non-null");
181     }
182     this.readPromise = this.proc.stdout
183       .readUint32()
184       .then(len => {
185         if (len > lazy.maxRead) {
186           throw new ExtensionError(
187             `Native application tried to send a message of ${len} bytes, which exceeds the limit of ${lazy.maxRead} bytes.`
188           );
189         }
190         return this.proc.stdout.readJSON(len);
191       })
192       .then(msg => {
193         this.emit("message", msg);
194         this.readPromise = null;
195         this._startRead();
196       })
197       .catch(err => {
198         if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) {
199           Cu.reportError(err instanceof Error ? err : err.message);
200         }
201         this._cleanup(err);
202       });
203   }
205   _startWrite() {
206     if (!this.sendQueue.length) {
207       return;
208     }
210     if (this.writePromise) {
211       throw new Error("Entered _startWrite() while writePromise is non-null");
212     }
214     let buffer = this.sendQueue.shift();
215     let uintArray = Uint32Array.of(buffer.byteLength);
217     this.writePromise = Promise.all([
218       this.proc.stdin.write(uintArray.buffer),
219       this.proc.stdin.write(buffer),
220     ])
221       .then(() => {
222         this.writePromise = null;
223         this._startWrite();
224       })
225       .catch(err => {
226         Cu.reportError(err.message);
227         this._cleanup(err);
228       });
229   }
231   _startStderrRead() {
232     let proc = this.proc;
233     let app = this.name;
234     (async function () {
235       let partial = "";
236       while (true) {
237         let data = await proc.stderr.readString();
238         if (!data.length) {
239           // We have hit EOF, just stop reading
240           if (partial) {
241             Services.console.logStringMessage(
242               `stderr output from native app ${app}: ${partial}`
243             );
244           }
245           break;
246         }
248         let lines = data.split(/\r?\n/);
249         lines[0] = partial + lines[0];
250         partial = lines.pop();
252         for (let line of lines) {
253           Services.console.logStringMessage(
254             `stderr output from native app ${app}: ${line}`
255           );
256         }
257       }
258     })();
259   }
261   send(holder) {
262     if (this._isDisconnected) {
263       throw new ExtensionError("Attempt to postMessage on disconnected port");
264     }
265     let msg = holder.deserialize(globalThis);
266     if (Cu.getClassName(msg, true) != "ArrayBuffer") {
267       // This error cannot be triggered by extensions; it indicates an error in
268       // our implementation.
269       throw new Error(
270         "The message to the native messaging host is not an ArrayBuffer"
271       );
272     }
274     let buffer = msg;
276     if (buffer.byteLength > lazy.maxWrite) {
277       throw new ExtensionError("Write too big");
278     }
280     this.sendQueue.push(buffer);
281     if (!this.startupPromise && !this.writePromise) {
282       this._startWrite();
283     }
284   }
286   // Shut down the native application and (by default) signal to the extension
287   // that the connect has been disconnected.
288   async _cleanup(err, fromExtension = false) {
289     if (this.cleanupStarted) {
290       return;
291     }
292     this.cleanupStarted = true;
293     this.context.forgetOnClose(this);
295     if (!fromExtension) {
296       if (err && err.errorCode == lazy.Subprocess.ERROR_END_OF_FILE) {
297         err = null;
298       }
299       this.emit("disconnect", err);
300     }
302     await this.startupPromise;
304     if (!this.proc) {
305       // Failed to initialize proc in the constructor.
306       return;
307     }
309     // To prevent an uncooperative process from blocking shutdown, we take the
310     // following actions, and wait for GRACEFUL_SHUTDOWN_TIME in between.
311     //
312     // 1. Allow exit by closing the stdin pipe.
313     // 2. Allow exit by a kill signal.
314     // 3. Allow exit by forced kill signal.
315     // 4. Give up and unblock shutdown despite the process still being alive.
317     // Close the stdin stream and allow the process to exit on its own.
318     // proc.wait() below will resolve once the process has exited gracefully.
319     this.proc.stdin.close().catch(err => {
320       if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) {
321         Cu.reportError(err);
322       }
323     });
324     let exitPromise = Promise.race([
325       // 1. Allow the process to exit on its own after closing stdin.
326       this.proc.wait().then(() => {
327         this.proc = null;
328       }),
329       promiseTimeout(GRACEFUL_SHUTDOWN_TIME).then(() => {
330         if (this.proc) {
331           // 2. Kill the process gracefully. 3. Force kill after a timeout.
332           this.proc.kill(GRACEFUL_SHUTDOWN_TIME);
334           // 4. If the process is still alive after a kill + timeout followed
335           // by a forced kill + timeout, give up and just resolve exitPromise.
336           //
337           // Note that waiting for just one interval is not enough, because the
338           // `proc.kill()` is asynchronous, so we need to wait a bit after the
339           // kill signal has been sent.
340           return promiseTimeout(2 * GRACEFUL_SHUTDOWN_TIME);
341         }
342       }),
343     ]);
345     lazy.AsyncShutdown.profileBeforeChange.addBlocker(
346       `Native Messaging: Wait for application ${this.name} to exit`,
347       exitPromise
348     );
349   }
351   // Called when the Context or Port is closed.
352   close() {
353     this._cleanup(null, true);
354   }
356   sendMessage(holder) {
357     let responsePromise = new Promise((resolve, reject) => {
358       this.once("message", (what, msg) => {
359         resolve(msg);
360       });
361       this.once("disconnect", (what, err) => {
362         reject(err);
363       });
364     });
366     let result = this.startupPromise.then(() => {
367       // Skip .send() if _cleanup() has been called already;
368       // otherwise the error passed to _cleanup/"disconnect" would be hidden by the
369       // "Attempt to postMessage on disconnected port" error from this.send().
370       if (!this.cleanupStarted) {
371         this.send(holder);
372       }
373       return responsePromise;
374     });
376     result.then(
377       () => {
378         this._cleanup();
379       },
380       () => {
381         // Prevent the response promise from being reported as an
382         // unchecked rejection if the startup promise fails.
383         responsePromise.catch(() => {});
385         this._cleanup();
386       }
387     );
389     return result;
390   }