Find command-line tools from a macOS app
By Flavio Copes
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:
- user-local installations
- Deno’s default directory
- Apple Silicon Homebrew
- Intel Homebrew
- system tools
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.
Related posts about swift: