Firefox: улучшения панели загрузок

в 19:27, , рубрики: Firefox, javascript, mozilla, загрузка файлов, расширение Firefox, метки: ,

Речь пойдет об особенностях новой панели загрузок в Firefox и расширении Download Panel Tweaker, устраняющем некоторые из нежелательных особенностей.
В частности, о самом спорном, на мой взгляд, нововведении, из-за которого завершенные загрузки пропадают из списка (хотя и остаются видны в соответствующем разделе «библиотеки») – так уж получилось, что на это исправление улучшение времени ушло больше всего.
Результат выглядит так (это «компактный» вариант из настроек, «очень компактный» позволит сэкономить еще немного места):
Скриншот версии 0.2.0
А вот как было изначально.
Также будет довольно много примеров кода (а то куда же без подробностей?).

Вместо предисловия, или да здравствует компактность!

Для начала, все элементы панели загрузок по умолчанию огромны! Во-первых, у меня не сенсорный монитор – спасибо, но я и так могу попасть в нужный пункт. А во-вторых, пунктов видно всего три, не больше. То есть место расходуется, а пользы что-то мало.
В общем-то, если бы ограничение видимого количества загрузок можно было бы настроить встроенными средствами, расширения могло бы и не быть, потому как размеры легко настраиваются с помощью userChrome.css или расширения Stylish.
Вдобавок одними только стилями невозможно (?) вывести скорость загрузки – она есть во всплывающей подсказке, но псевдоклассы ::before и ::after работают в XUL далеко не всегда (видимо, это ограничения анонимных узлов), так что подобное не помогает:

.downloadDetails[tooltiptext]::after {
	content: attr(tooltiptext) !important;
}

Увеличение количества видимых загрузок

В результате код, отвечающий за количество загрузок в панели нашелся достаточно быстро в файле chrome://browser/content/downloads/downloads.js:

