Cocoaはやっぱり!
インターネットにアクセスしよう
Web Kit #7 : クリックでダウンロード

今回のテーマ

現在作成中のWebブラウザでは、リンクをクリックした時のページジャンプは出来ますが、sitやzipファイルなどのバイナリファイルをダウンロードすることは出来ません。これをダウンロードできるようにするのがメインテーマです。技術的には、WebPolicyDelagateというプロトコルの解説になりますので、このプロトコルにまつわる内容も扱います。

推奨環境 この解説は、以下の環境を前提に作成し、動作確認等を行っています。ご確認ください。

改版履歴

サンプルの概要

画面デザインやインスタンスの構成は前回と変わりません。

WebPolicyDelagateの概要

今回使用するのはWebPolicyDelegateというデリゲートです。リンクのクリックやサーバからのレスポンスの受信などが発生した時に、「 WebViewは何をすべきかというのを指示する 」のが、WebPolicyDelegateです。WebViewに対する指示は、「 WebViewに読み込んで表示する or ダウンロードする or 中止する 」の3つがあります。

WebPolicyDelegateが呼び出される時
(1) ナビゲーションアクション ( リンククリック/フォーム送信など ) が発生した時。
(2) 受信コンテンツのMIMEタイプが確定した時。
(3) 新しくウィンドウを開く必要が発生した時。

(1) 「 ナビゲーションアクション 」というのは、「 URL入力/リンクのクリック/リロード/戻る/進む/フォームの送信/フォームの再送信 」のようにフレームの読み込みが発生するトリガーになるアクションのことです。メインフレームが読み込まれた時にはサブフレームが連動して読み込まれることがあります。これも間接的なものですがナビゲーションアクションが発生します。直接的か間接的かは関係なくフレームの読み込みが発生したときは何かのナビゲーションアクションが起きた時です。

このように、WebPolicyDelegateはフレームの読み込みに連動して呼ばれます

(2) 受信コンテンツのMIMEタイプが決定された時にWebViewに対して指示をするということで、今回のテーマに大きく関係するのがこれです。「リンク先がHTMLならWebViewで表示」ですし、「sitファイルならダウンロード」という風に振り分けることが可能になります。

先程「WebPolicyDelegateはフレームの読み込みに連動」すると書きました。つまり、HTMLの中に貼付けてある画像に対してはどうするかという指示は出せません。例えば「HTMLに貼付けられているFlashは読み込まない」ということはWebPolicyDelegateではできません。リソース単位では呼ばれないからです。リソース単位での処理はWebResourceLoadDelegateで行うことになります。

(3) リンクの表示先が別のウィンドウの指定になっている場合や、JavaScriptでウィンドウを新たに開くような必要性が生じた時にもWebPolicyDelegateは呼ばれます。指示をするだけではなく、必要ならばウィンドウを開く処理も行う必要があります。

デリゲートを設定

今回も同様にデリゲートをWebViewに設定します。コードは以下のとおりです。

