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