入力したURLからソースを取得するサービス

お題:

ブラウザを開かずに直接HTMLソースを見る方法 - 頭ん中

テキストエディタの「開く」でファイル名を指定するところに

そのまんま URL を入力したら

そこにあるソースを読み込むことができるのだった。

画像編集ソフトの「開く」で

ウェブ上にある画像のURLを指定しても同じく開けました。

これって Mac ではきないんだろうか。

Mac のファイルまわりで一番不便だなと思うこと - 頭ん中


テキストエディットを改造したりしてもいいかもしれないけど、それだと汎用性ないし、とりあえずサービスとして実装してみた。


テキストのみで画像対応はなし。NSImage経由でTIFFRepresentationをNSTIFFPboardTypeにセットすれば画像用のサービスも作れそうではある。


ちなみにサービス作るのはNEXTSTEP 3.3Jの時以来なので作り方おかしいところあるかも。


追記(2009/2/13): Intel版のMacを入手したのでUniversal Binary化しました。さらに10.4/10.5の差異などを追記。

インストール

Mac OS X 10.4.11(PPC)で作りました。他のバージョンで動くかは不明。(PPCのバイナリしか無いので、Intelの人は自分でビルドしてみて。)Mac mini(Intel)買ったので10.5でビルドたものも追加しました。

  1. ここからzipファイルを落として来て展開
  2. 展開したら中にあるURLService.serviceというファイルをホームディレクトリ>「ライブラリ」>「Services」というフォルダ(~/Library/Services/)にコピー。Servicesフォルダがなければ作ってその中に入れる。
  3. ログアウトして再ログイン

使い方

テキストエディタなど、文字入力できる箇所でメニューの「サービス」>「URLService」>「指定したURLからソースを取得...」を選択すると入力パネルが表示されるので、URLを入力して「Fetch」を押すと、URL先のソースがカーソル位置に挿入される。


一応「Command+Shift+u」というショートカットを入れてあるので、TextEdit.appなどならショートカットも使えるかもしれない。

中身の解説: 概要

以下、実装内容を簡単に解説する。


まず、URLServiceProviderというクラスを作り、"URLService"という名前(NSPortName)でサービス登録するようにする。


メニューからサービスが選択されると、以下の順序で処理をおこなう

  1. URLServiceProviderクラスの-fetchURL:userData:error:が呼ばれる
  2. -fetchURL:userData:error:では、あらかじめnibファイルからロードしておいたNSPanelを-[NSApplication runModalForWindow:]で表示
  3. 入力があったらモーダルループを止め、入力された文字列(URL)を取得
  4. NSURLConnectionを使用してURL先の内容をNSDataで取得
  5. NSDataの内容を文字コードにあわせてNSStringに変換
  6. NSStringPboardType型のペーストボードを生成し、内容(NSString)をセットして戻す。

main関数

main.m:

#import <Cocoa/Cocoa.h>
#import "URLServiceProvider.h"

int main(int argc, char *argv[])
{
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    URLServiceProvider *serviceProvider = [[[URLServiceProvider alloc] init] autorelease];
    NSRegisterServicesProvider(serviceProvider, @"URLService");

    int results =  NSApplicationMain(argc,  (const char **) argv);

    [pool release];
    return results;
}

NSApplicationMain()を呼ぶ前に、URLServiceProviderクラスのインスタンスを生成し、NSRegisterServicesProvider()で登録している。


NSApplicationMain()を呼んでしまうのはサービスの作り方として一般的ではないかもしれない。

Info.plistのサービス定義箇所

Info.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
...
	<key>NSServices</key>
	<array>
		<dict>
			<key>NSMenuItem</key>
			<dict>
				<key>default</key>
				<string>URLService/Fetch Source with URL...</string>
			</dict>
			<key>NSKeyEquivalent</key>
			<dict>
				<key>default</key>
				<string>U</string>
			</dict>
			<key>NSMessage</key>
			<string>fetchURL</string>
			<key>NSPortName</key>
			<string>URLService</string>
			<key>NSReturnTypes</key>
			<array>
				<string>NSStringPboardType</string>
			</array>
		</dict>
	</array>
	<key>NSUIElement</key>
	<string>1</string>
</dict>
</plist>
  • NSServicesの内容
    • NSMenuItemでメニュー文言を指定している。日本語のメニュー文言はServicesMenu.stringsにある。
    • NSKeyEquivalentでショートカットのキー割当を指定している。この場合、Command+Shift+uで使用出来る。
    • NSSendTypesは指定していない。選択部を入力で欲しい場合なら付けると良い。今回は不要。
    • NSReturnTypesはNSStringPboardTypeを使用している。文字列を入力(ペースと)出来る箇所ならこのサービスが呼び出せる
  • NSUIElementに"1"を指定しておく事で、サービス起動時にアプリアイコンやメニューや表示されない

