◀ Back to NotesPublished on October 20, 2023

Integrating dynamic binaries in macOS apps

It's morning, 8:01am. A bit cold, as the fire has long stopped burning in the fireplace and the stone has lost its heat. Cora, the dog, is sleeping alone on the floor. Belly up as well, she seems to enjoy the cold.

My wife is still snoozing so I have to carefully choose my activities if I want to avoid her angry “why aren’t you sleeping” stare. I choose the only silent activity, I grab my phone and stare at the lockscreen.

Ten (10) notifications (in order of appearance):

  • ✉️ email: Question about Lunar. can’t wait to hear it.
  • ✉️ email: all settings disappear for all monitors when I toggle black out for the internal screen! damn.. my eyes are half open by now
  • ✉️ email: 6.3.0b2 app exits when displays are asleep and doesn't restart oh man, what did I break now?
  • 💬 discord: anyone else experiencing very weird graphical glitches? “anyone else”? I’m the only one responding there, but they probably noticed that by now
  • ✉️ email: student discount? I guess, why not?
  • ✉️ email: Vulnerability Detected, Remediation Required… consider offering a monetary reward as a gesture of gratitude, once the vulnerability is thoroughly validated and addressed what the heck?! Is this a scam? Yes it is.
  • ✉️ email: Lunar keeps reverting to trial. probably PiHole blocking paddle.com again.. this should be an FAQ
  • ✉️ email: Clop 2.2.0 optimizing endlessly oh. that’s not good.
  • ✉️ email: Clop issue - app prevented by OS from functioning? by OS? hey, at least they’re not blaming me
  • 💬 discord: v2.2.0 not downscaling images on Intel that’s.. odd

There goes my quiet morning. Who needs caffeine after reading such loving and uplifting messages. I grab my laptop and sneak out as quietly as I can into the kitchen.

# What is “Clop”?

You might have noticed some of those messages talk about a thing called Clop, which, from the messages, you can infer it’s something that makes people unhappy and irritable. Not far from the truth, but let me put it better:

👒Clop is an app born out of my frustration of having to send screenshots on a coffee shop internet connection, and constantly failing to do so because the upload kept stalling at ~85%.

Building such an app presented some unique challenges in borrowing code written by smarter people than me and especially on not reinventing the wheel. I thought some of you might find this useful.

I actually built the first functional version of Clop right there in that coffee shop. Being able to build an app with a minimal UI that can auto-optimize images directly in the clipboard in a single day is what still keeps me doing macOS development for a living.

So what’s it all about?

# Sending large images and videos

While replying to these emails and chats, I tend to do a lot of:

  1. Screenshot to clipboard
  2. Add some arrows and text
  3. Paste in email

But good luck pasting an 11MB HiDPI screenshot in chats nowadays. You’ll very soon hit some size limit, be it the 5MB Discord upload limit, or the 25MB email limit (or the 5GB Gmail free storage limit if you do that long enough).

Some explanations need more than an image, so I also do screen recordings, or even iPhone videos if I need to demonstrate some multi monitor functionality. The situation is even more dire there.

10 seconds of 4K video can take more than 100MB so there’s no chance of sending that without uploading it somewhere first. Not to mention the format which is often .mov and encoded as HEVC instead of the more compatible h.264-encoded .mp4

I eventually got tired of running ImageOptim and ffmpeg manually and uploading files to temporary public storage. I wanted something that would compress the images right in my clipboard, re-encode videos as soon as they’re finished and have them ready for sharing instantly.

But how would I do that in a macOS app? I’m not going to start implementing image and video encoders when there are perfectly fine command line solutions that do all the hard work already.

# Static and dynamic binaries

An important distinction we need to make is between static or fully self-contained binaries, and dynamic binaries which are linked to external file dependencies.

If I had static binaries for all the tools I needed, this would have been easy peasy, and I would have nothing to write about. I could, for example, just embed the ffmpeg binary into my app bundle, then execute it as a CLI from Swift.

But dynamic binaries will scream about missing this lib! and can’t find that lib?! when you move them out of their usual spot. And I needed quite a few of them:

Of all of the above, I only found ffmpeg as a static binary for both Intel and Apple Silicon on osxexperts.net. Which is great because it would have required the most effort to get working.

