macOSでGUIアプリの環境変数を設定する方法探求

# はじめに
macOSでGUIアプリの環境変数を設定したいこと、ありますよね？（私はないです）
ここではそんなニッチな需要に向けた設定方法を説明します。

この記事で説明すること

- macOSで起動する**すべての**GUIアプリの環境変数を設定する方法
  - FinderやSpotlightなどから起動するアプリが対象です

この記事で説明しないこと

- macOSで起動する**特定の**GUIアプリの環境変数を設定する方法
  - 環境変数設定した上でそのアプリをopenするシェルスクリプト作るとかすると良いと思います
  - それかアプリの`Info.plist`の`LSEnvironment`を無理やり書き換えるとか
- macOSのシェルの環境変数を設定する方法
  - すべてのユーザに適用したいなら「/etc/paths.d」、特定のユーザに適用したいなら「環境変数 .bash_profile」「環境変数 .zprofile」などで検索するとよいと思います

# 環境
検証は`macOS`の`10.14(Mojave)`と`10.15(Catalina)`で行っています。
また各説明箇所でも個別に注記をしていますが、ここで説明する方法は基本的に`OS X 10.10(Yosemite)`以降でしか動作しません。
また当然ながら、今後リリースされる新たなOSではここで書かれている方法が使えなくなる可能性があります。

# tl;dr
- GUIアプリ用の環境変数は`PATH`とそれ以外とで設定方法が異なる
  - `PATH`: `launchctl config user path <value>`
  - それ以外: `launchctl setenv <key> <value>`
     - ただし再起動でリセットされてしまうので、ログインの度にこのコマンドを実行する`launchd`サービスを作成すればOK
- macOSの「再ログイン時にウィンドウを再度開く」の機能で開いたアプリには上記方法で設定した環境変数は適用されない
  - => ただしDaemonから`gui`ドメインの作成を待機してごにょごにょやったりするとできるよ

# GUIアプリの環境変数設定方法

`PATH`とそれ以外とで設定方法が異なります。

## PATHを設定する方法
### 方法
`PATH`環境変数はターミナルから下記コマンドを実行すると設定できます。このサブコマンドは`OS X 10.10(Yosemite)`以降でのみ有効です。

```
sudo launchctl config user path <設定したいPATH情報>
```

反映には**OSの再起動**が必要となります。
またここで設定した`PATH`は**全ユーザ**に適用されます。

### 補足説明
- `launchctl`は`launchd`とやり取りするためのコマンドで、`config`はその名の通り各種の設定をするサブコマンドです
  - 引数の`user`の代わりに`system`とすると`system`ドメイン[^1]向けの設定になります
    - GUIアプリの場合は`user`ドメインを指定しておけばOKです。
  - `path`の他に`umask`の設定なども行えます
  - `launchctl config`の詳しい使用方法については`man launchctl`を参照してください
- ここで設定した`PATH`は実体としては`/var/db/com.apple.xpc.launchd/config/user.plist`に保存されているようです
- 検索すると`environment.plist`や`launchd.config`使った方法が出てきますがこれらは`OS X 10.9(Mavericks)`以前でしか動作しません

## PATH以外を設定する方法
### 方法
`PATH`以外の環境変数はターミナルから下記コマンドを実行すると設定できますが、このコマンドで設定した環境変数はOSを再起動するとリセットされてしまいます。

```bash
launchctl setenv <設定したい環境変数のキー> <設定したい環境変数の値>
```

そこで`launchd`サービスを作成してこのコマンドをユーザログインの度に実行させましょう。下記のような内容のファイルを作成・保存すればOKです[^2]。

```xml
<?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>Label</key>
    <!-- ↓Labelは適当に変えてください↓ -->
    <string>homu-konamilk.SetEnv</string>
    <key>ProgramArguments</key>
    <array>
      <string>/bin/launchctl</string>
      <string>setenv</string>
      <!-- ↓設定したい環境変数のキー↓ -->
      <string>HOGE</string>
      <!-- ↓設定したい環境変数の値↓ -->
      <string>fuga</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
  </dict>
</plist>
```

- 保存先:
  - **現ユーザ**のみに環境変数を適用する場合: `~/Library/LaunchAgents/`
  - **全ユーザ**に環境変数を適用する場合: `/Library/LaunchAgents/` または `/System/Library/LaunchAgents/`
     - ただし`/System/`ディレクトリ下はSIPの制限がかかってるので基本的に今回使う必要ないかと思います
