diff --git a/.github/scripts/app/bootstrap_mac.sh b/.github/scripts/app/bootstrap_mac.sh
new file mode 100644
index 000000000..404090560
--- /dev/null
+++ b/.github/scripts/app/bootstrap_mac.sh
@@ -0,0 +1,142 @@
+#!/bin/bash
+set -e
+
+main() {
+ export MAKEFLAGS=-j$(getconf _NPROCESSORS_ONLN)
+
+ wxwidgets_vsn="3.1.5"
+ otp_vsn="24.2"
+ elixir_vsn="1.13.1"
+
+ target=$(target)
+
+ mkdir -p tmp
+
+ if [ ! -d "tmp/wxwidgets-${wxwidgets_vsn}-$target" ]; then
+ build_wxwidgets $wxwidgets_vsn $target
+ fi
+
+ export PATH=$PWD/tmp/wxwidgets-${wxwidgets_vsn}-$target/bin:$PATH
+ echo "wx"
+ file `which wxrc`
+ wx-config --version
+ echo
+
+ openssl_dir=$(brew --prefix openssl@1.1)
+
+ if [ ! -d "tmp/otp-${otp_vsn}-$target" ]; then
+ build_otp $otp_vsn $target $openssl_dir
+ fi
+
+ export PATH=$PWD/tmp/otp-${otp_vsn}-$target/bin:$PATH
+ echo "otp"
+ file `which erlc`
+ erl +V
+ erl -noshell -eval 'ok = crypto:start(), io:format("crypto ok~n"), halt().'
+ erl -noshell -eval '{wx_ref,_,_,_} = wx:new(), io:format("wx ok~n"), halt().'
+ echo
+
+ if [ ! -d "tmp/elixir-${elixir_vsn}" ]; then
+ build_elixir $elixir_vsn
+ fi
+
+ export PATH=$PWD/tmp/elixir-${elixir_vsn}/bin:$PATH
+ echo "elixir"
+ elixir --version
+
+ cat << EOF > tmp/bootstrap_env.sh
+export PATH=\$PWD/tmp/otp-${otp_vsn}-${target}/bin:\$PATH
+export PATH=\$PWD/tmp/elixir-${elixir_vsn}/bin:\$PATH
+EOF
+}
+
+build_wxwidgets() {
+ vsn=$1
+ target=$2
+
+ otp_bootstrap_root=$PWD
+ cd tmp
+
+ if [ ! -d wxwidgets-$vsn-src ]; then
+ url=https://github.com/wxWidgets/wxWidgets/releases/download/v$vsn/wxWidgets-$vsn.tar.bz2
+ echo downloading $url
+ curl --fail -LO $url
+ tar -xf wxWidgets-$vsn.tar.bz2
+ mv wxWidgets-$vsn wxwidgets-$vsn-src
+ fi
+
+ cd wxwidgets-$vsn-src
+ ./configure \
+ --disable-shared \
+ --prefix=$otp_bootstrap_root/tmp/wxwidgets-$vsn-$target \
+ --with-cocoa \
+ --with-macosx-version-min=10.15 \
+ --with-libjpeg=builtin \
+ --with-libtiff=builtin \
+ --with-libpng=builtin \
+ --with-liblzma=builtin \
+ --with-zlib=builtin \
+ --with-expat=builtin
+
+ make
+ make install
+ cd $otp_bootstrap_root
+}
+
+build_otp() {
+ vsn=$1
+ target=$2
+ openssl_dir=$3
+
+ otp_bootstrap_root=$PWD
+ cd tmp
+ curl --fail -LO https://github.com/erlang/otp/releases/download/OTP-${vsn}/otp_src_${vsn}.tar.gz
+ tar -xf otp_src_${vsn}.tar.gz
+
+ cd otp_src_${vsn}
+
+ export ERL_TOP=`pwd`
+ export RELEASE_ROOT=$otp_bootstrap_root/tmp/otp-$vsn-$target
+
+ ./otp_build configure \
+ --disable-dynamic-ssl-lib \
+ --with-ssl=$openssl_dir
+
+ ./otp_build boot -a
+ ./otp_build release -a $RELEASE_ROOT
+ make release_docs DOC_TARGETS=chunks
+
+ cd $RELEASE_ROOT
+ ./Install -sasl $PWD
+ ./bin/erl -noshell -eval 'io:format("~s", [erlang:system_info(system_version)]), halt().'
+ ./bin/erl -noshell -eval 'ok = crypto:start(), halt().'
+ ./bin/erl -noshell -eval '{wx_ref,_,_,_} = wx:new(), halt().'
+ cd ../..
+}
+
+build_elixir() {
+ vsn=$1
+ otp_release=$(erl -noshell -eval 'io:format("~s", [erlang:system_info(otp_release)]), halt().')
+
+ cd tmp
+ # TODO: On Elixir 1.14, use https://github.com/elixir-lang/elixir/releases/download/v${vsn}/elixir-${vsn}-otp-${otp_release}.zip
+ url=https://repo.hex.pm/builds/elixir/v${vsn}-otp-${otp_release}.zip
+ curl --fail -LO $url
+ mkdir elixir-$vsn
+ unzip v${vsn}-otp-${otp_release}.zip -d elixir-$vsn
+ cd ..
+}
+
+target() {
+ os=$(uname -s | tr '[:upper:]' '[:lower:]')
+
+ arch=$(uname -m)
+ case $arch in
+ "arm64") arch="aarch64";;
+ *) ;;
+ esac
+
+ echo "$arch-$os"
+}
+
+main
diff --git a/.github/scripts/app/build_mac.sh b/.github/scripts/app/build_mac.sh
new file mode 100644
index 000000000..d3955425d
--- /dev/null
+++ b/.github/scripts/app/build_mac.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+# Usage:
+#
+# $ sh .github/scripts/app/build_mac.sh
+# $ open _build/app_prod/rel/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/bootstrap_mac.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
diff --git a/app_builder/.formatter.exs b/app_builder/.formatter.exs
new file mode 100644
index 000000000..d2cda26ed
--- /dev/null
+++ b/app_builder/.formatter.exs
@@ -0,0 +1,4 @@
+# Used by "mix format"
+[
+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
+]
diff --git a/app_builder/.gitignore b/app_builder/.gitignore
new file mode 100644
index 000000000..6f575ebd7
--- /dev/null
+++ b/app_builder/.gitignore
@@ -0,0 +1,26 @@
+# The directory Mix will write compiled artifacts to.
+/_build/
+
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where third-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Ignore .fetch files in case you like to edit your project deps locally.
+/.fetch
+
+# If the VM crashes, it generates a dump, let's ignore it too.
+erl_crash.dump
+
+# Also ignore archive artifacts (built via "mix archive.build").
+*.ez
+
+# Ignore package tarball (built via "mix hex.build").
+app_builder-*.tar
+
+# Temporary files, for example, from tests.
+/tmp/
diff --git a/app_builder/README.md b/app_builder/README.md
new file mode 100644
index 000000000..ee582adf5
--- /dev/null
+++ b/app_builder/README.md
@@ -0,0 +1 @@
+# AppBuilder
diff --git a/app_builder/examples/wx_demo/.formatter.exs b/app_builder/examples/wx_demo/.formatter.exs
new file mode 100644
index 000000000..d2cda26ed
--- /dev/null
+++ b/app_builder/examples/wx_demo/.formatter.exs
@@ -0,0 +1,4 @@
+# Used by "mix format"
+[
+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
+]
diff --git a/app_builder/examples/wx_demo/.gitignore b/app_builder/examples/wx_demo/.gitignore
new file mode 100644
index 000000000..6d26d1cbb
--- /dev/null
+++ b/app_builder/examples/wx_demo/.gitignore
@@ -0,0 +1,28 @@
+# The directory Mix will write compiled artifacts to.
+/_build/
+
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where third-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Ignore .fetch files in case you like to edit your project deps locally.
+/.fetch
+
+# If the VM crashes, it generates a dump, let's ignore it too.
+erl_crash.dump
+
+# Also ignore archive artifacts (built via "mix archive.build").
+*.ez
+
+# Ignore package tarball (built via "mix hex.build").
+wx_demo-*.tar
+
+# Temporary files, for example, from tests.
+/tmp/
+
+/test.sh
diff --git a/app_builder/examples/wx_demo/README.md b/app_builder/examples/wx_demo/README.md
new file mode 100644
index 000000000..312f8e889
--- /dev/null
+++ b/app_builder/examples/wx_demo/README.md
@@ -0,0 +1 @@
+# WxDemo
diff --git a/app_builder/examples/wx_demo/lib/wx_demo.ex b/app_builder/examples/wx_demo/lib/wx_demo.ex
new file mode 100644
index 000000000..4bb45f79a
--- /dev/null
+++ b/app_builder/examples/wx_demo/lib/wx_demo.ex
@@ -0,0 +1,104 @@
+defmodule WxDemo.Application do
+ @moduledoc false
+
+ use Application
+
+ @impl true
+ def start(_type, _args) do
+ children = [
+ WxDemo.Window
+ ]
+
+ opts = [strategy: :one_for_one, name: WxDemo.Supervisor]
+ Supervisor.start_link(children, opts)
+ end
+end
+
+defmodule WxDemo.Window do
+ @moduledoc false
+
+ @behaviour :wx_object
+
+ # https://github.com/erlang/otp/blob/OTP-24.1.2/lib/wx/include/wx.hrl#L1314
+ @wx_id_exit 5006
+
+ def start_link(_) do
+ {:wx_ref, _, _, pid} = :wx_object.start_link(__MODULE__, [], [])
+ {:ok, pid}
+ end
+
+ def child_spec(init_arg) do
+ %{
+ id: __MODULE__,
+ start: {__MODULE__, :start_link, [init_arg]},
+ restart: :transient
+ }
+ end
+
+ @impl true
+ def init(_) do
+ title = "WxDemo"
+
+ wx = :wx.new()
+ frame = :wxFrame.new(wx, -1, title)
+
+ if 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}
+ {frame, state}
+ end
+
+ @impl true
+ def handle_event({:wx, @wx_id_exit, _, _, _}, state) do
+ :init.stop()
+ {:stop, :normal, state}
+ end
+
+ @impl true
+ def handle_event({:wx, _, _, _, {:wxClose, :close_window}}, state) do
+ :init.stop()
+ {:stop, :normal, state}
+ end
+
+ @impl true
+ def handle_info({:open_url, url}, state) do
+ :wxMessageDialog.new(state.frame, inspect(url))
+ |> :wxDialog.showModal()
+
+ {:noreply, state}
+ end
+
+ @impl true
+ # ignore other events
+ def handle_info(_event, state) do
+ {:noreply, state}
+ end
+
+ defp fixup_macos_menubar(frame, title) do
+ menubar = :wxMenuBar.new()
+ # :wxMenuBar.setAutoWindowMenu(false)
+ :wxFrame.setMenuBar(frame, menubar)
+
+ # App Menu
+ menu = :wxMenuBar.oSXGetAppleMenu(menubar)
+
+ # Remove all items except for quit
+ for item <- :wxMenu.getMenuItems(menu) do
+ if :wxMenuItem.getId(item) == @wx_id_exit do
+ :wxMenuItem.setText(item, "Quit #{title}\tCtrl+Q")
+ else
+ :wxMenu.delete(menu, item)
+ end
+ end
+ end
+
+ defp macOS?() do
+ :os.type() == {:unix, :darwin}
+ end
+end
diff --git a/app_builder/examples/wx_demo/mix.exs b/app_builder/examples/wx_demo/mix.exs
new file mode 100644
index 000000000..425dadd49
--- /dev/null
+++ b/app_builder/examples/wx_demo/mix.exs
@@ -0,0 +1,61 @@
+defmodule WxDemo.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :wx_demo,
+ version: "0.1.0",
+ elixir: "~> 1.13",
+ start_permanent: Mix.env() == :prod,
+ deps: deps(),
+ releases: releases()
+ ]
+ end
+
+ def application do
+ [
+ extra_applications: [:wx, :logger],
+ mod: {WxDemo.Application, []}
+ ]
+ end
+
+ defp deps do
+ [
+ {:app_builder, path: "../.."}
+ ]
+ end
+
+ defp releases do
+ options = [
+ name: "WxDemo",
+ url_schemes: ["wxdemo"]
+ ]
+
+ [
+ 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)]
+ ]
+ ]
+ 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
+
+ AppBuilder.build_mac_app_dmg(release, options)
+ end
+end
diff --git a/app_builder/examples/wx_demo/test/test_helper.exs b/app_builder/examples/wx_demo/test/test_helper.exs
new file mode 100644
index 000000000..869559e70
--- /dev/null
+++ b/app_builder/examples/wx_demo/test/test_helper.exs
@@ -0,0 +1 @@
+ExUnit.start()
diff --git a/app_builder/examples/wx_demo/test/wx_demo_test.exs b/app_builder/examples/wx_demo/test/wx_demo_test.exs
new file mode 100644
index 000000000..d4103b2b8
--- /dev/null
+++ b/app_builder/examples/wx_demo/test/wx_demo_test.exs
@@ -0,0 +1,3 @@
+defmodule WxDemoTest do
+ use ExUnit.Case, async: true
+end
diff --git a/app_builder/lib/app_builder.ex b/app_builder/lib/app_builder.ex
new file mode 100644
index 000000000..129f536b6
--- /dev/null
+++ b/app_builder/lib/app_builder.ex
@@ -0,0 +1,5 @@
+defmodule AppBuilder do
+ defdelegate build_mac_app(release, options), to: AppBuilder.MacOS
+
+ defdelegate build_mac_app_dmg(release, options), to: AppBuilder.MacOS
+end
diff --git a/app_builder/lib/app_builder/macos.ex b/app_builder/lib/app_builder/macos.ex
new file mode 100644
index 000000000..acd1b4102
--- /dev/null
+++ b/app_builder/lib/app_builder/macos.ex
@@ -0,0 +1,242 @@
+defmodule AppBuilder.MacOS do
+ @moduledoc false
+
+ import AppBuilder.Utils
+
+ def build_mac_app_dmg(release, options) do
+ {codesign, options} = Keyword.pop(options, :codesign)
+ {notarize, options} = Keyword.pop(options, :notarize)
+
+ release = build_mac_app(release, options)
+
+ app_name = Keyword.fetch!(options, :name)
+ File.rm_rf!("tmp/dmg")
+ File.mkdir_p!("tmp/dmg")
+ File.ln_s!("/Applications", "tmp/dmg/Applications")
+
+ File.cp_r!(
+ Path.join([Mix.Project.build_path(), "rel", "#{app_name}.app"]),
+ "tmp/dmg/#{app_name}.app"
+ )
+
+ to_sign =
+ "tmp/dmg/#{app_name}.app/**"
+ |> 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"]
+
+ if codesign do
+ entitlements_path = "tmp/entitlements.plist"
+
+ File.write!(entitlements_path, """
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+
+
+ """)
+
+ codesign(to_sign, "--options=runtime --entitlements=#{entitlements_path}", codesign)
+ end
+
+ 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)
+
+ cmd!(
+ "hdiutil",
+ ~w(create #{tmp_dmg_path} -ov -volname #{app_name}Install -fs HFS+ -srcfolder tmp/dmg)
+ )
+
+ cmd!(
+ "hdiutil",
+ ~w(convert #{tmp_dmg_path} -format UDZO -o #{dmg_path})
+ )
+
+ 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
+ identity = Keyword.fetch!(options, :identity)
+ paths = Enum.join(paths, " ")
+ shell!("codesign --force --timestamp --verbose=4 --sign=\"#{identity}\" #{args} #{paths}")
+ end
+
+ defp notarize(path, options) do
+ team_id = Keyword.fetch!(options, :team_id)
+ apple_id = Keyword.fetch!(options, :apple_id)
+ password = Keyword.fetch!(options, :password)
+
+ shell!("""
+ xcrun notarytool submit \
+ --team-id "#{team_id}" \
+ --apple-id "#{apple_id}" \
+ --password "#{password}" \
+ --progress \
+ --wait \
+ #{path}
+ """)
+ end
+
+ def build_mac_app(release, options) do
+ options =
+ Keyword.validate!(options, [
+ :name,
+ :launcher_script,
+ :logo_path,
+ :info_plist,
+ :url_schemes,
+ :document_types
+ ])
+
+ app_name = Keyword.fetch!(options, :name)
+
+ 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"]))
+
+ launcher_script = options[:launcher_script] || launcher_script(release.name, app_name)
+ launcher_script_path = Path.join([app_bundle_path, "Contents", "MacOS", app_name])
+ File.mkdir_p!(Path.dirname(launcher_script_path))
+ File.write!(launcher_script_path, launcher_script)
+ File.chmod!(launcher_script_path, 0o700)
+
+ logo_path = options[:logo_path] || Application.app_dir(:wx, "examples/demo/erlang.png")
+ create_logo(app_bundle_path, logo_path)
+
+ info_plist = options[:info_plist] || build_info_plist(options)
+ File.write!(Path.join([app_bundle_path, "Contents", "Info.plist"]), info_plist)
+
+ release
+ end
+
+ defp launcher_script(release_name, app_name) do
+ """
+ #!/bin/sh
+ set -e
+ root=$(dirname $(dirname "$0"))
+ $root/Resources/rel/bin/#{release_name} start \
+ 1>> ~/Library/Logs/#{app_name}.stdout.log \
+ 2>> ~/Library/Logs/#{app_name}.stderr.log
+ """
+ end
+
+ defp build_info_plist(options) do
+ app_name = Keyword.fetch!(options, :name)
+
+ url_schemes =
+ """
+ \nCFBundleURLTypes
+
+ """ <>
+ for scheme <- options[:url_schemes] || [], into: "" do
+ """
+
+ CFBundleURLName
+ #{app_name}
+ CFBundleURLSchemes
+
+ #{scheme}
+
+
+ """
+ end <>
+ ""
+
+ document_types =
+ """
+ \nCFBundleDocumentTypes
+
+ """ <>
+ for type <- options[:document_types] || [], into: "" do
+ """
+
+ CFBundleTypeName
+ #{type.name}
+ CFBundleTypeRole
+ #{type.role}
+ CFBundleTypeExtensions
+
+ #{for ext <- type.extensions, do: "#{ext}"}
+
+
+ """
+ end <>
+ ""
+
+ """
+
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ #{app_name}
+ CFBundleDisplayName
+ #{app_name}
+ CFBundleIconFile
+ AppIcon
+ CFBundleIconName
+ AppIcon#{url_schemes}#{document_types}
+
+
+ """
+ end
+
+ defp create_logo(app_bundle_path, logo_source_path) do
+ logo_dest_path = Path.join([app_bundle_path, "Contents", "Resources", "AppIcon.icns"])
+
+ if Path.extname(logo_source_path) == ".icns" do
+ File.cp!(logo_source_path, logo_dest_path)
+ else
+ logo_dest_tmp_path = "tmp/AppIcon.iconset"
+ File.rm_rf!(logo_dest_tmp_path)
+ File.mkdir_p!(logo_dest_tmp_path)
+
+ sizes = for(i <- [16, 32, 64, 128], j <- [1, 2], do: {i, j}) ++ [{512, 1}]
+
+ for {size, scale} <- sizes do
+ suffix =
+ case scale do
+ 1 -> ""
+ 2 -> "@2x"
+ end
+
+ size = size * scale
+ out = "#{logo_dest_tmp_path}/icon_#{size}x#{size}#{suffix}.png"
+ cmd!("sips", ~w(-z #{size} #{size} #{logo_source_path} --out #{out}))
+ end
+
+ cmd!("iconutil", ~w(-c icns #{logo_dest_tmp_path} -o #{logo_dest_path}))
+ File.rm_rf!(logo_dest_tmp_path)
+ end
+ end
+end
diff --git a/app_builder/lib/app_builder/utils.ex b/app_builder/lib/app_builder/utils.ex
new file mode 100644
index 000000000..5699fc423
--- /dev/null
+++ b/app_builder/lib/app_builder/utils.ex
@@ -0,0 +1,13 @@
+defmodule AppBuilder.Utils do
+ @moduledoc false
+
+ def cmd!(bin, args, opts \\ []) do
+ opts = Keyword.put_new(opts, :into, IO.stream())
+ {_, 0} = System.cmd(bin, args, opts)
+ end
+
+ def shell!(command, opts \\ []) do
+ opts = Keyword.put_new(opts, :into, IO.stream())
+ {_, 0} = System.shell(command, opts)
+ end
+end
diff --git a/app_builder/mix.exs b/app_builder/mix.exs
new file mode 100644
index 000000000..27c8b19ff
--- /dev/null
+++ b/app_builder/mix.exs
@@ -0,0 +1,23 @@
+defmodule AppBuilder.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :app_builder,
+ version: "0.1.0",
+ elixir: "~> 1.13",
+ start_permanent: Mix.env() == :prod,
+ deps: deps()
+ ]
+ end
+
+ def application do
+ [
+ extra_applications: [:logger]
+ ]
+ end
+
+ defp deps do
+ []
+ end
+end
diff --git a/app_builder/test/app_builder_test.exs b/app_builder/test/app_builder_test.exs
new file mode 100644
index 000000000..a37991f2c
--- /dev/null
+++ b/app_builder/test/app_builder_test.exs
@@ -0,0 +1,3 @@
+defmodule AppBuilderTest do
+ use ExUnit.Case, async: true
+end
diff --git a/app_builder/test/test_helper.exs b/app_builder/test/test_helper.exs
new file mode 100644
index 000000000..869559e70
--- /dev/null
+++ b/app_builder/test/test_helper.exs
@@ -0,0 +1 @@
+ExUnit.start()
diff --git a/lib/livebook/application.ex b/lib/livebook/application.ex
index c79f048c4..d20173e26 100644
--- a/lib/livebook/application.ex
+++ b/lib/livebook/application.ex
@@ -10,24 +10,25 @@ defmodule Livebook.Application do
validate_hostname_resolution!()
set_cookie()
- children = [
- # Start the Telemetry supervisor
- LivebookWeb.Telemetry,
- # Start the PubSub system
- {Phoenix.PubSub, name: Livebook.PubSub},
- # Start the tracker server on this node
- {Livebook.Tracker, pubsub_server: Livebook.PubSub},
- # Start the supervisor dynamically managing sessions
- {DynamicSupervisor, name: Livebook.SessionSupervisor, strategy: :one_for_one},
- # Start the server responsible for associating files with sessions
- Livebook.Session.FileGuard,
- # Start the Node Pool for managing node names
- Livebook.Runtime.NodePool,
- # Start the unique task dependencies
- Livebook.UniqueTask,
- # Start the Endpoint (http/https)
- LivebookWeb.Endpoint
- ]
+ children =
+ [
+ # Start the Telemetry supervisor
+ LivebookWeb.Telemetry,
+ # Start the PubSub system
+ {Phoenix.PubSub, name: Livebook.PubSub},
+ # Start the tracker server on this node
+ {Livebook.Tracker, pubsub_server: Livebook.PubSub},
+ # Start the supervisor dynamically managing sessions
+ {DynamicSupervisor, name: Livebook.SessionSupervisor, strategy: :one_for_one},
+ # Start the server responsible for associating files with sessions
+ Livebook.Session.FileGuard,
+ # Start the Node Pool for managing node names
+ Livebook.Runtime.NodePool,
+ # Start the unique task dependencies
+ Livebook.UniqueTask,
+ # Start the Endpoint (http/https)
+ LivebookWeb.Endpoint
+ ] ++ app_specs()
opts = [strategy: :one_for_one, name: Livebook.Supervisor]
@@ -157,4 +158,10 @@ defmodule Livebook.Application do
defp config_env_var?("LIVEBOOK_" <> _), do: true
defp config_env_var?("RELEASE_" <> _), do: true
defp config_env_var?(_), do: false
+
+ if Mix.target() == :app do
+ defp app_specs, do: [LivebookApp]
+ else
+ defp app_specs, do: []
+ end
end
diff --git a/lib/livebook/utils.ex b/lib/livebook/utils.ex
index 7a6e2bd4f..bc19a1d5c 100644
--- a/lib/livebook/utils.ex
+++ b/lib/livebook/utils.ex
@@ -304,6 +304,20 @@ defmodule Livebook.Utils do
"data:#{mime};base64,#{data}"
end
+ @doc """
+ Opens the given `url` in the browser.
+ """
+ def browser_open(url) do
+ {cmd, args} =
+ case :os.type() do
+ {:win32, _} -> {"cmd", ["/c", "start", url]}
+ {:unix, :darwin} -> {"open", [url]}
+ {:unix, _} -> {"xdg-open", [url]}
+ end
+
+ System.cmd(cmd, args)
+ end
+
@doc """
Splits the given string at the last occurrence of `pattern`.
diff --git a/lib/livebook_app.ex b/lib/livebook_app.ex
new file mode 100644
index 000000000..609ccf5c3
--- /dev/null
+++ b/lib/livebook_app.ex
@@ -0,0 +1,120 @@
+if Mix.target() == :app do
+ defmodule LivebookApp do
+ @moduledoc false
+
+ @behaviour :wx_object
+
+ # https://github.com/erlang/otp/blob/OTP-24.1.2/lib/wx/include/wx.hrl#L1314
+ @wx_id_exit 5006
+
+ def start_link(_) do
+ {:wx_ref, _, _, pid} = :wx_object.start_link(__MODULE__, [], [])
+ {:ok, pid}
+ end
+
+ def child_spec(init_arg) do
+ %{
+ id: __MODULE__,
+ start: {__MODULE__, :start_link, [init_arg]},
+ restart: :transient
+ }
+ end
+
+ @impl true
+ def init(_) do
+ title = "Livebook"
+
+ wx = :wx.new()
+ frame = :wxFrame.new(wx, -1, title, size: {0, 0})
+
+ if 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}
+
+ Livebook.Utils.browser_open(LivebookWeb.Endpoint.access_url())
+
+ {frame, state}
+ end
+
+ @impl true
+ def handle_event({:wx, @wx_id_exit, _, _, _}, state) do
+ :init.stop()
+ {:stop, :normal, state}
+ end
+
+ @impl true
+ def handle_event({:wx, _, _, _, {:wxClose, :close_window}}, state) do
+ :init.stop()
+ {:stop, :normal, 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, url}, state) do
+ 'livebook://' ++ rest = url
+ import_livebook("https://#{rest}")
+ {:noreply, state}
+ end
+
+ @impl true
+ def handle_info({:open_file, path}, state) do
+ import_livebook("file://#{path}")
+ {:noreply, state}
+ end
+
+ # ignore other events
+ @impl true
+ def handle_info(_event, state) do
+ {:noreply, state}
+ end
+
+ defp import_livebook(url) do
+ %{
+ URI.parse(LivebookWeb.Endpoint.access_url())
+ | path: "/import"
+ }
+ |> append_query("url=#{URI.encode_www_form(url)}")
+ |> URI.to_string()
+ |> Livebook.Utils.browser_open()
+ end
+
+ defp fixup_macos_menubar(frame, title) do
+ menubar = :wxMenuBar.new()
+ :wxFrame.setMenuBar(frame, menubar)
+
+ # App Menu
+ menu = :wxMenuBar.oSXGetAppleMenu(menubar)
+
+ # Remove all items except for quit
+ for item <- :wxMenu.getMenuItems(menu) do
+ if :wxMenuItem.getId(item) == @wx_id_exit do
+ :wxMenuItem.setText(item, "Quit #{title}\tCtrl+Q")
+ else
+ :wxMenu.delete(menu, item)
+ end
+ end
+ end
+
+ defp macos?() do
+ :os.type() == {:unix, :darwin}
+ end
+
+ # TODO: On Elixir v1.14, use URI.append_query/2
+ defp append_query(%URI{query: query} = uri, query_to_add) when query in [nil, ""] do
+ %{uri | query: query_to_add}
+ end
+
+ defp append_query(%URI{} = uri, query) do
+ %{uri | query: uri.query <> "&" <> query}
+ end
+ end
+end
diff --git a/lib/livebook_cli/server.ex b/lib/livebook_cli/server.ex
index 4815f0897..3964eaf48 100644
--- a/lib/livebook_cli/server.ex
+++ b/lib/livebook_cli/server.ex
@@ -118,13 +118,13 @@ defmodule LivebookCLI.Server do
defp open_from_options(base_url, opts) do
if opts[:open] do
- browser_open(base_url)
+ Livebook.Utils.browser_open(base_url)
end
if opts[:open_new] do
base_url
|> append_path("/explore/notebooks/new")
- |> browser_open()
+ |> Livebook.Utils.browser_open()
end
end
@@ -213,17 +213,6 @@ defmodule LivebookCLI.Server do
defp opts_to_config([_opt | opts], config), do: opts_to_config(opts, config)
- defp browser_open(url) do
- {cmd, args} =
- case :os.type() do
- {:win32, _} -> {"cmd", ["/c", "start", url]}
- {:unix, :darwin} -> {"open", [url]}
- {:unix, _} -> {"xdg-open", [url]}
- end
-
- System.cmd(cmd, args)
- end
-
defp append_path(url, path) do
url
|> URI.parse()
diff --git a/mix.exs b/mix.exs
index b5202459f..bd652e284 100644
--- a/mix.exs
+++ b/mix.exs
@@ -24,11 +24,16 @@ defmodule Livebook.MixProject do
def application do
[
mod: {Livebook.Application, []},
- extra_applications: [:logger, :runtime_tools, :os_mon, :inets, :ssl, :xmerl],
+ extra_applications:
+ [:logger, :runtime_tools, :os_mon, :inets, :ssl, :xmerl] ++
+ extra_applications(Mix.target()),
env: Application.get_all_env(:livebook)
]
end
+ defp extra_applications(:app), do: [:wx]
+ defp extra_applications(_), do: []
+
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
@@ -62,7 +67,8 @@ defmodule Livebook.MixProject do
{:aws_signature, "~> 0.2.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:floki, ">= 0.27.0", only: :test},
- {:bypass, "~> 2.1", only: :test}
+ {:bypass, "~> 2.1", only: :test},
+ {:app_builder, path: "app_builder", targets: [:app]}
]
end
@@ -103,10 +109,49 @@ defmodule Livebook.MixProject do
livebook: [
include_executables_for: [:unix],
include_erts: false
+ ],
+ mac_app: [
+ include_executables_for: [:unix],
+ rel_templates_path: "rel/app",
+ steps: [:assemble, &build_mac_app/1]
+ ],
+ mac_app_dmg: [
+ include_executables_for: [:unix],
+ rel_templates_path: "rel/app",
+ steps: [:assemble, &build_mac_app_dmg/1]
]
]
end
+ @app_options [
+ name: "Livebook",
+ logo_path: "static/images/logo.png",
+ url_schemes: ["livebook"],
+ document_types: [
+ %{name: "LiveMarkdown", role: "Editor", extensions: ["livemd"]}
+ ]
+ ]
+
+ defp build_mac_app(release) do
+ AppBuilder.build_mac_app(release, @app_options)
+ end
+
+ defp build_mac_app_dmg(release) 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")
+ ]
+ ] ++ @app_options
+
+ AppBuilder.build_mac_app_dmg(release, options)
+ end
+
def package do
[
licenses: ["Apache-2.0"],
diff --git a/rel/app/env.sh.bat b/rel/app/env.sh.bat
new file mode 100644
index 000000000..e586f54c8
--- /dev/null
+++ b/rel/app/env.sh.bat
@@ -0,0 +1 @@
+set RELEASE_MODE=interactive
diff --git a/rel/app/env.sh.eex b/rel/app/env.sh.eex
new file mode 100644
index 000000000..f5defa495
--- /dev/null
+++ b/rel/app/env.sh.eex
@@ -0,0 +1 @@
+export RELEASE_MODE=interactive