Add taskbar icon to desktop app (#1119)

This commit is contained in:
Wojtek Mach 2022-04-21 09:51:48 +02:00 committed by GitHub
parent 88f3f8fc97
commit e5092e4c99
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 310 additions and 135 deletions

View file

@ -111,7 +111,7 @@ defmodule AppBuilder.MacOS do
File.mkdir_p!("tmp")
launcher_src_path = "tmp/Launcher.swift"
File.write!(launcher_src_path, launcher(additional_paths))
File.write!(launcher_src_path, launcher(release, additional_paths))
launcher_path = Path.join([app_bundle_path, "Contents", "MacOS", app_name <> "Launcher"])
File.mkdir_p!(Path.dirname(launcher_path))
@ -133,7 +133,7 @@ defmodule AppBuilder.MacOS do
release
end
defp launcher(additional_paths) do
defp launcher(release, additional_paths) do
additional_paths = Enum.map_join(additional_paths, ":", &"\\(resourcePath)#{&1}")
"""
@ -149,7 +149,7 @@ defmodule AppBuilder.MacOS do
let logFile = FileHandle(forUpdatingAtPath: logPath)
logFile?.seekToEndOfFile()
let releaseScriptPath = Bundle.main.path(forResource: "rel/bin/mac_app", ofType: "")!
let releaseScriptPath = Bundle.main.path(forResource: "rel/bin/#{release.name}", ofType: "")!
let resourcePath = Bundle.main.resourcePath ?? ""
let additionalPaths = "#{additional_paths}"

View file

@ -4,6 +4,29 @@ defmodule AppBuilder.Windows do
import AppBuilder.Utils
require EEx
def __send_events__(server, input)
def __send_events__(server, "new_file") do
send(server, {:new_file, ''})
end
def __send_events__(server, "reopen_app") do
send(server, {:reopen_app, ''})
end
def __send_events__(server, "open_url:" <> url) do
send(server, {:open_url, String.to_charlist(url)})
end
def __send_events__(server, "open_file:" <> path) do
path =
path
|> String.replace("\\", "/")
|> String.to_charlist()
send(server, {:open_file, path})
end
@doc """
Creates a Windows installer.
"""
@ -36,6 +59,9 @@ defmodule AppBuilder.Windows do
erl_exe = Path.join([tmp_dir, "rel", "erts-#{release.erts_version}", "bin", "erl.exe"])
rcedit_path = ensure_rcedit()
cmd!(rcedit_path, ["--set-icon", app_icon_path, erl_exe])
manifest_path = Path.join(tmp_dir, "manifest.xml")
File.write!(manifest_path, manifest())
cmd!(rcedit_path, ["--application-manifest", manifest_path, erl_exe])
File.write!(Path.join(tmp_dir, "#{app_name}.vbs"), launcher_vbs(release, options))
nsi_path = Path.join(tmp_dir, "#{app_name}.nsi")
@ -51,6 +77,21 @@ defmodule AppBuilder.Windows do
release
end
# https://docs.microsoft.com/en-us/windows/win32/hidpi/setting-the-default-dpi-awareness-for-a-process
defp manifest do
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
"""
end
code = """
<%
app_name = Keyword.fetch!(options, :name)
@ -112,7 +153,7 @@ defmodule AppBuilder.Windows do
<% end %>
WriteRegStr HKCR "<%= app_name %>.<%= type.name %>" "" "<%= type.name %>"
WriteRegStr HKCR "<%= app_name %>.<%= type.name %>\\DefaultIcon" "" "$INSTDIR\\app_icon.ico"
WriteRegStr HKCR "<%= app_name %>.<%= type.name %>\\shell\\open\\command" "" '$WINDIR\\system32\\wscript.exe "$INSTDIR\\<%= app_name %>.vbs" "%1"'
WriteRegStr HKCR "<%= app_name %>.<%= type.name %>\\shell\\open\\command" "" '$WINDIR\\system32\\wscript.exe "$INSTDIR\\<%= app_name %>.vbs" "open_file:%1"'
<% end %>
<%= for url_scheme <- url_schemes do %>
@ -122,7 +163,7 @@ defmodule AppBuilder.Windows do
WriteRegStr HKCR "<%= url_scheme %>" "URL Protocol" ""
WriteRegStr HKCR "<%= url_scheme %>\\shell" "" ""
WriteRegStr HKCR "<%= url_scheme %>\\shell\\open" "" ""
WriteRegStr HKCR "<%= url_scheme %>\\shell\\open\\command" "" '$WINDIR\\system32\\wscript.exe "$INSTDIR\\<%= app_name %>.vbs" "%1"'
WriteRegStr HKCR "<%= url_scheme %>\\shell\\open\\command" "" '$WINDIR\\system32\\wscript.exe "$INSTDIR\\<%= app_name %>.vbs" "open_url:%1"'
<% end %>
SectionEnd
@ -143,16 +184,10 @@ defmodule AppBuilder.Windows do
<%
app_name = Keyword.fetch!(options, :name)
module = Keyword.fetch!(options, :module)
%>
' This vbs script avoids a flashing cmd window when launching the release bat file
%>' This vbs script avoids a flashing cmd window when launching the release bat file
path = Left(Wscript.ScriptFullName, Len(Wscript.ScriptFullName) - Len(Wscript.ScriptName)) & "rel\bin\<%= release.name %>.bat"
If WScript.Arguments.Count > 0 Then
url = WScript.Arguments(0)
Else
url = ""
End If
root = Left(Wscript.ScriptFullName, Len(Wscript.ScriptFullName) - Len(Wscript.ScriptName))
script = root & "rel\bin\<%= release.name %>.bat"
Set shell = CreateObject("WScript.Shell")
@ -167,16 +202,35 @@ defmodule AppBuilder.Windows do
Set env = shell.Environment("Process")
env("PATH") = ".\rel\vendor\elixir\bin;.\rel\erts-<%= release.erts_version %>\bin;" & env("PATH")
' > bin/release rpc "mod.windows_connected(url)"
If WScript.Arguments.Count > 0 Then
input = WScript.Arguments(0)
Else
input = "reopen_app"
End If
' Below, we're basically doing:
'
' We send the URL through IO, as opposed through the rpc expression, to avoid RCE.
cmd = "echo """ & url & """ | """ & path & """ rpc <%= inspect(module) %>.windows_connected(IO.read(:line))"
' $ bin/release rpc 'AppBuilder.Windows.__send_events__(MyApp, input)'
'
' We send the input through IO, as opposed using the rpc expression, to avoid RCE.
cmd = "echo " & input & " | \""" & script & \""" rpc ""AppBuilder.Windows.__send_events__(<%= inspect(module) %>, String.trim(IO.read(:line)))\"""
code = shell.Run("cmd /c " & cmd, 0)
' > bin/release start
cmd = """" & path & """ start"
env("<%= String.upcase(app_name) <> "_URL" %>") = url
code = shell.Run("cmd /c " & cmd & " >> .\Logs\<%= app_name %>.log 2>&1", 0)
' Below, we're basically doing:
'
' $ bin/release start
'
' We send the input through the environment variable as we can't easily access argv
' when booting through the release script.
If WScript.Arguments.Count > 0 Then
env("APP_BUILDER_INPUT") = WScript.Arguments(0)
Else
env("APP_BUILDER_INPUT") = "new_file"
End If
cmd = \"""" & script & \""" start"
code = shell.Run("cmd /c " & cmd & " >> " & root & "\Logs\<%= app_name %>.log 2>&1", 0)
"""
EEx.function_from_string(:defp, :launcher_vbs, code, [:release, :options], trim: true)
@ -203,7 +257,7 @@ defmodule AppBuilder.Windows do
url =
"https://download.imagemagick.org/ImageMagick/download/binaries/ImageMagick-7.1.0-portable-Q16-x64.zip"
sha256 = "d7b82e95d2860042c241d9913e14832cf1491f39c4da91286bace39582916dc8"
sha256 = "b61a726cea1e3bf395b9aeb323fca062f574fbf8f11f4067f88a0e6b984a1391"
AppBuilder.Utils.ensure_executable(url, sha256, "magick.exe")
end

View file

@ -0,0 +1,39 @@
defmodule AppBuilder.Wx do
require Logger
@doc """
Subscribe the given `server` to application events.
The possible events are:
* `{:new_file, []}`
* `{:reopen_app, []}`
* `{:open_file, path}`
* `{:open_url, url}`
On macOS, we simply call `:wx.subscribe_events()`. On Windows,
we emulate it.
"""
def subscribe_to_app_events(server) do
unless Code.ensure_loaded?(:wx) do
Logger.error("""
wx is not available.
Please add it to your extra applications:
extra_applications: [:wx]
""")
raise "wx is not available"
end
case :os.type() do
{:unix, :darwin} ->
:wx.subscribe_events()
{:win32, _} ->
input = System.get_env("APP_BUILDER_INPUT", "new_file")
AppBuilder.Windows.__send_events__(server, input)
end
end
end

View file

@ -7,7 +7,14 @@ defmodule AppBuilder.MixProject do
version: "0.1.0",
elixir: "~> 1.13",
start_permanent: Mix.env() == :prod,
deps: deps()
deps: deps(),
# Suppress warnings
xref: [
exclude: [
:wx
]
]
]
end