- ファイル名:`<指定したLabel名>.plist`[^3]
  - 上の例の場合は`homu-konamilk.SetEnv.plist`

あとは**OSの再起動**か**ユーザの再ログイン**をすれば、次からログイン時に`launchctl setenv`が実行され、指定した環境変数の設定が適用されます。
もしすぐに反映させたい場合はターミナルから下記のコマンドを実行しましょう。（`/path/to/your.plist`の部分は適当に書き換えてください）

```bash
# 初回は下記でOK
launchctl bootstrap gui/`id -u` /path/to/your.plist

# 一度bootstrapした後やOS再起動した後でプロパティリストの変更を反映したい場合は、
# 下記のように一度`bootout`してから`bootstrap`し直す必要がある
launchctl bootout gui/`id -u` /path/to/your.plist
launchctl bootstrap gui/`id -u` /path/to/your.plist
```

### 補足説明
- `setenv`サブコマンドは`OS X 10.10(Yosemite)`以降、`LEGACY SUBCOMMANDS`とされています[^4]がとりあえず動作します
  - これに代わる`LEGACY`ではないサブコマンドは現状存在しないようです
- 例のプロパティリストの各設定項目はそれぞれ次のような意味を持っています
  - `Label`: ラベル名。`launchctl`の各種コマンドでサービスを操作するときに使います
     - とりあえずは他のものと被らなければ適当でOKですが、`reverse domain name notation`が使われることが多いようです（`com.klab.hogehoge`とかそういうやつ）
  - `ProgramArguments`: 実行するプログラムとその引数
  - `RunAtLoad`: サービスのロードと同時に実行を行うように指定します
     - `launchd`においてはサービスのロードとそのプロセスの起動はイコールではなく、基本的にすべてのサービスはon demandで動作し、指定のポートにアクセスがあるなど必要があった場合にのみプロセスが起動します
     - ただし`RunAtLoad`を指定することでサービスのロードと同時にプロセスを起動させることができます
- `bootstrap`/`bootout`は任意のサービスをロード/アンロードするサブコマンドです
  - `launchd`サービスは所定の場所にプロパティファイルを置いておきさえすればOS再起動、ユーザ再ログイン時にロードされるので、OS再起動、ユーザ再ログインをする場合はこのコマンドの実行は必要ありません
  - `bootstrap`/`bootout`の引数に指定している``gui/`id -u` ``はサービスをロードする先のドメイン[^1]です
     - 他に指定できるドメインとして`system`や`` user/`id -u` ``などがあります
     - GUIアプリに環境変数を適用する場合は`launchctl setenv`を`gui`ドメインで実行する必要があります
  - `bootstrap`/`bootout`の代わりに`load`/`unload`でもOKです
     - `load`/`unload`では問答無用で呼び出し元のドメインにサービスがロードされます
     - `load`/`unload`は`LEGACY SUBCOMMANDS`です。
- `launchctl setenv`をユーザログイン時に実行すればよいので、たとえば`Automator`アプリを作成して、「システム環境設定」->「ユーザとグループ」->「ログイン項目」にそのアプリを指定するなどの方法でもOKです

### 注意点
- macOSには「再ログイン時にウィンドウを再度開く」機能がありますが、**この機能で再ログイン後に再度開かれたアプリには、ここで設定した環境変数が適用されません**
  - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77929/f13b96dd-7dd7-a3fb-c343-1209c6b0aef4.png)
  - 上記ダイアログで「再ログイン時にウィンドウを再度開く」のチェックを外しておくか、ログイン後に改めてアプリを開き直してください
  - どうしてもこの機能を使いたい場合は後述の余談を参照してください

# ちょっと長い余談: 「再ログイン時にウィンドウを再度開く」で起動したアプリにも環境変数の設定を適用するには？

先に書いた通り、紹介した方法を使っても「再ログイン時にウィンドウを再度開く」で起動したアプリにはその環境変数の設定は適用されません。（`launchctl config`で設定した`PATH`環境変数については問題ありません。`PATH`以外の環境変数が問題）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77929/f13b96dd-7dd7-a3fb-c343-1209c6b0aef4.png)

これを実現する方法がないかいろいろ調べて試行錯誤してみました。（そこまでする必要あるかなと思いつつ……Advent Calendarに書くネタとして前半部分だけだと物足りない気がしたから……）

なお先に書いておきますが、最終的にちょっと無理やりな方法しか見つからなかったので、あくまで余談としてお楽しみください。基本的には先に書いた方法だけで十分だと思います。

