App bundling improvements (#1211)

1. Replace `mix release mac_app|mac_app_dmg|windows_installer` with a single `mix release app`
2. Extract templates (Launcher.swift, Launcher.vbs, etc) into separate
   files in app_builder/lib/templates
3. Don't verify vc_redist.x64.exe checksum as the Microsoft publishes
   new releases on the same URL
This commit is contained in:
Wojtek Mach 2022-06-01 22:29:54 +02:00 committed by GitHub
parent 79c453c8ee
commit fc7328703a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 555 additions and 579 deletions

View file

@ -17,7 +17,7 @@ main() {
fi
export PATH=$PWD/tmp/wxwidgets-${wxwidgets_vsn}-$target/bin:$PATH
echo "wx"
echo "checking wx"
file `which wxrc`
wx-config --version
echo
@ -29,7 +29,7 @@ main() {
fi
export PATH=$PWD/tmp/otp-${otp_vsn}-$target/bin:$PATH
echo "otp"
echo "checking otp"
file `which erlc`
erl +V
erl -noshell -eval 'ok = crypto:start(), io:format("crypto ok~n"), halt().'
@ -41,7 +41,7 @@ main() {
fi
export PATH=$PWD/tmp/elixir-${elixir_vsn}/bin:$PATH
echo "elixir"
echo "checking elixir"
elixir --version
cat << EOF > tmp/bootstrap_env.sh

View file

@ -3,18 +3,11 @@
# Usage:
#
# $ sh .github/scripts/app/build_mac.sh
# $ open _build/app_prod/rel/Livebook.app
# $ open _build/app_prod/Livebook.app
# $ open livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd
# $ open ./test/support/notebooks/basic.livemd
set -e
sh .github/scripts/app/bootstrap_mac.sh
sh tmp/bootstrap_env.sh
# If CODESIGN_IDENITY is set, let's build the .dmg which would also notarize it.
# Otherwise, let's build just the .app.
if [ -n "$CODESIGN_IDENTITY" ]; then
MIX_ENV=prod MIX_TARGET=app mix release mac_app_dmg --overwrite
else
MIX_ENV=prod MIX_TARGET=app mix release mac_app --overwrite
fi
. tmp/bootstrap_env.sh
MIX_ENV=prod MIX_TARGET=app mix release app --overwrite

View file

@ -3,9 +3,9 @@
# Usage:
#
# $ sh .github/scripts/app/build_windows.sh
# $ _build/app_prod/rel/LivebookInstall.exe
# $ wscript _build/app_prod/Livebook-win/LivebookLauncher.vbs
# $ 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
MIX_ENV=prod MIX_TARGET=app mix release app --overwrite

View file

@ -0,0 +1 @@
An example file to test "open file" feature.

View file

@ -24,7 +24,7 @@ defmodule WxDemo.Window do
@wx_id_osx_hide 5250
def start_link(_) do
{:wx_ref, _, _, pid} = :wx_object.start_link(__MODULE__, [], [])
{:wx_ref, _, _, pid} = :wx_object.start_link({:local, __MODULE__}, __MODULE__, [], [])
{:ok, pid}
end
@ -47,11 +47,9 @@ defmodule WxDemo.Window do
@impl true
def init(_) do
app_name = "WxDemo"
true = Process.register(self(), __MODULE__)
os = os()
wx = :wx.new()
frame = :wxFrame.new(wx, -1, app_name, size: {100, 100})
frame = :wxFrame.new(wx, -1, app_name, size: {400, 400})
if os == :macos do
fixup_macos_menubar(frame, app_name)

View file

@ -26,51 +26,41 @@ defmodule WxDemo.MixProject do
end
defp releases do
options = [
name: "WxDemo",
url_schemes: ["wxdemo"],
document_types: [
%{
name: "WxDemo",
extensions: ["wxdemo"],
# macos specific
role: "Editor"
}
]
]
macos_notarization = macos_notarization()
[
mac_app: [
include_executables_for: [:unix],
steps: [:assemble, &AppBuilder.build_mac_app(&1, options)]
],
mac_app_dmg: [
include_executables_for: [:unix],
steps: [:assemble, &build_mac_app_dmg(&1, options)]
],
windows_installer: [
include_executables_for: [:windows],
app: [
steps: [
:assemble,
&AppBuilder.build_windows_installer(&1, [module: WxDemo.Window] ++ options)
&AppBuilder.bundle/1
],
app: [
name: "WxDemo",
url_schemes: ["wxdemo"],
document_types: [
%{
name: "WxDemo",
extensions: ["wxdemo"],
macos_role: "Editor"
}
],
server: WxDemo,
macos_build_dmg: macos_notarization != nil,
macos_notarization: macos_notarization,
windows_build_installer: true
]
]
]
end
defp build_mac_app_dmg(release, options) do
options =
[
codesign: [
identity: System.fetch_env!("CODESIGN_IDENTITY")
],
notarize: [
team_id: System.fetch_env!("NOTARIZE_TEAM_ID"),
apple_id: System.fetch_env!("NOTARIZE_APPLE_ID"),
password: System.fetch_env!("NOTARIZE_PASSWORD")
]
] ++ options
defp macos_notarization do
identity = System.get_env("NOTARIZE_IDENTITY")
team_id = System.get_env("NOTARIZE_TEAM_ID")
apple_id = System.get_env("NOTARIZE_APPLE_ID")
password = System.get_env("NOTARIZE_PASSWORD")
AppBuilder.build_mac_app_dmg(release, options)
if identity && team_id && apple_id && password do
[identity: identity, team_id: team_id, apple_id: apple_id, password: password]
end
end
end

View file

@ -1,9 +1,32 @@
defmodule AppBuilder do
defdelegate build_mac_app(release, options), to: AppBuilder.MacOS
def bundle(release) do
os = os()
defdelegate build_mac_app_dmg(release, options), to: AppBuilder.MacOS
allowed_options = [
:name,
:server,
icon_path: [
macos: Application.app_dir(:wx, "examples/demo/erlang.png")
],
url_schemes: [],
document_types: [],
additional_paths: [],
macos_is_agent_app: false,
macos_build_dmg: false,
macos_notarization: nil,
windows_build_installer: true
]
defdelegate build_windows_installer(release, options), to: AppBuilder.Windows
options = Keyword.validate!(release.options[:app], allowed_options)
case os do
:macos ->
AppBuilder.MacOS.bundle(release, options)
:windows ->
AppBuilder.Windows.bundle(release, options)
end
end
def os do
case :os.type() do

View file

@ -3,74 +3,120 @@ defmodule AppBuilder.MacOS do
import AppBuilder.Utils
def build_mac_app_dmg(release, options) do
{codesign, options} = Keyword.pop(options, :codesign)
{notarize, options} = Keyword.pop(options, :notarize)
@templates_path "#{__ENV__.file}/../../templates"
release = build_mac_app(release, options)
def bundle(release, options) do
app_name = options[:name]
app_path = "#{Mix.Project.build_path()}/#{app_name}.app"
File.rm_rf!(app_path)
tmp_dir = "#{Mix.Project.build_path()}/tmp"
contents_path = "#{app_path}/Contents"
resources_path = "#{contents_path}/Resources"
copy_dir(release.path, "#{resources_path}/rel")
launcher_eex_path = Path.expand("#{@templates_path}/macos/Launcher.swift.eex")
launcher_src_path = "#{tmp_dir}/Launcher.swift"
launcher_bin_path = "#{contents_path}/MacOS/#{app_name}Launcher"
copy_template(launcher_eex_path, launcher_src_path, release: release, app_options: options)
File.mkdir!("#{contents_path}/MacOS")
log(:green, :creating, Path.relative_to_cwd(launcher_bin_path))
cmd!("swiftc", [
"-warnings-as-errors",
"-target",
swiftc_target(),
"-o",
launcher_bin_path,
launcher_src_path
])
icon_path = Keyword.fetch!(options, :icon_path)
dest_path = "#{resources_path}/AppIcon.icns"
create_icon(icon_path, dest_path)
for type <- Keyword.fetch!(options, :document_types) do
if src_path = type[:icon_path] do
dest_path = "#{resources_path}/#{type.name}Icon.icns"
create_icon(src_path, dest_path)
end
end
copy_template(
Path.expand("#{@templates_path}/macos/Info.plist.eex"),
"#{contents_path}/Info.plist",
release: release,
app_options: options
)
if options[:macos_build_dmg] do
build_dmg(release, options)
end
release
end
defp build_dmg(release, options) do
app_name = Keyword.fetch!(options, :name)
File.rm_rf!("tmp/dmg")
File.mkdir_p!("tmp/dmg")
File.ln_s!("/Applications", "tmp/dmg/Applications")
notarization = Keyword.fetch!(options, :macos_notarization)
dmg_dir = "#{Mix.Project.build_path()}/dmg"
app_dir = "#{dmg_dir}/#{app_name}.app"
tmp_dir = "#{Mix.Project.build_path()}/tmp"
File.rm_rf!(dmg_dir)
File.mkdir_p!(dmg_dir)
File.ln_s!("/Applications", "#{dmg_dir}/Applications")
File.cp_r!(
Path.join([Mix.Project.build_path(), "rel", "#{app_name}.app"]),
"tmp/dmg/#{app_name}.app"
"#{Mix.Project.build_path()}/#{app_name}.app",
app_dir
)
to_sign =
"tmp/dmg/#{app_name}.app/**"
"#{app_dir}/**"
|> Path.wildcard()
|> Enum.filter(fn file ->
stat = File.lstat!(file)
Bitwise.band(0o100, stat.mode) != 0 and stat.type == :regular
end)
to_sign = to_sign ++ ["tmp/dmg/#{app_name}.app"]
to_sign = to_sign ++ [app_dir]
if codesign do
entitlements_path = "tmp/entitlements.plist"
File.write!(entitlements_path, entitlements())
codesign(to_sign, "--options=runtime --entitlements=#{entitlements_path}", codesign)
end
entitlements_eex_path = "#{Path.expand(@templates_path)}/macos/Entitlements.plist.eex"
entitlements_plist_path = "#{tmp_dir}/Entitlements.plist"
copy_template(entitlements_eex_path, entitlements_plist_path,
release: release,
app_options: options
)
log(:green, "signing", Path.relative_to_cwd(app_dir))
codesign(to_sign, "--options=runtime --entitlements=#{entitlements_plist_path}", notarization)
arch = :erlang.system_info(:system_architecture) |> to_string |> String.split("-") |> hd()
vsn = release.version
basename = "#{app_name}-#{vsn}-#{arch}.dmg"
tmp_dmg_path = "tmp/#{app_name}.dmg"
dmg_path = "#{Mix.Project.build_path()}/rel/#{basename}"
File.rm_rf!(tmp_dmg_path)
File.rm_rf!(dmg_path)
dmg_path = "#{Mix.Project.build_path()}/#{app_name}Install-#{vsn}-#{arch}.dmg"
log(:green, "creating", Path.relative_to_cwd(dmg_path))
cmd!(
"hdiutil",
~w(create #{tmp_dmg_path} -ov -volname #{app_name}Install -fs HFS+ -srcfolder tmp/dmg)
~w(create #{dmg_path} -ov -volname #{app_name}Install -fs HFS+ -srcfolder #{dmg_dir})
)
cmd!(
"hdiutil",
~w(convert #{tmp_dmg_path} -format UDZO -o #{dmg_path})
)
log(:green, "notarizing", Path.relative_to_cwd(dmg_path))
notarize(dmg_path, notarization)
if codesign do
codesign([dmg_path], "", codesign)
end
if notarize do
notarize(dmg_path, notarize)
end
File.rm!(tmp_dmg_path)
release
end
defp codesign(paths, args, options) do
defp codesign(paths, extra_flags, options) do
identity = Keyword.fetch!(options, :identity)
paths = Enum.join(paths, " ")
shell!("codesign --force --timestamp --verbose=4 --sign=\"#{identity}\" #{args} #{paths}")
flags = "--force --timestamp --verbose=4 --sign=\"#{identity}\" #{extra_flags}"
shell!("codesign #{flags} #{paths}")
end
defp notarize(path, options) do
@ -89,106 +135,8 @@ defmodule AppBuilder.MacOS do
""")
end
def build_mac_app(release, options) do
options =
Keyword.validate!(options, [
:name,
:version,
:icon_path,
:info_plist,
:url_schemes,
:document_types,
:additional_paths,
:is_agent_app
])
app_name = Keyword.fetch!(options, :name)
additional_paths = Keyword.get(options, :additional_paths, [])
app_bundle_path = Path.join([Mix.Project.build_path(), "rel", "#{app_name}.app"])
File.rm_rf!(app_bundle_path)
File.mkdir_p!(Path.join([app_bundle_path, "Contents", "Resources"]))
File.rename!(release.path, Path.join([app_bundle_path, "Contents", "Resources", "rel"]))
File.mkdir_p!("tmp")
launcher_src_path = "tmp/Launcher.swift"
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))
cmd!("swiftc", [
"-warnings-as-errors",
"-target",
swiftc_target(),
"-o",
launcher_path,
launcher_src_path
])
icon_path = options[:icon_path] || Application.app_dir(:wx, "examples/demo/erlang.png")
dest_path = Path.join([app_bundle_path, "Contents", "Resources", "AppIcon.icns"])
create_icon(icon_path, dest_path)
for type <- options[:document_types] || [] do
if src_path = type[:icon_path] do
dest_path = Path.join([app_bundle_path, "Contents", "Resources", "#{type.name}Icon.icns"])
create_icon(src_path, dest_path)
end
end
info_plist = options[:info_plist] || info_plist(options)
File.write!(Path.join([app_bundle_path, "Contents", "Info.plist"]), info_plist)
release
end
defp launcher(release, additional_paths) do
additional_paths = Enum.map_join(additional_paths, ":", &"\\(resourcePath)#{&1}")
"""
import Foundation
import Cocoa
let fm = FileManager.default
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String
let home = NSHomeDirectory()
let logPath = "\\(home)/Library/Logs/\\(appName).log"
if !fm.fileExists(atPath: logPath) { fm.createFile(atPath: logPath, contents: Data()) }
let logFile = FileHandle(forUpdatingAtPath: logPath)
logFile?.seekToEndOfFile()
let releaseScriptPath = Bundle.main.path(forResource: "rel/bin/#{release.name}", ofType: "")!
let resourcePath = Bundle.main.resourcePath ?? ""
let additionalPaths = "#{additional_paths}"
var environment = ProcessInfo.processInfo.environment
let path = environment["PATH"] ?? ""
environment["PATH"] = "\\(additionalPaths):\\(path)"
let task = Process()
task.environment = environment
task.launchPath = releaseScriptPath
task.arguments = ["start"]
task.standardOutput = logFile
task.standardError = logFile
try task.run()
task.waitUntilExit()
if task.terminationStatus != 0 {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "\\(appName) exited with error status \\(task.terminationStatus)."
alert.informativeText = "Logs available at \\(logPath)."
alert.runModal()
}
"""
end
defp create_icon(src_path, dest_path) do
log(:green, :creating, Path.relative_to_cwd(dest_path))
src_path = normalize_icon_path(src_path)
if Path.extname(src_path) == ".icns" do
@ -210,7 +158,7 @@ defmodule AppBuilder.MacOS do
size = size * scale
out = "#{dest_tmp_path}/icon_#{size}x#{size}#{suffix}.png"
cmd!("sips", ~w(-z #{size} #{size} #{src_path} --out #{out}))
cmd!("sips", ~w(-z #{size} #{size} #{src_path} --out #{out}), into: "")
end
cmd!("iconutil", ~w(-c icns #{dest_tmp_path} -o #{dest_path}))
@ -227,101 +175,4 @@ defmodule AppBuilder.MacOS do
"arm64-apple-macosx12"
end
end
## Templates
require EEx
defp entitlements do
"""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
"""
end
code = """
<%
app_name = Keyword.fetch!(options, :name)
app_version = Keyword.fetch!(options, :version)
%>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string><%= app_name %>Launcher</string>
<key>CFBundleName</key>
<string><%= app_name %></string>
<key>CFBundleDisplayName</key>
<string><%= app_name %></string>
<key>CFBundleShortVersionString</key>
<string><%= app_version %></string>
<key>CFBundleVersion</key>
<string><%= app_version %></string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<%= if schemes = options[:url_schemes] do %>
<key>CFBundleURLTypes</key>
<array>
<%= for scheme <- schemes do %>
<dict>
<key>CFBundleURLName</key>
<string><%= app_name %></string>
<key>CFBundleURLSchemes</key>
<array>
<string><%= scheme %></string>
</array>
</dict>
<% end %>
</array>
<% end %>
<%= if types = options[:document_types] do %>
<key>CFBundleDocumentTypes</key>
<array>
<%= for type <- types do %>
<dict>
<key>CFBundleTypeName</key>
<string><%= type.name %></string>
<key>CFBundleTypeRole</key>
<string><%= type.role %></string>
<key>CFBundleTypeExtensions</key>
<array>
<%= for ext <- type.extensions do %>
<string><%= ext %></string>
<% end %>
</array>
<%= if type[:icon_path] do %>
<key>CFBundleTypeIconFile</key>
<string><%= type.name %>Icon</string>
<% end %>
</dict>
<% end %>
</array>
<% end %>
<%= if options[:is_agent_app] do %>
<key>LSUIElement</key>
<true/>
<% end %>
</dict>
</plist>
"""
EEx.function_from_string(:defp, :info_plist, code, [:options], trim: true)
end

View file

@ -5,7 +5,11 @@ defmodule AppBuilder.Utils do
def cmd!(bin, args, opts \\ []) do
opts = Keyword.put_new(opts, :into, IO.stream())
{_, 0} = System.cmd(bin, args, opts)
{_, status} = System.cmd(bin, args, opts)
if status != 0 do
raise "command exited with #{status}"
end
end
def shell!(command, opts \\ []) do
@ -13,11 +17,17 @@ defmodule AppBuilder.Utils do
{_, 0} = System.shell(command, opts)
end
def ensure_executable(url) do
ensure_executable(url, :no_verify)
end
def ensure_executable(url, expected_sha256) do
tmp_dir = Path.join(System.tmp_dir!(), Path.basename(url, Path.extname(url)))
path = Path.join(tmp_dir, Path.basename(url))
unless File.exists?(path) do
if File.exists?(path) do
verify(File.read!(path), expected_sha256)
else
File.mkdir_p!(tmp_dir)
body = download_and_verify(url, expected_sha256)
File.write!(path, body)
@ -53,6 +63,10 @@ defmodule AppBuilder.Utils do
body
end
defp verify(data, :no_verify) do
data
end
defp verify(data, expected_sha256) do
actual_sha256 = :crypto.hash(:sha256, data) |> Base.encode16(case: :lower)
@ -74,4 +88,30 @@ defmodule AppBuilder.Utils do
def normalize_icon_path(path_per_os) when is_list(path_per_os) do
Keyword.fetch!(path_per_os, AppBuilder.os())
end
def copy_dir(from, to, options \\ []) do
File.mkdir_p!(Path.dirname(to))
log(:green, "creating", Path.relative_to_cwd(to), options)
File.cp_r!(from, to)
end
def copy_file(source, target, options \\ []) do
create_file(target, File.read!(source), options)
end
def copy_template(source, target, assigns, options \\ []) do
create_file(target, EEx.eval_file(source, assigns: assigns, trim: true), options)
end
def create_file(path, contents, options \\ []) when is_binary(path) do
log(:green, :creating, Path.relative_to_cwd(path), options)
File.mkdir_p!(Path.dirname(path))
File.write!(path, contents)
end
def log(color, command, message, options \\ []) do
unless options[:quiet] do
Mix.shell().info([color, "* #{command} ", :reset, message])
end
end
end

View file

@ -2,7 +2,52 @@ defmodule AppBuilder.Windows do
@moduledoc false
import AppBuilder.Utils
require EEx
@templates_path "#{__ENV__.file}/../../templates"
def bundle(release, options) do
app_name = options[:name]
app_path = "#{Mix.Project.build_path()}/#{app_name}-win"
File.rm_rf!(app_path)
copy_dir(release.path, "#{app_path}/rel")
launcher_eex_path = Path.expand("#{@templates_path}/windows/Launcher.vbs.eex")
launcher_bin_path = "#{app_path}/#{app_name}Launcher.vbs"
copy_template(launcher_eex_path, launcher_bin_path, release: release, app_options: options)
File.mkdir!("#{app_path}/Logs")
manifest_eex_path = Path.expand("#{@templates_path}/windows/Manifest.xml.eex")
manifest_xml_path = "#{app_path}/Manifest.xml"
copy_template(manifest_eex_path, manifest_xml_path, release: release)
rcedit_path = ensure_rcedit()
erl_exe = "#{app_path}/rel/erts-#{release.erts_version}/bin/erl.exe"
log(:green, :updating, Path.relative_to_cwd(erl_exe))
cmd!(rcedit_path, ["--application-manifest", manifest_xml_path, erl_exe])
vcredist_path = ensure_vcredistx64()
copy_file(vcredist_path, "#{app_path}/vcredist_x64.exe")
create_icon(options[:icon_path], "#{app_path}/AppIcon.ico")
for type <- Keyword.fetch!(options, :document_types) do
if src_path = type[:icon_path] do
dest_path = "#{app_path}/#{type.name}Icon.ico"
create_icon(src_path, dest_path)
end
end
installer_eex_path = Path.expand("#{@templates_path}/windows/Installer.nsi.eex")
installer_nsi_path = "#{app_path}/Installer.nsi"
copy_template(installer_eex_path, installer_nsi_path, release: release, app_options: options)
makensis_path = ensure_makensis()
log(:green, "creating", Path.relative_to_cwd("#{app_path}/#{app_name}Install.exe"))
cmd!(makensis_path, [installer_nsi_path])
release
end
def __send_events__(server, input)
@ -27,218 +72,9 @@ defmodule AppBuilder.Windows do
send(server, {:open_file, path})
end
@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,
:icon_path,
:module
])
app_name = Keyword.fetch!(options, :name)
vcredist_path = ensure_vcredistx64()
File.cp!(vcredist_path, Path.join(tmp_dir, "vcredist_x64.exe"))
icon_path = options[:icon_path] || Application.app_dir(:wx, "examples/demo/erlang.png")
app_icon_path = Path.join(tmp_dir, "app_icon.ico")
create_icon(icon_path, app_icon_path)
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")
File.write!(nsi_path, nsi(options))
makensis_path = ensure_makensis()
cmd!(makensis_path, [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
# 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)
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 vcredist_x64.exe
ExecWait '"$INSTDIR\\vcredist_x64.exe" /install'
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" "open_file:%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" "open_url:%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
root = Left(Wscript.ScriptFullName, Len(Wscript.ScriptFullName) - Len(Wscript.ScriptName))
script = root & "rel\bin\<%= release.name %>.bat"
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\vendor\elixir\bin;.\rel\erts-<%= release.erts_version %>\bin;" & env("PATH")
If WScript.Arguments.Count > 0 Then
input = WScript.Arguments(0)
Else
input = "reopen_app"
End If
' Below, we're basically doing:
'
' $ 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)
' 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)
defp ensure_vcredistx64 do
url = "https://aka.ms/vs/17/release/vc_redist.x64.exe"
sha256 = "426a34c6f10ea8f7da58a8c976b586ad84dd4bab42a0cfdbe941f1763b7755e5"
AppBuilder.Utils.ensure_executable(url, sha256)
AppBuilder.Utils.ensure_executable(url)
end
defp ensure_makensis do
@ -259,6 +95,7 @@ defmodule AppBuilder.Windows do
end
defp create_icon(src_path, dest_path) do
log(:green, "creating", Path.relative_to_cwd(dest_path))
src_path = normalize_icon_path(src_path)
if Path.extname(src_path) == ".ico" do

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string><%= @app_options[:name] %>Launcher</string>
<key>CFBundleName</key>
<string><%= @app_options[:name] %></string>
<key>CFBundleDisplayName</key>
<string><%= @app_options[:name] %></string>
<key>CFBundleShortVersionString</key>
<string><%= @release.version %></string>
<key>CFBundleVersion</key>
<string><%= @release.version %></string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<%= if schemes = @app_options[:url_schemes] do %>
<key>CFBundleURLTypes</key>
<array>
<%= for scheme <- schemes do %>
<dict>
<key>CFBundleURLName</key>
<string><%= @app_options[:name] %></string>
<key>CFBundleURLSchemes</key>
<array>
<string><%= scheme %></string>
</array>
</dict>
<% end %>
</array>
<% end %>
<%= if types = @app_options[:document_types] do %>
<key>CFBundleDocumentTypes</key>
<array>
<%= for type <- types do %>
<dict>
<key>CFBundleTypeName</key>
<string><%= type.name %></string>
<key>CFBundleTypeRole</key>
<string><%= type.macos_role %></string>
<key>CFBundleTypeExtensions</key>
<array>
<%= for ext <- type.extensions do %>
<string><%= ext %></string>
<% end %>
</array>
<%= if type[:icon_path] do %>
<key>CFBundleTypeIconFile</key>
<string><%= type.name %>Icon</string>
<% end %>
</dict>
<% end %>
</array>
<% end %>
<%= if @app_options[:macos_is_agent_app] do %>
<key>LSUIElement</key>
<true/>
<% end %>
</dict>
</plist>

View file

@ -0,0 +1,54 @@
<%
additional_paths = [
"rel/erts-#{@release.erts_version}/bin"
] ++ @app_options[:additional_paths]
additional_paths = Enum.map_join(additional_paths, ":", &"\\(resourcePath)/#{&1}")
%>
import Foundation
import Cocoa
func log(_ line: String) {
logFile.write("\(line)\n".data(using: .utf8)!)
}
let fm = FileManager.default
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String
let home = NSHomeDirectory()
let logPath = "\(home)/Library/Logs/\(appName).log"
if !fm.fileExists(atPath: logPath) { fm.createFile(atPath: logPath, contents: Data()) }
let logFile = FileHandle(forUpdatingAtPath: logPath)!
logFile.seekToEndOfFile()
let releaseScriptPath = Bundle.main.path(forResource: "rel/bin/<%= @release.name %>", ofType: "")!
let resourcePath = Bundle.main.resourcePath ?? ""
let additionalPaths = "<%= additional_paths %>"
var environment = ProcessInfo.processInfo.environment
let path = environment["PATH"] ?? ""
environment["PATH"] = "\(additionalPaths):\(path)"
let task = Process()
task.environment = environment
task.launchPath = releaseScriptPath
task.arguments = ["start"]
task.standardOutput = logFile
task.standardError = logFile
log("[\(appName)Launcher] starting release")
try task.run()
log("[\(appName)Launcher] pid: \(task.processIdentifier)")
task.waitUntilExit()
log("[\(appName)Launcher] release exited with \(task.terminationStatus)")
if task.terminationStatus != 0 {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "\(appName) exited with error status \(task.terminationStatus)."
alert.informativeText = "Logs available at: \(logPath)"
alert.runModal()
}

View file

@ -0,0 +1,82 @@
<%
app_name = Keyword.fetch!(@app_options, :name)
%>
!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 "AppIcon.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 vcredist_x64.exe
ExecWait '"$INSTDIR\vcredist_x64.exe" /install'
File /r rel rel
File "<%= app_name %>Launcher.vbs"
File "AppIcon.ico"
CreateDirectory "$INSTDIR\Logs"
WriteUninstaller "$INSTDIR\<%= app_name %>Uninstall.exe"
<%= for type <- Keyword.fetch!(@app_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\<%= type.name %>Icon.ico"
WriteRegStr HKCR "<%= app_name %>.<%= type.name %>\shell\open\command" "" '$WINDIR\system32\wscript.exe "$INSTDIR\<%= app_name %>Launcher.vbs" "open_file:%1"'
<% end %>
<%= for url_scheme <- Keyword.fetch!(@app_options, :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 %>Launcher.vbs" "open_url:%1"'
<% end %>
SectionEnd
Section "Desktop Shortcut"
CreateShortCut "$DESKTOP\<%= app_name %>.lnk" "$INSTDIR\<%= app_name %>Launcher.vbs" "" "$INSTDIR\AppIcon.ico"
SectionEnd
Section "Uninstall"
Delete "$DESKTOP\<%= app_name %>.lnk"
; TODO: stop epmd if it was started
RMDir /r "$INSTDIR"
SectionEnd

View file

@ -0,0 +1,48 @@
' This vbs script avoids a flashing cmd window when launching the release bat file
root = Left(Wscript.ScriptFullName, Len(Wscript.ScriptFullName) - Len(Wscript.ScriptName))
script = root & "rel\bin\<%= @release.name %>.bat"
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") = "<%= Enum.map_join(@app_options[:additional_paths], ";", &String.replace(&1, "/", "\\")) %>" & env("PATH")
If WScript.Arguments.Count > 0 Then
input = WScript.Arguments(0)
Else
input = "reopen_app"
End If
' Below, we're basically doing:
'
' $ 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(@app_options[:server]) %>, String.trim(IO.read(:line)))"""
code = shell.Run("cmd /c " & cmd, 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_options[:name] %>.log 2>&1", 0)

View file

@ -0,0 +1,9 @@
<?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>

115
mix.exs
View file

@ -126,6 +126,8 @@ defmodule Livebook.MixProject do
## Releases
defp releases do
macos_notarization = macos_notarization()
[
livebook: [
include_executables_for: [:unix],
@ -133,32 +135,58 @@ defmodule Livebook.MixProject do
rel_templates_path: "rel/server",
steps: [:assemble, &remove_cookie/1]
],
mac_app: [
include_executables_for: [:unix],
include_erts: false,
rel_templates_path: "rel/app",
steps: [:assemble, &remove_cookie/1, &standalone_erlang_elixir/1, &build_mac_app/1]
],
mac_app_dmg: [
include_executables_for: [:unix],
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],
app: [
include_erts: false,
rel_templates_path: "rel/app",
steps: [
:assemble,
&remove_cookie/1,
&standalone_erlang_elixir/1,
&build_windows_installer/1
&AppBuilder.bundle/1
],
app: [
name: "Livebook",
icon_path: [
macos: "rel/app/icon-macos.png",
windows: "rel/app/icon.ico"
],
url_schemes: ["livebook"],
document_types: [
%{
name: "LiveMarkdown",
extensions: ["livemd"],
icon_path: [
macos: "rel/app/icon.png",
windows: "rel/app/icon.ico"
],
macos_role: "Editor"
}
],
additional_paths: [
"rel/erts-#{:erlang.system_info(:version)}/bin",
"rel/vendor/elixir/bin"
],
server: LivebookApp,
macos_is_agent_app: true,
macos_build_dmg: macos_notarization != nil,
macos_notarization: macos_notarization,
windows_build_installer: true
]
]
]
end
defp macos_notarization do
identity = System.get_env("NOTARIZE_IDENTITY")
team_id = System.get_env("NOTARIZE_TEAM_ID")
apple_id = System.get_env("NOTARIZE_APPLE_ID")
password = System.get_env("NOTARIZE_PASSWORD")
if identity && team_id && apple_id && password do
[identity: identity, team_id: team_id, apple_id: apple_id, password: password]
end
end
defp remove_cookie(release) do
File.rm!(Path.join(release.path, "releases/COOKIE"))
release
@ -171,61 +199,4 @@ defmodule Livebook.MixProject do
|> Standalone.copy_otp()
|> Standalone.copy_elixir(@app_elixir_version)
end
@app_options [
name: "Livebook",
version: @version,
icon_path: [
macos: "rel/app/icon-macos.png",
windows: "rel/app/icon.ico"
],
additional_paths: [
"/rel/erts-#{:erlang.system_info(:version)}/bin",
"/rel/vendor/elixir/bin"
],
url_schemes: ["livebook"],
document_types: [
%{
name: "LiveMarkdown",
extensions: ["livemd"],
icon_path: [
macos: "rel/app/icon.png",
windows: "rel/app/icon.ico"
],
# macos specific
role: "Editor"
}
]
]
defp build_mac_app(release) do
options =
[
is_agent_app: true
] ++ @app_options
AppBuilder.build_mac_app(release, options)
end
defp build_mac_app_dmg(release) do
options =
[
is_agent_app: true,
codesign: [
identity: System.fetch_env!("CODESIGN_IDENTITY")
],
notarize: [
team_id: System.fetch_env!("NOTARIZE_TEAM_ID"),
apple_id: System.fetch_env!("NOTARIZE_APPLE_ID"),
password: System.fetch_env!("NOTARIZE_PASSWORD")
]
] ++ @app_options
AppBuilder.build_mac_app_dmg(release, options)
end
defp build_windows_installer(release) do
options = Keyword.drop(@app_options, [:additional_paths]) ++ [module: LivebookApp]
AppBuilder.build_windows_installer(release, options)
end
end