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