lib/lightpanda_ex.ex

defmodule LightpandaEx do
  @checksums %{
    "0.3.0" => %{
      "aarch64-linux" => "66b06ce2cc067437245934a0782d574691dee63ee1c2cdc7e949b2efce9764c0",
      "aarch64-macos" => "4ca3897a1547c9b3b843a0a921c2b4d044afb3ad4914091a845ac608fe1cb047",
      "x86_64-linux" => "256f8dcb45676c53c26a2e5a40a9a60d844feb387d7f43c20925982fa2be723a",
      "x86_64-macos" => "8267ad21feff114619a60fd7b1cd23440f1d5e443a09aabdadb27c5179b0f347"
    }
  }

  # https://github.com/lightpanda-io/browser/releases/latest
  @latest_version "0.3.0"

  @default_profiles %{
    default: [
      args: ~w(serve --host 127.0.0.1 --port 9222)
    ]
  }

  @moduledoc """
  LightpandaEx is an installer and runner for the
  [Lightpanda](https://github.com/lightpanda-io/browser) headless browser.

  ## Configuration

  Configure in your `config/config.exs`:

      config :lightpanda_ex,
        version: "#{@latest_version}",
        default: [
          args: ~w(serve --host 127.0.0.1 --port 9222)
        ]

  ## Global options

    * `:version` - the expected Lightpanda version. Defaults to
      `#{@latest_version}`.

    * `:path` - the path to the Lightpanda binary. By default it is
      automatically downloaded and placed inside the `_build` directory.

    * `:release` - which release to track. Either a version string like
      `"#{@latest_version}"` (default, derived from `:version`) or `"nightly"`
      to track the nightly build.

    * `:url` - the base URL template to download the binary from.
      Defaults to `#{__MODULE__}.default_base_url/0`. Supports the
      placeholders `$version` and `$target`, for example:

          "https://my-mirror.example.com/lightpanda/$version/lightpanda-$target"

    * `:version_check` - set to `false` to skip the boot-time check
      that warns when the installed binary's version doesn't match the
      configured `:version`. Defaults to `true`.

  ## Profiles

  Each profile accepts:

    * `:args` - arguments to pass to the Lightpanda binary.
    * `:cd` - the working directory.
    * `:env` - environment variables as a map of string key/value pairs.

  The built-in `:default` profile starts a CDP server on `127.0.0.1:9222`.
  """

  use Application
  require Logger

  @doc false
  def start(_, _) do
    maybe_warn_version_mismatch()

    Supervisor.start_link([], strategy: :one_for_one, name: __MODULE__.Supervisor)
  end

  @doc """
  Returns the latest known version of the Lightpanda binary.
  """
  def latest_version, do: @latest_version

  @doc """
  Returns the configured version of the Lightpanda binary.
  """
  def configured_version do
    Application.get_env(:lightpanda_ex, :version, latest_version())
  end

  @doc """
  Returns the configuration for the given profile.
  """
  def config_for!(profile) when is_atom(profile) do
    Application.get_env(:lightpanda_ex, profile) ||
      @default_profiles[profile] ||
      raise ArgumentError, """
      unknown lightpanda profile. Make sure the profile is defined in your config/config.exs file, such as:

          config :lightpanda_ex,
            #{profile}: [
              args: ~w(serve --host 127.0.0.1 --port 9222)
            ]
      """
  end

  @doc """
  Returns the path to the Lightpanda binary.
  """
  def bin_path do
    name = "lightpanda-#{target()}"

    Application.get_env(:lightpanda_ex, :path) ||
      if Code.ensure_loaded?(Mix.Project) do
        Path.join(Path.dirname(Mix.Project.build_path()), name)
      else
        Path.expand("_build/#{name}")
      end
  end

  @doc """
  Returns the version of the installed Lightpanda binary.

  Returns `{:ok, version_string}` on success or `:error` when the executable
  is not available.
  """
  def bin_version do
    path = bin_path()

    with true <- File.exists?(path),
         {result, 0} <- System.cmd(path, ["version"], stderr_to_stdout: true),
         {:ok, version} <- parse_version(result) do
      {:ok, version}
    else
      _ -> :error
    end
  end

  @doc """
  Runs the Lightpanda binary with the given profile and extra arguments.

  The given arguments are appended to the configured profile arguments.
  Output is streamed directly to stdio, and the return value is the exit status.
  """
  def run(profile, extra_args \\ []) when is_atom(profile) and is_list(extra_args) do
    config = config_for!(profile)
    args = config[:args] || []

    if args == [] and extra_args == [] do
      raise "no arguments passed to lightpanda"
    end

    opts = [
      cd: config[:cd] || File.cwd!(),
      env: normalize_env(config[:env] || %{}),
      into: IO.stream(:stdio, :line),
      stderr_to_stdout: true
    ]

    bin_path()
    |> System.cmd(args ++ extra_args, opts)
    |> elem(1)
  end

  defp start_unique_install_worker() do
    ref =
      __MODULE__.Supervisor
      |> Supervisor.start_child(
        Supervisor.child_spec({Task, &install/0}, restart: :transient, id: __MODULE__.Installer)
      )
      |> case do
        {:ok, pid} -> pid
        {:error, {:already_started, pid}} -> pid
      end
      |> Process.monitor()

    receive do
      {:DOWN, ^ref, _, _, _} -> :ok
    end
  end

  @doc """
  Ensures the Lightpanda binary is installed.

  Concurrent callers are deduplicated so parallel `install_and_run/2`
  invocations only download once.
  """
  def ensure_installed! do
    unless File.exists?(bin_path()) do
      if Process.whereis(__MODULE__.Supervisor) do
        start_unique_install_worker()
      else
        install()
      end
    end

    :ok
  end

  @doc """
  Installs the binary if missing, then runs it with the given profile and extra arguments.

  Returns the exit status.
  """
  def install_and_run(profile, args \\ []) do
    ensure_installed!()
    run(profile, args)
  end

  @doc """
  Returns the default URL template used to fetch the binary.

  Supports the `$version` and `$target` placeholders. Configure
  `config :lightpanda_ex, :url, "..."` to redirect downloads to a mirror
  or local cache.
  """
  def default_base_url do
    "https://github.com/lightpanda-io/browser/releases/download/$version/lightpanda-$target"
  end

  @doc """
  Installs the configured Lightpanda binary.

  This downloads `configured_version/0` by default. Configure `:release` to
  `"nightly"` to track Lightpanda nightly builds instead.
  """
  def install do
    version = configured_version()
    tmp_opts = if System.get_env("MIX_XDG"), do: %{os: :linux}, else: %{}

    tmp_dir =
      freshdir_p(:filename.basedir(:user_cache, "lightpanda-ex", tmp_opts)) ||
        freshdir_p(Path.join(System.tmp_dir!(), "lightpanda-ex")) ||
        raise "could not install lightpanda. Set MIX_XDG=1 and then set XDG_CACHE_HOME to the path you want to use as cache"

    release = Application.get_env(:lightpanda_ex, :release, version) |> to_string()
    target = target()
    binary = release |> release_urls(target) |> LightpandaEx.GitHubRelease.fetch_binary!()
    verify_checksum!(binary, release, target)
    tmp_bin_path = Path.join(tmp_dir, "lightpanda")
    File.write!(tmp_bin_path, binary)

    bin_path = bin_path()
    File.mkdir_p!(Path.dirname(bin_path))
    File.cp!(tmp_bin_path, bin_path)
    File.chmod!(bin_path, 0o755)
  end

  defp freshdir_p(path) do
    with {:ok, _} <- File.rm_rf(path),
         :ok <- File.mkdir_p(path) do
      path
    else
      _ -> nil
    end
  end

  @doc """
  Returns the platform target string, for example `"x86_64-linux"`.
  """
  def target do
    case :os.type() do
      {:win32, _} ->
        raise "lightpanda is not available for Windows. Use WSL2 and install the Linux binary instead"

      {:unix, osname} when osname in [:linux, :darwin] ->
        os = if osname == :darwin, do: "macos", else: "linux"
        "#{arch()}-#{os}"

      {:unix, osname} ->
        raise "lightpanda is not available for OS: #{osname}"
    end
  end

  defp arch do
    arch_str = :erlang.system_info(:system_architecture)
    [arch | _] = arch_str |> List.to_string() |> String.split("-")

    case arch do
      "amd64" -> "x86_64"
      "x86_64" -> "x86_64"
      "aarch64" -> "aarch64"
      "arm64" -> "aarch64"
      _ -> raise "lightpanda is not available for architecture: #{arch_str}"
    end
  end

  @doc false
  def maybe_warn_version_mismatch do
    if Application.get_env(:lightpanda_ex, :version_check, true) do
      configured_version = configured_version()

      case bin_version() do
        {:ok, ^configured_version} ->
          :ok

        {:ok, version} ->
          Logger.warning("""
          Outdated lightpanda binary. Expected #{configured_version}, got #{version}. \
          Please run `mix lightpanda.install` or update the version in your config files.\
          """)

        :error ->
          :ok
      end
    end

    :ok
  end

  defp release_urls("nightly", target) do
    [render_url(download_url_template(), "nightly", target)]
  end

  defp release_urls("v" <> _ = release, target) do
    [render_url(download_url_template(), release, target)]
  end

  defp release_urls(release, target) do
    template = download_url_template()

    [
      render_url(template, release, target),
      render_url(template, "v#{release}", target)
    ]
    |> Enum.uniq()
  end

  defp download_url_template do
    Application.get_env(:lightpanda_ex, :url, default_base_url())
  end

  defp render_url(template, version, target) do
    template
    |> String.replace("$version", version)
    |> String.replace("$target", target)
  end

  defp parse_version(result) do
    case Regex.run(~r/\d+\.\d+\.\d+(?:[-+][[:alnum:].-]+)?/, result) do
      [version] -> {:ok, version}
      _ -> :error
    end
  end

  defp verify_checksum!(binary, release, target) do
    case checksum_for(release, target) do
      nil ->
        Logger.warning(
          "no checksum available for lightpanda #{release} #{target}, skipping verification"
        )

      expected ->
        actual =
          binary
          |> then(&:crypto.hash(:sha256, &1))
          |> Base.encode16(case: :lower)

        if actual != expected do
          raise """
          checksum mismatch for lightpanda-#{target}

            expected: #{expected}
            got:      #{actual}

          This could mean the download was corrupted or tampered with.
          """
        end
    end
  end

  defp checksum_for("nightly", _target), do: nil
  defp checksum_for("v" <> version, target), do: get_in(@checksums, [version, target])
  defp checksum_for(version, target), do: get_in(@checksums, [version, target])

  defp normalize_env(env) do
    Map.new(env, fn
      {key, value} when is_list(value) -> {key, Enum.join(value, path_sep())}
      other -> other
    end)
  end

  defp path_sep do
    case :os.type() do
      {:win32, _} -> ";"
      {:unix, _} -> ":"
    end
  end
end