Desktop app packaging for Windows (#1032)

This commit is contained in:
Wojtek Mach 2022-03-02 12:06:30 +01:00 committed by GitHub
parent 59e4713008
commit 29f0f54bbd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 389 additions and 46 deletions

View file

@ -1,4 +1,5 @@
#!/bin/bash
#
# Usage:
#
# $ sh .github/scripts/app/build_mac.sh

11
.github/scripts/app/build_windows.sh vendored Normal file
View file

@ -0,0 +1,11 @@
#!/bin/bash
#
# Usage:
#
# $ sh .github/scripts/app/build_windows.sh
# $ _build/app_prod/rel/LivebookInstall.exe
# $ start livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd
# $ start ./test/support/notebooks/basic.livemd
set -e
MIX_ENV=prod MIX_TARGET=app mix release windows_installer --overwrite

View file

@ -35,21 +35,39 @@ defmodule WxDemo.Window do
}
end
def windows_connected(url) do
url
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_trailing("\"")
|> windows_to_wx()
end
@impl true
def init(_) do
title = "WxDemo"
true = Process.register(self(), __MODULE__)
os = os()
wx = :wx.new()
frame = :wxFrame.new(wx, -1, title)
frame = :wxFrame.new(wx, -1, title, size: {100, 100})
if macOS?() do
if os == :macos do
fixup_macos_menubar(frame, title)
end
:wxFrame.show(frame)
:wxFrame.connect(frame, :command_menu_selected)
:wxFrame.connect(frame, :close_window, skip: true)
:wx.subscribe_events()
case os do
:macos ->
:wx.subscribe_events()
:windows ->
windows_to_wx(System.get_env("WXDEMO_URL") || "")
end
state = %{frame: frame}
{frame, state}
end
@ -67,17 +85,16 @@ defmodule WxDemo.Window do
end
@impl true
def handle_info({:open_url, url}, state) do
:wxMessageDialog.new(state.frame, inspect(url))
|> :wxDialog.showModal()
def handle_info(event, state) do
show_dialog(state, inspect(event))
{:noreply, state}
end
@impl true
# ignore other events
def handle_info(_event, state) do
{:noreply, state}
# Helpers
defp show_dialog(state, data) do
:wxMessageDialog.new(state.frame, data)
|> :wxDialog.showModal()
end
defp fixup_macos_menubar(frame, title) do
@ -98,7 +115,22 @@ defmodule WxDemo.Window do
end
end
defp macOS?() do
:os.type() == {:unix, :darwin}
defp os() do
case :os.type() do
{:unix, :darwin} -> :macos
{:win32, _} -> :windows
end
end
defp windows_to_wx("") do
send(__MODULE__, {:new_file, ''})
end
defp windows_to_wx("wxdemo://" <> _ = url) do
send(__MODULE__, {:open_url, String.to_charlist(url)})
end
defp windows_to_wx(path) do
send(__MODULE__, {:open_file, String.to_charlist(path)})
end
end

View file

@ -28,7 +28,15 @@ defmodule WxDemo.MixProject do
defp releases do
options = [
name: "WxDemo",
url_schemes: ["wxdemo"]
url_schemes: ["wxdemo"],
document_types: [
%{
name: "WxDemo",
extensions: ["wxdemo"],
# macos specific
role: "Editor"
}
]
]
[
@ -39,6 +47,13 @@ defmodule WxDemo.MixProject do
mac_app_dmg: [
include_executables_for: [:unix],
steps: [:assemble, &build_mac_app_dmg(&1, options)]
],
windows_installer: [
include_executables_for: [:windows],
steps: [
:assemble,
&AppBuilder.build_windows_installer(&1, [module: WxDemo.Window] ++ options)
]
]
]
end

View file

