みずぴー日記

人間の再起動ボタンはハワイのビーチにある

🖼入力メソッドの設定画面

入力メソッドはシステムに組込まれるので、ユーザとやりとりするための画面を持たない。 しかし、入力メソッドの挙動を設定するための設定画面は必要である。

macOS標準の日本語入力の設定画面はキーボード設定内に組み込んでいる。

f:id:mzp:20171118155509p:plain

この画面を実現するための非公開APIについて説明する。

f:id:mzp:20171118160629p:plain

🐙ソースコード/English version

🙊入力メソッドの設定画面

入力メソッドを選択した際、システム環境設定が入力メソッドに含まれる Resources/Preferences.prefPane を設定画面としてロードする。

Preferences.prefPane は、設定画面を提供するためのプラグイン形式であるPreference paneである。 Preference paneの詳細はPreference Pane Programming Guideに詳しい。

設定画面はシステム環境設定の一部としてロードされるので、直接入力メソッドとは別プロセスで動作する。 ユーザーが変更した内容は UserDefaults などを経由して入力メソッドと共有する。

f:id:mzp:20171118160140p:plain

(Preference Pane Programming Guide: Figure 1 Plug-in architecture of preference panesより引用)

設定画面の追加

XcodeのPreference Paneテンプレートを用いて設定画面用のターゲットを追加する。設定画面のファイル名はPreferences.prefPaneに固定されているので、ターゲット名はPreferences にする。

f:id:mzp:20171118160450p:plain  入力メソッドをビルドする際に設定画面もビルドされるように、入力メソッドのTarget Dependenciesに設定画面を追加する。

f:id:mzp:20171118160549p:plain  ビルド時に設定画面が入力メソッド内に埋め込まれるようCopy Fise Phaseを追加する。コピー先はResourcesディレクトリとする。

f:id:mzp:20171118160616p:plain

これでキーボード設定にて入力メソッドを選択した際に、設定画面がロードされるようになる。

🕊Swiftの利用

Preference Paneテンプレートで生成されたファイルはObjective-Cで記述されているが、Swiftで書き直したい。Swiftで書き直すために、実行時にlibswiftCore.dylibなどのSwiftの標準ライブラリをロードできるようにする必要がある。

macOSの実行ファイルは、rpathと呼ばれるディレクトリから動的リンクライブラリを探索する。 通常はこのrpathに@executable_path/../Frameworks が含まれている。 @executable_path は実行ファイルのパスに解決されるため、アプリケーション内のFrameworksディレクトリに依存する動的リンクライブラリを同梱する。

f:id:mzp:20171118161647p:plain

しかし設定ファイルはシステム環境設定に読み込まれるため、@executable_path/Applications/System Preferences.app/Contents/MacOS/System Preferences に解決される。そのため、Swiftの標準ライブラリが意図した通りにロードされない。

ロードされるファイルのパスは @loader_path で表現できる。設定画面のBuild Settingsを変更し@loader_path/../../../../Frameworks をRunpath Search Pathsに加える。  f:id:mzp:20171118162221p:plain

../../../../ を用いるのは以下のように入力メソッドとFrameworksを共有するためである。  f:id:mzp:20171118162239p:plain

🍫CocoaPodsの利用

CocoaPodsはPreference paneに対するフレームワークの埋め込みに対応していない。そのため、podを利用したい場合は、以下のようにホストとなるアプリケーションに埋め込む必要がある。

platform :osx, '10.11'
       
abstract_target 'App' do
  pod '※ikemen' <- 使える

  target 'EmojiIM'
  target 'Preferences' do
    pod 'NorthLayout'  # <- これは使えない
  end
end

🔑設定の共有

ユーザーが変更した設定は UserDefaults に保存するのが一般的である。 しかし入力メソッドはサンドボックス内で実行されるため、他のアプリケーションとUserDefaultsを共有できない。サンドボックスの詳細についてはApp Sandbox Design Guideが詳しい。

f:id:mzp:20171118163015p:plain

(App Sandbox Design Guideより引用)

設定画面がロードされるシステム環境設定はサンドボックス外で実行されているため、設定を共有するためにはサンドボックスを越える必要がある。 サンドボックスのEntitlementsで例外設定をすることで、サンドボックス外にアクセスできるようにする。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.temporary-exception.shared-preference.read-only</key>
        <string>jp.mzp.inputmethod.EmojiIM</string>
</dict>
</plist>

これを用いて以下のようなサンドボックスの内外で設定を共有できる UserDefaults のラッパーを作成する。

class SettingStore {
    // Sandbox内のアプリケーションのbundle identifier
    private let kSuiteName: String = "jp.mzp.inputmethod.EmojiIM"

    // システム環境設定にロードされているかどうかを判定する
    private var inSandbox: Bool {
        return Bundle.main.bundleIdentifier == kSuiteName
    }

    private lazy var userDefaults: UserDefaults? = {
        if inSandbox {
            // サンドボックス内ならUserDefaults.standardを使う
            return UserDefaults.standard
        } else {
            // サンドボックス外なら、名前を明示的に指定する
            return UserDefaults(suiteName: suiteName)
        }
    }()

    // userDefaultsを用いて設定を書き込む
    func setKeyboardLayout(inputSourceID: String) {
        userDefaults?.set(inputSourceID, forKey: "keyboardLayout")
        userDefaults?.synchronize()
    }

    // userDefaultsを用いて設定を読み込む
    func keyboardLayout() -> String {
        return (userDefaults?.value(forKey: "keyboardLayout") as? String) ?? "com.apple.keylayout.US"
    }
}