Packages
mob_dev
0.6.21
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/mlx_downloader.ex
defmodule MobDev.MLXDownloader do
@moduledoc """
Downloads and caches pre-built Apple MLX + EMLX NIF static archives so
iOS Mob apps can ship `EMLX.Backend` as an Nx backend without
cross-compiling MLX themselves.
Mirrors the `MobDev.OtpDownloader` / `MobDev.PythonAppleSupport` pattern:
hashed URL + cached download at `~/.mob/cache/mlx-<version>-<target>/`,
validated against the expected layout. Reused across projects.
Used by `MobDev.NativeBuild` when the project's deps include `:emlx` or
`mob.exs` declares `mlx_enabled: true`. The build template sources
`MLX_DIR` from `dir/1` and links `libmlx.a` + `libemlx.a` from there.
## Scope (v1)
CPU-only MLX. Metal-on-iOS is gated behind a separate tarball variant
(`libmlx-<ver>-ios-device-metal.tar.gz`) that requires the iOS-Metal
CMakeLists patch and the Xcode Metal Toolchain installed at build
time. v1 ships CPU + Accelerate framework — already
~10-50x faster than `Nx.BinaryBackend` for typical Nx workloads.
## Local-build override
Set `MOB_MLX_LOCAL_TARBALL_DIR=/path/to/dir` to bypass the GitHub
download and use locally-built tarballs (named exactly as
`tarball_name/1` returns). Useful when iterating on the cross-compile
scripts in `mob_dev/scripts/release/mlx/`.
"""
# Pinned MLX upstream version. Bump in lock-step with EMLX (deps/emlx/mix.exs
# @mlx_version) and the cross-compile scripts in
# scripts/release/mlx/_lib.sh.
@mlx_version "0.25.1"
# Distinct release tag from the OTP tarballs so MLX can move independently.
# Repo-hosted on the same `GenericJam/mob` release surface OtpDownloader uses.
#
# TODO(2026-05-17): switch `@base_url` to `cocoa-xu/mlx-build/releases/...`
# once that repo publishes iOS assets. The iOS build workflow merged
# upstream in cocoa-xu/mlx-build#2 (GenericJam:ios-metal-builds, merged
# 2026-05-17), but the latest release (v0.31.2) predates the workflow
# and has no iOS assets yet. Next upstream release that triggers
# `ios.yml` should produce `mlx-arm64-apple-ios-*.tar.gz` +
# `mlx-arm64-apple-iossimulator-*.tar.gz` assets we can consume
# directly, retiring our own iOS cross-compile in
# scripts/release/mlx/. Until then we keep self-hosting.
@release_tag "mlx-#{@mlx_version}"
@base_url "https://github.com/GenericJam/mob/releases/download/#{@release_tag}"
@typedoc "Target slice this downloader supports."
@type target :: :ios_device | :ios_sim
# ── Public API ──────────────────────────────────────────────────────────────
@doc """
Ensure the MLX bundle for `target` is cached and extracted.
Returns `{:ok, path}` where `path` is the unpacked root containing
`lib/libmlx.a`, `lib/libemlx.a`, `include/mlx/...`, `VERSION`.
"""
@spec ensure(target()) :: {:ok, String.t()} | {:error, term()}
def ensure(target) when target in [:ios_device, :ios_sim] do
dest = dir(target)
if valid_dir?(dest) do
{:ok, dest}
else
# Stale or partial — clean and re-download. Same rationale as
# OtpDownloader: previous failed extraction or schema bump.
if File.dir?(dest), do: File.rm_rf!(dest)
download_and_extract(target, dest)
end
end
@doc "Convenience for iOS device."
@spec ensure_ios_device() :: {:ok, String.t()} | {:error, term()}
def ensure_ios_device, do: ensure(:ios_device)
@doc "Convenience for iOS simulator."
@spec ensure_ios_sim() :: {:ok, String.t()} | {:error, term()}
def ensure_ios_sim, do: ensure(:ios_sim)
@doc """
Cached MLX root directory for `target`. May not exist if `ensure/1`
hasn't been called.
"""
@spec dir(target()) :: String.t()
def dir(target) do
Path.join(cache_dir(), name(target))
end
@doc """
Returns true if the cache directory has the expected layout
(`lib/libmlx.a`, `lib/libemlx.a`, `include/mlx/`, `VERSION`).
Public for testing and so `NativeBuild` can cheaply probe for a partial
cache without parsing the VERSION file.
"""
@spec valid_dir?(String.t()) :: boolean()
def valid_dir?(dir) do
File.regular?(Path.join([dir, "lib", "libmlx.a"])) and
File.regular?(Path.join([dir, "lib", "libemlx.a"])) and
File.dir?(Path.join([dir, "include", "mlx"])) and
File.regular?(Path.join([dir, "VERSION"]))
end
@doc """
Path to `mlx.metallib` if this bundle ships Metal GPU kernels, or
`nil` if it's a CPU-only bundle.
The CPU build (`scripts/release/mlx/ios_device.sh`) doesn't produce
a metallib. The Metal build (`ios_device_metal.sh`) puts one at
`lib/mlx.metallib` alongside the static archives. Callers use this
to decide whether to copy the metallib into the iOS .app bundle so
EMLX's runtime `device: :gpu` path can find it (via MLX's
`load_colocated_library`).
"""
@spec metallib_path(String.t()) :: nil | String.t()
def metallib_path(dir) do
path = Path.join([dir, "lib", "mlx.metallib"])
if File.regular?(path), do: path, else: nil
end
@doc "Bundle name (no extension, no path) for `target` — `libmlx-0.25.1-ios-device` etc."
@spec name(target()) :: String.t()
def name(:ios_device), do: "libmlx-#{@mlx_version}-ios-device"
def name(:ios_sim), do: "libmlx-#{@mlx_version}-ios-sim"
@doc "Tarball file name for `target` (e.g. `libmlx-0.25.1-ios-device.tar.gz`)."
@spec tarball_name(target()) :: String.t()
def tarball_name(target), do: name(target) <> ".tar.gz"
@doc "Download URL for the `target` tarball."
@spec download_url(target()) :: String.t()
def download_url(target), do: "#{@base_url}/#{tarball_name(target)}"
@doc "Pinned MLX upstream version (e.g. `0.25.1`)."
@spec mlx_version() :: String.t()
def mlx_version, do: @mlx_version
@doc "GitHub release tag this downloader targets."
@spec release_tag() :: String.t()
def release_tag, do: @release_tag
# ── Private ─────────────────────────────────────────────────────────────────
defp cache_dir do
System.get_env("MOB_CACHE_DIR") ||
Path.join([System.get_env("HOME") || ".", ".mob", "cache"])
end
defp local_tarball_dir do
case System.get_env("MOB_MLX_LOCAL_TARBALL_DIR") do
nil -> nil
"" -> nil
v -> v
end
end
defp download_and_extract(target, dest_dir) do
File.mkdir_p!(Path.dirname(dest_dir))
with {:ok, tarball_path} <- fetch_tarball(target),
:ok <- extract(tarball_path, dest_dir),
:ok <- verify_layout(dest_dir) do
# Only clean up the tarball when we downloaded it ourselves.
# Local-override tarballs are user-managed; don't surprise them.
if String.starts_with?(tarball_path, System.tmp_dir!()) do
File.rm(tarball_path)
end
IO.puts(" Cached MLX (#{target}) at #{dest_dir}")
{:ok, dest_dir}
else
{:error, reason} ->
File.rm_rf(dest_dir)
{:error, reason}
end
end
defp fetch_tarball(target) do
case local_tarball_dir() do
nil -> download(target)
dir -> use_local(target, dir)
end
end
defp use_local(target, dir) do
path = Path.join(dir, tarball_name(target))
if File.regular?(path) do
IO.puts(" Using local MLX tarball: #{path}")
{:ok, path}
else
{:error,
"MOB_MLX_LOCAL_TARBALL_DIR is set to #{dir} but " <>
"#{tarball_name(target)} is not there."}
end
end
defp download(target) do
url = download_url(target)
tmp_file = Path.join(System.tmp_dir!(), tarball_name(target))
IO.puts(" Downloading MLX #{@mlx_version} (#{target})...")
IO.puts(" URL: #{url}")
case System.cmd("curl", ["-L", "--fail", "--progress-bar", "-o", tmp_file, url],
stderr_to_stdout: false
) do
{_, 0} -> {:ok, tmp_file}
{out, rc} -> {:error, "curl failed (exit #{rc}): #{String.trim(out)}"}
end
end
defp extract(tarball, dest_dir) do
parent = Path.dirname(dest_dir)
File.mkdir_p!(parent)
# The tarball's top-level dir matches `name/1`, so extracting into the
# parent of `dest_dir` lands the right layout. Verify happens afterward.
MobDev.Download.untar(tarball, parent)
end
defp verify_layout(dir) do
if valid_dir?(dir) do
:ok
else
{:error,
"MLX bundle at #{dir} is missing expected paths.\n" <>
" Expected lib/libmlx.a, lib/libemlx.a, include/mlx/, VERSION.\n" <>
" Tarball may have an unexpected layout."}
end
end
end