入力したURLからソースを取得するサービス
お題:
テキストエディタの「開く」でファイル名を指定するところに
そのまんま 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でビルドたものも追加しました。
- ここからzipファイルを落として来て展開
- (2009/2/13追記)Universal Binary版はこちら
- 展開したら中にあるURLService.serviceというファイルをホームディレクトリ>「ライブラリ」>「Services」というフォルダ(~/Library/Services/)にコピー。Servicesフォルダがなければ作ってその中に入れる。
- ログアウトして再ログイン
使い方
テキストエディタなど、文字入力できる箇所でメニューの「サービス」>「URLService」>「指定したURLからソースを取得...」を選択すると入力パネルが表示されるので、URLを入力して「Fetch」を押すと、URL先のソースがカーソル位置に挿入される。
一応「Command+Shift+u」というショートカットを入れてあるので、TextEdit.appなどならショートカットも使えるかもしれない。
今回参考にしたサイト
中身の解説: 概要
以下、実装内容を簡単に解説する。
まず、URLServiceProviderというクラスを作り、"URLService"という名前(NSPortName)でサービス登録するようにする。
メニューからサービスが選択されると、以下の順序で処理をおこなう
- URLServiceProviderクラスの-fetchURL:userData:error:が呼ばれる
- -fetchURL:userData:error:では、あらかじめnibファイルからロードしておいたNSPanelを-[NSApplication runModalForWindow:]で表示
- 入力があったらモーダルループを止め、入力された文字列(URL)を取得
- NSURLConnectionを使用してURL先の内容をNSDataで取得
- NSDataの内容を文字コードにあわせてNSStringに変換
- 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:]でコンテンツを取得
- -[NSData dataWithContentsOfURL:]などだとgzip圧縮されたレスポンスを上手く捌けないため。(参考:blog.8-p.info: Cocoa が Accept-Encoding: gzip を投げ逃げ。) これは酷い。
- -[NSURLResponse textEncodingName]で文字コードが取れた場合、それを使ってNSStringに変換する。そうでない場合は、UTF-8/Shift_JIS/EUC-JPの順に試してみて最初にデコード出来たものを使う。
- -[NSURLConnection sendSynchronousRequest:returningResponse:error:]でコンテンツを取得
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