ここで指定した内容を変更した場合、反映させるにはログアウト/ログインが必要になる。なんとかならないかな……。

サービス提供クラス(interface)

URLServiceProvider.h:

#import <Cocoa/Cocoa.h>

@interface URLServiceProvider : NSObject {
    IBOutlet NSPanel *inputPanel;
    IBOutlet NSTextField *urlField;
}
- (void)fetchURL:(NSPasteboard *)pboard userData:(NSString *)userData error:(NSString **)error;
- (IBAction)stopModal:(id)sender;
- (IBAction)abortModal:(id)sender;
@end
  • サービスを受けるメソッド-fetchURL:userData:error:を定義
  • 入力パネル(inputPanel)と、URLを入力するフィールド(urlField)をアウトレットとして定義しておく(init時にloadNibNamed:owner:で読み込む)
  • 「Fetch」ボタンと「Cancel」ボタンに対応するアクションを用意しておく

サービス提供クラス(implementation)

URLServiceProvider.m:

#import "URLServiceProvider.h"

@interface URLServiceProvider(Utilties)
- (NSString *) getContentsOfURL:(NSString *) urlString;
- (NSString *) stringWithData:(NSData *)data withTextEncodingName:(NSString *)textEncodingName;
@end

@implementation URLServiceProvider
- (id) init
{
    if ((self = [super init]) != nil) {
        [NSBundle loadNibNamed: @"InputPanel" owner:self];
    }
    return self;
}
- (void)dealloc
{
    [inputPanel release];
    [urlField release];
    [super dealloc];
}
/* サービス処理用メソッド */
- (void)fetchURL:(NSPasteboard *)pboard userData:(NSString *)userData
                    error:(NSString **)error
{
    NSArray *types = [pboard types];
    
    [[NSApplication sharedApplication] activateIgnoringOtherApps:true]; 
    int ret = [[NSApplication sharedApplication] runModalForWindow:inputPanel];
    [inputPanel orderOut:self];
    [[NSApplication sharedApplication] deactivate]; 
    
    if (ret != NSRunStoppedResponse) {
        return;
    }
    
    NSString *urlString = [urlField stringValue];
    
    NSString *returnValue = [self getContentsOfURL:urlString];

    types = [NSArray arrayWithObject:NSStringPboardType];
    [pboard declareTypes:types owner:nil];
    [pboard setString:returnValue forType:NSStringPboardType];
}

/* OK時アクション。モーダルループを停止 */
- (IBAction)stopModal:(id)sender
{
    [[NSApplication sharedApplication] stopModal];
}
/* キャンセル時アクション。モーダルループを停止(abort) */
- (IBAction)abortModal:(id)sender
{
    [[NSApplication sharedApplication] abortModal];
}
/* ウィンドウクローズ時の処理 */
- (void)windowWillClose:(NSNotification *)aNotification
{
     [[NSApplication sharedApplication] abortModal];
}

