lib/menu/server.ex

defmodule ExSni.Menu.Server do
  @moduledoc false

  use GenServer

  alias ExSni.Menu

  defmodule SignalsState do
    defstruct layout_updated: {0, false},
              items_properties_updated: {0, false},
              activation_request: {0, false}

    @type signal_tracking() ::
            {last_requested_timestamp :: non_neg_integer(), queued? :: boolean()}
    @type t() :: %__MODULE__{
            layout_updated: signal_tracking(),
            items_properties_updated: signal_tracking(),
            activation_request: signal_tracking()
          }
  end

  defmodule State do
    defstruct menu: %Menu{version: 1},
              backup_server: nil,
              dbus_service: nil,
              menu_queue: []

    @type method_tracking() ::
            {last_requested_timestamp :: non_neg_integer(), payload :: any()}
    @type t() :: %__MODULE__{
            menu: Menu.t(),
            backup_server: nil | GenServer.server(),
            dbus_service: nil | GenServer.server(),
            menu_queue: list(Menu.t())
          }
  end

  def start_link(opts, gen_opts \\ []) do
    GenServer.start_link(
      __MODULE__,
      opts,
      gen_opts
    )
  end

  @impl true
  def init(options) do
    backup_server = Keyword.get(options, :backup_server)

    init_menu =
      case Keyword.get(options, :menu, nil) do
        %Menu{} = menu -> menu
        _ -> %Menu{version: 1}
      end

    state =
      %State{
        menu: init_menu,
        dbus_service: Keyword.get(options, :dbus_service, nil),
        backup_server: backup_server
      }
      |> restore_backup_menu()

    {:ok, state}
  end

  def method(server_pid, name, arguments) do
    GenServer.call(server_pid, {:method, name, arguments})
  end

  def get_property(server_pid, property_name) do
    GenServer.call(server_pid, {:get_property, property_name})
  end

  def set(server_pid, menu) do
    GenServer.cast(server_pid, {:set, menu})
  end

  def get(server_pid) do
    GenServer.call(server_pid, :get)
  end

  def reset(server_pid) do
    GenServer.cast(server_pid, {:set, nil})
  end

  # GenServer implementations

  @impl true
  def handle_cast({:set, menu}, state) do
    state =
      case add_menu_to_queue(state, menu) do
        %{menu_queue: [_ | _]} = state ->
          queue_menu_update(state)

        _ ->
          state
      end

    {:noreply, state}
  end

  @impl true
  def handle_call(:get, _from, %{menu: menu} = state) do
    {:reply, {:ok, menu}, state}
  end

  def handle_call({:method, method_name, arguments}, from, state) do
    handle_method(method_name, arguments, from, state)
  end

  def handle_call(
        {:get_property, property_name},
        _from,
        %{menu: menu} = state
      ) do
    ret = ExSni.DbusProtocol.get_property(menu, property_name)
    {:reply, ret, state}
  end

  def handle_call(
        {:get_property, _property_name},
        _from,
        state
      ) do
    {:reply, {:error, "org.freedesktop.DBus.Error.UnknownProperty", "Invalid property"}, state}
  end

  @impl true
  def handle_info(:menu_update, state) do
    case do_menu_update(state) do
      {:skip, state} ->
        {:noreply, state}

      {:ok, state} ->
        {:noreply, state}
    end
  end

  def handle_info(
        {:signal_layout_updated, [version, item_id]},
        %{dbus_service: dbus_service} = state
      ) do
    Debouncer.immediate(
      {__MODULE__, self(), "LayoutUpdated"},
      fn ->
        send_dbus_signal(dbus_service, "LayoutUpdated", [version, item_id])
      end,
      3000
    )

    {:noreply, state}
  end

  def handle_info(
        {:signal_items_properties_updated, [properties_updated, properties_removed]},
        %{dbus_service: dbus_service} = state
      ) do
    send_dbus_signal(dbus_service, "ItemsPropertiesUpdated", [
      properties_updated,
      properties_removed
    ])

    {:noreply, state}
  end

  def handle_info(_, state) do
    {:noreply, state}
  end

  # Private methods

  # Menu queue is empty, so current menu is still valid
  # Return that we want to skip any dbus menu updates
  defp do_menu_update(%{menu_queue: []} = state) do
    {:skip, state}
  end

  # Current menu is nil and a reset is queued
  # Skip the reset and try again
  defp do_menu_update(%{menu: %Menu{root: nil}, menu_queue: [:reset | menu_queue]} = state) do
    state
    |> set_menu_queue(menu_queue)
    |> do_menu_update()
  end

  # There is a reset pending, and the current menu is not empty
  # Action on the reset.
  defp do_menu_update(
         %{menu: %Menu{version: old_version} = old_menu, menu_queue: [:reset | menu_queue]} =
           state
       ) do
    # Increment the version of an empty menu
    menu = %Menu{old_menu | root: nil, version: old_version + 1}

    # Update the queue
    # Send a LayoutUpdated signal, so that D-Bus can fetch the new empty menu

    {:ok,
     state
     |> set_current_menu(menu)
     |> set_menu_queue(menu_queue)}
  end

  # Current menu is nil, no reset pending and full new menu
  defp do_menu_update(
         %{menu: %Menu{version: old_version}, menu_queue: [{:nodiff, new_menu}]} = state
       ) do
    if menu_empty?(new_menu) do
      {:skip, set_menu_queue(state, [])}
    else
      menu = %Menu{new_menu | version: old_version + 1}
      # Update the queue
      # Send a LayoutUpdated signal, so that D-Bus can fetch the new empty menu
      {:ok,
       state
       |> set_current_menu(menu)
       |> set_menu_queue([])}
    end
  end

  # No reset pending, just a possible menu update
  defp do_menu_update(
         %{
           menu: %Menu{version: old_version},
           menu_queue: [%Menu{} = new_menu]
         } = state
       ) do
    if menu_empty?(new_menu) do
      {:skip, set_menu_queue(state, [])}
    else
      menu = %Menu{new_menu | version: old_version + 1}
      # Update the queue
      # Send a LayoutUpdated signal, so that D-Bus can fetch the new empty menu
      {:ok,
       state
       |> set_current_menu(menu)
       |> set_menu_queue([])}
    end
  end

  defp queue_menu_update(state) do
    pid = self()

    Debouncer.immediate(
      {__MODULE__, pid, :menu_update},
      fn ->
        send(pid, :menu_update)
      end,
      1000
    )

    state
  end

  defp trigger_layout_update_signal(%{menu: %Menu{version: version}} = state, item_id \\ 0) do
    send(self(), {:signal_layout_updated, [version, item_id]})
    state
  end

  defp send_dbus_signal(service_pid, "LayoutUpdated" = signal, args) do
    service_send_signal(service_pid, signal, {"ui", [:uint32, :int32], args})
  end

  defp send_dbus_signal(service_pid, "ItemsPropertiesUpdated" = signal, args) do
    service_send_signal(service_pid, signal, {
      "a(ia{sv})a(ias)",
      [
        {:array, {:struct, [:int32, {:dict, :string, :variant}]}},
        {:array, {:struct, [:int32, {:array, :string}]}}
      ],
      args
    })
  end

  defp service_send_signal(service_pid, signal, args) do
    # IO.inspect([signal, args], label: "Sending D-Bus signal", limit: :infinity)

    ExDBus.Service.send_signal(
      service_pid,
      "/MenuBar",
      "com.canonical.dbusmenu",
      signal,
      args
    )
  end

  # Method handling

  defp handle_method("GetLayout", {parentId, depth, properties}, _from, state) do
    {:reply, method_reply("GetLayout", {parentId, depth, properties}, state), state}
  end

  defp handle_method("GetGroupProperties", {ids, properties}, _from, state) do
    {:reply, method_reply("GetGroupProperties", {ids, properties}, state), state}
  end

  # This is called by the applet to notify the application
  # that it is about to show the menu under the specified item.

  # Params:
  #   - id::uint32 - Which menu item represents
  #                 the parent of the item about to be shown.
  # Returns:
  #   - needUpdate::boolean() - Whether this AboutToShow event
  #                   should result in the menu being updated.
  defp handle_method("AboutToShow", id, _from, state) do
    {:reply, method_reply("AboutToShow", id, state), state}
  end

  # This is called by the applet to notify the application
  # an event happened on a menu item.

  # Params:
  #   - id::uint32      - the id of the item which received the event
  #   - eventId::string - the type of event
  #           ("clicked", "hovered", "opened", "closed")
  #   - data::variant   - event-specific data
  #   - timestamp::uint32 - The time that the event occured if available
  #         or the time the message was sent if not
  # Returns:
  #   - needUpdate::boolean() - Whether this AboutToShow event
  #                   should result in the menu being updated.
  defp handle_method("Event", {id, eventId, data, timestamp}, _from, state) do
    {:reply, method_reply("Event", {id, eventId, data, timestamp}, state), state}
  end

  # Reject all other method calls
  defp handle_method(_method, _arguments, _from, state) do
    {:reply, :skip, state}
  end

  defp method_reply("GetLayout", {parentId, depth, properties}, %{menu: menu}) do
    # IO.inspect({parentId, depth, properties},
    #   label: "[#{System.os_time(:millisecond)}] [ExSni][Menu.Server] GetLayout"
    # )

    Menu.get_layout(menu, parentId, depth, properties)
    # |> IO.inspect(label: "GetLayout reply", limit: :infinity)
  end

  defp method_reply("GetGroupProperties", {ids, properties}, %{menu: menu}) do
    # IO.inspect({ids, properties},
    #   label: "[#{System.os_time(:millisecond)}] [ExSni][Menu.Server] GetGroupProperties"
    # )

    result = Menu.get_group_properties(menu, ids, properties)
    # |> IO.inspect(label: "GetGroupProperties reply", limit: :infinity)

    {:ok, [{:array, {:struct, [:int32, {:dict, :string, :variant}]}}], [result]}
  end

  defp method_reply("AboutToShow", id, %{menu: menu}) do
    ret = Menu.onAboutToShow(menu, id)
    {:ok, [:boolean], [ret]}
  end

  defp method_reply("Event", {id, eventId, data, timestamp}, %{menu: menu}) do
    # IO.inspect({id, eventId, data, timestamp},
    #   label: "[#{System.os_time(:millisecond)}] [ExSni][Menu.Server] Menu OnEvent"
    # )

    Menu.onEvent(menu, eventId, id, data, timestamp)
    {:ok, [], []}
  end

  defp method_reply(_, _, _) do
    :skip
  end

  # Other private functions

  # A menu queue (`menu_queue`) holds the next menus to process
  # Queueing a menu will always take care of pruning the menu queue so that
  # it would only hold the bare minimum to process on next round.
  #
  # There are only 3 types of queued menus:
  # - An empty menu (where root is nil, or the root element has no children)
  # - A menu that holds items
  # - :reset - a special entry that means we need to force sending an empty menu to DBus
  # so that it would clear up the current menu. This entry must never be removed from the queue
  # once it has been set. It can only be consumed by menu processing.
  #
  # The queue is always either empty or contains two queued entries at most:
  # - 0 entries:  No changes queued; the current dbus menu
  #               can be either in a nil/cleared state or have items
  # - 1 entry:    One change queued
  # - 2 entries:  This is always going to be a `:reset` followed by a non-empty menu
  #               menu_queue = [:reset, non_empty_menu]
  #
  # Queuing the menus with the below functions,
  # must always ensure the correct format for the menu_queue.
  # Never store multiple :reset entries in the queue.
  #
  # If for example, menu_queue == [:reset] then we never remove it from the queue
  # and never push empty menus or further resets after it.
  # If menu_queue == [:reset, non_empty_menu] then if we want to queue an empty menu
  # we just take the non_empty_menu out, leaving menu_queue = [:reset]. However, when
  # the menu we want to queue is not an empty menu, we only replace the non_empty_menu entry
  # leaving menu_queue = [:reset, new_non_empty_menu]
  # If menu_queue == [non_empty_menu] we replace it with whatever we want to queue,
  # setting menu_queue = [new_menu], regardless if the new menu is empty, non-empty or a reset

  # Handle queueing :reset
  defp add_menu_to_queue(%{menu_queue: _queue, menu: _current_menu} = state, :reset) do
    # Reset is very straight-forward when queueing.

    # Clear the queue and add :reset to it
    set_menu_queue(state, [:reset])
  end

  # Handling of empty menus that do not have root: nil, but have a root without children

  defp add_menu_to_queue(state, %Menu{root: %Menu.Item{type: :root, children: []}} = new_menu) do
    # Menu is actually empty, because there are no children in the root item
    # Forward it as %Menu{root: nil}
    add_menu_to_queue(state, {:nodiff, new_menu})
  end

  defp add_menu_to_queue(
         state,
         {:nodiff, %Menu{root: %Menu.Item{type: :root, children: []}} = new_menu}
       ) do
    # Menu is actually empty, because there are no children in the root item
    # Forward it as %Menu{root: nil}
    add_menu_to_queue(state, {:nodiff, %{new_menu | root: nil}})
  end

  # Handle queueing nil menu
  defp add_menu_to_queue(
         state,
         %Menu{root: nil} = empty_menu
       ) do
    add_menu_to_queue(state, {:nodiff, empty_menu})
  end

  defp add_menu_to_queue(
         %{menu_queue: queue, menu: current_menu} = state,
         {:nodiff, %Menu{root: nil} = empty_menu}
       ) do
    case queue do
      [] ->
        # Handle empty queue
        if menu_empty?(current_menu) do
          # Do not update D-Bus with empty menus if the current D-Bus menu is already empty.
          state
        else
          set_menu_queue(state, [{:nodiff, empty_menu}])
        end

      [:reset] ->
        # The reset will also clear the current menu, just as the empty menu would do,
        # but it's an enforced clear that we need to keep
        state

      [%Menu{}] ->
        if menu_empty?(current_menu) do
          # The current menu is already cleared, so clear the queue
          set_menu_queue(state, [])
        else
          # The current menu is non-empty. Queue clearing the menu
          set_menu_queue(state, [{:nodiff, empty_menu}])
        end

      [{:nodiff, %Menu{}}] ->
        if menu_empty?(current_menu) do
          # The current menu is already cleared, so clear the queue
          set_menu_queue(state, [])
        else
          # The current menu is non-empty. Queue clearing the menu
          set_menu_queue(state, [{:nodiff, empty_menu}])
        end

      [_, :reset] ->
        # If there is a reset queued, regardless of the next item in queue,
        # because we ask to update to an empty menu, keep the :reset only
        set_menu_queue(state, [:reset])
    end
  end

  # Queue non-empty menu when the queue is empty
  defp add_menu_to_queue(%{menu_queue: []} = state, {:nodiff, %Menu{} = new_menu}) do
    # Just add it to the queue
    set_menu_queue(state, [{:nodiff, new_menu}])
  end

  defp add_menu_to_queue(%{menu_queue: []} = state, %Menu{} = new_menu) do
    # Just add it to the queue
    set_menu_queue(state, [new_menu])
  end

  # Queue non-empty menu when there's a reset in the queue
  defp add_menu_to_queue(%{menu_queue: [:reset | _]} = state, {:nodiff, %Menu{} = new_menu}) do
    set_menu_queue(state, [:reset, {:nodiff, new_menu}])
  end

  defp add_menu_to_queue(%{menu_queue: [:reset | _]} = state, %Menu{} = new_menu) do
    set_menu_queue(state, [:reset, new_menu])
  end

  # Queue non-empty menu when there's another menu in the queue
  defp add_menu_to_queue(%{menu_queue: [_]} = state, {:nodiff, %Menu{} = new_menu}) do
    # There is a non-empty menu queued. Replace it
    set_menu_queue(state, [{:nodiff, new_menu}])
  end

  defp add_menu_to_queue(%{menu_queue: [_]} = state, %Menu{} = new_menu) do
    # There is a non-empty menu queued. Replace it
    set_menu_queue(state, [new_menu])
  end

  defp set_current_menu(
         %State{menu: %{item_cache: items} = old_menu} = state,
         %Menu{root: root} = menu
       ) do
    root = strip_menu(root)

    if old_menu != nil and strip_menu(old_menu.root) == root do
      state
    else
      {last_id, version} =
        case old_menu do
          %Menu{last_id: last_id, version: version} -> {max(last_id, 100), version}
          nil -> {100, 1}
        end

      cache = Enum.map(items, fn {item, _time} -> {strip_menu(item), item} end) |> Map.new()
      {root, last_id} = update_menu(root, last_id, cache)

      menu = %Menu{menu | version: version + 1, last_id: last_id, root: root}
      now = System.os_time(:millisecond)

      new_items =
        Menu.get_children(old_menu)
        |> Enum.map(fn item -> {item, now} end)
        |> Map.new()

      items =
        Enum.reject(items, fn {_, time} -> now - time > 10_000 end)
        |> Map.new()
        |> Map.merge(new_items)

      backup_current_menu(%State{state | menu: Map.put(menu, :item_cache, items)})
      |> trigger_layout_update_signal()
    end
  end

  defp update_menu(%Menu.Item{children: children} = item, id, cache) do
    {children, id} =
      Enum.map_reduce(children, id, fn item = %Menu.Item{type: type}, id ->
        case cache[item] do
          nil ->
            id = id + 1
            item = Menu.Item.set_id(item, id)

            if type == :menu do
              update_menu(item, id, cache)
            else
              {item, id}
            end

          old_item ->
            {old_item, id}
        end
      end)

    {%Menu.Item{item | children: children}, id}
  end

  defp strip_menu(nil), do: nil

  defp strip_menu(%Menu.Item{children: children} = item) do
    children =
      Enum.map(children, fn item = %Menu.Item{type: type} ->
        item = Menu.Item.set_id(item, nil)

        if type == :menu do
          strip_menu(item)
        else
          item
        end
      end)

    %Menu.Item{item | children: children}
  end

  defp restore_backup_menu(%{backup_server: nil} = state) do
    state
  end

  defp restore_backup_menu(%{backup_server: server, menu: init_menu} = state) do
    try do
      menu = GenServer.call(server, {:restore_menu, init_menu})
      %{state | menu: menu}
    rescue
      _error -> state
    end
  end

  defp backup_current_menu(%{backup_server: nil} = state) do
    state
  end

  defp backup_current_menu(%{backup_server: server, menu: menu} = state) do
    try do
      GenServer.cast(server, {:save_menu, menu})
    rescue
      _ -> :ok
    end

    state
  end

  defp set_menu_queue(state, queue) do
    %{state | menu_queue: queue}
  end

  defp menu_empty?(nil) do
    true
  end

  defp menu_empty?({:nodiff, menu}) do
    menu_empty?(menu)
  end

  defp menu_empty?(%Menu{root: nil}) do
    true
  end

  defp menu_empty?(%Menu{root: %Menu.Item{type: :root, children: []}}) do
    true
  end

  defp menu_empty?(%Menu{root: %Menu.Item{}}) do
    false
  end
end