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