From 78009912f9a4248f873aac742209bc63504efd70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 00:23:03 +0000 Subject: [PATCH 1/4] Add mix desktop.check_toolchain and .tool-versions parsing Introduce Desktop.ToolVersions and Desktop.Toolchain to compare the running OTP major and Elixir semver against project .tool-versions without shelling out to mise/asdf. Mix task fails fast with actionable messages when mismatched. Adds ExUnit coverage for parsing edge cases and verification behavior. Refs https://github.com/elixir-desktop/desktop/issues/67 Co-authored-by: Dominic Letz --- lib/desktop/tool_versions.ex | 40 ++++++++ lib/desktop/toolchain.ex | 114 ++++++++++++++++++++++ lib/mix/tasks/desktop.check_toolchain.ex | 38 ++++++++ test/desktop/tool_versions_test.exs | 29 ++++++ test/desktop/toolchain_test.exs | 115 +++++++++++++++++++++++ 5 files changed, 336 insertions(+) create mode 100644 lib/desktop/tool_versions.ex create mode 100644 lib/desktop/toolchain.ex create mode 100644 lib/mix/tasks/desktop.check_toolchain.ex create mode 100644 test/desktop/tool_versions_test.exs create mode 100644 test/desktop/toolchain_test.exs diff --git a/lib/desktop/tool_versions.ex b/lib/desktop/tool_versions.ex new file mode 100644 index 0000000..e52ee87 --- /dev/null +++ b/lib/desktop/tool_versions.ex @@ -0,0 +1,40 @@ +defmodule Desktop.ToolVersions do + @moduledoc false + + @doc """ + Parses a `.tool-versions` file body (asdf/mise format). + + Returns a map with optional string values for `:erlang` and `:elixir` keys + (raw version fields from the file, e.g. `"26.2.5.5 system"`, `"1.19.1-otp-26"`). + """ + @spec parse(String.t()) :: %{optional(:erlang) => String.t(), optional(:elixir) => String.t()} + def parse(content) when is_binary(content) do + content + |> String.split("\n") + |> Enum.reduce(%{}, &parse_line/2) + end + + defp parse_line(line, acc) do + line = String.trim(line) + + cond do + line == "" -> + acc + + String.starts_with?(line, "#") -> + acc + + true -> + case Regex.run(~r/^(elixir|erlang)\s+(.+)$/, line) do + [_, "elixir", rest] -> + Map.put(acc, :elixir, String.trim(rest)) + + [_, "erlang", rest] -> + Map.put(acc, :erlang, String.trim(rest)) + + _ -> + acc + end + end + end +end diff --git a/lib/desktop/toolchain.ex b/lib/desktop/toolchain.ex new file mode 100644 index 0000000..21e6b91 --- /dev/null +++ b/lib/desktop/toolchain.ex @@ -0,0 +1,114 @@ +defmodule Desktop.Toolchain do + @moduledoc false + + alias Desktop.ToolVersions + + @doc """ + Verifies that running OTP major and Elixir version match entries parsed from `.tool-versions`. + + `requirements` is the map returned by `Desktop.ToolVersions.parse/1`. + + Optional `otp_release` and `elixir_version` override `System` values (for testing). + + Returns `:ok` or `{:error, message}` where `message` is human-readable. + """ + @spec verify(map(), keyword()) :: :ok | {:error, String.t()} + def verify(requirements, opts \\ []) when is_map(requirements) do + otp_actual = Keyword.get_lazy(opts, :otp_release, fn -> System.otp_release() end) + elixir_actual = Keyword.get_lazy(opts, :elixir_version, fn -> System.version() end) + + errors = + [] + |> maybe_check_erlang(Map.get(requirements, :erlang), otp_actual) + |> maybe_check_elixir(Map.get(requirements, :elixir), elixir_actual) + + case errors do + [] -> :ok + msgs -> {:error, Enum.join(msgs, "\n")} + end + end + + defp maybe_check_erlang(acc, nil, _otp_actual), do: acc + + defp maybe_check_erlang(acc, raw, otp_actual) when is_binary(raw) do + expected_major = erlang_major(raw) + + case Integer.parse(to_string(otp_actual)) do + {actual_major, _} when actual_major == expected_major -> + acc + + {actual_major, _} -> + [ + "Erlang/OTP major mismatch: running OTP #{actual_major}, `.tool-versions` expects OTP #{expected_major} (from erlang #{inspect(raw)})." + | acc + ] + + :error -> + ["Could not parse running OTP release #{inspect(otp_actual)}." | acc] + end + end + + defp maybe_check_elixir(acc, nil, _elixir_actual), do: acc + + defp maybe_check_elixir(acc, raw, elixir_actual) when is_binary(raw) do + expected_base = elixir_base_version(raw) + + case Version.parse(expected_base) do + {:ok, expected_ver} -> + case Version.parse(elixir_actual) do + {:ok, actual_ver} -> + if Version.compare(actual_ver, expected_ver) == :eq do + acc + else + [ + "Elixir version mismatch: running #{elixir_actual}, `.tool-versions` expects #{expected_base} (from elixir #{inspect(raw)})." + | acc + ] + end + + :error -> + ["Could not parse running Elixir version #{inspect(elixir_actual)}." | acc] + end + + :error -> + ["Could not parse Elixir version in `.tool-versions`: #{inspect(raw)}." | acc] + end + end + + @doc false + def erlang_major(raw) when is_binary(raw) do + raw + |> String.split() + |> hd() + |> String.split(".") + |> hd() + |> String.to_integer() + end + + @doc false + def elixir_base_version(raw) when is_binary(raw) do + case Regex.run(~r/^(\d+\.\d+\.\d+)/, raw) do + [_, base] -> + base + + nil -> + raw + |> String.split("-") + |> hd() + end + end + + @doc """ + Loads `.tool-versions` from `path`, parses it, and verifies the toolchain. + """ + @spec verify_file(String.t(), keyword()) :: :ok | {:error, String.t()} + def verify_file(path, opts \\ []) do + case File.read(path) do + {:ok, content} -> + content |> ToolVersions.parse() |> verify(opts) + + {:error, reason} -> + {:error, "Could not read #{inspect(path)}: #{inspect(reason)}"} + end + end +end diff --git a/lib/mix/tasks/desktop.check_toolchain.ex b/lib/mix/tasks/desktop.check_toolchain.ex new file mode 100644 index 0000000..97c27b7 --- /dev/null +++ b/lib/mix/tasks/desktop.check_toolchain.ex @@ -0,0 +1,38 @@ +defmodule Mix.Tasks.Desktop.CheckToolchain do + @shortdoc "Checks running Erlang/OTP and Elixir against `.tool-versions`" + + @moduledoc """ + #{@shortdoc} + + Compares the **currently running** BEAM (`System.otp_release/0`, `System.version/0`) + to `erlang` and `elixir` lines in `.tool-versions`. It does not invoke mise, asdf, or + other installers—activate your toolchain however you prefer, then run this task to fail fast. + + ## Examples + + mix desktop.check_toolchain + + """ + + use Mix.Task + + @impl Mix.Task + def run(_argv) do + root = Mix.Project.config()[:root] || File.cwd!() + path = Path.join(root, ".tool-versions") + + unless File.exists?(path) do + Mix.shell().error("No `.tool-versions` found at #{path}.") + exit({:shutdown, 1}) + end + + case Desktop.Toolchain.verify_file(path) do + :ok -> + Mix.shell().info("Toolchain matches `.tool-versions`.") + + {:error, msg} -> + Mix.shell().error(msg) + exit({:shutdown, 1}) + end + end +end diff --git a/test/desktop/tool_versions_test.exs b/test/desktop/tool_versions_test.exs new file mode 100644 index 0000000..db57ab6 --- /dev/null +++ b/test/desktop/tool_versions_test.exs @@ -0,0 +1,29 @@ +defmodule Desktop.ToolVersionsTest do + use ExUnit.Case, async: true + + alias Desktop.ToolVersions + + test "parse ignores comments and blank lines" do + content = """ + # comment + erlang 26.2.5.5 system + + elixir 1.19.1-otp-26 + """ + + assert ToolVersions.parse(content) == %{ + erlang: "26.2.5.5 system", + elixir: "1.19.1-otp-26" + } + end + + test "parse handles extra whitespace on values" do + content = "elixir 1.12.3 \n" + + assert ToolVersions.parse(content) == %{elixir: "1.12.3"} + end + + test "parse empty file" do + assert ToolVersions.parse("") == %{} + end +end diff --git a/test/desktop/toolchain_test.exs b/test/desktop/toolchain_test.exs new file mode 100644 index 0000000..133fe9c --- /dev/null +++ b/test/desktop/toolchain_test.exs @@ -0,0 +1,115 @@ +defmodule Desktop.ToolchainTest do + use ExUnit.Case, async: true + + alias Desktop.Toolchain + + describe "verify/2" do + test "ok when erlang major matches and elixir semver matches" do + req = %{erlang: "26.2.5.5 system", elixir: "1.19.1-otp-26"} + + assert Toolchain.verify(req, + otp_release: 26, + elixir_version: "1.19.1" + ) == :ok + end + + test "error when OTP major mismatches" do + req = %{erlang: "26.2.5.5 system", elixir: "1.19.1-otp-26"} + + assert {:error, msg} = + Toolchain.verify(req, + otp_release: 25, + elixir_version: "1.19.1" + ) + + assert msg =~ "OTP" + assert msg =~ "25" + assert msg =~ "26" + end + + test "error when Elixir semver mismatches" do + req = %{erlang: "26.2.5.5 system", elixir: "1.19.1-otp-26"} + + assert {:error, msg} = + Toolchain.verify(req, + otp_release: 26, + elixir_version: "1.18.0" + ) + + assert msg =~ "Elixir" + assert msg =~ "1.18.0" + assert msg =~ "1.19.1" + end + + test "ok when only erlang line present" do + req = %{erlang: "24.0.1"} + + assert Toolchain.verify(req, otp_release: 24, elixir_version: "9.9.9") == :ok + end + + test "ok when only elixir line present" do + req = %{elixir: "1.12.0"} + + assert Toolchain.verify(req, otp_release: 99, elixir_version: "1.12.0") == :ok + end + + test "empty requirements always ok" do + assert Toolchain.verify(%{}, otp_release: 1, elixir_version: "0.1.0") == :ok + end + end + + describe "verify_file/2" do + test "reads and verifies temp file" do + path = + Path.join( + System.tmp_dir!(), + "desktop-tool-versions-test-#{:erlang.unique_integer([:positive])}" + ) + + content = """ + erlang 26.0.1 + elixir 1.14.0 + """ + + :ok = File.write(path, content) + + try do + assert Toolchain.verify_file(path, + otp_release: 26, + elixir_version: "1.14.0" + ) == :ok + + assert {:error, _} = + Toolchain.verify_file(path, + otp_release: 25, + elixir_version: "1.14.0" + ) + after + File.rm(path) + end + end + + test "missing file returns error" do + path = + Path.join( + System.tmp_dir!(), + "nonexistent-tool-versions-#{:erlang.unique_integer([:positive])}" + ) + + assert {:error, msg} = Toolchain.verify_file(path) + assert msg =~ "Could not read" + end + end + + describe "helpers" do + test "erlang_major/1" do + assert Toolchain.erlang_major("26.2.5.5 system") == 26 + assert Toolchain.erlang_major("24.0.1") == 24 + end + + test "elixir_base_version/1" do + assert Toolchain.elixir_base_version("1.19.1-otp-26") == "1.19.1" + assert Toolchain.elixir_base_version("1.12.0") == "1.12.0" + end + end +end From ddc4ed04c8b9331b05236ee916cf611c29a09706 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 00:23:05 +0000 Subject: [PATCH 2/4] Document mise alongside asdf and link toolchain check task Linux getting started treats mise and asdf equally for .tool-versions, explains manager-agnostic shell wrappers, and documents mix desktop.check_toolchain. README notes contributor toolchain pinning. Refs https://github.com/elixir-desktop/desktop/issues/67 Co-authored-by: Dominic Letz --- README.md | 4 +++- guides/getting_started.md | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 83c259d..5e6f55b 100644 --- a/README.md +++ b/README.md @@ -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). + +This repo’s [`.tool-versions`](./.tool-versions) pins Erlang and Elixir for contributors; [mise](https://mise.jdx.dev/) and [asdf](https://asdf-vm.com/) both understand that file. After activating your toolchain, run `mix desktop.check_toolchain` to confirm the running OTP major and Elixir version match `.tool-versions`. ## Status / Roadmap diff --git a/guides/getting_started.md b/guides/getting_started.md index d415396..fe0ad6f 100644 --- a/guides/getting_started.md +++ b/guides/getting_started.md @@ -121,10 +121,31 @@ echo ". ~/maint-24/activate" >> ~/.bashrc Best to use Erlang solutions packages: https://www.erlang-solutions.com/downloads/ -**Or use ASDF** +### Version managers (optional) + +Many projects pin Erlang and Elixir with a **`.tool-versions`** file (same format works with [mise](https://mise.jdx.dev/) and [asdf](https://asdf-vm.com/)). Pick either tool—your shell only needs the correct `erl` / `elixir` on `PATH` when you run `mix`. + +**mise** (example): + +```bash +curl https://mise.run | sh +mise install ``` + +**asdf** (example): + +```bash asdf plugin update --all asdf install erlang 24.0.1 +asdf install elixir 1.14.0-otp-24 +``` + +Shell wrappers (for example Android `run_mix` scripts in the [example app](https://github.com/elixir-desktop/desktop-example-app)) should not hard-code asdf-specific paths. Prefer invoking Mix through your activated environment, or explicitly via `mise exec -- mix …` / `asdf exec mix …` when you rely on a version manager. + +After your toolchain is active, you can verify it against the project’s `.tool-versions` from this library: + +```bash +mix desktop.check_toolchain ``` **Install NIF Dependencies:** From 0a8839d89b871bfe9c43b70d0e4f9078fbf47eca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 00:23:12 +0000 Subject: [PATCH 3/4] Changelog: desktop.check_toolchain and mise/asdf docs Co-authored-by: Dominic Letz --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6432989..2f90992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Changes in 1.5 - Support for iOS hibernation and wakeup +- `mix desktop.check_toolchain` verifies running Erlang/OTP (major) and Elixir against `.tool-versions`; docs describe mise and asdf equally for Linux contributors ## Changes in 1.4 From 3c6cb045d60a7633abf950fc36efdb4cc1ed4985 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 01:07:04 +0000 Subject: [PATCH 4/4] Fix Dialyzer: pass string suffix to Igniter.Project.Module.module_name/2 MainWindow was passed as an atom but the API expects String.t(), which made Dialyzer infer igniter/1 had no successful return and flagged the rest of the module as dead code. Use "MainWindow" consistently. Fixes CI Compile & Lint (dialyzer). Co-authored-by: Dominic Letz --- lib/mix/tasks/desktop.install.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/desktop.install.ex b/lib/mix/tasks/desktop.install.ex index e3b0bce..7099f67 100644 --- a/lib/mix/tasks/desktop.install.ex +++ b/lib/mix/tasks/desktop.install.ex @@ -50,7 +50,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do menu = Igniter.Project.Module.module_name(igniter, "Menu") menubar = Igniter.Project.Module.module_name(igniter, "MenuBar") gettext = Igniter.Libs.Phoenix.web_module_name(igniter, "Gettext") - main_window = Igniter.Project.Module.module_name(igniter, MainWindow) + main_window = Igniter.Project.Module.module_name(igniter, "MainWindow") igniter |> Igniter.compose_task("igniter.add", ["desktop"]) @@ -63,7 +63,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do quote do [ app: unquote(app), - id: unquote(Igniter.Project.Module.module_name(igniter, MainWindow)), + id: unquote(Igniter.Project.Module.module_name(igniter, "MainWindow")), title: unquote(to_string(app)), size: {600, 500}, menubar: unquote(menubar),