@ -2,4 +2,6 @@ defmodule AppBuilder do
defdelegate build_mac_app(release, options), to: AppBuilder.MacOS
defdelegate build_mac_app_dmg(release, options), to: AppBuilder.MacOS
defdelegate build_windows_installer(release, options), to: AppBuilder.Windows
end

View file

@ -134,9 +134,7 @@ defmodule AppBuilder.MacOS do
end
defp launcher(additional_paths) do
additional_paths = additional_paths
|> Enum.map(&("\\(resourcePath)#{&1}"))
|> Enum.join(":")
additional_paths = Enum.map_join(additional_paths, ":", &"\\(resourcePath)#{&1}")
"""
import Foundation

View file

@ -0,0 +1,202 @@
defmodule AppBuilder.Windows do
@moduledoc false
import AppBuilder.Utils
require EEx
@doc """
Creates a Windows installer.
"""
def build_windows_installer(release, options) do
tmp_dir = release.path <> "_tmp"
File.rm_rf(tmp_dir)
File.mkdir_p!(tmp_dir)
File.cp_r!(release.path, Path.join(tmp_dir, "rel"))
options =
Keyword.validate!(options, [
:name,
:version,
:url_schemes,
:document_types,
:logo_path,
:module
])
app_name = Keyword.fetch!(options, :name)
logo_path = options[:logo_path] || Application.app_dir(:wx, "examples/demo/erlang.png")
app_icon_path = Path.join(tmp_dir, "app_icon.ico")
copy_image(logo_path, app_icon_path)
erts_dir = Path.join([tmp_dir, "rel", "erts-#{:erlang.system_info(:version)}"])
rcedit_path = Path.join(Mix.Project.build_path(), "rcedit")
ensure_rcedit(rcedit_path)
cmd!(rcedit_path, ["--set-icon", app_icon_path, Path.join([erts_dir, "bin", "erl.exe"])])
File.write!(Path.join(tmp_dir, "#{app_name}.vbs"), launcher_vbs(release, options))
nsi_path = Path.join(tmp_dir, "#{app_name}.nsi")
File.write!(nsi_path, nsi(options))
cmd!("makensis", [nsi_path])
File.rename!(
Path.join(tmp_dir, "#{app_name}Install.exe"),
Path.join([Mix.Project.build_path(), "rel", "#{app_name}Install.exe"])
)
release
end
code = """
<%
app_name = Keyword.fetch!(options, :name)
url_schemes = Keyword.get(options, :url_schemes, [])
%>
!include "MUI2.nsh"
;--------------------------------
;General
Name "<%= app_name %>"
OutFile "<%= app_name %>Install.exe"
Unicode True
InstallDir "$LOCALAPPDATA\\<%= app_name %>"
; Need admin for registering URL scheme
RequestExecutionLevel admin
;--------------------------------
;Interface Settings
!define MUI_ABORTWARNING
;--------------------------------
;Pages
;!insertmacro MUI_PAGE_COMPONENTS
!define MUI_ICON "app_icon.ico"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
;--------------------------------
;Languages
!insertmacro MUI_LANGUAGE "English"
;--------------------------------
;Installer Sections
Section "Install"
SetOutPath "$INSTDIR"
File /r rel rel
File "<%= app_name %>.vbs"
File "app_icon.ico"
CreateDirectory "$INSTDIR\\Logs"
WriteUninstaller "$INSTDIR\\<%= app_name %>Uninstall.exe"
<%= for type <- Keyword.get(options, :document_types, []) do %>
<%= for ext <- type.extensions do %>
WriteRegStr HKCR ".<%= ext %>" "" "<%= app_name %>.<%= type.name %>"
<% 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"'
<% end %>
<%= for url_scheme <- url_schemes do %>
DetailPrint "Register <%= url_scheme %> URL Handler"
DeleteRegKey HKCR "<%= url_scheme %>"
WriteRegStr HKCR "<%= url_scheme %>" "" "<%= url_scheme %> Protocol"
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"'
<% end %>
SectionEnd
Section "Desktop Shortcut"
CreateShortCut "$DESKTOP\\<%= app_name %>.lnk" "$INSTDIR\\<%= app_name %>.vbs" "" "$INSTDIR\\app_icon.ico"
SectionEnd
Section "Uninstall"
Delete "$DESKTOP\\<%= app_name %>.lnk"
; TODO: stop epmd if it was started
RMDir /r "$INSTDIR"
SectionEnd
"""
EEx.function_from_string(:defp, :nsi, code, [:options], trim: true)
code = ~S"""
<%
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
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
Set shell = CreateObject("WScript.Shell")
' Below we run two commands:
'
' 1. bin/release rpc
' 2. bin/release start
'
' The first one will only succeed when the app is already running. The second one when it is not.
' It's ok for either to fail because we run them asynchronously.
Set env = shell.Environment("Process")
env("PATH") = ".\rel\erts-<%= :erlang.system_info(:version) %>\bin;" & env("PATH")
' > bin/release rpc "mod.windows_connected(url)"
'
' 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))"
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)
"""
EEx.function_from_string(:defp, :launcher_vbs, code, [:release, :options], trim: true)
# TODO: Use https://github.com/elixir-desktop/libpe when fixed
defp ensure_rcedit(path) do
unless File.exists?(path) do
url = "https://github.com/electron/rcedit/releases/download/v1.1.1/rcedit-x64.exe"
cmd!("curl", ["-L", url, "-o", path])
end
end
defp copy_image(src_path, dest_path) do
if Path.extname(src_path) == ".ico" do
File.cp!(src_path, dest_path)
else
sizes = [16, 32, 48, 64, 128]
for i <- sizes do
cmd!("magick", [src_path, "-resize", "#{i}x#{i}", sized_path(dest_path, i)])
end
sized_paths = Enum.map(sizes, &sized_path(dest_path, &1))
cmd!("magick", sized_paths ++ [dest_path])
end
end
defp sized_path(path, size) do
String.replace_trailing(path, ".ico", ".#{size}.ico")
end
end

View file

@ -310,7 +310,7 @@ defmodule Livebook.Utils do
def browser_open(url) do
{cmd, args} =
case :os.type() do
{:win32, _} -> {"cmd", ["/c", "start", url]}
{:win32, _} -> {"cmd", ["/c", "start", String.replace(url, "&", "^&")]}
{:unix, :darwin} -> {"open", [url]}
{:unix, _} -> {"xdg-open", [url]}
end

View file

@ -20,23 +20,44 @@ if Mix.target() == :app do
}
end
def windows_connected(url) do
url
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_trailing("\"")
|> windows_to_wx()
end
@impl true
def init(_) do
title = "Livebook"
wx = :wx.new()
frame = :wxFrame.new(wx, -1, title, size: {0, 0})
true = Process.register(self(), __MODULE__)
os = os()
if macos?() do
# 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, title, size: size)
if os == :macos do
fixup_macos_menubar(frame, title)
end
:wxFrame.show(frame)
:wxFrame.connect(frame, :command_menu_selected)
:wxFrame.connect(frame, :close_window, skip: true)
:wx.subscribe_events()
state = %{frame: frame}
case os do
:macos ->
:wx.subscribe_events()
:windows ->
windows_to_wx(System.get_env("LIVEBOOK_URL") || "")
end
state = %{frame: frame}
{frame, state}
end
@ -111,8 +132,28 @@ if Mix.target() == :app do
end
end
defp macos?() do
:os.type() == {:unix, :darwin}
defp os() do
case :os.type() do
{:unix, :darwin} -> :macos
{:win32, _} -> :windows
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})
end
end
end

28
mix.exs
View file

@ -144,6 +144,17 @@ defmodule Livebook.MixProject do
include_erts: false,
rel_templates_path: "rel/app",
steps: [:assemble, &remove_cookie/1, &standalone_erlang_elixir/1, &build_mac_app_dmg/1]
],
windows_installer: [
include_executables_for: [:windows],
include_erts: false,
rel_templates_path: "rel/app",
steps: [
:assemble,
&remove_cookie/1,
&standalone_erlang_elixir/1,
&build_windows_installer/1
]
]
]
end
@ -165,10 +176,15 @@ defmodule Livebook.MixProject do
name: "Livebook",
version: @version,
logo_path: "rel/app/mac-icon.png",
url_schemes: ["livebook"],
additional_paths: ["/rel/vendor/bin", "/rel/vendor/elixir/bin"],
url_schemes: ["livebook"],
document_types: [
%{name: "LiveMarkdown", role: "Editor", extensions: ["livemd"]}
%{
name: "LiveMarkdown",
extensions: ["livemd"],
# macos specific
role: "Editor"
}
]
]
@ -191,4 +207,12 @@ defmodule Livebook.MixProject do
AppBuilder.build_mac_app_dmg(release, options)
end
defp build_windows_installer(release) do
options =
Keyword.take(@app_options, [:name, :version, :url_schemes, :document_types]) ++
[module: LivebookApp, logo_path: "static/images/logo.png"]
AppBuilder.build_windows_installer(release, options)
end
end

View file

@ -25,24 +25,26 @@ defmodule Standalone do
_ = File.rm(Path.join(erts_destination_source, "erl"))
_ = File.rm(Path.join(erts_destination_source, "erl.ini"))
erts_destination_source
|> Path.join("erl")
|> File.write!(~S"""
#!/bin/sh
SELF=$(readlink "$0" || true)
if [ -z "$SELF" ]; then SELF="$0"; fi
BINDIR="$(cd "$(dirname "$SELF")" && pwd -P)"
ROOTDIR="${ERL_ROOTDIR:-"$(dirname "$(dirname "$BINDIR")")"}"
EMU=beam
PROGNAME=$(echo "$0" | sed 's/.*\///')
export EMU
export ROOTDIR
export BINDIR
export PROGNAME
exec "$BINDIR/erlexec" ${1+"$@"}
""")
if os() == :macos do
erts_destination_source
|> Path.join("erl")
|> File.write!(~S"""
#!/bin/sh
SELF=$(readlink "$0" || true)
if [ -z "$SELF" ]; then SELF="$0"; fi
BINDIR="$(cd "$(dirname "$SELF")" && pwd -P)"
ROOTDIR="${ERL_ROOTDIR:-"$(dirname "$(dirname "$BINDIR")")"}"
EMU=beam
PROGNAME=$(echo "$0" | sed 's/.*\///')
export EMU
export ROOTDIR
export BINDIR
export PROGNAME
exec "$BINDIR/erlexec" ${1+"$@"}
""")
executable!(Path.join(erts_destination_source, "erl"))
executable!(Path.join(erts_destination_source, "erl"))
end
# Copy lib
erts_destination_lib = Path.join(release.path, "lib")
@ -93,8 +95,16 @@ defmodule Standalone do
standalone_destination = Path.join(release.path, "vendor/elixir")
download_elixir_at_destination(standalone_destination, elixir_version)
["elixir", "elixirc", "mix", "iex"]
|> Enum.map(&executable!(Path.join(standalone_destination, "bin/#{&1}")))
filenames =
case os() do
:macos ->
["elixir", "elixirc", "mix", "iex"]
:windows ->
["elixir.bat", "elixirc.bat", "mix.bat", "iex.bat"]
end
Enum.map(filenames, &executable!(Path.join(standalone_destination, "bin/#{&1}")))
release
end
@ -131,4 +141,11 @@ defmodule Standalone do
end
defp executable!(path), do: File.chmod!(path, 0o755)
defp os() do
case :os.type() do
{:unix, :darwin} -> :macos
{:win32, _} -> :windows
end
end
end