diff options
Diffstat (limited to 'toolkit/components/taskscheduler')
13 files changed, 2035 insertions, 0 deletions
diff --git a/toolkit/components/taskscheduler/TaskScheduler.sys.mjs b/toolkit/components/taskscheduler/TaskScheduler.sys.mjs new file mode 100644 index 0000000000..2225328c41 --- /dev/null +++ b/toolkit/components/taskscheduler/TaskScheduler.sys.mjs @@ -0,0 +1,193 @@ +/* -*- 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"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + WinImpl: "resource://gre/modules/TaskSchedulerWinImpl.sys.mjs", + MacOSImpl: "resource://gre/modules/TaskSchedulerMacOSImpl.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "gImpl", () => { + if (AppConstants.platform == "win") { + return lazy.WinImpl; + } + + if (AppConstants.platform == "macosx") { + return lazy.MacOSImpl; + } + + // Stubs for unsupported platforms + return { + registerTask() {}, + deleteTask() {}, + deleteAllTasks() {}, + }; +}); + +/** + * Interface to a system task scheduler, capable of running a command line at an interval + * independent of the application. + * + * The expected consumer of this component wants to run a periodic short-lived maintenance task. + * These periodic maintenance tasks will be per-OS-level user and run with the OS-level user's + * permissions. (These still need to work across systems with multiple users and the various + * ownership and permission combinations that we see.) This component does not help schedule + * maintenance daemons, meaning long-lived processes. + * + * Currently only implemented for Windows and macOS, on other platforms these calls do nothing. + * + * The implementation will only interact with tasks from the same install of this application. + * - On Windows the native tasks are named like "\<vendor>\<id> <install path hash>", + * e.g. "\Mozilla\Task Identifier 308046B0AF4A39CB" + * - On macOS the native tasks are labeled like "<macOS bundle ID>.<install path hash>.<id>", + * e.g. "org.mozilla.nightly.308046B0AF4A39CB.Task Identifier". + */ +export var TaskScheduler = { + MIN_INTERVAL_SECONDS: 1800, + + /** + * Create a scheduled task that will run a command indepedent of the application. + * + * It will run every intervalSeconds seconds, starting intervalSeconds seconds from now. + * + * If the task is unable to run one or more scheduled times (e.g. if the computer is + * off, or the owning user is not logged in), then the next time a run is possible the task + * will be run once. + * + * An existing task with the same `id` will be replaced. + * + * Only one instance of the task will run at once, though this does not affect different + * tasks from the same application. + * + * @param id + * A unique string (including a UUID is recommended) to distinguish the task among + * other tasks from this installation. + * This string will also be visible to system administrators, so it should be a legible + * description, but it does not need to be localized. + * + * @param command + * Full path to the executable to run. + * + * @param intervalSeconds + * Interval at which to run the command, in seconds. Minimum 1800 (30 minutes). + * + * @param {Object} options + * Optional, as are all of its properties: + * { + * options.args + * Array of arguments to pass on the command line. Does not include the command + * itself even if that is considered part of the command line. If missing, no + * argument list is generated. + * + * options.workingDirectory + * Working directory for the command. If missing, no working directory is set. + * + * options.description + * A description string that will be visible to system administrators. This should + * be localized. If missing, no description is set. + * + * options.disabled + * If true the task will be created disabled, so that it will not be run. + * Ignored on macOS: see comments in TaskSchedulerMacOSImpl.jsm. + * Default false, intended for tests. + * + * options.executionTimeoutSec + * Specifies how long (in seconds) the scheduled task can execute for before it is + * automatically stopped by the task scheduler. If a value <= 0 is given, it will be + * ignored. + * This is not currently implemented on macOS. + * On Windows, the default timeout is 72 hours. + * + * options.nameVersion + * Over time, we have needed to change the name format that tasks are registered with. + * When interacting with an up-to-date task, this value can be unspecified and the + * current version of the name format will be used by default. When interacting with + * an out-of-date task using an old naming format, this can be used to specify what + * version of the name should be used. Since the precise naming format is platform + * specific, these version numbers are also platform-specific. + * } + * } + */ + async registerTask(id, command, intervalSeconds, options) { + if (typeof id !== "string") { + throw new Error("id is not a string"); + } + if (!Number.isInteger(intervalSeconds)) { + throw new Error("Interval is not an integer"); + } + if (intervalSeconds < this.MIN_INTERVAL_SECONDS) { + throw new Error("Interval is too short"); + } + + return lazy.gImpl.registerTask(id, command, intervalSeconds, options); + }, + + /** + * Delete a scheduled task previously created with registerTask. + * + * @param {Object} options + * Optional, as are all of its properties: + * { + * options.nameVersion + * Over time, we have needed to change the name format that tasks are registered with. + * When interacting with an up-to-date task, this value can be unspecified and the + * current version of the name format will be used by default. When interacting with + * an out-of-date task using an old naming format, this can be used to specify what + * version of the name should be used. Since the precise naming format is platform + * specific, these version numbers are also platform-specific. + * } + * @throws NS_ERROR_FILE_NOT_FOUND if the task does not exist. + */ + async deleteTask(id, options) { + return lazy.gImpl.deleteTask(id, options); + }, + + /** + * Delete all tasks registered by this application. + * + * @param {Object} options + * Optional, as are all of its properties: + * { + * options.nameVersion + * Over time, we have needed to change the name format that tasks are registered with. + * When interacting with an up-to-date task, this value can be unspecified and the + * current version of the name format will be used by default. When interacting with + * an out-of-date task using an old naming format, this can be used to specify what + * version of the name should be used. Since the precise naming format is platform + * specific, these version numbers are also platform-specific. + * } + */ + async deleteAllTasks() { + return lazy.gImpl.deleteAllTasks(); + }, + + /** + * Checks if a task exists. + * + * @param id + * A string representing the identifier of the task to look for. + * + * @param {Object} options + * Optional, as are all of its properties: + * { + * options.nameVersion + * Over time, we have needed to change the name format that tasks are registered with. + * When interacting with an up-to-date task, this value can be unspecified and the + * current version of the name format will be used by default. When interacting with + * an out-of-date task using an old naming format, this can be used to specify what + * version of the name should be used. Since the precise naming format is platform + * specific, these version numbers are also platform-specific. + * } + * + * @return + * true if the task exists, otherwise false. + */ + async taskExists(id, options) { + return lazy.gImpl.taskExists(id, options); + }, +}; diff --git a/toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs b/toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs new file mode 100644 index 0000000000..d47d3c5c14 --- /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", + ], +}); + +ChromeUtils.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, options); + + // 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, options) { + lazy.log.info(`deleteTask(${id})`); + + let label = this._formatLabelForThisApp(id, options); + 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, options) { + const label = this._formatLabelForThisApp(id, options); + 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, options) { + let installHash = lazy.XreDirProvider.getInstallHash(); + return `${AppConstants.MOZ_MACBUNDLE_ID}.${installHash}.${id}`; + }, + + _labelMatchesThisApp(label, options) { + 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 + ); + } + }, +}; diff --git a/toolkit/components/taskscheduler/TaskSchedulerWinImpl.sys.mjs b/toolkit/components/taskscheduler/TaskSchedulerWinImpl.sys.mjs new file mode 100644 index 0000000000..8d9c15c314 --- /dev/null +++ b/toolkit/components/taskscheduler/TaskSchedulerWinImpl.sys.mjs @@ -0,0 +1,316 @@ +/* -*- 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 = {}; + +XPCOMUtils.defineLazyServiceGetters(lazy, { + WinTaskSvc: [ + "@mozilla.org/win-task-scheduler-service;1", + "nsIWinTaskSchedulerService", + ], + XreDirProvider: [ + "@mozilla.org/xre/directory-provider;1", + "nsIXREDirProvider", + ], +}); + +/** + * Task generation and management for Windows, using Task Scheduler 2.0 (taskschd). + * + * Implements the API exposed in TaskScheduler.jsm + * Not intended for external use, this is in a separate module to ship the code only + * on Windows, and to expose for testing. + */ +export var WinImpl = { + registerTask(id, command, intervalSeconds, options) { + // The folder might not yet exist. + this._createFolderIfNonexistent(); + + const xml = this._formatTaskDefinitionXML( + command, + intervalSeconds, + options + ); + const updateExisting = true; + + lazy.WinTaskSvc.registerTask( + this._taskFolderName(), + this._formatTaskName(id, options), + xml, + updateExisting + ); + }, + + deleteTask(id, options) { + lazy.WinTaskSvc.deleteTask( + this._taskFolderName(), + this._formatTaskName(id, options) + ); + }, + + /** + * Delete all tasks created by this installation. + * + * The Windows Default Browser Agent task is special: it's + * registered by the installer and might run as a different user and + * require permissions to delete. We ignore it and leave it for the + * uninstaller to remove. + */ + deleteAllTasks() { + const taskFolderName = this._taskFolderName(); + + let allTasks; + try { + allTasks = lazy.WinTaskSvc.getFolderTasks(taskFolderName); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_FILE_NOT_FOUND) { + // Folder doesn't exist, nothing to delete. + return; + } + throw ex; + } + + const tasksToDelete = allTasks.filter(name => this._matchAppTaskName(name)); + + let numberDeleted = 0; + let lastFailedTaskName; + // We need `MOZ_APP_DISPLAYNAME` since that's what the WDBA (written in C++) uses. + const defaultBrowserAgentTaskName = + AppConstants.MOZ_APP_DISPLAYNAME_DO_NOT_USE + + " Default Browser Agent " + + lazy.XreDirProvider.getInstallHash(); + for (const taskName of tasksToDelete) { + if (taskName == defaultBrowserAgentTaskName) { + // Skip the Windows Default Browser Agent task. + continue; + } + + try { + lazy.WinTaskSvc.deleteTask(taskFolderName, taskName); + numberDeleted += 1; + } catch (e) { + lastFailedTaskName = taskName; + } + } + + if (lastFailedTaskName) { + // There's no standard way to chain exceptions, so instead try again, + // which should fail and throw again. It's possible this isn't idempotent + // but we're expecting failures to be due to permission errors, which are + // likely to be static. + lazy.WinTaskSvc.deleteTask(taskFolderName, lastFailedTaskName); + } + + if (allTasks.length == numberDeleted) { + // Deleted every task, remove the folder. + this._deleteFolderIfEmpty(); + } + }, + + taskExists(id, options) { + const taskFolderName = this._taskFolderName(); + + let allTasks; + try { + allTasks = lazy.WinTaskSvc.getFolderTasks(taskFolderName); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_FILE_NOT_FOUND) { + // Folder doesn't exist, so neither do tasks within it. + return false; + } + throw ex; + } + + return allTasks.includes(this._formatTaskName(id, options)); + }, + + _formatTaskDefinitionXML(command, intervalSeconds, options) { + const startTime = new Date(Date.now() + intervalSeconds * 1000); + const xmlns = "http://schemas.microsoft.com/windows/2004/02/mit/task"; + + // Fill in the constant parts of the task, and those that don't require escaping. + const docBase = `<Task xmlns="${xmlns}"> + <Triggers> + <TimeTrigger> + <StartBoundary>${startTime.toISOString()}</StartBoundary> + <Repetition> + <Interval>PT${intervalSeconds}S</Interval> + </Repetition> + </TimeTrigger> + </Triggers> + <Actions> + <Exec /> + </Actions> + <Settings> + <StartWhenAvailable>true</StartWhenAvailable> + <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy> + </Settings> + <RegistrationInfo> + <Author /> + </RegistrationInfo> +</Task>`; + const doc = new DOMParser().parseFromString(docBase, "text/xml"); + + const execAction = doc.querySelector("Actions Exec"); + + const settings = doc.querySelector("Settings"); + + const commandNode = doc.createElementNS(xmlns, "Command"); + commandNode.textContent = command; + execAction.appendChild(commandNode); + + if (options?.args) { + const args = doc.createElementNS(xmlns, "Arguments"); + args.textContent = options.args.map(this._quoteString).join(" "); + execAction.appendChild(args); + } + + if (options?.workingDirectory) { + const workingDirectory = doc.createElementNS(xmlns, "WorkingDirectory"); + workingDirectory.textContent = options.workingDirectory; + execAction.appendChild(workingDirectory); + } + + if (options?.disabled) { + const enabled = doc.createElementNS(xmlns, "Enabled"); + enabled.textContent = "false"; + settings.appendChild(enabled); + } + + if (options?.executionTimeoutSec && options.executionTimeoutSec > 0) { + const timeout = doc.createElementNS(xmlns, "ExecutionTimeLimit"); + timeout.textContent = `PT${options.executionTimeoutSec}S`; + settings.appendChild(timeout); + } + + // Other settings to consider for the future: + // Idle + // Battery + + doc.querySelector("RegistrationInfo Author").textContent = + Services.appinfo.vendor; + + if (options?.description) { + const registrationInfo = doc.querySelector("RegistrationInfo"); + const description = doc.createElementNS(xmlns, "Description"); + description.textContent = options.description; + registrationInfo.appendChild(description); + } + + const serializer = new XMLSerializer(); + return serializer.serializeToString(doc); + }, + + _createFolderIfNonexistent() { + const { parentName, subName } = this._taskFolderNameParts(); + + try { + lazy.WinTaskSvc.createFolder(parentName, subName); + } catch (e) { + if (e.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) { + throw e; + } + } + }, + + _deleteFolderIfEmpty() { + const { parentName, subName } = this._taskFolderNameParts(); + + try { + lazy.WinTaskSvc.deleteFolder(parentName, subName); + } catch (e) { + // Missed one somehow, possibly a subfolder? + if (e.result != Cr.NS_ERROR_FILE_DIR_NOT_EMPTY) { + throw e; + } + } + }, + + /** + * Quotes a string for use as a single command argument, using Windows quoting + * conventions. + * + * copied from quoteString() in toolkit/modules/subproces/subprocess_worker_win.js + * + * + * @see https://msdn.microsoft.com/en-us/library/17w5ykft(v=vs.85).aspx + * + * @param {string} str + * The argument string to quote. + * @returns {string} + */ + _quoteString(str) { + if (!/[\s"]/.test(str)) { + return str; + } + + let escaped = str.replace(/(\\*)("|$)/g, (m0, m1, m2) => { + if (m2) { + m2 = `\\${m2}`; + } + return `${m1}${m1}${m2}`; + }); + + return `"${escaped}"`; + }, + + _taskFolderName() { + return `\\${Services.appinfo.vendor}`; + }, + + _taskFolderNameParts() { + return { + parentName: "\\", + subName: Services.appinfo.vendor, + }; + }, + + /** + * Formats a given task id according to one of two formats. + * + * @param id + * A string representing the identifier of the task to format + * + * @param {Object} options + * Optional, as are all of its properties: + * { + * options.nameVersion + * Specifies whether to search for tasks using nameVersion 1 + * which is `${taskID} ${installHash}` or nameVersion 2 which is + * `${taskID} ${currentUserSid} ${installHash}`. Defaults to nameVersion 2. + * } + * + * @return + * Formatted task name. + */ + _formatTaskName(id, options) { + const installHash = lazy.XreDirProvider.getInstallHash(); + if (options?.nameVersion == 1) { + return `${id} ${installHash}`; + } + const currentUserSid = lazy.WinTaskSvc.getCurrentUserSid(); + return `${id} ${currentUserSid} ${installHash}`; + }, + + _matchAppTaskName(name) { + const installHash = lazy.XreDirProvider.getInstallHash(); + return name.endsWith(` ${installHash}`); + }, + + _updateTaskNameFormat(id) { + const taskFolderName = this._taskFolderName(); + const allTasks = lazy.WinTaskSvc.getFolderTasks(taskFolderName); + const taskNameV1 = this._formatTaskName(id, { nameVersion: 1 }); + const taskNameV2 = this._formatTaskName(id, { nameVersion: 2 }); + if (allTasks.includes(taskNameV1)) { + const taskXML = lazy.WinTaskSvc.getTaskXML(taskFolderName, taskNameV1); + lazy.WinTaskSvc.registerTask(taskFolderName, taskNameV2, taskXML, true); + lazy.WinTaskSvc.deleteTask(taskFolderName, taskNameV1); + } + }, +}; diff --git a/toolkit/components/taskscheduler/components.conf b/toolkit/components/taskscheduler/components.conf new file mode 100644 index 0000000000..c6e96da1b4 --- /dev/null +++ b/toolkit/components/taskscheduler/components.conf @@ -0,0 +1,18 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +Classes = [] + +if buildconfig.substs['OS_TARGET'] == 'WINNT': + Classes += [ + { + 'cid': '{e2113dfc-8efe-43a1-8a20-ad720dd771d6}', + 'contract_ids': ['@mozilla.org/win-task-scheduler-service;1'], + 'type': 'nsWinTaskSchedulerService', + 'headers': ['nsWinTaskScheduler.h'], + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, + ] diff --git a/toolkit/components/taskscheduler/moz.build b/toolkit/components/taskscheduler/moz.build new file mode 100644 index 0000000000..1f5078938f --- /dev/null +++ b/toolkit/components/taskscheduler/moz.build @@ -0,0 +1,29 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Application Update") + +XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] + +EXTRA_JS_MODULES += ["TaskScheduler.sys.mjs"] + +# This whole component is currently Windows-only, but a Mac implementation is planned. +# Only the Windows C++ interface is an XPCOM component. +if CONFIG["OS_TARGET"] == "WINNT": + EXTRA_JS_MODULES += ["TaskSchedulerWinImpl.sys.mjs"] + XPCOM_MANIFESTS += ["components.conf"] + XPIDL_MODULE = "taskscheduler" + XPIDL_SOURCES += ["nsIWinTaskSchedulerService.idl"] + EXPORTS += ["nsWinTaskScheduler.h"] + SOURCES += ["nsWinTaskScheduler.cpp"] + OS_LIBS += ["taskschd"] + DEFINES["UNICODE"] = True + +if CONFIG["OS_TARGET"] == "Darwin": + EXTRA_JS_MODULES += ["TaskSchedulerMacOSImpl.sys.mjs"] + +FINAL_LIBRARY = "xul" diff --git a/toolkit/components/taskscheduler/nsIWinTaskSchedulerService.idl b/toolkit/components/taskscheduler/nsIWinTaskSchedulerService.idl new file mode 100644 index 0000000000..7778360e7d --- /dev/null +++ b/toolkit/components/taskscheduler/nsIWinTaskSchedulerService.idl @@ -0,0 +1,115 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +/** + * An interface for Windows Task Scheduler 2.0. + * Documentation for the underlying APIs can be found at + * https://docs.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-start-page + */ +[scriptable, main_process_scriptable_only, uuid(a8d36901-0b6a-46c3-a214-a9e1d5d6047a)] +interface nsIWinTaskSchedulerService : nsISupports +{ + /** + * Register (create) a task from an XML definition. + * The task will be created so that it only runs as the current user + * (TASK_LOGON_INTERACTIVE_TOKEN). + * + * @throws NS_ERROR_FILE_NOT_FOUND if the folder does not exist. + * @throws NS_ERROR_FILE_ALREADY_EXISTS if the task already existed and aUpdateExisting is false. + * + * @param aFolderName Full name of the folder in which to create the task, starting with "\". + * + * @param aTaskName Name of the task. + * + * @param aDefinitionXML XML definition of the task. This is passed directly to Task Scheduler, + * see the schema at + * https://docs.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-schema + * + * @param aUpdateExisting Whether to update an existing task with the same name, default false. + */ + void registerTask(in wstring aFolderName, + in wstring aTaskName, + in wstring aDefinitionXML, + [optional] in boolean aUpdateExisting); + + /** + * Validate the XML task definition with Task Scheduler without creating a task, for testing. + * Doesn't throw if only the final ITaskFolder::RegisterTask() fails. + * + * @param aDefinitionXML Definition to validate. + * @return HRESULT from ITaskFolder::RegisterTask() + * Success should be S_OK (0). XML validation failure could be one of + * SCHED_E_UNEXPECTED_NODE, SCHED_E_NAMESPACE, SCHED_E_INVALIDVALUE, + * SCHED_E_MISSINGNODE, SCHED_E_MALFORMEDXML, but there may be others. + */ + long validateTaskDefinition(in wstring aDefinitionXML); + + /** + * Get the registration information for a task. + * + * @throws NS_ERROR_FILE_NOT_FOUND if the folder or task do not exist. + * + * @param aFolderName Full name of the folder containing the task, starting with "\". + * @param aTaskName Name of the task to read. + * @return Registration information for the task, as XML text. + */ + AString getTaskXML(in wstring aFolderName, in wstring aTaskName); + + /** + * Gets the sid of the current user. + * + * @throws NS_ERROR_NOT_IMPLEMENTED If called on a non-Windows OS. + * @throws NS_ERROR_FAILURE If the user token cannot be found. + * @throws NS_ERROR_ABORT If converting the sid to a string fails. + * + * @returns The sid of the current user. + */ + AString getCurrentUserSid(); + + /** + * Delete a task. + * + * @throws NS_ERROR_FILE_NOT_FOUND if the folder or task do not exist. + * + * @param aFolderName Full name of the folder containing the task, starting with "\". + * @param aTaskName Name of the task to delete. + */ + void deleteTask(in wstring aFolderName, in wstring aTaskName); + + /** + * List the names of all tasks in a task folder. + * + * @throws NS_ERROR_FILE_NOT_FOUND if the folder doesn't exist. + * + * @param aFolderName The full name of the task folder to enumerate, starting with "\". + * + * @return An array with the names of the tasks found. + */ + Array<AString> getFolderTasks(in wstring aFolderName); + + /** + * Create a new task subfolder under a given parent folder. + * + * @throws NS_ERROR_FILE_NOT_FOUND if the parent folder does not exist. + * @throws NS_ERROR_FILE_ALREADY_EXISTS if the subfolder already exists. + * + * @param aParentFolderName Immediate parent for the new folder, starting with "\". + * @param aSubFolderName Name of the new folder to create. + */ + void createFolder(in wstring aParentFolderName, in wstring aSubFolderName); + + /** + * Delete a folder. + * + * @throws NS_ERROR_FILE_NOT_FOUND if the parent folder does not exist. + * @throws NS_ERROR_FILE_DIR_NOT_EMPTY if the folder was not empty. + * + * @param aParentFolderName Immediate parent of the folder to delete, starting with "\". + * @param aSubFolderName Name of the folder to delete. + */ + void deleteFolder(in wstring aParentFolderName, in wstring aSubFolderName); +}; diff --git a/toolkit/components/taskscheduler/nsWinTaskScheduler.cpp b/toolkit/components/taskscheduler/nsWinTaskScheduler.cpp new file mode 100644 index 0000000000..1efae5c349 --- /dev/null +++ b/toolkit/components/taskscheduler/nsWinTaskScheduler.cpp @@ -0,0 +1,334 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode:nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsWinTaskScheduler.h" + +#include <windows.h> +#include <comdef.h> +#include <sddl.h> +#include <securitybaseapi.h> +#include <taskschd.h> + +#include "nsString.h" + +#include "mozilla/RefPtr.h" +#include "mozilla/ResultVariant.h" + +using namespace mozilla; + +struct SysFreeStringDeleter { + void operator()(BSTR aPtr) { ::SysFreeString(aPtr); } +}; +using BStrPtr = mozilla::UniquePtr<OLECHAR, SysFreeStringDeleter>; + +static nsresult ToNotFoundOrFailure(HRESULT hr) { + if (hr == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND)) { + return NS_ERROR_FILE_NOT_FOUND; + } else { + return NS_ERROR_FAILURE; + } +} + +static nsresult ToAlreadyExistsOrFailure(HRESULT hr) { + if (hr == HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS)) { + return NS_ERROR_FILE_ALREADY_EXISTS; + } else { + return NS_ERROR_FAILURE; + } +} + +[[nodiscard]] static Result<RefPtr<ITaskFolder>, HRESULT> GetTaskFolder( + const char16_t* aFolderName) { + HRESULT hr; + RefPtr<ITaskService> scheduler = nullptr; + + hr = CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, + IID_ITaskService, getter_AddRefs(scheduler)); + if (FAILED(hr)) { + return Err(hr); + } + + // Connect to the local Task Scheduler. + hr = scheduler->Connect(VARIANT{}, VARIANT{}, VARIANT{}, VARIANT{}); + if (FAILED(hr)) { + return Err(hr); + } + + BStrPtr bstrFolderName = + BStrPtr(::SysAllocString(reinterpret_cast<const OLECHAR*>(aFolderName))); + + RefPtr<ITaskFolder> folder = nullptr; + hr = scheduler->GetFolder(bstrFolderName.get(), getter_AddRefs(folder)); + if (FAILED(hr)) { + return Err(hr); + } + + return folder; +} + +[[nodiscard]] static Result<RefPtr<IRegisteredTask>, HRESULT> GetRegisteredTask( + const char16_t* aFolderName, const char16_t* aTaskName) { + auto folder = GetTaskFolder(aFolderName); + if (!folder.isOk()) { + return Err(folder.unwrapErr()); + } + + BStrPtr bstrTaskName = + BStrPtr(::SysAllocString(reinterpret_cast<const OLECHAR*>(aTaskName))); + + RefPtr<IRegisteredTask> task = nullptr; + HRESULT hr = + folder.unwrap()->GetTask(bstrTaskName.get(), getter_AddRefs(task)); + if (FAILED(hr)) { + return Err(hr); + } + + return task; +} + +NS_IMPL_ISUPPORTS(nsWinTaskSchedulerService, nsIWinTaskSchedulerService) + +NS_IMETHODIMP +nsWinTaskSchedulerService::GetTaskXML(const char16_t* aFolderName, + const char16_t* aTaskName, + nsAString& aResult) { + if (!aFolderName || !aTaskName) { + return NS_ERROR_NULL_POINTER; + } + + auto task = GetRegisteredTask(aFolderName, aTaskName); + if (!task.isOk()) { + return ToNotFoundOrFailure(task.unwrapErr()); + } + + { + BSTR bstrXml = nullptr; + if (FAILED(task.unwrap()->get_Xml(&bstrXml))) { + return NS_ERROR_FAILURE; + } + + aResult.Assign(bstrXml, ::SysStringLen(bstrXml)); + ::SysFreeString(bstrXml); + } + + return NS_OK; +} + +NS_IMETHODIMP +nsWinTaskSchedulerService::GetCurrentUserSid(nsAString& aUserSid) { +#ifndef XP_WIN + return NS_ERROR_NOT_IMPLEMENTED; +#else // !XP_WIN + DWORD tokenLen; + LPWSTR stringSid; + BYTE tokenBuf[TOKEN_USER_MAX_SIZE]; + PTOKEN_USER tokenInfo = reinterpret_cast<PTOKEN_USER>(tokenBuf); + BOOL success = GetTokenInformation(GetCurrentProcessToken(), TokenUser, + tokenInfo, sizeof(tokenBuf), &tokenLen); + if (!success) { + return NS_ERROR_FAILURE; + } + success = ConvertSidToStringSidW(tokenInfo->User.Sid, &stringSid); + if (!success) { + return NS_ERROR_ABORT; + } + aUserSid.Assign(stringSid); + LocalFree(stringSid); + return NS_OK; +#endif +} + +NS_IMETHODIMP +nsWinTaskSchedulerService::RegisterTask(const char16_t* aFolderName, + const char16_t* aTaskName, + const char16_t* aDefinitionXML, + bool aUpdateExisting) { + if (!aFolderName || !aTaskName || !aDefinitionXML) { + return NS_ERROR_NULL_POINTER; + } + + auto folder = GetTaskFolder(aFolderName); + if (!folder.isOk()) { + return ToNotFoundOrFailure(folder.unwrapErr()); + } + + BStrPtr bstrTaskName = + BStrPtr(::SysAllocString(reinterpret_cast<const OLECHAR*>(aTaskName))); + BStrPtr bstrXml = BStrPtr( + ::SysAllocString(reinterpret_cast<const OLECHAR*>(aDefinitionXML))); + LONG flags = aUpdateExisting ? TASK_CREATE_OR_UPDATE : TASK_CREATE; + TASK_LOGON_TYPE logonType = TASK_LOGON_INTERACTIVE_TOKEN; + + // The outparam is not needed, but not documented as optional. + RefPtr<IRegisteredTask> unusedTaskOutput = nullptr; + HRESULT hr = folder.unwrap()->RegisterTask( + bstrTaskName.get(), bstrXml.get(), flags, VARIANT{} /* userId */, + VARIANT{} /* password */, logonType, VARIANT{} /* sddl */, + getter_AddRefs(unusedTaskOutput)); + + if (FAILED(hr)) { + return ToAlreadyExistsOrFailure(hr); + } + + return NS_OK; +} + +NS_IMETHODIMP +nsWinTaskSchedulerService::ValidateTaskDefinition( + const char16_t* aDefinitionXML, int32_t* aResult) { + if (!aDefinitionXML) { + return NS_ERROR_NULL_POINTER; + } + + auto folder = GetTaskFolder(reinterpret_cast<const char16_t*>(L"\\")); + if (!folder.isOk()) { + return NS_ERROR_FAILURE; + } + + BStrPtr bstrXml = BStrPtr( + ::SysAllocString(reinterpret_cast<const OLECHAR*>(aDefinitionXML))); + LONG flags = TASK_VALIDATE_ONLY; + TASK_LOGON_TYPE logonType = TASK_LOGON_INTERACTIVE_TOKEN; + + // The outparam is not needed, but not documented as optional. + RefPtr<IRegisteredTask> unusedTaskOutput = nullptr; + HRESULT hr = folder.unwrap()->RegisterTask( + nullptr /* path */, bstrXml.get(), flags, VARIANT{} /* userId */, + VARIANT{} /* password */, logonType, VARIANT{} /* sddl */, + getter_AddRefs(unusedTaskOutput)); + + if (aResult) { + *aResult = hr; + } + + return NS_OK; +} + +NS_IMETHODIMP +nsWinTaskSchedulerService::DeleteTask(const char16_t* aFolderName, + const char16_t* aTaskName) { + if (!aFolderName || !aTaskName) { + return NS_ERROR_NULL_POINTER; + } + + auto folder = GetTaskFolder(aFolderName); + if (!folder.isOk()) { + return ToNotFoundOrFailure(folder.unwrapErr()); + } + + BStrPtr bstrTaskName = + BStrPtr(::SysAllocString(reinterpret_cast<const OLECHAR*>(aTaskName))); + + HRESULT hr = folder.unwrap()->DeleteTask(bstrTaskName.get(), 0 /* flags */); + if (FAILED(hr)) { + return ToNotFoundOrFailure(hr); + } + + return NS_OK; +} + +NS_IMETHODIMP +nsWinTaskSchedulerService::GetFolderTasks(const char16_t* aFolderName, + nsTArray<nsString>& aResult) { + if (!aFolderName) { + return NS_ERROR_NULL_POINTER; + } + + auto folder = GetTaskFolder(aFolderName); + if (!folder.isOk()) { + return ToNotFoundOrFailure(folder.unwrapErr()); + } + + RefPtr<IRegisteredTaskCollection> taskCollection = nullptr; + if (FAILED(folder.unwrap()->GetTasks(TASK_ENUM_HIDDEN, + getter_AddRefs(taskCollection)))) { + return NS_ERROR_FAILURE; + } + + LONG taskCount = 0; + if (FAILED(taskCollection->get_Count(&taskCount))) { + return NS_ERROR_FAILURE; + } + + aResult.Clear(); + + for (LONG i = 0; i < taskCount; ++i) { + RefPtr<IRegisteredTask> task = nullptr; + + // nb: Collections are indexed from 1. + if (FAILED(taskCollection->get_Item(_variant_t(i + 1), + getter_AddRefs(task)))) { + return NS_ERROR_FAILURE; + } + + BStrPtr bstrTaskName; + { + BSTR tempTaskName = nullptr; + if (FAILED(task->get_Name(&tempTaskName))) { + return NS_ERROR_FAILURE; + } + bstrTaskName = BStrPtr(tempTaskName); + } + + aResult.AppendElement(nsString(bstrTaskName.get())); + } + + return NS_OK; +} + +NS_IMETHODIMP +nsWinTaskSchedulerService::CreateFolder(const char16_t* aParentFolderName, + const char16_t* aSubFolderName) { + if (!aParentFolderName || !aSubFolderName) { + return NS_ERROR_NULL_POINTER; + } + + auto parentFolder = GetTaskFolder(aParentFolderName); + if (!parentFolder.isOk()) { + return ToNotFoundOrFailure(parentFolder.unwrapErr()); + } + + BStrPtr bstrSubFolderName = BStrPtr( + ::SysAllocString(reinterpret_cast<const OLECHAR*>(aSubFolderName))); + + HRESULT hr = parentFolder.unwrap()->CreateFolder(bstrSubFolderName.get(), + VARIANT{}, // sddl + nullptr); // ppFolder + + if (FAILED(hr)) { + return ToAlreadyExistsOrFailure(hr); + } + + return NS_OK; +} + +NS_IMETHODIMP +nsWinTaskSchedulerService::DeleteFolder(const char16_t* aParentFolderName, + const char16_t* aSubFolderName) { + if (!aParentFolderName || !aSubFolderName) { + return NS_ERROR_NULL_POINTER; + } + + auto parentFolder = GetTaskFolder(aParentFolderName); + if (!parentFolder.isOk()) { + return ToNotFoundOrFailure(parentFolder.unwrapErr()); + } + + BStrPtr bstrSubFolderName = BStrPtr( + ::SysAllocString(reinterpret_cast<const OLECHAR*>(aSubFolderName))); + + HRESULT hr = parentFolder.unwrap()->DeleteFolder(bstrSubFolderName.get(), + 0 /* flags */); + + if (FAILED(hr)) { + if (hr == HRESULT_FROM_WIN32(ERROR_DIR_NOT_EMPTY)) { + return NS_ERROR_FILE_DIR_NOT_EMPTY; + } else { + return ToNotFoundOrFailure(hr); + } + } + + return NS_OK; +} diff --git a/toolkit/components/taskscheduler/nsWinTaskScheduler.h b/toolkit/components/taskscheduler/nsWinTaskScheduler.h new file mode 100644 index 0000000000..e89c1b9b05 --- /dev/null +++ b/toolkit/components/taskscheduler/nsWinTaskScheduler.h @@ -0,0 +1,22 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#ifndef WINTASKSCHEDULER_H_ +#define WINTASKSCHEDULER_H_ + +#include "nsIWinTaskSchedulerService.h" + +class nsWinTaskSchedulerService : public nsIWinTaskSchedulerService { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIWINTASKSCHEDULERSERVICE + + nsWinTaskSchedulerService() = default; + + protected: + virtual ~nsWinTaskSchedulerService() = default; +}; + +#endif /* WINTASKSCHEDULER_H_ */ diff --git a/toolkit/components/taskscheduler/tests/xpcshell/test_TaskScheduler.js b/toolkit/components/taskscheduler/tests/xpcshell/test_TaskScheduler.js new file mode 100644 index 0000000000..8c4aa8c454 --- /dev/null +++ b/toolkit/components/taskscheduler/tests/xpcshell/test_TaskScheduler.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; + +// Cross-platform task scheduler tests. +// +// There's not much that can be done here without allowing the task to run, so this +// only touches on the basics of argument checking. On platforms without a task +// scheduler implementation, these interfaces currently do nothing else. + +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo(); + +const { TaskScheduler } = ChromeUtils.importESModule( + "resource://gre/modules/TaskScheduler.sys.mjs" +); + +registerCleanupFunction(async () => { + await TaskScheduler.deleteAllTasks(); +}); + +add_task(async function test_gen() { + await TaskScheduler.registerTask( + "FOO", + "xyz", + TaskScheduler.MIN_INTERVAL_SECONDS, + { + disabled: true, + } + ); + + Assert.equal( + await TaskScheduler.taskExists("FOO"), + true, + "Task should exist after we created it." + ); + + await TaskScheduler.deleteTask("FOO"); + + Assert.equal( + await TaskScheduler.taskExists("FOO"), + false, + "Task should not exist after we deleted it." + ); + + await Assert.rejects( + TaskScheduler.registerTask("BAR", "123", 1, { + disabled: true, + }), + /Interval is too short/ + ); +}); diff --git a/toolkit/components/taskscheduler/tests/xpcshell/test_TaskSchedulerMacOSImpl.js b/toolkit/components/taskscheduler/tests/xpcshell/test_TaskSchedulerMacOSImpl.js new file mode 100644 index 0000000000..d087c77446 --- /dev/null +++ b/toolkit/components/taskscheduler/tests/xpcshell/test_TaskSchedulerMacOSImpl.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; + +// Unit tests for macOS scheduled task generation. + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo(); + +const { TaskScheduler } = ChromeUtils.importESModule( + "resource://gre/modules/TaskScheduler.sys.mjs" +); +const { MacOSImpl } = ChromeUtils.importESModule( + "resource://gre/modules/TaskSchedulerMacOSImpl.sys.mjs" +); + +function getFirefoxExecutableFilename() { + if (AppConstants.platform === "win") { + return AppConstants.MOZ_APP_NAME + ".exe"; + } + return AppConstants.MOZ_APP_NAME; +} + +// Returns a nsIFile to the firefox.exe (really, application) executable file. +function getFirefoxExecutableFile() { + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file = Services.dirsvc.get("GreBinD", Ci.nsIFile); + + file.append(getFirefoxExecutableFilename()); + return file; +} + +const uuidGenerator = Services.uuid; + +function randomName() { + return ( + "moz-taskschd-test-" + uuidGenerator.generateUUID().toString().slice(1, -1) + ); +} + +add_task(async function test_all() { + let labels; + Assert.notEqual(await MacOSImpl._uid(), 0, "Should not be running as root"); + + let id1 = randomName(); + let id2 = randomName(); + Assert.notEqual(id1, id2, "Random labels should not collide"); + + await MacOSImpl.registerTask( + id1, + getFirefoxExecutableFile().path, + TaskScheduler.MIN_INTERVAL_SECONDS, + { disabled: true } + ); + + await MacOSImpl.registerTask( + id2, + getFirefoxExecutableFile().path, + TaskScheduler.MIN_INTERVAL_SECONDS, + { disabled: true } + ); + + let label1 = MacOSImpl._formatLabelForThisApp(id1); + let label2 = MacOSImpl._formatLabelForThisApp(id2); + + // We don't assert equality because there may be existing tasks, concurrent + // tests, etc. This also means we can't reasonably tests `deleteAllTasks()`. + labels = await MacOSImpl._listAllLabelsForThisApp(); + Assert.ok( + labels.includes(label1), + `Task ${label1} should have been registered in ${JSON.stringify(labels)}` + ); + Assert.ok( + labels.includes(label2), + `Task ${label2} should have been registered in ${JSON.stringify(labels)}` + ); + + Assert.ok(await MacOSImpl.deleteTask(id1)); + + labels = await MacOSImpl._listAllLabelsForThisApp(); + Assert.ok( + !labels.includes(label1), + `Task ${label1} should no longer be registered in ${JSON.stringify(labels)}` + ); + Assert.ok( + labels.includes(label2), + `Task ${label2} should still be registered in ${JSON.stringify(labels)}` + ); + + Assert.ok(await MacOSImpl.deleteTask(id2)); + + labels = await MacOSImpl._listAllLabelsForThisApp(); + Assert.ok( + !labels.includes(label1), + `Task ${label1} should no longer be registered in ${JSON.stringify(labels)}` + ); + Assert.ok( + !labels.includes(label2), + `Task ${label2} should no longer be registered in ${JSON.stringify(labels)}` + ); +}); diff --git a/toolkit/components/taskscheduler/tests/xpcshell/test_TaskSchedulerWinImpl.js b/toolkit/components/taskscheduler/tests/xpcshell/test_TaskSchedulerWinImpl.js new file mode 100644 index 0000000000..ed1be3e49b --- /dev/null +++ b/toolkit/components/taskscheduler/tests/xpcshell/test_TaskSchedulerWinImpl.js @@ -0,0 +1,291 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; + +// Unit tests for Windows scheduled task generation. + +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo(); + +const { TaskScheduler } = ChromeUtils.importESModule( + "resource://gre/modules/TaskScheduler.sys.mjs" +); + +const { WinImpl } = ChromeUtils.importESModule( + "resource://gre/modules/TaskSchedulerWinImpl.sys.mjs" +); + +const WinSvc = Cc["@mozilla.org/win-task-scheduler-service;1"].getService( + Ci.nsIWinTaskSchedulerService +); + +const uuidGenerator = Services.uuid; + +function randomName() { + return ( + "moz-taskschd-test-" + uuidGenerator.generateUUID().toString().slice(1, -1) + ); +} + +const gFolderName = randomName(); + +// Override task folder name, to prevent colliding with other tests. +WinImpl._taskFolderName = function () { + return gFolderName; +}; +WinImpl._taskFolderNameParts = function () { + return { + parentName: "\\", + subName: gFolderName, + }; +}; + +registerCleanupFunction(async () => { + await TaskScheduler.deleteAllTasks(); +}); + +add_task(async function test_create() { + const taskName = "test-task-1"; + const rawTaskName = WinImpl._formatTaskName(taskName); + const folderName = WinImpl._taskFolderName(); + const exePath = "C:\\Program Files\\XYZ\\123.exe"; + const workingDir = "C:\\Program Files\\XYZ"; + const argsIn = [ + "x.txt", + "c:\\x.txt", + 'C:\\"HELLO WORLD".txt', + "only space.txt", + ]; + const expectedArgsOutStr = [ + "x.txt", + "c:\\x.txt", + '"C:\\\\\\"HELLO WORLD\\".txt"', + '"only space.txt"', + ].join(" "); + const description = "Entities: < &. Non-ASCII: abc😀def."; + const intervalSecsIn = 2 * 60 * 60; // 2 hours + const expectedIntervalOutWin10 = "PT2H"; // Windows 10 regroups by hours and minutes + const expectedIntervalOutWin7 = `PT${intervalSecsIn}S`; // Windows 7 doesn't regroup + + await TaskScheduler.registerTask(taskName, exePath, intervalSecsIn, { + disabled: true, + args: argsIn, + description, + workingDirectory: workingDir, + }); + + // Read back the task + const readBackXML = WinSvc.getTaskXML(folderName, rawTaskName); + const parser = new DOMParser(); + const doc = parser.parseFromString(readBackXML, "text/xml"); + Assert.equal(doc.documentElement.tagName, "Task"); + + // Check for the values set above + Assert.equal(doc.querySelector("Actions Exec Command").textContent, exePath); + Assert.equal( + doc.querySelector("Actions Exec WorkingDirectory").textContent, + workingDir + ); + Assert.equal( + doc.querySelector("Actions Exec Arguments").textContent, + expectedArgsOutStr + ); + Assert.equal( + doc.querySelector("RegistrationInfo Description").textContent, + description + ); + Assert.equal( + doc.querySelector("RegistrationInfo Author").textContent, + Services.appinfo.vendor + ); + + Assert.equal(doc.querySelector("Settings Enabled").textContent, "false"); + + // Note: It's a little too tricky to check for a specific StartBoundary value reliably here, given + // that it gets set relative to Date.now(), so I'm skipping that. + const intervalOut = doc.querySelector( + "Triggers TimeTrigger Repetition Interval" + ).textContent; + Assert.ok( + intervalOut == expectedIntervalOutWin7 || + intervalOut == expectedIntervalOutWin10 + ); + + // Validate the XML + WinSvc.validateTaskDefinition(readBackXML); + + // Update + const updatedExePath = "C:\\Program Files (x86)\\ABC\\foo.exe"; + const updatedIntervalSecsIn = 3 * 60 * 60; // 3 hours + const expectedUpdatedIntervalOutWin10 = "PT3H"; + const expectedUpdatedIntervalOutWin7 = `PT${updatedIntervalSecsIn}S`; + + await TaskScheduler.registerTask( + taskName, + updatedExePath, + updatedIntervalSecsIn, + { + disabled: true, + args: argsIn, + description, + workingDirectory: workingDir, + } + ); + + // Read back the updated task + const readBackUpdatedXML = WinSvc.getTaskXML(folderName, rawTaskName); + const updatedDoc = parser.parseFromString(readBackUpdatedXML, "text/xml"); + Assert.equal(updatedDoc.documentElement.tagName, "Task"); + + // Check for updated values + Assert.equal( + updatedDoc.querySelector("Actions Exec Command").textContent, + updatedExePath + ); + + Assert.notEqual( + doc.querySelector("Triggers TimeTrigger StartBoundary").textContent, + updatedDoc.querySelector("Triggers TimeTrigger StartBoundary").textContent + ); + const updatedIntervalOut = updatedDoc.querySelector( + "Triggers TimeTrigger Repetition Interval" + ).textContent; + Assert.ok( + updatedIntervalOut == expectedUpdatedIntervalOutWin7 || + updatedIntervalOut == expectedUpdatedIntervalOutWin10 + ); + + // Check that the folder really was there + { + const { parentName, subName } = WinImpl._taskFolderNameParts(); + let threw; + try { + WinSvc.deleteFolder(parentName, subName); + } catch (ex) { + threw = ex; + } + Assert.equal(threw.result, Cr.NS_ERROR_FILE_DIR_NOT_EMPTY); + } + + // Delete + await TaskScheduler.deleteAllTasks(); + + // Check that the folder is gone + { + const { parentName, subName } = WinImpl._taskFolderNameParts(); + let threw; + try { + WinSvc.deleteFolder(parentName, subName); + } catch (ex) { + threw = ex; + } + Assert.equal(threw.result, Cr.NS_ERROR_FILE_NOT_FOUND); + } + + // Format and validate the XML with the task not disabled + const enabledXML = WinImpl._formatTaskDefinitionXML(exePath, intervalSecsIn, { + args: argsIn, + description, + workingDirectory: workingDir, + }); + Assert.equal(WinSvc.validateTaskDefinition(enabledXML), 0 /* S_OK */); + + // Format and validate with no options + const basicXML = WinImpl._formatTaskDefinitionXML( + "foo", + TaskScheduler.MIN_INTERVAL_SECONDS + ); + Assert.equal(WinSvc.validateTaskDefinition(basicXML), 0 /* S_OK */); +}); + +add_task(async function test_migrate() { + // Create task name with nameVersion1 + const taskName = "test-task-1"; + const rawTaskNameV1 = WinImpl._formatTaskName(taskName, { nameVersion: 1 }); + const rawTaskNameV2 = WinImpl._formatTaskName(taskName, { nameVersion: 2 }); + const folderName = WinImpl._taskFolderName(); + const exePath = "C:\\Program Files\\XYZ\\123.exe"; + const workingDir = "C:\\Program Files\\XYZ"; + const argsIn = [ + "x.txt", + "c:\\x.txt", + 'C:\\"HELLO WORLD".txt', + "only space.txt", + ]; + const expectedArgsOutStr = [ + "x.txt", + "c:\\x.txt", + '"C:\\\\\\"HELLO WORLD\\".txt"', + '"only space.txt"', + ].join(" "); + const description = "Entities: < &. Non-ASCII: abc😀def."; + const intervalSecsIn = 2 * 60 * 60; // 2 hours + const expectedIntervalOut = "PT2H"; // 2 hours + + const queries = [ + ["Actions Exec Command", exePath], + ["Actions Exec WorkingDirectory", workingDir], + ["Actions Exec Arguments", expectedArgsOutStr], + ["RegistrationInfo Description", description], + ["RegistrationInfo Author", Services.appinfo.vendor], + ["Settings Enabled", "false"], + ["Triggers TimeTrigger Repetition Interval", expectedIntervalOut], + ]; + + await TaskScheduler.registerTask(taskName, exePath, intervalSecsIn, { + disabled: true, + args: argsIn, + description, + workingDirectory: workingDir, + nameVersion: 1, + }); + + ok( + WinImpl.taskExists(taskName, { nameVersion: 1 }), + "Task exists with nameVersion1" + ); + const originalTaskXML = WinSvc.getTaskXML(folderName, rawTaskNameV1); + const parser = new DOMParser(); + const docV1 = parser.parseFromString(originalTaskXML, "text/xml"); + + Assert.equal(docV1.documentElement.tagName, "Task"); + + // Check for the values set above + for (let [sel, expected] of queries) { + Assert.equal( + docV1.querySelector(sel).textContent, + expected, + `Task V1 ${sel} had expected textContent` + ); + } + + // Update task name format to nameVersion2 + WinImpl._updateTaskNameFormat(taskName); + ok( + WinImpl.taskExists(taskName, { nameVersion: 2 }), + "Task exists with nameVersion2" + ); + ok( + !WinImpl.taskExists(taskName, { nameVersion: 1 }), + "Task with nameVersion1 successfully deleted" + ); + + // Check that the new task XML is still valid + const newTaskXML = WinSvc.getTaskXML(folderName, rawTaskNameV2); + Assert.equal(WinSvc.validateTaskDefinition(newTaskXML), 0 /* S_OK */); + const docV2 = parser.parseFromString(newTaskXML, "text/xml"); + + Assert.equal(docV2.documentElement.tagName, "Task"); + + // Check that the updated values still match the provided ones. + for (let [sel, expected] of queries) { + Assert.equal( + docV2.querySelector(sel).textContent, + expected, + `Task V2 ${sel} had expected textContent` + ); + } +}); diff --git a/toolkit/components/taskscheduler/tests/xpcshell/test_WinTaskSchedulerService.js b/toolkit/components/taskscheduler/tests/xpcshell/test_WinTaskSchedulerService.js new file mode 100644 index 0000000000..d84fe0901c --- /dev/null +++ b/toolkit/components/taskscheduler/tests/xpcshell/test_WinTaskSchedulerService.js @@ -0,0 +1,183 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; + +// Unit tests for access to the Windows Task Scheduler via nsIWinTaskSchedulerService. + +const svc = Cc["@mozilla.org/win-task-scheduler-service;1"].getService( + Ci.nsIWinTaskSchedulerService +); + +function randomName() { + return ( + "moz-taskschd-test-" + Services.uuid.generateUUID().toString().slice(1, -1) + ); +} + +const gParentFolderName = randomName(); +const gParentFolderPath = `\\${gParentFolderName}`; +const gSubFolderName = randomName(); +const gSubFolderPath = `\\${gParentFolderName}\\${gSubFolderName}`; +// This folder will not be created +const gMissingFolderName = randomName(); +const gMissingFolderPath = `\\${gParentFolderName}\\${gMissingFolderName}`; + +const gValidTaskXML = `<Task xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> + <Triggers /> + <Settings> + <Enabled>false</Enabled> + </Settings> + <Actions> + <Exec> + <Command>xyz123.exe</Command> + </Exec> + </Actions> +</Task>`; + +// Missing actions +const gInvalidTaskXML = `<Task xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> + <Triggers /> + <Settings> + <Enabled>false</Enabled> + </Settings> +</Task>`; + +function cleanup() { + let tasksToDelete = svc.getFolderTasks(gSubFolderPath); + + for (const task of tasksToDelete) { + svc.deleteTask(gSubFolderPath, task); + } + + svc.deleteFolder(gParentFolderPath, gSubFolderName); + + svc.deleteFolder("\\", gParentFolderPath); +} + +registerCleanupFunction(() => { + try { + cleanup(); + } catch (_ex) { + // Folders may not exist + } +}); + +add_task(async function test_svc() { + /***** FOLDERS *****/ + + // Try creating subfolder before parent folder exists + Assert.throws( + () => svc.createFolder(gParentFolderPath, gSubFolderName), + /NS_ERROR_FILE_NOT_FOUND/ + ); + + // Create parent folder + svc.createFolder("\\", gParentFolderName); + + // Create subfolder + svc.createFolder(gParentFolderPath, gSubFolderName); + + // Try creating existing folder + Assert.throws( + () => svc.createFolder(gParentFolderPath, gSubFolderName), + /NS_ERROR_FILE_ALREADY_EXISTS/ + ); + + // Try deleting nonexistent subfolder + Assert.throws( + () => svc.deleteFolder(gParentFolderPath, gMissingFolderName), + /NS_ERROR_FILE_NOT_FOUND/ + ); + + /***** TASKS *****/ + const taskNames = [randomName(), randomName(), randomName()]; + + // Try enumerating nonexistent subfolder + Assert.throws( + () => svc.getFolderTasks(gMissingFolderPath), + /NS_ERROR_FILE_NOT_FOUND/ + ); + + // List empty subfolder + Assert.deepEqual(svc.getFolderTasks(gSubFolderPath), []); + + // Try to create task in nonexistent subfolder + Assert.throws( + () => svc.registerTask(gMissingFolderPath, taskNames[0], gValidTaskXML), + /NS_ERROR_FILE_NOT_FOUND/ + ); + + // Create task 0 + + svc.registerTask(gSubFolderPath, taskNames[0], gValidTaskXML); + + // Try to recreate task 0 + Assert.throws( + () => svc.registerTask(gSubFolderPath, taskNames[0], gValidTaskXML), + /NS_ERROR_FILE_ALREADY_EXISTS/ + ); + + // Update task 0 + svc.registerTask( + gSubFolderPath, + taskNames[0], + gValidTaskXML, + true /* aUpdateExisting */ + ); + + // Read back XML + Assert.ok(svc.getTaskXML(gSubFolderPath, taskNames[0])); + + // Create remaining tasks + for (const task of taskNames.slice(1)) { + svc.registerTask(gSubFolderPath, task, gValidTaskXML); + } + + // Try to create with invalid XML + Assert.throws( + () => svc.registerTask(gSubFolderPath, randomName(), gInvalidTaskXML), + /NS_ERROR_FAILURE/ + ); + + // Validate XML + Assert.equal(svc.validateTaskDefinition(gValidTaskXML), 0 /* S_OK */); + + // Try to validate invalid XML + Assert.notEqual(svc.validateTaskDefinition(gInvalidTaskXML), 0 /* S_OK */); + + // Test enumeration + { + let foundTasks = svc.getFolderTasks(gSubFolderPath); + foundTasks.sort(); + + let allTasks = taskNames.slice(); + allTasks.sort(); + + Assert.deepEqual(foundTasks, allTasks); + } + + // Try deleting non-empty folder + Assert.throws( + () => svc.deleteFolder(gParentFolderPath, gSubFolderName), + /NS_ERROR_FILE_DIR_NOT_EMPTY/ + ); + + const missingTaskName = randomName(); + + // Try deleting non-existent task + Assert.throws( + () => svc.deleteTask(gSubFolderName, missingTaskName), + /NS_ERROR_FILE_NOT_FOUND/ + ); + + // Try reading non-existent task + Assert.throws( + () => svc.getTaskXML(gSubFolderPath, missingTaskName), + /NS_ERROR_FILE_NOT_FOUND/ + ); + + /***** Cleanup *****/ + // Explicitly call cleanup() to test that it removes the folder without error. + cleanup(); +}); diff --git a/toolkit/components/taskscheduler/tests/xpcshell/xpcshell.toml b/toolkit/components/taskscheduler/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..9551b1c877 --- /dev/null +++ b/toolkit/components/taskscheduler/tests/xpcshell/xpcshell.toml @@ -0,0 +1,20 @@ +[DEFAULT] + +["test_TaskScheduler.js"] +skip-if = [ + "apple_silicon", # bug 1707753 + "apple_catalina", # Bug 1713329 +] + +["test_TaskSchedulerMacOSImpl.js"] +run-if = ["os == 'mac'"] # Test of macOS backend +skip-if = [ + "apple_silicon", # bug 1707753 + "apple_catalina", # Bug 1713329 +] + +["test_TaskSchedulerWinImpl.js"] +run-if = ["os == 'win'"] # Test of Windows backend + +["test_WinTaskSchedulerService.js"] +run-if = ["os == 'win'"] # Test of Windows only service |