デリゲートの設定: MyWebViewDelegate.m
- (void) applicationDidFinishLaunching : (NSNotification *) aNotification { // デリゲートの設定 [ vWeb setFrameLoadDelegate : self ]; [ vWeb setResourceLoadDelegate : self ]; [ vWeb setPolicyDelegate : self ]; // (1) ポリシーデリゲートの設定 // ユーザインターフェイスの更新 [ self updateUserInterface ]; }

(1) setPolicyDelegate : メソッドで、自分自身をデリゲートに設定しています。

MIMEタイプ確定時の処理

コンテンツの読み込み直前に呼ばれるのは以下のメソッドです。右側にあるコメントを数字の順に読むと大体の意味が分かると思います。

MIMEタイプ確定時のデリゲートメソッド
- (void) webView : (WebView *) sender // 1. このWebViewの decidePolicyForMIMEType : (NSString *) type // 3. このMIMEタイプのデータを読込こんでいいですか? request : (NSURLRequest *) request // 4. 送信したリクエストはこれです frame : (WebFrame *) frame // 2. このフレームに decisionListener : (id<WebPolicyDecisionListener>) listener // 5. どうするかは、ここに知らせてね

サーバへリクエストを送信すると、しばらくしてレスポンスが返ってきます。その中にMIMEタイプも入っています(一般に)。そのMIMEタイプを表す文字列は第2パラメータに入っています。

「WebViewが表示できるものは表示させて、そうでないものはダウンロードする」という仕様は以下のコードで実現できます。

MIMEタイプ確定時のデリゲートメソッド : MyWebViewDelegate.m
- (void) webView : (WebView *) sender decidePolicyForMIMEType : (NSString *) type request : (NSURLRequest *) request frame : (WebFrame *) frame decisionListener : (id<WebPolicyDecisionListener>) listener { if ( [ sender canShowMIMEType : type ] ) [ listener use ]; // (1) 表示する// else [ listener download ]; // (2) ダウンロードする }

WebViewでこのMIMEタイプのデータが表示できるかどうかは canShowMIMEType : メソッドで知ることが出来ます。MIMEタイプを文字列で渡すと、BOOL値が返ってきます。表示可能な場合がYESです。

YESの場合は、第5パラメータのlistenerのuseメソッドを呼びます。これでWebViewで表示されます。NOの場合は、downloadを呼びます。listenerは、クラスがidですのでどのクラスのインスタンスが来るかは分かりませんが、WebPolicyDecisionListenerプロトコルを持っています。WebPolicyDecisionListenerは、WebPolicyDelegateの判断を受けるためのプロトコルで「 use、download、ignore 」の3つのメソッドがあります。最後のignoreは中止を指示したい時に使用します。

downloadメソッドを呼ぶと、ダウンロードが始まりますが、ここから先はNSURLDownloadの処理と同じことを行います。ですので、予め、WebViewのsetDownloadDelegate : メソッドでWebViewが作成するNSDownloadのデリゲートを指定しておく必要があります。そして、最低限ダウンロード先のファイルパスを決定する download : decideDestinationWithSuggestedFilename : メソッドを実装しておきます。これでダウンロードができるはずです。

オプションキーとリンククリックでダウンロード

Safariでは、オプションキーを押した状態でリンククリックするとリンク先のコンテンツをダウンロードします。今度は、これを実装してみましょう。リンクをクリックした直後にはナビゲーションアクションのデリゲートメソッドが呼ばれるということは最初に説明しました。具体的には以下のメソッドです。

ナビゲーションアクションのデリゲートメソッド
- (void) webView : (WebView *) sender // 1. このWebViewの decidePolicyForNavigationAction : (NSDictionary *) info // 3. こんなアクションがおきました request : (NSURLRequest *) request // 4. こんなリクエストを送信予定です frame : (WebFrame *) frame // 2. このフレームで decisionListener : (id <WebPolicyDecisionListener >) listener // 5. どうするかは、ここに知らせてね

第2パラメータの辞書には、このナビゲーションアクションについての詳細な情報が詰まっています。構造は以下のようになっています。

情報が多くてちょっとびっくりするかもしれませんが、難しいものはありませんので、1つ1つを見ていけば内容は理解できると思います。

まずは、ナビゲーションアクションの種類が何かというのを調べます。「リンククリックなのか、戻るボタンが押されたのか」という情報は図の右上にある「 アクションの種類 」に格納されています。ナビゲーションアクションは辞書 ( NSDictionary ) ですので、情報を取り出すには objectForKey : メソッドを使います。アクションの種類を取り出すためのキーは WebActionNavigationTypeKey になります。実際の種類はアクションの種類の右側の6種類です。

「その他」は、URL入力によるものや、何らかのナビゲーションアクションによってあるフレームが読み込まれたことにより発生するサブフレームの読み込みなどが該当します。(多分、METAタグやJavaScriptによるページジャンプも該当します(未確認))。

オプションキーが押されていたかどうかは、中央付近にある「 モディファイキー 」というので分かります。キーは WebActionModifierFlagsKey になります。この値はNSEventで使用されているものと同じで、シフトキーやコマンドキーなどがビットアサインされているものです。

今回は、この2つの情報しか使いませんが、下側にある要素情報についても少し説明します。これはクリックしたものについての情報です。リンクをクリックした場合であれば、リンクのタグに書かれているリンク先のURLなどの情報、画像だったら画像タグのALTの文字列などです。

ナビゲーションアクションのデリゲートメソッド : MyWebViewDelegate.m
- (void) webView : (WebView *) sender decidePolicyForNavigationAction : (NSDictionary *) info request : (NSURLRequest *) request frame : (WebFrame *) frame decisionListener : (id<WebPolicyDecisionListener>) listener { // (1) リンククリックの場合 if ( [ [ info objectForKey : WebActionNavigationTypeKey ] intValue ] == WebNavigationTypeLinkClicked ) { // (2) モディファイキーを取得 int iModifier = [ [ info objectForKey : WebActionModifierFlagsKey ] intValue ]; // (3) オプションキーチェック if ( iModifier & NSAlternateKeyMask ) [ listener download ]; else [ listener use ]; } else [ listener use ]; }

(1) 最初にナビゲーションアクションの種類を調べます。objectForKey : WebActionNavigationTypeKey で種類がNSNumberで取得できますので、intValueメソッドを使ってintを取得して、リンククリックを表すWebNavigationTypeLinkClickedという値と比較します。

(2) リンククリックだった場合は、モディファイキーの状態を調べます。同様に、objectForKey : WebActionModifierFlagsKey でモディファイキーの状態が分かります。

(3) NSAlternateKeyMaskでANDを取るとオプションキーのビットを取り出せますので、これでオプションキーが押されているかが分かります。そして、この場合のみdownloadメソッドを呼びます。

これで完成です。応用として、コマンドキーとリンククリックで別ウィンドウで開くなどもできるようになります。

実験した範囲では、リロードボタンを押してリロードした場合、ナビゲーションアクションはWebNavigationTypeReloadにはなっていなくて、WebNavigationTypeOtherになっています。バグ?

シングルウィンドウブラウザの実現

新しいウィンドウが必要になった時にも、ポリシーデリゲートが呼ばれます。マルチウィンドウのブラウザについては別途解説するとして、今回は、現在作成しているシングルウィンドウブラウザの場合の実装について解説します。

新しいウィンドウが必要になった際、WebViewは自分が今表示しているコンテンツを残すことを優先するので、ポリシーデリゲートがないと、「 ジャンプ先のコンテンツを表示する場所が無い 」と判断して処理をやめてしまいます。つまり、リンクジャンプができなくなります。今までのブラウザではリンクジャンプができなかったことがあったと思いますが、これが原因だったのです。

そのため、ポリシーデリゲートが現在のWebView自身に表示させるように指示を行います。

新ウィンドウが必要な場合のデリゲートメソッド
- (void) webView : (WebView *) sender // 1. このWebViewで decidePolicyForNewWindowAction : (NSDictionary *) info // 2. ウィンドウを開けというこんなアクションが発生 request : (NSURLRequest *) request // 3. リクエストの内容はこれで newFrameName : (NSString *) frameName // 4. 新しいフレームの名前はこれです decisionListener : (id<WebPolicyDecisionListener>) listener // 5. どうするかは、ここに知らせてね

コードは以下のようになります。

新ウィンドウが必要な場合のデリゲートメソッド : MyWebViewDelegate.m
- (void) webView : (WebView *) sender decidePolicyForNewWindowAction : (NSDictionary *) info request : (NSURLRequest *) request newFrameName : (NSString *) frameName decisionListener : (id<WebPolicyDecisionListener>) listener { // (1) このアクションは無視する [ listener ignore ]; // (2) 現在のWebViewにリクエストを送る [ [ sender mainFrame ] loadRequest : request ]; }

(1) 新しいウィンドウを開けというアクションは無視させます。listenerのignoreを呼ぶだけでよいです。

(2) 本来ならば新しく作成したウィンドウの中にあるWebViewに送るべきリクエスト情報を、現在のWebViewに送ります。WebViewのメインフレームの loadRequest : メソッドを呼ぶことで実現できます。

翻訳機能をつける

応用編としてこのWebブラウザに翻訳機能をつけてみましょう。翻訳機能といってもWeb上にある翻訳サービスを使わせてもらうだけです。ポリシーデリゲートの「 ナビゲーションアクションの通知の時にURLを差し替えてしまう 」ことで翻訳サービスへ転送するわけです。

ナビゲーションアクションのデリゲートメソッド : MyWebViewDelegate.m
- (void) webView : (WebView *) sender decidePolicyForNavigationAction : (NSDictionary *) info request : (NSURLRequest *) request frame : (WebFrame *) frame decisionListener : (id<WebPolicyDecisionListener>) listener { // 種類を取得 int iActionType = [ [ info objectForKey : WebActionNavigationTypeKey ] intValue ]; // モディファイキーを取得 int iModifier = [ [ info objectForKey : WebActionModifierFlagsKey ] intValue ]; // リンククリックの場合 if ( iActionType == WebNavigationTypeLinkClicked ) { // オプションキーが押されてた場合 if ( iModifier & NSAlternateKeyMask ) [ listener download ]; // ダウンロード else [ listener use ]; // 表示 } // (1) シフトキーが押されてた場合 if ( iModifier & NSShiftKeyMask ) { // (2) 現在のアクションをキャンセルする [ listener ignore ]; // (3) 翻訳サイト用のリクエストを作成する NSString *sNowUrl = [ [ request URL ] absoluteString ]; NSString *sNewUrl = [ NSString stringWithFormat : @"http://www.excite.co.jp/world/url/body?wb_url=%@&wb_lp=ENJA&wb_dis=3", sNowUrl ]; NSURLRequest *urlReq = [ NSURLRequest requestWithURL : [ NSURL URLWithString : sNewUrl ] ]; // (4) リクエストを送信 [ [ sender mainFrame ] loadRequest : urlReq ]; } else [ listener use ]; }

(1) シフトキーが押されているときは翻訳サイトサービスを利用するようにします。

(2) ignoreを呼ぶことで、まず現在のアクションをキャンセルします。

(3) 第3パラメータのrequestからURLを取り出して、ここではexciteの英文和訳サイトのURLを作成しています。本当はURLエンコードしないといけないのですが、手抜きをしています (これでも動きます)。そして、この文字列からURLからリクエストを作成します。

(4) 最後に、このリクエストを送信します。これだけで翻訳機能が付いてしまいます。