lib/specter.ex

defmodule Specter do
  @moduledoc """
  Specter is a method for managing data structures and entities provided by
  `webrtc.rs`. It is intended as a low-level library with some small set of
  opinions, which can composed into more complex behaviors by higher-level
  libraries and applications.

  ## Key points

  Specter wraps `webrtc.rs`, which heavily utilizes async Rust. For this reason,
  many functions cannot be automatically awaited by the caller—the NIF functions
  send messages across channels to separate threads managed by Rust, which send
  messages back to Elixir that can be caught by `receive` or `handle_info`.

  ## Usage

  A process initializes Specter via the `init/1` function, which registers the
  current process for callbacks that may be triggered via webrtc entities.

      iex> ## Initialize the library. Register messages to the current pid.
      iex> {:ok, specter} = Specter.init(ice_servers: ["stun:stun.l.google.com:19302"])
      ...>
      iex> ## Create a peer connection's dependencies
      iex> {:ok, media_engine} = Specter.new_media_engine(specter)
      iex> {:ok, registry} = Specter.new_registry(specter, media_engine)
      ...>
      iex> Specter.media_engine_exists?(specter, media_engine)
      true
      iex> Specter.registry_exists?(specter, registry)
      true
      ...>
      iex> {:ok, api} = Specter.new_api(specter, media_engine, registry)
      ...>
      iex> Specter.media_engine_exists?(specter, media_engine)
      false
      iex> Specter.registry_exists?(specter, registry)
      false
      ...>
      iex> ## Create a peer connection
      iex> {:ok, pc_1} = Specter.PeerConnection.new(specter, api)
      iex> assert_receive {:peer_connection_ready, ^pc_1}
      iex> Specter.PeerConnection.exists?(specter, pc_1)
      true
      iex> ## Add a thing to be negotiated
      iex> :ok = Specter.PeerConnection.create_data_channel(specter, pc_1, "data")
      iex> assert_receive {:data_channel_created, ^pc_1}
      ...>
      iex> ## Create an offer
      iex> :ok = Specter.PeerConnection.create_offer(specter, pc_1)
      iex> assert_receive {:offer, ^pc_1, offer}
      iex> :ok = Specter.PeerConnection.set_local_description(specter, pc_1, offer)
      iex> assert_receive {:ok, ^pc_1, :set_local_description}
      ...>
      iex> ## Create a second peer connection, to answer back
      iex> {:ok, media_engine} = Specter.new_media_engine(specter)
      iex> {:ok, registry} = Specter.new_registry(specter, media_engine)
      iex> {:ok, api} = Specter.new_api(specter, media_engine, registry)
      iex> {:ok, pc_2} = Specter.PeerConnection.new(specter, api)
      iex> assert_receive {:peer_connection_ready, ^pc_2}
      ...>
      iex> ## Begin negotiating offer/answer
      iex> :ok = Specter.PeerConnection.set_remote_description(specter, pc_2, offer)
      iex> assert_receive {:ok, ^pc_2, :set_remote_description}
      iex> :ok = Specter.PeerConnection.create_answer(specter, pc_2)
      iex> assert_receive {:answer, ^pc_2, answer}
      iex> :ok = Specter.PeerConnection.set_local_description(specter, pc_2, answer)
      iex> assert_receive {:ok, ^pc_2, :set_local_description}
      ...>
      iex> ## Receive ice candidates
      iex> assert_receive {:ice_candidate, ^pc_1, _candidate}
      iex> assert_receive {:ice_candidate, ^pc_2, _candidate}
      ...>
      iex> ## Shut everything down
      iex> Specter.PeerConnection.close(specter, pc_1)
      :ok
      iex> assert_receive {:peer_connection_closed, ^pc_1}
      ...>
      iex> :ok = Specter.PeerConnection.close(specter, pc_2)
      iex> assert_receive {:peer_connection_closed, ^pc_2}

  ## Thoughts

  During development of the library, it can be assumed that callers will
  implement `handle_info/2` function heads appropriate to the underlying
  implementation. Once these are more solid, it would be nice to `use Specter`,
  which will inject a `handle_info/2` callback, and send the messages to
  other callback functions defined by a behaviour. `handle_ice_candidate`,
  and so on.

  Some things are returned from the NIF as UUIDs. These are declared as `@opaque`,
  to indicate that users of the library should not rely of them being in a
  particular format. They could change later to be references, for instance.
  """

  alias Specter.Native

  @enforce_keys [:native]
  defstruct [:native]

  @typedoc """
  `t:native_t/0` references are returned from the NIF, and represent state held
  in Rust code.
  """
  @opaque native_t() :: Specter.Native.t()

  @typedoc """
  `t:Specter.t/0` wraps the reference returned from `init/1`. All functions interacting with
  NIF state take a `t:Specter.t/0` as their first argument.
  """
  @type t() :: %Specter{native: native_t()}

  @typedoc """
  `t:Specter.api_t/0` represent an instantiated API managed in the NIF.
  """
  @opaque api_t() :: String.t()

  @typedoc """
  `t:Specter.media_engine_t/0` represents an instantiated MediaEngine managed in the NIF.
  """
  @opaque media_engine_t() :: String.t()

  @typedoc """
  `t:Specter.registry_t/0` represent an instantiated intercepter Registry managed in the NIF.
  """
  @opaque registry_t() :: String.t()

  @typedoc """
  A uri in the form `protocol:host:port`, where protocol is either
  `stun` or `turn`.

  Defaults to `stun:stun.l.google.com:19302`.
  """
  @type ice_server() :: String.t()

  @typedoc """
  Options for initializing RTCPeerConnections. This is set during initialization
  of the library, and later used when creating new connections.
  """
  @type init_options() :: [] | [ice_servers: [ice_server()]]

  @doc """
  Initialize the library. This registers the calling process to receive
  callback messages to `handle_info/2`.

  | param         | type               | default |
  | ------------- | ------------------ | ------- |
  | `ice_servers` | `list(String.t())` | `["stun:stun.l.google.com:19302"]` |

  ## Usage

      iex> {:ok, _specter} = Specter.init(ice_servers: ["stun:stun.example.com:3478"])

  """
  @spec init() :: {:ok, t()}
  @spec init(init_options()) :: {:ok, t()} | {:error, term()}
  def init(args \\ []) do
    with {:ok, native} <- Native.init(args) do
      {:ok, %Specter{native: native}}
    end
  end

  @doc """
  Returns the current configuration for the initialized NIF.

  ## Usage

      iex> {:ok, specter} = Specter.init(ice_servers: ["stun:stun.example.com:3478"])
      iex> Specter.config(specter)
      {:ok, %Specter.Config{ice_servers: ["stun:stun.example.com:3478"]}}

  """
  @spec config(t()) :: {:ok, Specter.Config.t()} | {:error, term()}
  def config(%Specter{native: ref}), do: Native.config(ref)

  @doc """
  Returns true or false, depending on whether the media engine is available for
  consumption, i.e. is initialized and has not been used by a function that takes
  ownership of it.

  ## Usage

      iex> {:ok, specter} = Specter.init(ice_servers: ["stun:stun.l.google.com:19302"])
      iex> {:ok, media_engine} = Specter.new_media_engine(specter)
      iex> Specter.media_engine_exists?(specter, media_engine)
      true

      iex> {:ok, specter} = Specter.init(ice_servers: ["stun:stun.l.google.com:19302"])
      iex> Specter.media_engine_exists?(specter, UUID.uuid4())
      false

  """
  @spec media_engine_exists?(t(), media_engine_t()) :: boolean() | no_return()
  def media_engine_exists?(%Specter{native: ref}, media_engine) do
    case Native.media_engine_exists(ref, media_engine) do
      {:ok, value} ->
        value

      {:error, error} ->
        raise "Unable to determine whether media engine exists:\n#{inspect(error)}"
    end
  end

  @doc """
  An APIBuilder is used to create RTCPeerConnections. This accepts as parameters
  the output of `init/1`, `new_media_enine/1`, and `new_registry/2`.

  Note that this takes ownership of both the media engine and the registry,
  effectively consuming them.

  | param          | type     | default |
  | -------------- | -------- | ------- |
  | `specter`      | `t()`    | |
  | `media_engine` | `opaque` | |
  | `registry`     | `opaque` | |

  ## Usage

      iex> {:ok, specter} = Specter.init(ice_servers: ["stun:stun.l.google.com:19302"])
      iex> {:ok, media_engine} = Specter.new_media_engine(specter)
      iex> {:ok, registry} = Specter.new_registry(specter, media_engine)
      iex> {:ok, _api} = Specter.new_api(specter, media_engine, registry)

  """
  @spec new_api(t(), media_engine_t(), registry_t()) :: {:ok, api_t()} | {:error, term()}
  def new_api(%Specter{native: ref}, media_engine, registry),
    do: Native.new_api(ref, media_engine, registry)

  @doc """
  Creates a MediaEngine to be configured and used by later function calls.
  Codecs and other high level configuration are done on instances of MediaEngines.
  A MediaEngine is combined with a Registry in an entity called an APIBuilder,
  which is then used to create RTCPeerConnections.

  ## Usage

      iex> {:ok, specter} = Specter.init(ice_servers: ["stun:stun.l.google.com:19302"])
      iex> {:ok, _media_engine} = Specter.new_media_engine(specter)

  """
  @spec new_media_engine(t()) :: {:ok, media_engine_t()} | {:error, term()}
  def new_media_engine(%Specter{native: ref}), do: Native.new_media_engine(ref)

  @doc """
  Creates an intercepter registry. This is a user configurable RTP/RTCP pipeline,
  and provides features such as NACKs and RTCP Reports. A registry must be created for
  each peer connection.

  The registry may be combined with a MediaEngine in an API (consuming both). The API
  instance is then used to create RTCPeerConnections.

  Note that creating a registry does **not** take ownership of the media engine.

  ## Usage

      iex> {:ok, specter} = Specter.init(ice_servers: ["stun:stun.l.google.com:19302"])
      iex> {:ok, media_engine} = Specter.new_media_engine(specter)
      iex> {:ok, _registry} = Specter.new_registry(specter, media_engine)
      ...>
      iex> Specter.media_engine_exists?(specter, media_engine)
      true

  """
  @spec new_registry(t(), media_engine_t()) :: {:ok, registry_t()} | {:error, term()}
  def new_registry(%Specter{native: ref}, media_engine),
    do: Native.new_registry(ref, media_engine)

  @doc """
  Returns true or false, depending on whether the registry is available for
  consumption, i.e. is initialized and has not been used by a function that takes
  ownership of it.

  ## Usage

      iex> {:ok, specter} = Specter.init(ice_servers: ["stun:stun.l.google.com:19302"])
      iex> {:ok, media_engine} = Specter.new_media_engine(specter)
      iex> {:ok, registry} = Specter.new_registry(specter, media_engine)
      iex> Specter.registry_exists?(specter, registry)
      true

      iex> {:ok, specter} = Specter.init(ice_servers: ["stun:stun.l.google.com:19302"])
      iex> Specter.registry_exists?(specter, UUID.uuid4())
      false
  """
  @spec registry_exists?(t(), registry_t()) :: boolean() | no_return()
  def registry_exists?(%Specter{native: ref}, registry) do
    case Native.registry_exists(ref, registry) do
      {:ok, value} ->
        value

      {:error, error} ->
        raise "Unable to determine whether registry exists:\n#{inspect(error)}"
    end
  end
end