View file

@ -1,96 +1,204 @@
if Mix.target() == :app do
defmodule LivebookApp do
defmodule WxUtils do
@moduledoc false
@behaviour :wx_object
defmacro wxID_ANY, do: -1
defmacro wxID_OPEN, do: 5000
defmacro wxID_EXIT, do: 5006
defmacro wxID_OSX_HIDE, do: 5250
defmacro wxBITMAP_TYPE_PNG, do: 15
# https://github.com/erlang/otp/blob/OTP-24.1.2/lib/wx/include/wx.hrl#L1314
@wx_id_exit 5006
@wx_id_osx_hide 5250
def start_link(_) do
{:wx_ref, _, _, pid} = :wx_object.start_link(__MODULE__, [], [])
{:ok, pid}
def os do
case :os.type() do
{:unix, :darwin} -> :macos
{:win32, _} -> :windows
end
end
def child_spec(init_arg) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [init_arg]},
restart: :transient
}
def taskbar(title, icon, menu_items) do
pid = self()
options = if os() == :macos, do: [iconType: 1], else: []
# skip keyboard shortcuts
menu_items =
for item <- menu_items do
{title, options} = item
options = Keyword.delete(options, :key)
{title, options}
end
taskbar =
:wxTaskBarIcon.new(
[
createPopupMenu: fn ->
menu = menu(menu_items)
# For some reason, on macOS the menu event must be handled in another process
# but on Windows it must be either the same process OR we use the callback.
case os() do
:macos ->
env = :wx.get_env()
Task.start_link(fn ->
:wx.set_env(env)
:wxMenu.connect(menu, :command_menu_selected)
receive do
message ->
send(pid, message)
end
end)
:windows ->
:ok =
:wxMenu.connect(menu, :command_menu_selected,
callback: fn wx, _ ->
send(pid, wx)
end
)
end
menu
end
] ++ options
)
:wxTaskBarIcon.setIcon(taskbar, icon, tooltip: title)
taskbar
end
def windows_connected(url) do
url
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_trailing("\"")
|> windows_to_wx()
def menu(items) do
menu = :wxMenu.new()
Enum.each(items, fn
{title, options} ->
id = Keyword.get(options, :id, wxID_ANY())
title =
case Keyword.fetch(options, :key) do
{:ok, key} ->
title <> "\t" <> key
:error ->
title
end
:wxMenu.append(menu, id, title)
end)
menu
end
def menubar(app_name, menus) do
menubar = :wxMenuBar.new()
if os() == :macos, do: fixup_macos_menubar(menubar, app_name)
for {title, menu_items} <- menus do
true = :wxMenuBar.append(menubar, menu(menu_items), title)
end
menubar
end
defp fixup_macos_menubar(menubar, app_name) do
menu = :wxMenuBar.oSXGetAppleMenu(menubar)
menu
|> :wxMenu.findItem(wxID_OSX_HIDE())
|> :wxMenuItem.setItemLabel("Hide #{app_name}\tCtrl+H")
menu
|> :wxMenu.findItem(wxID_EXIT())
|> :wxMenuItem.setItemLabel("Quit #{app_name}\tCtrl+Q")
end
end
defmodule LivebookApp do
@moduledoc false
@name __MODULE__
use GenServer
import WxUtils
def start_link(arg) do
GenServer.start_link(__MODULE__, arg, name: @name)
end
taskbar_icon_path = "rel/app/taskbar_icon.png"
@external_resource taskbar_icon_path
@taskbar_icon File.read!(taskbar_icon_path)
@impl true
def init(_) do
app_name = "Livebook"
true = Process.register(self(), __MODULE__)
os = os()
# TODO: on all platforms either add a basic window with some buttons OR a wxwebview
size = if os == :macos, do: {0, 0}, else: {100, 100}
wx = :wx.new()
frame = :wxFrame.new(wx, -1, app_name, size: size)
:wxFrame.show(frame)
AppBuilder.Wx.subscribe_to_app_events(@name)
menu_items = [
{"Open Browser", key: "ctrl+o", id: wxID_OPEN()},
{"Quit", key: "ctrl+q", id: wxID_EXIT()}
]
if os == :macos do
fixup_macos_menubar(frame, app_name)
:wxMenuBar.setAutoWindowMenu(false)
menubar =
menubar("Livebook", [
{"File", menu_items}
])
:ok = :wxMenuBar.connect(menubar, :command_menu_selected, skip: true)
# TODO: use :wxMenuBar.macSetCommonMenuBar/1 when OTP 25 is out
frame = :wxFrame.new(wx, -1, "", size: {0, 0})
:wxFrame.show(frame)
:wxFrame.setMenuBar(frame, menubar)
end
:wxFrame.connect(frame, :command_menu_selected, skip: true)
:wxFrame.connect(frame, :close_window, skip: true)
# TODO: instead of embedding the icon and copying to tmp, copy the file known location.
# It's a bit tricky because it needs to support all the ways of running the app:
# 1. MIX_TARGET=app mix phx.server
# 2. mix app
# 3. mix release app
taskbar_icon_path = Path.join(System.tmp_dir!(), "taskbar_icon.png")
File.write!(taskbar_icon_path, @taskbar_icon)
icon = :wxIcon.new(taskbar_icon_path, type: wxBITMAP_TYPE_PNG())
case os do
:macos ->
:wx.subscribe_events()
taskbar = taskbar("Livebook", icon, menu_items)
:windows ->
windows_to_wx(System.get_env("LIVEBOOK_URL") || "")
if os == :windows do
:wxTaskBarIcon.connect(taskbar, :taskbar_left_down,
callback: fn _, _ ->
open_browser()
end
)
end
state = %{frame: frame}
{frame, state}
{:ok, nil}
end
@impl true
def handle_event({:wx, @wx_id_exit, _, _, _}, state) do
:init.stop()
{:stop, :shutdown, state}
def handle_info({:wx, wxID_EXIT(), _, _, _}, _state) do
System.stop(0)
end
@impl true
def handle_event({:wx, _, _, _, {:wxClose, :close_window}}, state) do
:init.stop()
{:stop, :shutdown, state}
def handle_info({:wx, wxID_OPEN(), _, _, _}, state) do
open_browser()
{:noreply, state}
end
# This event is triggered when the application is opened for the first time
@impl true
def handle_info({:new_file, ''}, state) do
Livebook.Utils.browser_open(LivebookWeb.Endpoint.access_url())
open_browser()
{:noreply, state}
end
# TODO: investigate "Universal Links" [1], that is, instead of livebook://foo, we have
# https://livebook.dev/foo, which means the link works with and without Livebook.app.
#
# [1] https://developer.apple.com/documentation/xcode/allowing-apps-and-websites-to-link-to-your-content
@impl true
def handle_info({:open_url, 'livebook://' ++ rest}, state) do
"https://#{rest}"
|> Livebook.Utils.notebook_import_url()
|> Livebook.Utils.browser_open()
def handle_info({:reopen_app, _}, state) do
open_browser()
{:noreply, state}
end
@ -99,69 +207,30 @@ if Mix.target() == :app do
path
|> List.to_string()
|> Livebook.Utils.notebook_open_url()
|> Livebook.Utils.browser_open()
|> open_browser()
{:noreply, state}
end
@impl true
def handle_info({:reopen_app, _}, state) do
Livebook.Utils.browser_open(LivebookWeb.Endpoint.access_url())
def handle_info({:open_url, 'livebook://' ++ rest}, state) do
"https://#{rest}"
|> Livebook.Utils.notebook_import_url()
|> open_browser()
{:noreply, state}
end
# ignore other events
@impl true
def handle_info(_event, state) do
{:noreply, state}
end
# 1. WxeApp attaches event handler to "Quit" menu item that does nothing (to not accidentally bring
# down the VM). Let's create a fresh menu bar without that caveat.
# 2. Fix app name
defp fixup_macos_menubar(frame, app_name) do
menubar = :wxMenuBar.new()
:wxFrame.setMenuBar(frame, menubar)
menu = :wxMenuBar.oSXGetAppleMenu(menubar)
# without this, for some reason setting the title later will make it non-bold
:wxMenu.getTitle(menu)
# this is useful in dev, not needed when bundled in .app
:wxMenu.setTitle(menu, app_name)
menu
|> :wxMenu.findItem(@wx_id_osx_hide)
|> :wxMenuItem.setItemLabel("Hide #{app_name}\tCtrl+H")
menu
|> :wxMenu.findItem(@wx_id_exit)
|> :wxMenuItem.setItemLabel("Quit #{app_name}\tCtrl+Q")
end
defp os() do
case :os.type() do
{:unix, :darwin} -> :macos
{:win32, _} -> :windows
if Mix.env() == :dev do
@impl true
def handle_info(event, state) do
IO.inspect(event)
{:noreply, state}
end
end
defp windows_to_wx("") do
send(__MODULE__, {:new_file, ''})
end
defp windows_to_wx("livebook://" <> _ = url) do
send(__MODULE__, {:open_url, String.to_charlist(url)})
end
defp windows_to_wx(path) do
path =
path
|> String.replace("\\", "/")
|> String.to_charlist()
send(__MODULE__, {:open_file, path})
defp open_browser(url \\ LivebookWeb.Endpoint.access_url()) do
Livebook.Utils.browser_open(url)
end
end
end

