diff options
Diffstat (limited to 'toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs')
-rw-r--r-- | toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs | 351 |
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 + ); + } + }, +}; |