const DownloadsView = {
  //////////////////////////////////////////////////////////////////////////////
  //// Functions handling download items in the list

  /**
   * Maximum number of items shown by the list at any given time.
   */
  kItemCountLimit: 3,

Но изменения будут работать только если были внесены при запуске браузера, то есть до того, как инициализировалась панель загрузок.
Поэтому дальнейшие поиски привели в файл resource://app/modules/DownloadsCommon.jsm и в функцию DownloadsCommon.getSummary():

  /**
   * Returns a reference to the DownloadsSummaryData singleton - creating one
   * in the process if one hasn't been instantiated yet.
   *
   * @param aWindow
   *        The browser window which owns the download button.
   * @param aNumToExclude
   *        The number of items on the top of the downloads list to exclude
   *        from the summary.
   */
  getSummary: function DC_getSummary(aWindow, aNumToExclude)
  {
    if (PrivateBrowsingUtils.isWindowPrivate(aWindow)) {
      if (this._privateSummary) {
        return this._privateSummary;
      }
      return this._privateSummary = new DownloadsSummaryData(true, aNumToExclude);
    } else {
      if (this._summary) {
        return this._summary;
      }
      return this._summary = new DownloadsSummaryData(false, aNumToExclude);
    }
  },

И собственно происходящее в конструкторе DownloadsSummaryData:

/**
 * DownloadsSummaryData is a view for DownloadsData that produces a summary
 * of all downloads after a certain exclusion point aNumToExclude. For example,
 * if there were 5 downloads in progress, and a DownloadsSummaryData was
 * constructed with aNumToExclude equal to 3, then that DownloadsSummaryData
 * would produce a summary of the last 2 downloads.
 *
 * @param aIsPrivate
 *        True if the browser window which owns the download button is a private
 *        window.
 * @param aNumToExclude
 *        The number of items to exclude from the summary, starting from the
 *        top of the list.
 */
function DownloadsSummaryData(aIsPrivate, aNumToExclude) {
  this._numToExclude = aNumToExclude;

Тут все просто – достаточно сделать вот так:

var itemCountLimit = 5; // Для примера увеличим количество видимых загрузок с 3 до 5
if(DownloadsCommon._privateSummary)
	DownloadsCommon._privateSummary._numToExclude = itemCountLimit;
if(DownloadsCommon._summary)
	DownloadsCommon._summary._numToExclude = itemCountLimit;

То есть корректируется лимит для уже созданных обычного и приватного экземпляров DownloadsSummaryData.
Самая же сложная часть состоит в том, что сам по себе список загрузок в соответствии с новыми настройками не перерисуется.
Но тут у меня была фора: в процессе разработки расширения Private Tab (по которому, кстати, тоже была статья) возник точно такой же вопрос, потому как нужно было менять список загрузок с обычного на приватный при переключении вкладок (и там не обошлось без множества экспериментов разной степени успешности).
Но без подвохов, как водится, все равно не обошлось: в Firefox 28 удалили функцию для очистки панели загрузок, совсем (раньше она была для переключения между старым и новом движком загрузок). Так что пришлось написать аналог – благо, он простой.
Результат можно посмотреть по уже приведенной выше ссылке или в коде расширения.
Причем в расширении есть особенность: если просто сделать

DownloadsView._viewItems = {};
DownloadsView._dataItems = [];

, то возникнет утечка памяти, потому как объекты будут созданы в области видимости расширения, так что пригодились обычно не используемые функции-конструкторы:

DownloadsView._viewItems = new window.Object();
DownloadsView._dataItems = new window.Array();

При этом window – это окно, в котором находится DownloadsView (то есть DownloadsView === window.DownloadsView).

Сохранение загрузок при выходе

Это оказалось самым сложным, но не столько из-за того, что внутренняя реализация загрузок поменялась в Firefox 26, сколько из-за множества сопутствующих проблем. Многие из этих сложностей отражены в соответствующем issue, но лучше обо всем по порядку.

Старые версии и очистка загрузок

В Firefox 25 и более старых версиях загрузки принудительно очищались (resource://app/components/DownloadsStartup.js):

      case "browser-lastwindow-close-granted":
        // When using the panel interface, downloads that are already completed
        // should be removed when the last full browser window is closed.  This
        // event is invoked only if the application is not shutting down yet.
        // If the Download Manager service is not initialized, we don't want to
        // initialize it just to clean up completed downloads, because they can
        // be present only in case there was a browser crash or restart.
        if (this._downloadsServiceInitialized &&
            !DownloadsCommon.useToolkitUI) {
          Services.downloads.cleanUp();
        }
        break;
      ...
      case "quit-application":
        ...
        // When using the panel interface, downloads that are already completed
        // should be removed when quitting the application.
        if (!DownloadsCommon.useToolkitUI && aData != "restart") {
          this._cleanupOnShutdown = true;
        }
        break;

      case "profile-change-teardown":
        // If we need to clean up, we must do it synchronously after all the
        // "quit-application" listeners are invoked, so that the Download
        // Manager service has a chance to pause or cancel in-progress downloads
        // before we remove completed downloads from the list.  Note that, since
        // "quit-application" was invoked, we've already exited Private Browsing
        // Mode, thus we are always working on the disk database.
        if (this._cleanupOnShutdown) {
          Services.downloads.cleanUp();
        }

В результате при закрытии браузера вызывалась функция Services.downloads.cleanUp().
(Кстати, там же уже есть следы нового движка – проверка на значение настройки browser.download.useJSTransfer.)
Пришлось переопределить Services.downloads целиком, потому как это

Components.classes["@mozilla.org/download-manager;1"]
	.getService(Components.interfaces.nsIDownloadManager);

(см. resource://gre/modules/Services.jsm), а свойства сервисов менять нельзя:

Object.defineProperty(Services.downloads, "cleanUp", {
	value: function() {},
	enumerable: true,
	configurable: true,
	writable: true
}); // Exception: can't redefine non-configurable property 'cleanUp'

Если упрощенно, результат получился следующий:

var downloads = Services.downloads;
var downloadsWrapper = {
	__proto__: downloads,
	cleanUp: function() { ... }
};
this.setProperty(Services, "downloads", downloadsWrapper);

То есть наш поддельный объект содержит свою реализацию функции-свойства cleanUp и наследует все остальное от настоящего Services.downloads.
Ну, а из поддельной функции Services.downloads.cleanUp() можно проверить стек вызова, и если это тот самый DownloadsStartup.js, то ничего не делать. Это хоть и не очень надежно, но зато легко восстанавливается при отключении расширения. Можно даже усложинить задачу и добавить проверки на случай, если какое-нибудь другое расширение сделает аналогичную обертку.

Новые версии, выборочное сохранение загрузок и множество хаков

Затем в Firefox 26 включили по умолчанию новый движок загрузок и перенесли временные загрузки (а именно они выводятся в панель загрузок) в файл downloads.json в профиле. Вдобавок вместо очистки была сделана фильтрация при сохранении:
resource://gre/modules/DownloadStore.jsm

this.DownloadStore.prototype = {
  ...
  /**
   * This function is called with a Download object as its first argument, and
   * should return true if the item should be saved.
   */
  onsaveitem: () => true,
  ...
  /**
   * Saves persistent downloads from the list to the file.
   *
   * If an error occurs, the previous file is not deleted.
   *
   * @return {Promise}
   * @resolves When the operation finished successfully.
   * @rejects JavaScript exception.
   */
  save: function DS_save()
  {
    return Task.spawn(function task_DS_save() {
      let downloads = yield this.list.getAll();
      ...
      for (let download of downloads) {
        try {
          if (!this.onsaveitem(download)) {
            continue;
          }

Затем в resource://gre/modules/DownloadIntegration.jsm метод onsaveitem переопределяется:

this.DownloadIntegration = {
  ...
  initializePublicDownloadList: function(aList) {
    return Task.spawn(function task_DI_initializePublicDownloadList() {
      ...
      this._store.onsaveitem = this.shouldPersistDownload.bind(this);
  ...
  /**
   * Determines if a Download object from the list of persistent downloads
   * should be saved into a file, so that it can be restored across sessions.
   *
   * This function allows filtering out downloads that the host application is
   * not interested in persisting across sessions, for example downloads that
   * finished successfully.
   *
   * @param aDownload
   *        The Download object to be inspected.  This is originally taken from
   *        the global DownloadList object for downloads that were not started
   *        from a private browsing window.  The item may have been removed
   *        from the list since the save operation started, though in this case
   *        the save operation will be repeated later.
   *
   * @return True to save the download, false otherwise.
   */
  shouldPersistDownload: function (aDownload)
  {
    // In the default implementation, we save all the downloads currently in
    // progress, as well as stopped downloads for which we retained partially
    // downloaded data.  Stopped downloads for which we don't need to track the
    // presence of a ".part" file are only retained in the browser history.
    // On b2g, we keep a few days of history.
//@line 319 "c:buildsmoz2_slavem-cen-w32-ntly-000000000000000buildtoolkitcomponentsjsdownloadssrcDownloadIntegration.jsm"
    return aDownload.hasPartialData || !aDownload.stopped;
//@line 321 "c:buildsmoz2_slavem-cen-w32-ntly-000000000000000buildtoolkitcomponentsjsdownloadssrcDownloadIntegration.jsm"
  },

Таким образом, есть функция DownloadStore.prototype.onsaveitem(), которая всегда разрешает сохранение и переопределяется для каждой конкретной реализации new DownloadStore().
(Забегая вперед, добавлю, что, к сожалению, не все комментарии одинаково полезны и правдивы.)
Причем в исходном коде DownloadIntegration.jsm есть интересный условный комментарий:

   shouldPersistDownload: function (aDownload)
   {
     // In the default implementation, we save all the downloads currently in
     // progress, as well as stopped downloads for which we retained partially
     // downloaded data.  Stopped downloads for which we don't need to track the
     // presence of a ".part" file are only retained in the browser history.
     // On b2g, we keep a few days of history.
 #ifdef MOZ_B2G
     let maxTime = Date.now() -
       Services.prefs.getIntPref("dom.downloads.max_retention_days") * 24 * 60 * 60 * 1000;
     return (aDownload.startTime > maxTime) ||
            aDownload.hasPartialData ||
            !aDownload.stopped;
 #else
     return aDownload.hasPartialData || !aDownload.stopped;
 #endif
   },

Однако, если подменить функцию DownloadIntegration.shouldPersistDownload() (и не забыть про DownloadIntegration._store.onsaveitem() на случай, если загрузки уже инициализировались) по аналогии с кодом из этого условного комментария, всплывет целая куча неприятных сюрпризов – вроде, и документировано, а корректно работает только в изначальном виде, когда завершенные загрузки не сохраняются.

Во-первых, после перезапуска у всех завершенных загрузок будет показываться нулевой размер и время старта браузера (хотя в downloads.json все корректно сохраняется).
Неверная дата вызвана кодом из resource://app/modules/DownloadsCommon.jsm:

/**
 * Represents a single item in the list of downloads.
 *
 * The endTime property is initialized to the current date and time.
 *
 * @param aDownload
 *        The Download object with the current state.
 */
function DownloadsDataItem(aDownload)
{
  this._download = aDownload;
  ...
  this.endTime = Date.now();

  this.updateFromDownload();
}

Затем это время не меняется при вызове DownloadsDataItem.prototype.updateFromJSDownload() (Firefox 26-27) и DownloadsDataItem.prototype.updateFromDownload() (Firefox 28+).
К счастью, можно сделать обертку вокруг этой функции и вносить необходимые правки при каждом вызове.

Во-вторых, код из условного комментария про MOZ_B2G на самом деле не работает: завершенные загрузки не будут удалены вообще никогда. Причем других подходящих условных комментариев про MOZ_B2G найти не удалось (видимо, там тоже не работает), правда, я не особо старался – там легко все исправить.
Затем отсюда же и в-третьих: большой список завершенных загрузок не может быть загружен – происходит переполнение стека (ошибка «too much recursion»). Причем проблему можно получить уже на списке из 35 загрузок.
Видимо, используемая реализация обещаний (promises) не умеет корректно работать с фактически синхронными вызовами.
К примеру, если в DownloadStore.prototype.load() (resource://gre/modules/DownloadStore.jsm) немного подправить и заменить в

  /**
   * Loads persistent downloads from the file to the list.
   *
   * @return {Promise}
   * @resolves When the operation finished successfully.
   * @rejects JavaScript exception.
   */
  load: function DS_load()
  {
    return Task.spawn(function task_DS_load() {
      let bytes;
      try {
        bytes = yield OS.File.read(this.path);
      } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
        // If the file does not exist, there are no downloads to load.
        return;
      }

      let storeData = JSON.parse(gTextDecoder.decode(bytes));

      // Create live downloads based on the static snapshot.
      for (let downloadData of storeData.list) {
        try {
          let download = yield Downloads.createDownload(downloadData);

последнюю строчку на

          let {Download} = Components.utils.import("resource://gre/modules/DownloadCore.jsm", {});
          let download = Download.fromSerializable(downloadData);

, то переполнение стека никуда не денется, но произойдет при чуть большем количестве сохраненных загрузок.
В-четверых, где-то еще есть оптимизации (?), так что удаление завершенных загрузок через интерфейс может не запустить сохранение данных в downloads.json (по умолчанию они же не сохраняются), поэтому после перезагрузки все останется как было.
К счастью, можно применить простой хак: добавить сохранение актуальных загрузок при завершении работы браузера.
В-пятых, при запуске браузера, если в панели загрузок что-то есть (даже если это приостановленные или вообще завершенные загрузки) будет уведомление о начале загрузки.
Но у нас уже есть обертка для DownloadsDataItem.prototype.updateFromDownload(), так что это легко правится.
Ну, а код чтения downloads.json, к сожалению, пришлось переписать. От чего мое мнение об обещаниях (promises) ничуть не улучшилось – любую технологию надо применять с умом и только там, где она реально нужна (а не пихать куда ни попадя только потому, что это модно и современно).
А еще есть странная проблема со списком загрузок: если даты почти не отличаются, то сортировка окажется инвертированной (новые будут снизу, а не сверху), но если при запуске браузера открыть панель загрузок до того, как запустится отложенная инициализация, то порядок будет правильный (но только в этом окне).
Вдобавок проблемы с большим списком загрузок не ограничиваются чтением… Хотя даже с чтением есть еще одна проблема: после каждого добавления новой загрузки вызывается обновление интерфейса, синхронно. При восстановлении сохраненного списка тоже происходит добавление с последующим оповещением об этом всех заинтересованных. Здесь пришлось сделать еще парочку исправлений.
Тут неоценимую помощь оказал встроенный профайлер (Shift+F5), без него разобраться в причинах подвисания было бы практически нереально – уж больно сложная там логика (а стек вызовов ужасает).
Ну, а помимо чтения с полным списком работает еще, как минимум, функция очистки списка, так что там тоже может вывалиться в переполнение стека, если список достаточно большой. Это пока не исправлено. Но, в принципе, это не критично: если что-то не должно быть сохранено, всегда есть приватный режим, а поштучное удаление (и добавленный пункт очистки вообще всех загрузок) работает.
Что самое интересное, с незавершенными загрузками проблем куда меньше – их восстановление по каким-то причинам не впадет в рекурсию, даже если их довольно много (я проверял для 150 штук). По-видимому, это благодаря выделенному в отдельный поток OS.File API для работы с файлами (а у поставленных на паузу загрузок как раз проверяется наличие файла), из-за него же могут вылезать невнятные ошибки вида

Error: Win error 2 during operation open (Не удается найти указанный файл.
)
Source file: resource://gre/modules/commonjs/sdk/core/promise.js
Line: 133

(это если удалить файл поставленной на паузу загрузки)
Вдобавок, после внесенных исправлений автоматическая загрузка стала работать нормально, но если сразу после запуска браузера попытаться открыть панель загрузок, то отработает какой-то другой код и, если загрузок много, то может подвиснуть.

P.S. Новая версия расширения все еще проходит проверку, «позиция в очереди: 4 из 17». Изначально я планировал дождаться (да и тексту, в плане редактуры – а написан он больше недели назад – это только на пользу), но всему же есть предел.
P.P.S. Я старался писать так, чтобы не навязывать читателю свое мнение о качестве нового кода в Firefox, надеюсь, мне это удалось, а уж выводы пусть каждый сделает сам. Впрочем, повторюсь, в оригинальном виде никаких особых проблем не возникает.

Автор: Infocatcher

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js