## そもそもなぜ「再度開く」アプリには環境変数設定が適用されないのか？
### 「再度開く」アプリはいつ起動する？
「再度開く」アプリに環境変数の設定が適用されない理由について、検索してみるとフォーラムなどで「**『再度開く』アプリの起動が、`RunAtLoad`を指定したLaunchAgentの起動よりも早いから**」といったことが書かれているのが見つかります。

おそらくそう推測されるということだと思うのですがソースが見つからなかったので、とりあえずまずは「再度開く」アプリと`RunAtLoad`を指定したLaunchAgentの起動順序を検証してみました。

長時間sleepするだけのLaunchAgentを作成して、そのpidを「再度開く」アプリのpidと比べることで簡単に起動順序を確認してみます。

```xml:~/Library/LaunchAgents/homu-konamilk.Sleep.plist
<?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>Label</key>
    <string>homu-konamilk.Sleep</string>
    <key>ProgramArguments</key>
    <array>
      <string>/bin/bash</string>
      <string>-c</string>
      <string>sleep 9999999</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
  </dict>
</plist>
```

このファイルを`~/Library/LaunchAgents/`に保存してOSを再起動した上で、そのpidを「再度開く」アプリのpidと比べてみます。

<img width="705" alt="スクリーンショット 2019-12-13 12.05.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77929/b420152f-0e6e-3d1b-6a9c-f35693469c51.png">
<img width="700" alt="スクリーンショット 2019-12-13 12.07.08.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77929/c2eb29e8-c3ad-06e1-e652-9983e9d886f3.png">

上のスクリーンショットで「Sublime Text」「iTerm2」が「再度開く」で起動したアプリで、pidがそれぞれ`282`、`286`です。一方で今回実行した`sleep`はpidが`609`でした。

起動直後なのでpidの再利用などは特にないものとすると、とりあえず今回のケースでは「再度開く」アプリの方が`RunAtLoad`を指定したLaunchAgentのプログラムよりも早く起動したようです。なおプログラムの起動時刻を記録してみたところ、「再度開く」アプリのほうが5秒以上早く起動していました。

ということで、必ずかはわかりませんが、**「再度開く」アプリは`RunAtLoad`を指定したLaunchAgentよりも早く起動している**ようです。

※ ちなみにアクティビティモニタを見ると「Sublime Text」の前にもいくつかのサービスが起動していますが、`launchctl blame`でサービスの起動理由を調べてみた限りだと単に他のプロセスから使用要求があって起動したものと思われます[^5]

### どうすれば「再度開く」アプリに環境変数の設定を適用できるか？

`gui`ドメインの初期環境変数を設定できるような方法が何かあればそれで解決なんですが、いくら検索してもドキュメントを漁ってもそんな方法は見つかりません。`launchd`のソースコードを見れたりすると何かわかるかもしれませんが、現在は残念ながら非公開……[^6]。

なのでとりあえずは「再度開く」アプリの起動よりも前に`launchctl setenv`を実行する方法を探してみることにします。

ただし`launchctl setenv`の実行があまりにも早すぎると今度はまだ`gui`ドメインが作成されていなくて環境変数の設定ができなかったりするので、**`gui`ドメインの作成後、かつ「再度開く」アプリの起動前に`launchctl setenv`を実行する**ことを目指していきます。

## 駄目だった方法

以下、試してみて駄目だった方法です。

- 作成するLaunchAgentのplistファイル名の先頭を「!」にしてみる（ファイル名ソートで先頭の方に来るように）
  - => ワンチャンいけるのではと思ったが失敗。ファイル名をいじったLaunchAgentを複数作成して検証してみたがファイル名は特に起動順序には影響しない模様。
  - そもそももしなんらかの方法で「`RunAtLoad`のLaunchAgentの中で一番最初に起動させる」ことができたとしても、それをしたところで「再度開く」アプリより起動が遅いままの可能性は高い
- `launchctl setenv`を実行するAutomatorアプリを作成して「システム環境設定」->「ユーザとグループ」->「ログイン項目」に指定してみる
  - => ワンチャン行けるのではと思ったが失敗２。`RunAtLoad`のLaunchAgentよりは早いタイミングで起動したが、「再度開く」アプリよりは起動が遅かった

## 成功した方法
### 方針

AgentではなくDaemonを使います。

AgentとかDaemonとかいうのは`launchd`における用語で、`launchd`によって起動するサービスの種類です。Agentはユーザのコンテキスト、Daemonはシステムの（rootの）コンテキストで実行されるものを指します[^7]。

