読者です 読者をやめる 読者になる 読者になる

風柳メモ

ソフトウェア・プログラミング関連の覚書が中心。

【覚書】Chrome拡張機能にて、オプション画面の変更を即座に反映させたい場合

Google Chrome の拡張機能で、オプション画面で変更した内容を即座に反映させたい場合には、何らかの方法でオプション画面(options_page)からスクリプト(content_scripts)が動作しているタブ(tabs)に変更を通知してやる必要があるが、そのやり方の一例。

概要

タブ(tabs)上で動作している contents_script は、オプションが格納される(extension 用の) localStorage を直接参照することはできないため、オプションを取得するためにはbackground スクリプトを介在させる必要がある。

このため、典型的には、

  1. background は、あらかじめメッセージ待ち受け状態にしておく(chrome.runtime.onMessage.addListener())。
  2. contents_script がオプションを取得したいタイミングで、background へメッセージを送信(chrome.runtime.sendMessage())。
  3. background は、contents_script よりメッセージを受信すると、localStorage よりオプション値を読み出して適宜変換後*1、contents_script に返す。

のような作りとなる。

また、オプション画面(options_page)の典型的な作りは、

  1. 開いたタイミングで、localStorage よりオプション値を読み出し、ユーザーにわかりやすい形で表示。
  2. ユーザーの入力に応じて、localStorage にオプション値を書き出し。

のようになっている。

ここに新たに

  1. contents_script 側であらかじめメッセージを受け付けられる状態にしておく(chrome.runtime.onMessage.addListener())。
  2. options_page にて、localStorage にオプション値を書き出したタイミングで、contents_scriptの動作しているタブ(tabs)に対して、メッセージを送信(chrome.tabs.sendMessage())。
  3. contents_script は、options_page よりメッセージを受信すると、background よりオプションを取得する。

のような手順を追加してやることで、オプション画面からの変更が即座にタブ側(contents_script)もに反映されるようになる。
なお、options_page→contents_scriptへメッセージを送信するときに変更したオプション内容も一緒に送信してやれば、backgroundへのオプション問い合わせは省略できる。ただしその場合、options_page側でもオプション値の翻訳(ユーザーの入力値から、contents_scriptで用いる値への変換)を実装する必要があり、やや煩雑になる可能性がある。

サンプル

具体的な実装例は、

github.com

を参照のこと。

注意
  • タブに対するメッセージ送信のために、manifest.json に
    "permissions" : [ "tabs" ]
    の記述が必要。

経緯

GoogleChrome拡張機能「twitter画像原寸ボタン」ver. 2.0公開 - hogashi.*を入れていると、ときどき、Originalボタンが表示されなくなってしまう現象があり、調べていた
現時点の最新版である、2.0.4でも発生。

状況としては、

  • しばらく(一晩とか)放置しておいたタイムラインにて、新しいツイートを表示すると、[Original]ボタンが表示されなくなっていることがある。
  • 現象発生時、DOM ツリーに変化があるたびにコールされるようになっている関数(main.js 内の start())までは呼び出されているが、オプション設定を読み込むために発行したchrome.runtime.sendMessage()のコールバック関数が呼び出されていないように見える。
  • background.js 側のchrome.runtime.onMessage.addListener()で設定した関数も呼び出されていない(ただし、他タブでタイムラインを表示したところ、正しく呼び出される)。

ということで、sendMessage() コール後のメッセージ送受信処理のどこかで滞ってしまっている可能性が高い。

もし上記推測通りとすると、Chrome の不具合の可能性が高くなってくるため、ユーザー(拡張機能開発者)側で抜本的な対策を取るのは難しいかもしれない。

回避策としては、現状イベント発生(DOMツリー更新や[Enter]キー押下時)のたびにオプション取得(background.js とのメッセージ送受信)処理を実施しているのをやめ、なるべく頻度を減らす、という方向性はどうか?

  1. main.js が起動した直後の一回だけにする(結果として、オプション変更内容の反映はタブのリロード時に行われることとなるので、リアルタイムではなくなる)。
  2. 起動時とオプション画面からの変更時にのみ、実施する。

