summaryrefslogtreecommitdiffstats
path: root/toolkit/components/taskscheduler
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
commit9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/taskscheduler
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/taskscheduler')
-rw-r--r--toolkit/components/taskscheduler/TaskScheduler.sys.mjs151
-rw-r--r--toolkit/components/taskscheduler/TaskSchedulerMacOSImpl.sys.mjs351
-rw-r--r--toolkit/components/taskscheduler/TaskSchedulerWinImpl.sys.mjs282
-rw-r--r--toolkit/components/taskscheduler/components.conf18
-rw-r--r--toolkit/components/taskscheduler/moz.build29
-rw-r--r--toolkit/components/taskscheduler/nsIWinTaskSchedulerService.idl104
-rw-r--r--toolkit/components/taskscheduler/nsWinTaskScheduler.cpp308
-rw-r--r--toolkit/components/taskscheduler/nsWinTaskScheduler.h22
-rw-r--r--toolkit/components/taskscheduler/tests/xpcshell/test_TaskScheduler.js55
-rw-r--r--toolkit/components/taskscheduler/tests/xpcshell/test_TaskSchedulerMacOSImpl.js108
-rw-r--r--toolkit/components/taskscheduler/tests/xpcshell/test_TaskSchedulerWinImpl.js202
-rw-r--r--toolkit/components/taskscheduler/tests/xpcshell/test_WinTaskSchedulerService.js183
-rw-r--r--toolkit/components/taskscheduler/tests/xpcshell/xpcshell.ini13
13 files changed, 1826 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..d432484a06
--- /dev/null
+++ b/toolkit/components/taskscheduler/TaskScheduler.sys.mjs
@@ -0,0 +1,151 @@
+/* -*- 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, {
+ WinImpl: "resource://gre/modules/TaskSchedulerWinImpl.sys.mjs",
+ MacOSImpl: "resource://gre/modules/TaskSchedulerMacOSImpl.sys.mjs",
+});
+
+XPCOMUtils.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 options
+ * Optional, as are all of its properties:
+ * {
+ * 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.
+ *
+ * workingDirectory
+ * Working directory for the command. If missing, no working directory is set.
+ *
+ * description
+ * A description string that will be visible to system administrators. This should
+ * be localized. If missing, no description is set.
+ *
+ * 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.
+ *
+ * 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.
+ * }
+ * }
+ */
+ 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.
+ *
+ * @throws NS_ERROR_FILE_NOT_FOUND if the task does not exist.
+ */
+ async deleteTask(id) {
+ return lazy.gImpl.deleteTask(id);
+ },
+
+ /**
+ * Delete all tasks registered by this application.
+ */
+ async deleteAllTasks() {
+ return lazy.gImpl.deleteAllTasks();
+ },
+
+ /**
+ * Checks if a task exists.
+ *
+ * @param id
+ * A string representing the identifier of the task to look for.
+ *
+ * @return
+ * true if the task exists, otherwise false.
+ */
+ async taskExists(id) {
+ return lazy.gImpl.taskExists(id);
+ },
+};
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
+ );
+ }
+ },
+};
diff --git a/toolkit/components/taskscheduler/TaskSchedulerWinImpl.sys.mjs b/toolkit/components/taskscheduler/TaskSchedulerWinImpl.sys.mjs
new file mode 100644
index 0000000000..b9f2716fb8
--- /dev/null
+++ b/toolkit/components/taskscheduler/TaskSchedulerWinImpl.sys.mjs
@@ -0,0 +1,282 @@
+/* -*- 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),
+ xml,
+ updateExisting
+ );
+ },
+
+ deleteTask(id) {
+ lazy.WinTaskSvc.deleteTask(
+ this._taskFolderName(),
+ this._formatTaskName(id)
+ );
+ },
+
+ /**
+ * 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) {
+ 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));
+ },
+
+ _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,
+ };
+ },
+
+ _formatTaskName(id) {
+ const installHash = lazy.XreDirProvider.getInstallHash();
+ return `${id} ${installHash}`;
+ },
+
+ _matchAppTaskName(name) {
+ const installHash = lazy.XreDirProvider.getInstallHash();
+ return name.endsWith(` ${installHash}`);
+ },
+};
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..abff3e7a1d
--- /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.ini"]
+
+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..7a7c5747c0
--- /dev/null
+++ b/toolkit/components/taskscheduler/nsIWinTaskSchedulerService.idl
@@ -0,0 +1,104 @@
+/* -*- 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);
+
+ /**
+ * 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..21b2909805
--- /dev/null
+++ b/toolkit/components/taskscheduler/nsWinTaskScheduler.cpp
@@ -0,0 +1,308 @@
+/* -*- 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 <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::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..d0c2a6c357
--- /dev/null
+++ b/toolkit/components/taskscheduler/tests/xpcshell/test_TaskSchedulerWinImpl.js
@@ -0,0 +1,202 @@
+/* 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 */);
+});
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.ini b/toolkit/components/taskscheduler/tests/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..ede5742015
--- /dev/null
+++ b/toolkit/components/taskscheduler/tests/xpcshell/xpcshell.ini
@@ -0,0 +1,13 @@
+[test_TaskSchedulerMacOSImpl.js]
+run-if = os == "mac" # Test of macOS backend
+skip-if =
+ apple_silicon # bug 1707753
+ apple_catalina # Bug 1713329
+[test_WinTaskSchedulerService.js]
+run-if = os == "win" # Test of Windows only service
+[test_TaskSchedulerWinImpl.js]
+run-if = os == "win" # Test of Windows backend
+[test_TaskScheduler.js]
+skip-if =
+ apple_silicon # bug 1707753
+ apple_catalina # Bug 1713329