ある`launchd`サービスがAgentとDaemonのどちらとしてロードされるかは単にそのplistファイルの保存先ディレクトリによって決定されます。

- Agent用ディレクトリ:
  - `~/Library/LaunchAgents/`
  - `/Library/LaunchAgents/`
  - `/System/Library/LaunchAgents/`
- Daemon用ディレクトリ:
  - `/Library/LaunchDaemons/`
  - `/System/Library/LaunchDaemons/`

Daemon用ディレクトリに保存したplistに`RunAtLoad`の指定がされていた場合、そのサービスはログイン画面でユーザ選択してパスワード入力してEnterを押した後、Agentよりも前に起動します。

ただしこの`RunAtLoad`のDaemon実行時点ではまだ`gui`ドメインが作成されていません。そこでDaemonからスクリプトを開始し、その中で`gui`ドメインの作成完了を待機してから`launchctl setenv`を実行することにします。

`gui`ドメインが作成済みかどうかは、ドメインの情報を出力する`launchctl print`コマンドが成功するかどうかで判定して、このコマンドが成功するまで`sleep`しつつループすることにします。

またDaemonはシステムのコンテキストで実行されるので、ここで`launchctl setenv`を実行してもそのままだと環境変数は`system`ドメインに適用されてしまいます。（`launchctl setenv`では呼び出し元のコンテキストを元に環境変数の設定適用先のドメインが決まる）

`launchctl`にはこれを回避してコンテキストを切り替えるための`asuser`というサブコマンドが用意されているので、これを使用してコンテキストを切り替えた上で`setenv`を実行することにしましょう。

まとめると下記の通りです。

- 「再度開く」アプリよりも前に処理を実行するためにAgentではなくDaemonとして`launchd`サービスを作成する
- `launchd`サービスからスクリプトを開始して`gui`ドメインの作成完了を待機する
  - `gui`ドメインが作成済みかどうかは`launchctl print`コマンドの成否で判断する
- `gui`ドメインの作成を確認できたら`asuser`サブコマンドを介した上で`launchctl setenv`を実行する[^9]

### 方法

まずは`gui`ドメインの作成を待機してから`setenv`を実行するスクリプトを作成します。下記のファイルを作成して適当な場所に保存してください。ファイル中で書き換えが指示されている箇所は適当に書き換えてください。

```bash:setenvgui.sh
#!/bin/bash

# TARGET_UIDは適当に書き換えてください
TARGET_UID=501

# `launchctl print`を繰り返す間隔
# 手元の環境だと1秒で問題なさそうでしたが必要であれば調整してください
# ただし1秒未満の値を指定する場合はsleepコマンドだと対応できないので何か他の方法が必要（pythonやperlを起動してsleepするなり）
DOMAIN_CHECK_RETRY_INTERVAL=1
DOMAIN_CHECK_MAX_RETRY=50

# guiドメインが作成されるまで（=guiドメインのprintが成功するまで）待機
DOMAIN_CREATED=0
for i in `seq 1 $DOMAIN_CHECK_MAX_RETRY`
do
	echo "check if gui domain created: $i"
	if launchctl print "gui/$TARGET_UID"; then
		DOMAIN_CREATED=1
		break
	else
		# guiドメインがまだ作成されてなければ一定時間sleepしてからリトライ
		sleep $DOMAIN_CHECK_RETRY_INTERVAL
	fi
done

if [ "$DOMAIN_CREATED" = "1" ]; then
	# guiドメインが作成されてたらasuserした上でsetenv
	# 環境変数のキーと値は適当に書き換えてください
	launchctl asuser $TARGET_UID launchctl setenv HOGE fuga
fi
```

次にこのスクリプトを起動する`launchd`サービスのplistファイルを作成します。

```xml:/Library/LaunchDaemons/homu-konamilk.SetEnv2.plist
<?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>Label</key>
    <!-- ↓Labelは適当に変えてください↓ -->
    <string>homu-konamilk.SetEnv2</string>
    <key>ProgramArguments</key>
    <array>
      <string>/bin/bash</string>
      <!-- ↓setenvgui.shへのパス。適当に書き換えてください↓ -->
      <string>/path/to/setenvgui.sh</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
  </dict>
</plist>
```

- 保存先: `/Library/LaunchDaemons/` または `/System/Library/LaunchDaemons/`
  - ただし`/System/`ディレクトリ下はSIPの制限がかかってるので基本的に今回使う必要ないかと思います
  - 先に説明したAgentを使った方法とは保存先のディレクトリ名が異なるので注意してください
