Find command-line tools from a macOS app

By

Fix tools that work in Terminal but disappear from Finder-launched macOS apps by locating binaries and building an explicit child-process PATH.

~~~

A command can work in Terminal and fail from the same user’s macOS app.

The executable did not disappear.

The app launched from Finder or the Dock did not inherit the interactive shell’s PATH.

Do not trust the inherited PATH

Read it, but add known user and system locations:

let inherited = environment["PATH", default: ""]
    .split(separator: ":")
    .map {
        URL(
            fileURLWithPath: String($0),
            isDirectory: true
        )
    }

let known = [
    home.appendingPathComponent(".local/bin"),
    home.appendingPathComponent(".deno/bin"),
    home.appendingPathComponent("bin"),
    URL(fileURLWithPath: "/opt/homebrew/bin"),
    URL(fileURLWithPath: "/usr/local/bin"),
    URL(fileURLWithPath: "/usr/bin")
]

This covers:

Remove duplicates

var seen = Set<String>()

let directories = (inherited + known).filter { url in
    seen.insert(url.standardizedFileURL.path).inserted
}

Preserve order.

The first matching executable wins.

Check executability

File existence is not enough:

func executable(
    named name: String,
    in directories: [URL]
) -> URL? {
    directories
        .map {
            $0.appendingPathComponent(name)
        }
        .first {
            FileManager.default.isExecutableFile(
                atPath: $0.path
            )
        }
}

Return absolute URLs for every required tool:

struct Toolchain {
    let ytDLP: URL
    let ffmpeg: URL
    let deno: URL
}

Locating the parent command is only half the fix

Suppose the app successfully launches yt-dlp.

yt-dlp can still fail later when it tries to find ffmpeg or Deno from the minimal child environment.

Build an explicit PATH for the process:

let required = [
    toolchain.ytDLP.deletingLastPathComponent().path,
    toolchain.ffmpeg.deletingLastPathComponent().path,
    toolchain.deno.deletingLastPathComponent().path,
    "/opt/homebrew/bin",
    "/usr/local/bin",
    "/usr/bin",
    "/bin",
    "/usr/sbin",
    "/sbin"
]

let existing = environment["PATH", default: ""]
    .split(separator: ":")
    .map(String.init)

var childEnvironment = environment
var seen = Set<String>()
childEnvironment["PATH"] = (required + existing)
    .filter { seen.insert($0).inserted }
    .joined(separator: ":")

Then assign it:

process.environment = childEnvironment

Pass absolute dependency paths when supported

yt-dlp accepts an explicit JavaScript runtime:

--js-runtimes deno:/Users/flavio/.deno/bin/deno

It also accepts an explicit ffmpeg location:

--ffmpeg-location /opt/homebrew/bin/ffmpeg

Use these options.

An explicit path is more reliable than hoping a nested process searches the same environment correctly.

Test a Finder-like environment

Do not run the test against real Homebrew directories.

Create a temporary directory tree and inject it:

let root = FileManager.default.temporaryDirectory
    .appendingPathComponent(UUID().uuidString)

let localBin = root.appendingPathComponent(".local/bin")
let denoBin = root.appendingPathComponent(".deno/bin")
let toolsBin = root.appendingPathComponent("test-tools")

let locator = ToolchainLocator(
    searchDirectories: [
        localBin,
        denoBin,
        toolsBin
    ]
)

Create fake executable files in:

<temporary root>/.local/bin/yt-dlp
<temporary root>/.deno/bin/deno
<temporary root>/test-tools/ffmpeg

Then assert the locator finds them.

Never create a fake executable in /opt/homebrew/bin during a test. That can overwrite a real installation.

The bug only appears outside Terminal. The test should reproduce that environment.

Tagged: Swift · All topics
~~~

Related posts about swift: