defmodule Opal.Application do @moduledoc false use Application require Logger @impl true def start(_type, _args) do setup_file_logger() children = [ {Registry, keys: :unique, name: Opal.Registry}, {Registry, keys: :duplicate, name: Opal.Events.Registry}, Opal.Shell.Process, {DynamicSupervisor, name: Opal.SessionSupervisor, strategy: :one_for_one} ] # Only start stdio transport when enabled (default true for backward compat; # set `config :opal, start_rpc: false` for embedded SDK use). children = if Application.get_env(:opal, :start_rpc, true) do children ++ [Opal.RPC.Server] else children end opts = [strategy: :rest_for_one, name: Opal.Supervisor] with {:ok, pid} <- Supervisor.start_link(children, opts) do if Application.get_env(:opal, :start_distribution, false) do start_distribution() end {:ok, pid} end end @doc """ Starts Erlang distribution so remote nodes can connect. Writes the node name to `~/.opal/node` for discovery by `pnpm inspect`. No-op if distribution is already started. Returns `{:ok, node_name}` or `{:error, reason}`. The distribution cookie is read from `config :opal, :distribution_cookie`. When set to `:random` (the default), a cryptographically random cookie is generated each time. Set an explicit atom to use a fixed cookie. """ @spec start_distribution() :: {:ok, node()} | {:error, term()} def start_distribution do if Node.alive?() do # Already distributed (e.g. started via --name/--sname by mix or release) write_node_file(Node.self(), Node.get_cookie()) Logger.debug("Distribution already active: #{Node.self()}") {:ok, Node.self()} else node_name = :"opal_#{System.pid()}" cookie = distribution_cookie() case Node.start(node_name, name_domain: :shortnames) do {:ok, _pid} -> Node.set_cookie(cookie) write_node_file(Node.self(), cookie) Logger.debug("Distribution started: #{Node.self()}") {:ok, Node.self()} {:error, reason} -> Logger.warning("Could not start distribution: #{inspect(reason)}") {:error, reason} end end end @doc """ Generates a cryptographically random cookie for Erlang distribution. """ @spec generate_cookie() :: atom() def generate_cookie do :crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false) |> String.to_atom() end defp setup_file_logger do data_dir = Application.get_env(:opal, :data_dir) || Opal.Config.default_data_dir() logs_dir = Path.join(Path.expand(data_dir), "logs") log_file = Path.join(logs_dir, "server.log") with :ok <- File.mkdir_p(logs_dir), :ok <- :logger.add_handler(:file_log, :logger_std_h, %{ config: %{ file: String.to_charlist(log_file), max_no_bytes: 5_000_000, max_no_files: 3 }, formatter: {:logger_formatter, %{ single_line: true, template: [:time, " [", :level, "] ", :msg, "\n"] }} }) do :ok else {:error, reason} -> Logger.warning("Could not set up file logging: #{inspect(reason)}") end end defp distribution_cookie do case Application.get_env(:opal, :distribution_cookie, :random) do :random -> generate_cookie() cookie when is_atom(cookie) -> cookie end end @doc """ Reads the node name and cookie from `~/.opal/node`. Returns `{:ok, node_name, cookie}` or `:error`. """ @spec read_node_file() :: {:ok, node(), atom()} | :error def read_node_file do path = node_file_path() case File.read(path) do {:ok, contents} -> case String.split(String.trim(contents), "\n") do [name, cookie] when name != "" and cookie != "" -> # Validate node name format (e.g. "opal_12345@hostname") if Regex.match?(~r/^[\w@.\-]+$/, name) do {:ok, String.to_atom(name), String.to_atom(cookie)} else Logger.warning("Invalid node name in #{path}: #{inspect(name)}") :error end _ -> :error end {:error, _} -> :error end end @doc """ Writes node name and cookie to the node file for discovery. """ @spec write_node_file(node(), atom()) :: :ok def write_node_file(node_name, cookie) do path = node_file_path() File.mkdir_p!(Path.dirname(path)) File.write!(path, "#{node_name}\n#{cookie}\n") # Best-effort permission restriction — chmod is unsupported on Windows _ = File.chmod(path, 0o600) :ok end defp node_file_path do Path.join([System.user_home!(), ".opal", "node"]) end end