- ファイル名:`<指定したLabel名>.plist`[^3]
  - 上の例の場合は`homu-konamilk.SetEnv2.plist`

あとはOSを再起動すれば、「再ログイン時に再度開く」機能で起動したアプリにも環境変数が適用されていることが確認できます。

なおこの方法の問題点として、OSの再起動なしにユーザの再ログインをしたときには動作しません。この問題を解決するためにはさらにユーザのログインを監視して処理を実行するような実装が必要そうです。（私は力尽きたので誰か気力のある奇特な人がいたらやってください……）

## 余談の余談

余談の余談です。

上には書きませんでしたが、DaemonではなくLoginHook[^8]を使う方法でもうまくいきました。実行タイミング的には、

1. `RunAtLoad`を指定したDaemon
2. LoginHook
3. 「再度開く」アプリの起動
4. `RunAtLoad`を指定したAgent

の順になっているようです。

このLoginHookを使う方法だと、OSの再起動なしにユーザの再ログインをしたときでも正しく動作します。
ちなみにLoginHookはdeprecatedです。

また今回、システムコンテキストで実行するスクリプトから`gui`ドメインに環境変数を適用する方法として`asuser`コマンドを使いましたが、これは代わりに、Agentを`gui`ドメインに`bootstrap`することでも実現できます。（Daemonとは別に、`launchctl setenv`を実行するAgentを作って、`gui`ドメインの作成完了を待機してからそのAgentを`gui`ドメインに`bootstrap`する）
最初は`asuser`コマンドの存在を知らなくてこの方法で記事書いてたんですが、後から`asuser`コマンドの存在を知って記事書き換えました。

# おわりに
余談部分、いろいろ試して最終的にうまくいくまでなんだかんだ3,4日かかりました。
その過程でMach（≠Mac）まわりの概念やlaunchdのことについて少しだけ詳しくなれたのはよかったですが……。この余談部分が役に立つ人はいるのだろうか……。

あれこれ推測を重ねてやってるため、Macの内部動作とかをマジで研究してる方々から見るといろいろとツッコミどころがあるかと思いますのでご指摘いただけると嬉しいです。

# 参考にしたページ
- `launchd`サービスについての公式ドキュメント: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/Introduction.html#//apple_ref/doc/uid/10000172i-SW1-SW1
  - ただしメンテナンスされてない
- MachのBootstrap Contextについての公式ドキュメント: https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/contexts/contexts.html#//apple_ref/doc/uid/TP30000905-CH212-BEHJDFCA
  - これもメンテナンスされてない
- http://docs.macsysadmin.se/2014/pdf/Launchd_-_At_your_service.pdf
- https://babodee.wordpress.com/2016/04/09/launchctl-2-0-syntax/
- http://newosxbook.com/articles/jlaunchctl.html
- `man launchd`
- `man launchd.plist`
- `man launchctl`

[^1]: そもそもここでドメインってなんぞやという説明は`man launchctl`にあります: `A domain manages the execution policy for a collection of services. ...(略)... Domains advertise these endpoints in a shared namespace and may be thought of as synonymous with Mach bootstrap subsets.`　この最後で言ってる`Mach bootstrap subsets`については[こちら](https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/contexts/contexts.html)が詳しいです
[^2]: `launchd`サービスについては`man launchd`、`man launchd.plist`、`man launchctl`等を参照してください
[^3]: `man launchd.plist`より: `please note that it is the expected convention for launchd property list files to be named <Label>.plist.`
[^4]: `man launchctl`を参照
[^5]: 先述した通り`launchd`サービスは基本的にon demandで起動します: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/Lifecycle.html#//apple_ref/doc/uid/10000172i-SW3-BAJJBJEG
[^6]: `OS X 10.9(Mavericks)`までは公開されてました: https://opensource.apple.com/tarballs/launchd/
[^7]: `man launchd`より: `In the launchd lexicon, a daemon is, by definition, a system-wide service of which there is one instance for all clients. An agent is a service that runs on a per-user basis.`
[^8]: `LoginHook`についてはAppleの公式ドキュメント等を参照（古いですが）: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CustomLogin.html
[^9]: ちなみにこの試行錯誤の過程で、`gui`ドメイン作成前に`launchctl asuser <uid> launchctl setenv`を実行すると`gui`ドメインではなく`user`ドメインに環境変数が設定されることがわかりました。なんでこういう挙動になるのかわからないので誰かご存じの方いたら教えてほしい……。