defmodule Desktop.Window do
@moduledoc ~S"""
Defines a Desktop Window.
The window hosts a Phoenix Endpoint and displays its content.
It should be part of a supervision tree and is the main interface
to interact with your application.
In total the window is doing:
* Displaying the endpoint content
* Hosting and starting an optional menu bar
* Controlling a taskbar icon if present
## The Window
You can add the Window to your own Supervision tree:
children = [{
Desktop.Window,
[
app: :your_app,
id: YourAppWindow,
title: "Your App Title",
size: {600, 500},
icon: "icon.png",
menubar: YourApp.MenuBar,
icon_menu: YourApp.Menu,
url: fn -> YourAppWeb.Router.Helpers.live_url(YourAppWeb.Endpoint, YourAppWeb.YourAppLive) end
]
}]
### Window configuration
In order to change the appearance of the application window these options can be defined:
* `:app` - your app name within which the Window is running.
* `:id` - an atom identifying the window. Can later be used to control the
window using the functions of this module.
* `:title` - the window title that will be show initially. The window
title can be set later using `set_title/2`.
* `:size` - the initial windows size in pixels {width, height}.
* `:hidden` - whether the window should be initially hidden defaults to false,
but is ignored on mobile platforms
Possible values are:
* `false` - Show the window on startup (default)
* `true` - Don't show the window on startup
* `:icon` - an icon file name that will be used as taskbar and
window icon. Supported formats are png files
* `:menubar` - an optional MenuBar module that will be rendered
as the windows menu bar when given.
* `:icon_menu` - an optional MenuBar module that will be rendered
as menu onclick on the taskbar icon.
* `:url` - a callback to the initial (default) url to show in the
window.
"""
alias Desktop.{OS, Window, Wx, Menu, Fallback}
require Logger
use GenServer
@enforce_keys [:frame]
defstruct [
:module,
:taskbar,
:frame,
:notifications,
:webview,
:home_url,
:last_url,
:title,
:rebuild,
:rebuild_timer
]
@doc false
def child_spec(opts) do
app = Keyword.fetch!(opts, :app)
id = Keyword.fetch!(opts, :id)
%{
id: id,
start: {__MODULE__, :start_link, [opts ++ [app: app, id: id]]}
}
end
@doc false
def start_link(opts) do
id = Keyword.fetch!(opts, :id)
{_ref, _num, _type, pid} = :wx_object.start_link({:local, id}, __MODULE__, opts, [])
{:ok, pid}
end
@impl true
@doc false
def init(options) do
window_title = options[:title] || Atom.to_string(options[:id])
size = options[:size] || {600, 500}
app = options[:app]
icon = options[:icon]
# not supported on mobile atm
menubar = unless OS.mobile?(), do: options[:menubar]
icon_menu = unless OS.mobile?(), do: options[:icon_menu]
hidden = unless OS.mobile?(), do: options[:hidden]
url = options[:url]
env = Desktop.Env.wx_env()
GenServer.cast(Desktop.Env, {:register_window, self()})
:wx.set_env(env)
frame =
:wxFrame.new(Desktop.Env.wx(), Wx.wxID_ANY(), window_title, [
{:size, size},
{:style, Wx.wxDEFAULT_FRAME_STYLE()}
])
:wxFrame.connect(frame, :close_window,
callback: &close_window/2,
userData: self()
)
:wxFrame.setSizer(frame, :wxBoxSizer.new(Wx.wxHORIZONTAL()))
{:ok, icon} =
case icon do
nil -> {:ok, :wxArtProvider.getIcon("wxART_EXECUTABLE_FILE")}
filename -> Desktop.Image.new_icon(app, filename)
end
:wxTopLevelWindow.setIcon(frame, icon)
wx_menubar =
if menubar do
{:ok, menu_pid} =
Menu.start_link(
module: menubar,
app: app,
env: env,
wx: :wxMenuBar.new()
)
wx_menubar = Menu.menubar(menu_pid)
:wxFrame.setMenuBar(frame, wx_menubar)
wx_menubar
end
if OS.type() == MacOS do
update_apple_menu(window_title, frame, wx_menubar || :wxMenuBar.new())
end
taskbar =
if icon_menu do
sni_link = Desktop.Env.sni()
adapter = if sni_link == nil, do: nil, else: Menu.Adapter.DBus
{:ok, menu_pid} =
Menu.start_link(
module: icon_menu,
app: app,
adapter: adapter,
env: env,
sni: sni_link,
icon: icon,
wx: {:taskbar, icon}
)
menu_pid
end
timer =
if OS.type() == Windows do
{:ok, timer} = :timer.send_interval(500, :rebuild)
timer
end
ui = %Window{
frame: frame,
webview: Fallback.webview_new(frame),
notifications: %{},
home_url: url,
title: window_title,
taskbar: taskbar,
rebuild: 0,
rebuild_timer: timer
}
if hidden != true do
show(self(), url)
end
{frame, ui}
end
@doc """
Returns the url currently shown of the Window.
* `pid` - The pid or atom of the Window
## Examples
iex> Desktop.Window.url(pid)
http://localhost:1234/main
"""
def url(pid) do
GenServer.call(pid, :url)
end
@doc """
Show the Window if not visible with the given url.
* `pid` - The pid or atom of the Window
* `url` - The endpoint url to show. If non is provided
the url callback will be used to get one.
## Examples
iex> Desktop.Window.show(pid, "/")
:ok
"""
def show(pid, url \\ nil) do
GenServer.cast(pid, {:show, url})
end
@doc """
Hide the Window if visible (noop on mobile platforms)
* `pid` - The pid or atom of the Window
## Examples
iex> Desktop.Window.hide(pid)
:ok
"""
def hide(pid) do
GenServer.cast(pid, :hide)
end
@doc """
Returns true if the window is hidden. Always returns false
on mobile platforms.
* `pid` - The pid or atom of the Window
## Examples
iex> Desktop.Window.is_hidden?(pid)
false
"""
def is_hidden?(pid) do
GenServer.call(pid, :is_hidden?)
end
@doc """
Set the windows title
* `pid` - The pid or atom of the Window
* `title` - The new windows title
## Examples
iex> Desktop.Window.set_title(pid, "New Window Title")
:ok
"""
def set_title(pid, title) do
GenServer.cast(pid, {:set_title, title})
end
@doc """
Iconize or restore the window
* `pid` - The pid or atom of the Window
* `restore` - Optional defaults to false whether the
window should be restored
"""
def iconize(pid, iconize \\ true) do
GenServer.cast(pid, {:iconize, iconize})
end
@doc """
Rebuild the webview. This function is a troubleshooting
function at this time. On Windows it's sometimes necessary
to rebuild the WebView2 frame.
* `pid` - The pid or atom of the Window
## Examples
iex> Desktop.Window.rebuild_webview(pid)
:ok
"""
def rebuild_webview(pid) do
GenServer.cast(pid, :rebuild_webview)
end
@doc """
Fetch the underlying :wxWebView instance object. Call
this if you have to use more advanced :wxWebView functions
directly on the object.
* `pid` - The pid or atom of the Window
## Examples
iex> :wx.set_env(Desktop.Env.wx_env())
iex> :wxWebView.isContextMenuEnabled(Desktop.Window.webview(pid))
false
"""
def webview(pid) do
GenServer.call(pid, :webview)
end
@doc """
Fetch the underlying :wxFrame instance object. This represents
the window which the webview is drawn into.
* `pid` - The pid or atom of the Window
## Examples
iex> :wx.set_env(Desktop.Env.wx_env())
iex> :wxWindow.show(Desktop.Window.frame(pid), show: false)
false
"""
def frame(pid) do
GenServer.call(pid, :frame)
end
@doc """
Show a desktop notification
* `pid` - The pid or atom of the Window
* `text` - The text content to show in the notification
* `opts` - Additional notification options
Valid keys are:
* `:id` - An id for the notification, this is important if you
want control, the visibility of the notification. The default
value when none is provided is `:default`
* `:type` - One of `:info` `:error` `:warn` these will change
how the notification will be displayed. The default is `:info`
* `:title` - An alternative title for the notificaion,
when none is provided the current window title is used.
* `:timeout` - A timeout hint specifying how long the notification
should be displayed.
Possible values are:
* `:auto` - This is the default and let's the OS decide
* `:never` - Indicates that notification should not be hidden
automatically
* ms - A time value in milliseconds, how long the notification
should be shown
* `:callback` - A function to be executed when the user clicks on the
notification.
## Examples
iex> :wx.set_env(Desktop.Env.wx_env())
iex> :wxWebView.isContextMenuEnabled(Desktop.Window.webview(pid))
false
"""
def show_notification(pid, text, opts \\ []) do
id = Keyword.get(opts, :id, :default)
type =
case Keyword.get(opts, :type, :info) do
:info -> :info
:error -> :error
:warn -> :warning
:warning -> :warning
end
title = Keyword.get(opts, :title, nil)
timeout =
case Keyword.get(opts, :timeout, :auto) do
:auto -> -1
:never -> 0
ms when is_integer(ms) -> ms
end
callback = Keyword.get(opts, :callback, nil)
GenServer.cast(pid, {:show_notification, text, id, type, title, callback, timeout})
end
@doc """
Quit the application. This forces a quick termination which can
be helpful on MacOS/Windows as sometimes the destruction is
crashing.
"""
def quit() do
OS.shutdown()
end
require Record
for tag <- [:wx, :wxCommand, :wxClose] do
Record.defrecordp(tag, Record.extract(tag, from_lib: "wx/include/wx.hrl"))
end
@doc false
def handle_event(wx(event: {:wxWebView, :webview_newwindow, _, _, _target, url}), ui) do
:wx_misc.launchDefaultBrowser(url)
{:noreply, ui}
end
def handle_event(wx(id: id, event: wxCommand(type: :command_menu_selected)), ui) do
if id == Wx.wxID_EXIT() do
quit()
end
{:noreply, ui}
end
def handle_event(wx(obj: obj, event: wxCommand(type: :notification_message_click)), ui) do
notification(ui, obj, :click)
{:noreply, ui}
end
def handle_event(wx(obj: obj, event: wxCommand(type: :notification_message_dismissed)), ui) do
notification(ui, obj, :dismiss)
if OS.type() == Linux do
notification(ui, obj, :action)
else
notification(ui, obj, :dismiss)
end
{:noreply, ui}
end
def handle_event(
wx(obj: obj, event: wxCommand(commandInt: action, type: :notification_message_action)),
ui
) do
notification(ui, obj, {:action, action})
{:noreply, ui}
end
defp notification(%Window{notifications: noties}, obj, action) do
case Enum.find(noties, fn {_, {wx_ref, _callback}} -> wx_ref == obj end) do
nil ->
Logger.error(
"Received unhandled notification event #{inspect(obj)}: #{inspect(action)} (#{
inspect(noties)
})"
)
{_, {_ref, nil}} ->
:ok
{_, {_ref, callback}} ->
spawn(fn -> callback.(action) end)
end
end
@impl true
@doc false
def handle_info(:rebuild, ui = %Window{rebuild: rebuild, rebuild_timer: t, webview: webview}) do
ui =
if Fallback.webview_can_fix(webview) do
case rebuild do
0 ->
%Window{ui | rebuild: 1}
1 ->
:timer.cancel(t)
%Window{ui | rebuild: :done, webview: Fallback.webview_rebuild(ui)}
:done ->
ui
end
else
if rebuild == :done do
ui
else
%Window{ui | rebuild: 0}
end
end
{:noreply, ui}
end
def close_window(wx(userData: pid), inev) do
# if we don't veto vetoable events on MacOS the app freezes.
if :wxCloseEvent.canVeto(inev) do
:wxCloseEvent.veto(inev)
end
GenServer.cast(pid, :close_window)
:ok
end
@impl true
@doc false
def handle_cast(:close_window, ui = %Window{frame: frame, taskbar: taskbar}) do
if not :wxFrame.isShown(frame) do
OS.shutdown()
end
if taskbar == nil do
:wxFrame.hide(frame)
{:stop, :normal, ui}
else
:wxFrame.hide(frame)
{:noreply, ui}
end
end
def handle_cast({:set_title, title}, ui = %Window{title: old, frame: frame}) do
if title != old and frame != nil do
:wxFrame.setTitle(frame, String.to_charlist(title))
end
{:noreply, %Window{ui | title: title}}
end
def handle_cast({:iconize, iconize}, ui = %Window{frame: frame}) do
:wxTopLevelWindow.iconize(frame, iconize: iconize)
{:noreply, ui}
end
def handle_cast(:rebuild_webview, ui) do
{:noreply, %Window{ui | webview: Fallback.webview_rebuild(ui)}}
end
def handle_cast(
{:show_notification, message, id, type, title, callback, timeout},
ui = %Window{notifications: noties, title: window_title}
) do
{n, _} =
note =
case Map.get(noties, id, nil) do
nil -> {Fallback.notification_new(title || window_title, type), callback}
{note, _} -> {note, callback}
end
Fallback.notification_show(n, message, timeout, title || window_title)
noties = Map.put(noties, id, note)
{:noreply, %Window{ui | notifications: noties}}
end
def handle_cast({:show, url}, ui = %Window{home_url: home, last_url: last}) do
new_url = prepare_url(url || last || home)
Logger.info("Showing #{new_url}")
Fallback.webview_show(ui, new_url, url == nil)
{:noreply, %Window{ui | last_url: new_url}}
end
@impl true
def handle_cast(:hide, ui = %Window{frame: frame}) do
if frame do
:wxWindow.hide(frame)
end
{:noreply, ui}
end
@impl true
@doc false
def handle_call(:is_hidden?, _from, ui = %Window{frame: frame}) do
ret =
if frame do
not :wxWindow.isShown(frame)
else
false
end
{:reply, ret, ui}
end
def handle_call(:url, _from, ui) do
ret =
case Fallback.webview_url(ui) do
url when is_list(url) -> List.to_string(url)
other -> other
end
{:reply, ret, ui}
end
def handle_call(:webview, _from, ui = %Window{webview: webview}) do
{:reply, webview, ui}
end
def handle_call(:frame, _from, ui = %Window{frame: frame}) do
{:reply, frame, ui}
end
def prepare_url(url) do
query = "k=" <> Desktop.Auth.login_key()
case url do
nil -> nil
fun when is_function(fun) -> append_query(fun.(), query)
string when is_binary(string) -> append_query(string, query)
end
end
defp append_query(url, query) do
case URI.parse(url) do
url = %URI{query: nil} ->
%URI{url | query: query}
url = %URI{query: other} ->
if String.contains?(other, query) do
url
else
%URI{url | query: other <> "&" <> query}
end
end
|> URI.to_string()
end
defp update_apple_menu(title, frame, menubar) do
menu = :wxMenuBar.oSXGetAppleMenu(menubar)
:wxMenu.setTitle(menu, title)
# Remove all items except for Quit since we don't yet handle the standard items
# like "Hide <app>", "Hide Others", "Show All", etc
for item <- :wxMenu.getMenuItems(menu) do
if :wxMenuItem.getId(item) == Wx.wxID_EXIT() do
:wxMenuItem.setText(item, "Quit #{title}\tCtrl+Q")
else
:wxMenu.delete(menu, item)
end
end
:wxFrame.connect(frame, :command_menu_selected)
end
end