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