View file

@ -5,8 +5,8 @@ defmodule Livebook.MixProject do
@version "0.5.2"
@description "Interactive and collaborative code notebooks - made with Phoenix LiveView"
@app_elixir_version "1.13.2"
@app_otp_version "24.2"
@app_elixir_version "1.13.4"
@app_otp_version "24.3"
def project do
[
@ -176,7 +176,10 @@ defmodule Livebook.MixProject do
name: "Livebook",
version: @version,
logo_path: "rel/app/mac-icon.png",
additional_paths: ["/rel/vendor/bin", "/rel/vendor/elixir/bin"],
additional_paths: [
"/rel/erts-#{:erlang.system_info(:version)}/bin",
"/rel/vendor/elixir/bin"
],
url_schemes: ["livebook"],
document_types: [
%{

View file

@ -7,7 +7,10 @@ defmodule Standalone do
"""
@spec copy_otp(Mix.Release.t(), otp_version :: String.t()) :: Mix.Release.t()
def copy_otp(release, otp_version) do
ensure_otp_version(otp_version)
if Mix.env() != :dev do
ensure_otp_version(otp_version)
end
{erts_source, otp_bin_dir, otp_lib_dir} = otp_dirs()
# 1. copy erts/bin

BIN
rel/app/taskbar_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB