1. ファイルをすでに開いているEmacs内で開きたい
以前の記事で、AppleScriptで Emma.app を作り、emacs-plusをデーモンとして常駐させながら素早く起動できる環境を整えました。
Emma.appの役割は「Emacsを前面に出す」ことで、空のバッファを表示するかフォーカスを移すかを判断します。
今回はその続きで、Finderからファイルをダブルクリックしたとき、すでに開いているEmacsのフレーム(macOSでいうウィンドウのこと)の中でそのファイルを開く方法です。
1.1. Emacs Client.appは毎回フレームを作成する
emacs-plus@30以降、HomebrewのEmacsには Emacs Client.app が同梱されています。
Finderの「このアプリケーションで開く」でこれを選べば、一見よさそうに見えます。
ところが、ファイルを開くたびに新しいフレームが起動してしまいます。
原因はシンプルで、Emacs Client.app の内部実装にあります。Emacs Client.appは、AppleScriptで書かれた起動スクリプトをラップしたものです。
on open theDropped
repeat with oneDrop in theDropped
set dropPath to quoted form of POSIX path of oneDrop
do shell script pathEnv & emacsclientPath & " -c -a '' -n " & dropPath
end repeat
tell application "Emacs" to activate
end openCode language: AppleScript (applescript)
起動コマンドをみると、-c になっています。
これは、「新規GUIフレームを作成する」オプションで、既存フレームの有無にかかわらず、呼ぶたびに新しいウィンドウが生まれます。
1.2. -r オプションでも解決しなかった
それなら、-c の代わりに -r(--reuse-frame)を使えばいいはずです。
仕様上は「既存フレームがあれば再利用し、なければ新規作成する」とされています。
ところが、macOSでは -r が既存フレームを無視して新規フレームを作ってしまうバグが報告されています。
GNUのバグトラッカーにbug#52590として登録されており、2021年の報告以来、修正されていないようです。
1.3. 【補足】なぜシェルスクリプトではなくAppleScriptか
ちなみに、Finderの連携アプリをシェルスクリプトではなく、AppleScriptで作るのには理由があります。
FinderでファイルをダブルクリックするとmacOSは対象アプリに application:openFiles: という AppleEvent を送るからです。
シェルスクリプトだと、コマンドライン引数しか見えず、このapplication:openFiles:イベントを受け取れず、どのファイルを開くべきかわからないのです。
2. 解決策:フレームの有無を自前で判定する
そこで、emacsclient -r でフレームを制御するのではなく、AppleScript側でEmacsに問い合わせてから呼び分けることにしました。
GUIフレームが存在する → emacsclient -n(既存フレームで開く)
GUIフレームがない → emacsclient -c -n -a ''(新規フレームを作る)Code language: JavaScript (javascript)
フレームの有無は、emacsclient -e でEmacsにS式を評価させて確認します。
on hasGuiFrame()
try
set expr to quoted form of "(let ((n 0)) (dolist (f (frame-list) n) (when (display-graphic-p f) (setq n (1+ n)))))"
set out to do shell script pathEnv & quoted form of emacsclientPath & " -e " & expr
if out is "0" then
return false
else
return true
end if
on error
return false
end try
end hasGuiFrameCode language: AppleScript (applescript)
(frame-list) で全フレームを走査し、(display-graphic-p f) でGUIフレームだけを数えます。
デーモンだけが動いていてウィンドウが一つもないときは 0 が返るので、そのときだけ -c で新規フレームを作ります。
なお、フレームなしのとき -a '' を使っていますが、これは「サーバーがいなければデーモンを自動起動する」という指定です。Emacs.app を前面起動するわけではないので、GUIが出てこないこともあります。
デーモン運用が前提で、server-start がinit.elに書いてある環境なら、通常この分岐は起きません。
2.1. Emacs側の設定
デーモン運用が前提なので、init.el にこれが入っていることを確認してください。
(require 'server)
(unless (server-running-p)
(server-start))Code language: Lisp (lisp)
これがないと、Emacsが起動していても emacsclient が接続先を見つけられません。
3. アプリを作成するスクリプト
Emacs ClientR.app を /Applications に作成するシェルスクリプトを作りました。
実行すると、emacsclient のパスは command -v で自動取得し、起動用のAppleScriptファイルを作成します。
さらに、osacompileでapp形式にし、アイコンは既存の Emacs Client.app または Emacs.app から探してコピーします。
#!/bin/bash
set -euo pipefail
APP_NAME="Emacs ClientR.app"
APP_PATH="/Applications/${APP_NAME}"
EMACSCLIENT="$(command -v emacsclient || true)"
if [[ -z "${EMACSCLIENT}" ]]; then
echo "emacsclient が見つかりません。
" >&2
exit 1
fi
find_icon() {
local p
for p in \
"/Applications/Emacs Client.app/Contents/Resources/applet.icns" \
"/Applications/Emacs.app/Contents/Resources/Emacs.icns" \
"/Applications/Emacs.app/Contents/Resources/applet.icns"
do
[[ -f "$p" ]] && { printf '%s\n' "$p"; return 0; }
done
if command -v brew >/dev/null 2>&1; then
local brew_prefix
brew_prefix="$(brew --prefix 2>/dev/null || true)"
if [[ -n "${brew_prefix}" && -d "${brew_prefix}" ]]; then
p="$(find "${brew_prefix}" -type f \
\( -path '*/Emacs Client.app/Contents/Resources/applet.icns' \
-o -path '*/Emacs.app/Contents/Resources/Emacs.icns' \
-o -path '*/Emacs.app/Contents/Resources/applet.icns' \) \
2>/dev/null | head -n 1 || true)"
[[ -n "${p}" ]] && { printf '%s\n' "$p"; return 0; }
fi
fi
return 1
}
USER_ID_SAFE="$(id -un | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9.-' '-')"
BUNDLE_ID="local.${USER_ID_SAFE}.emacs-clientr"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
cat > "${tmpdir}/main.applescript" <<EOF
property emacsclientPath : "${EMACSCLIENT}"
property pathEnv : "PATH='/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin' "
on hasGuiFrame()
try
set expr to quoted form of "(let ((n 0)) (dolist (f (frame-list) n) (when (display-graphic-p f) (setq n (1+ n)))))"
set out to do shell script pathEnv & quoted form of emacsclientPath & " -e " & expr
if out is "0" then
return false
else
return true
end if
on error
return false
end try
end hasGuiFrame
on activateEmacs()
try
tell application "Emacs" to activate
end try
end activateEmacs
on open theDropped
set argList to ""
repeat with oneDrop in theDropped
set argList to argList & " " & quoted form of POSIX path of oneDrop
end repeat
if my hasGuiFrame() then
do shell script pathEnv & quoted form of emacsclientPath & " -n --" & argList
else
do shell script pathEnv & quoted form of emacsclientPath & " -c -n -a '' --" & argList
end if
my activateEmacs()
end open
on run
if my hasGuiFrame() then
my activateEmacs()
else
do shell script pathEnv & quoted form of emacsclientPath & " -c -n -a ''"
my activateEmacs()
end if
end run
on open location this_URL
if my hasGuiFrame() then
do shell script pathEnv & quoted form of emacsclientPath & " -n -- " & quoted form of this_URL
else
do shell script pathEnv & quoted form of emacsclientPath & " -c -n -a '' -- " & quoted form of this_URL
end if
my activateEmacs()
end open location
EOF
rm -rf "${APP_PATH}"
osacompile -o "${APP_PATH}" "${tmpdir}/main.applescript"
PLIST="${APP_PATH}/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${BUNDLE_ID}" "${PLIST}" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string ${BUNDLE_ID}" "${PLIST}"
/usr/libexec/PlistBuddy -c "Set :CFBundleName Emacs ClientR" "${PLIST}" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName Emacs ClientR" "${PLIST}" 2>/dev/null || true
ICON_SRC="$(find_icon || true)"
if [[ -n "${ICON_SRC}" ]]; then
cp "${ICON_SRC}" "${APP_PATH}/Contents/Resources/applet.icns"
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile applet" "${PLIST}" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string applet" "${PLIST}"
fi
if command -v codesign >/dev/null 2>&1; then
codesign --force --deep --sign - "${APP_PATH}" >/dev/null 2>&1 || true
fi
LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
if [[ -x "${LSREGISTER}" ]]; then
"${LSREGISTER}" -f "${APP_PATH}" >/dev/null 2>&1 || true
fi
touch "${APP_PATH}"
echo "作成完了: ${APP_PATH}"
echo "emacsclient: ${EMACSCLIENT}"
echo "bundle id: ${BUNDLE_ID}"
echo "icon: ${ICON_SRC:-'(未設定)'}"Code language: Bash (bash)
スクリプトを実行すると、アプリケーションフォルダにEmacs ClientR.appが追加されます。
bash make-emacs-clientr.shCode language: Bash (bash)
3.1. Finderへの関連付け
- Finderで対象ファイル(
.mdなど)を選び、情報ウィンドウを開く - 「このアプリケーションで開く」のプルダウンから
Emacs ClientR.appを選ぶ - 「すべてを変更…」をクリックして、同じ拡張子のファイル全体に適用する
これで、Finderからダブルクリックしたファイルが、すでに開いているEmacsのフレームの中にバッファとして現れるようになります。
Emma.appでEmacsを前面に出し、Emacs ClientR.appでFinderからファイルを渡す。
それぞれの役割が分かれた構成です。
3.2. 【補足】アイコンの設定
osacompile でAppleScriptをアプリ化すると、デフォルトでは汎用のAppleScriptアイコン(巻物のようなもの)が使われます。
そこで既存の .icns ファイルを探してコピーします。
ICON_SRC="$(find_icon || true)"
if [[ -n "${ICON_SRC}" ]]; then
cp "${ICON_SRC}" "${APP_PATH}/Contents/Resources/applet.icns"
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile applet" "${PLIST}" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string applet" "${PLIST}"
fiCode language: Bash (bash)
find_icon 関数は、/Applications/Emacs Client.app、/Applications/Emacs.app、Homebrewのprefixの順に .icns ファイルを探します。
見つかったファイルをアプリの Contents/Resources/applet.icns に上書きコピーします。applet.icns というファイル名は osacompile が生成するアプリの規約です。
次の PlistBuddy は Info.plist の CFBundleIconFile キーを applet に設定します。
これはmacOSに「アイコンファイルは applet.icns を使え」と伝えるためのものです。
キーがすでにあれば Set で更新し、なければ Add で追加します。
3.3. コード署名
if command -v codesign >/dev/null 2>&1; then
codesign --force --deep --sign - "${APP_PATH}" >/dev/null 2>&1 || true
fiCode language: Bash (bash)
macOSはGatekeeperという仕組みでアプリの署名を確認します。
署名がないアプリは「開発元が未確認」として起動をブロックされることがあります。
--sign - の - は「アドホック署名」を意味します。
Apple Developer IDによる正式な署名ではなく、「このアプリはこのビルドのもの」というハッシュだけを付ける最小限の署名です。
App Storeには出せませんが、自分のMacで使うぶんには問題ありません。--force は既存の署名を上書きし、--deep はアプリバンドル内のすべてのファイルにまとめて署名します。
末尾の || true は、署名に失敗してもスクリプト全体が止まらないようにするためです。set -euo pipefail の影響を逃がしています。