Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

macOS App includes elixir, erlang for standalone mode. #929

Merged
merged 11 commits into from
Jan 24, 2022
25 changes: 20 additions & 5 deletions app_builder/lib/app_builder/macos.ex
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,12 @@ defmodule AppBuilder.MacOS do
:logo_path,
:info_plist,
:url_schemes,
:document_types
:document_types,
:additional_paths
])

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)
Expand All @@ -109,7 +111,7 @@ defmodule AppBuilder.MacOS do

File.mkdir_p!("tmp")
launcher_src_path = "tmp/Launcher.swift"
File.write!(launcher_src_path, launcher())
File.write!(launcher_src_path, launcher(additional_paths))
launcher_path = Path.join([app_bundle_path, "Contents", "MacOS", app_name <> "Launcher"])
File.mkdir_p!(Path.dirname(launcher_path))

Expand All @@ -131,7 +133,11 @@ defmodule AppBuilder.MacOS do
release
end

defp launcher do
defp launcher(additional_paths) do
additional_paths = additional_paths
|> Enum.map(&("\\(resourcePath)#{&1}"))
|> Enum.join(":")
Comment on lines +137 to +139
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
additional_paths = additional_paths
|> Enum.map(&("\\(resourcePath)#{&1}"))
|> Enum.join(":")
additional_paths = Enum.map_join(additional_paths, ":", &"\\(resourcePath)#{&1}")


"""
import Foundation
import Cocoa
Expand All @@ -147,7 +153,16 @@ defmodule AppBuilder.MacOS do

let releaseScriptPath = Bundle.main.path(forResource: "rel/bin/mac_app", 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
Expand Down Expand Up @@ -244,9 +259,9 @@ defmodule AppBuilder.MacOS do
<key>CFBundleDisplayName</key>
<string><%= app_name %></string>
<key>CFBundleShortVersionString</key>
<string><%= app_version %>on}</string>
<string><%= app_version %></string>
<key>CFBundleVersion</key>
<string><%= app_version %>on}</string>
<string><%= app_version %></string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
Expand Down
15 changes: 13 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,15 @@ defmodule Livebook.MixProject do
],
mac_app: [
include_executables_for: [:unix],
include_erts: false,
rel_templates_path: "rel/app",
steps: [:assemble, &build_mac_app/1]
steps: [:assemble, &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, &build_mac_app_dmg/1]
steps: [:assemble, &standalone_erlang_elixir/1, &build_mac_app_dmg/1]
]
]
end
Expand All @@ -131,11 +133,20 @@ defmodule Livebook.MixProject do
version: @version,
logo_path: "static/images/logo.png",
url_schemes: ["livebook"],
additional_paths: ["/rel/vendor/erts/bin", "/rel/vendor/elixir/bin"],
paridin marked this conversation as resolved.
Show resolved Hide resolved
document_types: [
%{name: "LiveMarkdown", role: "Editor", extensions: ["livemd"]}
]
]

defp standalone_erlang_elixir(release) do
Code.require_file("rel/app/standalone.exs")

release
|> Standalone.copy_erlang()
|> Standalone.copy_elixir("1.13.2")
end

defp build_mac_app(release) do
AppBuilder.build_mac_app(release, @app_options)
end
Expand Down
160 changes: 160 additions & 0 deletions rel/app/standalone.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
defmodule Standalone do
@moduledoc false
require Logger

@doc """
Copies ERTS into the release.
"""
@spec copy_erlang(Mix.Release.t()) :: Mix.Release.t()
def copy_erlang(release) do
{erts_source, erts_bin_dir, erts_lib_dir, _erts_version} = erts_data()

erts_destination_source = Path.join(release.path, "vendor/erts/bin")
File.mkdir_p!(erts_destination_source)

erts_source
|> Path.join("bin")
|> File.cp_r!(erts_destination_source, fn _, _ -> false end)

_ = 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+"$@"}
""")
executable!(Path.join(erts_destination_source, "erl"))

# Copy lib
erts_destination_lib = Path.join(release.path, "/vendor/lib")
File.mkdir_p!(erts_destination_lib)

erts_lib_dir
|> File.cp_r!(erts_destination_lib, fn _, _ -> false end)

# copy start.boot to <resource_path>/rel/bin
erts_destination_bin = Path.join(release.path, "/vendor/bin")
start_boot_file = Path.join(erts_destination_bin, "start.boot")
File.mkdir_p!(erts_destination_bin)

erts_bin_dir
|> Path.join("start.boot")
|> File.cp!(start_boot_file, fn _, _ -> false end)

%{release | erts_source: erts_source}
end

@erts_bin [~s[ERTS_BIN="$ERTS_BIN"], ~s[ERTS_BIN=!ERTS_BIN!]]

defp replace_erts_bin(contents, release, new_path) do
if release.erts_source do
String.replace(contents, @erts_bin, ~s[ERTS_BIN=#{new_path}])
else
contents
end
end

@doc """
Copies elixir into the release.
"""
@spec copy_elixir(Mix.Release.t(), elixir_version :: String.t()) :: Mix.Release.t()
def copy_elixir(release, elixir_version) do
include_executables_for = Keyword.get(release.options, :include_executables_for, [:unix])

# download and unzip
standalone_destination = Path.join(release.path, "vendor/elixir")
download_elixir_at_destination(standalone_destination, elixir_version)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that we're already downloading Elixir in out bootstrap script https://github.com/livebook-dev/livebook/blob/main/.github/scripts/app/bootstrap_mac.sh#L123:L124 so we don't need to do it again. I think it's better if this function really just copies the currently running Elixir, similarly how copy_erlang does that for OTP. This way it's going to be super easy to test it with Elixir main when developing too.

Copy link
Contributor

@josevalim josevalim Jan 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am worried though that we will have even less control over how Elixir is installed. For example, some package installers like to change the bin files. :'(

The downside of using the Elixir precompiled though is that it uses the minimum OTP version (at least until Elixir v1.14) and if you are building from scratch then it is guaranteed you compiled it to the latest.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair. But we'll always be building Livebook.app with our bootstrap script where we have full control so I don't think that's gonna be an issue in practice. We need to be running the bootstrap script because we need to have full control over OTP for exactly same reason.


# patch elixir file to look for the right erts <resource_path>/rel/vendor/bin/elixir
patch_elixir(include_executables_for, release,
fn filename ->
Path.join([standalone_destination, "bin", filename])
end,
fn filename ->
Path.join([standalone_destination, "bin", filename])
end
)

release
end

defp download_elixir_at_destination(destination, elixir_version) do
url = "https://github.com/elixir-lang/elixir/releases/download/v#{elixir_version}/Precompiled.zip"
binary = fetch_body!(url)
File.write!("/tmp/elixir_#{elixir_version}.zip", binary, [:binary])
:zip.unzip('/tmp/elixir_#{elixir_version}.zip', cwd: destination)
end

defp patch_elixir(include_executables_for, release, fn_source, fn_target) do
for os <- include_executables_for do
for {filename, contents_fun} <- elixir_cli_for(os, release) do
source = fn_source.(filename)

if File.regular?(source) do
target = fn_target.(filename)

File.write!(target, contents_fun.(source))
executable!(target)

else
skipping("#{filename} for #{os} (bin/#{filename} not found in the Elixir installation)")
end
end
end
end

defp elixir_cli_for(:unix, release) do
[
{"elixir",
&(&1
|> File.read!()
|> replace_erts_bin(release, ~s["$SCRIPT_PATH"/../../erts/bin/]))},
paridin marked this conversation as resolved.
Show resolved Hide resolved
{"iex", &File.read!/1}
]
end

defp elixir_cli_for(:windows, release) do
[
{"elixir.bat",
&(&1
|> File.read!()
|> replace_erts_bin(release, ~s[%~dp0\\..\\..\\erts\\bin\\]))},
paridin marked this conversation as resolved.
Show resolved Hide resolved
{"iex.bat", &File.read!/1}
]
end

defp erts_data do
version = :erlang.system_info(:version)
{:filename.join(:code.root_dir(), 'erts-#{version}'), :filename.join(:code.root_dir(), 'bin'), :code.lib_dir(), version}
end

defp fetch_body!(url) do
Logger.debug("Downloading elixir from #{url}")
case Livebook.Utils.HTTP.request(:get, url, [timeout: :infinity]) do
{:ok, 200, _headers, body} ->
body

{:error, error} ->
raise "couldn't fetch #{url}: #{inspect(error)}"
end
end

defp skipping(message) do
Mix.shell().info([:yellow, "* skipping ", :reset, message])
end

defp executable!(path), do: File.chmod!(path, 0o755)

end