For all the rest, I had to choose between downloading their source code and all of their dependencies and finding a way to compile them statically, or…

# Isolating a dynamic binary

What if I could just copy the binary and all the libraries it depends on into a folder and run it. Would it work?

Well, first, how do I find the dependencies of a binary? Since macOS uses Mach-O binaries, the otool command seems to be what I need.

> otool -L $(which pngquant)
/opt/homebrew/bin/pngquant:
	/usr/lib/libz.1.dylib (compatibility version 1.0.0, current version 1.2.11)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.0.0)
	/opt/homebrew/opt/libpng/lib/libpng16.16.dylib (compatibility version 56.0.0, current version 56.0.0)
	/opt/homebrew/opt/little-cms2/lib/liblcms2.2.dylib (compatibility version 3.0.0, current version 3.14.0)

We can ignore the /usr/lib ones since they are always provided by the system. Let's move the rest into a folder and try to run it.

> mkdir -p /tmp/pngquant-dynamic
> mv $(which pngquant) /opt/homebrew/opt/{libpng,little-cms2}/lib/*.dylib /tmp/pngquant-dynamic
> /tmp/pngquant-dynamic/pngquant
dyld[90573]: Library not loaded: /opt/homebrew/opt/little-cms2/lib/liblcms2.2.dylib
  Referenced from: <E2E50465-97AB-3A6D-BC28-8AC0024B342C> /private/tmp/pngquant-dynamic/pngquant

Of course, you did not expect that to work from the first try, did you? The binary is still looking for the libraries in their original location, because it has those paths hardcoded inside it. That is how otool was able to extract them in the first place.

Fortunately we can change those paths with the install_name_tool command. Let's alter the binary so that it looks for the libraries in the same folder where it resides (aka @executable_path).

> install_name_tool -change /opt/homebrew/opt/libpng/lib/libpng16.16.dylib @executable_path/libpng16.16.dylib /tmp/pngquant-dynamic/pngquant
install_name_tool: warning: changes being made to the file will invalidate the code signature in: /tmp/pngquant-dynamic/pngquant
> install_name_tool -change /opt/homebrew/opt/little-cms2/lib/liblcms2.2.dylib @executable_path/liblcms2.2.dylib /tmp/pngquant-dynamic/pngquant
install_name_tool: warning: changes being made to the file will invalidate the code signature in: /tmp/pngquant-dynamic/pngquant
> otool -L /tmp/pngquant-dynamic/pngquant
/tmp/pngquant-dynamic/pngquant:
	/usr/lib/libz.1.dylib (compatibility version 1.0.0, current version 1.2.11)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.0.0)
	@executable_path/libpng16.16.dylib (compatibility version 56.0.0, current version 56.0.0)
	@executable_path/liblcms2.2.dylib (compatibility version 3.0.0, current version 3.14.0)
> /tmp/pngquant-dynamic/pngquant
pngquant, 2.18.0 (January 2023), by Kornel Lesinski, Greg Roelofs.
...

Not bad, the binary runs, and it does the right thing. I can now copy the binary and the libraries into my app bundle and run it from there. But there's one catch I did not pay enough attention to.

# Codesigning

Remember all those people talking about Clop v2.2.0 not working correctly anymore? Well, it turns out that the app was crashing on launch because I signed the binary but I forgot to sign the libraries as well.

kernel CODE SIGNING: process 23462[pngquant]: rejecting invalid page at address 0x1029f8000 from offset 0x0 in file "/Users/****/Library/Application Scripts/com.lowtechguys.Clop/com.lowtechguys.Clop/bin-arm64/liblcms2.2.dylib" (cs_mtime:1695487745.0 == mtime:1695487745.0) (signed:1 validated:1 tainted:1 nx:0 wpmapped:0 dirty:0 depth:0)

A macOS binary signed with the hardened runtime will refuse to load unsigned libraries. I had this security mechanism disabled on my machine for reverse engineering purposes, so I didn't catch this error in my tests.

Glad there are users eager to alert me on these issues. You know what's worse than someone telling you about a bug in your app? You finding out about a bug and thinking "How long was this there? Two years?? Does nobody use this thing?"

# Hardened runtime

So about this hardened runtime thing, it actually blocks a lot more stuff than just loading unsigned libraries.

