FinderからEmacsの
既存フレームでファイルを開く

  • Finderでファイルをダブルクリックしたとき、既存のEmacsフレームでそのファイルを開く方法を解説する記事です。
  • 標準の Emacs Client.app-c オプションで毎回新しいフレームを作ってしまい、-r オプションもmacOSではバグで機能しません。
  • AppleScript側で emacsclient -e を使ってGUIフレームの有無を問い合わせ、結果に応じて -n-c を使い分けることで解決しています。

関連記事

1. ファイルをすでに開いているEmacs内で開きたい

以前の記事で、AppleScriptで Emma.app を作り、emacs-plusをデーモンとして常駐させながら素早く起動できる環境を整えました。

FinderからEmacsの既存フレームで開く Finder ダブルクリック どのアプリで開く? 毎回新しいフレームが開く 3つ目のウィンドウ… Emacs Client.app の挙動

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)を使えばいいはずです。
仕様上は「既存フレームがあれば再利用し、なければ新規作成する」とされています。

原因:-c オプションと -r のバグ -c オプション(原因) emacsclient -c -a ” -n → 常に新規フレームを作成 -r オプション(バグあり) emacsclient -r macOS では既存フレームを無視 bug#52590(2021年〜未修正) なぜ AppleScript? Finder openFiles: ↓ AppleEvent シェルスクリプトでは 受け取れない AppleScript が必要

ところが、macOSでは -r が既存フレームを無視して新規フレームを作ってしまうバグが報告されています。
GNUのバグトラッカーにbug#52590として登録されており、2021年の報告以来、修正されていないようです。

1.3. 【補足】なぜシェルスクリプトではなくAppleScriptか

ちなみに、Finderの連携アプリをシェルスクリプトではなく、AppleScriptで作るのには理由があります。

FinderでファイルをダブルクリックするとmacOSは対象アプリに application:openFiles: という AppleEvent を送るからです。
シェルスクリプトだと、コマンドライン引数しか見えず、このapplication:openFiles:イベントを受け取れず、どのファイルを開くべきかわからないのです。

2. 解決策:フレームの有無を自前で判定する

そこで、emacsclient -r でフレームを制御するのではなく、AppleScript側でEmacsに問い合わせてから呼び分けることにしました。

解決策:フレームの有無を AppleScript で判定 ① emacsclient -e でフレーム数を確認 0? 0 → -c で新規作成 (フレームなし) 1以上 → -n で既存フレームへ (フレームあり) 判定に使う S式 (dolist (f (frame-list)) (when (display-graphic-p f) …))
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 に作成するシェルスクリプトを作りました。

アプリ作成スクリプトと Finder 連携 bash make-emacs-clientr.sh → /Applications/Emacs ClientR.app を自動生成 アイコン Emacs.app から .icns を自動取得 巻物アイコン → Emacs顔 コード署名 –sign – (アドホック) Gatekeeper の ブロックを回避 Finder 連携 ① ファイルを選択 ② 情報ウィンドウ ③ すべてを変更 Finder ダブルクリック → 既存 Emacs フレームで開く Emma.app(起動)+ Emacs ClientR.app(ファイル渡し)で役割分担

実行すると、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への関連付け

  1. Finderで対象ファイル(.md など)を選び、情報ウィンドウを開く
  2. 「このアプリケーションで開く」のプルダウンから Emacs ClientR.app を選ぶ
  3. 「すべてを変更…」をクリックして、同じ拡張子のファイル全体に適用する

これで、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 が生成するアプリの規約です。

次の PlistBuddyInfo.plistCFBundleIconFile キーを 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 の影響を逃がしています。