lib/desktop/env.ex

defmodule Desktop.Env do
  @moduledoc """
  Env holds any needed :wx / Desktop application state. Currently
  it keeps track of
  * The open Desktop.Window(s),
  * OS Application events (such as when a file is dragged on the application icon)
  * The :wx environment
  * The dbus connection (sni) on linux

  Also it has a global connect() method to allow binding of :wx event callbacks using
  this long lived process as reference.
  """
  alias Desktop.Env
  use GenServer
  require Logger

  defstruct [:wx_env, :wx, :map, :waiters, :windows, :sni, :events, :subs]

  @doc false
  @spec start_link() :: :ignore | {:error, any} | {:ok, pid}
  def start_link() do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  @doc false
  @impl true
  def init(_arg) do
    wx = Desktop.Fallback.wx_new([])
    Desktop.Fallback.wx_subscribe()

    {:ok,
     %Env{
       wx_env: Desktop.Fallback.wx_get_env(),
       wx: wx,
       map: %{},
       waiters: %{},
       windows: [],
       sni: :not_initialized,
       events: [],
       subs: []
     }}
  end

  @doc """
  Returns the currently used webview backend
  """
  def backend() do
    get(:webview_backend, "nil")
  end

  @impl true
  def handle_call({:subscribe, pid}, _from, d = %Env{events: events, subs: subs}) do
    for e <- events do
      send(pid, e)
    end

    Process.monitor(pid)
    {:reply, :ok, %Env{d | subs: [pid | subs], events: []}}
  end

  def handle_call(:wx_env, _from, d = %Env{wx_env: env}) do
    {:reply, env, d}
  end

  def handle_call(:wx, _from, d = %Env{wx: wx}) do
    {:reply, wx, d}
  end

  def handle_call(:sni, _from, state = %Env{sni: :not_initialized}) do
    sni = init_sni()
    {:reply, sni, %Env{state | sni: sni}}
  end

  def handle_call(:sni, _from, state = %Env{sni: sni}) do
    {:reply, sni, state}
  end

  def handle_call({:get, key, default}, _from, d = %Env{map: map}) do
    {:reply, Map.get(map, key, default), d}
  end

  def handle_call({:pop, key, default}, _from, d = %Env{map: map}) do
    {value, map} = Map.pop(map, key, default)
    {:reply, value, %Env{d | map: map}}
  end

  def handle_call({:put, key, value}, _from, d = %Env{map: map, waiters: waiters}) do
    {froms, waiters} = Map.pop(waiters, key, [])
    Enum.each(froms, fn from -> GenServer.reply(from, value) end)
    {:reply, Map.get(map, key), %Env{d | map: Map.put(map, key, value), waiters: waiters}}
  end

  def handle_call({:await, key}, from, d = %Env{map: map, waiters: waiters}) do
    if Map.has_key?(map, key) do
      {:reply, Map.get(map, key), d}
    else
      waiters = Map.update(waiters, key, [from], fn froms -> [from | froms] end)
      {:noreply, %Env{d | waiters: waiters}}
    end
  end

  def handle_call({:connect, object, command, callback, id}, _from, d) do
    opts = [{:callback, fn _, _ -> callback.() end}]
    opts = if id == nil, do: opts, else: [{:id, id} | opts]
    ret = :wxMenu.connect(object, command, opts)
    {:reply, ret, d}
  end

  @impl true
  def handle_cast({:register_window, window}, state = %Env{windows: windows}) do
    Process.monitor(window)
    {:noreply, %Env{state | windows: [window | windows]}}
  end

  @impl true
  def handle_info({:reopen_app, []}, state = %Env{windows: windows}) do
    # Handling MacOS event when the app icon is clicked again
    case windows do
      [window | _] ->
        # Avoiding constant reopen loops
        Debouncer.immediate2({Desktop, :reopen}, fn -> Desktop.Window.show(window) end, 500)

      [] ->
        :nothing
    end

    {:noreply, state}
  end

  # Reconnect is a mobile Bridge specific event issued
  # when the Server Sockets need to be re-restablished
  def handle_info(:reconnect, state = %Env{}) do
    # There seems to be an iOS bug where listening ports
    # are "zombied" after hibernation and not restarted
    # correctly. To rectify this situation we're re-creating them
    # all here

    if Desktop.OS.type() == IOS do
      for endpoint <- endpoints() do
        IO.puts("reconnect: #{inspect(endpoint)}")

        if Kernel.function_exported?(:ranch, :suspend_listener, 1) do
          apply(:ranch, :suspend_listener, [endpoint])
          apply(:ranch, :resume_listener, [endpoint])
        end
      end
    end

    {:noreply, state}
  end

  def handle_info({_mac_event, list} = e, state = %Env{subs: subs, events: events})
      when is_list(list) do
    if subs == [] do
      {:noreply, %Env{state | events: events ++ [e]}}
    else
      for sub <- subs do
        send(sub, e)
      end

      {:noreply, state}
    end
  end

  def handle_info(
        {:DOWN, _ref, :process, pid, _reason},
        state = %Env{subs: subs, windows: windows}
      ) do
    {:noreply, %Env{state | windows: windows -- [pid], subs: subs -- [pid]}}
  end

  @doc false
  def endpoints() do
    case :ets.whereis(:ranch_server) do
      :undefined ->
        []

      tid ->
        :ets.tab2list(tid)
        |> Map.new()
        |> Map.keys()
        |> Enum.filter(fn
          {:addr, _endpoint} -> true
          _ -> false
        end)
        |> Enum.map(fn {:addr, endpoint} -> endpoint end)
    end
  end

  @doc """
  Returns the raw ExSni handle if used under linux to talk to DBus.
  """
  def sni() do
    GenServer.call(__MODULE__, :sni)
  end

  @doc """
  Returns the wx object. This is what has been created by the application using `:wx.new/1`. You
  typically need a reference to this to execute raw `:wx` commands.
  """
  def wx() do
    GenServer.call(__MODULE__, :wx)
  end

  @doc """
  Gets the Desktop process's current raw `:wx` environment.
  Can be sent to other processes to allow them use this process wx environment.

  ## Example

      iex> :wx.set_env(Desktop.Env.wx_env())
      iex> :wxWebView.isContextMenuEnabled(Desktop.Window.webview(pid))
      false
  """
  def wx_env() do
    GenServer.call(__MODULE__, :wx_env)
  end

  @doc """
  Shortcut for `:wx.set_env(Desktop.Env.wx_env())`
  """
  def wx_use_env() do
    with env when env != nil <- wx_env() do
      :wx.set_env(env)
    end
  end

  @doc false
  def connect(object, command, callback, id \\ nil) do
    GenServer.call(__MODULE__, {:connect, object, command, callback, id})
  end

  @doc false
  def put(key, value) do
    GenServer.call(__MODULE__, {:put, key, value})
  end

  @doc false
  def get(key, default \\ nil) do
    GenServer.call(__MODULE__, {:get, key, default})
  end

  @doc false
  def pop(key, default \\ nil) do
    GenServer.call(__MODULE__, {:pop, key, default})
  end

  @doc false
  def await(key) do
    GenServer.call(__MODULE__, {:await, key})
  end

  @doc """
    Wrapper around wx.subscribe()

    Will send to the calling process events in the form:

    * `{:print_file, [filename]}`
    * `{:open_file, [filename]}`
    * `{:open_url, [filename]}`
    * `{:new_file, []}`
  """
  def subscribe() do
    GenServer.call(__MODULE__, {:subscribe, self()})
  end

  defp init_sni() do
    # We're protecting the main application from any possible
    # side effects of the SNI startup using a monitored (not linked) process:
    {task, ref} = spawn_monitor(fn -> exit(do_init_sni()) end)

    receive do
      {:DOWN, ^ref, :process, ^task, {:ok, pid}} ->
        pid

      {:DOWN, ^ref, :process, ^task, :not_supported} ->
        nil

      {:DOWN, ^ref, :process, ^task, reason} ->
        Logger.error("ExSNI.start crashed: #{inspect(reason)}")
        nil
    end
  end

  defp do_init_sni() do
    cond do
      Desktop.OS.type() != Linux ->
        :not_supported

      System.get_env("USE_DBUS_MENU", nil) == "false" ->
        :not_supported

      true ->
        case ExSni.start_link() do
          {:ok, pid} ->
            if ExSni.is_supported?(pid) do
              Process.unlink(pid)
              {:ok, pid}
            else
              ExSni.close(pid)
              :not_supported
            end

          error ->
            error
        end
    end
  end
end