Skip to content
Open
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
- `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

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).

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

Expand Down
23 changes: 22 additions & 1 deletion guides/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
40 changes: 40 additions & 0 deletions lib/desktop/tool_versions.ex
Original file line number Diff line number Diff line change
@@ -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
114 changes: 114 additions & 0 deletions lib/desktop/toolchain.ex
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions lib/mix/tasks/desktop.check_toolchain.ex
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions lib/mix/tasks/desktop.install.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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),
Expand Down
29 changes: 29 additions & 0 deletions test/desktop/tool_versions_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading