🐉

Firefoxに新機能を追加してみた

2024/11/19に公開

はじめに

この記事は、2024年度の東京大学工学部電気電子工学科・電子情報工学科3年後期実験「大規模ソフトウェアを手探る」の成果報告レポートです。

firefoxとは

Firefoxは、Mozillaによって開発されているWebブラウザです。数ある主要ブラウザの中でも比較的ビルドが簡単であったため、今回の実験で手探ることにしました。

Firefoxのソースコードは、バックエンドが主にC++、フロントエンドが主にJavaScriptで書かれています。C++部分の改変は複雑で敷居が高いため、今回はJavaScript部分を中心に改良を行い、便利な機能を追加することにしました。

ソースコードの入手とビルド方法

ここでは、linux上でビルドすることを前提とします。

Mozillaのリポジトリからソースコードを入手し、公式の案内に従ってビルドを行いました。

git-cinnabarを使う方法もありますが、インストールに若干手間がかかるのでhgを使う方法がおすすめです。

実際にコマンドラインで実行したコードを以下に示します。

python3 -m pip install --user mercurial
curl https://hg.mozilla.org/mozilla-central/raw-file/default/python/mozboot/bin/bootstrap.py -O
python3 bootstrap.py
cd mozilla-unified
hg up -C central
./mach build
./mach run
ビルドでエラーが出た時の対処方法

「ERROR: Cannot find unzip」のようなエラーが出た場合は、コマンドラインで以下を実行。

unset UNZIP

KeyError: 'HG'のようなエラーが出たときは、hgのパスが通っていないことが原因。
まずはhg –versionでhgがインストールされていることを確認し、なかったらインストールする。

その上で、コマンドラインで以下を実行。

hg up -C central
./mach clobber
./mach build

「git: 'cinnabar' はgitコマンドではありません」のようなエラーが出たときは、git-cinnabarへのパスが通っていないことが原因。
まずは、git-cinnabarのリポジトリから、案内に従ってインストール。

その上で、コマンドラインで以下を実行。

export PATH=$PATH:~/git-cinnabar # インストール先へのパスを通す。後半のパスは適宜変更
source ~/.bashrc #bashを使っているときはこれで設定の変更を反映
git cinnabar --version # パスが通っていることを確認
./mach clobber
./mach build

機能追加の手順

firefoxの開発者ツールであるBrowser Toolboxをデバッガとして使いました。

以下の画像のようなもので、Inspectorを開くと、実行時/表示時に HTML と CSS の状態も見ることができます。

このToolboxと、vscodeなどのファイル検索機能を使うことで、firefoxの主にフロントエンドの部分を手探りました。

追加した機能

1. ダウンロード時の音楽の再生

ブラウザで重いファイルをダウンロードするような時いつ終わるか分からなくてPCの近くから離れられない状況が多々あるので、
少し離れて画面を見ていなくてもダウンロード完了がわかるようにダウンロード進行中は音楽が再生されるような機能を実装しました。

mozilla-unified/browser/components/downloads/content/indicator.jsというファイルがダウンロードの進捗に関係しているということはToolboxを使った検証で
比較的早めに分かりましたが、そのファイルのどこに注目すればいいかを特定するのにかなり時間がかかりました。

班のメンバーに、ダウンロード中のアイコンの変化をしているファイルがmozilla-unified/browser/themes/shared/downloads/indicator.cssだと教えてもらったので、
これを手掛かりにしてダウンロードの進行状況を処理している関数を探しました。

このファイルの79行目の#downloads-indicator-progress-innerに、以下のようなコードを発見して、
ダウンロードの進行状況が--download-progress-pcentという変数に入っていたので、この変数が他のファイルで使われていないか調べました。

#downloads-indicator-progress-inner {
  --download-progress-pcent: 0%;

  width: 14px;
  height: 14px;
  /*
    From javascript side we update the --download-progress-pcent variable to show the current progress
   */
  background: conic-gradient(var(--toolbarbutton-icon-fill-attention) var(--download-progress-pcent), transparent var(--download-progress-pcent));
  border-radius: 50%;
}

検索した結果、もともと目をつけていたmozilla-unified/browser/components/downloads/content/indicator.jsの473行目の_maybeScheduleProgressUpdate()
がヒットしました。

この関数がダウンロードの進行状況の処理をしているとわかったので、以下のように編集しました。

