Commit 3f710215 authored by Adam Gashlin's avatar Adam Gashlin
Browse files

Bug 1676296 Part 2: Cross-platform task scheduler interface, Windows...

Bug 1676296 Part 2: Cross-platform task scheduler interface, Windows implementation. r=mhowell,nalexander

Differential Revision: https://phabricator.services.mozilla.com/D97842
parent 2fb662ca
Loading
Loading
Loading
Loading
+124 −0
Original line number Diff line number Diff line
/* -*- 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/. */

"use strict";

var EXPORTED_SYMBOLS = ["TaskScheduler"];

const { XPCOMUtils } = ChromeUtils.import(
  "resource://gre/modules/XPCOMUtils.jsm"
);

XPCOMUtils.defineLazyModuleGetter(
  this,
  "WinImpl",
  "resource://gre/modules/TaskSchedulerWinImpl.jsm",
  "_TaskSchedulerWinImpl"
);

XPCOMUtils.defineLazyGetter(this, "gImpl", () => {
  if (AppConstants.platform == "win") {
    return WinImpl;
  }

  // Stubs for unsupported platforms
  return {
    registerTask() {},
    deleteTask() {},
    deleteAllTasks() {},
  };
});

const { AppConstants } = ChromeUtils.import(
  "resource://gre/modules/AppConstants.jsm"
);

/**
 * Interface to a system task scheduler, capable of running a command line at an interval
 * independent of the application.
 *
 * Currently only implemented for Windows, 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"
 */
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 as 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.
   *            Default false, intended for tests.
   *        }
   * }
   */
  registerTask(id, command, intervalSeconds, options) {
    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 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.
   */
  deleteTask(id) {
    return gImpl.deleteTask(id);
  },

  /**
   * Delete all tasks registered by this application.
   */
  deleteAllTasks() {
    return gImpl.deleteAllTasks();
  },
};
+229 −0
Original line number Diff line number Diff line
/* -*- 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/. */

"use strict";

var EXPORTED_SYMBOLS = ["_TaskSchedulerWinImpl"];

const { XPCOMUtils } = ChromeUtils.import(
  "resource://gre/modules/XPCOMUtils.jsm"
);

const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");

XPCOMUtils.defineLazyServiceGetters(this, {
  WinTaskSvc: [
    "@mozilla.org/win-task-scheduler-service;1",
    "nsIWinTaskSchedulerService",
  ],
  XreDirProvider: [
    "@mozilla.org/xre/directory-provider;1",
    "nsIXREDirProvider",
  ],
});

XPCOMUtils.defineLazyGlobalGetters(this, ["XMLSerializer"]);

/**
 * 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.
 */
var _TaskSchedulerWinImpl = {
  registerTask(id, command, intervalSeconds, options) {
    // The folder might not yet exist.
    this._createFolderIfNonexistent();

    const xml = this._formatTaskDefinitionXML(
      command,
      intervalSeconds,
      options
    );
    const updateExisting = true;

    WinTaskSvc.registerTask(
      this._taskFolderName(),
      this._formatTaskName(id),
      xml,
      updateExisting
    );
  },

  deleteTask(id) {
    WinTaskSvc.deleteTask(this._taskFolderName(), this._formatTaskName(id));
  },

  deleteAllTasks() {
    const taskFolderName = this._taskFolderName();

    let allTasks;
    try {
      allTasks = 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));

    for (const taskName of tasksToDelete) {
      WinTaskSvc.deleteTask(taskFolderName, taskName);
    }

    if (allTasks.length == tasksToDelete.length) {
      // Deleted every task, remove the folder.
      this._deleteFolderIfEmpty();
    }
  },

  _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 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";
      doc.querySelector("Settings").appendChild(enabled);
    }

    // Other settings to consider for the future:
    // Idle
    // Battery
    // Max run time

    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 {
      WinTaskSvc.createFolder(parentName, subName);
    } catch (e) {
      if (e.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
        throw e;
      }
    }
  },

  _deleteFolderIfEmpty() {
    const { parentName, subName } = this._taskFolderNameParts();

    try {
      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 = XreDirProvider.getInstallHash();
    return `${id} ${installHash}`;
  },

  _matchAppTaskName(name) {
    const installHash = XreDirProvider.getInstallHash();
    return name.endsWith(` ${installHash}`);
  },
};
+3 −0
Original line number Diff line number Diff line
@@ -12,9 +12,12 @@ XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.ini"]
XPIDL_MODULE = "taskscheduler"
XPCOM_MANIFESTS += ["components.conf"]

EXTRA_JS_MODULES += ["TaskScheduler.jsm"]

# 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.jsm"]
    XPIDL_SOURCES += ["nsIWinTaskSchedulerService.idl"]
    EXPORTS += ["nsWinTaskScheduler.h"]
    SOURCES += ["nsWinTaskScheduler.cpp"]
+40 −0
Original line number Diff line number Diff line
/* 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");

const { updateAppInfo } = ChromeUtils.import(
  "resource://testing-common/AppInfo.jsm"
);
updateAppInfo();

const { TaskScheduler } = ChromeUtils.import(
  "resource://gre/modules/TaskScheduler.jsm"
);

registerCleanupFunction(() => {
  TaskScheduler.deleteAllTasks();
});

add_task(async function test_gen() {
  TaskScheduler.registerTask("FOO", "xyz", TaskScheduler.MIN_INTERVAL_SECONDS, {
    disabled: true,
  });
  TaskScheduler.deleteTask("FOO");

  Assert.throws(
    () =>
      TaskScheduler.registerTask("BAR", "123", 1, {
        disabled: true,
      }),
    /Interval is too short/
  );
});
+205 −0
Original line number Diff line number Diff line
/* 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");

const { updateAppInfo } = ChromeUtils.import(
  "resource://testing-common/AppInfo.jsm"
);
updateAppInfo();

const { TaskScheduler } = ChromeUtils.import(
  "resource://gre/modules/TaskScheduler.jsm"
);

const { _TaskSchedulerWinImpl: WinImpl } = ChromeUtils.import(
  "resource://gre/modules/TaskSchedulerWinImpl.jsm"
);

const WinSvc = Cc["@mozilla.org/win-task-scheduler-service;1"].getService(
  Ci.nsIWinTaskSchedulerService
);

const uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService(
  Ci.nsIUUIDGenerator
);

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(() => {
  TaskScheduler.deleteAllTasks();
});

add_task(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

  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`;

  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
  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 */);
});
Loading