Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Changes in 1.5

- Support for iOS hibernation and wakeup
- Document mise alongside asdf for `.tool-versions`; add `mix desktop.check_toolchain` to verify the active Erlang/OTP and Elixir match the project file without depending on a specific version manager

## Changes in 1.4

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ Checkout [the example app](https://github.com/elixir-desktop/desktop-example-app

## Getting Started

Check out the [Getting your Environment Ready Guide](./guides/getting_started.md) and [Your first Desktop App](./guides/your_first_desktop_app.md)
Check out the [Getting your Environment Ready Guide](./guides/getting_started.md) and [Your first Desktop App](./guides/your_first_desktop_app.md).

Erlang/Elixir versions are pinned in `.tool-versions` for [mise](https://mise.jdx.dev/) or [asdf](https://asdf-vm.com/). After activating those runtimes, run `mix desktop.check_toolchain` to verify they match the file.

## Status / Roadmap

Expand Down
31 changes: 28 additions & 3 deletions guides/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,37 @@ echo ". ~/maint-24/activate" >> ~/.bashrc

Best to use Erlang solutions packages: https://www.erlang-solutions.com/downloads/

**Or use ASDF**
**Or use a version manager (mise or asdf)**

This repository ships a `.tool-versions` file so you can pin Erlang and Elixir with
[mise](https://mise.jdx.dev/) or [asdf](https://asdf-vm.com/). Either tool reads the same file format.

Install the pinned versions, then run Mix from that environment:

```bash
# mise (https://mise.jdx.dev/)
mise install
mise exec -- mix deps.get

# asdf (https://asdf-vm.com/)
asdf plugin add erlang || true
asdf plugin add elixir || true
asdf install
asdf exec mix deps.get
```
asdf plugin update --all
asdf install erlang 24.0.1

To confirm your shell is using the same OTP major and Elixir release as `.tool-versions`
(before CI or Android scripts run `mix`), use:

```bash
mix desktop.check_toolchain
```

This task only compares the **active** `elixir` / `erlang` in your PATH to `.tool-versions`;
it does not call mise or asdf, so it stays compatible with any way you activate those runtimes.

For shell wrappers (for example an Android `run_mix` script), avoid hard-coding asdf-specific paths: run `mix` from an environment where Erlang/Elixir are already correct, or prefix the command with `mise exec --` or `asdf exec` so the same `.tool-versions` file drives every tool.

**Install NIF Dependencies:**

For compiling NIFs you will need a c compiler and dependencies, such as from:
Expand Down
48 changes: 48 additions & 0 deletions lib/desktop/tool_versions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
defmodule Desktop.ToolVersions do
@moduledoc false

@doc """
Parses a `.tool-versions` file body into a map of tool name => declared version string.

Names are normalized to lowercase. Lines starting with `#` and blank lines are ignored.
For lines with more than two whitespace-separated fields (e.g. `erlang 26.2.5.5 system`),
the version is the second field only.
"""
@spec parse(String.t()) :: %{optional(String.t()) => String.t()}
def parse(content) when is_binary(content) do
content
|> String.split("\n")
|> Enum.flat_map(&parse_line/1)
|> Map.new()
end

defp parse_line(line) do
line = String.trim(line)

cond do
line == "" or String.starts_with?(line, "#") ->
[]

true ->
case String.split(line, ~r/\s+/, parts: 3) do
[tool, version] -> [{String.downcase(tool), version}]
[tool, version, _rest] -> [{String.downcase(tool), version}]
_ -> []
end
end
end

@doc """
Reads `.tool-versions` from `directory` and parses it. Returns `{:ok, map}` or
`{:error, reason}` from `File.read/1` (typically `:enoent` when the file is missing).
"""
@spec read_from_dir(String.t()) :: {:ok, map()} | {:error, :enoent | File.posix() | :badarg}
def read_from_dir(directory) do
path = Path.join(directory, ".tool-versions")

case File.read(path) do
{:ok, body} -> {:ok, parse(body)}
{:error, reason} -> {:error, reason}
end
end
end
101 changes: 101 additions & 0 deletions lib/desktop/toolchain.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
defmodule Desktop.Toolchain do
@moduledoc false

@doc """
Verifies that the running Erlang/OTP and Elixir versions match `.tool-versions` in
`project_root` when that file declares `erlang` and/or `elixir` entries.

Returns `{:ok, :no_tool_versions}` if the file is missing, `{:ok, :verified}` on success,
or `{:error, [message]}` when `.tool-versions` cannot be read or versions do not match.

`opts` may override runtime versions for testing:

* `:otp_release` — string from `System.otp_release/0` (major OTP, e.g. `"26"`)
* `:elixir_version` — string from `System.version/0` (e.g. `"1.19.1"`)
"""
@spec verify(String.t(), keyword()) ::
{:ok, :no_tool_versions | :verified} | {:error, [String.t()]}
def verify(project_root, opts \\ []) do
otp = Keyword.get_lazy(opts, :otp_release, &System.otp_release/0)
elixir = Keyword.get_lazy(opts, :elixir_version, &System.version/0)

case Desktop.ToolVersions.read_from_dir(project_root) do
{:error, :enoent} ->
{:ok, :no_tool_versions}

{:error, reason} ->
{:error, [read_tool_versions_failed_message(reason)]}

{:ok, tools} ->
errors =
[]
|> maybe_check_otp(tools, otp)
|> maybe_check_elixir(tools, elixir)

if errors == [] do
{:ok, :verified}
else
{:error, Enum.reverse(errors)}
end
end
end

defp maybe_check_otp(errors, tools, otp) do
case Map.get(tools, "erlang") do
nil ->
errors

declared ->
expected = otp_major_from_declared(declared)

if expected && expected != otp do
[
"Erlang/OTP mismatch: .tool-versions requests OTP #{expected} (from #{declared}), but this shell is OTP #{otp}."
| errors
]
else
errors
end
end
end

defp maybe_check_elixir(errors, tools, elixir) do
case Map.get(tools, "elixir") do
nil ->
errors

declared ->
expected = elixir_semver_from_declared(declared)
running = elixir_semver_from_declared(elixir)

if expected && running && expected != running do
[
"Elixir mismatch: .tool-versions requests #{expected} (from #{declared}), but this shell is #{running}."
| errors
]
else
errors
end
end
end

defp read_tool_versions_failed_message(reason) when is_atom(reason) do
"Could not read .tool-versions: #{:file.format_error(reason)}"
end

defp read_tool_versions_failed_message(reason) do
"Could not read .tool-versions: #{inspect(reason)}"
end

defp otp_major_from_declared(declared) do
declared
|> String.split(".", parts: 2)
|> List.first()
end

defp elixir_semver_from_declared(declared) do
declared
|> String.split("-", parts: 2)
|> List.first()
end
end
63 changes: 63 additions & 0 deletions lib/mix/tasks/desktop.check_toolchain.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
defmodule Mix.Tasks.Desktop.CheckToolchain do
@shortdoc "Checks Erlang/OTP and Elixir against .tool-versions in the project"

@moduledoc """
#{@shortdoc}

This task is tooling-agnostic: it does not invoke asdf, mise, or kerl. It only
compares the **currently active** `elixir` and `erlang` in your shell against the
versions declared in `.tool-versions` at the project root (next to `mix.exs`).

Use it from CI scripts or Android build wrappers to fail fast with a clear message
when the wrong runtime is active.

## Examples

mix desktop.check_toolchain
"""

use Mix.Task

@impl Mix.Task
def run(_argv) do
root = project_root()

case Desktop.Toolchain.verify(root) do
{:ok, :no_tool_versions} ->
Mix.shell().info("No .tool-versions found; skipping version check.")

{:ok, :verified} ->
Mix.shell().info(".tool-versions matches the active Erlang/OTP and Elixir.")

{:error, messages} ->
for msg <- messages, do: Mix.shell().error(msg)

unless toolchain_hint_irrelevant?(messages) do
Mix.shell().error("""
Activate the versions in .tool-versions for this project, for example:
mise install && mise exec -- mix desktop.check_toolchain
or:
asdf install && asdf exec mix desktop.check_toolchain
""")
end

System.halt(1)
end
end

defp project_root do
if mix_project?() do
Mix.Project.project_file() |> Path.dirname()
else
File.cwd!()
end
end

defp mix_project? do
Mix.Project.get() != nil
end

defp toolchain_hint_irrelevant?(messages) do
Enum.any?(messages, &match?("Could not read .tool-versions:" <> _, &1))
end
end
16 changes: 16 additions & 0 deletions test/desktop/tool_versions_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule Desktop.ToolVersionsTest do
use ExUnit.Case, async: true

test "parse ignores comments and blank lines" do
assert Desktop.ToolVersions.parse("""
# comment
elixir 1.19.1

erlang 26.2.5.5 system
""") == %{"elixir" => "1.19.1", "erlang" => "26.2.5.5"}
end

test "parse normalizes tool names to lowercase" do
assert Desktop.ToolVersions.parse("Elixir 1.2.3") == %{"elixir" => "1.2.3"}
end
end
49 changes: 49 additions & 0 deletions test/desktop/toolchain_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
defmodule Desktop.ToolchainTest do
use ExUnit.Case, async: true

@moduletag :tmp_dir

setup context do
root = Path.join(context.tmp_dir, "proj")
File.mkdir_p!(root)
Map.put(context, :root, root)
end

test "returns no_tool_versions when .tool-versions is missing", %{root: root} do
assert Desktop.Toolchain.verify(root) == {:ok, :no_tool_versions}
end

test "verified when versions match", %{root: root} do
File.write!(Path.join(root, ".tool-versions"), "erlang 26.2.5.5\nelixir 1.19.1-otp-26\n")

assert Desktop.Toolchain.verify(root,
otp_release: "26",
elixir_version: "1.19.1"
) == {:ok, :verified}
end

test "error when OTP major mismatches", %{root: root} do
File.write!(Path.join(root, ".tool-versions"), "erlang 26.0.1\n")

assert {:error, [msg]} =
Desktop.Toolchain.verify(root, otp_release: "25", elixir_version: "1.19.1")

assert msg =~ "OTP"
end

test "error when Elixir semver mismatches", %{root: root} do
File.write!(Path.join(root, ".tool-versions"), "elixir 1.19.1-otp-26\n")

assert {:error, [msg]} =
Desktop.Toolchain.verify(root, otp_release: "26", elixir_version: "1.18.0")

assert msg =~ "Elixir mismatch"
end

test "error when .tool-versions cannot be read", %{root: root} do
File.mkdir!(Path.join(root, ".tool-versions"))

assert {:error, [msg]} = Desktop.Toolchain.verify(root)
assert msg =~ "Could not read .tool-versions"
end
end
Loading