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";
11 ChromeUtils.defineESModuleGetters(lazy, {
12 Subprocess: "resource://gre/modules/Subprocess.sys.mjs",
15 XPCOMUtils.defineLazyServiceGetters(lazy, {
17 "@mozilla.org/xre/directory-provider;1",
22 ChromeUtils.defineLazyGetter(lazy, "log", () => {
23 let { ConsoleAPI } = ChromeUtils.importESModule(
24 "resource://gre/modules/Console.sys.mjs"
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.
30 maxLogLevelPref: "toolkit.components.taskscheduler.loglevel",
31 prefix: "TaskScheduler",
33 return new ConsoleAPI(consoleOptions);
37 * Task generation and management for macOS, using `launchd` via `launchctl`.
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.
43 export var MacOSImpl = {
44 async registerTask(id, command, intervalSeconds, options) {
46 `registerTask(${id}, ${command}, ${intervalSeconds}, ${JSON.stringify(
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.
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.
66 // For the future: there is the `RunAtLoad` key, should we want to run the
67 // task once immediately.
70 plist.ProgramArguments = [command];
72 plist.ProgramArguments.push(...options.args);
74 plist.StartInterval = intervalSeconds;
75 if (options.workingDirectory) {
76 plist.WorkingDirectory = options.workingDirectory;
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}`);
86 let bootout = await lazy.Subprocess.call({
87 command: "/bin/launchctl",
88 arguments: ["bootout", `gui/${uid}/${label}`],
93 "registerTask: bootout stdout",
94 await bootout.stdout.readString()
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],
107 "registerTask: bootstrap stdout",
108 await bootstrap.stdout.readString()
111 ({ exitCode } = await bootstrap.wait());
112 lazy.log.debug(`registerTask: bootstrap returned ${exitCode}`);
115 throw new Components.Exception(
116 `Failed to run launchctl bootstrap: ${exitCode}`,
117 Cr.NS_ERROR_UNEXPECTED
122 await IOUtils.remove(path, { ignoreAbsent: true });
129 async deleteTask(id, options) {
130 lazy.log.info(`deleteTask(${id})`);
132 let label = this._formatLabelForThisApp(id, options);
133 return this._deleteTaskByLabel(label);
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}`],
150 let { exitCode } = await bootout.wait();
151 lazy.log.debug(`_deleteTaskByLabel: bootout returned ${exitCode}`);
153 `_deleteTaskByLabel: bootout stdout`,
154 await bootout.stdout.readString()
160 // For internal and testing use only.
161 async _listAllLabelsForThisApp() {
162 let proc = await lazy.Subprocess.call({
163 command: "/bin/launchctl",
168 let { exitCode } = await proc.wait();
170 throw new Components.Exception(
171 `Failed to run /bin/launchctl list: ${exitCode}`,
172 Cr.NS_ERROR_UNEXPECTED
176 let stdout = await proc.stdout.readString();
178 let lines = stdout.split(/\r\n|\n|\r/);
180 .map(line => line.split("\t").pop()) // Lines are like "-\t0\tlabel".
181 .filter(this._labelMatchesThisApp);
183 lazy.log.debug(`_listAllLabelsForThisApp`, labels);
187 async deleteAllTasks() {
188 lazy.log.info(`deleteAllTasks()`);
190 let labelsToDelete = await this._listAllLabelsForThisApp();
194 for (const label of labelsToDelete) {
196 if (await this._deleteTaskByLabel(label)) {
206 let result = { deleted, failed };
207 lazy.log.debug(`deleteAllTasks: returning ${JSON.stringify(result)}`);
210 async taskExists(id, options) {
211 const label = this._formatLabelForThisApp(id, options);
212 const path = this._formatPlistPath(label);
213 return IOUtils.exists(path);
217 * Turn an object into a macOS plist.
219 * Properties of type array-of-string, dict-of-string, string,
220 * number, and boolean are supported.
222 * @param options object to turn into macOS plist.
223 * @returns plist as an XML DOM object.
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");
236 dict.appendChild(key);
238 if (Array.isArray(v)) {
239 let array = doc.createElement("array");
240 dict.appendChild(array);
243 let string = doc.createElement("string");
244 string.textContent = vv;
245 array.appendChild(string);
247 } else if (typeof v === "object") {
248 let d = doc.createElement("dict");
251 for (let [kk, vv] of Object.entries(v)) {
252 key = doc.createElement("key");
253 key.textContent = kk;
256 let string = doc.createElement("string");
257 string.textContent = vv;
258 d.appendChild(string);
260 } else if (typeof v === "number") {
261 let number = doc.createElement(
262 Number.isInteger(v) ? "integer" : "real"
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);
280 * Turn an object into a macOS plist encoded as a string.
282 * Properties of type array-of-string, dict-of-string, string,
283 * number, and boolean are supported.
285 * @param options object to turn into macOS plist.
286 * @returns plist as a string.
288 _formatLaunchdPlist(options) {
289 let doc = this._toLaunchdPlist(options);
291 let serializer = new XMLSerializer();
292 return serializer.serializeToString(doc);
295 _formatLabelForThisApp(id, options) {
296 let installHash = lazy.XreDirProvider.getInstallHash();
297 return `${AppConstants.MOZ_MACBUNDLE_ID}.${installHash}.${id}`;
300 _labelMatchesThisApp(label, options) {
301 let installHash = lazy.XreDirProvider.getInstallHash();
304 label.startsWith(`${AppConstants.MOZ_MACBUNDLE_ID}.${installHash}.`)
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`);
319 if (this._cachedUid >= 0) {
320 return this._cachedUid;
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",
331 let stdout = await proc.stdout.readString();
333 let { exitCode } = await proc.wait();
335 throw new Components.Exception(
336 `Failed to run /usr/bin/id: ${exitCode}`,
337 Cr.NS_ERROR_UNEXPECTED
342 this._cachedUid = Number.parseInt(stdout);
343 return this._cachedUid;
345 throw new Components.Exception(
346 `Failed to parse /usr/bin/id output as integer: ${stdout}`,
347 Cr.NS_ERROR_UNEXPECTED