_maybeScheduleProgressUpdate() {
    // 音楽のファイルURL
    const musicUrl = "https://amachamusic.chagasi.com/mp3/tanoshiimugibatake.mp3";
    // const musicUrl = "SoundEffect.mp3";
  
    // 音楽のオブジェクトを初期化
    if (!this._audio) {
      this._audio = new Audio(musicUrl);
      this._audio.loop = true; // ループ再生を設定
    
  // ...中略

          // 音楽が再生されていない場合に再生を開始
          if (this._audio.paused) {
            this._audio.play().catch(error => {
              console.error("音楽の再生に失敗しました:", error);
            });
          }

その結果、web上のフリー音源がファイルのダウンロード時に流れるようになりました。

2. トップのロゴを変える

Google Chromeの季節やイベントごとにトップページのデザインが変わるような機能があると面白いなと思い、開くたびにトップのロゴが変わる機能をFirefoxに実装してみました。

まずはBrowser ToolboxのInspecterタブから、ロゴのhtml要素を探します。(ロゴを右クリックして「Inspecter」を選択するとhtmlファイルの対応する箇所に直接飛ぶことができます)

次にVscodeやDebuggerタブのSearchからclassや"logo"などのワードを検索して、トップページのhtmlファイルや関係する箇所を探してみました。

すると、トップページのhtmlファイルは見つからなかったもののmozilla-unified/browser/components/newtab/data/content/activity-stream.bundle.jsにロゴのhtml要素を生成していると思われるLogo関数を見つけました。

function Logo() {
  return /*#__PURE__*/external_React_default().createElement("div", {
    className: "logo-and-wordmark",
    role: "img",
    "data-l10n-id": "newtab-logo-and-wordmark"
  }, /*#__PURE__*/external_React_default().createElement("div", {
    className: "logo"
  }), /*#__PURE__*/external_React_default().createElement("div", {
    className: "wordmark"
  }));
}

どうやらトップページは静的なhtmlファイルをサーバーから取得するのではなく、開くたびにJavaScriptで動的に生成しているようです。これはユーザーの閲覧履歴に基づいたコンテンツや天気、壁紙の設定などユーザーの状態によって表示を変える必要があるからだと考えられます。

そのためLogo関数を以下のように変更し、開くたびにランダムで6つhtml要素の中から一つが選ばれるようにしました。

function Logo() {
  // ロゴのクラス名リスト
  const logoClasses = ["logo1", "logo2", "logo3", "logo4", "logo5","logo6"]; // 好きな数だけ追加可能
  
  // ランダムにインデックスを選択してクラス名を取得
  const randomIndex = Math.floor(Math.random() * logoClasses.length);
  const selectedLogoClass = logoClasses[randomIndex];

  return /*#__PURE__*/external_React_default().createElement("div", {
    className: "logo-and-wordmark",
    role: "img",
    "data-l10n-id": "newtab-logo-and-wordmark"
  }, /*#__PURE__*/external_React_default().createElement("div", {
    className: selectedLogoClass
  }), /*#__PURE__*/external_React_default().createElement("div", {
    className: "wordmark"
  }));
}

さらにlogo1,logo2,...に対応する画像をmozilla-unified/browser/components/newtab/css/activity-stream.cssから以下のように追加しました。

.logo-and-wordmark .logo1 {
  display: inline-block;
  height: 64px;
  width: 64px;
  background: image-set(url("chrome://branding/content/about-logo1.png")) no-repeat center;
  background-size: 64px;
}

表示する画像データはmozilla-unified/browser/branding/unofficial/に保存しました。ただこれだけだとロゴが表示されず空白になってしまいます。以下のようにTerminal上でバイナリに画像ファイルのシンボリックリンクを作成することで解決しました。

$ sudo ln -s ~mozilla-unified/browser/branding/unofficial/about-logo.png ~mozilla-unified/objdir-frontend-debug-airtifact/dist/bin/browser/chrome/browser/chrome/browser/content/branding/about-logo.png

ここまで設定するとタブを開くたびに追加した6つの画像からランダムに選ばれたロゴが表示されるようになります。

3. タブの保存・復元

Firefoxには元々Restore Previous Sessionという前回終了時のウインドウとタブを一度だけ復元することができる機能が備わっています。

ただこれは保存のタイミングが決まっており、復元できるのは一度きりなため、開いていた複数のタブ情報を好きなタイミングで保存・復元する機能を実装してみました。

まずはボタンそのものをUIに追加していきます。場所はアプリケーションメニュー(右上の3本線マーク)に追加する方針です。

Browser Toolbox上で例えばcommand="Browser:restoreLastSession"と検索して、アプリケーションメニューのボタンが定義されている場所mozilla-unified/browser/base/content/appmenu-viewcache.inc.xhtmlを特定しました。

ここの50行目付近にボタン定義を以下のように追加しました。

<toolbarbutton id="appMenu-savetabs-button"
               class="subviewbutton"
               data-l10n-id="appmenuitem-save-tabs"
               command="Browser:SaveTabs"/>
<toolbarbutton id="appMenu-restoretabs-button"
               class="subviewbutton"
               data-l10n-id="appmenuitem-restore-saved-tabs"
               command="Browser:RestoreSavedTabs"/>

data(ボタンの表示名)は次のappmenu.ftlと対応させます。commandは以下のbrowser-sets.jsで設定したものと揃えました。key(ショートカットコマンド)は今回設定していません。

ボタンの表示名はmozilla-unified/browser/locales/en-US/browser/appmenu.ftlから次のように設定しました。

appmenuitem-save-tabs =
    .label = Save tabs
appmenuitem-restore-saved-tabs =
    .label = Restore saved tabs

これでメニュー欄にSave tabsボタンとRestore saved tabsボタンが追加されました。

次にこれらのボタンを押した時の処理を追加していきます。

まずコマンドを定義していきます。mozilla-unified/browser/base/content/browser-sets.incの86行目付近にcommand idを追加しました。

<command id="Browser:SaveTabs" />
<command id="Browser:RestoreSavedTabs" />

そしてmozilla-unified/browser/base/content/browser-sets.jsの193行目以降に上記で設定した2つのcommandが呼ばれた際のcase文を追加します。

let openTabsBackup = []; // メモリ内にタブ情報を保存するための配列
case "Browser:SaveTabs":
    function saveOpenTabsToMemory() {
        openTabsBackup = []; // バックアップを初期化
        let tabbrowser = gBrowser; // gBrowserはタブブラウザのインスタンス
            
        for (let tab of tabbrowser.tabs) {
            openTabsBackup.push({
                title: tab.label,
                url: tab.linkedBrowser.currentURI.spec
            });
        }
    }
    saveOpenTabsToMemory();                
break;
case "Browser:RestoreSavedTabs":
    function restoreOpenTabsFromMemory() {
        for (let tabInfo of openTabsBackup) {
            let newTab = gBrowser.addTab(tabInfo.url, {
                triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal() // Principalを設定
            }); 
            newTab.label = tabInfo.title; // タブのタイトルを設定
        }
    }
   restoreOpenTabsFromMemory();
break;

ここまで設定してみると、メニューにボタンが追加されて押したときにbrowser-sets.jsで設定した処理が実行されるようになりました。

ボタンを押した時の処理について、今回は実装のしやすさからメモリ(グローバル変数)にタブ情報を保存するようにしています。そのため、Firefoxが実行終了すると保存していた情報は消えてしまいます。

機会があればバックエンドのDBに保存してみたいと思います。

4. ページを「いいね」する機能

普段からFirefoxを使用している中で、「現在開いているタブを気軽に保存しておける機能があれば便利だな」と思うことがありました。タブを復元する際に履歴から探すのは手間がかかりますし、毎回ブックマークを利用するのも少しハードルが高いと感じていました。そこで、もっと気軽に使える「いいね」機能を実装してみることにしました。

実装の流れとしては、まずFirefoxのブックマーク機能がどのように作られているかを調査するところから始めました。その際、FirefoxのBrowser Toolboxをデバッガとして利用しました。ブックマーク機能に関連しそうな箇所にブレークポイントを設置し、ブックマークボタンを押したときに呼び出される関数を一つひとつ確認しました。

次に、ボタンの配置について具体的に取り組みました。該当のCSSファイルに以下のようなコードを記述し、HTMLファイル内で指定したIDにスタイルを適用しました。このコードでは、「like-button」というボックスを定義し、その状態が「liked」になると赤色のハートアイコンが表示されるよう設定しています。ここでは、いいねが押される前と押された後の2種類のアイコンのSVGファイルを自作し、リンク先に配置することで、無事に見た目を整えることができました。

#like-button-box {
  display: flex;
}
#like-button{
  list-style-image: url("chrome://browser/skin/heart-hollow.svg");
}
#like-button[liked] {
  list-style-image: url("chrome://browser/skin/heart-filled.svg");
  fill-opacity: 1;
  fill: var(--toolbarbutton-icon-fill-attention);
}