自作のスクリプト(原寸びゅー等)では手を抜いて基本、1. の方針にしているが、本来は 2. にすべきだろう……では、オプション変更時にどうやって各タブに通知すればよいのか?

という経緯と思考過程があって、記事にまとめてみた次第。

*1:localStorage には文字列しか格納できないため、contents_script が利用しやすい形(連想配列など)に変換してやる必要がある

【覚書】クロスドメインな画像を任意のファイル名でダウンロードするためのユーザースクリプトの記述方法

Twitter の原寸画像は、例えば「https://pbs.twimg.com/media/CYhLLnfU0AAbHun.jpg:orig」のようなURLとなっており、これを Windows のブラウザでダウンロードしようとすると、「CYhLLnfU0AAbHun.jpg-orig」のようなおかしな拡張子に変換されて保存されてしまうことがある*1

いちいち拡張子を修正するのが面倒だったため、
furyu.hatenablog.com
では、リンク(A)要素の download 属性で、ちゃんとした拡張子のファイル名(例えば、「CYhLLnfU0AAbHun-orig.jpg」)を指定してやり、リネームの手間を省こうとしたのだが……。
以前、
furyu.hatenablog.com
でも書いたように、クロスドメイン(クロスサイト)に於いては、download属性で指定したファイル名は無視されてしまう。

■例

<a href="https://pbs.twimg.com/media/CYhLLnfU0AAbHun.jpg:orig" download="<任意のファイル名>.jpg" id="download_link_sample_a">ダウンロードリンク-A</a>

これを何とかするための苦肉の策が以下のもの。
例によって、もっとスマートなやり方があればコメント願う。

ユーザースクリプトのサンプル

Cross-Domainなサイトの画像を任意のファイル名でダウンロードするユーザースクリプトのサンプル

このユーザースクリプトを Greasemonkey や Tampermonkey でインストールしてから、このページをリロードすると、↑の例のところに「ダウンロードリンク-B」というリンクが新たに表示されるはず。
これをクリックすると、「<任意のファイル名>.jpg」なるファイル名で画像がダウンロードできる……はず。
何をやっているかは、下記のソースコードを参照。また、このテスト以外には意味はないスクリプトなので、確認が終わったら削除しておくことを推奨。

// ==UserScript==
// @name            sample_download_link
// @namespace       http://furyu.hatenablog.com/
// @author          furyu
// @version         0.1.0.0
// @include         http://furyu.hatenablog.com/*
// @include         https://pbs.twimg.com/media/*
// @description     Cross-Domainなサイトの画像を任意のファイル名でダウンロードするユーザースクリプトのサンプル
// ==/UserScript==

( function () {

'use strict';

var SCRIPT_NAME = 'SAMPLE_DOWNLOAD_LINK',
    IFRAME_NAME = SCRIPT_NAME + '_download_frame',
    filename = '<任意のファイル名>.jpg',
    current_url = window.location.href;

if ( window !== window.parent ) {
    if ( window.name != IFRAME_NAME ) {
        return;
    }
    
    // 親 window から呼び出された画像用 IFRAME の処理
    var image_url = current_url,
        link = document.createElement( 'a' );
    
    link.href = image_url;
    link.download = filename;
    
    document.documentElement.appendChild( link );
    link.click(); // ダウンロード開始
    
    return;
}

// 親 window の処理

var source_image_link = document.querySelector( 'div#download_sample_image_container a#download_link_sample_a' );

if ( ! source_image_link ) {
    return;
}

var download_link = document.createElement( 'a' ),
    image_url = source_image_link.href;

download_link.href = image_url;
download_link.download = filename;
download_link.appendChild( document.createTextNode( 'ダウンロードリンク-B' ) );

download_link.addEventListener( 'click', function ( event ) {

    // リンクがクリックされたら、name を指定した隠し IFRAME を呼び出す
    
    event.stopPropagation();
    event.preventDefault();
    
    var iframe = document.createElement( 'iframe' ),
        iframe_style = iframe.style;
    
    iframe_style.width = '0';
    iframe_style.height = '0';
    iframe_style.visibility = 'hidden';
    
    iframe.src = image_url;
    iframe.name = IFRAME_NAME;
    
    document.documentElement.appendChild( iframe );
    
    return false;
}, false );

source_image_link.parentNode.appendChild( download_link );

} )()

