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

「ログイン時に起動」を実装する

Objective-C OS X

2014/08/13 追記
AppleScriptを使った実装が MacAppStore の審査で Reject される件について書きました。

「ログイン時に起動」を実装する (2)


Mac アプリを使っていると、よく「ログイン時に起動 (Launch at Login)」という設定項目を目にします。
とても便利な機能なので、皆さん当たり前のように利用しているかと思いますが、実はけっこう実装が面倒です。

僕も自分の Mac アプリで実装したいと思ったのですが、情報が古かったり間違っていたり、日本語の情報が少なかったりで非常に苦労しました。
そこで、これから Mac アプリを作る人のために「ログイン時に起動」の実装方法をまとめておこうと思います。

「ログイン時に起動」3つの実装方法

「ログイン時に起動」を実装する方法は3つあります。(他にもあるかも, CFPreferencesとか)

  1. Launch Services を使う
  2. Helper Application を使う
  3. AppleScript を使う

どれを使えばいいのかは、アプリを Sandbox 化するかどうか (= Mac App Store で配布するかどうか) で変わります。
それぞれの場合に利用できる方法を表にまとめました。

Launch Services Helper Application AppleScript
Sandboxed × ※1
Non-Sandboxed

※1. ただし MacAppStore への申請では Reject される

参考: サンドボックス化とは?

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 を使った実装の章をご覧ください。

ログイン項目を追加する

  1. LSSharedFileListCreate でログイン項目のリストを取得
  2. 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
}

ログイン項目を削除する

  1. LSSharedFileListCreate でログイン項目のリストを取得
  2. LSSharedFileListCopySnapshot でリストのスナップショットを取得
  3. リストを走査して、対象の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, &currentItemURL, 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 を使って追加されたログイン項目は、システム環境設定の「ユーザとグループ」にある「ログイン項目」タブで確認することができます。
自分のアプリがログイン項目に正しく登録できているか確認しておきましょう。

f:id:questbeat:20140419031302p:plain

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 を選択します

f:id:questbeat:20140419031419p:plain

ここでは例として LaunchAtLoginHelper というターゲット名で作成したことにします。

f:id:questbeat:20140419031432p:plain

まず LaunchAtLoginHelper-Info.plist を開き、Application is agent (UIElement) を追加して YES に設定します。
(Application is background only でも構いません)

f:id:questbeat:20140419031445p:plain

次にヘルパーの MainMenu.xib を開き、ウィンドウを削除してしまいましょう。
同時に AppDelegate.h にある NSWindow の Outlet も削除してください。

f:id:questbeat:20140419031455p:plain

次にメインアプリの Build Phases を開き、Copy Files Build Phase を追加します。
DestinationWrapper、Subpath を Contents/Library/LoginItems に設定し、下の + ボタンから LaunchAtLoginHelper.app を追加します。

これはビルド時にヘルパーをメインのバンドル内にコピーするための設定です。
(Build Phaseの追加方法が分からない場合はこちらを参考にしてください)

f:id:questbeat:20140419031503p:plain

App Sandbox を有効にする

もし Mac App Store でリリースする予定のアプリであれば、ここでメイン・ヘルパー共に App Sandboxing を有効にしておきます。
(その予定がないならこの章は飛ばしても OK です)

f:id:questbeat:20140419031516p:plain

Code Signing も忘れずに。
こちらもメイン・ヘルパー共にやっておく必要があります。

f:id:questbeat:20140419031523p:plain

ヘルパーをログイン項目に追加する

ここで一度メインアプリに移ります。
ヘルパーをログイン項目に追加するコードを追加しましょう。

- (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 にはお好きな文字列を設定してください。

f:id:questbeat:20140419031543p:plain

これでメインアプリを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 というものが公開されています。

LaunchAtLoginHelper

使い方の説明はリンク先に載っているので省略します。
やはりこういうものは既に誰かが便利にしてくれているものですね。

3. AppleScript を使う

3つめの方法は AppleScript から System Events (システム環境設定) を操作する方法です。

スクリプトを追加する

以下のスクリプトをファイルに保存して、プロジェクトにコピーしてください。
%@ があるのは、後でこれを NSString のフォーマット文字列として使うためです。

AddLoginItem.scpt

tell application "System Events"
    make login item at end with properties {path:"%@", name:"%@"}
end tell

DeleteLoginItem.scpt

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 の実装と比べるとかなり簡単ですね。

Sandboxed 環境での利用

この方法はアプリから外部スクリプトを実行してシステムの設定を操作しているため、Sandbox 環境ではブロックされてしまいます。
しかし entitlements で Bundle Identifier を指定しておくと例外的に利用できるようになります。

f:id:questbeat:20140419031557p:plain

com.apple.security.temporary-exception.apple-events というキーに Bundle Identifier の配列を指定しています。
com.apple.systemevents は System Events (システム環境設定) の Bundle Identifier です。
例えばここに com.apple.safari を追加すると Safari に対しても例外的にメッセージを送信できるというわけです。

まとめ

このエントリでは「ログイン時に起動」の3つの実装方法を紹介しました。
ほとんどのアプリに当たり前のようについている機能が、実は異常な努力によって実現されていたことが分かったかと思います。

この文章だけだと Sandbox は最悪、という印象を持たれるかもしれませんが、長期的に見ればセキュリティを保つために Sandbox は必須であると言えます。
ただ、その分いくつかの機能の実装が面倒になってしまっているのも事実で、僕たち開発者は制限された範囲の中でうまくやっていく必要があります。

今回はその中でも「ログイン時に起動」の機能について、間違った実装を予防するために、そしてこれから Mac アプリを作る人が Google 検索に時間をかけなくてもいいようにこれを書いたのでした。

questbeat/LaunchAtLoginExample

参考

これを書いていく上で参考にしたページへのリンクをまとめておきます。

Launch Services を使った実装

Helper Application を使った実装

AppleScript を使った実装