アイコンを作成した後は、いいねボタンを押すと「liked pages」というフォルダの中にページを保存するようにしました。今回、「いいね」機能を実現するために、以下のコードのようにタブの現在のURLを取得し、それを「いいね」のデータとして管理する仕組みを実装しました。このコードでは、クリックイベントをトリガーとして「いいね」状態の切り替え(追加・解除)を行っています。

async onLikeCommand(aEvent) {
    if ((aEvent.type != "click" || aEvent.button == 0)) {
      let currentUrl = gBrowser.currentURI.spec;
      let likeFolderId = await this.getLikeFolderId();
      // 既にいいねがついているか確認
      let isLiked = await PlacesUtils.bookmarks.search({ url: currentUrl, parentGuid: likeFolderId });
      
      if (isLiked.length > 0) {
        // いいね解除
        await PlacesUtils.bookmarks.remove(isLiked[0]);
        this.like.removeAttribute("liked");
      } else {
        // いいね追加
        await PlacesUtils.bookmarks.insert({
          parentGuid: likeFolderId,
          url: currentUrl,
          title: gBrowser.contentTitle || currentUrl,
          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
        });
        this.like.setAttribute("liked", "true");
      }
    }
  }

このような変更を加えることで、以下のように「いいね」されたページがうまく保存されるようになりました。