It prevents code from accessing resources like the camera and microphone, disables access to location, and does quite a few more security things I pretend to understand in casual conversations.

One of those security things came to bite me one more time. There was this guy on Discord telling me:

v2.2.0 is not downscaling images on Intel

I was using vipsthumbnail and I had separate binaries for Apple Silicon and Intel, both extracted from a simple brew install. I did test the Intel version using Rosetta and everything seemed to work. But it works on my machine doesn't fly these days.

After about 3 failed updates, I got this user to run the exact command that was failing in his Terminal (not easy with a less technical person). This is the output that they sent me:

That's a pretty self explanatory error. It even contains the solution.

So it looks like Just-in-Time compiling code is forbidden by default in a hardened runtime, and libvips was running the ORC JIT on Intel CPUs (but not on Intel emulators like Rosetta, most likely because JIT is slower in an emulator).

To enable JIT, I needed to change the way I codesign the binaries. First I needed an entitlements file, which is just a Property List file (which is just an XML with a specific schema):

bin.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.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
	<true/>
</dict>
</plist>

Then I needed to codesign the binaries and libs by embedding those entitlements into the signature:

codesign -fs "Developer ID Application: Alin Panaitiu (RDDXV84A73)" \
    --options runtime \
    --entitlements bin.entitlements \
    --timestamp vipsthumbnail

And of course I had to do this for every other binary just to be sure, recreate the binary archive, create a binary update scheme using checksums because I’m not going to stall the app 3 seconds on every launch to decompress the binaries, rebuild the app, generate an update, upload the files, deal with people that just got an update yesterday and don’t need another 90MB download and an interruption today…

But at least, this seemed to fix everything.

# End result

What had to be done was done.

The emails and chats were replied to and solved, people were pleased, or at least less angry and the dynamic binary issues were finally dealt with.

The morning passed by fast with me as not even a spectator. I remember blinking a few times, finishing two espressos, hearing some laughter and vague questions, hearing myself answer them with a robot voice. I remember seeing Cora and my wife run through the house… but thinking about it now, maybe they were just walking and it was my time stretched memory creating that impression.

I woke up from the coding-induced trance at 8PM. I was not in the kitchen anymore, when did that happen? And I thought it was just the morning that passed by fast.

To be honest, it felt like a lost day. Many days feel like that nowadays. I'd rather have walked or run around the house with my family than sit in the same spot for 12 hours.

To make myself feel that it wasn't all lost time, I’ll leave here a fish script that can kind of isolate a whole binary with its dependencies, because there’s a bit more to do than what I have shown above. Dylibs can have other dylib dependencies of their own, so recursivity needs to be used.

# The script

Check out this gist: collect-bin-with-deps.fish

# Example usage for extracting `vipsthumbnail` and signing it and its dylibs
collect-bin-with-deps --sign "Developer ID Application: Your Name (TEAMID)" vipsthumbnail

# Some more notes

# Non-binary dependencies

Some CLI tools have other kinds of dependencies like fonts, external scripts and other resources.

For example exiftool has a lib folder packed with perl modules that it needs for specific parsers. I copied that whole folder near the binary and changed line 33 to let it know it should look in the executable path for it.

Another example is gs (GhostScript) which has fonts, color profiles, PostScript scripts etc. in a share folder.

I copied that into the executable path as well, and used the GS_LIB environment variable to let it know where to look for the files:

> export GS_DIR="/path/to/folder-containing-gs/"
> cp -R "$(brew --prefix ghostscript)/share" "$GS_DIR/"

> export GS_LIB="$GS_DIR/share/ghostscript/10.02.0/Resource/Init"
> $GS_DIR/gs
GPL Ghostscript 10.02.0 (2023-09-13)
Copyright (C) 2023 Artifex Software, Inc.  All rights reserved.
This software is supplied under the GNU AGPLv3 and comes with NO WARRANTY:
see the file COPYING for details.

# Compressing binaries

A macOS app bundle size can balloon pretty fast when including binaries and dylibs in this manner. To keep storage size and download bandwidth lower, I ended up archiving binaries with xz as it has the best compression ratio and is available out of the box on a macOS system.

It takes a while to compress, and spikes the CPU usage for a few seconds to decompress the binaries on the very first app start, but it's worth the trouble.

lrzip is a bit better on compression, but it is not available out of the box.