注意

Google Chrome の拡張機能で上記の手法を使用するためには、manifest.json にて、

"content_scripts" : [
    {
        "matches" : [ "http://furyu.hatenablog.com/*", "https://pbs.twimg.com/media/*" ],
        "js" : [ "js/main.js" ],
        "run_at" : "document_end",
        "all_frames" : true
    }
]

のように、「 "all_frames" : true 」を指定して、IFRAME 内に対してもスクリプトが動作するように設定しておく必要がある。

*1:Firefoxだと、「CYhLLnfU0AAbHun.jpg orig.jpg」のようになるので、一応 JPEG としては解釈される

Google Chromeで、スクリプトが拡張機能として動作しているかどうかを判別するには?

furyu.hatenablog.com
等では、単体でユーザースクリプト(Greasemonkey/Tampermonkey)としても動作するスクリプトを、Google Chrome 拡張機能用として転用している。

このとき、

  • ユーザースクリプトとして動作する際には、スクリプト内で定義したデフォルト値を使用
  • 拡張機能として動作する際には、ユーザーが設定可能なオプション値を優先的に使用

という条件を満たさねばならず、そうすると、スクリプト自身が「今、自分はどちらとして動作しているか」を判別する必要が出てくる。

これをお手軽に知る方法はないか、検索をかけてみたのだけれど、探し方が悪いのか、なかなかしっくりくる方法が見つからない。
そこで、苦肉の策として以下のような方法を用いているのだが、もし読者の方でより簡便な方法をご存知の方がおられれば、ご教授願いたい。

1. manifest.json にて、初期化用スクリプトとメインスクリプトを、別々のタイミングで起動

"content_scripts" : [
    {
        "matches" : [ "https://twitter.com/*" ],
        "js" : [ "js/init.js" ],
        "run_at" : "document_start"
    },
    {
        "matches" : [ "https://twitter.com/*" ],
        "js" : [ "js/main.js" ],
        "run_at" : "document_end"
    }
],
"background" : {
    "scripts" : [ "js/background.js" ]
}

例の中の init.js が初期用(document_start:DOM構築開始時に起動)で、main.js がユーザースクリプトを兼ねたメインスクリプト(document_end:DOM構築完了時に起動)。

なおバックグラウンドスクリプト(background.js)では、chrome.runtime.onMessage.addListener() で設定したリスナーにより、localStorageから取り出したユーザーオプション値を返す処理を定義しておく。

2. 初期化用スクリプト(init.js)内にて、ユーザーが設定したオプション値を取得する関数を定義

window.init_extension = function( callback ) {
    /* 任意の前処理 */
    chrome.runtime.sendMessage( {
        /* background.js で規定の処理を呼び出すためのパラメータ */
    }, function ( response ) {
        var user_options;
        /* response を解析して、ユーザーオプションを取得する処理 */
        callback( user_options );
    } );
};

background.js に対してchrome.runtime.sendMessage()にてメッセージを送信し、必要なユーザーオプションを取得した上で、コールバック関数に渡してやる関数を定義しておく。

3. メインスクリプト(main.js)内にて、2. の関数が定義されているかどうかで、処理を振り分け

if ( typeof window.init_extension == 'function' ) {
    // 拡張機能として動作
    window.init_extension( function( user_options ) {
        main( user_options ); // ユーザーオプションを設定
    } );
}
else {
    // ユーザースクリプトとして動作
    main(); // オプション未指定→デフォルト値を使用
}

2. で定義する関数が存在するかどうかにより、

  • 存在する場合:拡張機能として動作しているとみなして当該関数を呼び出し、ユーザーオプションを取得して使用
  • 存在しない場合:ユーザースクリプトとして動作しているとみなし、デフォルト値を使用