Backed out 3 changesets (bug 1884623) for causing multiple failures CLOSED TREE
[gecko.git] / toolkit / components / taskscheduler / TaskSchedulerMacOSImpl.sys.mjs
blobd47d3c5c14ed431f14c299afa84ac687e834a58e
1 /* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
9 const lazy = {};
11 ChromeUtils.defineESModuleGetters(lazy, {
12   Subprocess: "resource://gre/modules/Subprocess.sys.mjs",
13 });
15 XPCOMUtils.defineLazyServiceGetters(lazy, {
16   XreDirProvider: [
17     "@mozilla.org/xre/directory-provider;1",
18     "nsIXREDirProvider",
19   ],
20 });
22 ChromeUtils.defineLazyGetter(lazy, "log", () => {
23   let { ConsoleAPI } = ChromeUtils.importESModule(
24     "resource://gre/modules/Console.sys.mjs"
25   );
26   let consoleOptions = {
27     // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
28     // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
29     maxLogLevel: "error",
30     maxLogLevelPref: "toolkit.components.taskscheduler.loglevel",
31     prefix: "TaskScheduler",
32   };
33   return new ConsoleAPI(consoleOptions);
34 });
36 /**
37  * Task generation and management for macOS, using `launchd` via `launchctl`.
38  *
39  * Implements the API exposed in TaskScheduler.jsm
40  * Not intended for external use, this is in a separate module to ship the code only
41  * on macOS, and to expose for testing.
42  */
43 export var MacOSImpl = {
44   async registerTask(id, command, intervalSeconds, options) {
45     lazy.log.info(
46       `registerTask(${id}, ${command}, ${intervalSeconds}, ${JSON.stringify(
47         options
48       )})`
49     );
51     let uid = await this._uid();
52     lazy.log.debug(`registerTask: uid=${uid}`);
54     let label = this._formatLabelForThisApp(id, options);
56     // We ignore `options.disabled`, which is test only.
57     //
58     // The `Disabled` key prevents `launchd` from registering the task, with
59     // exit code 133 and error message "Service is disabled".  If we really want
60     // this flow in the future, there is `launchctl disable ...`, but it's
61     // fraught with peril: the disabled status is stored outside of any plist,
62     // and it persists even after the task is deleted.  Monkeying with the
63     // disabled status will likely prevent users from disabling these tasks
64     // forcibly, should it come to that.  All told, fraught.
65     //
66     // For the future: there is the `RunAtLoad` key, should we want to run the
67     // task once immediately.
68     let plist = {};
69     plist.Label = label;
70     plist.ProgramArguments = [command];
71     if (options.args) {
72       plist.ProgramArguments.push(...options.args);
73     }
74     plist.StartInterval = intervalSeconds;
75     if (options.workingDirectory) {
76       plist.WorkingDirectory = options.workingDirectory;
77     }
79     let str = this._formatLaunchdPlist(plist);
80     let path = this._formatPlistPath(label);
82     await IOUtils.write(path, new TextEncoder().encode(str));
83     lazy.log.debug(`registerTask: wrote ${path}`);
85     try {
86       let bootout = await lazy.Subprocess.call({
87         command: "/bin/launchctl",
88         arguments: ["bootout", `gui/${uid}/${label}`],
89         stderr: "stdout",
90       });
92       lazy.log.debug(
93         "registerTask: bootout stdout",
94         await bootout.stdout.readString()
95       );
97       let { exitCode } = await bootout.wait();
98       lazy.log.debug(`registerTask: bootout returned ${exitCode}`);
100       let bootstrap = await lazy.Subprocess.call({
101         command: "/bin/launchctl",
102         arguments: ["bootstrap", `gui/${uid}`, path],
103         stderr: "stdout",
104       });
106       lazy.log.debug(
107         "registerTask: bootstrap stdout",
108         await bootstrap.stdout.readString()
109       );
111       ({ exitCode } = await bootstrap.wait());
112       lazy.log.debug(`registerTask: bootstrap returned ${exitCode}`);
114       if (exitCode != 0) {
115         throw new Components.Exception(
116           `Failed to run launchctl bootstrap: ${exitCode}`,
117           Cr.NS_ERROR_UNEXPECTED
118         );
119       }
120     } catch (e) {
121       // Try to clean up.
122       await IOUtils.remove(path, { ignoreAbsent: true });
123       throw e;
124     }
126     return true;
127   },
129   async deleteTask(id, options) {
130     lazy.log.info(`deleteTask(${id})`);
132     let label = this._formatLabelForThisApp(id, options);
133     return this._deleteTaskByLabel(label);
134   },
136   async _deleteTaskByLabel(label) {
137     let path = this._formatPlistPath(label);
138     lazy.log.debug(`_deleteTaskByLabel: removing ${path}`);
139     await IOUtils.remove(path, { ignoreAbsent: true });
141     let uid = await this._uid();
142     lazy.log.debug(`_deleteTaskByLabel: uid=${uid}`);
144     let bootout = await lazy.Subprocess.call({
145       command: "/bin/launchctl",
146       arguments: ["bootout", `gui/${uid}/${label}`],
147       stderr: "stdout",
148     });
150     let { exitCode } = await bootout.wait();
151     lazy.log.debug(`_deleteTaskByLabel: bootout returned ${exitCode}`);
152     lazy.log.debug(
153       `_deleteTaskByLabel: bootout stdout`,
154       await bootout.stdout.readString()
155     );
157     return !exitCode;
158   },
160   // For internal and testing use only.
161   async _listAllLabelsForThisApp() {
162     let proc = await lazy.Subprocess.call({
163       command: "/bin/launchctl",
164       arguments: ["list"],
165       stderr: "stdout",
166     });
168     let { exitCode } = await proc.wait();
169     if (exitCode != 0) {
170       throw new Components.Exception(
171         `Failed to run /bin/launchctl list: ${exitCode}`,
172         Cr.NS_ERROR_UNEXPECTED
173       );
174     }
176     let stdout = await proc.stdout.readString();
178     let lines = stdout.split(/\r\n|\n|\r/);
179     let labels = lines
180       .map(line => line.split("\t").pop()) // Lines are like "-\t0\tlabel".
181       .filter(this._labelMatchesThisApp);
183     lazy.log.debug(`_listAllLabelsForThisApp`, labels);
184     return labels;
185   },
187   async deleteAllTasks() {
188     lazy.log.info(`deleteAllTasks()`);
190     let labelsToDelete = await this._listAllLabelsForThisApp();
192     let deleted = 0;
193     let failed = 0;
194     for (const label of labelsToDelete) {
195       try {
196         if (await this._deleteTaskByLabel(label)) {
197           deleted += 1;
198         } else {
199           failed += 1;
200         }
201       } catch (e) {
202         failed += 1;
203       }
204     }
206     let result = { deleted, failed };
207     lazy.log.debug(`deleteAllTasks: returning ${JSON.stringify(result)}`);
208   },
210   async taskExists(id, options) {
211     const label = this._formatLabelForThisApp(id, options);
212     const path = this._formatPlistPath(label);
213     return IOUtils.exists(path);
214   },
216   /**
217    * Turn an object into a macOS plist.
218    *
219    * Properties of type array-of-string, dict-of-string, string,
220    * number, and boolean are supported.
221    *
222    * @param   options object to turn into macOS plist.
223    * @returns plist as an XML DOM object.
224    */
225   _toLaunchdPlist(options) {
226     const doc = new DOMParser().parseFromString("<plist></plist>", "text/xml");
227     const root = doc.documentElement;
228     root.setAttribute("version", "1.0");
230     let dict = doc.createElement("dict");
231     root.appendChild(dict);
233     for (let [k, v] of Object.entries(options)) {
234       let key = doc.createElement("key");
235       key.textContent = k;
236       dict.appendChild(key);
238       if (Array.isArray(v)) {
239         let array = doc.createElement("array");
240         dict.appendChild(array);
242         for (let vv of v) {
243           let string = doc.createElement("string");
244           string.textContent = vv;
245           array.appendChild(string);
246         }
247       } else if (typeof v === "object") {
248         let d = doc.createElement("dict");
249         dict.appendChild(d);
251         for (let [kk, vv] of Object.entries(v)) {
252           key = doc.createElement("key");
253           key.textContent = kk;
254           d.appendChild(key);
256           let string = doc.createElement("string");
257           string.textContent = vv;
258           d.appendChild(string);
259         }
260       } else if (typeof v === "number") {
261         let number = doc.createElement(
262           Number.isInteger(v) ? "integer" : "real"
263         );
264         number.textContent = v;
265         dict.appendChild(number);
266       } else if (typeof v === "string") {
267         let string = doc.createElement("string");
268         string.textContent = v;
269         dict.appendChild(string);
270       } else if (typeof v === "boolean") {
271         let bool = doc.createElement(v ? "true" : "false");
272         dict.appendChild(bool);
273       }
274     }
276     return doc;
277   },
279   /**
280    * Turn an object into a macOS plist encoded as a string.
281    *
282    * Properties of type array-of-string, dict-of-string, string,
283    * number, and boolean are supported.
284    *
285    * @param   options object to turn into macOS plist.
286    * @returns plist as a string.
287    */
288   _formatLaunchdPlist(options) {
289     let doc = this._toLaunchdPlist(options);
291     let serializer = new XMLSerializer();
292     return serializer.serializeToString(doc);
293   },
295   _formatLabelForThisApp(id, options) {
296     let installHash = lazy.XreDirProvider.getInstallHash();
297     return `${AppConstants.MOZ_MACBUNDLE_ID}.${installHash}.${id}`;
298   },
300   _labelMatchesThisApp(label, options) {
301     let installHash = lazy.XreDirProvider.getInstallHash();
302     return (
303       label &&
304       label.startsWith(`${AppConstants.MOZ_MACBUNDLE_ID}.${installHash}.`)
305     );
306   },
308   _formatPlistPath(label) {
309     let file = Services.dirsvc.get("Home", Ci.nsIFile);
310     file.append("Library");
311     file.append("LaunchAgents");
312     file.append(`${label}.plist`);
313     return file.path;
314   },
316   _cachedUid: -1,
318   async _uid() {
319     if (this._cachedUid >= 0) {
320       return this._cachedUid;
321     }
323     // There are standard APIs for determining our current UID, but this
324     // is easy and parallel to the general tactics used by this module.
325     let proc = await lazy.Subprocess.call({
326       command: "/usr/bin/id",
327       arguments: ["-u"],
328       stderr: "stdout",
329     });
331     let stdout = await proc.stdout.readString();
333     let { exitCode } = await proc.wait();
334     if (exitCode != 0) {
335       throw new Components.Exception(
336         `Failed to run /usr/bin/id: ${exitCode}`,
337         Cr.NS_ERROR_UNEXPECTED
338       );
339     }
341     try {
342       this._cachedUid = Number.parseInt(stdout);
343       return this._cachedUid;
344     } catch (e) {
345       throw new Components.Exception(
346         `Failed to parse /usr/bin/id output as integer: ${stdout}`,
347         Cr.NS_ERROR_UNEXPECTED
348       );
349     }
350   },