summaryrefslogtreecommitdiffstats
path: root/toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs')
-rw-r--r--toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs351
1 files changed, 351 insertions, 0 deletions
diff --git a/toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs b/toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs
new file mode 100644
index 0000000000..682839068d
--- /dev/null
+++ b/toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs
@@ -0,0 +1,351 @@
+/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
+/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Subprocess: "resource://gre/modules/Subprocess.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ XreDirProvider: [
+ "@mozilla.org/xre/directory-provider;1",
+ "nsIXREDirProvider",
+ ],
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ let consoleOptions = {
+ // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
+ // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
+ maxLogLevel: "error",
+ maxLogLevelPref: "toolkit.components.taskscheduler.loglevel",
+ prefix: "TaskScheduler",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+
+/**
+ * Task generation and management for macOS, using `launchd` via `launchctl`.
+ *
+ * Implements the API exposed in TaskScheduler.jsm
+ * Not intended for external use, this is in a separate module to ship the code only
+ * on macOS, and to expose for testing.
+ */
+export var MacOSImpl = {
+ async registerTask(id, command, intervalSeconds, options) {
+ lazy.log.info(
+ `registerTask(${id}, ${command}, ${intervalSeconds}, ${JSON.stringify(
+ options
+ )})`
+ );
+
+ let uid = await this._uid();
+ lazy.log.debug(`registerTask: uid=${uid}`);
+
+ let label = this._formatLabelForThisApp(id);
+
+ // We ignore `options.disabled`, which is test only.
+ //
+ // The `Disabled` key prevents `launchd` from registering the task, with
+ // exit code 133 and error message "Service is disabled". If we really want
+ // this flow in the future, there is `launchctl disable ...`, but it's
+ // fraught with peril: the disabled status is stored outside of any plist,
+ // and it persists even after the task is deleted. Monkeying with the
+ // disabled status will likely prevent users from disabling these tasks
+ // forcibly, should it come to that. All told, fraught.
+ //
+ // For the future: there is the `RunAtLoad` key, should we want to run the
+ // task once immediately.
+ let plist = {};
+ plist.Label = label;
+ plist.ProgramArguments = [command];
+ if (options.args) {
+ plist.ProgramArguments.push(...options.args);
+ }
+ plist.StartInterval = intervalSeconds;
+ if (options.workingDirectory) {
+ plist.WorkingDirectory = options.workingDirectory;
+ }
+
+ let str = this._formatLaunchdPlist(plist);
+ let path = this._formatPlistPath(label);
+
+ await IOUtils.write(path, new TextEncoder().encode(str));
+ lazy.log.debug(`registerTask: wrote ${path}`);
+
+ try {
+ let bootout = await lazy.Subprocess.call({
+ command: "/bin/launchctl",
+ arguments: ["bootout", `gui/${uid}/${label}`],
+ stderr: "stdout",
+ });
+
+ lazy.log.debug(
+ "registerTask: bootout stdout",
+ await bootout.stdout.readString()
+ );
+
+ let { exitCode } = await bootout.wait();
+ lazy.log.debug(`registerTask: bootout returned ${exitCode}`);
+
+ let bootstrap = await lazy.Subprocess.call({
+ command: "/bin/launchctl",
+ arguments: ["bootstrap", `gui/${uid}`, path],
+ stderr: "stdout",
+ });
+
+ lazy.log.debug(
+ "registerTask: bootstrap stdout",
+ await bootstrap.stdout.readString()
+ );
+
+ ({ exitCode } = await bootstrap.wait());
+ lazy.log.debug(`registerTask: bootstrap returned ${exitCode}`);
+
+ if (exitCode != 0) {
+ throw new Components.Exception(
+ `Failed to run launchctl bootstrap: ${exitCode}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+ } catch (e) {
+ // Try to clean up.
+ await IOUtils.remove(path, { ignoreAbsent: true });
+ throw e;
+ }
+
+ return true;
+ },
+
+ async deleteTask(id) {
+ lazy.log.info(`deleteTask(${id})`);
+
+ let label = this._formatLabelForThisApp(id);
+ return this._deleteTaskByLabel(label);
+ },
+
+ async _deleteTaskByLabel(label) {
+ let path = this._formatPlistPath(label);
+ lazy.log.debug(`_deleteTaskByLabel: removing ${path}`);
+ await IOUtils.remove(path, { ignoreAbsent: true });
+
+ let uid = await this._uid();
+ lazy.log.debug(`_deleteTaskByLabel: uid=${uid}`);
+
+ let bootout = await lazy.Subprocess.call({
+ command: "/bin/launchctl",
+ arguments: ["bootout", `gui/${uid}/${label}`],
+ stderr: "stdout",
+ });
+
+ let { exitCode } = await bootout.wait();
+ lazy.log.debug(`_deleteTaskByLabel: bootout returned ${exitCode}`);
+ lazy.log.debug(
+ `_deleteTaskByLabel: bootout stdout`,
+ await bootout.stdout.readString()
+ );
+
+ return !exitCode;
+ },
+
+ // For internal and testing use only.
+ async _listAllLabelsForThisApp() {
+ let proc = await lazy.Subprocess.call({
+ command: "/bin/launchctl",
+ arguments: ["list"],
+ stderr: "stdout",
+ });
+
+ let { exitCode } = await proc.wait();
+ if (exitCode != 0) {
+ throw new Components.Exception(
+ `Failed to run /bin/launchctl list: ${exitCode}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let stdout = await proc.stdout.readString();
+
+ let lines = stdout.split(/\r\n|\n|\r/);
+ let labels = lines
+ .map(line => line.split("\t").pop()) // Lines are like "-\t0\tlabel".
+ .filter(this._labelMatchesThisApp);
+
+ lazy.log.debug(`_listAllLabelsForThisApp`, labels);
+ return labels;
+ },
+
+ async deleteAllTasks() {
+ lazy.log.info(`deleteAllTasks()`);
+
+ let labelsToDelete = await this._listAllLabelsForThisApp();
+
+ let deleted = 0;
+ let failed = 0;
+ for (const label of labelsToDelete) {
+ try {
+ if (await this._deleteTaskByLabel(label)) {
+ deleted += 1;
+ } else {
+ failed += 1;
+ }
+ } catch (e) {
+ failed += 1;
+ }
+ }
+
+ let result = { deleted, failed };
+ lazy.log.debug(`deleteAllTasks: returning ${JSON.stringify(result)}`);
+ },
+
+ async taskExists(id) {
+ const label = this._formatLabelForThisApp(id);
+ const path = this._formatPlistPath(label);
+ return IOUtils.exists(path);
+ },
+
+ /**
+ * Turn an object into a macOS plist.
+ *
+ * Properties of type array-of-string, dict-of-string, string,
+ * number, and boolean are supported.
+ *
+ * @param options object to turn into macOS plist.
+ * @returns plist as an XML DOM object.
+ */
+ _toLaunchdPlist(options) {
+ const doc = new DOMParser().parseFromString("<plist></plist>", "text/xml");
+ const root = doc.documentElement;
+ root.setAttribute("version", "1.0");
+
+ let dict = doc.createElement("dict");
+ root.appendChild(dict);
+
+ for (let [k, v] of Object.entries(options)) {
+ let key = doc.createElement("key");
+ key.textContent = k;
+ dict.appendChild(key);
+
+ if (Array.isArray(v)) {
+ let array = doc.createElement("array");
+ dict.appendChild(array);
+
+ for (let vv of v) {
+ let string = doc.createElement("string");
+ string.textContent = vv;
+ array.appendChild(string);
+ }
+ } else if (typeof v === "object") {
+ let d = doc.createElement("dict");
+ dict.appendChild(d);
+
+ for (let [kk, vv] of Object.entries(v)) {
+ key = doc.createElement("key");
+ key.textContent = kk;
+ d.appendChild(key);
+
+ let string = doc.createElement("string");
+ string.textContent = vv;
+ d.appendChild(string);
+ }
+ } else if (typeof v === "number") {
+ let number = doc.createElement(
+ Number.isInteger(v) ? "integer" : "real"
+ );
+ number.textContent = v;
+ dict.appendChild(number);
+ } else if (typeof v === "string") {
+ let string = doc.createElement("string");
+ string.textContent = v;
+ dict.appendChild(string);
+ } else if (typeof v === "boolean") {
+ let bool = doc.createElement(v ? "true" : "false");
+ dict.appendChild(bool);
+ }
+ }
+
+ return doc;
+ },
+
+ /**
+ * Turn an object into a macOS plist encoded as a string.
+ *
+ * Properties of type array-of-string, dict-of-string, string,
+ * number, and boolean are supported.
+ *
+ * @param options object to turn into macOS plist.
+ * @returns plist as a string.
+ */
+ _formatLaunchdPlist(options) {
+ let doc = this._toLaunchdPlist(options);
+
+ let serializer = new XMLSerializer();
+ return serializer.serializeToString(doc);
+ },
+
+ _formatLabelForThisApp(id) {
+ let installHash = lazy.XreDirProvider.getInstallHash();
+ return `${AppConstants.MOZ_MACBUNDLE_ID}.${installHash}.${id}`;
+ },
+
+ _labelMatchesThisApp(label) {
+ let installHash = lazy.XreDirProvider.getInstallHash();
+ return (
+ label &&
+ label.startsWith(`${AppConstants.MOZ_MACBUNDLE_ID}.${installHash}.`)
+ );
+ },
+
+ _formatPlistPath(label) {
+ let file = Services.dirsvc.get("Home", Ci.nsIFile);
+ file.append("Library");
+ file.append("LaunchAgents");
+ file.append(`${label}.plist`);
+ return file.path;
+ },
+
+ _cachedUid: -1,
+
+ async _uid() {
+ if (this._cachedUid >= 0) {
+ return this._cachedUid;
+ }
+
+ // There are standard APIs for determining our current UID, but this
+ // is easy and parallel to the general tactics used by this module.
+ let proc = await lazy.Subprocess.call({
+ command: "/usr/bin/id",
+ arguments: ["-u"],
+ stderr: "stdout",
+ });
+
+ let stdout = await proc.stdout.readString();
+
+ let { exitCode } = await proc.wait();
+ if (exitCode != 0) {
+ throw new Components.Exception(
+ `Failed to run /usr/bin/id: ${exitCode}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ try {
+ this._cachedUid = Number.parseInt(stdout);
+ return this._cachedUid;
+ } catch (e) {
+ throw new Components.Exception(
+ `Failed to parse /usr/bin/id output as integer: ${stdout}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+ },
+};