diff --git a/core/modules/update/update.authorize.inc b/core/modules/update/update.authorize.inc
index d6a7066..827354d 100644
--- a/core/modules/update/update.authorize.inc
+++ b/core/modules/update/update.authorize.inc
@@ -65,30 +65,38 @@ function update_authorize_run_update($filetransfer, $projects) {
  * @param FileTransfer $filetransfer
  *   The FileTransfer object created by authorize.php for use during this
  *   operation.
- * @param string $project
- *   The canonical project short name; i.e., the name of the module, theme, or
- *   profile.
- * @param string $updater_name
- *   The name of the Drupal\Core\Updater\Updater class to use for installing
- *   this project.
- * @param string $local_url
- *   The URL to the locally installed temp directory where the project has
- *   already been downloaded and extracted into.
+ * @param Array $projects
+ *   A nested array of projects to install into the live webroot. Each
+ *   subarray contains the following keys:
+ *   - project: The canonical project short name; i.e., the name of the
+ *     module, theme, or profile.
+ *   - updater_name: The name of the Drupal\Core\Updater\Updater class to use
+ *     for installing this project.
+ *   - local_url: The URL to the locally installed temp directory where the
+ *     project has already been downloaded and extracted into.
  */
-function update_authorize_run_install($filetransfer, $project, $updater_name, $local_url) {
-  $operations[] = array(
-    'update_authorize_batch_copy_project',
-    array(
-      $project,
-      $updater_name,
-      $local_url,
-      $filetransfer,
-    ),
-  );
+function update_authorize_run_install($filetransfer, $projects) {
+  $operations = array();
+  foreach ($projects as $project_info) {
+    $operations[] = array(
+      'update_authorize_batch_copy_project',
+      array(
+        $project_info['project'],
+        $project_info['updater_name'],
+        $project_info['local_url'],
+        $filetransfer,
+      ),
+    );
+  }
 
+  $title = format_plural(count($projects),
+    'Installing %project',
+    'Installing @count projects',
+    array('%project' => $project_info)
+  );
   // @todo Instantiate our Updater to set the human-readable title?
   $batch = array(
-    'title' => t('Installing %project', array('%project' => $project)),
+    'title' => $title,
     'init_message' => t('Preparing to install'),
     'operations' => $operations,
     // @todo Use a different finished callback for different messages?
diff --git a/core/modules/update/update.manager.inc b/core/modules/update/update.manager.inc
index da571ed..151234f 100644
--- a/core/modules/update/update.manager.inc
+++ b/core/modules/update/update.manager.inc
@@ -68,6 +68,181 @@ function update_manager_download_batch_finished($success, $results) {
 }
 
 /**
+ * Form constructor for the update ready form.
+ *
+ * Build the form when the site is ready to update (after downloading).
+ *
+ * This form is an intermediary step in the automated update workflow. It is
+ * presented to the site administrator after all the required updates have been
+ * downloaded and verified. The point of this page is to encourage the user to
+ * backup their site, give them the opportunity to put the site offline, and
+ * then ask them to confirm that the update should continue. After this step,
+ * the user is redirected to authorize.php to enter their file transfer
+ * credentials and attempt to complete the update.
+ *
+ * @see update_manager_update_ready_form_submit()
+ * @see update_menu()
+ * @ingroup forms
+ */
+function update_manager_update_ready_form($form, &$form_state) {
+  if (!_update_manager_check_backends($form, 'update')) {
+    return $form;
+  }
+
+  $form['backup'] = array(
+    '#prefix' => '<strong>',
+    '#markup' => t('Back up your database and site before you continue. <a href="@backup_url">Learn how</a>.', array('@backup_url' => url('http://drupal.org/node/22281'))),
+    '#suffix' => '</strong>',
+  );
+
+  $form['maintenance_mode'] = array(
+    '#title' => t('Perform updates with site in maintenance mode (strongly recommended)'),
+    '#type' => 'checkbox',
+    '#default_value' => TRUE,
+  );
+
+  $form['actions'] = array('#type' => 'actions');
+  $form['actions']['submit'] = array(
+    '#type' => 'submit',
+    '#value' => t('Continue'),
+  );
+
+  return $form;
+}
+
+/**
+ * Form submission handler for update_manager_update_ready_form().
+ *
+ * If the site administrator requested that the site is put offline during the
+ * update, do so now. Otherwise, pull information about all the required updates
+ * out of the SESSION, figure out what Drupal\Core\Updater\Updater class is
+ * needed for each one, generate an array of update operations to perform, and
+ * hand it all off to system_authorized_init(), then redirect to authorize.php.
+ *
+ * @see update_authorize_run_update()
+ * @see system_authorized_init()
+ * @see system_authorized_get_url()
+ */
+function update_manager_update_ready_form_submit($form, &$form_state) {
+  // Store maintenance_mode setting so we can restore it when done.
+  $_SESSION['maintenance_mode'] = config('system.maintenance')->get('enabled');
+  if ($form_state['values']['maintenance_mode'] == TRUE) {
+    config('system.maintenance')->set('enabled', TRUE)->save();
+  }
+
+  if (!empty($_SESSION['update_manager_update_projects'])) {
+    // Make sure the Updater registry is loaded.
+    drupal_get_updaters();
+
+    $updates = array();
+    $directory = _update_manager_extract_directory();
+
+    $projects = $_SESSION['update_manager_update_projects'];
+    unset($_SESSION['update_manager_update_projects']);
+
+    foreach ($projects as $project => $url) {
+      $project_location = $directory . '/' . $project;
+      $updater = Updater::factory($project_location);
+      $project_real_location = drupal_realpath($project_location);
+      $updates[] = array(
+        'project' => $project,
+        'updater_name' => get_class($updater),
+        'local_url' => $project_real_location,
+      );
+    }
+
+    // If the owner of the last directory we extracted is the same as the
+    // owner of our configuration directory (e.g. sites/default) where we're
+    // trying to install the code, there's no need to prompt for FTP/SSH
+    // credentials. Instead, we instantiate a Drupal\Core\FileTransfer\Local and
+    // invoke update_authorize_run_update() directly.
+    if (fileowner($project_real_location) == fileowner(conf_path())) {
+      module_load_include('inc', 'update', 'update.authorize');
+      $filetransfer = new Local(DRUPAL_ROOT);
+      update_authorize_run_update($filetransfer, $updates);
+    }
+    // Otherwise, go through the regular workflow to prompt for FTP/SSH
+    // credentials and invoke update_authorize_run_update() indirectly with
+    // whatever FileTransfer object authorize.php creates for us.
+    else {
+      system_authorized_init('update_authorize_run_update', drupal_get_path('module', 'update') . '/update.authorize.inc', array($updates), t('Update manager'));
+      $form_state['redirect'] = system_authorized_get_url();
+    }
+  }
+}
+
+/**
+ * @} End of "defgroup update_manager_update".
+ */
+
+/**
+ * @defgroup update_manager_install Update Manager module: install
+ * @{
+ * Update Manager module functionality for installing new code.
+ *
+ * Provides a user interface to install new code.
+ */
+
+/**
+ * Form constructor for the install form of the Update Manager module.
+ *
+ * This presents a place to enter a URL or upload an archive file to use to
+ * install a new module or theme.
+ *
+ * @param String $context
+ *   The context from which we're trying to install. Allowed values are
+ *   'module', 'theme', and 'report'.
+ *
+ * @see update_manager_install_form_validate()
+ * @see update_manager_install_form_submit()
+ * @see update_menu()
+ * @ingroup forms
+ */
+function update_manager_install_form($form, &$form_state, $context) {
+  if (!_update_manager_check_backends($form, 'install')) {
+    return $form;
+  }
+
+  $form['help_text'] = array(
+    '#prefix' => '<p>',
+    '#markup' => t('You can find <a href="@module_url">modules</a> and <a href="@theme_url">themes</a> on <a href="@drupal_org_url">drupal.org</a>. The following file extensions are supported: %extensions.', array(
+      '@module_url' => 'http://drupal.org/project/modules',
+      '@theme_url' => 'http://drupal.org/project/themes',
+      '@drupal_org_url' => 'http://drupal.org',
+      '%extensions' => archiver_get_extensions(),
+    )),
+    '#suffix' => '</p>',
+  );
+
+  $form['project_urls'] = array(
+    '#type' => 'textarea',
+    '#title' => t('Install from a URL'),
+    '#description' => t('For example: %url. Enter one per line.',
+      array('%url' => 'http://ftp.drupal.org/files/projects/name.tar.gz')),
+  );
+
+  $form['information'] = array(
+    '#prefix' => '<strong>',
+    '#markup' => t('Or'),
+    '#suffix' => '</strong>',
+  );
+
+  $form['project_upload'] = array(
+    '#type' => 'file',
+    '#title' => t('Upload a module or theme archive to install'),
+    '#description' => t('For example: %filename from your local computer', array('%filename' => 'name.tar.gz')),
+  );
+
+  $form['actions'] = array('#type' => 'actions');
+  $form['actions']['submit'] = array(
+    '#type' => 'submit',
+    '#value' => t('Install'),
+  );
+
+  return $form;
+}
+
+/**
  * Checks for file transfer backends and prepares a form fragment about them.
  *
  * @param array $form
@@ -135,6 +310,215 @@ function _update_manager_check_backends(&$form, $operation) {
 }
 
 /**
+ * Form validation handler for update_manager_install_form().
+ *
+ * @see update_manager_install_form_submit()
+ * @see form_validate_url()
+ */
+function update_manager_install_form_validate($form, &$form_state) {
+  $project_urls = $form_state['values']['project_urls'];
+  $project_upload = $_FILES['files']['name']['project_upload'];
+  if (!($project_urls XOR !empty($project_upload))) {
+    form_set_error('project_urls',
+      t('You must either provide a URL or upload an archive file to install.'));
+  }
+  if ($project_urls) {
+    // Trim whitespace and validate url's.
+    $url_list = array_filter(array_map('trim', explode("\n", $project_urls)));
+    form_set_value($form['project_urls'], implode("\n", $url_list), $form_state);
+    $bad_urls = array_filter($url_list, '_update_manager_bad_url');
+    if (!empty($bad_urls)) {
+      $message = format_plural(count($bad_urls),
+        'The URL %urls is not valid.',
+        'The URLs %urls are not valid.',
+        array('%urls' => implode(', ', $bad_urls))
+      );
+      form_set_error('project_urls', $message);
+    }
+  }
+}
+
+/**
+ * Callback function used in update_manager_install_form_validate().
+ */
+function _update_manager_bad_url($url) {
+  return !valid_url($url, TRUE);
+}
+
+/**
+ * Form submission handler for update_manager_install_form().
+ *
+ * Either downloads the file specified in the URL to a temporary cache, or
+ * uploads the file attached to the form, then attempts to extract the archive
+ * into a temporary location and verify it. Instantiate the appropriate
+ * Drupal\Core\Updater\Updater class for this project and make sure it is not
+ * already installed in the live webroot. If everything is successful, setup an
+ * operation to run via authorize.php which will copy the extracted files from
+ * the temporary location into the live site.
+ *
+ * @see update_manager_install_form_validate()
+ * @see update_authorize_run_install()
+ * @see system_authorized_init()
+ * @see system_authorized_get_url()
+ */
+function update_manager_install_form_submit($form, &$form_state) {
+  $local_caches = array();
+  if ($form_state['values']['project_urls']) {
+    $field = 'project_urls';
+    foreach (explode("\n", $form_state['values']['project_urls']) as $url) {
+      $local_cache = update_manager_file_get($url);
+      if ($local_cache) {
+        $local_caches[] = $local_cache;
+      }
+      else {
+        form_set_error($field, t('Unable to retrieve Drupal project from %url.',
+          array('%url' => $url)));
+        return;
+      }
+    }
+  }
+  elseif ($_FILES['files']['name']['project_upload']) {
+    $validators = array('file_validate_extensions' => array(archiver_get_extensions()));
+    $field = 'project_upload';
+    if (!($finfo = file_save_upload($field, $validators, NULL, FILE_EXISTS_REPLACE))) {
+      // Failed to upload the file. file_save_upload() calls form_set_error() on
+      // failure.
+      return;
+    }
+    $local_caches[] = $finfo->uri;
+  }
+
+  $directory = _update_manager_extract_directory();
+  $project_data = array();
+  $already_installed = array();
+
+  // Loop through the projects to be installed. If any one of them has a
+  // problem, then set a form error and bail out without trying any more.
+  foreach ($local_caches as $local_cache) {
+    try {
+      $archive = update_manager_archive_extract($local_cache, $directory);
+    }
+    catch (Exception $e) {
+      form_set_error($field, $e->getMessage());
+      return;
+    }
+
+    $files = $archive->listContents();
+    if (!$files) {
+      form_set_error($field, t('Provided archive contains no files.'));
+      return;
+    }
+
+    // Unfortunately, we can only use the directory name to determine the
+    // project name. Some archivers list the first file as the directory
+    // (i.e., MODULE/) and others list an actual file (i.e., MODULE/README.TXT).
+    $project = strtok($files[0], '/\\');
+
+    $archive_errors = update_manager_archive_verify($project, $local_cache, $directory);
+    if (!empty($archive_errors)) {
+      form_set_error($field, array_shift($archive_errors));
+      // @todo: Fix me in D8: We need a way to set multiple errors on the same
+      // form element and have all of them appear!
+      if (!empty($archive_errors)) {
+        foreach ($archive_errors as $error) {
+          drupal_set_message($error, 'error');
+        }
+      }
+      return;
+    }
+
+    $project_location = $directory . '/' . $project;
+    try {
+      $updater = Updater::factory($project_location);
+    }
+    catch (Exception $e) {
+      form_set_error($field, $e->getMessage());
+      return;
+    }
+
+    try {
+      $project_title = Updater::getProjectTitle($project_location);
+    }
+    catch (Exception $e) {
+      form_set_error($field, $e->getMessage());
+      return;
+    }
+
+    if (!$project_title) {
+      form_set_error($field, t('Unable to determine %project name.', array('%project' => $project)));
+    }
+
+    if ($updater->isInstalled()) {
+      $already_installed[] = $project_title;
+      continue;
+    }
+
+    $project_real_location = drupal_realpath($project_location);
+    $project_data[] = array(
+      'project' => $project,
+      'updater_name' => get_class($updater),
+      'local_url' => $project_real_location,
+    );
+  }
+
+  // The validation function makes sure that there is at least one project, so
+  // if $project_data is empty, then $already_installed is not.
+  if (empty($project_data)) {
+    $message = format_plural(count($already_installed),
+      '%projects is already installed.',
+      'All @count projects are already installed.',
+      array('%projects' => $already_installed[0])
+    );
+    form_set_error($field, $message);
+    return;
+  }
+  elseif ($count = count($already_installed)) {
+    $message = format_plural($count,
+      'Skipped %projects, which is already installed.',
+      'Skipped these @count projects, which are already installed: %projects.',
+      array('%projects' => implode(', ', $already_installed))
+    );
+    drupal_set_message($message, 'warning');
+  }
+
+  // If the owner of the directory we extracted is the same as the
+  // owner of our configuration directory (e.g. sites/default) where we're
+  // trying to install the code, there's no need to prompt for FTP/SSH
+  // credentials. Instead, we instantiate a Drupal\Core\FileTransfer\Local and
+  // invoke update_authorize_run_install() directly. If there is more than one
+  // project, the directories should all have ths same owner, so we can test
+  // using $project_real_location as set for the last one.
+  if (fileowner($project_real_location) == fileowner(conf_path())) {
+    module_load_include('inc', 'update', 'update.authorize');
+    update_authorize_run_install(new Local(DRUPAL_ROOT), $project_data);
+  }
+  // Otherwise, go through the regular workflow to prompt for FTP/SSH
+  // credentials and invoke update_authorize_run_install() indirectly with
+  // whatever FileTransfer object authorize.php creates for us.
+  else {
+    system_authorized_init('update_authorize_run_install',
+      drupal_get_path('module', 'update') . '/update.authorize.inc',
+      array('project_data' => $project_data),
+      t('Update manager')
+    );
+    $form_state['redirect'] = system_authorized_get_url();
+  }
+}
+
+/**
+ * @} End of "defgroup update_manager_install".
+ */
+
+/**
+ * @defgroup update_manager_file Update Manager module: file management
+ * @{
+ * Update Manager module file management functions.
+ *
+ * These functions are used by the update manager to copy, extract, and verify
+ * archive files.
+ */
+
+/**
  * Unpacks a downloaded archive file.
  *
  * @param string $file
