Creating EmacsClient.app on macOS

Hi,

I am trying to create a EmacsClient.app module for macOS (similar to GitHub - xuchunyang/setup-org-protocol-on-mac: Setting Up org-protocol on Mac or GitHub - neil-smithline-elisp/EmacsClient.app: Use emacsclient to load URLs.).

Managed to put together a script to reproduce the process, but am struggling to figure out how to find the right buildInputs, namely osacompile and plutil.

osacompile -o EmacsClient.app -e "on open location this_URL
	do shell script \"<path-to-emacsclient> -n -a \\\"<path-to-Emacs.app>/Contents/MacOS/Emacs\\\" '\" & this_URL & \"'\"
	tell application \"Emacs\" to activate
end open location"
plutil -insert CFBundleURLTypes -xml "<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>org-protocol handler</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>org-protocol</string>
    </array>
  </dict>
</array>" EmacsClient.app/Contents/Info.plist

I think I can get the plutil dependency through xcbuild, but not sure if there is a better way? Or better way to build in general?

Thanks in advance.

I don’t think the Apple Script runtime is distributed independently of macOS. I’d therefore avoid using osacompile at build time. Instead, I’d just manually create an app bundle using the following Nix expression:

{ lib, runCommandNoCC, writeScript, writeText, emacs }:

let
  appName = "EmacsClient";

  launcher = writeScript "emacsclient" ''
    #!/usr/bin/osascript
    on open location this_URL
      do shell script "${emacs}/bin/emacsclient \"" & this_URL & "\""
      tell application "Emacs" to activate
    end open location
  '';

  infoPlist =
    writeText "Info.plist" (lib.generators.toPlist { } {
      CFBundleName = appName;
      CFBundleDisplayName = appName;
      CFBundleExecutable = "emacsclient";
      CFBundleIconFile = "Emacs";
      CFBundleIdentifier = "org.gnu.${appName}";
      CFBundleInfoDictionaryVersion = "6.0";
      CFBundlePackageType = "APPL";
      CFBundleVersion = emacs.version;
      CFBundleShortVersionString = emacs.version;
      CFBundleURLTypes = [
        {
          CFBundleURLName = "org-protocol handler";
          CFBundleURLSchemes = "org-protocol";
        }
      ];
    });

  icon = "${emacs}/Applications/Emacs.app/Contents/Resources/Emacs.icns";
in
runCommandNoCC "emacsclient-app" { } ''
  install -Dm644 ${infoPlist} $out/Applications/${appName}.app/Contents/Info.plist
  install -Dm755 ${launcher} $out/Applications/${appName}.app/Contents/MacOS/emacsclient
  install -Dm644 ${icon} $out/Applications/${appName}.app/Contents/Resources/Emacs.icns
''

I haven’t tested this, so some fixes might be necessary.

Thanks for your help!

Unfortunately, this does not seem to be working for me. I think the app gets recognized as the org-protocol handler, but fails to do anything.
I also tried replacing the osascript with a simple display alert, just to test things out, to no avail.

Any ideas what might be the issue?

I’ve looked further into this, and came to the conclusion that this is best handled outside of Nix itself, or if possible, through Home Manager activation scripts.

First off, I found out that osascript doesn’t seem to handle “open location” events as I initially assumed. This means it’s basically useless as a org-protocol handler.

That leaves us with the second choice, using osacompile. However, having tried this, I can’t recommend using it inside a Nix derivation. If you want to go ahead with this solution, the best choice would be to invoke osacompile outside of Nix. Or if you happen to use Home Manager, you can run osacompile inside an activation script, which runs from outside the Nix build process when switching generations. I believe nix-darwin has a similar mechanism too. Using activation scripts would allow you to embed Nix variables like pkgs.emacs or better, config.programs.emacs.finalPackage without being constrained too much by purity requirements.

The reason I recommend this solution is because osacompile is too deeply tied to the rest of the OS that it breaks down once you start sandboxing the build. Since AppleScript is closed source and only distributed with macOS, attempting to integrate it into Nix would be a herculean undertaking if not impossible.

So if you use Home Manager, do something like

{ config, lib, pkgs, ... }:

with libs;

let
  infoPlist = builtins.toJSON [
    {
      CFBundleURLName = "org-protocol handler";
      CFBundleURLSchemes = [ "org-protocol" ];
    }
  ];

  emacs = config.programs.emacs.finalPackage;

  launcher = pkgs.writeScript "emacsclient" ''
    on open location this_URL
      do shell script "${emacs}/bin/emacsclient -n -a ${emacs}/Applications/Emacs.app/Contents/MacOS/Emacs '" & this_URL & "'"
      tell application "Emacs" to activate
    end open location
  '';
in
{
  config.home.activation.createEmacsClientApp = hm.dag.entryAfter [ "writeBoundary" ] ''
    $VERBOSE_ECHO 'Creating EmacsClient.app'
    pushd '${config.home.homeDirectory}/Applications'
    $DRY_RUN_CMD rm -rf $VERBOSE_ARG EmacsClient.app || true
    $DRY_RUN_CMD /usr/bin/osacompile -o EmacsClient.app ${launcher}
    $DRY_RUN_CMD /usr/bin/plutil -insert CFBundleURLTypes -json ${lib.escapeShellArg infoPlist} EmacsClient.app/Contents/Info.plist
    popd
  '';
}

Or in plain bash, use the command you originally posted with <path-to-emacsclient> as ~/.nix-profile/bin/emacsclient and <path-to-Emacs.app> as ~/.nix-profile/Applications/Emacs.app.

An alternative choice I haven’t mentioned is to create a Objective-C or Swift application that can handle custom URL schemes. Although this would be easier to package than compiled AppleScript, it would be bit of a hassle to create.

Appreciate the suggestions.

I played around with a few of the options, and did some more digging around. The solution I ended up going with is to patch in the required build inputs (similar to macvim).

Don’t think it’s the best/right solution, but at least it works for now. I think when I get more time, maybe will try to write a AppleScriptObjC application.