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