diff --git a/core/modules/update/update.authorize.inc b/core/modules/update/update.authorize.inc
index 4557453..46dc79d 100644
--- a/core/modules/update/update.authorize.inc
+++ b/core/modules/update/update.authorize.inc
@@ -75,20 +75,31 @@ function update_authorize_run_update($filetransfer, $projects) {
  *   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, $project, $updater_name, $local_url) {
+function update_authorize_run_install($filetransfer, $project_data) {
+  dsm($project_data);
+  // return;
+  $operations = array();
+  foreach ($project_data as $project) {
+    $operations[] = array(
+      'update_authorize_batch_copy_project',
+      array(
+        $project['project'],
+        $project['updater_name'],
+        $project['local_url'],
+        $filetransfer,
+      ),
+    );
+  }
 
+  $title = format_plural(count($project_data),
+    'Installing %project',
+    'Installing @count projects',
+    array('%project' => $project)
+  );
   // @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 062ffca..52cc7d0 100644
--- a/core/modules/update/update.manager.inc
+++ b/core/modules/update/update.manager.inc
@@ -512,10 +512,11 @@ function update_manager_install_form($form, &$form_state, $context) {
     '#suffix' => '</p>',
   );
 
-  $form['project_url'] = array(
-    '#type' => 'url',
+  $form['project_urls'] = array(
+    '#type' => 'textarea',
     '#title' => t('Install from a URL'),
-    '#description' => t('For example: %url', array('%url' => 'http://ftp.drupal.org/files/projects/name.tar.gz')),
+    '#description' => t('For example: %url. Enter one per line.',
+      array('%url' => 'http://ftp.drupal.org/files/projects/name.tar.gz')),
   );
 
   $form['information'] = array(
@@ -610,14 +611,39 @@ 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) {
-  if (!($form_state['values']['project_url'] XOR !empty($_FILES['files']['name']['project_upload']))) {
-    form_set_error('project_url', t('You must either provide a URL or upload an archive file to install.'));
+  $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
@@ -634,12 +660,19 @@ function update_manager_install_form_validate($form, &$form_state) {
  * @see system_authorized_get_url()
  */
 function update_manager_install_form_submit($form, &$form_state) {
-  if ($form_state['values']['project_url']) {
-    $field = 'project_url';
-    $local_cache = update_manager_file_get($form_state['values']['project_url']);
-    if (!$local_cache) {
-      form_set_error($field, t('Unable to retrieve Drupal project from %url.', array('%url' => $form_state['values']['project_url'])));
-      return;
+  if ($form_state['values']['project_urls']) {
+    $field = 'project_urls';
+    $local_caches = array();
+    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']) {
@@ -654,89 +687,116 @@ function update_manager_install_form_submit($form, &$form_state) {
   }
 
   $directory = _update_manager_extract_directory();
-  try {
-    $archive = update_manager_archive_extract($local_cache, $directory);
-  }
-  catch (Exception $e) {
-    form_set_error($field, $e->getMessage());
-    return;
-  }
+  $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;
-  }
+    $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], '/\\');
+    // 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!
+    $archive_errors = update_manager_archive_verify($project, $local_cache, $directory);
     if (!empty($archive_errors)) {
-      foreach ($archive_errors as $error) {
-        drupal_set_message($error, 'error');
+      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;
     }
-    return;
-  }
 
-  // Make sure the Updater registry is loaded.
-  drupal_get_updaters();
+    $project_location = $directory . '/' . $project;
+    try {
+      $updater = Updater::factory($project_location);
+    }
+    catch (Exception $e) {
+      form_set_error($field, $e->getMessage());
+      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;
+    }
 
-  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 (!$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,
+    );
   }
 
-  if ($updater->isInstalled()) {
-    form_set_error($field, t('%project is already installed.', array('%project' => $project_title)));
+  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;
   }
-
-  $project_real_location = drupal_realpath($project_location);
-  $arguments = array(
-    'project' => $project,
-    'updater_name' => get_class($updater),
-    'local_url' => $project_real_location,
-  );
+  else {
+    $message = format_plural(count($already_installed),
+      '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.
+  // 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');
-    $filetransfer = new Local(DRUPAL_ROOT);
-    call_user_func_array('update_authorize_run_install', array_merge(array($filetransfer), $arguments));
+    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', $arguments, t('Update manager'));
+    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();
   }
 }