/* URLの内容を文字列で取得する */
- (NSString *) getContentsOfURL:(NSString *) urlString
{
    NSURL *url = [NSURL URLWithString:urlString];
    NSError *error = nil;
    NSURLResponse *urlResponse = nil;
    NSData *contentData = [NSURLConnection sendSynchronousRequest: [NSURLRequest requestWithURL: url]
                          returningResponse: &urlResponse
                                      error: &error];
    if (error != nil) {
        NSRunAlertPanel(@"Fetch Error",
                        [error localizedDescription],
                        @"OK", nil, nil);
        return nil;
    }
    
    NSString *contentString = nil;
    NSString *textEncodingName = [urlResponse textEncodingName];
    if (textEncodingName != nil) {
        contentString = [self stringWithData:contentData withTextEncodingName:textEncodingName];
    } else { // レスポンスから文字コードを拾えなければ総当たり?でデコードする
        NSStringEncoding encodings[] = {
            NSUTF8StringEncoding,
            NSShiftJISStringEncoding,
            NSJapaneseEUCStringEncoding,
            0
        };
        NSStringEncoding *ptr = encodings;
        do  {
            contentString = [[[NSString alloc] initWithData:contentData encoding:*ptr] autorelease];
        } while (!contentString && *(++ptr));
    }
    return contentString;
}
/*
  Responseから拾った文字コードを元にNSDataをNSStringに変換する。
  IANA名じゃなくてもなるべく拾う。
*/
- (NSString *) stringWithData:(NSData *)data withTextEncodingName:(NSString *)textEncodingName
{
    // 小文字で統一し、「-_」は取り除く
    NSString *encodingName = [textEncodingName lowercaseString];
    encodingName = [[encodingName componentsSeparatedByString:@"-"] componentsJoinedByString:@""];
    encodingName = [[encodingName componentsSeparatedByString:@"_"] componentsJoinedByString:@""];
    
    NSStringEncoding encoding;
    if ([encodingName isEqualToString:@"eucjp"]) {
        encoding = NSJapaneseEUCStringEncoding;
    } else if ([encodingName isEqualToString:@"shiftjis"] || [encodingName isEqualToString:@"sjis"]) {
        encoding = NSShiftJISStringEncoding;
    } else {
        encoding = NSUTF8StringEncoding;
    }
    return [[[NSString alloc] initWithData:data encoding:encoding] autorelease];
}
@end
  • 入力周りの処理
    • -[NSApplication runModalForWindow:]で入力パネルを表示して、入力待ちにする
    • 入力orキャンセルがあると、戻り値にNSRunStoppedResponseまたはNSRunAbortedResponseが帰ってくるのでそれで処理。
    • urlFieldに入力されたURLが入っているので-stringValueで取り出す
    • ウィンドウがクローズされるとモーダルループから抜けられなくなるので-windowWillClose:で拾ってabortする
      • 実はちょっと上手く動いていないけど……
  • コンテンツ取得周りの処理
    • -[NSURLConnection sendSynchronousRequest:returningResponse:error:]でコンテンツを取得
    • -[NSURLResponse textEncodingName]で文字コードが取れた場合、それを使ってNSStringに変換する。そうでない場合は、UTF-8/Shift_JIS/EUC-JPの順に試してみて最初にデコード出来たものを使う。

nibファイル

メインnibとは別にInputPanel.nibを用意した

  • File's OwnerにURLServiceProviderクラスを指定(あらかじめClassesの「Read Files...」でヘッダファイルを読み込んでおく)
  • File's Ownerのアウトレットをパネルおよびその中のTextFieldに接続
  • パネルのdelegateをFile's Ownerに接続(クローズ時処理のため)
  • TextFieldと「Fetch」ボタンのアクションをFile's Ownerの「stopModal:」に接続
  • 「Cancel」ボタンのアクションをFile's Ownerの「abortModal:」に接続
  • TextField、「Fetch」ボタン、「Cancel」ボタンをnextKeyViewで順に接続
    • これがなぜか上手く動かない。「Test Interface」実行時はタブで移動出来るのに……

プロジェクトファイル上のTips

プロジェクトの「情報を見る」の「スタイル」で「ビルドスタイル」を「Deployment」にし、以下の項目を設定する

インストール先を~/Library/Servicesにするには
「デプロイメント」で「インストール・ビルド・プロダクト」を「/」にし、「インストールディレクトリ」を「$(HOME)/Library/Services」にする
ラッパーの拡張子を「.app」でなく「.service」にするには
「パッケージング」で「ラッパーの拡張子」を「service」に設定

なお、インストール方法はTerminal.appから以下のコマンドで実行する。

xcodebuild -buildstyle Deployment install

既にインストール済みの場合失敗する場合がある。その場合は~/Library/Services/URLService.serviceを一旦削除する。

追記

ローカルディスクを探してたら、昔作った「コマンドを実行して出力結果を取得するサービス」ってのが出て来た。前回作ったのはNS3.3JじゃなくてMac OS X Server 1.0のころ(1999年7月!)だった。折角なので無修正であげよう。→SimplePipe.zip

今と違うところ:

  • ProjectBuilder上からmake installまで出来た
  • make_servivesってコマンドがあって、ログアウトしなくてもサービス登録内容が更新出来た
  • Info.plistの形式がXMLじゃなかった

make_servivesの後継コマンドは探せばあるor作れるかもしれないな。

(2009/2/13追記)プロジェクトファイル上のTips(10.4 -> 10.5変更点)

  • 「インストール・ビルド・プロダクトの位置」/「インストールディレクトリ」/「ラッパーの拡張子」の設定場所は、プロジェクトの「情報を見る」ではなく、ターゲットの「URLService」を選んで右クリックし「情報を見る」で出てきたパネルで設定する。
  • 「Development」/「Deployment」が「Debug」/「Release」に変わっている。(名前は好きに変えられるけど)
  • xcodebuildのオプションがbuildstyleからconfigrationに変わっている
xcodebuild -configuration Release install