5. DL完了付近でDL履歴を開く

「大きなファイルをDLするときに別の作業をしたいが、DLが終了したらすぐに気づきたい」ということを考えました。サイトによって右上のツールバーの右上のDLボタンのポップアップが出現するときとしないときがあるため、これを統一する方針を取りました。Firefoxにはダウンロードやブックマークを管理する別ウィンドウ(英語版ではLibraryと表示される)が存在し、これを表示することにしました。

実装する自然な流れとしては、「ダウンロードが終了する」→「Libraryを表示する」という処理を行うことですが、前者の処理を探し当てることができませんでした。今回の実験ではフロントエンド部分を手探っていたため、ダウンロードそのものを司っているであろうバックエンド部分に触れることがなかったのが原因であったと思われます。そこで、DLの進捗状況を表示するインジケータから間接的にDLが終了したか否かを判断することにしました。機能1でも言及されているmozilla-unified/browser/components/downloads/content/indicator.jsの473行目の_maybeScheduleProgressUpdate()がこれにあたり、

 _maybeScheduleProgressUpdate() {
   if (
     this.indicator &&
     !this._progressRaf &&
     document.visibilityState == "visible"
   ) {
     this._progressRaf = requestAnimationFrame(() => {
       // indeterminate downloads (unknown content-length) will show up as aValue = 0
       if (this._percentComplete >= 0) {
         if (!this.indicator.hasAttribute("progress")) {
           this.indicator.setAttribute("progress", "true");
         }
         // For arrow type only: Set the % complete on the pie-chart.
         // We use a minimum of 10% to ensure something is always visible
         this._progressIcon.style.setProperty(
           "--download-progress-pcent",
           `${Math.max(10, this._percentComplete)}%`
         );
       } else {
         this.indicator.removeAttribute("progress");
         this._progressIcon.style.setProperty(
           "--download-progress-pcent",
           "0%"
         );
       }
       this._progressRaf = null;

       //(↓追記部分)ダウンロードの進捗が95%を超えたならば
        if (this._percentComplete > 95) {
          //ダウンロードヒストリーを直接表示せよ
          DownloadsPanel.showDownloadsHistory();
        }
      //(↑追記部分)
     });
   }
 },

のように、ダウンロード進捗状況が95%を超えたという表示になったならば、Libraryのうちダウンロード履歴を司っている部分の画面を表示する関数DownloadsPanel.showDownloadsHistory()が呼ばれる、という仕様にしました。この関数自体はmozilla-unified/browser/components/downloads/content/downloads.jsにて定義がなされています。「ダウンロード進捗状況が100%に達したという表示になったならば」、つまり、

if (this._percentComplete == 100)

という条件にしていないのは、100%に達した時点で_maybeScheduleProgressUpdate()が呼ばれなくなるのか、うまくLibraryのウィンドウが表示されなかったからです。99%など100に限りなく近い値に設定したときも処理が間に合わないのか、上手く表示されませんでした。95%で試したところ多くの場合においてダウンロード完了付近に自動でLibrary画面が表示されました。また、他アプリケーションのウインドウを表示していても「Libraryの準備ができました」というポップアップが表示されるため、ダウンロード中にブラウジング以外の作業を行ってもダウンロード終了が近いことに気がつくことができます。

また、DLのインジケータを押下したときにも直接Libraryへ飛べるように、mozilla-unified/browser/components/downloads/content/downloads.jsの570行目の_openPopupIfDataReady()関数中にもDownloadsPanel.showDownloadsHistory()関数を追記しました。これによってダウンロード完了付近でLibraryが自動的に開いたあとに一度Libraryを閉じてもワンタップで遷移できます。(遷移自体はCtrl+Shift+Yで可能なため、あくまで表示を統一するための追記です)

おわりに

演習を通して、特に修正箇所のファイルを探す作業が大変でした。

システムは想像以上に大規模で、簡単な機能を追加するだけでも複数のファイルをまたいで編集する必要がありました。また、似たような処理が別の関係ないファイルに記述されていることも多く、目的のコードがどこにあるのかを特定するのに苦労しました。

それでも、目的の箇所を見つけて実装し、実際に動作を確認できたときには大きな達成感を得られました。

Discussion