Mac アプリを使っていると、よく「ログイン時に起動 (Launch at Login)」という設定項目を目にします。
とても便利な機能なので、皆さん当たり前のように利用しているかと思いますが、実はけっこう実装が面倒です。
僕も自分の Mac アプリで実装したいと思ったのですが、情報が古かったり間違っていたり、日本語の情報が少なかったりで非常に苦労しました。
そこで、これから Mac アプリを作る人のために「ログイン時に起動」の実装方法をまとめておこうと思います。
「ログイン時に起動」3つの実装方法
「ログイン時に起動」を実装する方法は3つあります。(他にもあるかも, CFPreferencesとか)
- Launch Services を使う
- Helper Application を使う
- AppleScript を使う
どれを使えばいいのかは、アプリを Sandbox 化するかどうか (= Mac App Store で配布するかどうか) で変わります。
それぞれの場合に利用できる方法を表にまとめました。
Launch Services | Helper Application | AppleScript | |
---|---|---|---|
Sandboxed | × | ○ | ○ |
Non-Sandboxed | ○ | ○ | ○ |
参考: サンドボックス化とは?
OS Xにおける「サンドボックス」とは、保護された環境下でプログラムを動作させるためのセキュリティモデルのこと。
子どもの砂場のように外部と隔離された状況を作り出し、その範囲内でのみプログラムを動作させることで、プログラムの誤動作やマルウェア発生による被害が外部に及ばなくなる。
(新・OS X ハッキング! (37) これから必須のセキュリティモデル「サンドボックス」 | マイナビニュース より)
サンプルコード
さて、ここからはそれぞれの実装方法をサンプルコードとあわせて解説していきます。
ここで紹介していることをすべてまとめたサンプルプロジェクトを GitHub に置いたので、ダウンロードしてこの記事と同時に読んでいくと理解しやすくなるかもしれません。
questbeat/LaunchAtLoginExample
1. Launch Services を使う
Launch Services はアプリケーションやドキュメントを開くための API 群です。
ここではその中の Shared File Lists という機能を利用します。
Shared File Lists は OS X Leopard で追加された API で、その中の1つに Login Items (ログイン項目)を操作するものがあります。
The Shared File List API is new to Launch Services in OS X Leopard.
This API provides access to several kinds of system-global and per-user persistent lists of file system objects, such as recent documents and applications, favorites, and login items.
For details, see the new interface file LSSharedFileList.h.
(Launch Services Release Notes より)
Launch Services を使って「ログイン時に起動」を実装できるのは Non-Sandboxed なアプリだけなので、アプリを Mac App Store でリリースしたい場合にはこの方法は使えません。
詳しくは Helper Application を使った実装の章をご覧ください。
ログイン項目を追加する
LSSharedFileListCreate
でログイン項目のリストを取得LSSharedFileListInsertItemURL
でログイン項目を追加
- (void)addLoginItemForURL:(NSURL *)itemURL { // Get login items LSSharedFileListRef loginItems = LSSharedFileListCreate(NULL, kLSSharedFileListSessionLoginItems, NULL); // Add URL as a login item LSSharedFileListItemRef loginItem = LSSharedFileListInsertItemURL(loginItems, kLSSharedFileListItemLast, NULL, NULL, (__bridge CFURLRef)itemURL, NULL, NULL); CFRelease(loginItem); // Returned value has to be released }
ログイン項目を削除する
LSSharedFileListCreate
でログイン項目のリストを取得LSSharedFileListCopySnapshot
でリストのスナップショットを取得- リストを走査して、対象のURLがあれば
LSSharedFileListItemRemove
で項目を削除
- (BOOL)removeLoginItemForURL:(NSURL *)itemURL { // Get login items LSSharedFileListRef loginItems = LSSharedFileListCreate(NULL, kLSSharedFileListSessionLoginItems, NULL); // Get a snapshot of the list NSArray *snapshot = (__bridge_transfer NSArray *)LSSharedFileListCopySnapshot(loginItems, NULL); for (id loginItemObject in snapshot) { // Resolve item LSSharedFileListItemRef loginItem = (__bridge LSSharedFileListItemRef)loginItemObject; UInt32 resolutionFlags = kLSSharedFileListNoUserInteraction | kLSSharedFileListDoNotMountVolumes; CFURLRef currentItemURL = NULL; if (LSSharedFileListItemResolve(loginItem, resolutionFlags, ¤tItemURL, NULL) == noErr) { if (currentItemURL && CFEqual(currentItemURL, (__bridge CFTypeRef)itemURL)) { // Remove login item LSSharedFileListItemRemove(loginItems, loginItem); CFRelease(currentItemURL); return YES; } if (currentItemURL) { CFRelease(currentItemURL); } } } return NO; }
ログイン項目を監視する
ログイン項目を監視して、変更があった時に何か処理を行うこともできます。
下のコードは init
内でログイン項目の監視を始める例です。
- (instancetype)init { self = [super init]; if (self) { // Get login items _loginItems = LSSharedFileListCreate(NULL, haredFileListSessionLoginItems, NULL); // Start observing login items LSSharedFileListAddObserver(_loginItems, CFRunLoopGetMain(), (CFStringRef)NSDefaultRunLoopMode, sharedFileListDidChange, (__bridge void *)(self)); } return self; }
sharedFileListDidChange
はコールバック関数です。
Cの関数を自分で定義してあげる必要があります。
ここでは Key-Value Observing に対応するための処理を行っています。
void sharedFileListDidChange(LSSharedFileListRef fileList, void *context) { id obj = (__bridge id)context; [obj willChangeValueForKey:kStartAtLoginKey]; [obj didChangeValueForKey:kStartAtLoginKey]; }
ログイン項目を監視する場合は dealloc
などで監視を終了するのを忘れずに。
- (void)dealloc { // Stop observing LSSharedFileListRemoveObserver(_loginItems, CFRunLoopGetMain(), (CFStringRef)NSDefaultRunLoopMode, sharedFileListDidChange, (__bridge void *)(self)); CFRelease(_loginItems); }
ログイン項目を確認する
Launch Services を使って追加されたログイン項目は、システム環境設定の「ユーザとグループ」にある「ログイン項目」タブで確認することができます。
自分のアプリがログイン項目に正しく登録できているか確認しておきましょう。
LaunchAtLoginController
さて、ここまでいくつかサンプルコードを貼りながら説明してきましたが、一つひとつ丁寧にあなたのプロジェクトにコピペしていく必要はありません。
これらの機能をまとめた、ログイン項目の追加・削除が簡単にできるコードが公開されています。
Mozketo / LaunchAtLoginController
そしてこれを ARC に対応させたものがこちらです。
questbeat / LaunchAtLoginController
使い方は簡単、LaunchAtLoginController.h
を import して、
[[LaunchAtLoginController sharedController] setLaunchAtLoginEnabled:YES];
と書くだけでメインのアプリをログイン項目に追加できるようになっています。
ご活用ください。
2. Helper Application を使う
さて、Launch Services を使った実装ではコードだけでログイン項目を操作することができました。
しかし残念なことに、サンドボックス化されたアプリではこの方法は使えないようになっています。
ではどうするのかということですが、別途ヘルパーアプリを用意してそれを自動起動するように設定し、そのアプリからメインのアプリを起動するように実装してあげます。
はっきり言ってかなり面倒なので、気合入れて実装していきましょう。
(なぜメインはダメでヘルパーなら自動起動できるのかがよくわからない…)
Helper Application を使った場合、システム環境設定のログイン項目には表示されないので注意してください。
ヘルパーアプリを作成する
まずは新しくターゲットを作成します。
OS X > Application > Cocoa Application を選択します
ここでは例として LaunchAtLoginHelper というターゲット名で作成したことにします。
まず LaunchAtLoginHelper-Info.plist を開き、Application is agent (UIElement) を追加して YES に設定します。
(Application is background only でも構いません)
次にヘルパーの MainMenu.xib
を開き、ウィンドウを削除してしまいましょう。
同時に AppDelegate.h
にある NSWindow
の Outlet も削除してください。
次にメインアプリの Build Phases を開き、Copy Files Build Phase を追加します。
Destination を Wrapper
、Subpath を Contents/Library/LoginItems
に設定し、下の + ボタンから LaunchAtLoginHelper.app
を追加します。
これはビルド時にヘルパーをメインのバンドル内にコピーするための設定です。
(Build Phaseの追加方法が分からない場合はこちらを参考にしてください)
App Sandbox を有効にする
もし Mac App Store でリリースする予定のアプリであれば、ここでメイン・ヘルパー共に App Sandboxing を有効にしておきます。
(その予定がないならこの章は飛ばしても OK です)
Code Signing も忘れずに。
こちらもメイン・ヘルパー共にやっておく必要があります。
ヘルパーをログイン項目に追加する
ここで一度メインアプリに移ります。
ヘルパーをログイン項目に追加するコードを追加しましょう。
- (void)setLaunchAtLoginEnabled:(BOOL)enabled { if (!SMLoginItemSetEnabled((__bridge ringRef)kHelperAppBundleIdentifier, (Boolean)enabled)) { NSLog(@"Failed to enable login item."); } }
ポイントは SMLoginItemSetEnabled
です。
この関数は指定された Bundle Identifier のアプリをログイン項目として登録します。
ただしアプリはメインアプリのバンドル内の Contents/Library/LoginItems
に置く必要があります。
先ほどの Copy Files の設定はこのためというわけです。
また、調べてみると LSRegisterURL
でヘルパーのURLを登録するコードを載せている記事があったりしますが、Apple のエンジニアから「Sandboxed App では LSRegisterURL
は呼ばないほうがいい」という回答があったそうなので使わないようにしましょう。
参考: Login items in the sandbox
ヘルパーからメインのアプリを起動する
さて、これでヘルパーが自動起動するようになりました。
あとはヘルパーからメインのアプリを起動するだけです。
もしあなたのアプリが Sandbox 化されていなければ、ヘルパーの applicationDidFinishLaunching:
を次のように実装するだけです。
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification { // Check whether the main application is running and active BOOL running = NO; BOOL active = NO; NSArray *applications = [NSRunningApplication runningApplicationsWithBundleIdentifier:kMainAppBundleIdentifier]; if (applications.count > 0) { NSRunningApplication *application = [applications firstObject]; running = YES; active = [application isActive]; } if (!running && !active) { // Build path to main application NSString *bundlePath = [[NSBundle mainBundle] bundlePath]; NSMutableArray *pathComponents = [NSMutableArray arrayWithArray:[bundlePath pathComponents]]; [pathComponents removeLastObject]; [pathComponents removeLastObject]; [pathComponents removeLastObject]; [pathComponents addObject:@"MacOS"]; [pathComponents addObject:kMainAppName]; NSString *applicationPath = [NSString pathWithComponents:pathComponents]; // Launch main application [[NSWorkspace sharedWorkspace] launchApplication:applicationPath]; } // Quit [NSApp terminate:nil]; }
しかしさらに残念なことに、上のコードではメインのアプリを起動する部分が Sandbox によってブロックされてしまいます!
これを回避するために、iOS でお馴染みの URLスキーム を使ってメインのアプリを起動します。
メインアプリのターゲット設定 > Info を開いて、下の画像を参考に URL Types を設定してください。
Identifier にはメインアプリの Bundle Identifier を、URL Schemes にはお好きな文字列を設定してください。
これでメインアプリをURLスキームで起動できるようになりました。
最終的にヘルパーの applicationDidFinishLaunching:
の実装はこのようになります。
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification { // Check whether the main application is running and active BOOL running = NO; BOOL active = NO; NSArray *applications = [NSRunningApplication runningApplicationsWithBundleIdentifier:kMainAppBundleIdentifier]; if (applications.count > 0) { NSRunningApplication *application = [applications firstObject]; running = YES; active = [application isActive]; } if (!running && !active) { // Launch main application NSURL *applicationURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@://", kMainAppURLScheme]]; [[NSWorkspace sharedWorkspace] openURL:applicationURL]; } // Quit [NSApp terminate:nil]; }
Helper Application を使った「ログイン時に起動」の実装はこれで終わりです。
お疲れさまでした。
LaunchAtLoginHelper
さて、ここまで読んできた方はあまりの面倒さに絶望していることかと思います。
そんな人のために、Helper Application を簡単に実装できる LaunchAtLoginHelper というものが公開されています。
使い方の説明はリンク先に載っているので省略します。
やはりこういうものは既に誰かが便利にしてくれているものですね。
3. AppleScript を使う
3つめの方法は AppleScript を使って自分自身をログイン項目として登録する方法です。
Sandbox 環境なのにそんなことできるの?と思われるかもしれません。
もちろん普通にやるとブロックされてしまうのですが、entitlements に例外を指定しておくことで Apple Events による通信を利用できるようになります。
com.apple.security.temporary-exception.apple-events
というキーに Bundle Identifier の配列を指定しています。
com.apple.systemevents
は System Events の Bundle Identifier です。ログイン項目の追加に使います。
例えばここに com.apple.safari
を追加すると Safari に対しても例外的にメッセージを送信できるというわけです。
スクリプトを追加する
以下のスクリプトをファイルに保存して、プロジェクトにコピーしてください。
%@
があるのは、後でこれを NSString のフォーマット文字列として使うためです。
tell application "System Events" make login item at end with properties {path:"%@", name:"%@"} end tell
tell application "System Events" get the name of every login item if login item "%@" exists then delete login item "%@" end if end tell
スクリプトを実行する
続いてスクリプトを実行するコードを追加しましょう。
AppleScript の実行には NSAppleScript
クラスを利用します。
- (void)setLaunchAtLoginEnabled:(BOOL)enabled { // Load script NSString *fileName = enabled ? @"AddLoginItem" : @"DeleteLoginItem"; NSString *filePath = [[NSBundle mainBundle] pathForResource:fileName ofType:@"scpt"]; NSString *template = [NSString stringWithContentsOfFile:filePath encoding:NSUTF8StringEncoding error:NULL]; NSString *source; NSString *localizedName = [[NSRunningApplication currentApplication] localizedName]; if (enabled) { NSString *bundlePath = [[NSBundle mainBundle] bundlePath]; source = [NSString stringWithFormat:template, bundlePath, localizedName]; } else { source = [NSString stringWithFormat:template, localizedName, localizedName]; } // Run script NSAppleScript *script = [[NSAppleScript alloc] initWithSource:source]; NSDictionary *error = nil; [script executeAndReturnError:&error]; if (error) { NSLog(@"Error: %@", error[NSAppleScriptErrorBriefMessage]); } }
これだけで「ログイン時に起動」が実装できたことになります。
Helper Application の実装と比べるとかなり簡単ですね。
まとめ
このエントリでは「ログイン時に起動」の3つの実装方法を紹介しました。
ほとんどのアプリに当たり前のようについている機能が、実は異常な努力によって実現されていたことが分かったかと思います。
この文章だけだと Sandbox は最悪、という印象を持たれるかもしれませんが、長期的に見ればセキュリティを保つために Sandbox は必須であると言えます。
ただ、その分いくつかの機能の実装が面倒になってしまっているのも事実で、僕たち開発者は制限された範囲の中でうまくやっていく必要があります。
今回はその中でも「ログイン時に起動」の機能について、間違った実装を予防するために、そしてこれから Mac アプリを作る人が Google 検索に時間をかけなくてもいいようにこれを書いたのでした。
questbeat/LaunchAtLoginExample
参考
これを書いていく上で参考にしたページへのリンクをまとめておきます。
Launch Services を使った実装
- Mac Dev Center
- Adding a preference to launch app on login using the Shared File List API
- Launch Services を使ったログイン項目の追加・削除の解説
- How do you make your App open at login? - Stackoverflow
- LaunchAtLoginController
Helper Application を使った実装
- Start dockless apps at login with App Sandbox enabled
- The Launch At Login Sandbox Project
- Login items in the sandbox
- LSRegisterURL failed when trying to install a helper login item
- 情報プロパティリストキー(6)~Launch Servicesキー
- Sandbox に悩まされることばかり
- サンドボックス使用時のログイン項目のアプリケーション自動起動
- LaunchAtLoginHelper
AppleScript を使った実装
- sandbox化したアプリからログイン項目に自アプリを登録する
- IN THE APP STORE BUT OUTSIDE THE SANDBOX: LOGIN ITEMS
- AppleScript を用いた Launch at Login の実装
ただしこの記事にある AppleScript は少し修正しないと動作しないので注意, 具体的には以下のページを参考に書き換える - Manipulating OS X Login Items From Command Line
- AppleScript を用いた Launch at Login の実装